Compare commits
118
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5df013bc1e | ||
|
|
c0201cc7e0 | ||
|
|
aff4c90d8f | ||
|
|
b6a4f17cbe | ||
|
|
ffc96bec97 | ||
|
|
8cb8b9128e | ||
|
|
64d96a9c53 | ||
|
|
9dedac1dbd | ||
|
|
413ddbb45d | ||
|
|
246912f596 | ||
|
|
2d31b67d0c | ||
|
|
349c112c61 | ||
|
|
939cb40a73 | ||
|
|
c5700ff5b4 | ||
|
|
6bb83f4600 | ||
|
|
a94a90ea3f | ||
|
|
9e5a066d26 | ||
|
|
5095d5e36f | ||
|
|
22ca6a8d73 | ||
|
|
0ff36548e0 | ||
|
|
d344ef26da | ||
|
|
4fed005662 | ||
|
|
b42b35bf93 | ||
|
|
db70fdbe5e | ||
|
|
c5273f5fd4 | ||
|
|
4285e94406 | ||
|
|
35dfd3aa0c | ||
|
|
0bcbf8491b | ||
|
|
f0f0d554f2 | ||
|
|
0ad6ee3156 | ||
|
|
39bf6820d6 | ||
|
|
8ef9c48a62 | ||
|
|
7fe773c0ee | ||
|
|
58e68f3e4a | ||
|
|
4f533dde94 | ||
|
|
08d2c78ae7 | ||
|
|
8e2e2d5bf2 | ||
|
|
4a55068db7 | ||
|
|
e1f539c612 | ||
|
|
742f212b2f | ||
|
|
f2b0ff7d38 | ||
|
|
8ae3ae13a6 | ||
|
|
546d595c9f | ||
|
|
26944e25bc | ||
|
|
e6333719ee | ||
|
|
d886d3acb9 | ||
|
|
35b2cbb6ed | ||
|
|
f4390bdc2e | ||
|
|
e0f0da5d2d | ||
|
|
a9e6d9f731 | ||
|
|
8ce06e3e7c | ||
|
|
365fa3ce50 | ||
|
|
2eb1019da9 | ||
|
|
2f2db2a6e2 | ||
|
|
caa53ee370 | ||
|
|
5cdc714a38 | ||
|
|
78aa7c537e | ||
|
|
3f31cbf505 | ||
|
|
b7abf8565a | ||
|
|
682cbf38ee | ||
|
|
5e8952c54a | ||
|
|
8364b9c5d5 | ||
|
|
5a486883e8 | ||
|
|
d2c32cb8e4 | ||
|
|
ce691549ba | ||
|
|
72b61214f6 | ||
|
|
103994c25f | ||
|
|
36b5627d2c | ||
|
|
d284de28c7 | ||
|
|
93609f74ab | ||
|
|
9a5f83adb4 | ||
|
|
b4320254b2 | ||
|
|
97f7a365e8 | ||
|
|
20e17f28ad | ||
|
|
80b1badf74 | ||
|
|
ea662d062e | ||
|
|
94cf89b570 | ||
|
|
439424559e | ||
|
|
4c4b3568db | ||
|
|
a706905653 | ||
|
|
fe12be47a0 | ||
|
|
a56cd044e5 | ||
|
|
438ce98b40 | ||
|
|
446c75f3e2 | ||
|
|
276a4ba7e8 | ||
|
|
31f1c53c8f | ||
|
|
c10c9c89e9 | ||
|
|
1f1462a5f6 | ||
|
|
0727f2d069 | ||
|
|
72c25c97e3 | ||
|
|
8c378b981a | ||
|
|
e2b19d3b38 | ||
|
|
210a40665d | ||
|
|
28dac7c7f8 | ||
|
|
f88f0a3b26 | ||
|
|
e4f8a157c2 | ||
|
|
ef90842f87 | ||
|
|
f68e2e2851 | ||
|
|
61b01cc040 | ||
|
|
bbcfe2f5ab | ||
|
|
d2bfa84bca | ||
|
|
8a64dc8db6 | ||
|
|
e7da7d0e4f | ||
|
|
f09ad9deac | ||
|
|
f27bd95382 | ||
|
|
7eabe5e168 | ||
|
|
33565a8236 | ||
|
|
51f05365a1 | ||
|
|
1e6cb15e99 | ||
|
|
bd6348aa08 | ||
|
|
836fd81e19 | ||
|
|
32b00cea4f | ||
|
|
21f9f46ca3 | ||
|
|
c7db770281 | ||
|
|
5fdb0e863f | ||
|
|
4a69a422a0 | ||
|
|
26472df166 | ||
|
|
5de793eec7 |
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://anthropic.com/claude-code/marketplace.schema.json",
|
||||
"name": "hindsight",
|
||||
"description": "Official Hindsight integrations for Claude Code",
|
||||
"owner": {
|
||||
"name": "vectorize-io"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "hindsight-memory",
|
||||
"description": "Automatic long-term memory for Claude Code via Hindsight",
|
||||
"source": "./hindsight-integrations/claude-code"
|
||||
}
|
||||
]
|
||||
}
|
||||
+2
-2
@@ -20,10 +20,10 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
|
||||
# HINDSIGHT_API_LLM_VERTEXAI_REGION=us-central1
|
||||
# HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/path/to/service-account-key.json # Optional, uses ADC if not set
|
||||
|
||||
# Example: MiniMax configuration (204K context window)
|
||||
# Example: MiniMax configuration (1M context window)
|
||||
# HINDSIGHT_API_LLM_PROVIDER=minimax
|
||||
# HINDSIGHT_API_LLM_API_KEY=your-minimax-api-key
|
||||
# HINDSIGHT_API_LLM_MODEL=MiniMax-M2.5
|
||||
# HINDSIGHT_API_LLM_MODEL=MiniMax-M2.7
|
||||
|
||||
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
|
||||
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
name: Release Integration
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'integrations/**'
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write # for PyPI trusted publishing
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Extract integration info
|
||||
id: info
|
||||
run: |
|
||||
# refs/tags/integrations/litellm/v0.1.0 → integration=litellm, version=0.1.0
|
||||
TAG="${GITHUB_REF#refs/tags/}"
|
||||
INTEGRATION=$(echo "$TAG" | cut -d'/' -f2)
|
||||
VERSION=$(echo "$TAG" | cut -d'/' -f3 | sed 's/^v//')
|
||||
echo "integration=$INTEGRATION" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "tag=$TAG" >> $GITHUB_OUTPUT
|
||||
echo "Integration: $INTEGRATION, Version: $VERSION"
|
||||
|
||||
- name: Detect integration type
|
||||
id: type
|
||||
run: |
|
||||
if [ -f "hindsight-integrations/${{ steps.info.outputs.integration }}/pyproject.toml" ]; then
|
||||
echo "type=python" >> $GITHUB_OUTPUT
|
||||
elif [ -f "hindsight-integrations/${{ steps.info.outputs.integration }}/package.json" ]; then
|
||||
echo "type=typescript" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "type=plugin" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# ── Python integrations (litellm, pydantic-ai, crewai) ──────────────────
|
||||
|
||||
- name: Install uv
|
||||
if: steps.type.outputs.type == 'python'
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Python
|
||||
if: steps.type.outputs.type == 'python'
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Build Python package
|
||||
if: steps.type.outputs.type == 'python'
|
||||
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Publish Python package to PyPI
|
||||
if: steps.type.outputs.type == 'python'
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-integrations/${{ steps.info.outputs.integration }}/dist
|
||||
skip-existing: true
|
||||
|
||||
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
|
||||
|
||||
# ── Plugin integrations (claude-code) — no package to publish ───────────
|
||||
|
||||
- name: Plugin release
|
||||
if: steps.type.outputs.type == 'plugin'
|
||||
run: |
|
||||
echo "Plugin integration ${{ steps.info.outputs.integration }} v${{ steps.info.outputs.version }} — no package to publish."
|
||||
echo "Users install via: claude plugin marketplace add vectorize-io/hindsight --sparse hindsight-integrations"
|
||||
|
||||
# ── TypeScript integrations (ai-sdk, chat, openclaw) ────────────────────
|
||||
|
||||
- name: Set up Node.js
|
||||
if: steps.type.outputs.type == 'typescript'
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.type.outputs.type == 'typescript'
|
||||
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
|
||||
run: npm ci
|
||||
|
||||
- name: Build TypeScript package
|
||||
if: steps.type.outputs.type == 'typescript'
|
||||
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
|
||||
run: npm run build
|
||||
|
||||
- name: Publish TypeScript package to npm
|
||||
if: steps.type.outputs.type == 'typescript'
|
||||
working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }}
|
||||
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 }}
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -46,22 +46,10 @@ jobs:
|
||||
working-directory: ./hindsight-all-slim
|
||||
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
|
||||
|
||||
- name: Build hindsight-crewai
|
||||
working-directory: ./hindsight-integrations/crewai
|
||||
run: uv build --out-dir dist
|
||||
|
||||
- name: Build hindsight-pydantic-ai
|
||||
working-directory: ./hindsight-integrations/pydantic-ai
|
||||
run: uv build --out-dir dist
|
||||
|
||||
# Publish in order (client and api-slim first, then api/all wrappers which depend on them)
|
||||
- name: Publish hindsight-client to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
@@ -93,30 +81,12 @@ jobs:
|
||||
packages-dir: ./hindsight-all-slim/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
|
||||
|
||||
- name: Publish hindsight-crewai to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-integrations/crewai/dist
|
||||
skip-existing: true
|
||||
|
||||
- name: Publish hindsight-pydantic-ai to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: ./hindsight-integrations/pydantic-ai/dist
|
||||
skip-existing: true
|
||||
|
||||
# Upload artifacts for GitHub release
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -128,10 +98,7 @@ jobs:
|
||||
hindsight-api/dist/*
|
||||
hindsight-all/dist/*
|
||||
hindsight-all-slim/dist/*
|
||||
hindsight-integrations/litellm/dist/*
|
||||
hindsight-embed/dist/*
|
||||
hindsight-integrations/crewai/dist/*
|
||||
hindsight-integrations/pydantic-ai/dist/*
|
||||
retention-days: 1
|
||||
|
||||
release-typescript-client:
|
||||
@@ -183,153 +150,6 @@ jobs:
|
||||
path: hindsight-clients/typescript/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-openclaw-integration:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: |
|
||||
set +e
|
||||
OUTPUT=$(npm publish --access public 2>&1)
|
||||
EXIT_CODE=$?
|
||||
echo "$OUTPUT"
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
if echo "$OUTPUT" | grep -q "cannot publish over"; then
|
||||
echo "Package version already published, skipping..."
|
||||
exit 0
|
||||
fi
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Pack for GitHub release
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm pack
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openclaw-integration
|
||||
path: hindsight-integrations/openclaw/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-ai-sdk-integration:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/ai-sdk
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/ai-sdk
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: ./hindsight-integrations/ai-sdk
|
||||
run: |
|
||||
set +e
|
||||
OUTPUT=$(npm publish --access public 2>&1)
|
||||
EXIT_CODE=$?
|
||||
echo "$OUTPUT"
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
if echo "$OUTPUT" | grep -q "cannot publish over"; then
|
||||
echo "Package version already published, skipping..."
|
||||
exit 0
|
||||
fi
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Pack for GitHub release
|
||||
working-directory: ./hindsight-integrations/ai-sdk
|
||||
run: npm pack
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ai-sdk-integration
|
||||
path: hindsight-integrations/ai-sdk/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-chat-integration:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/chat
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/chat
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: ./hindsight-integrations/chat
|
||||
run: |
|
||||
set +e
|
||||
OUTPUT=$(npm publish --access public 2>&1)
|
||||
EXIT_CODE=$?
|
||||
echo "$OUTPUT"
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
if echo "$OUTPUT" | grep -q "cannot publish over"; then
|
||||
echo "Package version already published, skipping..."
|
||||
exit 0
|
||||
fi
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Pack for GitHub release
|
||||
working-directory: ./hindsight-integrations/chat
|
||||
run: npm pack
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: chat-integration
|
||||
path: hindsight-integrations/chat/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-control-plane:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm
|
||||
@@ -587,7 +407,7 @@ jobs:
|
||||
|
||||
create-github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-python-packages, release-typescript-client, release-openclaw-integration, release-ai-sdk-integration, release-chat-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
|
||||
needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -599,61 +419,43 @@ jobs:
|
||||
run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Download Python packages
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: python-packages
|
||||
path: ./artifacts/python-packages
|
||||
|
||||
- name: Download TypeScript client
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: typescript-client
|
||||
path: ./artifacts/typescript-client
|
||||
|
||||
- name: Download OpenClaw Integration
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: openclaw-integration
|
||||
path: ./artifacts/openclaw-integration
|
||||
|
||||
- name: Download AI SDK Integration
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: ai-sdk-integration
|
||||
path: ./artifacts/ai-sdk-integration
|
||||
|
||||
- name: Download Chat Integration
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: chat-integration
|
||||
path: ./artifacts/chat-integration
|
||||
|
||||
- name: Download Control Plane
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: control-plane
|
||||
path: ./artifacts/control-plane
|
||||
|
||||
- name: Download Rust CLI (Linux)
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: rust-cli-hindsight-linux-amd64
|
||||
path: ./artifacts/rust-cli-linux
|
||||
|
||||
- name: Download Rust CLI (macOS Intel)
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: rust-cli-hindsight-darwin-amd64
|
||||
path: ./artifacts/rust-cli-darwin-amd64
|
||||
|
||||
- name: Download Rust CLI (macOS ARM)
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: rust-cli-hindsight-darwin-arm64
|
||||
path: ./artifacts/rust-cli-darwin-arm64
|
||||
|
||||
- name: Download Helm chart
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: helm-chart
|
||||
path: ./artifacts/helm-chart
|
||||
@@ -667,17 +469,9 @@ jobs:
|
||||
cp artifacts/python-packages/hindsight-api/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-all/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-all-slim/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-integrations/litellm/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-integrations/pydantic-ai/dist/* release-assets/ || true
|
||||
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
|
||||
# TypeScript client
|
||||
cp artifacts/typescript-client/*.tgz release-assets/ || true
|
||||
# OpenClaw Integration
|
||||
cp artifacts/openclaw-integration/*.tgz release-assets/ || true
|
||||
# AI SDK Integration
|
||||
cp artifacts/ai-sdk-integration/*.tgz release-assets/ || true
|
||||
# Chat Integration
|
||||
cp artifacts/chat-integration/*.tgz release-assets/ || true
|
||||
# Control Plane
|
||||
cp artifacts/control-plane/*.tgz release-assets/ || true
|
||||
# Rust CLI binaries
|
||||
|
||||
+479
-39
@@ -3,13 +3,110 @@ name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
detect-changes:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: read
|
||||
outputs:
|
||||
core: ${{ steps.filter.outputs.core }}
|
||||
clients-ts: ${{ steps.filter.outputs.clients-ts }}
|
||||
clients-python: ${{ steps.filter.outputs.clients-python }}
|
||||
clients-rust: ${{ steps.filter.outputs.clients-rust }}
|
||||
clients-go: ${{ steps.filter.outputs.clients-go }}
|
||||
control-plane: ${{ steps.filter.outputs.control-plane }}
|
||||
cli: ${{ steps.filter.outputs.cli }}
|
||||
docker: ${{ steps.filter.outputs.docker }}
|
||||
helm: ${{ steps.filter.outputs.helm }}
|
||||
docs: ${{ steps.filter.outputs.docs }}
|
||||
embed: ${{ steps.filter.outputs.embed }}
|
||||
hindsight-all: ${{ steps.filter.outputs.hindsight-all }}
|
||||
integration-tests: ${{ steps.filter.outputs.integration-tests }}
|
||||
integrations-openclaw: ${{ steps.filter.outputs.integrations-openclaw }}
|
||||
integrations-ai-sdk: ${{ steps.filter.outputs.integrations-ai-sdk }}
|
||||
integrations-chat: ${{ steps.filter.outputs.integrations-chat }}
|
||||
integrations-claude-code: ${{ steps.filter.outputs.integrations-claude-code }}
|
||||
integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }}
|
||||
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
|
||||
integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }}
|
||||
integrations-hermes: ${{ steps.filter.outputs.integrations-hermes }}
|
||||
dev: ${{ steps.filter.outputs.dev }}
|
||||
ci: ${{ steps.filter.outputs.ci }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
core:
|
||||
- 'hindsight-api-slim/**'
|
||||
- 'hindsight-api/**'
|
||||
- 'scripts/**'
|
||||
- '.python-version'
|
||||
- '.env.example'
|
||||
clients-ts:
|
||||
- 'hindsight-clients/typescript/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
clients-python:
|
||||
- 'hindsight-clients/python/**'
|
||||
clients-rust:
|
||||
- 'hindsight-clients/rust/**'
|
||||
clients-go:
|
||||
- 'hindsight-clients/go/**'
|
||||
control-plane:
|
||||
- 'hindsight-control-plane/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
cli:
|
||||
- 'hindsight-cli/**'
|
||||
docker:
|
||||
- 'docker/**'
|
||||
helm:
|
||||
- 'helm/**'
|
||||
docs:
|
||||
- 'hindsight-docs/**'
|
||||
- '*.md'
|
||||
embed:
|
||||
- 'hindsight-embed/**'
|
||||
hindsight-all:
|
||||
- 'hindsight-all/**'
|
||||
integration-tests:
|
||||
- 'hindsight-integration-tests/**'
|
||||
integrations-openclaw:
|
||||
- 'hindsight-integrations/openclaw/**'
|
||||
integrations-ai-sdk:
|
||||
- 'hindsight-integrations/ai-sdk/**'
|
||||
integrations-chat:
|
||||
- 'hindsight-integrations/chat/**'
|
||||
integrations-claude-code:
|
||||
- 'hindsight-integrations/claude-code/**'
|
||||
integrations-crewai:
|
||||
- 'hindsight-integrations/crewai/**'
|
||||
integrations-litellm:
|
||||
- 'hindsight-integrations/litellm/**'
|
||||
integrations-pydantic-ai:
|
||||
- 'hindsight-integrations/pydantic-ai/**'
|
||||
integrations-hermes:
|
||||
- 'hindsight-integrations/hermes/**'
|
||||
dev:
|
||||
- 'hindsight-dev/**'
|
||||
ci:
|
||||
- '.github/**'
|
||||
|
||||
build-api-python-versions:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -24,7 +121,7 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
@@ -33,6 +130,11 @@ jobs:
|
||||
run: uv build
|
||||
|
||||
build-typescript-client:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.clients-ts == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -52,6 +154,11 @@ jobs:
|
||||
run: npm run build --workspace=hindsight-clients/typescript
|
||||
|
||||
build-openclaw-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -74,7 +181,35 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm run build
|
||||
|
||||
test-claude-code-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-claude-code == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install pytest
|
||||
run: pip install pytest
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/claude-code
|
||||
run: python -m pytest tests/ -v
|
||||
|
||||
build-ai-sdk-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-ai-sdk == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -97,7 +232,41 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/ai-sdk
|
||||
run: npm run build
|
||||
|
||||
test-ai-sdk-integration-deno:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-ai-sdk == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/ai-sdk
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests (Deno)
|
||||
working-directory: ./hindsight-integrations/ai-sdk
|
||||
run: npm run test:deno
|
||||
|
||||
build-chat-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-chat == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -121,6 +290,12 @@ jobs:
|
||||
run: npm run build
|
||||
|
||||
build-control-plane:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.control-plane == 'true' ||
|
||||
needs.detect-changes.outputs.clients-ts == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -173,6 +348,11 @@ jobs:
|
||||
fi
|
||||
|
||||
build-docs:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.docs == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -192,6 +372,12 @@ jobs:
|
||||
run: npm run build --workspace=hindsight-docs
|
||||
|
||||
test-rust-cli:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.cli == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -199,7 +385,6 @@ jobs:
|
||||
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
|
||||
HINDSIGHT_API_URL: http://localhost:8888
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -244,7 +429,7 @@ jobs:
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -313,6 +498,11 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
lint-helm-chart:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.helm == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -327,6 +517,13 @@ jobs:
|
||||
run: helm lint helm/hindsight
|
||||
|
||||
build-docker-images:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.docker == 'true' ||
|
||||
needs.detect-changes.outputs.control-plane == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
name: Build Docker (${{ matrix.name }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
@@ -410,6 +607,11 @@ jobs:
|
||||
run: ./docker/test-image.sh "hindsight-${{ matrix.name }}:test" "${{ matrix.target }}"
|
||||
|
||||
test-api:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -419,9 +621,11 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
|
||||
HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
AWS_REGION_NAME: ${{ secrets.AWS_REGION_NAME }}
|
||||
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@v6
|
||||
@@ -439,7 +643,7 @@ jobs:
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -476,6 +680,12 @@ jobs:
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-python-client:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.clients-python == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -484,7 +694,6 @@ jobs:
|
||||
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@v6
|
||||
@@ -502,7 +711,7 @@ jobs:
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -579,6 +788,12 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-typescript-client:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.clients-ts == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -587,7 +802,6 @@ jobs:
|
||||
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@v6
|
||||
@@ -605,7 +819,7 @@ jobs:
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -686,7 +900,131 @@ jobs:
|
||||
echo "=== API Server Logs ==="
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-typescript-client-deno:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.clients-ts == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
|
||||
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
|
||||
HINDSIGHT_API_URL: http://localhost:8888
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup GCP credentials
|
||||
run: |
|
||||
printf '%s' '${{ secrets.GCP_VERTEXAI_CREDENTIALS }}' > /tmp/gcp-credentials.json
|
||||
PROJECT_ID=$(jq -r '.project_id' /tmp/gcp-credentials.json)
|
||||
echo "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$PROJECT_ID" >> $GITHUB_ENV
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Set up Deno
|
||||
uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: Build API
|
||||
working-directory: ./hindsight-api-slim
|
||||
run: uv build
|
||||
|
||||
- name: Install API dependencies
|
||||
working-directory: ./hindsight-api-slim
|
||||
run: uv sync --frozen --all-extras --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: Cache HuggingFace models
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/huggingface
|
||||
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api-slim/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-huggingface-
|
||||
|
||||
- name: Pre-download models
|
||||
working-directory: ./hindsight-api-slim
|
||||
run: |
|
||||
uv run python -c "
|
||||
from sentence_transformers import SentenceTransformer, CrossEncoder
|
||||
print('Downloading embedding model...')
|
||||
SentenceTransformer('BAAI/bge-small-en-v1.5')
|
||||
print('Downloading cross-encoder model...')
|
||||
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
|
||||
print('Models downloaded successfully')
|
||||
"
|
||||
|
||||
- name: Create .env file
|
||||
run: |
|
||||
cat > .env << EOF
|
||||
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
|
||||
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
|
||||
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/tmp/gcp-credentials.json
|
||||
HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID
|
||||
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..120}; 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 120 ]; then
|
||||
echo "API server failed to start after 120s"
|
||||
cat /tmp/api-server.log
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
- name: Run TypeScript client tests (Deno)
|
||||
working-directory: ./hindsight-clients/typescript
|
||||
run: npm run test:deno
|
||||
|
||||
- name: Show API server logs
|
||||
if: always()
|
||||
run: |
|
||||
echo "=== API Server Logs ==="
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
build-rust-cli-arm64:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.cli == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
@@ -711,6 +1049,12 @@ jobs:
|
||||
run: cargo build --release --target aarch64-unknown-linux-gnu
|
||||
|
||||
test-rust-client:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.clients-rust == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -719,7 +1063,6 @@ jobs:
|
||||
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@v6
|
||||
@@ -737,7 +1080,7 @@ jobs:
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -818,6 +1161,12 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-go-client:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.clients-go == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -826,7 +1175,6 @@ jobs:
|
||||
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@v6
|
||||
@@ -844,7 +1192,7 @@ jobs:
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -923,6 +1271,13 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-openclaw-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
|
||||
needs.detect-changes.outputs.embed == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -931,7 +1286,6 @@ jobs:
|
||||
HINDSIGHT_API_URL: http://localhost:8888
|
||||
HINDSIGHT_EMBED_PACKAGE_PATH: ${{ github.workspace }}/hindsight-embed
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -949,7 +1303,7 @@ jobs:
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -1031,6 +1385,12 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.integration-tests == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -1038,7 +1398,6 @@ jobs:
|
||||
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
|
||||
HINDSIGHT_API_URL: http://localhost:8888
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -1056,7 +1415,7 @@ jobs:
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -1129,6 +1488,11 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-crewai-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-crewai == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -1141,7 +1505,7 @@ jobs:
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -1158,6 +1522,11 @@ jobs:
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-litellm-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-litellm == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -1170,7 +1539,7 @@ jobs:
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -1187,6 +1556,11 @@ jobs:
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-pydantic-ai-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-pydantic-ai == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
@@ -1199,7 +1573,7 @@ jobs:
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -1215,7 +1589,46 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/pydantic-ai
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-hermes-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-hermes == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Build hermes integration
|
||||
working-directory: ./hindsight-integrations/hermes
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/hermes
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/hermes
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-pip-slim:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -1226,7 +1639,7 @@ jobs:
|
||||
HINDSIGHT_API_COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup GCP credentials
|
||||
run: |
|
||||
@@ -1235,13 +1648,13 @@ jobs:
|
||||
echo "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=$PROJECT_ID" >> $GITHUB_ENV
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -1276,13 +1689,18 @@ jobs:
|
||||
cat /tmp/slim-api-server.log 2>/dev/null || true
|
||||
|
||||
test-embed:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.embed == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
|
||||
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
|
||||
# Prefer CPU-only PyTorch in CI
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -1300,7 +1718,7 @@ jobs:
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -1330,6 +1748,12 @@ jobs:
|
||||
run: ./test.sh
|
||||
|
||||
test-hindsight-all:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.hindsight-all == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
@@ -1340,7 +1764,6 @@ jobs:
|
||||
HINDSIGHT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
|
||||
HINDSIGHT_LLM_MODEL: google/gemini-2.5-flash-lite
|
||||
# Prefer CPU-only PyTorch in CI
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -1358,7 +1781,7 @@ jobs:
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -1384,6 +1807,16 @@ jobs:
|
||||
run: uv run pytest tests/ -v
|
||||
|
||||
test-doc-examples:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.clients-ts == 'true' ||
|
||||
needs.detect-changes.outputs.clients-python == 'true' ||
|
||||
needs.detect-changes.outputs.clients-go == 'true' ||
|
||||
needs.detect-changes.outputs.cli == 'true' ||
|
||||
needs.detect-changes.outputs.docs == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -1396,7 +1829,6 @@ jobs:
|
||||
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
|
||||
HINDSIGHT_API_URL: http://localhost:8888
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -1435,7 +1867,7 @@ jobs:
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -1524,13 +1956,18 @@ jobs:
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-upgrade:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.dev == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: vertexai
|
||||
HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY: /tmp/gcp-credentials.json
|
||||
HINDSIGHT_API_LLM_MODEL: google/gemini-2.5-flash-lite
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
@@ -1553,7 +1990,7 @@ jobs:
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -1603,9 +2040,6 @@ jobs:
|
||||
|
||||
verify-generated-files:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -1615,7 +2049,7 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
@@ -1652,6 +2086,9 @@ jobs:
|
||||
- name: Run generate-clients
|
||||
run: ./scripts/generate-clients.sh
|
||||
|
||||
- name: Run generate-docs-skill
|
||||
run: ./scripts/generate-docs-skill.sh
|
||||
|
||||
- name: Run lint
|
||||
run: ./scripts/hooks/lint.sh
|
||||
|
||||
@@ -1666,6 +2103,7 @@ jobs:
|
||||
echo "Please run the following commands locally and commit the changes:"
|
||||
echo " ./scripts/generate-openapi.sh"
|
||||
echo " ./scripts/generate-clients.sh"
|
||||
echo " ./scripts/generate-docs-skill.sh"
|
||||
echo " ./scripts/hooks/lint.sh"
|
||||
echo ""
|
||||
git diff --stat
|
||||
@@ -1674,10 +2112,12 @@ jobs:
|
||||
echo "✓ All generated files are up to date"
|
||||
|
||||
check-openapi-compatibility:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
@@ -1689,7 +2129,7 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
[](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml)
|
||||
[](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://gitcgr.com/vectorize-io/hindsight)
|
||||

|
||||

|
||||
<br/>
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"jsr:@std/assert@^1.0.17": "1.0.19",
|
||||
"jsr:@std/assert@^1.0.19": "1.0.19",
|
||||
"jsr:@std/expect@*": "1.0.18",
|
||||
"jsr:@std/internal@^1.0.12": "1.0.12",
|
||||
"jsr:@std/path@^1.1.4": "1.1.4",
|
||||
"jsr:@std/testing@*": "1.0.17"
|
||||
},
|
||||
"jsr": {
|
||||
"@std/[email protected]": {
|
||||
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@std/[email protected]": {
|
||||
"integrity": "8566eab35200466f8609eb7e7aed062ed0db314e9a258d5d201b1b8997ce801a",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@^1.0.19",
|
||||
"jsr:@std/internal",
|
||||
"jsr:@std/path"
|
||||
]
|
||||
},
|
||||
"@std/[email protected]": {
|
||||
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
|
||||
},
|
||||
"@std/[email protected]": {
|
||||
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@std/[email protected]": {
|
||||
"integrity": "87bdc2700fa98249d48a17cd72413352d3d3680dcfbdb64947fd0982d6bbf681",
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@^1.0.17",
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"members": {
|
||||
"hindsight-clients/typescript": {
|
||||
"packageJson": {
|
||||
"dependencies": [
|
||||
"npm:@hey-api/[email protected]",
|
||||
"npm:@types/jest@29",
|
||||
"npm:@types/node@20",
|
||||
"npm:jest@29",
|
||||
"npm:ts-jest@29",
|
||||
"npm:tsup@^8.5.1",
|
||||
"npm:typescript@5"
|
||||
]
|
||||
}
|
||||
},
|
||||
"hindsight-control-plane": {
|
||||
"packageJson": {
|
||||
"dependencies": [
|
||||
"npm:@eslint/eslintrc@^3.3.3",
|
||||
"npm:@eslint/js@^9.39.2",
|
||||
"npm:@radix-ui/react-alert-dialog@^1.1.15",
|
||||
"npm:@radix-ui/react-checkbox@^1.3.3",
|
||||
"npm:@radix-ui/react-dialog@^1.1.15",
|
||||
"npm:@radix-ui/react-dropdown-menu@^2.1.16",
|
||||
"npm:@radix-ui/react-label@^2.1.8",
|
||||
"npm:@radix-ui/react-popover@^1.1.15",
|
||||
"npm:@radix-ui/react-radio-group@^1.3.8",
|
||||
"npm:@radix-ui/react-select@^2.2.6",
|
||||
"npm:@radix-ui/react-slider@^1.3.6",
|
||||
"npm:@radix-ui/react-slot@^1.2.4",
|
||||
"npm:@radix-ui/react-switch@^1.2.6",
|
||||
"npm:@radix-ui/react-tabs@^1.1.13",
|
||||
"npm:@radix-ui/react-tooltip@^1.2.8",
|
||||
"npm:@tailwindcss/postcss@^4.1.17",
|
||||
"npm:@tailwindcss/typography@~0.5.19",
|
||||
"npm:@types/cytoscape@^3.21.9",
|
||||
"npm:@types/node@^24.10.0",
|
||||
"npm:@types/react-dom@^19.2.2",
|
||||
"npm:@types/react@^19.2.2",
|
||||
"npm:autoprefixer@^10.4.21",
|
||||
"npm:class-variance-authority@~0.7.1",
|
||||
"npm:clsx@^2.1.1",
|
||||
"npm:cmdk@^1.1.1",
|
||||
"npm:cytoscape-fcose@^2.2.0",
|
||||
"npm:cytoscape@^3.33.1",
|
||||
"npm:eslint-config-next@^16.0.1",
|
||||
"npm:eslint-plugin-react-hooks@^7.0.1",
|
||||
"npm:eslint-plugin-react@^7.37.5",
|
||||
"npm:eslint@^9.39.1",
|
||||
"npm:[email protected]",
|
||||
"npm:next-themes@~0.4.6",
|
||||
"npm:next@^16.1.6",
|
||||
"npm:postcss@^8.5.6",
|
||||
"npm:prettier@^3.7.4",
|
||||
"npm:react-chrono@^2.9.1",
|
||||
"npm:react-dom@^19.2.0",
|
||||
"npm:react-markdown@^10.1.0",
|
||||
"npm:react18-json-view@~0.2.9",
|
||||
"npm:react@^19.2.0",
|
||||
"npm:recharts@^3.5.1",
|
||||
"npm:remark-gfm@^4.0.1",
|
||||
"npm:sonner@^2.0.7",
|
||||
"npm:tailwind-merge@^3.4.0",
|
||||
"npm:tailwindcss-animate@^1.0.7",
|
||||
"npm:tailwindcss@^4.1.17",
|
||||
"npm:[email protected]",
|
||||
"npm:typescript-eslint@^8.50.0",
|
||||
"npm:typescript@^5.9.3"
|
||||
]
|
||||
}
|
||||
},
|
||||
"hindsight-docs": {
|
||||
"packageJson": {
|
||||
"dependencies": [
|
||||
"npm:@docusaurus/[email protected]",
|
||||
"npm:@docusaurus/[email protected]",
|
||||
"npm:@docusaurus/[email protected]",
|
||||
"npm:@docusaurus/theme-common@^3.9.2",
|
||||
"npm:@docusaurus/theme-mermaid@^3.9.2",
|
||||
"npm:@docusaurus/[email protected]",
|
||||
"npm:@docusaurus/[email protected]",
|
||||
"npm:@easyops-cn/docusaurus-search-local@~0.52.2",
|
||||
"npm:@mdx-js/react@3",
|
||||
"npm:clsx@2",
|
||||
"npm:prism-react-renderer@^2.3.0",
|
||||
"npm:raw-loader@^4.0.2",
|
||||
"npm:react-dom@19",
|
||||
"npm:react-icons@^5.6.0",
|
||||
"npm:react@19",
|
||||
"npm:redocusaurus@^2.5.0",
|
||||
"npm:typescript@~5.6.2"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,7 @@ fi
|
||||
if [ "$ENABLE_CP" = "true" ]; then
|
||||
echo "🎛️ Starting Control Plane..."
|
||||
cd /app/control-plane
|
||||
export HOSTNAME="${HINDSIGHT_CP_HOSTNAME:-0.0.0.0}"
|
||||
PORT="${HINDSIGHT_CP_PORT:-9999}" node server.js &
|
||||
CP_PID=$!
|
||||
PIDS+=($CP_PID)
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: hindsight
|
||||
description: Hindsight helm chart
|
||||
type: application
|
||||
version: 0.4.17
|
||||
appVersion: "0.4.17"
|
||||
version: 0.4.20
|
||||
appVersion: "0.4.20"
|
||||
keywords:
|
||||
- ai
|
||||
- memory
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hindsight-all-slim"
|
||||
version = "0.4.17"
|
||||
version = "0.4.20"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -13,7 +13,10 @@ from hindsight_client import Hindsight
|
||||
|
||||
|
||||
class BanksAPI:
|
||||
"""Namespace for bank-related operations."""
|
||||
"""Namespace for bank-related operations.
|
||||
|
||||
Provides methods to create, delete, and manage memory banks.
|
||||
"""
|
||||
|
||||
def __init__(self, client: Hindsight):
|
||||
self._client = client
|
||||
@@ -24,8 +27,18 @@ class BanksAPI:
|
||||
name: str | None = None,
|
||||
mission: str | None = None,
|
||||
disposition: dict[str, Any] | None = None,
|
||||
):
|
||||
"""Create a new bank."""
|
||||
) -> Any:
|
||||
"""Create a new bank.
|
||||
|
||||
Args:
|
||||
bank_id: Unique identifier for the bank.
|
||||
name: Optional display name for the bank.
|
||||
mission: Optional mission statement for the bank.
|
||||
disposition: Optional disposition configuration dict.
|
||||
|
||||
Returns:
|
||||
Bank creation response from the API.
|
||||
"""
|
||||
return self._client.create_bank(
|
||||
bank_id=bank_id,
|
||||
name=name,
|
||||
@@ -33,27 +46,57 @@ class BanksAPI:
|
||||
disposition=disposition,
|
||||
)
|
||||
|
||||
def delete(self, bank_id: str):
|
||||
"""Delete a bank."""
|
||||
def delete(self, bank_id: str) -> Any:
|
||||
"""Delete a bank.
|
||||
|
||||
Args:
|
||||
bank_id: The ID of the bank to delete.
|
||||
|
||||
Returns:
|
||||
Deletion response from the API.
|
||||
"""
|
||||
return self._client.delete_bank(bank_id=bank_id)
|
||||
|
||||
def set_mission(self, bank_id: str, mission: str):
|
||||
"""Set or update the mission for a bank."""
|
||||
def set_mission(self, bank_id: str, mission: str) -> Any:
|
||||
"""Set or update the mission for a bank.
|
||||
|
||||
Args:
|
||||
bank_id: The ID of the bank.
|
||||
mission: The mission statement to set.
|
||||
|
||||
Returns:
|
||||
API response confirming the update.
|
||||
"""
|
||||
return self._client.set_mission(bank_id=bank_id, mission=mission)
|
||||
|
||||
def set_disposition(self, bank_id: str, disposition: dict[str, Any]):
|
||||
"""Set or update the disposition for a bank."""
|
||||
def set_disposition(self, bank_id: str, disposition: dict[str, Any]) -> Any:
|
||||
"""Set or update the disposition for a bank.
|
||||
|
||||
Args:
|
||||
bank_id: The ID of the bank.
|
||||
disposition: The disposition configuration dict.
|
||||
|
||||
Returns:
|
||||
API response confirming the update.
|
||||
"""
|
||||
return self._client.set_disposition(bank_id=bank_id, disposition=disposition)
|
||||
|
||||
def list(self):
|
||||
"""List all banks."""
|
||||
def list(self) -> Any:
|
||||
"""List all banks.
|
||||
|
||||
Returns:
|
||||
List of banks from the API.
|
||||
"""
|
||||
from hindsight_client.hindsight_client import _run_async
|
||||
|
||||
return _run_async(self._client._banks_api.list_banks())
|
||||
|
||||
|
||||
class MentalModelsAPI:
|
||||
"""Namespace for mental model operations."""
|
||||
"""Namespace for mental model operations.
|
||||
|
||||
Mental models are reusable knowledge structures that guide agent behavior.
|
||||
"""
|
||||
|
||||
def __init__(self, client: Hindsight):
|
||||
self._client = client
|
||||
@@ -64,8 +107,18 @@ class MentalModelsAPI:
|
||||
name: str,
|
||||
content: str,
|
||||
tags: list[str] | None = None,
|
||||
):
|
||||
"""Create a new mental model."""
|
||||
) -> Any:
|
||||
"""Create a new mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The ID of the bank to add the model to.
|
||||
name: Name for the mental model.
|
||||
content: The content/instructions for the mental model.
|
||||
tags: Optional list of tags for categorization.
|
||||
|
||||
Returns:
|
||||
Creation response from the API.
|
||||
"""
|
||||
return self._client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name=name,
|
||||
@@ -73,16 +126,40 @@ class MentalModelsAPI:
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
def list(self, bank_id: str, tags: list[str] | None = None):
|
||||
"""List all mental models for a bank."""
|
||||
def list(self, bank_id: str, tags: list[str] | None = None) -> Any:
|
||||
"""List all mental models for a bank.
|
||||
|
||||
Args:
|
||||
bank_id: The ID of the bank.
|
||||
tags: Optional filter by tags.
|
||||
|
||||
Returns:
|
||||
List of mental models.
|
||||
"""
|
||||
return self._client.list_mental_models(bank_id=bank_id, tags=tags)
|
||||
|
||||
def get(self, bank_id: str, mental_model_id: str):
|
||||
"""Get a specific mental model."""
|
||||
def get(self, bank_id: str, mental_model_id: str) -> Any:
|
||||
"""Get a specific mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The ID of the bank.
|
||||
mental_model_id: The ID of the mental model.
|
||||
|
||||
Returns:
|
||||
The mental model details.
|
||||
"""
|
||||
return self._client.get_mental_model(bank_id=bank_id, mental_model_id=mental_model_id)
|
||||
|
||||
def refresh(self, bank_id: str, mental_model_id: str):
|
||||
"""Refresh a mental model."""
|
||||
def refresh(self, bank_id: str, mental_model_id: str) -> Any:
|
||||
"""Refresh a mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The ID of the bank.
|
||||
mental_model_id: The ID of the mental model to refresh.
|
||||
|
||||
Returns:
|
||||
Refresh response from the API.
|
||||
"""
|
||||
return self._client.refresh_mental_model(bank_id=bank_id, mental_model_id=mental_model_id)
|
||||
|
||||
def update(
|
||||
@@ -92,8 +169,19 @@ class MentalModelsAPI:
|
||||
name: str | None = None,
|
||||
content: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
):
|
||||
"""Update a mental model."""
|
||||
) -> Any:
|
||||
"""Update a mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The ID of the bank.
|
||||
mental_model_id: The ID of the mental model to update.
|
||||
name: Optional new name.
|
||||
content: Optional new content.
|
||||
tags: Optional new tags list.
|
||||
|
||||
Returns:
|
||||
Update response from the API.
|
||||
"""
|
||||
return self._client.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model_id,
|
||||
@@ -102,13 +190,24 @@ class MentalModelsAPI:
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
def delete(self, bank_id: str, mental_model_id: str):
|
||||
"""Delete a mental model."""
|
||||
def delete(self, bank_id: str, mental_model_id: str) -> Any:
|
||||
"""Delete a mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The ID of the bank.
|
||||
mental_model_id: The ID of the mental model to delete.
|
||||
|
||||
Returns:
|
||||
Deletion response from the API.
|
||||
"""
|
||||
return self._client.delete_mental_model(bank_id=bank_id, mental_model_id=mental_model_id)
|
||||
|
||||
|
||||
class DirectivesAPI:
|
||||
"""Namespace for directive operations."""
|
||||
"""Namespace for directive operations.
|
||||
|
||||
Directives are explicit instructions that guide agent behavior.
|
||||
"""
|
||||
|
||||
def __init__(self, client: Hindsight):
|
||||
self._client = client
|
||||
@@ -119,8 +218,18 @@ class DirectivesAPI:
|
||||
name: str,
|
||||
content: str,
|
||||
tags: list[str] | None = None,
|
||||
):
|
||||
"""Create a new directive."""
|
||||
) -> Any:
|
||||
"""Create a new directive.
|
||||
|
||||
Args:
|
||||
bank_id: The ID of the bank to add the directive to.
|
||||
name: Name for the directive.
|
||||
content: The directive content/instructions.
|
||||
tags: Optional list of tags for categorization.
|
||||
|
||||
Returns:
|
||||
Creation response from the API.
|
||||
"""
|
||||
return self._client.create_directive(
|
||||
bank_id=bank_id,
|
||||
name=name,
|
||||
@@ -128,12 +237,28 @@ class DirectivesAPI:
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
def list(self, bank_id: str, tags: list[str] | None = None):
|
||||
"""List all directives for a bank."""
|
||||
def list(self, bank_id: str, tags: list[str] | None = None) -> Any:
|
||||
"""List all directives for a bank.
|
||||
|
||||
Args:
|
||||
bank_id: The ID of the bank.
|
||||
tags: Optional filter by tags.
|
||||
|
||||
Returns:
|
||||
List of directives.
|
||||
"""
|
||||
return self._client.list_directives(bank_id=bank_id, tags=tags)
|
||||
|
||||
def get(self, bank_id: str, directive_id: str):
|
||||
"""Get a specific directive."""
|
||||
def get(self, bank_id: str, directive_id: str) -> Any:
|
||||
"""Get a specific directive.
|
||||
|
||||
Args:
|
||||
bank_id: The ID of the bank.
|
||||
directive_id: The ID of the directive.
|
||||
|
||||
Returns:
|
||||
The directive details.
|
||||
"""
|
||||
return self._client.get_directive(bank_id=bank_id, directive_id=directive_id)
|
||||
|
||||
def update(
|
||||
@@ -143,8 +268,19 @@ class DirectivesAPI:
|
||||
name: str | None = None,
|
||||
content: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
):
|
||||
"""Update a directive."""
|
||||
) -> Any:
|
||||
"""Update a directive.
|
||||
|
||||
Args:
|
||||
bank_id: The ID of the bank.
|
||||
directive_id: The ID of the directive to update.
|
||||
name: Optional new name.
|
||||
content: Optional new content.
|
||||
tags: Optional new tags list.
|
||||
|
||||
Returns:
|
||||
Update response from the API.
|
||||
"""
|
||||
return self._client.update_directive(
|
||||
bank_id=bank_id,
|
||||
directive_id=directive_id,
|
||||
@@ -153,13 +289,24 @@ class DirectivesAPI:
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
def delete(self, bank_id: str, directive_id: str):
|
||||
"""Delete a directive."""
|
||||
def delete(self, bank_id: str, directive_id: str) -> Any:
|
||||
"""Delete a directive.
|
||||
|
||||
Args:
|
||||
bank_id: The ID of the bank.
|
||||
directive_id: The ID of the directive to delete.
|
||||
|
||||
Returns:
|
||||
Deletion response from the API.
|
||||
"""
|
||||
return self._client.delete_directive(bank_id=bank_id, directive_id=directive_id)
|
||||
|
||||
|
||||
class MemoriesAPI:
|
||||
"""Namespace for memory operations."""
|
||||
"""Namespace for memory operations.
|
||||
|
||||
Provides methods to query and retrieve stored memories.
|
||||
"""
|
||||
|
||||
def __init__(self, client: Hindsight):
|
||||
self._client = client
|
||||
@@ -171,8 +318,19 @@ class MemoriesAPI:
|
||||
search_query: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
):
|
||||
"""List memories in a bank."""
|
||||
) -> Any:
|
||||
"""List memories in a bank.
|
||||
|
||||
Args:
|
||||
bank_id: The ID of the bank to query.
|
||||
type: Optional filter by memory type.
|
||||
search_query: Optional search query for filtering.
|
||||
limit: Maximum number of results to return (default: 100).
|
||||
offset: Number of results to skip for pagination (default: 0).
|
||||
|
||||
Returns:
|
||||
List of memories matching the criteria.
|
||||
"""
|
||||
return self._client.list_memories(
|
||||
bank_id=bank_id,
|
||||
type=type,
|
||||
@@ -205,9 +363,15 @@ class HindsightClient(Hindsight):
|
||||
directives = client.directives.list(bank_id="test")
|
||||
memories = client.memories.list(bank_id="test")
|
||||
```
|
||||
|
||||
Attributes:
|
||||
banks: Namespace for bank management operations.
|
||||
mental_models: Namespace for mental model operations.
|
||||
directives: Namespace for directive operations.
|
||||
memories: Namespace for memory listing operations.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._banks_namespace: BanksAPI | None = None
|
||||
self._mental_models_namespace: MentalModelsAPI | None = None
|
||||
@@ -216,28 +380,44 @@ class HindsightClient(Hindsight):
|
||||
|
||||
@property
|
||||
def banks(self) -> BanksAPI:
|
||||
"""Access bank management operations."""
|
||||
"""Access bank management operations.
|
||||
|
||||
Returns:
|
||||
BanksAPI instance for bank operations.
|
||||
"""
|
||||
if self._banks_namespace is None:
|
||||
self._banks_namespace = BanksAPI(self)
|
||||
return self._banks_namespace
|
||||
|
||||
@property
|
||||
def mental_models(self) -> MentalModelsAPI:
|
||||
"""Access mental model operations."""
|
||||
"""Access mental model operations.
|
||||
|
||||
Returns:
|
||||
MentalModelsAPI instance for mental model operations.
|
||||
"""
|
||||
if self._mental_models_namespace is None:
|
||||
self._mental_models_namespace = MentalModelsAPI(self)
|
||||
return self._mental_models_namespace
|
||||
|
||||
@property
|
||||
def directives(self) -> DirectivesAPI:
|
||||
"""Access directive operations."""
|
||||
"""Access directive operations.
|
||||
|
||||
Returns:
|
||||
DirectivesAPI instance for directive operations.
|
||||
"""
|
||||
if self._directives_namespace is None:
|
||||
self._directives_namespace = DirectivesAPI(self)
|
||||
return self._directives_namespace
|
||||
|
||||
@property
|
||||
def memories(self) -> MemoriesAPI:
|
||||
"""Access memory listing operations."""
|
||||
"""Access memory listing operations.
|
||||
|
||||
Returns:
|
||||
MemoriesAPI instance for memory operations.
|
||||
"""
|
||||
if self._memories_namespace is None:
|
||||
self._memories_namespace = MemoriesAPI(self)
|
||||
return self._memories_namespace
|
||||
|
||||
@@ -34,7 +34,6 @@ Using context manager:
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
@@ -140,13 +139,17 @@ class HindsightEmbedded:
|
||||
return
|
||||
|
||||
if self._closed:
|
||||
raise RuntimeError("Cannot use HindsightEmbedded after it has been closed")
|
||||
raise RuntimeError(
|
||||
"Cannot use HindsightEmbedded after it has been closed"
|
||||
)
|
||||
|
||||
# Use embed manager interface for daemon management
|
||||
logger.info(f"Ensuring daemon is running for profile '{self.profile}'...")
|
||||
success = self._manager.ensure_running(self.config, self.profile)
|
||||
if not success:
|
||||
raise RuntimeError(f"Failed to start daemon for profile '{self.profile}'")
|
||||
raise RuntimeError(
|
||||
f"Failed to start daemon for profile '{self.profile}'"
|
||||
)
|
||||
|
||||
# Get daemon URL and create client
|
||||
daemon_url = self._manager.get_url(self.profile)
|
||||
@@ -375,3 +378,45 @@ class HindsightEmbedded:
|
||||
def is_running(self) -> bool:
|
||||
"""Check if the client is initialized."""
|
||||
return self._started and not self._closed and self._client is not None
|
||||
|
||||
def start_ui(self, ui_port: int | None = None, hostname: str = "0.0.0.0") -> bool:
|
||||
"""Start the control plane web UI.
|
||||
|
||||
The daemon is started automatically if not already running.
|
||||
|
||||
Args:
|
||||
ui_port: Port for the UI. Defaults to daemon_port + 10000.
|
||||
hostname: Hostname to bind to. Defaults to 0.0.0.0.
|
||||
|
||||
Returns:
|
||||
True if UI started successfully.
|
||||
"""
|
||||
self._ensure_started()
|
||||
return self._manager.start_ui(self.profile, ui_port, hostname)
|
||||
|
||||
def stop_ui(self, ui_port: int | None = None) -> bool:
|
||||
"""Stop the control plane web UI.
|
||||
|
||||
Args:
|
||||
ui_port: Port the UI is running on. Defaults to daemon_port + 10000.
|
||||
|
||||
Returns:
|
||||
True if stopped successfully.
|
||||
"""
|
||||
return self._manager.stop_ui(self.profile, ui_port)
|
||||
|
||||
def is_ui_running(self, ui_port: int | None = None) -> bool:
|
||||
"""Check if the control plane web UI is running.
|
||||
|
||||
Args:
|
||||
ui_port: Port to check. Defaults to daemon_port + 10000.
|
||||
|
||||
Returns:
|
||||
True if UI is running and responsive.
|
||||
"""
|
||||
return self._manager.is_ui_running(self.profile, ui_port)
|
||||
|
||||
@property
|
||||
def ui_url(self) -> str:
|
||||
"""Get the UI URL for this profile."""
|
||||
return self._manager.get_ui_url(self.profile)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-all"
|
||||
version = "0.4.17"
|
||||
version = "0.4.20"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -46,4 +46,4 @@ __all__ = [
|
||||
"RemoteTEICrossEncoder",
|
||||
"LLMConfig",
|
||||
]
|
||||
__version__ = "0.4.17"
|
||||
__version__ = "0.4.20"
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
"""Add consolidation_failed_at column to memory_units for tracking persistent LLM failures.
|
||||
|
||||
When all LLM retries are exhausted on a single-memory batch, the memory is marked
|
||||
with consolidation_failed_at instead of consolidated_at, so it is not silently lost
|
||||
and can be retried later via the API.
|
||||
|
||||
Revision ID: a3b4c5d6e7f8
|
||||
Revises: g7h8i9j0k1l2
|
||||
Create Date: 2026-03-17
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision: str = "a3b4c5d6e7f8"
|
||||
down_revision: str | Sequence[str] | None = "g7h8i9j0k1l2"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (required for multi-tenant support)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
op.execute(
|
||||
f"""
|
||||
ALTER TABLE {schema}memory_units
|
||||
ADD COLUMN IF NOT EXISTS consolidation_failed_at TIMESTAMPTZ DEFAULT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
# Index to efficiently query memories that failed consolidation for a given bank
|
||||
op.execute(
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_units_consolidation_failed
|
||||
ON {schema}memory_units (bank_id, consolidation_failed_at)
|
||||
WHERE consolidation_failed_at IS NOT NULL AND fact_type IN ('experience', 'world')
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_consolidation_failed")
|
||||
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS consolidation_failed_at")
|
||||
+15
-2
@@ -11,6 +11,7 @@ block; see migrations.py for how this is handled safely.
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import context, op
|
||||
|
||||
revision: str = "c1a2b3d4e5f6"
|
||||
@@ -25,9 +26,21 @@ def _get_schema_prefix() -> str:
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# pg_trgm ships with every standard PostgreSQL installation as a contrib module.
|
||||
# pg_trgm ships with most PostgreSQL installations as a contrib module.
|
||||
# It enables fast similarity lookups via GIN indexes, used for entity name matching.
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm")
|
||||
# On managed services (e.g. Azure Flexible Server), the extension may not be
|
||||
# available or may require manual enablement. We gracefully skip the index
|
||||
# creation if the extension cannot be loaded — the entity resolver will
|
||||
# auto-detect and fall back to the "full" lookup strategy at runtime. See #626.
|
||||
conn = op.get_bind()
|
||||
try:
|
||||
conn.execute(sa.text("CREATE EXTENSION IF NOT EXISTS pg_trgm"))
|
||||
except Exception:
|
||||
# Extension not available (managed Postgres, insufficient privileges, etc.)
|
||||
# Roll back the failed statement and skip index creation.
|
||||
conn.execute(sa.text("ROLLBACK"))
|
||||
conn.execute(sa.text("BEGIN"))
|
||||
return
|
||||
|
||||
schema = _get_schema_prefix()
|
||||
# GIN index on canonical_name enables sub-millisecond trigram similarity queries
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
"""chunk_fk_cascade_delete
|
||||
|
||||
Revision ID: f6g7h8i9j0k1
|
||||
Revises: e5f6g7h8i9j0
|
||||
Create Date: 2026-03-16 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "f6g7h8i9j0k1"
|
||||
down_revision: str | Sequence[str] | None = "e5f6g7h8i9j0"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Change memory_units.chunk_id FK from SET NULL to CASCADE.
|
||||
|
||||
When a document is deleted the CASCADE reaches chunks first; with SET NULL
|
||||
the memory_units rows survived with chunk_id = NULL, leaving ghost records.
|
||||
Switching to CASCADE ensures they are removed together with their chunk.
|
||||
"""
|
||||
op.drop_constraint("memory_units_chunk_fkey", "memory_units", type_="foreignkey")
|
||||
op.create_foreign_key(
|
||||
"memory_units_chunk_fkey", "memory_units", "chunks", ["chunk_id"], ["chunk_id"], ondelete="CASCADE"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Revert to SET NULL behaviour."""
|
||||
op.drop_constraint("memory_units_chunk_fkey", "memory_units", type_="foreignkey")
|
||||
op.create_foreign_key(
|
||||
"memory_units_chunk_fkey", "memory_units", "chunks", ["chunk_id"], ["chunk_id"], ondelete="SET NULL"
|
||||
)
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
"""backsweep_orphan_memory_units
|
||||
|
||||
Two-pass cleanup of memory_units rows that were never removed by earlier bugs:
|
||||
|
||||
Pass 1 — any fact_type, bank gone:
|
||||
memory_units whose bank_id no longer exists in banks. These accumulate when
|
||||
a bank is deleted without a proper cascade (no FK from memory_units to banks
|
||||
exists in the schema).
|
||||
|
||||
Pass 2 — observations only, all sources gone:
|
||||
observation rows whose bank still exists but every source_memory_id points
|
||||
to a deleted memory unit. These were left behind before PR #580 fixed the
|
||||
chunk FK cascade and before delete_document() called
|
||||
_delete_stale_observations_for_memories.
|
||||
|
||||
Revision ID: g7h8i9j0k1l2
|
||||
Revises: f6g7h8i9j0k1
|
||||
Create Date: 2026-03-16
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision: str = "g7h8i9j0k1l2"
|
||||
down_revision: str | Sequence[str] | None = "f6g7h8i9j0k1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
schema = _get_schema_prefix()
|
||||
mu = f"{schema}memory_units"
|
||||
banks = f"{schema}banks"
|
||||
|
||||
# Pass 1: delete all memory_units (any fact_type) whose bank no longer exists.
|
||||
# There is no FK from memory_units to banks, so these never cascade away.
|
||||
op.execute(
|
||||
f"""
|
||||
DELETE FROM {mu}
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM {banks} b WHERE b.bank_id = {mu}.bank_id
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
# Pass 2: delete orphaned observations whose bank still exists but every
|
||||
# source_memory_id refers to a now-deleted memory unit (or the array is
|
||||
# empty). Observations with at least one surviving source are left alone.
|
||||
op.execute(
|
||||
f"""
|
||||
DELETE FROM {mu} orphan
|
||||
WHERE orphan.fact_type = 'observation'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM {mu} src
|
||||
WHERE src.id = ANY(orphan.source_memory_ids)
|
||||
AND src.bank_id = orphan.bank_id
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Deleted rows cannot be restored.
|
||||
pass
|
||||
@@ -10,7 +10,7 @@ import json
|
||||
import logging
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, UploadFile
|
||||
@@ -34,7 +34,7 @@ def _parse_metadata(metadata: Any) -> dict[str, Any]:
|
||||
|
||||
from typing import Callable
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
|
||||
from hindsight_api import MemoryEngine
|
||||
|
||||
@@ -72,8 +72,9 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any:
|
||||
|
||||
from hindsight_api.config import get_config
|
||||
from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding, fq_table
|
||||
from hindsight_api.engine.providers.none_llm import LLMNotAvailableError
|
||||
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage
|
||||
from hindsight_api.engine.search.tags import TagsMatch
|
||||
from hindsight_api.engine.search.tags import TagGroup, TagsMatch
|
||||
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
|
||||
from hindsight_api.metrics import create_metrics_collector, get_metrics_collector, initialize_metrics
|
||||
from hindsight_api.models import RequestContext
|
||||
@@ -163,6 +164,26 @@ class RecallRequest(BaseModel):
|
||||
description="How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), "
|
||||
"'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).",
|
||||
)
|
||||
tag_groups: list[TagGroup] | None = Field(
|
||||
default=None,
|
||||
description="Compound tag filter using boolean groups. Groups in the list are AND-ed. "
|
||||
"Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}.",
|
||||
)
|
||||
|
||||
@field_validator("query")
|
||||
@classmethod
|
||||
def validate_query_not_empty(cls, v: str) -> str:
|
||||
from ..engine.search.retrieval import tokenize_query
|
||||
|
||||
if not tokenize_query(v):
|
||||
raise ValueError("query must contain at least one word character after normalization")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_tags_exclusive(self) -> "RecallRequest":
|
||||
if self.tags is not None and self.tag_groups is not None:
|
||||
raise ValueError("'tags' and 'tag_groups' are mutually exclusive. Use 'tag_groups' for compound filtering.")
|
||||
return self
|
||||
|
||||
|
||||
class RecallResult(BaseModel):
|
||||
@@ -404,6 +425,27 @@ class MemoryItem(BaseModel):
|
||||
default=None,
|
||||
description="Optional tags for visibility scoping. Memories with tags can be filtered during recall.",
|
||||
)
|
||||
|
||||
@field_validator("tags", mode="before")
|
||||
@classmethod
|
||||
def coerce_tags(cls, v):
|
||||
"""Coerce JSON-string tags to list.
|
||||
|
||||
MCP tool bridges sometimes serialize JSON arrays as strings during
|
||||
transport, e.g. '["a", "b"]' instead of ["a", "b"]. This validator
|
||||
parses such strings back into lists so the retain call succeeds.
|
||||
A plain non-JSON string is wrapped in a single-element list.
|
||||
"""
|
||||
if isinstance(v, str):
|
||||
try:
|
||||
parsed = json.loads(v)
|
||||
if isinstance(parsed, list):
|
||||
return parsed
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
return [v]
|
||||
return v
|
||||
|
||||
observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = Field(
|
||||
default=None,
|
||||
title="ObservationScopes",
|
||||
@@ -414,6 +456,11 @@ class MemoryItem(BaseModel):
|
||||
"A list of tag lists runs one pass per inner list, giving full control over which combinations to use."
|
||||
),
|
||||
)
|
||||
strategy: str | None = Field(
|
||||
default=None,
|
||||
description="Named retain strategy for this item. Overrides the bank's default strategy for this item only. "
|
||||
"Strategies are defined in the bank config under 'retain_strategies'.",
|
||||
)
|
||||
|
||||
@field_validator("timestamp", mode="before")
|
||||
@classmethod
|
||||
@@ -480,6 +527,11 @@ class FileRetainMetadata(BaseModel):
|
||||
description="Parser or ordered fallback chain for this file (overrides request-level parser). "
|
||||
"E.g. 'iris' or ['iris', 'markitdown'].",
|
||||
)
|
||||
strategy: str | None = Field(
|
||||
default=None,
|
||||
description="Named retain strategy for this file. Overrides the bank's default strategy. "
|
||||
"Strategies are defined in the bank config under 'retain_strategies'.",
|
||||
)
|
||||
|
||||
|
||||
class FileRetainRequest(BaseModel):
|
||||
@@ -533,7 +585,11 @@ class RetainResponse(BaseModel):
|
||||
)
|
||||
operation_id: str | None = Field(
|
||||
default=None,
|
||||
description="Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true.",
|
||||
description="Operation ID for tracking async operations. Use GET /v1/default/banks/{bank_id}/operations to list operations. Only present when async=true. When items use different per-item strategies, use operation_ids instead.",
|
||||
)
|
||||
operation_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Operation IDs when items were submitted as multiple strategy groups (async=true with mixed per-item strategies). operation_id is set to the first entry for backward compatibility.",
|
||||
)
|
||||
usage: TokenUsage | None = Field(
|
||||
default=None,
|
||||
@@ -639,6 +695,36 @@ class ReflectRequest(BaseModel):
|
||||
description="How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), "
|
||||
"'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged).",
|
||||
)
|
||||
tag_groups: list[TagGroup] | None = Field(
|
||||
default=None,
|
||||
description="Compound tag filter using boolean groups. Groups in the list are AND-ed. "
|
||||
"Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}.",
|
||||
)
|
||||
fact_types: list[Literal["world", "experience", "observation"]] | None = Field(
|
||||
default=None,
|
||||
description="Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).",
|
||||
)
|
||||
exclude_mental_models: bool = Field(
|
||||
default=False,
|
||||
description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
|
||||
)
|
||||
exclude_mental_model_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Exclude specific mental models by ID from the reflect loop.",
|
||||
)
|
||||
|
||||
@field_validator("fact_types")
|
||||
@classmethod
|
||||
def validate_reflect_fact_types(cls, v: list[str] | None) -> list[str] | None:
|
||||
if v is not None and len(v) == 0:
|
||||
raise ValueError("fact_types must not be empty. Use null to include all fact types.")
|
||||
return v
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_tags_exclusive(self) -> "ReflectRequest":
|
||||
if self.tags is not None and self.tag_groups is not None:
|
||||
raise ValueError("'tags' and 'tag_groups' are mutually exclusive. Use 'tag_groups' for compound filtering.")
|
||||
return self
|
||||
|
||||
|
||||
class ReflectFact(BaseModel):
|
||||
@@ -1292,6 +1378,14 @@ class ClearMemoryObservationsResponse(BaseModel):
|
||||
deleted_count: int
|
||||
|
||||
|
||||
class RecoverConsolidationResponse(BaseModel):
|
||||
"""Response model for recovering failed consolidation."""
|
||||
|
||||
model_config = ConfigDict(json_schema_extra={"example": {"retried_count": 42}})
|
||||
|
||||
retried_count: int
|
||||
|
||||
|
||||
class BankStatsResponse(BaseModel):
|
||||
"""Response model for bank statistics endpoint."""
|
||||
|
||||
@@ -1391,6 +1485,25 @@ class MentalModelTrigger(BaseModel):
|
||||
default=False,
|
||||
description="If true, refresh this mental model after observations consolidation (real-time mode)",
|
||||
)
|
||||
fact_types: list[Literal["world", "experience", "observation"]] | None = Field(
|
||||
default=None,
|
||||
description="Filter which fact types are retrieved during reflect. None means all types (world, experience, observation).",
|
||||
)
|
||||
exclude_mental_models: bool = Field(
|
||||
default=False,
|
||||
description="If true, exclude all mental models from the reflect loop (skip search_mental_models tool).",
|
||||
)
|
||||
exclude_mental_model_ids: list[str] | None = Field(
|
||||
default=None,
|
||||
description="Exclude specific mental models by ID from the reflect loop.",
|
||||
)
|
||||
|
||||
@field_validator("fact_types")
|
||||
@classmethod
|
||||
def validate_fact_types(cls, v: list[str] | None) -> list[str] | None:
|
||||
if v is not None and len(v) == 0:
|
||||
raise ValueError("fact_types must not be empty. Use null to include all fact types.")
|
||||
return v
|
||||
|
||||
|
||||
class MentalModelResponse(BaseModel):
|
||||
@@ -1951,6 +2064,26 @@ def create_app(
|
||||
# This is required for mounted sub-applications where lifespan may not fire
|
||||
app.state.memory = memory
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch OpenAPI schema: align ValidationError with Pydantic v2 error format
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI auto-generates ValidationError with only loc/msg/type, but Pydantic
|
||||
# v2 actually returns additional fields: input (the rejected value), ctx (extra
|
||||
# context dict), and url (link to error docs). Without these in the spec,
|
||||
# generated clients using strict JSON decoding break on real 422 responses.
|
||||
_original_openapi = app.openapi
|
||||
|
||||
def _patched_openapi() -> dict[str, Any]:
|
||||
schema = _original_openapi()
|
||||
ve = schema.get("components", {}).get("schemas", {}).get("ValidationError")
|
||||
if ve and "input" not in ve.get("properties", {}):
|
||||
ve["properties"]["input"] = {"title": "Input"}
|
||||
ve["properties"]["ctx"] = {"title": "Context", "type": "object"}
|
||||
ve["properties"]["url"] = {"title": "URL", "type": "string"}
|
||||
return schema
|
||||
|
||||
app.openapi = _patched_openapi # type: ignore[assignment]
|
||||
|
||||
# Add HTTP metrics middleware
|
||||
@app.middleware("http")
|
||||
async def http_metrics_middleware(request, call_next):
|
||||
@@ -2324,6 +2457,7 @@ def _register_routes(app: FastAPI):
|
||||
request_context=request_context,
|
||||
tags=request.tags,
|
||||
tags_match=request.tags_match,
|
||||
tag_groups=request.tag_groups,
|
||||
)
|
||||
|
||||
# Convert core MemoryFact objects to API RecallResult objects (excluding internal metrics)
|
||||
@@ -2459,6 +2593,10 @@ def _register_routes(app: FastAPI):
|
||||
request_context=request_context,
|
||||
tags=request.tags,
|
||||
tags_match=request.tags_match,
|
||||
tag_groups=request.tag_groups,
|
||||
fact_types=request.fact_types,
|
||||
exclude_mental_models=request.exclude_mental_models,
|
||||
exclude_mental_model_ids=request.exclude_mental_model_ids,
|
||||
)
|
||||
|
||||
# Build based_on (memories + mental_models + directives) if facts are requested
|
||||
@@ -2534,6 +2672,14 @@ def _register_routes(app: FastAPI):
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except LLMNotAvailableError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except TimeoutError as e:
|
||||
logger.error("Timeout in /v1/default/banks/%s/reflect: %s", bank_id, e)
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail=str(e) or "Reflect operation timed out. Consider reducing the budget or simplifying the query.",
|
||||
)
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -2894,6 +3040,8 @@ def _register_routes(app: FastAPI):
|
||||
request_context=request_context,
|
||||
)
|
||||
return AsyncOperationSubmitResponse(operation_id=result["operation_id"], status="queued")
|
||||
except LLMNotAvailableError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except (AuthenticationError, HTTPException):
|
||||
@@ -3864,6 +4012,34 @@ def _register_routes(app: FastAPI):
|
||||
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/observations: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post(
|
||||
"/v1/default/banks/{bank_id}/consolidation/recover",
|
||||
response_model=RecoverConsolidationResponse,
|
||||
summary="Recover failed consolidation",
|
||||
description=(
|
||||
"Reset all memories that were permanently marked as failed during consolidation "
|
||||
"(after exhausting all LLM retries and adaptive batch splitting) so they are "
|
||||
"picked up again on the next consolidation run. Does not delete any observations."
|
||||
),
|
||||
operation_id="recover_consolidation",
|
||||
tags=["Banks"],
|
||||
)
|
||||
async def api_recover_consolidation(bank_id: str, request_context: RequestContext = Depends(get_request_context)):
|
||||
"""Reset consolidation-failed memories for recovery."""
|
||||
try:
|
||||
result = await app.state.memory.retry_failed_consolidation(bank_id, request_context=request_context)
|
||||
return RecoverConsolidationResponse(retried_count=result["retried_count"])
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in POST /v1/default/banks/{bank_id}/consolidation/recover: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.delete(
|
||||
"/v1/default/banks/{bank_id}/memories/{memory_id}/observations",
|
||||
response_model=ClearMemoryObservationsResponse,
|
||||
@@ -4097,7 +4273,7 @@ def _register_routes(app: FastAPI):
|
||||
await bank_utils.get_bank_profile(pool, bank_id)
|
||||
|
||||
webhook_id = uuid.uuid4()
|
||||
now = datetime.utcnow().isoformat() + "Z"
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
row = await pool.fetchrow(
|
||||
f"""
|
||||
INSERT INTO {fq_table("webhooks")}
|
||||
@@ -4417,10 +4593,13 @@ def _register_routes(app: FastAPI):
|
||||
metrics = get_metrics_collector()
|
||||
|
||||
try:
|
||||
# Prepare contents for processing
|
||||
contents = []
|
||||
# Group items by strategy
|
||||
strategy_groups: dict[str | None, list[dict]] = {}
|
||||
for item in request.items:
|
||||
content_dict = {"content": item.content}
|
||||
effective = item.strategy
|
||||
if effective not in strategy_groups:
|
||||
strategy_groups[effective] = []
|
||||
content_dict: dict = {"content": item.content}
|
||||
if item.timestamp == "unset":
|
||||
content_dict["event_date"] = None
|
||||
elif item.timestamp:
|
||||
@@ -4437,20 +4616,30 @@ def _register_routes(app: FastAPI):
|
||||
content_dict["tags"] = item.tags
|
||||
if item.observation_scopes is not None:
|
||||
content_dict["observation_scopes"] = item.observation_scopes
|
||||
contents.append(content_dict)
|
||||
strategy_groups[effective].append(content_dict)
|
||||
|
||||
if request.async_:
|
||||
# Async processing: queue task and return immediately
|
||||
result = await app.state.memory.submit_async_retain(
|
||||
bank_id, contents, document_tags=request.document_tags, request_context=request_context
|
||||
)
|
||||
# Async processing: one submit per strategy group
|
||||
all_operation_ids = []
|
||||
total_items_count = 0
|
||||
for group_strategy, contents in strategy_groups.items():
|
||||
result = await app.state.memory.submit_async_retain(
|
||||
bank_id,
|
||||
contents,
|
||||
document_tags=request.document_tags,
|
||||
strategy=group_strategy,
|
||||
request_context=request_context,
|
||||
)
|
||||
all_operation_ids.append(result["operation_id"])
|
||||
total_items_count += result["items_count"]
|
||||
return RetainResponse.model_validate(
|
||||
{
|
||||
"success": True,
|
||||
"bank_id": bank_id,
|
||||
"items_count": result["items_count"],
|
||||
"items_count": total_items_count,
|
||||
"async": True,
|
||||
"operation_id": result["operation_id"],
|
||||
"operation_id": all_operation_ids[0] if all_operation_ids else None,
|
||||
"operation_ids": all_operation_ids if len(all_operation_ids) > 1 else None,
|
||||
}
|
||||
)
|
||||
else:
|
||||
@@ -4469,24 +4658,41 @@ def _register_routes(app: FastAPI):
|
||||
),
|
||||
)
|
||||
|
||||
# Synchronous processing: wait for completion (record metrics)
|
||||
# Synchronous processing: one batch per strategy group, aggregate results
|
||||
total_items_count = 0
|
||||
total_usage = TokenUsage(input_tokens=0, output_tokens=0, total_tokens=0)
|
||||
with metrics.record_operation("retain", bank_id=bank_id, source="api"):
|
||||
result, usage = await app.state.memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=contents,
|
||||
document_tags=request.document_tags,
|
||||
request_context=request_context,
|
||||
return_usage=True,
|
||||
outbox_callback=app.state.memory._build_retain_outbox_callback(
|
||||
for group_strategy, contents in strategy_groups.items():
|
||||
result, usage = await app.state.memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=contents,
|
||||
operation_id=None,
|
||||
schema=_current_schema.get(),
|
||||
),
|
||||
)
|
||||
document_tags=request.document_tags,
|
||||
strategy=group_strategy,
|
||||
request_context=request_context,
|
||||
return_usage=True,
|
||||
outbox_callback=app.state.memory._build_retain_outbox_callback(
|
||||
bank_id=bank_id,
|
||||
contents=contents,
|
||||
operation_id=None,
|
||||
schema=_current_schema.get(),
|
||||
),
|
||||
)
|
||||
total_items_count += len(contents)
|
||||
if usage:
|
||||
total_usage = TokenUsage(
|
||||
input_tokens=total_usage.input_tokens + usage.input_tokens,
|
||||
output_tokens=total_usage.output_tokens + usage.output_tokens,
|
||||
total_tokens=total_usage.total_tokens + usage.total_tokens,
|
||||
)
|
||||
|
||||
return RetainResponse.model_validate(
|
||||
{"success": True, "bank_id": bank_id, "items_count": len(contents), "async": False, "usage": usage}
|
||||
{
|
||||
"success": True,
|
||||
"bank_id": bank_id,
|
||||
"items_count": total_items_count,
|
||||
"async": False,
|
||||
"usage": total_usage,
|
||||
}
|
||||
)
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
@@ -4649,6 +4855,7 @@ def _register_routes(app: FastAPI):
|
||||
"tags": file_meta.tags or [],
|
||||
"timestamp": file_meta.timestamp,
|
||||
"parser": parser_chain,
|
||||
"strategy": file_meta.strategy,
|
||||
}
|
||||
file_items.append(item)
|
||||
|
||||
|
||||
@@ -83,6 +83,9 @@ _current_api_key: ContextVar[str | None] = ContextVar("current_api_key", default
|
||||
_current_tenant_id: ContextVar[str | None] = ContextVar("current_tenant_id", default=None)
|
||||
_current_api_key_id: ContextVar[str | None] = ContextVar("current_api_key_id", default=None)
|
||||
|
||||
# Context variable for MCP pre-authentication flag (set when MCP_AUTH_TOKEN validates)
|
||||
_current_mcp_authenticated: ContextVar[bool] = ContextVar("current_mcp_authenticated", default=False)
|
||||
|
||||
|
||||
def get_current_bank_id() -> str | None:
|
||||
"""Get the current bank_id from context."""
|
||||
@@ -104,6 +107,11 @@ def get_current_api_key_id() -> str | None:
|
||||
return _current_api_key_id.get()
|
||||
|
||||
|
||||
def get_current_mcp_authenticated() -> bool:
|
||||
"""Get whether the request was pre-authenticated by MCP transport auth."""
|
||||
return _current_mcp_authenticated.get()
|
||||
|
||||
|
||||
def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
|
||||
"""
|
||||
Create and configure the Hindsight MCP server.
|
||||
@@ -164,6 +172,7 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
|
||||
api_key_resolver=get_current_api_key, # Propagate API key for tenant auth
|
||||
tenant_id_resolver=get_current_tenant_id, # Propagate tenant_id for usage metering
|
||||
api_key_id_resolver=get_current_api_key_id, # Propagate api_key_id for usage metering
|
||||
mcp_authenticated_resolver=get_current_mcp_authenticated, # Propagate MCP pre-auth flag
|
||||
include_bank_id_param=multi_bank,
|
||||
tools=base_tools,
|
||||
)
|
||||
@@ -312,6 +321,7 @@ class MCPMiddleware:
|
||||
tenant_context = None
|
||||
auth_tenant_id: str | None = None
|
||||
auth_api_key_id: str | None = None
|
||||
mcp_pre_authenticated = False
|
||||
if MCP_AUTH_TOKEN:
|
||||
# Legacy authentication mode - validate against static token
|
||||
if not auth_token:
|
||||
@@ -320,8 +330,9 @@ class MCPMiddleware:
|
||||
if auth_token != MCP_AUTH_TOKEN:
|
||||
await self._send_error(send, 401, "Invalid authentication token")
|
||||
return
|
||||
# Legacy mode doesn't use tenant schemas
|
||||
# Legacy mode: mark as pre-authenticated so tenant extension won't re-validate
|
||||
tenant_context = None
|
||||
mcp_pre_authenticated = True
|
||||
else:
|
||||
# Use TenantExtension.authenticate_mcp() for auth
|
||||
try:
|
||||
@@ -368,19 +379,30 @@ class MCPMiddleware:
|
||||
# - Header/env bank_id → multi-bank app (bank_id param, all tools)
|
||||
target_app = self.single_bank_app if bank_id_from_path else self.multi_bank_app
|
||||
|
||||
# Set bank_id, api_key, tenant_id, and api_key_id context
|
||||
# Set bank_id, api_key, tenant_id, api_key_id, and mcp_authenticated context
|
||||
bank_id_token = _current_bank_id.set(bank_id)
|
||||
# Store the auth token for tenant extension to validate
|
||||
api_key_token = _current_api_key.set(auth_token) if auth_token else None
|
||||
# Store tenant_id and api_key_id from authentication for usage metering
|
||||
tenant_id_token = _current_tenant_id.set(auth_tenant_id) if auth_tenant_id else None
|
||||
api_key_id_token = _current_api_key_id.set(auth_api_key_id) if auth_api_key_id else None
|
||||
# Store MCP pre-authentication flag to skip tenant re-validation
|
||||
mcp_auth_token = _current_mcp_authenticated.set(mcp_pre_authenticated)
|
||||
try:
|
||||
new_scope = scope.copy()
|
||||
new_scope["path"] = new_path
|
||||
# Clear root_path since we're passing directly to the app
|
||||
new_scope["root_path"] = ""
|
||||
|
||||
# Ensure Accept header includes required MIME types for MCP SDK.
|
||||
# Some clients (e.g., Claude Code) don't send Accept, causing
|
||||
# the SDK to reject with 406 Not Acceptable.
|
||||
accept_header = self._get_header(new_scope, "accept")
|
||||
if not accept_header or "text/event-stream" not in accept_header:
|
||||
headers = [(k, v) for k, v in new_scope.get("headers", []) if k.lower() != b"accept"]
|
||||
headers.append((b"accept", b"application/json, text/event-stream"))
|
||||
new_scope["headers"] = headers
|
||||
|
||||
# Wrap send to rewrite the SSE endpoint URL to include bank_id if using path-based routing.
|
||||
# Only rewrite SSE (text/event-stream) responses to avoid corrupting tool results
|
||||
# that might contain the literal string "data: /messages".
|
||||
@@ -410,6 +432,7 @@ class MCPMiddleware:
|
||||
_current_tenant_id.reset(tenant_id_token)
|
||||
if api_key_id_token is not None:
|
||||
_current_api_key_id.reset(api_key_id_token)
|
||||
_current_mcp_authenticated.reset(mcp_auth_token)
|
||||
if schema_token is not None:
|
||||
_current_schema.reset(schema_token)
|
||||
|
||||
|
||||
@@ -212,6 +212,9 @@ ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
|
||||
ENV_RERANKER_LOCAL_FORCE_CPU = "HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"
|
||||
ENV_RERANKER_LOCAL_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT"
|
||||
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE = "HINDSIGHT_API_RERANKER_LOCAL_TRUST_REMOTE_CODE"
|
||||
ENV_RERANKER_LOCAL_FP16 = "HINDSIGHT_API_RERANKER_LOCAL_FP16"
|
||||
ENV_RERANKER_LOCAL_BUCKET_BATCHING = "HINDSIGHT_API_RERANKER_LOCAL_BUCKET_BATCHING"
|
||||
ENV_RERANKER_LOCAL_BATCH_SIZE = "HINDSIGHT_API_RERANKER_LOCAL_BATCH_SIZE"
|
||||
ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL"
|
||||
ENV_RERANKER_TEI_BATCH_SIZE = "HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE"
|
||||
ENV_RERANKER_TEI_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT"
|
||||
@@ -264,6 +267,7 @@ ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
|
||||
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
|
||||
ENV_RETAIN_MISSION = "HINDSIGHT_API_RETAIN_MISSION"
|
||||
ENV_RETAIN_CUSTOM_INSTRUCTIONS = "HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS"
|
||||
ENV_RETAIN_DEFAULT_STRATEGY = "HINDSIGHT_API_RETAIN_DEFAULT_STRATEGY"
|
||||
ENV_RETAIN_BATCH_TOKENS = "HINDSIGHT_API_RETAIN_BATCH_TOKENS"
|
||||
ENV_RETAIN_ENTITY_LOOKUP = "HINDSIGHT_API_RETAIN_ENTITY_LOOKUP"
|
||||
ENV_RETAIN_BATCH_ENABLED = "HINDSIGHT_API_RETAIN_BATCH_ENABLED"
|
||||
@@ -334,7 +338,9 @@ ENV_WORKER_CONSOLIDATION_MAX_SLOTS = "HINDSIGHT_API_WORKER_CONSOLIDATION_MAX_SLO
|
||||
# Reflect agent settings
|
||||
ENV_REFLECT_MAX_ITERATIONS = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS"
|
||||
ENV_REFLECT_MAX_CONTEXT_TOKENS = "HINDSIGHT_API_REFLECT_MAX_CONTEXT_TOKENS"
|
||||
ENV_REFLECT_WALL_TIMEOUT = "HINDSIGHT_API_REFLECT_WALL_TIMEOUT"
|
||||
ENV_REFLECT_MISSION = "HINDSIGHT_API_REFLECT_MISSION"
|
||||
ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS"
|
||||
|
||||
# Disposition settings
|
||||
ENV_DISPOSITION_SKEPTICISM = "HINDSIGHT_API_DISPOSITION_SKEPTICISM"
|
||||
@@ -349,16 +355,19 @@ DEFAULT_LLM_PROVIDER = "openai"
|
||||
# Provider-specific default models
|
||||
PROVIDER_DEFAULT_MODELS = {
|
||||
"openai": "gpt-4o-mini",
|
||||
"anthropic": "claude-haiku-4-5-20251001",
|
||||
"anthropic": "claude-haiku-4-5",
|
||||
"gemini": "gemini-2.5-flash",
|
||||
"groq": "openai/gpt-oss-120b",
|
||||
"minimax": "MiniMax-M2.5",
|
||||
"minimax": "MiniMax-M2.7",
|
||||
"ollama": "gemma3:12b",
|
||||
"lmstudio": "local-model",
|
||||
"vertexai": "google/gemini-2.5-flash-lite",
|
||||
"openai-codex": "gpt-5.2-codex",
|
||||
"claude-code": "claude-sonnet-4-5-20250929",
|
||||
"mock": "mock-model",
|
||||
"none": "none",
|
||||
"litellm": "gpt-4o-mini",
|
||||
"bedrock": "us.amazon.nova-2-lite-v1:0",
|
||||
}
|
||||
DEFAULT_LLM_MODEL = "gpt-4o-mini" # Fallback if provider not in table
|
||||
DEFAULT_LLM_MAX_CONCURRENT = 32
|
||||
@@ -389,6 +398,9 @@ DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound rerankin
|
||||
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE = (
|
||||
False # Security: disabled by default, required for some models like jina-reranker-v2
|
||||
)
|
||||
DEFAULT_RERANKER_LOCAL_FP16 = False # FP16 inference: opt-in, faster on MPS/CUDA (not CPU)
|
||||
DEFAULT_RERANKER_LOCAL_BUCKET_BATCHING = False # Length-sorted bucket batching: opt-in, 36-54% speedup
|
||||
DEFAULT_RERANKER_LOCAL_BATCH_SIZE = 32 # Batch size for local reranker predict() calls
|
||||
DEFAULT_RERANKER_TEI_BATCH_SIZE = 128
|
||||
DEFAULT_RERANKER_TEI_MAX_CONCURRENT = 8
|
||||
DEFAULT_RERANKER_MAX_CANDIDATES = 300
|
||||
@@ -437,9 +449,11 @@ DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction L
|
||||
DEFAULT_RETAIN_CHUNK_SIZE = 3000 # Max chars per chunk for fact extraction
|
||||
DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS = True # Extract causal links between facts
|
||||
DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise", "verbose", or "custom"
|
||||
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom") # Allowed extraction modes
|
||||
RETAIN_EXTRACTION_MODES = ("concise", "verbose", "custom", "verbatim", "chunks") # Allowed extraction modes
|
||||
DEFAULT_RETAIN_MISSION = None # Declarative spec of what to retain (injected into any extraction mode)
|
||||
DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS = None # Custom extraction guidelines (only used when mode="custom")
|
||||
DEFAULT_RETAIN_DEFAULT_STRATEGY = None # Default strategy name (None = no strategy override)
|
||||
DEFAULT_RETAIN_STRATEGIES: dict | None = None # Named retain strategies (dict of name → config overrides)
|
||||
DEFAULT_RETAIN_BATCH_TOKENS = 10_000 # ~40KB of text # Max chars per sub-batch for async retain auto-splitting
|
||||
DEFAULT_RETAIN_ENTITY_LOOKUP = "trigram" # "full" or "trigram"
|
||||
DEFAULT_RETAIN_BATCH_ENABLED = False # Use LLM Batch API for fact extraction (only when async=True)
|
||||
@@ -490,6 +504,8 @@ DEFAULT_WORKER_CONSOLIDATION_MAX_SLOTS = 2 # Max concurrent consolidation tasks
|
||||
# Reflect agent settings
|
||||
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
|
||||
DEFAULT_REFLECT_MAX_CONTEXT_TOKENS = 100_000 # Max accumulated context tokens before forcing final prompt
|
||||
DEFAULT_REFLECT_WALL_TIMEOUT = 300 # Wall-clock timeout in seconds for the entire reflect operation (5 minutes)
|
||||
DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS = -1 # Token budget for source facts in search_observations (-1 = disabled)
|
||||
|
||||
# Disposition defaults (None = not set, fall back to bank DB value or 3)
|
||||
DEFAULT_DISPOSITION_SKEPTICISM = None
|
||||
@@ -668,6 +684,9 @@ class HindsightConfig:
|
||||
reranker_local_force_cpu: bool
|
||||
reranker_local_max_concurrent: int
|
||||
reranker_local_trust_remote_code: bool
|
||||
reranker_local_fp16: bool
|
||||
reranker_local_bucket_batching: bool
|
||||
reranker_local_batch_size: int
|
||||
reranker_tei_url: str | None
|
||||
reranker_tei_batch_size: int
|
||||
reranker_tei_max_concurrent: int
|
||||
@@ -710,6 +729,8 @@ class HindsightConfig:
|
||||
retain_extraction_mode: str
|
||||
retain_mission: str | None
|
||||
retain_custom_instructions: str | None
|
||||
retain_default_strategy: str | None
|
||||
retain_strategies: dict | None
|
||||
retain_batch_tokens: int
|
||||
retain_batch_enabled: bool
|
||||
retain_batch_poll_interval_seconds: int
|
||||
@@ -756,6 +777,7 @@ class HindsightConfig:
|
||||
|
||||
# Reflect agent settings
|
||||
reflect_mission: str | None
|
||||
reflect_source_facts_max_tokens: int
|
||||
|
||||
# Disposition settings (hierarchical - can be overridden per bank; None = fall back to DB)
|
||||
disposition_skepticism: int | None
|
||||
@@ -787,6 +809,7 @@ class HindsightConfig:
|
||||
# Reflect agent settings
|
||||
reflect_max_iterations: int
|
||||
reflect_max_context_tokens: int
|
||||
reflect_wall_timeout: int
|
||||
|
||||
# OpenTelemetry tracing configuration
|
||||
otel_traces_enabled: bool
|
||||
@@ -840,6 +863,8 @@ class HindsightConfig:
|
||||
"retain_extraction_mode",
|
||||
"retain_mission",
|
||||
"retain_custom_instructions",
|
||||
"retain_default_strategy",
|
||||
"retain_strategies",
|
||||
# Entity labels (controlled vocabulary for entity classification)
|
||||
"entity_labels",
|
||||
"entities_allow_free_form",
|
||||
@@ -851,6 +876,7 @@ class HindsightConfig:
|
||||
"observations_mission",
|
||||
# Reflect settings
|
||||
"reflect_mission",
|
||||
"reflect_source_facts_max_tokens",
|
||||
# Disposition settings
|
||||
"disposition_skepticism",
|
||||
"disposition_literalism",
|
||||
@@ -933,9 +959,19 @@ class HindsightConfig:
|
||||
f"Invalid text_search_extension: {self.text_search_extension}. Must be one of: {', '.join(valid_text_search)}"
|
||||
)
|
||||
|
||||
# When LLM provider is "none", force chunks-only mode and disable LLM-dependent features
|
||||
if self.llm_provider == "none":
|
||||
self.retain_extraction_mode = "chunks"
|
||||
self.enable_observations = False
|
||||
logger.info(
|
||||
"LLM provider set to 'none': forcing retain_extraction_mode='chunks', "
|
||||
"disabling observations/consolidation. Reflect will return HTTP 400."
|
||||
)
|
||||
|
||||
# RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE
|
||||
# to ensure the LLM has enough output capacity to extract facts from chunks
|
||||
if self.retain_max_completion_tokens <= self.retain_chunk_size:
|
||||
# (not applicable when provider is "none" since no LLM calls are made)
|
||||
if self.llm_provider != "none" and self.retain_max_completion_tokens <= self.retain_chunk_size:
|
||||
raise ValueError(
|
||||
f"Invalid configuration: HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS "
|
||||
f"({self.retain_max_completion_tokens}) must be greater than "
|
||||
@@ -1092,6 +1128,15 @@ class HindsightConfig:
|
||||
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE)
|
||||
).lower()
|
||||
in ("true", "1"),
|
||||
reranker_local_fp16=os.getenv(ENV_RERANKER_LOCAL_FP16, str(DEFAULT_RERANKER_LOCAL_FP16)).lower()
|
||||
in ("true", "1"),
|
||||
reranker_local_bucket_batching=os.getenv(
|
||||
ENV_RERANKER_LOCAL_BUCKET_BATCHING, str(DEFAULT_RERANKER_LOCAL_BUCKET_BATCHING)
|
||||
).lower()
|
||||
in ("true", "1"),
|
||||
reranker_local_batch_size=int(
|
||||
os.getenv(ENV_RERANKER_LOCAL_BATCH_SIZE, str(DEFAULT_RERANKER_LOCAL_BATCH_SIZE))
|
||||
),
|
||||
reranker_tei_url=os.getenv(ENV_RERANKER_TEI_URL),
|
||||
reranker_tei_batch_size=int(os.getenv(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE))),
|
||||
reranker_tei_max_concurrent=int(
|
||||
@@ -1157,6 +1202,8 @@ class HindsightConfig:
|
||||
),
|
||||
retain_mission=os.getenv(ENV_RETAIN_MISSION) or DEFAULT_RETAIN_MISSION,
|
||||
retain_custom_instructions=os.getenv(ENV_RETAIN_CUSTOM_INSTRUCTIONS) or DEFAULT_RETAIN_CUSTOM_INSTRUCTIONS,
|
||||
retain_default_strategy=os.getenv(ENV_RETAIN_DEFAULT_STRATEGY) or DEFAULT_RETAIN_DEFAULT_STRATEGY,
|
||||
retain_strategies=DEFAULT_RETAIN_STRATEGIES,
|
||||
retain_batch_tokens=int(os.getenv(ENV_RETAIN_BATCH_TOKENS, str(DEFAULT_RETAIN_BATCH_TOKENS))),
|
||||
retain_entity_lookup=os.getenv(ENV_RETAIN_ENTITY_LOOKUP, DEFAULT_RETAIN_ENTITY_LOOKUP),
|
||||
retain_batch_enabled=os.getenv(ENV_RETAIN_BATCH_ENABLED, str(DEFAULT_RETAIN_BATCH_ENABLED)).lower()
|
||||
@@ -1247,7 +1294,11 @@ class HindsightConfig:
|
||||
reflect_max_context_tokens=int(
|
||||
os.getenv(ENV_REFLECT_MAX_CONTEXT_TOKENS, str(DEFAULT_REFLECT_MAX_CONTEXT_TOKENS))
|
||||
),
|
||||
reflect_wall_timeout=int(os.getenv(ENV_REFLECT_WALL_TIMEOUT, str(DEFAULT_REFLECT_WALL_TIMEOUT))),
|
||||
reflect_mission=os.getenv(ENV_REFLECT_MISSION) or None,
|
||||
reflect_source_facts_max_tokens=int(
|
||||
os.getenv(ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS, str(DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS))
|
||||
),
|
||||
# Disposition settings (None = fall back to DB value)
|
||||
disposition_skepticism=int(os.getenv(ENV_DISPOSITION_SKEPTICISM))
|
||||
if os.getenv(ENV_DISPOSITION_SKEPTICISM)
|
||||
|
||||
@@ -10,7 +10,7 @@ multiple API servers.
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import asdict
|
||||
from dataclasses import asdict, replace
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
@@ -239,6 +239,14 @@ class ConfigResolver:
|
||||
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
|
||||
# Continue without permission check (fail open for backward compatibility)
|
||||
|
||||
# Validate retain_strategies: reject empty string keys
|
||||
if "retain_strategies" in normalized_updates and normalized_updates["retain_strategies"]:
|
||||
empty_keys = [k for k in normalized_updates["retain_strategies"] if not str(k).strip()]
|
||||
if empty_keys:
|
||||
raise ValueError(
|
||||
"Strategy names must not be empty strings. Remove entries with empty names before saving."
|
||||
)
|
||||
|
||||
# Merge with existing config (JSONB || operator)
|
||||
async with self.pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
@@ -273,3 +281,35 @@ class ConfigResolver:
|
||||
)
|
||||
|
||||
logger.info(f"Reset bank config for {bank_id} to defaults")
|
||||
|
||||
|
||||
def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConfig:
|
||||
"""
|
||||
Apply a named retain strategy's overrides on top of a resolved config.
|
||||
|
||||
A strategy is a named set of hierarchical field overrides stored in
|
||||
config.retain_strategies. Any field in _HIERARCHICAL_FIELDS can be
|
||||
overridden, including retain_extraction_mode, retain_chunk_size,
|
||||
entity_labels, entities_allow_free_form, etc.
|
||||
|
||||
Unknown strategy names log a warning and return config unchanged.
|
||||
Unknown or non-hierarchical fields in the strategy are silently ignored.
|
||||
"""
|
||||
strategies = config.retain_strategies or {}
|
||||
if strategy_name not in strategies:
|
||||
logger.warning(f"Unknown retain strategy '{strategy_name}', using resolved config as-is")
|
||||
return config
|
||||
|
||||
overrides = strategies[strategy_name]
|
||||
if not isinstance(overrides, dict):
|
||||
logger.warning(f"Retain strategy '{strategy_name}' is not a dict, skipping")
|
||||
return config
|
||||
|
||||
configurable = HindsightConfig.get_configurable_fields()
|
||||
filtered = {k: v for k, v in overrides.items() if k in configurable}
|
||||
|
||||
if not filtered:
|
||||
return config
|
||||
|
||||
logger.debug(f"Applying retain strategy '{strategy_name}': {list(filtered.keys())}")
|
||||
return replace(config, **filtered)
|
||||
|
||||
@@ -80,6 +80,7 @@ class _BatchLLMResult:
|
||||
deletes: list[_DeleteAction] = field(default_factory=list)
|
||||
obs_count: int = 0
|
||||
prompt_chars: int = 0
|
||||
failed: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -219,6 +220,7 @@ async def run_consolidation_job(
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $1
|
||||
AND consolidated_at IS NULL
|
||||
AND consolidation_failed_at IS NULL
|
||||
AND fact_type IN ('experience', 'world')
|
||||
""",
|
||||
bank_id,
|
||||
@@ -240,6 +242,7 @@ async def run_consolidation_job(
|
||||
"observations_deleted": 0,
|
||||
"actions_executed": 0,
|
||||
"skipped": 0,
|
||||
"memories_failed": 0,
|
||||
}
|
||||
|
||||
# Track all unique tags from consolidated memories for mental model refresh filtering
|
||||
@@ -257,6 +260,7 @@ async def run_consolidation_job(
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $1
|
||||
AND consolidated_at IS NULL
|
||||
AND consolidation_failed_at IS NULL
|
||||
AND fact_type IN ('experience', 'world')
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2
|
||||
@@ -298,94 +302,141 @@ async def run_consolidation_job(
|
||||
if memory_tags:
|
||||
consolidated_tags.update(memory_tags)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
# Determine observation_scopes for this batch. All memories in a batch share
|
||||
# the same tags (enforced by tag_groups), so we only check the first memory.
|
||||
# asyncpg returns JSONB columns as raw JSON strings, so parse if needed.
|
||||
_obs_raw = llm_batch[0].get("observation_scopes") if llm_batch else None
|
||||
_obs_parsed = json.loads(_obs_raw) if isinstance(_obs_raw, str) else _obs_raw
|
||||
# Process llm_batch with adaptive splitting: on LLM failure, halve the sub-batch
|
||||
# and retry, down to batch_size=1. Only if a single-memory batch still fails is
|
||||
# the memory marked with consolidation_failed_at and excluded from future runs
|
||||
# until explicitly retried via the API.
|
||||
all_results: list[dict[str, Any]] = []
|
||||
all_deleted = 0
|
||||
succeeded_ids: list[Any] = []
|
||||
failed_ids: list[Any] = []
|
||||
|
||||
# Resolve the scope spec into a concrete list[list[str]] (or None for combined).
|
||||
if _obs_parsed == "per_tag":
|
||||
_memory_tags = llm_batch[0].get("tags") or []
|
||||
obs_tags_list = [[tag] for tag in _memory_tags] if _memory_tags else None
|
||||
elif _obs_parsed == "all_combinations":
|
||||
_memory_tags = llm_batch[0].get("tags") or []
|
||||
obs_tags_list = (
|
||||
[
|
||||
list(combo)
|
||||
for r in range(1, len(_memory_tags) + 1)
|
||||
for combo in combinations(_memory_tags, r)
|
||||
]
|
||||
if _memory_tags
|
||||
else None
|
||||
)
|
||||
elif _obs_parsed == "combined" or _obs_parsed is None:
|
||||
obs_tags_list = None # single combined pass (default behaviour)
|
||||
else:
|
||||
# explicit list[list[str]]
|
||||
obs_tags_list = _obs_parsed
|
||||
pending: list[list[dict[str, Any]]] = [llm_batch]
|
||||
while pending:
|
||||
sub_batch = pending.pop(0)
|
||||
|
||||
batch_deleted: int = 0
|
||||
if obs_tags_list:
|
||||
# Multi-pass: run one observation consolidation pass per tag set
|
||||
results = []
|
||||
for obs_tags in obs_tags_list:
|
||||
pass_results, pass_deleted = await _process_memory_batch(
|
||||
async with pool.acquire() as conn:
|
||||
# Determine observation_scopes for this sub-batch. All memories share
|
||||
# the same tags (enforced by tag_groups), so we only check the first memory.
|
||||
# asyncpg returns JSONB columns as raw JSON strings, so parse if needed.
|
||||
_obs_raw = sub_batch[0].get("observation_scopes") if sub_batch else None
|
||||
_obs_parsed = json.loads(_obs_raw) if isinstance(_obs_raw, str) else _obs_raw
|
||||
|
||||
# Resolve the scope spec into a concrete list[list[str]] (or None for combined).
|
||||
if _obs_parsed == "per_tag":
|
||||
_memory_tags = sub_batch[0].get("tags") or []
|
||||
obs_tags_list = [[tag] for tag in _memory_tags] if _memory_tags else None
|
||||
elif _obs_parsed == "all_combinations":
|
||||
_memory_tags = sub_batch[0].get("tags") or []
|
||||
obs_tags_list = (
|
||||
[
|
||||
list(combo)
|
||||
for r in range(1, len(_memory_tags) + 1)
|
||||
for combo in combinations(_memory_tags, r)
|
||||
]
|
||||
if _memory_tags
|
||||
else None
|
||||
)
|
||||
elif _obs_parsed == "combined" or _obs_parsed is None:
|
||||
obs_tags_list = None # single combined pass (default behaviour)
|
||||
else:
|
||||
# explicit list[list[str]]
|
||||
obs_tags_list = _obs_parsed
|
||||
|
||||
sub_deleted: int = 0
|
||||
sub_llm_failed = False
|
||||
if obs_tags_list:
|
||||
# Multi-pass: run one observation consolidation pass per tag set
|
||||
sub_results: list[dict[str, Any]] = []
|
||||
for obs_tags in obs_tags_list:
|
||||
pass_results, pass_deleted, pass_failed = await _process_memory_batch(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
llm_config=llm_config,
|
||||
bank_id=bank_id,
|
||||
memories=sub_batch,
|
||||
request_context=request_context,
|
||||
perf=perf,
|
||||
config=config,
|
||||
obs_tags_override=obs_tags,
|
||||
)
|
||||
sub_deleted += pass_deleted
|
||||
sub_llm_failed = sub_llm_failed or pass_failed
|
||||
# Merge results: prefer non-skipped actions
|
||||
if not sub_results:
|
||||
sub_results = pass_results
|
||||
else:
|
||||
for i, (existing, new) in enumerate(zip(sub_results, pass_results)):
|
||||
if existing.get("action") == "skipped" and new.get("action") != "skipped":
|
||||
sub_results[i] = new
|
||||
elif existing.get("action") != "skipped" and new.get("action") != "skipped":
|
||||
# Both did something — combine into "multiple"
|
||||
existing_created = existing.get(
|
||||
"created", 1 if existing.get("action") == "created" else 0
|
||||
)
|
||||
existing_updated = existing.get(
|
||||
"updated", 1 if existing.get("action") == "updated" else 0
|
||||
)
|
||||
new_created = new.get("created", 1 if new.get("action") == "created" else 0)
|
||||
new_updated = new.get("updated", 1 if new.get("action") == "updated" else 0)
|
||||
total = existing_created + existing_updated + new_created + new_updated
|
||||
sub_results[i] = {
|
||||
"action": "multiple",
|
||||
"created": existing_created + new_created,
|
||||
"updated": existing_updated + new_updated,
|
||||
"merged": 0,
|
||||
"total_actions": total,
|
||||
}
|
||||
else:
|
||||
# Normal single pass using the memory's own tags
|
||||
sub_results, sub_deleted, sub_llm_failed = await _process_memory_batch(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
llm_config=llm_config,
|
||||
bank_id=bank_id,
|
||||
memories=llm_batch,
|
||||
memories=sub_batch,
|
||||
request_context=request_context,
|
||||
perf=perf,
|
||||
config=config,
|
||||
obs_tags_override=obs_tags,
|
||||
)
|
||||
batch_deleted += pass_deleted
|
||||
# Merge results: prefer non-skipped actions
|
||||
if not results:
|
||||
results = pass_results
|
||||
else:
|
||||
for i, (existing, new) in enumerate(zip(results, pass_results)):
|
||||
if existing.get("action") == "skipped" and new.get("action") != "skipped":
|
||||
results[i] = new
|
||||
elif existing.get("action") != "skipped" and new.get("action") != "skipped":
|
||||
# Both did something — combine into "multiple"
|
||||
existing_created = existing.get(
|
||||
"created", 1 if existing.get("action") == "created" else 0
|
||||
)
|
||||
existing_updated = existing.get(
|
||||
"updated", 1 if existing.get("action") == "updated" else 0
|
||||
)
|
||||
new_created = new.get("created", 1 if new.get("action") == "created" else 0)
|
||||
new_updated = new.get("updated", 1 if new.get("action") == "updated" else 0)
|
||||
total = existing_created + existing_updated + new_created + new_updated
|
||||
results[i] = {
|
||||
"action": "multiple",
|
||||
"created": existing_created + new_created,
|
||||
"updated": existing_updated + new_updated,
|
||||
"merged": 0,
|
||||
"total_actions": total,
|
||||
}
|
||||
else:
|
||||
# Normal single pass using the memory's own tags
|
||||
results, batch_deleted = await _process_memory_batch(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
llm_config=llm_config,
|
||||
bank_id=bank_id,
|
||||
memories=llm_batch,
|
||||
request_context=request_context,
|
||||
perf=perf,
|
||||
config=config,
|
||||
)
|
||||
stats["observations_deleted"] += batch_deleted
|
||||
|
||||
await conn.executemany(
|
||||
f"UPDATE {fq_table('memory_units')} SET consolidated_at = NOW() WHERE id = $1",
|
||||
[(m["id"],) for m in llm_batch],
|
||||
)
|
||||
all_deleted += sub_deleted
|
||||
|
||||
if sub_llm_failed and len(sub_batch) > 1:
|
||||
# Split and retry with smaller batches
|
||||
mid = len(sub_batch) // 2
|
||||
logger.warning(
|
||||
f"[CONSOLIDATION] bank={bank_id} LLM failed for sub-batch of {len(sub_batch)},"
|
||||
f" splitting into {mid}/{len(sub_batch) - mid}"
|
||||
)
|
||||
pending[0:0] = [sub_batch[:mid], sub_batch[mid:]]
|
||||
elif sub_llm_failed:
|
||||
# batch_size=1 and still failing — mark as permanently failed for now
|
||||
failed_ids.append(sub_batch[0]["id"])
|
||||
all_results.append({"action": "failed"})
|
||||
logger.warning(
|
||||
f"[CONSOLIDATION] bank={bank_id} LLM failed for single memory"
|
||||
f" {sub_batch[0]['id']}, marking consolidation_failed_at"
|
||||
)
|
||||
else:
|
||||
succeeded_ids.extend(m["id"] for m in sub_batch)
|
||||
all_results.extend(sub_results)
|
||||
|
||||
# Commit consolidated_at / consolidation_failed_at in a single DB round-trip
|
||||
async with pool.acquire() as conn:
|
||||
if succeeded_ids:
|
||||
await conn.executemany(
|
||||
f"UPDATE {fq_table('memory_units')} SET consolidated_at = NOW() WHERE id = $1",
|
||||
[(mem_id,) for mem_id in succeeded_ids],
|
||||
)
|
||||
if failed_ids:
|
||||
await conn.executemany(
|
||||
f"UPDATE {fq_table('memory_units')} SET consolidation_failed_at = NOW() WHERE id = $1",
|
||||
[(mem_id,) for mem_id in failed_ids],
|
||||
)
|
||||
|
||||
stats["observations_deleted"] += all_deleted
|
||||
results = all_results
|
||||
|
||||
# Checkpoint: abort if the operation (and thus the bank) was deleted mid-run.
|
||||
if operation_id and not await memory_engine._check_op_alive(operation_id):
|
||||
@@ -413,6 +464,8 @@ async def run_consolidation_job(
|
||||
stats["actions_executed"] += result.get("total_actions", 0)
|
||||
elif action == "skipped":
|
||||
stats["skipped"] += 1
|
||||
elif action == "failed":
|
||||
stats["memories_failed"] += 1
|
||||
|
||||
# Per-LLM-batch log
|
||||
llm_batch_time = time.time() - llm_batch_start
|
||||
@@ -425,6 +478,7 @@ async def run_consolidation_job(
|
||||
batch_created = stats["observations_created"] - snap_stats["observations_created"]
|
||||
batch_updated = stats["observations_updated"] - snap_stats["observations_updated"]
|
||||
batch_skipped = stats["skipped"] - snap_stats["skipped"]
|
||||
batch_failed = stats["memories_failed"] - snap_stats["memories_failed"]
|
||||
llm_calls_made = perf.llm_calls - snap_llm_calls
|
||||
logger.info(
|
||||
f"[CONSOLIDATION] bank={bank_id} llm_batch #{llm_batch_num}"
|
||||
@@ -432,7 +486,8 @@ async def run_consolidation_job(
|
||||
f" | {stats['memories_processed']}/{total_count} processed"
|
||||
f" | {', '.join(timing_parts)}"
|
||||
f" | created={batch_created} updated={batch_updated} skipped={batch_skipped}"
|
||||
f" | input_tokens=~{input_tokens}"
|
||||
+ (f" failed={batch_failed}" if batch_failed else "")
|
||||
+ f" | input_tokens=~{input_tokens}"
|
||||
f" | avg={llm_batch_time / len(llm_batch):.3f}s/memory"
|
||||
)
|
||||
|
||||
@@ -584,7 +639,7 @@ async def _process_memory_batch(
|
||||
perf: ConsolidationPerfLog | None = None,
|
||||
config: Any = None,
|
||||
obs_tags_override: list[str] | None = None,
|
||||
) -> tuple[list[dict[str, Any]], int]:
|
||||
) -> tuple[list[dict[str, Any]], int, bool]:
|
||||
"""
|
||||
Process a batch of memories in a single LLM call.
|
||||
|
||||
@@ -747,7 +802,7 @@ async def _process_memory_batch(
|
||||
else:
|
||||
results.append({"action": "skipped", "reason": "no_durable_knowledge"})
|
||||
|
||||
return results, deleted_count
|
||||
return results, deleted_count, llm_result.failed
|
||||
|
||||
|
||||
def _min_date(dates: "Any") -> "datetime | None":
|
||||
@@ -1081,7 +1136,7 @@ async def _consolidate_batch_with_llm(
|
||||
logger.error(
|
||||
f"[CONSOLIDATION] LLM batch call failed after {max_attempts} attempts, skipping batch. Last error: {last_exc}"
|
||||
)
|
||||
return _BatchLLMResult(obs_count=len(union_observations), prompt_chars=len(prompt))
|
||||
return _BatchLLMResult(obs_count=len(union_observations), prompt_chars=len(prompt), failed=True)
|
||||
|
||||
|
||||
async def _create_observation_directly(
|
||||
|
||||
@@ -23,6 +23,7 @@ from ..config import (
|
||||
DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC,
|
||||
DEFAULT_RERANKER_LITELLM_MODEL,
|
||||
DEFAULT_RERANKER_LITELLM_SDK_MODEL,
|
||||
DEFAULT_RERANKER_LOCAL_BATCH_SIZE,
|
||||
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
|
||||
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
|
||||
DEFAULT_RERANKER_LOCAL_MODEL,
|
||||
@@ -111,6 +112,9 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
max_concurrent: int = 4,
|
||||
force_cpu: bool = False,
|
||||
trust_remote_code: bool = False,
|
||||
fp16: bool = False,
|
||||
bucket_batching: bool = False,
|
||||
batch_size: int = DEFAULT_RERANKER_LOCAL_BATCH_SIZE,
|
||||
):
|
||||
"""
|
||||
Initialize local SentenceTransformers cross-encoder.
|
||||
@@ -125,10 +129,20 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
trust_remote_code: Allow loading models with custom code (security risk).
|
||||
Required for some models like jina-reranker-v2-base-multilingual.
|
||||
Default: False (disabled for security)
|
||||
fp16: Use FP16 (half precision) inference. Faster on MPS and CUDA,
|
||||
may be slower on CPU. Default: False (opt-in via env var).
|
||||
bucket_batching: Sort pairs by token length before batching to reduce
|
||||
padding waste. 36-54% speedup, quality-identical.
|
||||
Default: False (opt-in via env var).
|
||||
batch_size: Batch size for predict() calls. Optimal values vary by
|
||||
hardware and model (MPS: 32, CUDA: 128+). Default: 32.
|
||||
"""
|
||||
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
|
||||
self.force_cpu = force_cpu
|
||||
self.trust_remote_code = trust_remote_code
|
||||
self.fp16 = fp16
|
||||
self.bucket_batching = bucket_batching
|
||||
self.batch_size = batch_size
|
||||
self._model = None
|
||||
LocalSTCrossEncoder._max_concurrent = max_concurrent
|
||||
|
||||
@@ -176,6 +190,24 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
|
||||
|
||||
# Patch transformers 5.x compatibility for models using XLM-RoBERTa
|
||||
# (e.g., jina-reranker-v2-base-multilingual). transformers 5.x removed
|
||||
# create_position_ids_from_input_ids as a module-level function; the custom
|
||||
# code in these models still references it. This monkey-patch restores it.
|
||||
try:
|
||||
import transformers.models.xlm_roberta.modeling_xlm_roberta as xlm_module
|
||||
from transformers.models.xlm_roberta.modeling_xlm_roberta import XLMRobertaEmbeddings
|
||||
|
||||
if not hasattr(xlm_module, "create_position_ids_from_input_ids"):
|
||||
setattr(
|
||||
xlm_module,
|
||||
"create_position_ids_from_input_ids",
|
||||
XLMRobertaEmbeddings.create_position_ids_from_input_ids,
|
||||
)
|
||||
logger.info("Reranker: applied transformers 5.x compatibility patch for XLM-RoBERTa")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Suppress verbose transformers warnings during model loading
|
||||
# This suppresses the "UNEXPECTED" warnings from CrossEncoder which are harmless
|
||||
# but look alarming to users (e.g., "embeddings.position_ids | UNEXPECTED")
|
||||
@@ -200,6 +232,12 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
# Restore original logging level
|
||||
transformers_logger.setLevel(original_level)
|
||||
|
||||
# FP16 inference: convert model weights to half precision.
|
||||
# Empirically validated: 27-36% faster on MPS, quality-identical (20/20 overlap).
|
||||
if self.fp16 and device != "cpu":
|
||||
self._model.model.half()
|
||||
logger.info("Reranker: FP16 inference enabled")
|
||||
|
||||
# Initialize shared executor (limited workers naturally limits concurrency)
|
||||
if LocalSTCrossEncoder._executor is None:
|
||||
LocalSTCrossEncoder._executor = ThreadPoolExecutor(
|
||||
@@ -211,8 +249,32 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
logger.info("Reranker: local provider initialized (using existing executor)")
|
||||
|
||||
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""Synchronous prediction wrapper for thread pool execution."""
|
||||
scores = self._model.predict(pairs, show_progress_bar=False)
|
||||
"""Synchronous prediction wrapper for thread pool execution.
|
||||
|
||||
Supports two optimizations (controlled via .env):
|
||||
- bucket_batching: sort pairs by token length to reduce padding waste (36-54% speedup)
|
||||
- batch_size: explicit batch size for predict() calls (MPS optimal: 32)
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
if self.bucket_batching and len(pairs) > 1:
|
||||
# Sort pairs by approximate token length to create homogeneous batches.
|
||||
# This eliminates padding waste — short pairs aren't padded to the length
|
||||
# of the longest pair in the batch. Quality-identical by construction.
|
||||
lengths = [len(pairs[i][0]) + len(pairs[i][1]) for i in range(len(pairs))]
|
||||
sorted_indices = sorted(range(len(pairs)), key=lambda i: lengths[i])
|
||||
sorted_pairs = [pairs[i] for i in sorted_indices]
|
||||
|
||||
sorted_scores = self._model.predict(sorted_pairs, batch_size=self.batch_size, show_progress_bar=False)
|
||||
sorted_scores = sorted_scores.tolist() if hasattr(sorted_scores, "tolist") else list(sorted_scores)
|
||||
|
||||
# Restore original order
|
||||
scores = [0.0] * len(pairs)
|
||||
for new_pos, orig_idx in enumerate(sorted_indices):
|
||||
scores[orig_idx] = sorted_scores[new_pos]
|
||||
return scores
|
||||
|
||||
scores = self._model.predict(pairs, batch_size=self.batch_size, show_progress_bar=False)
|
||||
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
|
||||
|
||||
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
@@ -1196,6 +1258,9 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
max_concurrent=config.reranker_local_max_concurrent,
|
||||
force_cpu=config.reranker_local_force_cpu,
|
||||
trust_remote_code=config.reranker_local_trust_remote_code,
|
||||
fp16=config.reranker_local_fp16,
|
||||
bucket_batching=config.reranker_local_bucket_batching,
|
||||
batch_size=config.reranker_local_batch_size,
|
||||
)
|
||||
elif provider == "cohere":
|
||||
api_key = config.reranker_cohere_api_key
|
||||
|
||||
@@ -75,6 +75,7 @@ class EntityResolver:
|
||||
"""
|
||||
self.pool = pool
|
||||
self.entity_lookup = entity_lookup
|
||||
self._pg_trgm_checked = False
|
||||
# Keyed by asyncio task id so concurrent retain batches never mix their
|
||||
# pending updates. flush_pending_stats() pops only the calling task's items.
|
||||
self._pending_stats: dict[int, list[_EntityStat]] = {}
|
||||
@@ -85,6 +86,19 @@ class EntityResolver:
|
||||
task = asyncio.current_task()
|
||||
return id(task) if task is not None else 0
|
||||
|
||||
def discard_pending_stats(self) -> None:
|
||||
"""
|
||||
Discard accumulated entity stats and co-occurrence counts for the current task.
|
||||
|
||||
Call this on any exception path between resolve_entities_batch /
|
||||
link_units_to_entities_batch and flush_pending_stats() to prevent the
|
||||
per-task dicts from growing unbounded when tasks fail before flushing.
|
||||
Safe to call even if no entries exist for the current task.
|
||||
"""
|
||||
key = self._task_key()
|
||||
self._pending_stats.pop(key, None)
|
||||
self._pending_cooccurrences.pop(key, None)
|
||||
|
||||
async def flush_pending_stats(self) -> None:
|
||||
"""
|
||||
Flush accumulated entity stats and co-occurrence counts for the current task.
|
||||
@@ -202,6 +216,20 @@ class EntityResolver:
|
||||
taxonomy_lookup: set[str] | None = None,
|
||||
) -> list[str]:
|
||||
if self.entity_lookup == "trigram":
|
||||
# Auto-detect pg_trgm availability on first call and fall back to
|
||||
# "full" strategy if the extension is not installed. See #626.
|
||||
if not self._pg_trgm_checked:
|
||||
self._pg_trgm_checked = True
|
||||
has_trgm = await conn.fetchval("SELECT EXISTS(SELECT 1 FROM pg_extension WHERE extname = 'pg_trgm')")
|
||||
if not has_trgm:
|
||||
logger.warning(
|
||||
"pg_trgm extension is not available — falling back to 'full' "
|
||||
"entity lookup strategy. Install pg_trgm for faster entity "
|
||||
"resolution on large banks. See: "
|
||||
"https://github.com/vectorize-io/hindsight/issues/626"
|
||||
)
|
||||
self.entity_lookup = "full"
|
||||
return await self._resolve_entities_batch_full(conn, bank_id, entities_data, unit_event_date)
|
||||
return await self._resolve_entities_batch_trigram(conn, bank_id, entities_data, unit_event_date)
|
||||
return await self._resolve_entities_batch_full(conn, bank_id, entities_data, unit_event_date)
|
||||
|
||||
@@ -477,19 +505,42 @@ class EntityResolver:
|
||||
id_by_name: dict[str, str] = {row["name_lower"]: row["id"] for row in inserted_rows}
|
||||
|
||||
# Fallback SELECT for names that conflicted (another worker won the race).
|
||||
missing = [n for n, _ in sorted_groups if n not in id_by_name]
|
||||
if missing:
|
||||
#
|
||||
# IMPORTANT: we must let PostgreSQL do the lowercasing on BOTH sides of the
|
||||
# comparison. Python's str.lower() and PostgreSQL's LOWER() differ for some
|
||||
# Unicode characters — most notably Turkish İ (U+0130):
|
||||
# Python: 'İstanbul'.lower() == 'i\u0307stanbul' (i + combining dot, 2 chars)
|
||||
# PostgreSQL: LOWER('İstanbul') == 'istanbul' (plain i, 1 char)
|
||||
# Passing a Python-lowercased name to "LOWER(canonical_name) = ANY($2::text[])"
|
||||
# would fail to match the stored entity, leaving entity_id as None and causing
|
||||
# a NOT NULL constraint violation on unit_entities.entity_id.
|
||||
#
|
||||
# Fix: pass the original (mixed-case) input names and use
|
||||
# "LOWER(canonical_name) = ANY(SELECT LOWER(n) FROM unnest($2) AS n)" so
|
||||
# PostgreSQL lowercases both sides identically. The query also returns the
|
||||
# original input_name so we can index id_by_name by Python's lower() of that
|
||||
# name, which is what the assignment loop below uses as its lookup key.
|
||||
missing_original = [g.name for name_lower, g in sorted_groups if name_lower not in id_by_name]
|
||||
if missing_original:
|
||||
existing_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, LOWER(canonical_name) AS name_lower
|
||||
FROM {fq_table("entities")}
|
||||
WHERE bank_id = $1 AND LOWER(canonical_name) = ANY($2::text[])
|
||||
SELECT e.id, LOWER(e.canonical_name) AS name_lower, inputs.input_name
|
||||
FROM {fq_table("entities")} e
|
||||
JOIN (
|
||||
SELECT LOWER(n) AS input_name_lower, n AS input_name
|
||||
FROM unnest($2::text[]) AS n
|
||||
) AS inputs ON LOWER(e.canonical_name) = inputs.input_name_lower
|
||||
WHERE e.bank_id = $1
|
||||
""",
|
||||
bank_id,
|
||||
missing,
|
||||
missing_original,
|
||||
)
|
||||
for row in existing_rows:
|
||||
id_by_name[row["name_lower"]] = row["id"]
|
||||
# Also index by Python's lower() of the original input name so the
|
||||
# assignment loop (which uses Python-lowercased keys) finds it even
|
||||
# when Python and PostgreSQL produce different lowercase strings.
|
||||
id_by_name[row["input_name"].lower()] = row["id"]
|
||||
|
||||
# Assign entity IDs back and queue one stat per original mention so that
|
||||
# flush_pending_stats() increments mention_count by the true mention count,
|
||||
|
||||
@@ -125,7 +125,10 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
|
||||
"openai-codex",
|
||||
"claude-code",
|
||||
"mock",
|
||||
"none",
|
||||
"vertexai",
|
||||
"litellm",
|
||||
"bedrock",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -172,7 +175,9 @@ def create_llm_provider(
|
||||
ClaudeCodeLLM,
|
||||
CodexLLM,
|
||||
GeminiLLM,
|
||||
LiteLLMLLM,
|
||||
MockLLM,
|
||||
NoneLLM,
|
||||
OpenAICompatibleLLM,
|
||||
)
|
||||
|
||||
@@ -205,6 +210,15 @@ def create_llm_provider(
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
elif provider_lower == "none":
|
||||
return NoneLLM(
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
elif provider_lower in ("gemini", "vertexai"):
|
||||
return GeminiLLM(
|
||||
provider=provider,
|
||||
@@ -227,6 +241,26 @@ def create_llm_provider(
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
elif provider_lower == "litellm":
|
||||
return LiteLLMLLM(
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
elif provider_lower == "bedrock":
|
||||
# Bedrock is a first-class alias backed by LiteLLM with auto-prefixed model names
|
||||
bedrock_model = model if model.startswith("bedrock/") else f"bedrock/{model}"
|
||||
return LiteLLMLLM(
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=bedrock_model,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
elif provider_lower in ("openai", "groq", "ollama", "lmstudio", "minimax"):
|
||||
return OpenAICompatibleLLM(
|
||||
provider=provider,
|
||||
@@ -296,7 +330,10 @@ class LLMProvider:
|
||||
"openai-codex",
|
||||
"claude-code",
|
||||
"mock",
|
||||
"none",
|
||||
"minimax",
|
||||
"litellm",
|
||||
"bedrock",
|
||||
]
|
||||
if self.provider not in valid_providers:
|
||||
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
|
||||
@@ -633,7 +670,7 @@ class LLMProvider:
|
||||
# Reduce Claude Agent SDK logging verbosity
|
||||
import logging as sdk_logging
|
||||
|
||||
from claude_agent_sdk import query # noqa: F401
|
||||
from claude_agent_sdk import query # noqa: F401 # type: ignore[unresolved-import]
|
||||
|
||||
sdk_logging.getLogger("claude_agent_sdk").setLevel(sdk_logging.WARNING)
|
||||
sdk_logging.getLogger("claude_agent_sdk._internal").setLevel(sdk_logging.WARNING)
|
||||
@@ -675,10 +712,13 @@ class LLMProvider:
|
||||
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY", "")
|
||||
|
||||
# API key not needed for openai-codex (uses OAuth), claude-code (uses Keychain OAuth),
|
||||
# ollama (local), or vertexai (uses GCP service account credentials)
|
||||
if not api_key and provider not in ("openai-codex", "claude-code", "ollama", "vertexai"):
|
||||
# ollama (local), vertexai (uses GCP service account credentials),
|
||||
# or litellm (uses provider-specific auth, e.g. AWS credentials for Bedrock)
|
||||
if not api_key and not requires_api_key(provider):
|
||||
pass # Provider handles its own auth
|
||||
elif not api_key:
|
||||
raise ValueError(
|
||||
"HINDSIGHT_API_LLM_API_KEY environment variable is required (unless using openai-codex or claude-code)"
|
||||
"HINDSIGHT_API_LLM_API_KEY environment variable is required (unless using openai-codex, claude-code, or litellm)"
|
||||
)
|
||||
|
||||
base_url = os.getenv("HINDSIGHT_API_LLM_BASE_URL", "")
|
||||
@@ -692,12 +732,13 @@ class LLMProvider:
|
||||
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", ""))
|
||||
|
||||
# API key not needed for openai-codex (uses OAuth), claude-code (uses Keychain OAuth),
|
||||
# ollama (local), or vertexai (uses GCP service account credentials)
|
||||
if not api_key and provider not in ("openai-codex", "claude-code", "ollama", "vertexai"):
|
||||
# API key not needed for providers with their own auth mechanisms
|
||||
if not api_key and not requires_api_key(provider):
|
||||
pass # Provider handles its own auth
|
||||
elif not api_key:
|
||||
raise ValueError(
|
||||
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_ANSWER_LLM_API_KEY environment variable is required "
|
||||
"(unless using openai-codex or claude-code)"
|
||||
"(unless using openai-codex, claude-code, or litellm)"
|
||||
)
|
||||
|
||||
base_url = os.getenv("HINDSIGHT_API_ANSWER_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
|
||||
@@ -711,12 +752,13 @@ class LLMProvider:
|
||||
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", ""))
|
||||
|
||||
# API key not needed for openai-codex (uses OAuth), claude-code (uses Keychain OAuth),
|
||||
# ollama (local), or vertexai (uses GCP service account credentials)
|
||||
if not api_key and provider not in ("openai-codex", "claude-code", "ollama", "vertexai"):
|
||||
# API key not needed for providers with their own auth mechanisms
|
||||
if not api_key and not requires_api_key(provider):
|
||||
pass # Provider handles its own auth
|
||||
elif not api_key:
|
||||
raise ValueError(
|
||||
"HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_JUDGE_LLM_API_KEY environment variable is required "
|
||||
"(unless using openai-codex or claude-code)"
|
||||
"(unless using openai-codex, claude-code, or litellm)"
|
||||
)
|
||||
|
||||
base_url = os.getenv("HINDSIGHT_API_JUDGE_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
|
||||
|
||||
@@ -23,7 +23,7 @@ import asyncpg
|
||||
import httpx
|
||||
import tiktoken
|
||||
|
||||
from ..config import get_config
|
||||
from ..config import DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS, get_config
|
||||
from ..metrics import get_metrics_collector
|
||||
from ..tracing import create_operation_span
|
||||
from ..utils import mask_network_location
|
||||
@@ -67,6 +67,13 @@ def fq_table(table_name: str) -> str:
|
||||
return f"{get_current_schema()}.{table_name}"
|
||||
|
||||
|
||||
def _json_default(obj: Any) -> str:
|
||||
"""JSON serializer for types commonly carried through async task payloads."""
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
raise TypeError(f"Object of type {type(obj).__name__} is not JSON serializable")
|
||||
|
||||
|
||||
# Tables that must be schema-qualified (for runtime validation)
|
||||
_PROTECTED_TABLES = frozenset(
|
||||
[
|
||||
@@ -184,7 +191,7 @@ from .retain import bank_utils, embedding_utils
|
||||
from .retain.types import RetainContentDict
|
||||
from .search import think_utils
|
||||
from .search.reranking import CrossEncoderReranker, apply_combined_scoring
|
||||
from .search.tags import TagsMatch, build_tags_where_clause
|
||||
from .search.tags import TagGroup, TagsMatch, build_tags_where_clause
|
||||
from .task_backend import BrokerTaskBackend, SyncTaskBackend, TaskBackend
|
||||
|
||||
|
||||
@@ -321,6 +328,10 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Apply defaults from config
|
||||
db_url = db_url or config.database_url
|
||||
memory_llm_provider = memory_llm_provider or config.llm_provider
|
||||
|
||||
# Force skip LLM verification when provider is "none" (no LLM to verify)
|
||||
if memory_llm_provider == "none":
|
||||
self._skip_llm_verification = True
|
||||
memory_llm_api_key = memory_llm_api_key or config.llm_api_key
|
||||
if not memory_llm_api_key and requires_api_key(memory_llm_provider):
|
||||
raise ValueError("LLM API key is required. Set HINDSIGHT_API_LLM_API_KEY environment variable.")
|
||||
@@ -492,24 +503,28 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
"""The configured tenant extension, if any."""
|
||||
return self._tenant_extension
|
||||
|
||||
async def _validate_operation(self, validation_coro) -> None:
|
||||
async def _validate_operation(self, validation_coro) -> "ValidationResult | None":
|
||||
"""
|
||||
Run validation if an operation validator is configured.
|
||||
|
||||
Args:
|
||||
validation_coro: Coroutine that returns a ValidationResult
|
||||
|
||||
Returns:
|
||||
The ValidationResult (may contain enrichment fields), or None if no validator.
|
||||
|
||||
Raises:
|
||||
OperationValidationError: If validation fails
|
||||
"""
|
||||
if self._operation_validator is None:
|
||||
return
|
||||
return None
|
||||
|
||||
from hindsight_api.extensions import OperationValidationError
|
||||
from hindsight_api.extensions import OperationValidationError, ValidationResult
|
||||
|
||||
result = await validation_coro
|
||||
if not result.allowed:
|
||||
raise OperationValidationError(result.reason or "Operation not allowed", result.status_code)
|
||||
return result
|
||||
|
||||
async def _authenticate_tenant(self, request_context: "RequestContext | None") -> str:
|
||||
"""
|
||||
@@ -538,6 +553,12 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if request_context.internal:
|
||||
return _current_schema.get()
|
||||
|
||||
# For MCP requests already authenticated via MCP_AUTH_TOKEN, skip tenant re-validation.
|
||||
# The MCP transport layer already verified the token; re-validating against the tenant
|
||||
# extension would fail when MCP_AUTH_TOKEN and TENANT_API_KEY differ.
|
||||
if request_context.mcp_authenticated:
|
||||
return _current_schema.get()
|
||||
|
||||
# Authenticate through tenant extension (always set, may be default no-auth extension)
|
||||
tenant_context = await self._tenant_extension.authenticate(request_context)
|
||||
|
||||
@@ -561,6 +582,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
contents = task_dict.get("contents", [])
|
||||
document_tags = task_dict.get("document_tags")
|
||||
operation_id = task_dict.get("operation_id") # For batch API crash recovery
|
||||
strategy = task_dict.get("strategy")
|
||||
|
||||
logger.info(
|
||||
f"[BATCH_RETAIN_TASK] Starting background batch retain for bank_id={bank_id}, {len(contents)} items, operation_id={operation_id}"
|
||||
@@ -584,6 +606,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
document_tags=document_tags,
|
||||
request_context=context,
|
||||
operation_id=operation_id,
|
||||
strategy=strategy,
|
||||
outbox_callback=self._build_retain_outbox_callback(
|
||||
bank_id=bank_id,
|
||||
contents=contents,
|
||||
@@ -712,6 +735,8 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
retain_task_payload: dict[str, Any] = {"contents": retain_contents}
|
||||
if document_tags:
|
||||
retain_task_payload["document_tags"] = document_tags
|
||||
if task_dict.get("strategy"):
|
||||
retain_task_payload["strategy"] = task_dict["strategy"]
|
||||
|
||||
# Pass tenant/api_key context through to retain task
|
||||
if task_dict.get("_tenant_id"):
|
||||
@@ -799,6 +824,11 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if not bank_id:
|
||||
raise ValueError("bank_id is required for consolidation task")
|
||||
|
||||
# Skip consolidation when LLM provider is "none"
|
||||
if self._llm_config.provider == "none":
|
||||
logger.info(f"[CONSOLIDATION] Skipping consolidation for bank {bank_id}: LLM provider is 'none'")
|
||||
return {"memories_processed": 0, "skipped": True}
|
||||
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
from .consolidation import run_consolidation_job
|
||||
@@ -864,14 +894,23 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
tags = mental_model.get("tags")
|
||||
tags_match = "all_strict" if tags else "any"
|
||||
|
||||
# Read reflect options from trigger (if stored)
|
||||
trigger_data = mental_model.get("trigger") or {}
|
||||
fact_types = trigger_data.get("fact_types")
|
||||
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
|
||||
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
|
||||
|
||||
# Run reflect to generate new content, excluding the mental model being refreshed
|
||||
# Always add self to excluded IDs to prevent circular reference
|
||||
reflect_result = await self.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query=source_query,
|
||||
request_context=internal_context,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
exclude_mental_model_ids=[mental_model_id],
|
||||
fact_types=fact_types,
|
||||
exclude_mental_models=exclude_mental_models,
|
||||
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
|
||||
)
|
||||
|
||||
generated_content = reflect_result.text or "No content generated"
|
||||
@@ -1953,6 +1992,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
return_usage: bool = False,
|
||||
operation_id: str | None = None,
|
||||
outbox_callback: "Callable[[asyncpg.Connection], Awaitable[None]] | None" = None,
|
||||
strategy: str | None = None,
|
||||
):
|
||||
"""
|
||||
Store multiple content items as memory units in ONE batch operation.
|
||||
@@ -2025,7 +2065,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
fact_type_override=fact_type_override,
|
||||
confidence_score=confidence_score,
|
||||
)
|
||||
await self._validate_operation(self._operation_validator.validate_retain(ctx))
|
||||
result = await self._validate_operation(self._operation_validator.validate_retain(ctx))
|
||||
if result and result.contents is not None:
|
||||
contents = result.contents
|
||||
|
||||
# Apply batch-level document_id to contents that don't have their own (backwards compatibility)
|
||||
if document_id:
|
||||
@@ -2110,6 +2152,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
confidence_score=confidence_score,
|
||||
document_tags=document_tags,
|
||||
operation_id=operation_id,
|
||||
strategy=strategy,
|
||||
# Outbox callback runs inside the last sub-batch's transaction so the
|
||||
# webhook delivery row is committed atomically with the final retain data.
|
||||
outbox_callback=outbox_callback if i == len(sub_batches) else None,
|
||||
@@ -2134,6 +2177,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
confidence_score=confidence_score,
|
||||
document_tags=document_tags,
|
||||
operation_id=operation_id,
|
||||
strategy=strategy,
|
||||
outbox_callback=outbox_callback,
|
||||
)
|
||||
|
||||
@@ -2186,6 +2230,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
document_tags: list[str] | None = None,
|
||||
operation_id: str | None = None,
|
||||
outbox_callback: "Callable[[asyncpg.Connection], Awaitable[None]] | None" = None,
|
||||
strategy: str | None = None,
|
||||
) -> tuple[list[list[str]], "TokenUsage"]:
|
||||
"""
|
||||
Internal method for batch processing without chunking logic.
|
||||
@@ -2218,6 +2263,18 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Resolve bank-specific config for this operation
|
||||
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
|
||||
|
||||
# Force chunks mode when LLM provider is "none" (no LLM available for fact extraction)
|
||||
if self._llm_config.provider == "none":
|
||||
resolved_config.retain_extraction_mode = "chunks"
|
||||
resolved_config.enable_observations = False
|
||||
|
||||
# Apply strategy overrides: explicit strategy > bank default strategy
|
||||
from hindsight_api.config_resolver import apply_strategy
|
||||
|
||||
effective_strategy = strategy or resolved_config.retain_default_strategy
|
||||
if effective_strategy:
|
||||
resolved_config = apply_strategy(resolved_config, effective_strategy)
|
||||
|
||||
# Create parent span for retain operation
|
||||
with create_operation_span("retain", bank_id):
|
||||
return await orchestrator.retain_batch(
|
||||
@@ -2300,6 +2357,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
request_context: "RequestContext",
|
||||
tags: list[str] | None = None,
|
||||
tags_match: TagsMatch = "any",
|
||||
tag_groups: list[TagGroup] | None = None,
|
||||
_connection_budget: int | None = None,
|
||||
_quiet: bool = False,
|
||||
) -> RecallResultModel:
|
||||
@@ -2381,8 +2439,18 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
max_entity_tokens=max_entity_tokens,
|
||||
include_chunks=include_chunks,
|
||||
max_chunk_tokens=max_chunk_tokens,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
await self._validate_operation(self._operation_validator.validate_recall(ctx))
|
||||
result = await self._validate_operation(self._operation_validator.validate_recall(ctx))
|
||||
if result:
|
||||
if result.tags is not None:
|
||||
tags = result.tags
|
||||
if result.tags_match is not None:
|
||||
tags_match = result.tags_match
|
||||
if result.tag_groups is not None:
|
||||
tag_groups = result.tag_groups
|
||||
|
||||
# Map budget enum to thinking_budget number (default to MID if None)
|
||||
budget_mapping = {Budget.LOW: 100, Budget.MID: 300, Budget.HIGH: 1000}
|
||||
@@ -2434,6 +2502,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
semaphore_wait=semaphore_wait,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
connection_budget=_connection_budget,
|
||||
quiet=_quiet,
|
||||
include_source_facts=include_source_facts,
|
||||
@@ -2561,6 +2630,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
semaphore_wait: float = 0.0,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: TagsMatch = "any",
|
||||
tag_groups: list[TagGroup] | None = None,
|
||||
connection_budget: int | None = None,
|
||||
quiet: bool = False,
|
||||
include_source_facts: bool = False,
|
||||
@@ -2680,6 +2750,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
self.query_analyzer,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
parallel_duration = time.time() - parallel_start
|
||||
finally:
|
||||
@@ -3177,7 +3248,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
source_rows = await sf_conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, fact_type, context, occurred_start, occurred_end,
|
||||
mentioned_at, document_id, chunk_id, tags
|
||||
mentioned_at, document_id, chunk_id, tags, metadata
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = ANY($1::uuid[])
|
||||
""",
|
||||
@@ -3198,6 +3269,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
occurred_end=r["occurred_end"].isoformat() if r["occurred_end"] else None,
|
||||
mentioned_at=r["mentioned_at"].isoformat() if r["mentioned_at"] else None,
|
||||
document_id=r["document_id"],
|
||||
metadata=r["metadata"],
|
||||
chunk_id=str(r["chunk_id"]) if r["chunk_id"] else None,
|
||||
tags=r["tags"] or None,
|
||||
)
|
||||
@@ -3276,6 +3348,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
occurred_end=result_dict.get("occurred_end"),
|
||||
mentioned_at=result_dict.get("mentioned_at"),
|
||||
document_id=result_dict.get("document_id"),
|
||||
metadata=result_dict.get("metadata"),
|
||||
chunk_id=result_dict.get("chunk_id"),
|
||||
tags=result_dict.get("tags"),
|
||||
source_fact_ids=source_fact_ids_by_obs.get(result_id) if include_source_facts else None,
|
||||
@@ -3859,6 +3932,58 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
return {"deleted_count": count or 0}
|
||||
|
||||
async def retry_failed_consolidation(
|
||||
self,
|
||||
bank_id: str,
|
||||
*,
|
||||
request_context: "RequestContext",
|
||||
) -> dict[str, int]:
|
||||
"""
|
||||
Reset memories that previously failed consolidation so they are retried on the next
|
||||
consolidation run.
|
||||
|
||||
Clears consolidation_failed_at (and consolidated_at) for all memories in the bank
|
||||
that were marked as permanently failed after exhausting all LLM retries and adaptive
|
||||
batch splitting. Does not delete any observations.
|
||||
|
||||
Args:
|
||||
bank_id: Bank ID
|
||||
request_context: Request context for authentication.
|
||||
|
||||
Returns:
|
||||
Dictionary with count of memories queued for retry.
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
if self._operation_validator:
|
||||
from hindsight_api.extensions import BankWriteContext
|
||||
|
||||
ctx = BankWriteContext(
|
||||
bank_id=bank_id, operation="retry_failed_consolidation", request_context=request_context
|
||||
)
|
||||
await self._validate_operation(self._operation_validator.validate_bank_write(ctx))
|
||||
pool = await self._get_pool()
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
count = await conn.fetchval(
|
||||
f"""
|
||||
SELECT COUNT(*) FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $1
|
||||
AND consolidation_failed_at IS NOT NULL
|
||||
AND fact_type IN ('experience', 'world')
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {fq_table("memory_units")}
|
||||
SET consolidation_failed_at = NULL, consolidated_at = NULL
|
||||
WHERE bank_id = $1
|
||||
AND consolidation_failed_at IS NOT NULL
|
||||
AND fact_type IN ('experience', 'world')
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
return {"retried_count": count or 0}
|
||||
|
||||
async def clear_observations_for_memory(
|
||||
self,
|
||||
bank_id: str,
|
||||
@@ -5040,7 +5165,10 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
request_context: "RequestContext",
|
||||
tags: list[str] | None = None,
|
||||
tags_match: TagsMatch = "any",
|
||||
tag_groups: list[TagGroup] | None = None,
|
||||
exclude_mental_model_ids: list[str] | None = None,
|
||||
fact_types: list[str] | None = None,
|
||||
exclude_mental_models: bool = False,
|
||||
_skip_span: bool = False,
|
||||
) -> ReflectResult:
|
||||
"""
|
||||
@@ -5078,6 +5206,15 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if self._reflect_llm_config is None:
|
||||
raise ValueError("Memory LLM API key not set. Set HINDSIGHT_API_LLM_API_KEY environment variable.")
|
||||
|
||||
# Block reflect when LLM provider is "none"
|
||||
if self._llm_config.provider == "none":
|
||||
from .providers.none_llm import LLMNotAvailableError
|
||||
|
||||
raise LLMNotAvailableError(
|
||||
"Reflect requires an LLM provider. Current provider is set to 'none'. "
|
||||
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
|
||||
)
|
||||
|
||||
# Authenticate tenant and set schema in context (for fq_table())
|
||||
await self._authenticate_tenant(request_context)
|
||||
|
||||
@@ -5116,6 +5253,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
effective_budget = budget or Budget.LOW
|
||||
max_iterations = max(1, int(base_max_iterations * budget_multipliers.get(effective_budget, 1.0)))
|
||||
max_context_tokens = config.reflect_max_context_tokens
|
||||
wall_timeout = config.reflect_wall_timeout
|
||||
|
||||
# Run agentic loop - acquire connections only when needed for DB operations
|
||||
# (not held during LLM calls which can be slow)
|
||||
@@ -5142,10 +5280,17 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
max_results=max_results,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
exclude_ids=exclude_mental_model_ids,
|
||||
pending_consolidation=pending_consolidation,
|
||||
)
|
||||
|
||||
# Get reflect source facts config (hierarchical: env → tenant → bank)
|
||||
config_dict = await self._config_resolver.get_bank_config(bank_id, request_context)
|
||||
reflect_source_facts_max_tokens = config_dict.get(
|
||||
"reflect_source_facts_max_tokens", DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS
|
||||
)
|
||||
|
||||
async def search_observations_fn(q: str, max_tokens: int = 5000) -> dict[str, Any]:
|
||||
return await tool_search_observations(
|
||||
self,
|
||||
@@ -5155,10 +5300,17 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
max_tokens=max_tokens,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
last_consolidated_at=last_consolidated_at,
|
||||
pending_consolidation=pending_consolidation,
|
||||
source_facts_max_tokens=reflect_source_facts_max_tokens,
|
||||
)
|
||||
|
||||
# Determine which tools to enable based on fact_types and exclude_mental_models
|
||||
include_observations = fact_types is None or "observation" in fact_types
|
||||
recall_fact_types = [ft for ft in (fact_types or ["world", "experience"]) if ft in ("world", "experience")]
|
||||
include_recall = bool(recall_fact_types)
|
||||
|
||||
async def recall_fn(q: str, max_tokens: int = 4096, max_chunk_tokens: int = 1000) -> dict[str, Any]:
|
||||
return await tool_recall(
|
||||
self,
|
||||
@@ -5168,7 +5320,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
max_tokens=max_tokens,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
max_chunk_tokens=max_chunk_tokens,
|
||||
fact_types=recall_fact_types if fact_types is not None else None,
|
||||
)
|
||||
|
||||
async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]:
|
||||
@@ -5191,15 +5345,17 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if directives:
|
||||
logger.info(f"[REFLECT {reflect_id}] Loaded {len(directives)} directives")
|
||||
|
||||
# Check if the bank has any mental models
|
||||
async with pool.acquire() as conn:
|
||||
mental_model_count = await conn.fetchval(
|
||||
f"SELECT COUNT(*) FROM {fq_table('mental_models')} WHERE bank_id = $1",
|
||||
bank_id,
|
||||
)
|
||||
has_mental_models = mental_model_count > 0
|
||||
if has_mental_models:
|
||||
logger.info(f"[REFLECT {reflect_id}] Bank has {mental_model_count} mental models")
|
||||
# Check if the bank has any mental models (skip check if all mental models are excluded)
|
||||
has_mental_models = False
|
||||
if not exclude_mental_models:
|
||||
async with pool.acquire() as conn:
|
||||
mental_model_count = await conn.fetchval(
|
||||
f"SELECT COUNT(*) FROM {fq_table('mental_models')} WHERE bank_id = $1",
|
||||
bank_id,
|
||||
)
|
||||
has_mental_models = mental_model_count > 0
|
||||
if has_mental_models:
|
||||
logger.info(f"[REFLECT {reflect_id}] Bank has {mental_model_count} mental models")
|
||||
|
||||
# Run the agent with parent span for reflect operation (skip if called from another operation)
|
||||
if not _skip_span:
|
||||
@@ -5209,29 +5365,52 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
span_context = None
|
||||
|
||||
try:
|
||||
agent_result = await run_reflect_agent(
|
||||
llm_config=self._reflect_llm_config.with_config(resolved_reflect_config),
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
bank_profile=profile,
|
||||
search_mental_models_fn=search_mental_models_fn,
|
||||
search_observations_fn=search_observations_fn,
|
||||
recall_fn=recall_fn,
|
||||
expand_fn=expand_fn,
|
||||
context=context,
|
||||
max_iterations=max_iterations,
|
||||
max_tokens=max_tokens,
|
||||
response_schema=response_schema,
|
||||
directives=directives,
|
||||
has_mental_models=has_mental_models,
|
||||
budget=effective_budget,
|
||||
max_context_tokens=max_context_tokens,
|
||||
)
|
||||
try:
|
||||
agent_result = await asyncio.wait_for(
|
||||
run_reflect_agent(
|
||||
llm_config=self._reflect_llm_config.with_config(resolved_reflect_config),
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
bank_profile=profile,
|
||||
search_mental_models_fn=search_mental_models_fn,
|
||||
search_observations_fn=search_observations_fn,
|
||||
recall_fn=recall_fn,
|
||||
expand_fn=expand_fn,
|
||||
context=context,
|
||||
max_iterations=max_iterations,
|
||||
max_tokens=max_tokens,
|
||||
response_schema=response_schema,
|
||||
directives=directives,
|
||||
has_mental_models=has_mental_models,
|
||||
include_observations=include_observations,
|
||||
include_recall=include_recall,
|
||||
budget=effective_budget,
|
||||
max_context_tokens=max_context_tokens,
|
||||
),
|
||||
timeout=wall_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
total_time = time.time() - reflect_start
|
||||
logger.error(
|
||||
"[REFLECT %s] Wall-clock timeout after %.1fs (limit: %ss) for query: %.50s...",
|
||||
reflect_id,
|
||||
total_time,
|
||||
wall_timeout,
|
||||
query,
|
||||
)
|
||||
raise TimeoutError(
|
||||
f"Reflect operation timed out after {wall_timeout} seconds. "
|
||||
f"Consider reducing the budget or simplifying the query."
|
||||
)
|
||||
|
||||
total_time = time.time() - reflect_start
|
||||
logger.info(
|
||||
f"[REFLECT {reflect_id}] Complete: {len(agent_result.text)} chars, "
|
||||
f"{agent_result.iterations} iterations, {agent_result.tools_called} tool calls | {total_time:.3f}s"
|
||||
"[REFLECT %s] Complete: %d chars, %d iterations, %d tool calls | %.3fs",
|
||||
reflect_id,
|
||||
len(agent_result.text),
|
||||
agent_result.iterations,
|
||||
agent_result.tools_called,
|
||||
total_time,
|
||||
)
|
||||
|
||||
# Convert agent tool trace to ToolCallTrace objects
|
||||
@@ -6355,6 +6534,12 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
tags = mental_model.get("tags")
|
||||
tags_match = "all_strict" if tags else "any"
|
||||
|
||||
# Read reflect options from trigger (if stored)
|
||||
trigger_data = mental_model.get("trigger") or {}
|
||||
fact_types = trigger_data.get("fact_types")
|
||||
exclude_mental_models = trigger_data.get("exclude_mental_models", False)
|
||||
stored_exclude_ids: list[str] = trigger_data.get("exclude_mental_model_ids") or []
|
||||
|
||||
# Run reflect with the source query, excluding the mental model being refreshed
|
||||
# Skip creating a nested "hindsight.reflect" span since we already have "hindsight.mental_model_refresh"
|
||||
reflect_result = await self.reflect_async(
|
||||
@@ -6363,7 +6548,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
request_context=request_context,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
exclude_mental_model_ids=[mental_model_id],
|
||||
fact_types=fact_types,
|
||||
exclude_mental_models=exclude_mental_models,
|
||||
exclude_mental_model_ids=list({*stored_exclude_ids, mental_model_id}),
|
||||
_skip_span=True,
|
||||
)
|
||||
|
||||
@@ -7332,21 +7519,10 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
operation_id = uuid.uuid4()
|
||||
|
||||
# Insert operation record into database
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
await conn.execute(
|
||||
f"""
|
||||
INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata, status)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
""",
|
||||
operation_id,
|
||||
bank_id,
|
||||
operation_type,
|
||||
json.dumps(result_metadata or {}),
|
||||
"pending",
|
||||
)
|
||||
|
||||
# Build and submit task payload
|
||||
# Build full payload before INSERT so task_payload is included atomically.
|
||||
# Previously the INSERT omitted task_payload and a separate submit_task call
|
||||
# did an UPDATE — a crash between the two left a null-payload row that the
|
||||
# worker's claim query (task_payload IS NOT NULL) could never pick up.
|
||||
full_payload = {
|
||||
"type": task_type,
|
||||
"operation_id": str(operation_id),
|
||||
@@ -7354,6 +7530,24 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
**task_payload,
|
||||
}
|
||||
|
||||
# Insert operation record with task_payload in a single atomic statement
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
await conn.execute(
|
||||
f"""
|
||||
INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata, status, task_payload)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb)
|
||||
""",
|
||||
operation_id,
|
||||
bank_id,
|
||||
operation_type,
|
||||
json.dumps(result_metadata or {}, default=_json_default),
|
||||
"pending",
|
||||
json.dumps(full_payload, default=_json_default),
|
||||
)
|
||||
|
||||
# For SyncTaskBackend: executes the task immediately.
|
||||
# For BrokerTaskBackend: does an idempotent UPDATE (payload already set above),
|
||||
# kept for symmetry and to support any future notification mechanisms.
|
||||
await self._task_backend.submit_task(full_payload)
|
||||
|
||||
logger.info(f"{operation_type} task queued for bank_id={bank_id}, operation_id={operation_id}")
|
||||
@@ -7369,6 +7563,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
*,
|
||||
request_context: "RequestContext",
|
||||
document_tags: list[str] | None = None,
|
||||
strategy: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Submit a batch retain operation to run asynchronously.
|
||||
|
||||
@@ -7386,7 +7581,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
contents=[dict(c) for c in contents],
|
||||
request_context=request_context,
|
||||
)
|
||||
await self._validate_operation(self._operation_validator.validate_retain(ctx))
|
||||
result = await self._validate_operation(self._operation_validator.validate_retain(ctx))
|
||||
if result and result.contents is not None:
|
||||
contents = result.contents
|
||||
|
||||
# Validate no duplicate document_ids in the batch
|
||||
# Having duplicate document_ids causes race conditions in document upserts during parallel processing
|
||||
@@ -7477,6 +7674,8 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
task_payload: dict[str, Any] = {"contents": sub_batch}
|
||||
if document_tags:
|
||||
task_payload["document_tags"] = document_tags
|
||||
if strategy:
|
||||
task_payload["strategy"] = strategy
|
||||
# Pass tenant_id and api_key_id through task payload
|
||||
if request_context.tenant_id:
|
||||
task_payload["_tenant_id"] = request_context.tenant_id
|
||||
@@ -7591,6 +7790,8 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
"document_tags": document_tags or [],
|
||||
"timestamp": item.get("timestamp"),
|
||||
}
|
||||
if item.get("strategy"):
|
||||
task_payload["strategy"] = item["strategy"]
|
||||
|
||||
# Pass tenant_id and api_key_id through task payload
|
||||
if request_context.tenant_id:
|
||||
@@ -7678,6 +7879,15 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
Returns:
|
||||
Dict with operation_id
|
||||
"""
|
||||
# Block mental model refresh when LLM provider is "none"
|
||||
if self._llm_config.provider == "none":
|
||||
from .providers.none_llm import LLMNotAvailableError
|
||||
|
||||
raise LLMNotAvailableError(
|
||||
"Mental model refresh requires an LLM provider. Current provider is set to 'none'. "
|
||||
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
|
||||
)
|
||||
|
||||
await self._authenticate_tenant(request_context)
|
||||
|
||||
# Pre-operation validation (credit check)
|
||||
|
||||
@@ -8,7 +8,18 @@ from .anthropic_llm import AnthropicLLM
|
||||
from .claude_code_llm import ClaudeCodeLLM
|
||||
from .codex_llm import CodexLLM
|
||||
from .gemini_llm import GeminiLLM
|
||||
from .litellm_llm import LiteLLMLLM
|
||||
from .mock_llm import MockLLM
|
||||
from .none_llm import NoneLLM
|
||||
from .openai_compatible_llm import OpenAICompatibleLLM
|
||||
|
||||
__all__ = ["AnthropicLLM", "ClaudeCodeLLM", "CodexLLM", "GeminiLLM", "MockLLM", "OpenAICompatibleLLM"]
|
||||
__all__ = [
|
||||
"AnthropicLLM",
|
||||
"ClaudeCodeLLM",
|
||||
"CodexLLM",
|
||||
"GeminiLLM",
|
||||
"LiteLLMLLM",
|
||||
"MockLLM",
|
||||
"NoneLLM",
|
||||
"OpenAICompatibleLLM",
|
||||
]
|
||||
|
||||
@@ -68,7 +68,7 @@ class ClaudeCodeLLM(LLMInterface):
|
||||
# Reduce Claude Agent SDK logging verbosity
|
||||
import logging as sdk_logging
|
||||
|
||||
from claude_agent_sdk import query # noqa: F401
|
||||
from claude_agent_sdk import query # noqa: F401 # type: ignore[unresolved-import]
|
||||
|
||||
sdk_logging.getLogger("claude_agent_sdk").setLevel(sdk_logging.WARNING)
|
||||
sdk_logging.getLogger("claude_agent_sdk._internal").setLevel(sdk_logging.WARNING)
|
||||
@@ -141,7 +141,12 @@ class ClaudeCodeLLM(LLMInterface):
|
||||
OutputTooLongError: If output exceeds token limits (not supported by Claude Agent SDK).
|
||||
Exception: Re-raises API errors after retries exhausted.
|
||||
"""
|
||||
from claude_agent_sdk import AssistantMessage, ClaudeAgentOptions, TextBlock, query
|
||||
from claude_agent_sdk import ( # type: ignore[unresolved-import]
|
||||
AssistantMessage,
|
||||
ClaudeAgentOptions,
|
||||
TextBlock,
|
||||
query,
|
||||
)
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
@@ -331,7 +336,7 @@ class ClaudeCodeLLM(LLMInterface):
|
||||
Returns:
|
||||
LLMToolCallResult with content and/or tool_calls.
|
||||
"""
|
||||
from claude_agent_sdk import (
|
||||
from claude_agent_sdk import ( # type: ignore[unresolved-import]
|
||||
AssistantMessage,
|
||||
ClaudeAgentOptions,
|
||||
ClaudeSDKClient,
|
||||
|
||||
@@ -470,9 +470,11 @@ class GeminiLLM(LLMInterface):
|
||||
fn_name = fn.get("name", "")
|
||||
fn_args_str = fn.get("arguments", "{}")
|
||||
fn_args = parse_llm_json(fn_args_str)
|
||||
parts.append(
|
||||
genai_types.Part(function_call=genai_types.FunctionCall(name=fn_name, args=fn_args))
|
||||
)
|
||||
thought_signature = tc.get("thought_signature")
|
||||
fc_kwargs: dict[str, Any] = {"name": fn_name, "args": fn_args}
|
||||
if thought_signature:
|
||||
fc_kwargs["thought_signature"] = thought_signature
|
||||
parts.append(genai_types.Part(function_call=genai_types.FunctionCall(**fc_kwargs)))
|
||||
gemini_contents.append(genai_types.Content(role="model", parts=parts))
|
||||
else:
|
||||
gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)]))
|
||||
@@ -545,11 +547,13 @@ class GeminiLLM(LLMInterface):
|
||||
content = part.text
|
||||
if hasattr(part, "function_call") and part.function_call:
|
||||
fc = part.function_call
|
||||
thought_signature = getattr(fc, "thought_signature", None)
|
||||
tool_calls.append(
|
||||
LLMToolCall(
|
||||
id=f"gemini_{len(tool_calls)}",
|
||||
name=fc.name,
|
||||
arguments=dict(fc.args) if fc.args else {},
|
||||
thought_signature=thought_signature,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
"""
|
||||
LiteLLM LLM provider for universal model support.
|
||||
|
||||
This provider enables using 100+ LLM providers via the LiteLLM SDK, including:
|
||||
- AWS Bedrock (bedrock/anthropic.claude-3-5-sonnet-...)
|
||||
- Azure OpenAI (azure/gpt-4o)
|
||||
- Together AI (together_ai/meta-llama/...)
|
||||
- Any other LiteLLM-supported provider
|
||||
|
||||
Uses litellm.acompletion() for async chat completions.
|
||||
Authentication for cloud providers (e.g., AWS Bedrock via boto3 credential chain)
|
||||
is handled automatically by LiteLLM.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError
|
||||
from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage
|
||||
from hindsight_api.metrics import get_metrics_collector
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LiteLLMLLM(LLMInterface):
|
||||
"""
|
||||
LLM provider using the LiteLLM SDK for universal model support.
|
||||
|
||||
Supports any model accessible via litellm.acompletion(), including AWS Bedrock,
|
||||
Azure OpenAI, Together AI, Fireworks AI, and more.
|
||||
|
||||
Model names follow LiteLLM conventions with provider prefixes:
|
||||
- bedrock/anthropic.claude-3-5-sonnet-20241022-v2:0
|
||||
- azure/gpt-4o
|
||||
- together_ai/meta-llama/Llama-3-70b-chat-hf
|
||||
- fireworks_ai/accounts/fireworks/models/llama-v3p1-70b-instruct
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
reasoning_effort: str = "low",
|
||||
timeout: float = 300.0,
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
|
||||
self.timeout = timeout
|
||||
self._litellm: Any = None
|
||||
|
||||
try:
|
||||
import litellm
|
||||
|
||||
self._litellm = litellm
|
||||
# Suppress LiteLLM's verbose logging
|
||||
litellm.suppress_debug_info = True # type: ignore[assignment]
|
||||
# Drop unsupported params instead of raising errors (e.g. tool_choice on some Bedrock models)
|
||||
litellm.drop_params = True # type: ignore[assignment]
|
||||
logging.getLogger("LiteLLM").setLevel(logging.WARNING)
|
||||
logger.info(f"LiteLLM SDK initialized for model: {self.model}")
|
||||
except ImportError as e:
|
||||
raise RuntimeError("LiteLLM SDK not installed. Run: uv add litellm or pip install litellm") from e
|
||||
|
||||
async def verify_connection(self) -> None:
|
||||
try:
|
||||
test_messages = [{"role": "user", "content": "test"}]
|
||||
await self.call(
|
||||
messages=test_messages,
|
||||
max_completion_tokens=50,
|
||||
temperature=0.0,
|
||||
scope="verification",
|
||||
max_retries=0,
|
||||
)
|
||||
logger.info("LiteLLM connection verified successfully")
|
||||
except OutputTooLongError:
|
||||
# Truncation is fine for verification — it means the connection works
|
||||
logger.info("LiteLLM connection verified successfully (response truncated)")
|
||||
except Exception as e:
|
||||
logger.error(f"LiteLLM connection verification failed: {e}")
|
||||
raise RuntimeError(f"Failed to verify LiteLLM connection: {e}") from e
|
||||
|
||||
def _build_common_kwargs(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
max_completion_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build common kwargs for litellm calls."""
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"timeout": self.timeout,
|
||||
}
|
||||
|
||||
if self.api_key:
|
||||
kwargs["api_key"] = self.api_key
|
||||
if self.base_url:
|
||||
kwargs["api_base"] = self.base_url
|
||||
if max_completion_tokens is not None:
|
||||
kwargs["max_completion_tokens"] = max_completion_tokens
|
||||
if temperature is not None:
|
||||
kwargs["temperature"] = temperature
|
||||
|
||||
return kwargs
|
||||
|
||||
async def call(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
response_format: Any | None = None,
|
||||
max_completion_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
scope: str = "memory",
|
||||
max_retries: int = 10,
|
||||
initial_backoff: float = 1.0,
|
||||
max_backoff: float = 60.0,
|
||||
skip_validation: bool = False,
|
||||
strict_schema: bool = False,
|
||||
return_usage: bool = False,
|
||||
) -> Any:
|
||||
start_time = time.time()
|
||||
|
||||
call_kwargs = self._build_common_kwargs(messages, max_completion_tokens, temperature)
|
||||
|
||||
# Add JSON schema response format if provided
|
||||
if response_format is not None and hasattr(response_format, "model_json_schema"):
|
||||
schema = response_format.model_json_schema()
|
||||
call_kwargs["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": response_format.__name__ if hasattr(response_format, "__name__") else "response",
|
||||
"schema": schema,
|
||||
"strict": strict_schema,
|
||||
},
|
||||
}
|
||||
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
response = await self._litellm.acompletion(**call_kwargs)
|
||||
|
||||
content = response.choices[0].message.content or ""
|
||||
finish_reason = response.choices[0].finish_reason
|
||||
|
||||
# Check for length-limited output
|
||||
if finish_reason == "length":
|
||||
raise OutputTooLongError("LiteLLM response was truncated due to token limit")
|
||||
|
||||
if response_format is not None:
|
||||
# Strip markdown code fences if present
|
||||
clean_content = content
|
||||
if "```json" in content:
|
||||
clean_content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
clean_content = content.split("```")[1].split("```")[0].strip()
|
||||
|
||||
try:
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError:
|
||||
json_data = json.loads(content)
|
||||
|
||||
if skip_validation:
|
||||
result = json_data
|
||||
else:
|
||||
result = response_format.model_validate(json_data)
|
||||
else:
|
||||
result = content
|
||||
|
||||
# Extract usage
|
||||
input_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
|
||||
output_tokens = getattr(response.usage, "completion_tokens", 0) or 0
|
||||
total_tokens = input_tokens + output_tokens
|
||||
|
||||
# Record metrics
|
||||
duration = time.time() - start_time
|
||||
metrics = get_metrics_collector()
|
||||
metrics.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
duration=duration,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Record trace span
|
||||
from hindsight_api.tracing import _serialize_for_span, get_span_recorder
|
||||
|
||||
span_recorder = get_span_recorder()
|
||||
span_recorder.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
messages=messages,
|
||||
response_content=_serialize_for_span(result),
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
duration=duration,
|
||||
finish_reason=finish_reason,
|
||||
error=None,
|
||||
)
|
||||
|
||||
if duration > 10.0:
|
||||
logger.info(
|
||||
f"slow llm call: scope={scope}, model={self.provider}/{self.model}, "
|
||||
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
|
||||
f"time={duration:.3f}s"
|
||||
)
|
||||
|
||||
if return_usage:
|
||||
token_usage = TokenUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
return result, token_usage
|
||||
return result
|
||||
|
||||
except OutputTooLongError:
|
||||
raise
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
logger.warning("LiteLLM returned invalid JSON, retrying...")
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"LiteLLM returned invalid JSON after {max_retries + 1} attempts")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
# Fast fail on auth errors
|
||||
if "401" in error_str or "403" in error_str or "unauthorized" in error_str:
|
||||
logger.error(f"LiteLLM auth error, not retrying: {e}")
|
||||
raise
|
||||
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
# Retry on rate limits, connection errors, server errors
|
||||
is_retryable = any(
|
||||
keyword in error_str
|
||||
for keyword in ("rate", "limit", "timeout", "connection", "500", "502", "503", "529")
|
||||
)
|
||||
if is_retryable:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
|
||||
await asyncio.sleep(backoff + jitter)
|
||||
continue
|
||||
|
||||
logger.error(f"LiteLLM API error after {attempt + 1} attempts: {e}")
|
||||
raise
|
||||
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
raise RuntimeError("LiteLLM call failed after all retries")
|
||||
|
||||
async def call_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
max_completion_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
scope: str = "tools",
|
||||
max_retries: int = 5,
|
||||
initial_backoff: float = 1.0,
|
||||
max_backoff: float = 30.0,
|
||||
tool_choice: str | dict[str, Any] = "auto",
|
||||
) -> LLMToolCallResult:
|
||||
start_time = time.time()
|
||||
|
||||
call_kwargs = self._build_common_kwargs(messages, max_completion_tokens, temperature)
|
||||
call_kwargs["tools"] = tools
|
||||
call_kwargs["tool_choice"] = tool_choice
|
||||
|
||||
last_exception = None
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
response = await self._litellm.acompletion(**call_kwargs)
|
||||
|
||||
message = response.choices[0].message
|
||||
content = message.content
|
||||
finish_reason = response.choices[0].finish_reason
|
||||
|
||||
# Extract tool calls
|
||||
tool_calls: list[LLMToolCall] = []
|
||||
if message.tool_calls:
|
||||
for tc in message.tool_calls:
|
||||
arguments = tc.function.arguments
|
||||
if isinstance(arguments, str):
|
||||
arguments = json.loads(arguments)
|
||||
tool_calls.append(
|
||||
LLMToolCall(
|
||||
id=tc.id,
|
||||
name=tc.function.name,
|
||||
arguments=arguments,
|
||||
)
|
||||
)
|
||||
|
||||
# Extract usage
|
||||
input_tokens = getattr(response.usage, "prompt_tokens", 0) or 0
|
||||
output_tokens = getattr(response.usage, "completion_tokens", 0) or 0
|
||||
|
||||
# Record metrics
|
||||
duration = time.time() - start_time
|
||||
metrics = get_metrics_collector()
|
||||
metrics.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
duration=duration,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Record trace span
|
||||
from hindsight_api.tracing import get_span_recorder
|
||||
|
||||
span_recorder = get_span_recorder()
|
||||
tool_calls_dict = (
|
||||
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
|
||||
if tool_calls
|
||||
else None
|
||||
)
|
||||
span_recorder.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
messages=messages,
|
||||
response_content=content,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
duration=duration,
|
||||
finish_reason=finish_reason,
|
||||
error=None,
|
||||
tool_calls=tool_calls_dict,
|
||||
)
|
||||
|
||||
return LLMToolCallResult(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason or ("tool_calls" if tool_calls else "stop"),
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_str = str(e).lower()
|
||||
if "401" in error_str or "403" in error_str or "unauthorized" in error_str:
|
||||
raise
|
||||
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
is_retryable = any(
|
||||
keyword in error_str
|
||||
for keyword in ("rate", "limit", "timeout", "connection", "500", "502", "503", "529")
|
||||
)
|
||||
if is_retryable:
|
||||
await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff))
|
||||
continue
|
||||
|
||||
logger.error(f"LiteLLM tool call error after {attempt + 1} attempts: {e}")
|
||||
raise
|
||||
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
raise RuntimeError("LiteLLM tool call failed after all retries")
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
pass
|
||||
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
No-op LLM provider for chunk-only storage mode.
|
||||
|
||||
When the LLM provider is set to "none", the system operates without any LLM dependency.
|
||||
Retain uses chunks mode (no fact extraction), and reflect/consolidation are disabled.
|
||||
This provider acts as a safety net — if any code path unexpectedly tries to call the LLM,
|
||||
it raises a clear error instead of a confusing connection failure.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from ..llm_interface import LLMInterface
|
||||
from ..response_models import LLMToolCallResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMNotAvailableError(Exception):
|
||||
"""Raised when an operation requires an LLM but the provider is set to 'none'."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class NoneLLM(LLMInterface):
|
||||
"""
|
||||
No-op LLM provider that rejects all LLM calls.
|
||||
|
||||
Used when HINDSIGHT_API_LLM_PROVIDER=none to run Hindsight as a chunk store
|
||||
with semantic search but without LLM-based features (fact extraction, reflect,
|
||||
consolidation).
|
||||
"""
|
||||
|
||||
async def verify_connection(self) -> None:
|
||||
"""No-op — no LLM connection to verify."""
|
||||
logger.debug("NoneLLM: no LLM connection to verify (provider=none)")
|
||||
|
||||
async def call(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
response_format: Any | None = None,
|
||||
max_completion_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
scope: str = "memory",
|
||||
max_retries: int = 10,
|
||||
initial_backoff: float = 1.0,
|
||||
max_backoff: float = 60.0,
|
||||
skip_validation: bool = False,
|
||||
strict_schema: bool = False,
|
||||
return_usage: bool = False,
|
||||
) -> Any:
|
||||
"""Raise LLMNotAvailableError — no LLM is configured."""
|
||||
raise LLMNotAvailableError(
|
||||
"LLM provider is set to 'none'. This operation requires an LLM. "
|
||||
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
|
||||
)
|
||||
|
||||
async def call_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
max_completion_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
scope: str = "tools",
|
||||
max_retries: int = 5,
|
||||
initial_backoff: float = 1.0,
|
||||
max_backoff: float = 30.0,
|
||||
tool_choice: str | dict[str, Any] = "auto",
|
||||
) -> LLMToolCallResult:
|
||||
"""Raise LLMNotAvailableError — no LLM is configured."""
|
||||
raise LLMNotAvailableError(
|
||||
"LLM provider is set to 'none'. This operation requires an LLM. "
|
||||
"Set HINDSIGHT_API_LLM_PROVIDER to a real provider (e.g., openai, anthropic, gemini)."
|
||||
)
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""No-op — nothing to clean up."""
|
||||
pass
|
||||
@@ -6,7 +6,7 @@ This provider handles all OpenAI API-compatible models including:
|
||||
- Groq: Fast inference with seed control and service tiers
|
||||
- Ollama: Local models with native streaming API support
|
||||
- LMStudio: Local models with OpenAI-compatible API
|
||||
- MiniMax: MiniMax-M2.5 models with 204K context window
|
||||
- MiniMax: MiniMax-M2.7 models with 1M context window
|
||||
|
||||
Features:
|
||||
- Reasoning models with extended thinking (o1, o3, GPT-5 families)
|
||||
@@ -24,6 +24,7 @@ import os
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import parse_qs, urlparse, urlunparse
|
||||
|
||||
import httpx
|
||||
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
|
||||
@@ -39,6 +40,25 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_LLM_SEED = 4242
|
||||
|
||||
|
||||
def _strip_code_fences(content: str) -> str:
|
||||
"""Strip markdown code fences from LLM response if present.
|
||||
|
||||
Many LLM providers (MiniMax, some Ollama models, Claude via proxies)
|
||||
wrap JSON responses in ```json ... ``` fences even when json_object
|
||||
response format is requested. This strips the fences while preserving
|
||||
the JSON content inside. Returns the original content unchanged if
|
||||
no fences are detected.
|
||||
"""
|
||||
if "```" not in content:
|
||||
return content
|
||||
try:
|
||||
if "```json" in content:
|
||||
return content.split("```json")[1].split("```")[0].strip()
|
||||
return content.split("```")[1].split("```")[0].strip()
|
||||
except (IndexError, ValueError):
|
||||
return content
|
||||
|
||||
|
||||
class OpenAICompatibleLLM(LLMInterface):
|
||||
"""
|
||||
LLM provider for OpenAI-compatible APIs.
|
||||
@@ -48,7 +68,7 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
- Groq: Fast inference with seed control and service tiers
|
||||
- Ollama: Local models with native streaming API for better structured output
|
||||
- LMStudio: Local models with OpenAI-compatible API
|
||||
- MiniMax: MiniMax-M2.5 models via OpenAI-compatible API (https://api.minimax.io/v1)
|
||||
- MiniMax: MiniMax-M2.7 models via OpenAI-compatible API (https://api.minimax.io/v1)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -108,10 +128,18 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
# Get timeout config
|
||||
self.timeout = timeout or float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT)))
|
||||
|
||||
# Create OpenAI client
|
||||
# Create OpenAI client — extract query params from base_url (e.g. Azure api-version)
|
||||
client_kwargs: dict[str, Any] = {"api_key": self.api_key, "max_retries": 0}
|
||||
if self.base_url:
|
||||
client_kwargs["base_url"] = self.base_url
|
||||
parsed = urlparse(self.base_url)
|
||||
if parsed.query:
|
||||
clean_url = urlunparse(parsed._replace(query=""))
|
||||
client_kwargs["base_url"] = clean_url
|
||||
default_query = {k: v[0] for k, v in parse_qs(parsed.query).items()}
|
||||
client_kwargs["default_query"] = default_query
|
||||
self.base_url = clean_url
|
||||
else:
|
||||
client_kwargs["base_url"] = self.base_url
|
||||
if self.timeout:
|
||||
client_kwargs["timeout"] = self.timeout
|
||||
|
||||
@@ -313,20 +341,14 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
if len(content) < original_len:
|
||||
logger.debug(f"Stripped {original_len - len(content)} chars of reasoning tokens")
|
||||
|
||||
# For local models, they may wrap JSON in markdown code blocks
|
||||
if self.provider in ("lmstudio", "ollama"):
|
||||
clean_content = content
|
||||
if "```json" in content:
|
||||
clean_content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
clean_content = content.split("```")[1].split("```")[0].strip()
|
||||
try:
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to parsing raw content
|
||||
json_data = json.loads(content)
|
||||
else:
|
||||
# Log raw LLM response for debugging JSON parse issues
|
||||
# Strip markdown code fences if present — any provider may
|
||||
# produce these (confirmed with MiniMax, some Ollama models,
|
||||
# Claude via proxies). No-op when content is already bare JSON.
|
||||
clean_content = _strip_code_fences(content)
|
||||
try:
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to parsing raw content in case stripping was wrong
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
except json.JSONDecodeError as json_err:
|
||||
@@ -721,26 +743,33 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
result = response.json()
|
||||
content = result.get("message", {}).get("content", "")
|
||||
|
||||
# Parse JSON response
|
||||
# Strip markdown code fences if present (safety net —
|
||||
# Ollama with schema enforcement usually returns bare JSON,
|
||||
# but some models may still wrap in fences)
|
||||
clean_content = _strip_code_fences(content)
|
||||
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
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to raw content
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
except json.JSONDecodeError as json_err:
|
||||
content_preview = content[:500] if content else "<empty>"
|
||||
if content and len(content) > 700:
|
||||
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
|
||||
logger.warning(
|
||||
f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
|
||||
f" Model: ollama/{self.model}\n"
|
||||
f" Content length: {len(content) if content else 0} chars\n"
|
||||
f" Content preview: {content_preview!r}"
|
||||
)
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
last_exception = json_err
|
||||
continue
|
||||
else:
|
||||
raise
|
||||
|
||||
# Extract token usage from Ollama response
|
||||
duration = time.time() - start_time
|
||||
|
||||
@@ -316,6 +316,8 @@ async def run_reflect_agent(
|
||||
response_schema: dict | None = None,
|
||||
directives: list[dict[str, Any]] | None = None,
|
||||
has_mental_models: bool = False,
|
||||
include_observations: bool = True,
|
||||
include_recall: bool = True,
|
||||
budget: str | None = None,
|
||||
max_context_tokens: int = 100_000,
|
||||
) -> ReflectAgentResult:
|
||||
@@ -355,7 +357,14 @@ async def run_reflect_agent(
|
||||
directive_rules = _extract_directive_rules(directives) if directives else None
|
||||
|
||||
# Get tools for this agent (with directive compliance field if directives exist)
|
||||
tools = get_reflect_tools(directive_rules=directive_rules)
|
||||
tools = get_reflect_tools(
|
||||
directive_rules=directive_rules,
|
||||
include_mental_models=has_mental_models,
|
||||
include_observations=include_observations,
|
||||
include_recall=include_recall,
|
||||
)
|
||||
# Build set of enabled tool names to guard against LLM hallucinating disabled tool calls
|
||||
enabled_tools: frozenset[str] = frozenset(t["function"]["name"] for t in tools if t.get("type") == "function")
|
||||
|
||||
# Build initial messages (directives are injected into system prompt at START and END)
|
||||
system_prompt = build_system_prompt_for_tools(
|
||||
@@ -538,19 +547,18 @@ async def run_reflect_agent(
|
||||
llm_start = time.time()
|
||||
|
||||
# Determine tool_choice for this iteration.
|
||||
# Force the full hierarchical retrieval path before allowing auto:
|
||||
# With mental models:
|
||||
# 0 → search_mental_models, 1 → search_observations, 2 → recall, 3+ → auto
|
||||
# Without mental models:
|
||||
# 0 → search_observations, 1 → recall, 2+ → auto
|
||||
if iteration == 0 and has_mental_models:
|
||||
iter_tool_choice: str | dict = {"type": "function", "function": {"name": "search_mental_models"}}
|
||||
elif iteration == 0:
|
||||
iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}}
|
||||
elif iteration == 1 and has_mental_models:
|
||||
iter_tool_choice = {"type": "function", "function": {"name": "search_observations"}}
|
||||
elif iteration == 1 or (iteration == 2 and has_mental_models):
|
||||
iter_tool_choice = {"type": "function", "function": {"name": "recall"}}
|
||||
# Force the full hierarchical retrieval path (only for enabled tools) before allowing auto.
|
||||
# Build the forced sequence from the tools that are actually enabled.
|
||||
forced_sequence = []
|
||||
if has_mental_models:
|
||||
forced_sequence.append("search_mental_models")
|
||||
if include_observations:
|
||||
forced_sequence.append("search_observations")
|
||||
if include_recall:
|
||||
forced_sequence.append("recall")
|
||||
|
||||
if iteration < len(forced_sequence):
|
||||
iter_tool_choice: str | dict = {"type": "function", "function": {"name": forced_sequence[iteration]}}
|
||||
else:
|
||||
iter_tool_choice = "auto"
|
||||
|
||||
@@ -769,7 +777,17 @@ async def run_reflect_agent(
|
||||
# Execute other tools in parallel (exclude done tool in all its format variants)
|
||||
other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)]
|
||||
if other_tools:
|
||||
# Add assistant message with tool calls
|
||||
# Partition into enabled vs hallucinated (not in enabled_tools set)
|
||||
allowed_tools = []
|
||||
hallucinated_tools = []
|
||||
for tc in other_tools:
|
||||
norm = _normalize_tool_name(tc.name)
|
||||
if enabled_tools is not None and norm not in enabled_tools and norm not in ("done", "expand"):
|
||||
hallucinated_tools.append(tc)
|
||||
else:
|
||||
allowed_tools.append(tc)
|
||||
|
||||
# Build assistant message with all tool calls (LLM requires them for history)
|
||||
messages.append(
|
||||
{
|
||||
"role": "assistant",
|
||||
@@ -777,6 +795,23 @@ async def run_reflect_agent(
|
||||
}
|
||||
)
|
||||
|
||||
# Immediately reject hallucinated tool calls without adding to trace
|
||||
for tc in hallucinated_tools:
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"name": tc.name,
|
||||
"content": json.dumps(
|
||||
{
|
||||
"error": f"Tool '{_normalize_tool_name(tc.name)}' is not available. Use only the tools provided to you."
|
||||
}
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
other_tools = allowed_tools
|
||||
|
||||
# Execute tools in parallel
|
||||
tool_tasks = [
|
||||
_execute_tool_with_timing(
|
||||
@@ -785,6 +820,7 @@ async def run_reflect_agent(
|
||||
search_observations_fn,
|
||||
recall_fn,
|
||||
expand_fn,
|
||||
enabled_tools=enabled_tools,
|
||||
)
|
||||
for tc in other_tools
|
||||
]
|
||||
@@ -895,7 +931,7 @@ async def run_reflect_agent(
|
||||
|
||||
def _tool_call_to_dict(tc: "LLMToolCall") -> dict[str, Any]:
|
||||
"""Convert LLMToolCall to OpenAI message format."""
|
||||
return {
|
||||
d: dict[str, Any] = {
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -903,6 +939,9 @@ def _tool_call_to_dict(tc: "LLMToolCall") -> dict[str, Any]:
|
||||
"arguments": json.dumps(tc.arguments),
|
||||
},
|
||||
}
|
||||
if tc.thought_signature is not None:
|
||||
d["thought_signature"] = tc.thought_signature
|
||||
return d
|
||||
|
||||
|
||||
async def _process_done_tool(
|
||||
@@ -971,6 +1010,7 @@ async def _execute_tool_with_timing(
|
||||
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
|
||||
recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]],
|
||||
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
|
||||
enabled_tools: frozenset[str] | None = None,
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
"""Execute a tool call and return result with timing."""
|
||||
from hindsight_api.tracing import get_tracer
|
||||
@@ -1004,6 +1044,7 @@ async def _execute_tool_with_timing(
|
||||
search_observations_fn,
|
||||
recall_fn,
|
||||
expand_fn,
|
||||
enabled_tools=enabled_tools,
|
||||
)
|
||||
|
||||
# Set success attributes
|
||||
@@ -1043,11 +1084,16 @@ async def _execute_tool(
|
||||
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
|
||||
recall_fn: Callable[[str, int, int], Awaitable[dict[str, Any]]],
|
||||
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
|
||||
enabled_tools: frozenset[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a single tool by name."""
|
||||
# Normalize tool name for various LLM output formats
|
||||
tool_name = _normalize_tool_name(tool_name)
|
||||
|
||||
# Guard against LLMs hallucinating calls to tools that were not provided
|
||||
if enabled_tools is not None and tool_name not in enabled_tools and tool_name not in ("done", "expand"):
|
||||
return {"error": f"Tool '{tool_name}' is not available. Use only the tools provided to you."}
|
||||
|
||||
if tool_name == "search_mental_models":
|
||||
query = args.get("query")
|
||||
if not query:
|
||||
|
||||
@@ -29,6 +29,7 @@ async def tool_search_mental_models(
|
||||
max_results: int = 5,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: str = "any",
|
||||
tag_groups: "list | None" = None,
|
||||
exclude_ids: list[str] | None = None,
|
||||
pending_consolidation: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
@@ -52,7 +53,7 @@ async def tool_search_mental_models(
|
||||
Dict with matching mental models including content and freshness info
|
||||
"""
|
||||
from ..memory_engine import fq_table
|
||||
from ..search.tags import build_tags_where_clause
|
||||
from ..search.tags import build_tag_groups_where_clause, build_tags_where_clause
|
||||
|
||||
# Build filters dynamically
|
||||
filters = ""
|
||||
@@ -65,6 +66,11 @@ async def tool_search_mental_models(
|
||||
filters += f" {tag_clause}"
|
||||
params.extend(tag_params)
|
||||
|
||||
if tag_groups:
|
||||
groups_clause, groups_params, next_param = build_tag_groups_where_clause(tag_groups, next_param)
|
||||
filters += f" {groups_clause}"
|
||||
params.extend(groups_params)
|
||||
|
||||
if exclude_ids:
|
||||
filters += f" AND id != ALL(${next_param}::text[])"
|
||||
params.append(exclude_ids)
|
||||
@@ -125,11 +131,13 @@ async def tool_search_observations(
|
||||
max_tokens: int = 5000,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: str = "any",
|
||||
tag_groups: "list | None" = None,
|
||||
last_consolidated_at: datetime | None = None,
|
||||
pending_consolidation: int = 0,
|
||||
source_facts_max_tokens: int = -1,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Search consolidated observations using recall with include_source_facts.
|
||||
Search consolidated observations using recall.
|
||||
|
||||
Observations are auto-generated from memories. Returns freshness info
|
||||
so the agent knows if it should also verify with recall().
|
||||
@@ -144,10 +152,16 @@ async def tool_search_observations(
|
||||
tags_match: How to match tags - "any" (OR), "all" (AND)
|
||||
last_consolidated_at: When consolidation last ran (for staleness check)
|
||||
pending_consolidation: Number of memories waiting to be consolidated
|
||||
source_facts_max_tokens: Token budget for source facts (-1 = disabled, 0+ = enabled with limit)
|
||||
|
||||
Returns:
|
||||
Dict with matching observations including freshness info and source memories
|
||||
"""
|
||||
include_source_facts = source_facts_max_tokens != -1
|
||||
recall_kwargs: dict[str, Any] = {}
|
||||
if include_source_facts and source_facts_max_tokens > 0:
|
||||
recall_kwargs["max_source_facts_tokens"] = source_facts_max_tokens
|
||||
|
||||
result = await memory_engine.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
@@ -157,10 +171,11 @@ async def tool_search_observations(
|
||||
request_context=request_context,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
include_source_facts=True,
|
||||
max_source_facts_tokens=-1, # No token limit — include all source facts
|
||||
tag_groups=tag_groups,
|
||||
include_source_facts=include_source_facts,
|
||||
_connection_budget=1,
|
||||
_quiet=True,
|
||||
**recall_kwargs,
|
||||
)
|
||||
|
||||
is_stale = pending_consolidation > 0
|
||||
@@ -189,8 +204,10 @@ async def tool_recall(
|
||||
max_tokens: int = 2048,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: str = "any",
|
||||
tag_groups: "list | None" = None,
|
||||
connection_budget: int = 1,
|
||||
max_chunk_tokens: int = 1000,
|
||||
fact_types: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Search memories using TEMPR retrieval.
|
||||
@@ -208,20 +225,24 @@ async def tool_recall(
|
||||
tags_match: How to match tags - "any" (OR), "all" (AND), or "exact"
|
||||
connection_budget: Max DB connections for this recall (default 1 for internal ops)
|
||||
max_chunk_tokens: Maximum tokens for raw source chunk text (default 1000, always included)
|
||||
fact_types: Optional filter for fact types to retrieve. Defaults to ["experience", "world"].
|
||||
|
||||
Returns:
|
||||
Dict with list of matching memories including raw chunk text
|
||||
"""
|
||||
# Only world/experience are valid for raw recall (observation is handled by search_observations)
|
||||
recall_fact_type = [ft for ft in (fact_types or ["experience", "world"]) if ft in ("world", "experience")]
|
||||
include_chunks = True
|
||||
result = await memory_engine.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
fact_type=["experience", "world"],
|
||||
fact_type=recall_fact_type,
|
||||
max_tokens=max_tokens,
|
||||
enable_trace=False,
|
||||
request_context=request_context,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
_connection_budget=connection_budget,
|
||||
_quiet=True, # Suppress logging for internal operations
|
||||
include_chunks=include_chunks,
|
||||
|
||||
@@ -227,7 +227,12 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
|
||||
def get_reflect_tools(
|
||||
directive_rules: list[str] | None = None,
|
||||
include_mental_models: bool = True,
|
||||
include_observations: bool = True,
|
||||
include_recall: bool = True,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get the list of tools for the reflect agent.
|
||||
|
||||
@@ -239,16 +244,23 @@ def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
|
||||
Args:
|
||||
directive_rules: Optional list of directive rule strings. If provided,
|
||||
the done() tool will require directive compliance confirmation.
|
||||
include_mental_models: Whether to include the search_mental_models tool.
|
||||
include_observations: Whether to include the search_observations tool.
|
||||
include_recall: Whether to include the recall tool.
|
||||
|
||||
Returns:
|
||||
List of tool definitions in OpenAI format
|
||||
"""
|
||||
tools = [
|
||||
TOOL_SEARCH_MENTAL_MODELS,
|
||||
TOOL_SEARCH_OBSERVATIONS,
|
||||
TOOL_RECALL,
|
||||
TOOL_EXPAND,
|
||||
]
|
||||
tools = []
|
||||
|
||||
if include_mental_models:
|
||||
tools.append(TOOL_SEARCH_MENTAL_MODELS)
|
||||
if include_observations:
|
||||
tools.append(TOOL_SEARCH_OBSERVATIONS)
|
||||
if include_recall:
|
||||
tools.append(TOOL_RECALL)
|
||||
|
||||
tools.append(TOOL_EXPAND)
|
||||
|
||||
# Use directive-aware done tool if directives are present
|
||||
if directive_rules:
|
||||
|
||||
@@ -8,7 +8,7 @@ API stability even if internal models change.
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
# Valid fact types for recall operations (excludes 'opinion' which is deprecated)
|
||||
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "observation"])
|
||||
@@ -20,6 +20,10 @@ class LLMToolCall(BaseModel):
|
||||
id: str = Field(description="Unique identifier for this tool call")
|
||||
name: str = Field(description="Name of the tool to call")
|
||||
arguments: dict[str, Any] = Field(description="Arguments to pass to the tool")
|
||||
thought_signature: str | None = Field(
|
||||
default=None,
|
||||
description="Opaque token required by Gemini 3.1+ thinking models to preserve thought context across turns",
|
||||
)
|
||||
|
||||
|
||||
class LLMToolCallResult(BaseModel):
|
||||
@@ -155,6 +159,19 @@ class MemoryFact(BaseModel):
|
||||
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")
|
||||
|
||||
@field_validator("metadata", mode="before")
|
||||
@classmethod
|
||||
def parse_metadata(cls, v: Any) -> dict[str, str] | None:
|
||||
"""Parse metadata from JSON string if needed (asyncpg may return JSONB as str)."""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, str):
|
||||
import json
|
||||
|
||||
return json.loads(v)
|
||||
return v
|
||||
|
||||
chunk_id: str | None = Field(
|
||||
None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)"
|
||||
)
|
||||
|
||||
@@ -332,6 +332,43 @@ class FactExtractionResponseNoCausal(BaseModel):
|
||||
facts: list[ExtractedFactNoCausal] = Field(description="List of extracted factual statements")
|
||||
|
||||
|
||||
class VerbatimExtractedFact(BaseModel):
|
||||
"""
|
||||
Schema for verbatim extraction mode.
|
||||
|
||||
Omits 'what' entirely — the original chunk text is used as fact_text in code.
|
||||
The LLM only extracts metadata: entities, temporal info, location, people.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_mode="validation",
|
||||
json_schema_extra={"required": ["when", "where", "who", "fact_type"]},
|
||||
)
|
||||
|
||||
when: str = Field(description="When it happened. 'N/A' if unknown.")
|
||||
where: str = Field(description="Location if relevant. 'N/A' if none.")
|
||||
who: str = Field(description="People involved with relationships. 'N/A' if general.")
|
||||
|
||||
fact_kind: str = Field(default="conversation", description="'event' or 'conversation'")
|
||||
occurred_start: str | None = Field(default=None, description="ISO timestamp for events")
|
||||
occurred_end: str | None = Field(default=None, description="ISO timestamp for event end")
|
||||
fact_type: Literal["world", "assistant"] = Field(description="'world' or 'assistant'")
|
||||
entities: list[Entity] | None = Field(default=None, description="People, places, concepts")
|
||||
|
||||
@field_validator("entities", mode="before")
|
||||
@classmethod
|
||||
def ensure_entities_list(cls, v):
|
||||
if v is None:
|
||||
return []
|
||||
return v
|
||||
|
||||
|
||||
class VerbatimFactExtractionResponse(BaseModel):
|
||||
"""Response for verbatim extraction mode (one entry per chunk, no fact text)."""
|
||||
|
||||
facts: list[VerbatimExtractedFact] = Field(description="List of metadata entries (one per chunk)")
|
||||
|
||||
|
||||
def chunk_text(text: str, max_chars: int) -> list[str]:
|
||||
"""
|
||||
Split text into chunks, preserving conversation structure when possible.
|
||||
@@ -552,6 +589,27 @@ CUSTOM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
|
||||
examples="", # No examples for custom mode
|
||||
)
|
||||
|
||||
# Verbatim mode: preserve the original text exactly, but still extract metadata
|
||||
_VERBATIM_GUIDELINES = """══════════════════════════════════════════════════════════════════════════
|
||||
VERBATIM MODE — Extract metadata only
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
The original text will be stored as-is in code. Your ONLY job is to extract metadata.
|
||||
|
||||
RULES:
|
||||
- Produce EXACTLY ONE entry per input chunk.
|
||||
- DO NOT include a "what" field — it is not part of the output schema.
|
||||
- Extract all entities (people, places, organizations, objects, concepts).
|
||||
- Extract temporal information (occurred_start, occurred_end, fact_kind, when).
|
||||
- Extract location (where) and people (who).
|
||||
- fact_type: use "world" unless the content is clearly an interaction with the assistant."""
|
||||
|
||||
VERBATIM_FACT_EXTRACTION_PROMPT = _BASE_FACT_EXTRACTION_PROMPT.format(
|
||||
retain_mission_section="{retain_mission_section}",
|
||||
extraction_guidelines=_VERBATIM_GUIDELINES,
|
||||
examples="",
|
||||
)
|
||||
|
||||
|
||||
# Verbose extraction prompt - detailed, comprehensive facts (legacy mode)
|
||||
VERBOSE_FACT_EXTRACTION_PROMPT = """Extract facts from text into structured format with FIVE required dimensions - BE EXTREMELY DETAILED.
|
||||
@@ -770,6 +828,10 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
|
||||
)
|
||||
elif extraction_mode == "verbose":
|
||||
prompt = VERBOSE_FACT_EXTRACTION_PROMPT
|
||||
elif extraction_mode == "verbatim":
|
||||
prompt = VERBATIM_FACT_EXTRACTION_PROMPT.format(
|
||||
retain_mission_section=retain_mission_section,
|
||||
)
|
||||
else:
|
||||
base_prompt = CONCISE_FACT_EXTRACTION_PROMPT
|
||||
prompt = base_prompt.format(
|
||||
@@ -777,7 +839,11 @@ def _build_extraction_prompt_and_schema(config) -> tuple[str, type]:
|
||||
)
|
||||
|
||||
# Add causal relationships section if enabled
|
||||
if extract_causal_links:
|
||||
# Verbatim mode never uses causal relations (no fact text to relate causally)
|
||||
if extraction_mode == "verbatim":
|
||||
base_fact_class = VerbatimExtractedFact
|
||||
base_response_class = VerbatimFactExtractionResponse
|
||||
elif extract_causal_links:
|
||||
prompt = prompt + CAUSAL_RELATIONSHIPS_SECTION
|
||||
base_fact_class = ExtractedFactVerbose if extraction_mode == "verbose" else ExtractedFact
|
||||
base_response_class = FactExtractionResponseVerbose if extraction_mode == "verbose" else FactExtractionResponse
|
||||
@@ -1012,33 +1078,21 @@ async def _extract_facts_from_chunk(
|
||||
if not what:
|
||||
what = get_value("factual_core")
|
||||
if not what:
|
||||
logger.warning(f"Skipping fact {i}: missing 'what' field")
|
||||
continue
|
||||
# In verbatim mode, 'what' is intentionally absent — text is backfilled from chunk
|
||||
if extraction_mode != "verbatim":
|
||||
logger.warning(f"Skipping fact {i}: missing 'what' field")
|
||||
continue
|
||||
|
||||
# Critical field: fact_type
|
||||
# LLM uses "assistant" but we convert to "experience" for storage
|
||||
original_fact_type = llm_fact.get("fact_type")
|
||||
fact_type = original_fact_type
|
||||
|
||||
# Convert "assistant" → "experience" for storage
|
||||
if fact_type == "assistant":
|
||||
# Critical field: fact_type — "assistant" maps to "experience", everything else is "world".
|
||||
# If fact_type is unexpected, fall back to fact_kind before defaulting to "world".
|
||||
raw_fact_type = llm_fact.get("fact_type")
|
||||
if raw_fact_type == "assistant":
|
||||
fact_type = "experience"
|
||||
|
||||
# Validate fact_type (after conversion)
|
||||
if fact_type not in ["world", "experience", "opinion"]:
|
||||
# Try to fix common mistakes - check if they swapped fact_type and fact_kind
|
||||
fact_kind = llm_fact.get("fact_kind")
|
||||
if fact_kind == "assistant":
|
||||
fact_type = "experience"
|
||||
elif fact_kind in ["world", "experience", "opinion"]:
|
||||
fact_type = fact_kind
|
||||
else:
|
||||
# Default to 'world' if we can't determine
|
||||
fact_type = "world"
|
||||
logger.warning(
|
||||
f"Fact {i}: defaulting to fact_type='world' "
|
||||
f"(original fact_type={original_fact_type!r}, fact_kind={fact_kind!r})"
|
||||
)
|
||||
elif raw_fact_type == "world":
|
||||
fact_type = "world"
|
||||
else:
|
||||
raw_fact_kind = llm_fact.get("fact_kind")
|
||||
fact_type = "experience" if raw_fact_kind == "assistant" else "world"
|
||||
|
||||
# Get fact_kind for temporal handling (but don't store it)
|
||||
fact_kind = llm_fact.get("fact_kind", "conversation")
|
||||
@@ -1046,19 +1100,23 @@ async def _extract_facts_from_chunk(
|
||||
fact_kind = "conversation"
|
||||
|
||||
# Build combined fact text from the 4 dimensions: what | when | who | why
|
||||
# In verbatim mode, leave combined_text empty — _collapse_to_verbatim backfills it
|
||||
fact_data = {}
|
||||
combined_parts = [what]
|
||||
if extraction_mode == "verbatim":
|
||||
combined_text = ""
|
||||
else:
|
||||
combined_parts = [what]
|
||||
|
||||
if when:
|
||||
combined_parts.append(f"When: {when}")
|
||||
if when:
|
||||
combined_parts.append(f"When: {when}")
|
||||
|
||||
if who:
|
||||
combined_parts.append(f"Involving: {who}")
|
||||
if who:
|
||||
combined_parts.append(f"Involving: {who}")
|
||||
|
||||
if why:
|
||||
combined_parts.append(why)
|
||||
if why:
|
||||
combined_parts.append(why)
|
||||
|
||||
combined_text = " | ".join(combined_parts)
|
||||
combined_text = " | ".join(combined_parts)
|
||||
|
||||
# Add temporal fields
|
||||
# For events: occurred_start/occurred_end (when the event happened)
|
||||
@@ -1682,23 +1740,17 @@ async def extract_facts_from_contents_batch_api(
|
||||
who = get_value("who")
|
||||
why = get_value("why")
|
||||
|
||||
# Critical field: fact_type
|
||||
original_fact_type = llm_fact.get("fact_type")
|
||||
fact_type = original_fact_type
|
||||
|
||||
# Convert "assistant" → "experience"
|
||||
if fact_type == "assistant":
|
||||
# Critical field: fact_type — only "assistant" maps to "experience", everything else is "world"
|
||||
# Critical field: fact_type — "assistant" maps to "experience", everything else is "world".
|
||||
# If fact_type is unexpected, fall back to fact_kind before defaulting to "world".
|
||||
raw_fact_type = llm_fact.get("fact_type")
|
||||
if raw_fact_type == "assistant":
|
||||
fact_type = "experience"
|
||||
|
||||
# Validate fact_type
|
||||
if fact_type not in ["world", "experience", "opinion"]:
|
||||
fact_kind = llm_fact.get("fact_kind")
|
||||
if fact_kind == "assistant":
|
||||
fact_type = "experience"
|
||||
elif fact_kind in ["world", "experience", "opinion"]:
|
||||
fact_type = fact_kind
|
||||
else:
|
||||
fact_type = "world"
|
||||
elif raw_fact_type == "world":
|
||||
fact_type = "world"
|
||||
else:
|
||||
raw_fact_kind = llm_fact.get("fact_kind")
|
||||
fact_type = "experience" if raw_fact_kind == "assistant" else "world"
|
||||
|
||||
# Build combined fact text
|
||||
combined_parts = [what]
|
||||
@@ -1861,7 +1913,7 @@ async def extract_facts_from_contents_batch_api(
|
||||
for fact_from_llm in chunk_facts:
|
||||
extracted_fact = ExtractedFactType(
|
||||
fact_text=fact_from_llm.fact,
|
||||
fact_type=fact_from_llm.fact_type,
|
||||
fact_type="experience" if fact_from_llm.fact_type == "assistant" else "world",
|
||||
entities=[e.text for e in (fact_from_llm.entities or [])],
|
||||
occurred_start=_parse_datetime(fact_from_llm.occurred_start) if fact_from_llm.occurred_start else None,
|
||||
occurred_end=_parse_datetime(fact_from_llm.occurred_end) if fact_from_llm.occurred_end else None,
|
||||
@@ -1889,6 +1941,52 @@ async def extract_facts_from_contents_batch_api(
|
||||
return extracted_facts, chunks_metadata, total_usage
|
||||
|
||||
|
||||
def _extract_facts_chunks(
|
||||
contents: list[RetainContent],
|
||||
config,
|
||||
) -> tuple[list[ExtractedFactType], list[ChunkMetadata], TokenUsage]:
|
||||
"""
|
||||
chunks mode: no LLM call, no entity extraction.
|
||||
|
||||
Each chunk becomes one memory unit with the raw text as fact_text.
|
||||
User-provided entities from RetainContent.entities are picked up downstream
|
||||
by entity_processing.py — they are the sole source of entity data in this mode.
|
||||
"""
|
||||
extracted_facts: list[ExtractedFactType] = []
|
||||
chunks_metadata: list[ChunkMetadata] = []
|
||||
global_chunk_idx = 0
|
||||
|
||||
for content_index, content in enumerate(contents):
|
||||
chunks = chunk_text(content.content, config.retain_chunk_size)
|
||||
for chunk in chunks:
|
||||
chunks_metadata.append(
|
||||
ChunkMetadata(
|
||||
chunk_text=chunk,
|
||||
fact_count=1,
|
||||
content_index=content_index,
|
||||
chunk_index=global_chunk_idx,
|
||||
)
|
||||
)
|
||||
extracted_facts.append(
|
||||
ExtractedFactType(
|
||||
fact_text=chunk,
|
||||
fact_type="world",
|
||||
entities=[],
|
||||
content_index=content_index,
|
||||
chunk_index=global_chunk_idx,
|
||||
context=content.context,
|
||||
mentioned_at=content.event_date,
|
||||
metadata=content.metadata,
|
||||
tags=content.tags,
|
||||
observation_scopes=content.observation_scopes,
|
||||
)
|
||||
)
|
||||
global_chunk_idx += 1
|
||||
|
||||
_add_temporal_offsets(extracted_facts, contents)
|
||||
return extracted_facts, chunks_metadata, TokenUsage()
|
||||
|
||||
|
||||
async def extract_facts_from_contents(
|
||||
contents: list[RetainContent],
|
||||
llm_config,
|
||||
@@ -1924,6 +2022,11 @@ async def extract_facts_from_contents(
|
||||
if not contents:
|
||||
return [], [], TokenUsage()
|
||||
|
||||
# chunks mode: skip LLM entirely, store each chunk as-is
|
||||
# Must come before the batch-API check so no LLM queue/locks are acquired
|
||||
if config.retain_extraction_mode == "chunks":
|
||||
return _extract_facts_chunks(contents, config)
|
||||
|
||||
# Route to batch API if enabled
|
||||
if config.retain_batch_enabled:
|
||||
return await extract_facts_from_contents_batch_api(
|
||||
@@ -1987,7 +2090,7 @@ async def extract_facts_from_contents(
|
||||
# mentioned_at is always the event_date (when the conversation/document occurred)
|
||||
extracted_fact = ExtractedFactType(
|
||||
fact_text=fact_from_llm.fact,
|
||||
fact_type=fact_from_llm.fact_type,
|
||||
fact_type="experience" if fact_from_llm.fact_type == "assistant" else "world",
|
||||
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)
|
||||
@@ -2013,15 +2116,46 @@ async def extract_facts_from_contents(
|
||||
global_fact_idx += 1
|
||||
fact_idx_in_content += 1
|
||||
|
||||
# Step 4: Add time offsets to preserve ordering within each content
|
||||
# Step 4: For verbatim mode, collapse to one fact per chunk with original text
|
||||
if config.retain_extraction_mode == "verbatim":
|
||||
extracted_facts = _collapse_to_verbatim(extracted_facts, chunks_metadata)
|
||||
|
||||
# Step 5: Add time offsets to preserve ordering within each content
|
||||
_add_temporal_offsets(extracted_facts, contents)
|
||||
|
||||
# Step 5: Auto-tag facts from label groups with tag=True
|
||||
# Step 6: Auto-tag facts from label groups with tag=True
|
||||
_inject_label_tags(extracted_facts, config)
|
||||
|
||||
return extracted_facts, chunks_metadata, total_usage
|
||||
|
||||
|
||||
def _collapse_to_verbatim(facts: list[ExtractedFactType], chunks: list[ChunkMetadata]) -> list[ExtractedFactType]:
|
||||
"""
|
||||
For verbatim mode: ensure one fact per chunk with the original chunk text preserved.
|
||||
|
||||
The LLM prompt asks for exactly one fact per chunk, but if it returns more,
|
||||
this collapses them: keeps the first fact as representative, overrides its
|
||||
fact_text with the raw chunk text, and merges entities from any extra facts.
|
||||
"""
|
||||
chunk_text_map = {c.chunk_index: c.chunk_text for c in chunks}
|
||||
seen: dict[int, ExtractedFactType] = {}
|
||||
result: list[ExtractedFactType] = []
|
||||
|
||||
for fact in facts:
|
||||
if fact.chunk_index not in seen:
|
||||
fact.fact_text = chunk_text_map.get(fact.chunk_index, fact.fact_text)
|
||||
seen[fact.chunk_index] = fact
|
||||
result.append(fact)
|
||||
else:
|
||||
# Merge entities from extra facts into the representative
|
||||
representative = seen[fact.chunk_index]
|
||||
for entity in fact.entities:
|
||||
if entity not in representative.entities:
|
||||
representative.entities.append(entity)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _parse_datetime(date_str: str):
|
||||
"""Parse ISO datetime string."""
|
||||
from dateutil import parser as date_parser
|
||||
|
||||
@@ -81,9 +81,15 @@ async def insert_facts_batch(
|
||||
if fact.entities:
|
||||
signal_parts.extend(e.name for e in fact.entities)
|
||||
if fact.occurred_start:
|
||||
signal_parts.append(fact.occurred_start.strftime("%B %-d %Y"))
|
||||
try:
|
||||
signal_parts.append(fact.occurred_start.strftime("%B %d %Y").lstrip("0").replace(" 0", " "))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
if fact.occurred_end and fact.occurred_end != fact.occurred_start:
|
||||
signal_parts.append(fact.occurred_end.strftime("%B %-d %Y"))
|
||||
try:
|
||||
signal_parts.append(fact.occurred_end.strftime("%B %d %Y").lstrip("0").replace(" 0", " "))
|
||||
except (ValueError, AttributeError):
|
||||
pass
|
||||
text_signals_list.append(" ".join(signal_parts) if signal_parts else None)
|
||||
|
||||
# Batch insert all facts
|
||||
|
||||
@@ -292,6 +292,10 @@ async def retain_batch(
|
||||
pf.document_id = None
|
||||
pf.chunk_id = None
|
||||
|
||||
# Discard any leftover pending stats from a previous failed attempt so
|
||||
# retries don't double-count or accumulate unbounded state.
|
||||
entity_resolver.discard_pending_stats()
|
||||
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
async with conn.transaction():
|
||||
# Handle document tracking for all documents
|
||||
|
||||
@@ -11,7 +11,7 @@ from abc import ABC, abstractmethod
|
||||
|
||||
from ..db_utils import acquire_with_retry
|
||||
from ..memory_engine import fq_table
|
||||
from .tags import TagsMatch, filter_results_by_tags
|
||||
from .tags import TagGroup, TagsMatch, filter_results_by_tag_groups, filter_results_by_tags
|
||||
from .types import MPFPTimings, RetrievalResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -46,6 +46,7 @@ class GraphRetriever(ABC):
|
||||
adjacency=None, # TypedAdjacency, optional pre-loaded graph
|
||||
tags: list[str] | None = None, # Visibility scope tags for filtering
|
||||
tags_match: TagsMatch = "any", # How to match tags: 'any' (OR) or 'all' (AND)
|
||||
tag_groups: list[TagGroup] | None = None, # Compound boolean tag filter groups
|
||||
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
|
||||
"""
|
||||
Retrieve relevant facts via graph traversal.
|
||||
@@ -120,6 +121,7 @@ class BFSGraphRetriever(GraphRetriever):
|
||||
adjacency=None, # Not used by BFS
|
||||
tags: list[str] | None = None,
|
||||
tags_match: TagsMatch = "any",
|
||||
tag_groups: list[TagGroup] | None = None,
|
||||
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
|
||||
"""
|
||||
Retrieve facts using BFS spreading activation.
|
||||
@@ -136,7 +138,14 @@ class BFSGraphRetriever(GraphRetriever):
|
||||
"""
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
results = await self._retrieve_with_conn(
|
||||
conn, query_embedding_str, bank_id, fact_type, budget, tags=tags, tags_match=tags_match
|
||||
conn,
|
||||
query_embedding_str,
|
||||
bank_id,
|
||||
fact_type,
|
||||
budget,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
return results, None
|
||||
|
||||
@@ -149,14 +158,18 @@ class BFSGraphRetriever(GraphRetriever):
|
||||
budget: int,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: TagsMatch = "any",
|
||||
tag_groups: list[TagGroup] | None = None,
|
||||
) -> list[RetrievalResult]:
|
||||
"""Internal implementation with connection."""
|
||||
from .tags import build_tags_where_clause_simple
|
||||
from .tags import build_tag_groups_where_clause, build_tags_where_clause_simple
|
||||
|
||||
tags_clause = build_tags_where_clause_simple(tags, 6, match=tags_match)
|
||||
tag_groups_param_start = 6 + (1 if tags else 0)
|
||||
groups_clause, groups_params, _ = build_tag_groups_where_clause(tag_groups, tag_groups_param_start)
|
||||
params = [query_embedding_str, bank_id, fact_type, self.entry_point_threshold, self.entry_point_limit]
|
||||
if tags:
|
||||
params.append(tags)
|
||||
params.extend(groups_params)
|
||||
|
||||
# Step 1: Find entry points
|
||||
entry_points = await conn.fetch(
|
||||
@@ -170,6 +183,7 @@ class BFSGraphRetriever(GraphRetriever):
|
||||
AND fact_type = $3
|
||||
AND (1 - (embedding <=> $1::vector)) >= $4
|
||||
{tags_clause}
|
||||
{groups_clause}
|
||||
ORDER BY embedding <=> $1::vector
|
||||
LIMIT $5
|
||||
""",
|
||||
@@ -217,7 +231,7 @@ class BFSGraphRetriever(GraphRetriever):
|
||||
f"""
|
||||
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end,
|
||||
mu.mentioned_at, mu.fact_type,
|
||||
mu.document_id, mu.chunk_id, mu.tags,
|
||||
mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
|
||||
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
|
||||
@@ -261,4 +275,8 @@ class BFSGraphRetriever(GraphRetriever):
|
||||
if tags:
|
||||
results = filter_results_by_tags(results, tags, match=tags_match)
|
||||
|
||||
# Apply compound tag group filtering (post-traversal)
|
||||
if tag_groups:
|
||||
results = filter_results_by_tag_groups(results, tag_groups)
|
||||
|
||||
return results
|
||||
|
||||
@@ -28,7 +28,7 @@ import time
|
||||
from ..db_utils import acquire_with_retry
|
||||
from ..memory_engine import fq_table
|
||||
from .graph_retrieval import GraphRetriever
|
||||
from .tags import TagsMatch, filter_results_by_tags
|
||||
from .tags import TagGroup, TagsMatch, filter_results_by_tag_groups, filter_results_by_tags
|
||||
from .types import MPFPTimings, RetrievalResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -43,14 +43,18 @@ async def _find_semantic_seeds(
|
||||
threshold: float = 0.3,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: TagsMatch = "any",
|
||||
tag_groups: list[TagGroup] | None = None,
|
||||
) -> list[RetrievalResult]:
|
||||
"""Find semantic seeds via embedding search."""
|
||||
from .tags import build_tags_where_clause_simple
|
||||
from .tags import build_tag_groups_where_clause, build_tags_where_clause_simple
|
||||
|
||||
tags_clause = build_tags_where_clause_simple(tags, 6, match=tags_match)
|
||||
tag_groups_param_start = 6 + (1 if tags else 0)
|
||||
groups_clause, groups_params, _ = build_tag_groups_where_clause(tag_groups, tag_groups_param_start)
|
||||
params = [query_embedding_str, bank_id, fact_type, threshold, limit]
|
||||
if tags:
|
||||
params.append(tags)
|
||||
params.extend(groups_params)
|
||||
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
@@ -63,6 +67,7 @@ async def _find_semantic_seeds(
|
||||
AND fact_type = $3
|
||||
AND (1 - (embedding <=> $1::vector)) >= $4
|
||||
{tags_clause}
|
||||
{groups_clause}
|
||||
ORDER BY embedding <=> $1::vector
|
||||
LIMIT $5
|
||||
""",
|
||||
@@ -110,6 +115,7 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
adjacency=None,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: TagsMatch = "any",
|
||||
tag_groups: list[TagGroup] | None = None,
|
||||
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
|
||||
"""
|
||||
Retrieve facts by expanding links from seeds.
|
||||
@@ -147,6 +153,7 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
threshold=0.3,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
timings.seeds_time = time.time() - seeds_start
|
||||
logger.debug(
|
||||
@@ -221,6 +228,9 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
if tags:
|
||||
results = filter_results_by_tags(results, tags, match=tags_match)
|
||||
|
||||
if tag_groups:
|
||||
results = filter_results_by_tag_groups(results, tag_groups)
|
||||
|
||||
timings.result_count = len(results)
|
||||
timings.traverse = time.time() - start_time
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ from dataclasses import dataclass, field
|
||||
from ..db_utils import acquire_with_retry
|
||||
from ..memory_engine import fq_table
|
||||
from .graph_retrieval import GraphRetriever
|
||||
from .tags import TagsMatch
|
||||
from .tags import TagGroup, TagsMatch
|
||||
from .types import MPFPTimings, RetrievalResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -449,7 +449,7 @@ async def fetch_memory_units_by_ids(
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end,
|
||||
mentioned_at, fact_type, document_id, chunk_id, tags
|
||||
mentioned_at, fact_type, document_id, chunk_id, tags, metadata
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = ANY($1::uuid[])
|
||||
AND fact_type = $2
|
||||
@@ -506,6 +506,7 @@ class MPFPGraphRetriever(GraphRetriever):
|
||||
adjacency=None, # Ignored - kept for interface compatibility
|
||||
tags: list[str] | None = None,
|
||||
tags_match: TagsMatch = "any",
|
||||
tag_groups: list[TagGroup] | None = None,
|
||||
) -> tuple[list[RetrievalResult], MPFPTimings | None]:
|
||||
"""
|
||||
Retrieve facts using MPFP algorithm with lazy edge loading.
|
||||
@@ -537,7 +538,13 @@ class MPFPGraphRetriever(GraphRetriever):
|
||||
if not semantic_seed_nodes:
|
||||
seeds_start = time.time()
|
||||
semantic_seed_nodes = await self._find_semantic_seeds(
|
||||
pool, query_embedding_str, bank_id, fact_type, tags=tags, tags_match=tags_match
|
||||
pool,
|
||||
query_embedding_str,
|
||||
bank_id,
|
||||
fact_type,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
timings.seeds_time = time.time() - seeds_start
|
||||
logger.debug(
|
||||
@@ -616,6 +623,12 @@ class MPFPGraphRetriever(GraphRetriever):
|
||||
|
||||
results = filter_results_by_tags(results, tags, match=tags_match)
|
||||
|
||||
# Apply compound tag group filtering (post-traversal)
|
||||
if tag_groups:
|
||||
from .tags import filter_results_by_tag_groups
|
||||
|
||||
results = filter_results_by_tag_groups(results, tag_groups)
|
||||
|
||||
timings.result_count = len(results)
|
||||
|
||||
# Add activation scores from fusion
|
||||
@@ -656,14 +669,18 @@ class MPFPGraphRetriever(GraphRetriever):
|
||||
threshold: float = 0.3,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: TagsMatch = "any",
|
||||
tag_groups: list[TagGroup] | None = None,
|
||||
) -> list[SeedNode]:
|
||||
"""Fallback: find semantic seeds via embedding search."""
|
||||
from .tags import build_tags_where_clause_simple
|
||||
from .tags import build_tag_groups_where_clause, build_tags_where_clause_simple
|
||||
|
||||
tags_clause = build_tags_where_clause_simple(tags, 6, match=tags_match)
|
||||
tag_groups_param_start = 6 + (1 if tags else 0)
|
||||
groups_clause, groups_params, _ = build_tag_groups_where_clause(tag_groups, tag_groups_param_start)
|
||||
params = [query_embedding_str, bank_id, fact_type, threshold, limit]
|
||||
if tags:
|
||||
params.append(tags)
|
||||
params.extend(groups_params)
|
||||
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
rows = await conn.fetch(
|
||||
@@ -675,6 +692,7 @@ class MPFPGraphRetriever(GraphRetriever):
|
||||
AND fact_type = $3
|
||||
AND (1 - (embedding <=> $1::vector)) >= $4
|
||||
{tags_clause}
|
||||
{groups_clause}
|
||||
ORDER BY embedding <=> $1::vector
|
||||
LIMIT $5
|
||||
""",
|
||||
|
||||
@@ -153,6 +153,8 @@ class CrossEncoderReranker:
|
||||
|
||||
# Normalize scores using sigmoid to [0, 1] range
|
||||
# Cross-encoder returns logits which can be negative
|
||||
import math
|
||||
|
||||
import numpy as np
|
||||
|
||||
def sigmoid(x):
|
||||
@@ -163,11 +165,20 @@ class CrossEncoderReranker:
|
||||
# Create ScoredResult objects with cross-encoder scores
|
||||
scored_results = []
|
||||
for candidate, raw_score, norm_score in zip(candidates, scores, normalized_scores):
|
||||
# Sanitize NaN scores (cross-encoder can return NaN for certain inputs).
|
||||
# NaN propagates through all downstream scoring and Pydantic serializes
|
||||
# NaN as JSON null, which breaks clients expecting numeric values.
|
||||
raw = float(raw_score)
|
||||
norm = float(norm_score)
|
||||
if math.isnan(raw):
|
||||
raw = 0.0
|
||||
if math.isnan(norm):
|
||||
norm = 0.0
|
||||
scored_result = ScoredResult(
|
||||
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
|
||||
cross_encoder_score=raw,
|
||||
cross_encoder_score_normalized=norm,
|
||||
weight=norm, # Initial weight is just cross-encoder score
|
||||
)
|
||||
scored_results.append(scored_result)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ Implements:
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from typing import Optional
|
||||
@@ -20,12 +21,21 @@ from ..memory_engine import fq_table
|
||||
from .graph_retrieval import BFSGraphRetriever, GraphRetriever
|
||||
from .link_expansion_retrieval import LinkExpansionRetriever
|
||||
from .mpfp_retrieval import MPFPGraphRetriever
|
||||
from .tags import TagsMatch, build_tags_where_clause_simple
|
||||
from .tags import TagGroup, TagsMatch, build_tag_groups_where_clause, build_tags_where_clause_simple
|
||||
from .types import MPFPTimings, RetrievalResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def tokenize_query(query_text: str) -> list[str]:
|
||||
"""Normalize query text and split into BM25 tokens.
|
||||
|
||||
Strips punctuation, lowercases, and splits on whitespace.
|
||||
Returns an empty list when the query contains no word characters.
|
||||
"""
|
||||
return re.sub(r"[^\w\s]", " ", query_text.lower()).split()
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParallelRetrievalResult:
|
||||
"""Result from parallel retrieval across all methods."""
|
||||
@@ -94,6 +104,7 @@ async def retrieve_semantic_bm25_combined(
|
||||
limit: int,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: TagsMatch = "any",
|
||||
tag_groups: list[TagGroup] | None = None,
|
||||
) -> dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]]:
|
||||
"""
|
||||
Combined semantic + BM25 retrieval for multiple fact types in a single query.
|
||||
@@ -128,31 +139,37 @@ async def retrieve_semantic_bm25_combined(
|
||||
Returns:
|
||||
Dict mapping fact_type -> (semantic_results, bm25_results)
|
||||
"""
|
||||
import re
|
||||
|
||||
result_dict: dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]] = {ft: ([], []) for ft in fact_types}
|
||||
|
||||
sanitized_text = re.sub(r"[^\w\s]", " ", query_text.lower())
|
||||
tokens = [token for token in sanitized_text.split() if token]
|
||||
tokens = tokenize_query(query_text)
|
||||
|
||||
# Over-fetch for HNSW approximation; semantic results trimmed to limit in Python.
|
||||
hnsw_fetch = max(limit * 5, 100)
|
||||
|
||||
cols = (
|
||||
"id, text, context, event_date, occurred_start, occurred_end, mentioned_at, "
|
||||
"fact_type, document_id, chunk_id, tags"
|
||||
"fact_type, document_id, chunk_id, tags, metadata"
|
||||
)
|
||||
table = fq_table("memory_units")
|
||||
|
||||
# --- Parameter layout ---
|
||||
# $1 = query_emb_str (semantic arms)
|
||||
# $2 = bank_id
|
||||
# $3 = limit (BM25 LIMIT; semantic uses inlined hnsw_fetch literal)
|
||||
# $4 = bm25_text (only when tokens present)
|
||||
# $N = tags (N=4 when no tokens, N=5 when tokens present)
|
||||
tags_param_idx = 5 if tokens else 4
|
||||
# When tokens present:
|
||||
# $3 = limit (BM25 LIMIT; semantic uses inlined hnsw_fetch literal)
|
||||
# $4 = bm25_text
|
||||
# $5 = tags (if present)
|
||||
# $6+ = tag_groups params (one per leaf)
|
||||
# When no tokens ($3 is skipped — not included in params to avoid type inference gap):
|
||||
# $3 = tags (if present)
|
||||
# $4+ = tag_groups params (one per leaf)
|
||||
tags_param_idx = 5 if tokens else 3
|
||||
tags_clause = build_tags_where_clause_simple(tags, tags_param_idx, match=tags_match)
|
||||
|
||||
# tag_groups params start immediately after the tags param slot
|
||||
tag_groups_param_start = tags_param_idx + (1 if tags else 0)
|
||||
groups_clause, groups_params, _ = build_tag_groups_where_clause(tag_groups, tag_groups_param_start)
|
||||
|
||||
# --- Semantic UNION ALL arms (one per fact_type) ---
|
||||
# Each arm has its own ORDER BY embedding <=> $1 LIMIT {hnsw_fetch}, which
|
||||
# lets the planner use the partial HNSW index for that fact_type.
|
||||
@@ -169,6 +186,7 @@ async def retrieve_semantic_bm25_combined(
|
||||
f" AND embedding IS NOT NULL"
|
||||
f" AND (1 - (embedding <=> $1::vector)) >= 0.3"
|
||||
f" {tags_clause}"
|
||||
f" {groups_clause}"
|
||||
f" ORDER BY embedding <=> $1::vector"
|
||||
f" LIMIT {hnsw_fetch})"
|
||||
)
|
||||
@@ -208,17 +226,20 @@ async def retrieve_semantic_bm25_combined(
|
||||
f" AND fact_type = '{ft}'"
|
||||
f" {bm25_where_filter}"
|
||||
f" {tags_clause}"
|
||||
f" {groups_clause}"
|
||||
f" ORDER BY {bm25_order_by}"
|
||||
f" LIMIT $3)"
|
||||
)
|
||||
|
||||
query = "\nUNION ALL\n".join(arms)
|
||||
|
||||
params: list = [query_emb_str, bank_id, limit]
|
||||
params: list = [query_emb_str, bank_id]
|
||||
if tokens:
|
||||
params.append(bm25_text_param)
|
||||
params.append(limit) # $3: BM25 LIMIT (only referenced when tokens are present)
|
||||
params.append(bm25_text_param) # $4
|
||||
if tags:
|
||||
params.append(tags)
|
||||
params.extend(groups_params)
|
||||
|
||||
rows = await conn.fetch(query, *params)
|
||||
|
||||
@@ -251,6 +272,7 @@ async def retrieve_temporal_combined(
|
||||
semantic_threshold: float = 0.1,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: TagsMatch = "any",
|
||||
tag_groups: list[TagGroup] | None = None,
|
||||
) -> dict[str, list[RetrievalResult]]:
|
||||
"""
|
||||
Temporal retrieval for multiple fact types in a single query.
|
||||
@@ -280,10 +302,14 @@ async def retrieve_temporal_combined(
|
||||
end_date = end_date.replace(tzinfo=UTC)
|
||||
|
||||
# Build tags clause
|
||||
# Entry point query: fixed params are $1-$6, tags at $7
|
||||
tags_clause = build_tags_where_clause_simple(tags, 7, match=tags_match)
|
||||
params = [query_emb_str, bank_id, fact_types, start_date, end_date, semantic_threshold]
|
||||
tag_groups_param_start = 7 + (1 if tags else 0)
|
||||
groups_clause, groups_params, _ = build_tag_groups_where_clause(tag_groups, tag_groups_param_start)
|
||||
params: list = [query_emb_str, bank_id, fact_types, start_date, end_date, semantic_threshold]
|
||||
if tags:
|
||||
params.append(tags)
|
||||
params.extend(groups_params)
|
||||
|
||||
# Two-phase entry point query:
|
||||
# Phase 1 (date_ranked): rank by date only — no embedding computation — for all units in
|
||||
@@ -314,9 +340,10 @@ async def retrieve_temporal_combined(
|
||||
(occurred_end IS NOT NULL AND occurred_end BETWEEN $4 AND $5)
|
||||
)
|
||||
{tags_clause}
|
||||
{groups_clause}
|
||||
),
|
||||
sim_ranked AS (
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
|
||||
1 - (mu.embedding <=> $1::vector) AS similarity,
|
||||
ROW_NUMBER() OVER (PARTITION BY mu.fact_type ORDER BY mu.embedding <=> $1::vector) AS sim_rn
|
||||
FROM date_ranked dr
|
||||
@@ -324,7 +351,7 @@ async def retrieve_temporal_combined(
|
||||
WHERE dr.rn <= 50
|
||||
AND (1 - (mu.embedding <=> $1::vector)) >= $6
|
||||
)
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags, similarity
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags, metadata, similarity
|
||||
FROM sim_ranked
|
||||
WHERE sim_rn <= 10
|
||||
""",
|
||||
@@ -400,16 +427,21 @@ async def retrieve_temporal_combined(
|
||||
|
||||
# Build tags clause for spreading (use param 7 since 1-6 are used)
|
||||
spreading_tags_clause = build_tags_where_clause_simple(tags, 7, table_alias="mu.", match=tags_match)
|
||||
spreading_groups_param_start = 7 + (1 if tags else 0)
|
||||
spreading_groups_clause, spreading_groups_params, _ = build_tag_groups_where_clause(
|
||||
tag_groups, spreading_groups_param_start, table_alias="mu."
|
||||
)
|
||||
|
||||
while frontier and budget_remaining > 0 and iteration < max_iterations:
|
||||
iteration += 1
|
||||
batch_ids = frontier[:batch_size]
|
||||
frontier = frontier[batch_size:]
|
||||
|
||||
# $1=query_emb, $2=batch_ids, $3=fact_type, $4=threshold, $5=per_source_limit, $6=bank_id, $7=tags
|
||||
# $1=query_emb, $2=batch_ids, $3=fact_type, $4=threshold, $5=per_source_limit, $6=bank_id, $7=tags, $M+=tag_groups
|
||||
spreading_params = [query_emb_str, batch_ids, ft, semantic_threshold, per_source_limit, bank_id]
|
||||
if tags:
|
||||
spreading_params.append(tags)
|
||||
spreading_params.extend(spreading_groups_params)
|
||||
|
||||
# LATERAL join: for each source node, fetch top-K neighbors by weight using
|
||||
# the existing idx_memory_links_from_type_weight index with early-exit semantics.
|
||||
@@ -417,7 +449,7 @@ async def retrieve_temporal_combined(
|
||||
# bank_id on memory_units lets the planner use idx_memory_units_bank_fact_type.
|
||||
neighbors = await conn.fetch(
|
||||
f"""
|
||||
SELECT src.from_unit_id, mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
SELECT src.from_unit_id, mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
|
||||
l.weight, l.link_type,
|
||||
1 - (mu.embedding <=> $1::vector) AS similarity
|
||||
FROM unnest($2::uuid[]) AS src(from_unit_id)
|
||||
@@ -436,6 +468,7 @@ async def retrieve_temporal_combined(
|
||||
AND mu.embedding IS NOT NULL
|
||||
AND (1 - (mu.embedding <=> $1::vector)) >= $4
|
||||
{spreading_tags_clause}
|
||||
{spreading_groups_clause}
|
||||
""",
|
||||
*spreading_params,
|
||||
)
|
||||
@@ -509,6 +542,7 @@ async def retrieve_all_fact_types_parallel(
|
||||
graph_retriever: GraphRetriever | None = None,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: TagsMatch = "any",
|
||||
tag_groups: list[TagGroup] | None = None,
|
||||
) -> MultiFactTypeRetrievalResult:
|
||||
"""
|
||||
Optimized retrieval for multiple fact types using batched queries.
|
||||
@@ -566,6 +600,7 @@ async def retrieve_all_fact_types_parallel(
|
||||
thinking_budget,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
semantic_bm25_time = time.time() - semantic_bm25_start
|
||||
|
||||
@@ -584,6 +619,7 @@ async def retrieve_all_fact_types_parallel(
|
||||
semantic_threshold=0.1,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
temporal_time = time.time() - temporal_start
|
||||
|
||||
@@ -604,6 +640,7 @@ async def retrieve_all_fact_types_parallel(
|
||||
temporal_seeds=None,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
return ft, results, time.time() - graph_start, mpfp_timing
|
||||
|
||||
|
||||
@@ -12,7 +12,11 @@ OR matching (any/any_strict): Memory matches if ANY of its tags overlap with req
|
||||
AND matching (all/all_strict): Memory matches if ALL request tags are present in its tags
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
TagsMatch = Literal["any", "all", "any_strict", "all_strict"]
|
||||
|
||||
@@ -170,3 +174,217 @@ def filter_results_by_tags(
|
||||
filtered.append(result)
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Compound tag group models (recursive boolean expressions)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TagGroupLeaf(BaseModel):
|
||||
"""A leaf tag filter: matches memories by tag list and match mode."""
|
||||
|
||||
tags: list[str]
|
||||
match: TagsMatch = "any_strict"
|
||||
|
||||
|
||||
class TagGroupAnd(BaseModel):
|
||||
"""Compound AND group: all child filters must match."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
filters: list[TagGroup] = Field(alias="and")
|
||||
|
||||
|
||||
class TagGroupOr(BaseModel):
|
||||
"""Compound OR group: at least one child filter must match."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
filters: list[TagGroup] = Field(alias="or")
|
||||
|
||||
|
||||
class TagGroupNot(BaseModel):
|
||||
"""Compound NOT group: child filter must NOT match."""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
filter: TagGroup = Field(alias="not")
|
||||
|
||||
|
||||
# TagGroup is a discriminated union; Pydantic will try left-to-right.
|
||||
# TagGroupLeaf is identified by the presence of 'tags'.
|
||||
# TagGroupAnd / TagGroupOr / TagGroupNot are compound (no 'tags' key).
|
||||
TagGroup = Annotated[
|
||||
TagGroupLeaf | TagGroupAnd | TagGroupOr | TagGroupNot,
|
||||
Field(union_mode="left_to_right"),
|
||||
]
|
||||
|
||||
# Rebuild forward-reference models so recursive TagGroup is resolved.
|
||||
TagGroupAnd.model_rebuild()
|
||||
TagGroupOr.model_rebuild()
|
||||
TagGroupNot.model_rebuild()
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SQL builder for compound tag groups
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _build_group_clause(
|
||||
group: TagGroup,
|
||||
param_offset: int,
|
||||
table_alias: str,
|
||||
) -> tuple[str, list, int]:
|
||||
"""
|
||||
Recursively build an inner SQL clause (no leading AND/OR) for a single TagGroup.
|
||||
|
||||
Returns:
|
||||
(inner_clause, params, next_param_offset)
|
||||
"""
|
||||
if isinstance(group, TagGroupLeaf):
|
||||
column = f"{table_alias}tags" if table_alias else "tags"
|
||||
operator, include_untagged = _parse_tags_match(group.match)
|
||||
if include_untagged:
|
||||
clause = f"({column} IS NULL OR {column} = '{{}}' OR {column} {operator} ${param_offset})"
|
||||
else:
|
||||
clause = f"({column} IS NOT NULL AND {column} != '{{}}' AND {column} {operator} ${param_offset})"
|
||||
return clause, [group.tags], param_offset + 1
|
||||
|
||||
elif isinstance(group, TagGroupAnd):
|
||||
parts = []
|
||||
params: list = []
|
||||
offset = param_offset
|
||||
for child in group.filters:
|
||||
child_clause, child_params, offset = _build_group_clause(child, offset, table_alias)
|
||||
parts.append(child_clause)
|
||||
params.extend(child_params)
|
||||
inner = " AND ".join(parts)
|
||||
return f"({inner})", params, offset
|
||||
|
||||
elif isinstance(group, TagGroupOr):
|
||||
parts = []
|
||||
params = []
|
||||
offset = param_offset
|
||||
for child in group.filters:
|
||||
child_clause, child_params, offset = _build_group_clause(child, offset, table_alias)
|
||||
parts.append(child_clause)
|
||||
params.extend(child_params)
|
||||
inner = " OR ".join(parts)
|
||||
return f"({inner})", params, offset
|
||||
|
||||
elif isinstance(group, TagGroupNot):
|
||||
child_clause, child_params, next_offset = _build_group_clause(group.filter, param_offset, table_alias)
|
||||
return f"NOT {child_clause}", child_params, next_offset
|
||||
|
||||
else:
|
||||
# Should never happen with proper Pydantic validation
|
||||
return "", [], param_offset
|
||||
|
||||
|
||||
def build_tag_groups_where_clause(
|
||||
tag_groups: list[TagGroup] | None,
|
||||
param_offset: int,
|
||||
table_alias: str = "",
|
||||
) -> tuple[str, list, int]:
|
||||
"""
|
||||
Build a SQL WHERE clause for compound tag group filtering.
|
||||
|
||||
Top-level groups are AND-ed together. Each group is a recursive boolean
|
||||
expression (leaf, and, or, not).
|
||||
|
||||
Args:
|
||||
tag_groups: List of TagGroup objects. If None or empty, returns empty clause.
|
||||
param_offset: Starting parameter number for SQL placeholders.
|
||||
table_alias: Optional table alias prefix (e.g., "mu." for "memory_units mu").
|
||||
|
||||
Returns:
|
||||
Tuple of (sql_clause, params, next_param_offset):
|
||||
- sql_clause: SQL WHERE clause string starting with "AND" (or empty string)
|
||||
- params: List of parameter values to bind (one per leaf node)
|
||||
- next_param_offset: Next available parameter number
|
||||
|
||||
Example:
|
||||
>>> groups = [TagGroupLeaf(tags=["user:alice"], match="all_strict")]
|
||||
>>> clause, params, next_offset = build_tag_groups_where_clause(groups, 3)
|
||||
>>> print(clause) # "AND (tags IS NOT NULL AND tags != '{}' AND tags @> $3)"
|
||||
"""
|
||||
if not tag_groups:
|
||||
return "", [], param_offset
|
||||
|
||||
all_params: list = []
|
||||
all_clauses: list[str] = []
|
||||
offset = param_offset
|
||||
|
||||
for group in tag_groups:
|
||||
inner_clause, group_params, offset = _build_group_clause(group, offset, table_alias)
|
||||
all_clauses.append(inner_clause)
|
||||
all_params.extend(group_params)
|
||||
|
||||
combined = " AND ".join(all_clauses)
|
||||
return f"AND {combined}", all_params, offset
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Python-side filter for compound tag groups (post-retrieval filtering)
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def _match_group(result: object, group: TagGroup) -> bool:
|
||||
"""
|
||||
Recursively evaluate a TagGroup against a retrieval result.
|
||||
|
||||
Args:
|
||||
result: Any object with a 'tags' attribute (list[str] or None).
|
||||
group: The TagGroup to evaluate.
|
||||
|
||||
Returns:
|
||||
True if the result matches the group, False otherwise.
|
||||
"""
|
||||
if isinstance(group, TagGroupLeaf):
|
||||
result_tags = getattr(result, "tags", None)
|
||||
is_untagged = result_tags is None or len(result_tags) == 0
|
||||
_, include_untagged = _parse_tags_match(group.match)
|
||||
is_any_match = group.match in ("any", "any_strict")
|
||||
tags_set = set(group.tags)
|
||||
|
||||
if is_untagged:
|
||||
return include_untagged
|
||||
else:
|
||||
result_tags_set = set(result_tags)
|
||||
if is_any_match:
|
||||
return bool(result_tags_set & tags_set)
|
||||
else:
|
||||
return tags_set <= result_tags_set
|
||||
|
||||
elif isinstance(group, TagGroupAnd):
|
||||
return all(_match_group(result, child) for child in group.filters)
|
||||
|
||||
elif isinstance(group, TagGroupOr):
|
||||
return any(_match_group(result, child) for child in group.filters)
|
||||
|
||||
elif isinstance(group, TagGroupNot):
|
||||
return not _match_group(result, group.filter)
|
||||
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def filter_results_by_tag_groups(
|
||||
results: list,
|
||||
tag_groups: list[TagGroup] | None,
|
||||
) -> list:
|
||||
"""
|
||||
Filter retrieval results by compound tag groups in Python (for post-processing).
|
||||
|
||||
Used when SQL filtering isn't possible (e.g., graph traversal results).
|
||||
Top-level groups are AND-ed together.
|
||||
|
||||
Args:
|
||||
results: List of RetrievalResult objects with a 'tags' attribute.
|
||||
tag_groups: List of TagGroup objects. If None or empty, returns all results.
|
||||
|
||||
Returns:
|
||||
Filtered list of results where ALL top-level groups match.
|
||||
"""
|
||||
if not tag_groups:
|
||||
return results
|
||||
|
||||
return [r for r in results if all(_match_group(r, group) for group in tag_groups)]
|
||||
|
||||
@@ -47,6 +47,7 @@ class RetrievalResult:
|
||||
document_id: str | None = None
|
||||
chunk_id: str | None = None
|
||||
tags: list[str] | None = None # Visibility scope tags
|
||||
metadata: dict[str, str] | None = None # User-provided metadata
|
||||
|
||||
# Retrieval-specific scores (only one will be set depending on retrieval method)
|
||||
similarity: float | None = None # Semantic retrieval
|
||||
@@ -70,6 +71,7 @@ class RetrievalResult:
|
||||
document_id=row.get("document_id"),
|
||||
chunk_id=row.get("chunk_id"),
|
||||
tags=row.get("tags"),
|
||||
metadata=row.get("metadata"),
|
||||
similarity=row.get("similarity"),
|
||||
bm25_score=row.get("bm25_score"),
|
||||
activation=row.get("activation"),
|
||||
@@ -153,6 +155,7 @@ class ScoredResult:
|
||||
"document_id": self.retrieval.document_id,
|
||||
"chunk_id": self.retrieval.chunk_id,
|
||||
"tags": self.retrieval.tags,
|
||||
"metadata": self.retrieval.metadata,
|
||||
"semantic_similarity": self.retrieval.similarity,
|
||||
"bm25_score": self.retrieval.bm25_score,
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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.engine.search.tags import TagGroup, TagsMatch
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
|
||||
@@ -25,17 +26,51 @@ class OperationValidationError(Exception):
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of an operation validation."""
|
||||
"""Result of an operation validation.
|
||||
|
||||
Validators return this to accept or reject an operation. When accepting,
|
||||
validators can optionally return modified data that the engine will use
|
||||
instead of the original request parameters. This enables context enrichment
|
||||
(e.g., injecting tags or tag_groups).
|
||||
"""
|
||||
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
status_code: int = 403 # Default to Forbidden
|
||||
# Optional enrichment fields — returned by validator, used by engine if present.
|
||||
# None means "no modification" (engine uses original values).
|
||||
contents: list[dict] | None = None # Enriched retain contents (e.g., injected tags/strategy)
|
||||
tags: list[str] | None = None # Enriched recall tags
|
||||
tags_match: "TagsMatch | None" = None # Enriched recall tags match mode
|
||||
tag_groups: "list[TagGroup] | None" = None # Enriched recall tag_groups
|
||||
|
||||
@classmethod
|
||||
def accept(cls) -> "ValidationResult":
|
||||
"""Create an accepted validation result."""
|
||||
"""Create an accepted validation result (no enrichment)."""
|
||||
return cls(allowed=True)
|
||||
|
||||
@classmethod
|
||||
def accept_with(
|
||||
cls,
|
||||
*,
|
||||
contents: list[dict] | None = None,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: "TagsMatch | None" = None,
|
||||
tag_groups: "list[TagGroup] | None" = None,
|
||||
) -> "ValidationResult":
|
||||
"""Create an accepted validation result with enriched data.
|
||||
|
||||
The engine will use the returned values instead of the original request
|
||||
parameters. Only non-None fields are applied; None means "keep original".
|
||||
"""
|
||||
return cls(
|
||||
allowed=True,
|
||||
contents=contents,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def reject(cls, reason: str, status_code: int = 403) -> "ValidationResult":
|
||||
"""Create a rejected validation result with a reason and HTTP status code."""
|
||||
@@ -52,10 +87,12 @@ class RetainContext:
|
||||
"""Context for a retain operation validation (pre-operation).
|
||||
|
||||
Contains ALL user-provided parameters for the retain operation.
|
||||
To enrich contents (e.g., inject tags or strategy), return them
|
||||
via ValidationResult.accept_with(contents=...).
|
||||
"""
|
||||
|
||||
bank_id: str
|
||||
contents: list[dict] # List of {content, context, event_date, document_id}
|
||||
contents: list[dict] # List of {content, context, event_date, document_id, tags, strategy}
|
||||
request_context: "RequestContext"
|
||||
document_id: str | None = None
|
||||
fact_type_override: str | None = None
|
||||
@@ -67,6 +104,8 @@ class RecallContext:
|
||||
"""Context for a recall operation validation (pre-operation).
|
||||
|
||||
Contains ALL user-provided parameters for the recall operation.
|
||||
To enrich tag filters (e.g., inject tag_groups), return them
|
||||
via ValidationResult.accept_with(tag_groups=...).
|
||||
"""
|
||||
|
||||
bank_id: str
|
||||
@@ -81,6 +120,9 @@ class RecallContext:
|
||||
max_entity_tokens: int = 500
|
||||
include_chunks: bool = False
|
||||
max_chunk_tokens: int = 8192
|
||||
tags: list[str] | None = None
|
||||
tags_match: "TagsMatch" = "any"
|
||||
tag_groups: "list[TagGroup] | None" = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -13,6 +13,7 @@ Stop with Ctrl+C.
|
||||
import argparse
|
||||
import asyncio
|
||||
import atexit
|
||||
import dataclasses
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
@@ -152,173 +153,7 @@ def main():
|
||||
# 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,
|
||||
database_schema=config.database_schema,
|
||||
vector_extension=config.vector_extension,
|
||||
text_search_extension=config.text_search_extension,
|
||||
llm_provider=config.llm_provider,
|
||||
llm_api_key=config.llm_api_key,
|
||||
llm_model=config.llm_model,
|
||||
llm_base_url=config.llm_base_url,
|
||||
llm_max_concurrent=config.llm_max_concurrent,
|
||||
llm_max_retries=config.llm_max_retries,
|
||||
llm_initial_backoff=config.llm_initial_backoff,
|
||||
llm_max_backoff=config.llm_max_backoff,
|
||||
llm_timeout=config.llm_timeout,
|
||||
llm_groq_service_tier=config.llm_groq_service_tier,
|
||||
llm_openai_service_tier=config.llm_openai_service_tier,
|
||||
llm_vertexai_project_id=config.llm_vertexai_project_id,
|
||||
llm_vertexai_region=config.llm_vertexai_region,
|
||||
llm_vertexai_service_account_key=config.llm_vertexai_service_account_key,
|
||||
llm_gemini_safety_settings=config.llm_gemini_safety_settings,
|
||||
retain_llm_provider=config.retain_llm_provider,
|
||||
retain_llm_api_key=config.retain_llm_api_key,
|
||||
retain_llm_model=config.retain_llm_model,
|
||||
retain_llm_base_url=config.retain_llm_base_url,
|
||||
retain_llm_max_concurrent=config.retain_llm_max_concurrent,
|
||||
retain_llm_max_retries=config.retain_llm_max_retries,
|
||||
retain_llm_initial_backoff=config.retain_llm_initial_backoff,
|
||||
retain_llm_max_backoff=config.retain_llm_max_backoff,
|
||||
retain_llm_timeout=config.retain_llm_timeout,
|
||||
reflect_llm_provider=config.reflect_llm_provider,
|
||||
reflect_llm_api_key=config.reflect_llm_api_key,
|
||||
reflect_llm_model=config.reflect_llm_model,
|
||||
reflect_llm_base_url=config.reflect_llm_base_url,
|
||||
reflect_llm_max_concurrent=config.reflect_llm_max_concurrent,
|
||||
reflect_llm_max_retries=config.reflect_llm_max_retries,
|
||||
reflect_llm_initial_backoff=config.reflect_llm_initial_backoff,
|
||||
reflect_llm_max_backoff=config.reflect_llm_max_backoff,
|
||||
reflect_llm_timeout=config.reflect_llm_timeout,
|
||||
consolidation_llm_provider=config.consolidation_llm_provider,
|
||||
consolidation_llm_api_key=config.consolidation_llm_api_key,
|
||||
consolidation_llm_model=config.consolidation_llm_model,
|
||||
consolidation_llm_base_url=config.consolidation_llm_base_url,
|
||||
consolidation_llm_max_concurrent=config.consolidation_llm_max_concurrent,
|
||||
consolidation_llm_max_retries=config.consolidation_llm_max_retries,
|
||||
consolidation_llm_initial_backoff=config.consolidation_llm_initial_backoff,
|
||||
consolidation_llm_max_backoff=config.consolidation_llm_max_backoff,
|
||||
consolidation_llm_timeout=config.consolidation_llm_timeout,
|
||||
embeddings_provider=config.embeddings_provider,
|
||||
embeddings_local_model=config.embeddings_local_model,
|
||||
embeddings_local_force_cpu=config.embeddings_local_force_cpu,
|
||||
embeddings_local_trust_remote_code=config.embeddings_local_trust_remote_code,
|
||||
embeddings_tei_url=config.embeddings_tei_url,
|
||||
embeddings_openai_base_url=config.embeddings_openai_base_url,
|
||||
embeddings_cohere_api_key=config.embeddings_cohere_api_key,
|
||||
embeddings_cohere_model=config.embeddings_cohere_model,
|
||||
embeddings_cohere_base_url=config.embeddings_cohere_base_url,
|
||||
embeddings_litellm_api_base=config.embeddings_litellm_api_base,
|
||||
embeddings_litellm_api_key=config.embeddings_litellm_api_key,
|
||||
embeddings_litellm_model=config.embeddings_litellm_model,
|
||||
embeddings_litellm_sdk_api_key=config.embeddings_litellm_sdk_api_key,
|
||||
embeddings_litellm_sdk_model=config.embeddings_litellm_sdk_model,
|
||||
embeddings_litellm_sdk_api_base=config.embeddings_litellm_sdk_api_base,
|
||||
reranker_provider=config.reranker_provider,
|
||||
reranker_local_model=config.reranker_local_model,
|
||||
reranker_local_force_cpu=config.reranker_local_force_cpu,
|
||||
reranker_local_max_concurrent=config.reranker_local_max_concurrent,
|
||||
reranker_local_trust_remote_code=config.reranker_local_trust_remote_code,
|
||||
reranker_tei_url=config.reranker_tei_url,
|
||||
reranker_tei_batch_size=config.reranker_tei_batch_size,
|
||||
reranker_tei_max_concurrent=config.reranker_tei_max_concurrent,
|
||||
reranker_max_candidates=config.reranker_max_candidates,
|
||||
reranker_cohere_api_key=config.reranker_cohere_api_key,
|
||||
reranker_cohere_model=config.reranker_cohere_model,
|
||||
reranker_cohere_base_url=config.reranker_cohere_base_url,
|
||||
reranker_litellm_api_base=config.reranker_litellm_api_base,
|
||||
reranker_litellm_api_key=config.reranker_litellm_api_key,
|
||||
reranker_litellm_model=config.reranker_litellm_model,
|
||||
reranker_litellm_max_tokens_per_doc=config.reranker_litellm_max_tokens_per_doc,
|
||||
reranker_litellm_sdk_api_key=config.reranker_litellm_sdk_api_key,
|
||||
reranker_litellm_sdk_model=config.reranker_litellm_sdk_model,
|
||||
reranker_litellm_sdk_api_base=config.reranker_litellm_sdk_api_base,
|
||||
reranker_zeroentropy_api_key=config.reranker_zeroentropy_api_key,
|
||||
reranker_zeroentropy_model=config.reranker_zeroentropy_model,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
base_path=config.base_path,
|
||||
log_level=args.log_level,
|
||||
log_format=config.log_format,
|
||||
mcp_enabled=config.mcp_enabled,
|
||||
mcp_enabled_tools=config.mcp_enabled_tools,
|
||||
enable_bank_config_api=config.enable_bank_config_api,
|
||||
graph_retriever=config.graph_retriever,
|
||||
mpfp_top_k_neighbors=config.mpfp_top_k_neighbors,
|
||||
recall_max_concurrent=config.recall_max_concurrent,
|
||||
recall_connection_budget=config.recall_connection_budget,
|
||||
recall_max_query_tokens=config.recall_max_query_tokens,
|
||||
retain_max_completion_tokens=config.retain_max_completion_tokens,
|
||||
retain_chunk_size=config.retain_chunk_size,
|
||||
retain_extract_causal_links=config.retain_extract_causal_links,
|
||||
retain_extraction_mode=config.retain_extraction_mode,
|
||||
retain_mission=config.retain_mission,
|
||||
retain_custom_instructions=config.retain_custom_instructions,
|
||||
retain_batch_tokens=config.retain_batch_tokens,
|
||||
retain_entity_lookup=config.retain_entity_lookup,
|
||||
retain_batch_enabled=config.retain_batch_enabled,
|
||||
retain_batch_poll_interval_seconds=config.retain_batch_poll_interval_seconds,
|
||||
file_storage_type=config.file_storage_type,
|
||||
file_storage_s3_bucket=config.file_storage_s3_bucket,
|
||||
file_storage_s3_region=config.file_storage_s3_region,
|
||||
file_storage_s3_endpoint=config.file_storage_s3_endpoint,
|
||||
file_storage_s3_access_key_id=config.file_storage_s3_access_key_id,
|
||||
file_storage_s3_secret_access_key=config.file_storage_s3_secret_access_key,
|
||||
file_storage_gcs_bucket=config.file_storage_gcs_bucket,
|
||||
file_storage_gcs_service_account_key=config.file_storage_gcs_service_account_key,
|
||||
file_storage_azure_container=config.file_storage_azure_container,
|
||||
file_storage_azure_account_name=config.file_storage_azure_account_name,
|
||||
file_storage_azure_account_key=config.file_storage_azure_account_key,
|
||||
file_parser=config.file_parser,
|
||||
file_parser_allowlist=config.file_parser_allowlist,
|
||||
file_parser_iris_token=config.file_parser_iris_token,
|
||||
file_parser_iris_org_id=config.file_parser_iris_org_id,
|
||||
file_conversion_max_batch_size_mb=config.file_conversion_max_batch_size_mb,
|
||||
file_conversion_max_batch_size=config.file_conversion_max_batch_size,
|
||||
enable_file_upload_api=config.enable_file_upload_api,
|
||||
file_delete_after_retain=config.file_delete_after_retain,
|
||||
enable_observations=config.enable_observations,
|
||||
enable_observation_history=config.enable_observation_history,
|
||||
enable_mental_model_history=config.enable_mental_model_history,
|
||||
consolidation_batch_size=config.consolidation_batch_size,
|
||||
consolidation_llm_batch_size=config.consolidation_llm_batch_size,
|
||||
consolidation_max_tokens=config.consolidation_max_tokens,
|
||||
consolidation_source_facts_max_tokens=config.consolidation_source_facts_max_tokens,
|
||||
consolidation_source_facts_max_tokens_per_observation=config.consolidation_source_facts_max_tokens_per_observation,
|
||||
observations_mission=config.observations_mission,
|
||||
entity_labels=config.entity_labels,
|
||||
entities_allow_free_form=config.entities_allow_free_form,
|
||||
skip_llm_verification=config.skip_llm_verification,
|
||||
lazy_reranker=config.lazy_reranker,
|
||||
run_migrations_on_startup=config.run_migrations_on_startup,
|
||||
db_pool_min_size=config.db_pool_min_size,
|
||||
db_pool_max_size=config.db_pool_max_size,
|
||||
db_command_timeout=config.db_command_timeout,
|
||||
db_acquire_timeout=config.db_acquire_timeout,
|
||||
worker_enabled=config.worker_enabled,
|
||||
worker_id=config.worker_id,
|
||||
worker_poll_interval_ms=config.worker_poll_interval_ms,
|
||||
worker_max_retries=config.worker_max_retries,
|
||||
worker_http_port=config.worker_http_port,
|
||||
worker_max_slots=config.worker_max_slots,
|
||||
worker_consolidation_max_slots=config.worker_consolidation_max_slots,
|
||||
reflect_max_iterations=config.reflect_max_iterations,
|
||||
reflect_max_context_tokens=config.reflect_max_context_tokens,
|
||||
reflect_mission=config.reflect_mission,
|
||||
disposition_skepticism=config.disposition_skepticism,
|
||||
disposition_literalism=config.disposition_literalism,
|
||||
disposition_empathy=config.disposition_empathy,
|
||||
mental_model_refresh_concurrency=config.mental_model_refresh_concurrency,
|
||||
otel_traces_enabled=config.otel_traces_enabled,
|
||||
otel_exporter_otlp_endpoint=config.otel_exporter_otlp_endpoint,
|
||||
otel_exporter_otlp_headers=config.otel_exporter_otlp_headers,
|
||||
otel_service_name=config.otel_service_name,
|
||||
otel_deployment_environment=config.otel_deployment_environment,
|
||||
webhook_url=config.webhook_url,
|
||||
webhook_secret=config.webhook_secret,
|
||||
webhook_event_types=config.webhook_event_types,
|
||||
webhook_delivery_poll_interval_seconds=config.webhook_delivery_poll_interval_seconds,
|
||||
)
|
||||
config = dataclasses.replace(config, host=args.host, port=args.port, log_level=args.log_level)
|
||||
config.configure_logging()
|
||||
if not args.daemon:
|
||||
config.log_config()
|
||||
@@ -376,15 +211,27 @@ def main():
|
||||
# Prepare uvicorn config
|
||||
# When using workers or reload, we must use import string so each worker can import the app
|
||||
use_import_string = args.workers > 1 or args.reload
|
||||
# Check for uvloop availability
|
||||
try:
|
||||
import uvloop # noqa: F401
|
||||
# Check for uvloop/winloop availability
|
||||
import sys
|
||||
|
||||
loop_impl = "uvloop"
|
||||
print("uvloop available, will use for event loop")
|
||||
except ImportError:
|
||||
loop_impl = "asyncio"
|
||||
print("uvloop not installed, using default asyncio event loop")
|
||||
loop_impl = "asyncio"
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import winloop
|
||||
|
||||
winloop.install() # Patches asyncio globally — uvicorn uses "asyncio" but gets winloop
|
||||
loop_impl = "asyncio" # Tell uvicorn "asyncio" — it's now winloop underneath
|
||||
print("winloop installed as asyncio event loop policy (Windows uvloop port)")
|
||||
except ImportError:
|
||||
print("winloop not installed, using default asyncio event loop")
|
||||
else:
|
||||
try:
|
||||
import uvloop # noqa: F401
|
||||
|
||||
loop_impl = "uvloop"
|
||||
print("uvloop available, will use for event loop")
|
||||
except ImportError:
|
||||
print("uvloop not installed, using default asyncio event loop")
|
||||
|
||||
uvicorn_config = {
|
||||
"app": "hindsight_api.server:app" if use_import_string else app,
|
||||
|
||||
@@ -42,6 +42,9 @@ class MCPToolsConfig:
|
||||
# How to resolve api_key_id for usage metering (set by MCP middleware after auth)
|
||||
api_key_id_resolver: Callable[[], str | None] | None = None
|
||||
|
||||
# How to resolve mcp_authenticated flag (set when MCP_AUTH_TOKEN validates)
|
||||
mcp_authenticated_resolver: Callable[[], bool] | None = None
|
||||
|
||||
# Whether to include bank_id as a parameter on tools (for multi-bank support)
|
||||
include_bank_id_param: bool = False
|
||||
|
||||
@@ -64,7 +67,10 @@ def _get_request_context(config: MCPToolsConfig) -> RequestContext:
|
||||
api_key = config.api_key_resolver() if config.api_key_resolver else None
|
||||
tenant_id = config.tenant_id_resolver() if config.tenant_id_resolver else None
|
||||
api_key_id = config.api_key_id_resolver() if config.api_key_id_resolver else None
|
||||
return RequestContext(api_key=api_key, tenant_id=tenant_id, api_key_id=api_key_id)
|
||||
mcp_authenticated = config.mcp_authenticated_resolver() if config.mcp_authenticated_resolver else False
|
||||
return RequestContext(
|
||||
api_key=api_key, tenant_id=tenant_id, api_key_id=api_key_id, mcp_authenticated=mcp_authenticated
|
||||
)
|
||||
|
||||
|
||||
def parse_timestamp(timestamp: str) -> datetime | None:
|
||||
@@ -95,6 +101,7 @@ def build_content_dict(
|
||||
tags: list[str] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
document_id: str | None = None,
|
||||
strategy: str | None = None,
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
"""Build a content dict for retain operations.
|
||||
|
||||
@@ -105,10 +112,24 @@ def build_content_dict(
|
||||
tags: Optional tags for scoped visibility filtering
|
||||
metadata: Optional key-value metadata to attach to the memory
|
||||
document_id: Optional document ID to associate the memory with
|
||||
strategy: Optional named retain strategy override (e.g., 'exact', 'verbose')
|
||||
|
||||
Returns:
|
||||
Tuple of (content_dict, error_message). error_message is None if successful.
|
||||
"""
|
||||
# Coerce tags from JSON string to list if needed.
|
||||
# MCP tool bridges sometimes serialize JSON arrays as strings during
|
||||
# transport, e.g. '["a", "b"]' instead of ["a", "b"].
|
||||
if isinstance(tags, str):
|
||||
try:
|
||||
parsed = json.loads(tags)
|
||||
if isinstance(parsed, list):
|
||||
tags = parsed
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
if isinstance(tags, str):
|
||||
tags = [tags]
|
||||
|
||||
content_dict: dict[str, Any] = {"content": content, "context": context}
|
||||
|
||||
if timestamp:
|
||||
@@ -124,6 +145,8 @@ def build_content_dict(
|
||||
content_dict["metadata"] = metadata
|
||||
if document_id is not None:
|
||||
content_dict["document_id"] = document_id
|
||||
if strategy is not None:
|
||||
content_dict["strategy"] = strategy
|
||||
|
||||
return content_dict, None
|
||||
|
||||
@@ -353,6 +376,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||
metadata: dict[str, str] | None = None,
|
||||
document_id: str | None = None,
|
||||
bank_id: str | None = None,
|
||||
strategy: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Args:
|
||||
@@ -363,12 +387,13 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||
metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'})
|
||||
document_id: Optional document ID to associate this memory with
|
||||
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
|
||||
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
|
||||
"""
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"status": "error", "message": "No bank_id configured"}
|
||||
|
||||
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id)
|
||||
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
|
||||
if error:
|
||||
return {"status": "error", "message": error}
|
||||
|
||||
@@ -402,6 +427,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||
tags: list[str] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
document_id: str | None = None,
|
||||
strategy: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Args:
|
||||
@@ -411,12 +437,13 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||
tags: Optional tags for scoped visibility filtering (e.g., ['project:alpha', 'user:123'])
|
||||
metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'})
|
||||
document_id: Optional document ID to associate this memory with
|
||||
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
|
||||
"""
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"status": "error", "message": "No bank_id configured"}
|
||||
|
||||
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id)
|
||||
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
|
||||
if error:
|
||||
return {"status": "error", "message": error}
|
||||
|
||||
|
||||
@@ -13,7 +13,12 @@ This module provides metrics for:
|
||||
|
||||
import logging
|
||||
import os
|
||||
import resource
|
||||
import types
|
||||
|
||||
try:
|
||||
import resource
|
||||
except ImportError:
|
||||
resource: types.ModuleType | None = None # Windows doesn't have resource module
|
||||
import threading
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
@@ -455,6 +460,8 @@ class MetricsCollector(MetricsCollectorBase):
|
||||
|
||||
def _setup_process_metrics(self):
|
||||
"""Set up observable gauges for process metrics."""
|
||||
if resource is None:
|
||||
return # Skip process metrics on Windows
|
||||
|
||||
def get_cpu_times(_options):
|
||||
"""Get process CPU times."""
|
||||
|
||||
@@ -21,6 +21,7 @@ class RequestContext:
|
||||
api_key_id: str | None = None # UUID of the API key used for authentication
|
||||
tenant_id: str | None = None # Tenant identifier (set by extension after auth)
|
||||
internal: bool = False # True for background/internal operations (skips extension auth)
|
||||
mcp_authenticated: bool = False # True when MCP transport auth already validated (skips tenant re-auth)
|
||||
user_initiated: bool = False # True for async operations that originated from a user request
|
||||
allowed_bank_ids: list[str] | None = None # None = unrestricted (all banks)
|
||||
|
||||
|
||||
@@ -302,19 +302,106 @@ class WorkerPoller:
|
||||
)
|
||||
|
||||
async def _mark_failed(self, operation_id: str, error_message: str, schema: str | None):
|
||||
"""Mark a task as failed with error message."""
|
||||
"""Mark a task as failed with error message, then propagate to parent if applicable."""
|
||||
table = fq_table("async_operations", schema)
|
||||
# Truncate error message if too long (max 5000 chars in schema)
|
||||
error_message = error_message[:5000] if len(error_message) > 5000 else error_message
|
||||
await self._pool.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'failed', error_message = $2, completed_at = now(), updated_at = now()
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
operation_id,
|
||||
error_message,
|
||||
)
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'failed', error_message = $2, completed_at = now(), updated_at = now()
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
operation_id,
|
||||
error_message,
|
||||
)
|
||||
await self._maybe_update_parent_operation(operation_id, schema, conn)
|
||||
|
||||
async def _maybe_update_parent_operation(self, child_operation_id: str, schema: str | None, conn) -> None:
|
||||
"""If this operation is a child of a batch_retain, update the parent status when all siblings are done.
|
||||
|
||||
Must be called within an active transaction that has already updated the child's status.
|
||||
The memory engine has an equivalent method that runs inside task execution transactions.
|
||||
This poller-level version handles the case where a task fails via an unhandled exception
|
||||
that bypasses the memory engine's own failure path (e.g. a DB constraint violation that
|
||||
rolls back the engine's transaction before it can update the parent).
|
||||
"""
|
||||
import json
|
||||
import uuid
|
||||
|
||||
table = fq_table("async_operations", schema)
|
||||
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
f"SELECT result_metadata, bank_id FROM {table} WHERE operation_id = $1",
|
||||
uuid.UUID(child_operation_id),
|
||||
)
|
||||
if not row:
|
||||
return
|
||||
|
||||
result_metadata = row["result_metadata"] or {}
|
||||
if isinstance(result_metadata, str):
|
||||
result_metadata = json.loads(result_metadata)
|
||||
parent_operation_id = result_metadata.get("parent_operation_id")
|
||||
if not parent_operation_id:
|
||||
return
|
||||
|
||||
bank_id = row["bank_id"]
|
||||
|
||||
# Lock parent to prevent concurrent sibling updates
|
||||
parent_row = await conn.fetchrow(
|
||||
f"SELECT operation_id FROM {table} WHERE operation_id = $1 AND bank_id = $2 FOR UPDATE",
|
||||
uuid.UUID(parent_operation_id),
|
||||
bank_id,
|
||||
)
|
||||
if not parent_row:
|
||||
return
|
||||
|
||||
# Check whether all siblings are done
|
||||
siblings = await conn.fetch(
|
||||
f"""
|
||||
SELECT status FROM {table}
|
||||
WHERE bank_id = $1
|
||||
AND result_metadata::jsonb @> $2::jsonb
|
||||
""",
|
||||
bank_id,
|
||||
json.dumps({"parent_operation_id": parent_operation_id}),
|
||||
)
|
||||
if not siblings or not all(s["status"] in ("completed", "failed") for s in siblings):
|
||||
return
|
||||
|
||||
any_failed = any(s["status"] == "failed" for s in siblings)
|
||||
if any_failed:
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'failed', error_message = $2, updated_at = now()
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
uuid.UUID(parent_operation_id),
|
||||
"One or more sub-batches failed",
|
||||
)
|
||||
else:
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'completed', updated_at = now(), completed_at = now()
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
uuid.UUID(parent_operation_id),
|
||||
)
|
||||
logger.info(
|
||||
f"Poller updated parent operation {parent_operation_id} to "
|
||||
f"{'failed' if any_failed else 'completed'} (all siblings done)"
|
||||
)
|
||||
except Exception as e:
|
||||
# Log but don't re-raise — the child has already been marked failed,
|
||||
# which is the critical state change. A stuck parent will be caught on
|
||||
# the next run or via monitoring.
|
||||
logger.error(f"Failed to update parent operation for child {child_operation_id}: {e}")
|
||||
|
||||
async def _schedule_retry(self, operation_id: str, retry_at: "Any", error_message: str, schema: str | None):
|
||||
"""Reset task to pending with a future retry timestamp."""
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api-slim"
|
||||
version = "0.4.17"
|
||||
version = "0.4.20"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -40,12 +40,13 @@ dependencies = [
|
||||
"anthropic>=0.40.0",
|
||||
"typer>=0.9.0",
|
||||
"cohere>=5.0.0",
|
||||
"litellm>=1.0.0",
|
||||
"litellm>=1.0.0,<=1.82.6", # 1.82.7+ contains a supply chain attack (malicious .pth credential stealer)
|
||||
"markitdown[pdf,docx,pptx,xlsx,xls]>=0.1.4", # File to markdown conversion
|
||||
"obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed)
|
||||
"uvloop>=0.22.1",
|
||||
"winloop>=0.1.0; sys_platform == 'win32'",
|
||||
"uvloop>=0.22.1; sys_platform != 'win32'",
|
||||
# Transitive dependency security fixes
|
||||
"pyasn1>=0.6.2", # DoS vulnerability fix
|
||||
"pyasn1>=0.6.3", # DoS vulnerability fix
|
||||
"urllib3>=2.6.3", # Decompression-bomb safeguards bypass fix
|
||||
"langchain-core>=1.2.11", # Serialization injection + SSRF vulnerability fix
|
||||
"langsmith>=0.6.3", # SSRF via tracing header injection fix
|
||||
@@ -53,9 +54,13 @@ dependencies = [
|
||||
"pillow>=12.1.1", # Out-of-bounds write in PSD image loading fix
|
||||
"cryptography>=46.0.5", # Subgroup attack vulnerability fix
|
||||
"filelock>=3.20.1", # TOCTOU race condition fix
|
||||
"authlib>=1.6.6", # Account takeover vulnerability fix
|
||||
"authlib>=1.6.9", # Account takeover/JWS header injection vulnerability fix
|
||||
"pyjwt>=2.12.0", # Accepts unknown crit header extensions fix
|
||||
"orjson>=3.11.6", # Unbounded recursion DoS fix
|
||||
"tornado>=6.5.5", # DoS multipart/incomplete cookie validation fix
|
||||
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities
|
||||
"claude-agent-sdk>=0.1.27",
|
||||
"boto3>=1.42.74",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -168,9 +173,16 @@ quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.uv]
|
||||
# Allow uv to search all configured indexes for packages, not just the first one
|
||||
# This prevents dependency resolution failures when using pytorch index + PyPI
|
||||
index-strategy = "unsafe-best-match"
|
||||
# Use explicit index for PyTorch to prevent the pytorch index from serving
|
||||
# non-pytorch packages (e.g. markupsafe) with incompatible wheels
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-cpu"
|
||||
url = "https://download.pytorch.org/whl/cpu"
|
||||
explicit = true
|
||||
|
||||
[tool.uv.sources]
|
||||
# Route torch to the CPU-only PyTorch index; everything else uses PyPI
|
||||
torch = { index = "pytorch-cpu" }
|
||||
|
||||
[tool.ty]
|
||||
# Type checking configuration
|
||||
|
||||
@@ -48,7 +48,8 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
|
||||
Session-scoped fixture that ensures pg0 is running, migrations are applied,
|
||||
and returns the database URL.
|
||||
|
||||
If HINDSIGHT_API_DATABASE_URL is set, uses that directly (no pg0 management).
|
||||
If HINDSIGHT_API_DATABASE_URL is a plain postgresql:// URL, uses it directly.
|
||||
If HINDSIGHT_API_DATABASE_URL is a pg0:// URL, resolves it to a real URL first.
|
||||
Otherwise, starts pg0 once for the entire test session.
|
||||
|
||||
Uses filelock to ensure only one pytest-xdist worker starts pg0.
|
||||
@@ -58,10 +59,23 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
|
||||
Note: We don't stop pg0 at the end because pytest-xdist runs workers in separate
|
||||
processes that share the same pg0 instance. pg0 will persist for the next test run.
|
||||
"""
|
||||
if db_url:
|
||||
# Use provided database URL directly
|
||||
from hindsight_api.pg0 import parse_pg0_url as _parse_pg0_url
|
||||
|
||||
# Determine pg0 instance name/port from db_url (if it's a pg0:// URL) or use defaults
|
||||
if db_url and not _parse_pg0_url(db_url)[0]:
|
||||
# Plain postgresql:// URL - use it directly but still run migrations
|
||||
from hindsight_api.migrations import run_migrations
|
||||
run_migrations(db_url)
|
||||
return db_url
|
||||
|
||||
if db_url:
|
||||
_, pg0_name, pg0_port = _parse_pg0_url(db_url)
|
||||
pg0_instance_name = pg0_name or DEFAULT_PG0_INSTANCE_NAME
|
||||
pg0_instance_port = pg0_port or DEFAULT_PG0_PORT
|
||||
else:
|
||||
pg0_instance_name = DEFAULT_PG0_INSTANCE_NAME
|
||||
pg0_instance_port = DEFAULT_PG0_PORT
|
||||
|
||||
# Get shared temp dir for coordination between xdist workers
|
||||
if worker_id == "master":
|
||||
# Running without xdist (-n 0 or no -n flag)
|
||||
@@ -71,8 +85,8 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
|
||||
root_tmp_dir = tmp_path_factory.getbasetemp().parent
|
||||
|
||||
# Use a lock file to ensure only one worker starts pg0
|
||||
lock_file = root_tmp_dir / "pg0_setup.lock"
|
||||
url_file = root_tmp_dir / "pg0_url.txt"
|
||||
lock_file = root_tmp_dir / f"pg0_setup_{pg0_instance_name}.lock"
|
||||
url_file = root_tmp_dir / f"pg0_url_{pg0_instance_name}.txt"
|
||||
|
||||
with filelock.FileLock(str(lock_file)):
|
||||
if url_file.exists():
|
||||
@@ -80,7 +94,7 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id):
|
||||
url = url_file.read_text().strip()
|
||||
else:
|
||||
# First worker - start pg0
|
||||
pg0 = EmbeddedPostgres(name=DEFAULT_PG0_INSTANCE_NAME, port=DEFAULT_PG0_PORT)
|
||||
pg0 = EmbeddedPostgres(name=pg0_instance_name, port=pg0_instance_port)
|
||||
|
||||
# Run ensure_running in a new event loop
|
||||
loop = asyncio.new_event_loop()
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
"""Tests for consolidation failure handling: adaptive batch splitting, consolidation_failed_at,
|
||||
and the recovery API.
|
||||
|
||||
These tests use a mock LLM to simulate LLM failures deterministically, without making real
|
||||
API calls. All tests insert memories directly into the database to bypass retain's LLM calls
|
||||
and focus exclusively on the consolidation code paths.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from hindsight_api.engine.consolidation.consolidator import run_consolidation_job
|
||||
from hindsight_api.engine.memory_engine import MemoryEngine
|
||||
from hindsight_api.engine.providers.mock_llm import MockLLM
|
||||
from hindsight_api.engine.task_backend import SyncTaskBackend
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def memory_no_llm_verify(pg0_db_url, embeddings, cross_encoder, query_analyzer):
|
||||
"""MemoryEngine with mock LLM.
|
||||
|
||||
Migrations are already applied by the session-scoped pg0_db_url fixture, so
|
||||
run_migrations=False avoids advisory-lock serialization overhead per test.
|
||||
"""
|
||||
mem = MemoryEngine(
|
||||
db_url=pg0_db_url,
|
||||
memory_llm_provider="mock",
|
||||
memory_llm_api_key="",
|
||||
memory_llm_model="mock",
|
||||
embeddings=embeddings,
|
||||
cross_encoder=cross_encoder,
|
||||
query_analyzer=query_analyzer,
|
||||
pool_min_size=1,
|
||||
pool_max_size=5,
|
||||
run_migrations=False,
|
||||
task_backend=SyncTaskBackend(),
|
||||
skip_llm_verification=True,
|
||||
)
|
||||
await mem.initialize()
|
||||
yield mem
|
||||
try:
|
||||
if mem._pool and not mem._pool._closing:
|
||||
await mem.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_observations():
|
||||
"""Enable observations for all tests in this module."""
|
||||
from hindsight_api.config import _get_raw_config
|
||||
|
||||
config = _get_raw_config()
|
||||
original = config.enable_observations
|
||||
config.enable_observations = True
|
||||
yield
|
||||
config.enable_observations = original
|
||||
|
||||
|
||||
def _make_failing_mock_llm(*, fail_first_n: int = 999) -> MockLLM:
|
||||
"""Return a MockLLM that raises ValueError for the first `fail_first_n` consolidation calls."""
|
||||
mock_llm = MockLLM(provider="mock", api_key="", base_url="", model="mock-model")
|
||||
call_count = 0
|
||||
|
||||
def callback(messages, scope):
|
||||
nonlocal call_count
|
||||
if scope == "consolidation":
|
||||
call_count += 1
|
||||
if call_count <= fail_first_n:
|
||||
raise ValueError(f"Simulated LLM failure (call {call_count})")
|
||||
# Return empty response — no creates/updates/deletes
|
||||
from hindsight_api.engine.consolidation.consolidator import _ConsolidationBatchResponse
|
||||
|
||||
return _ConsolidationBatchResponse()
|
||||
|
||||
mock_llm.set_response_callback(callback)
|
||||
return mock_llm
|
||||
|
||||
|
||||
def _make_always_success_mock_llm() -> MockLLM:
|
||||
"""Return a MockLLM that always succeeds with an empty consolidation response."""
|
||||
mock_llm = MockLLM(provider="mock", api_key="", base_url="", model="mock-model")
|
||||
|
||||
def callback(messages, scope):
|
||||
from hindsight_api.engine.consolidation.consolidator import _ConsolidationBatchResponse
|
||||
|
||||
return _ConsolidationBatchResponse()
|
||||
|
||||
mock_llm.set_response_callback(callback)
|
||||
return mock_llm
|
||||
|
||||
|
||||
def _inject_mock_llm(memory: MemoryEngine, mock_llm: MockLLM) -> None:
|
||||
"""Replace memory._consolidation_llm_config with a wrapper that returns mock_llm from with_config."""
|
||||
wrapper = MagicMock()
|
||||
wrapper.with_config.return_value = mock_llm
|
||||
memory._consolidation_llm_config = wrapper
|
||||
|
||||
|
||||
async def _insert_memories(conn, bank_id: str, texts: list[str]) -> list[uuid.UUID]:
|
||||
"""Insert experience memories directly, bypassing LLM-based retain."""
|
||||
ids = []
|
||||
for text in texts:
|
||||
mem_id = uuid.uuid4()
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO memory_units (id, bank_id, text, fact_type, created_at)
|
||||
VALUES ($1, $2, $3, 'experience', now())
|
||||
""",
|
||||
mem_id,
|
||||
bank_id,
|
||||
text,
|
||||
)
|
||||
ids.append(mem_id)
|
||||
return ids
|
||||
|
||||
|
||||
class TestAdaptiveBatchSplitting:
|
||||
"""Verify that a failing batch is halved and retried until batch_size=1 succeeds."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_splitting_recovers_all_memories(self, memory_no_llm_verify: MemoryEngine, request_context):
|
||||
"""When a batch of 2 fails, both are retried individually and succeed."""
|
||||
bank_id = f"test-split-recovery-{uuid.uuid4().hex[:8]}"
|
||||
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
async with memory_no_llm_verify._pool.acquire() as conn:
|
||||
mem_ids = await _insert_memories(
|
||||
conn,
|
||||
bank_id,
|
||||
[
|
||||
"Alice runs marathons every spring.",
|
||||
"Alice trained for six months for her last race.",
|
||||
],
|
||||
)
|
||||
|
||||
# Exhaust all 3 retries for batch=2 (calls 1-3 fail), then each batch=1 succeeds (calls 4-5)
|
||||
mock_llm = _make_failing_mock_llm(fail_first_n=3)
|
||||
_inject_mock_llm(memory_no_llm_verify, mock_llm)
|
||||
|
||||
result = await run_consolidation_job(
|
||||
memory_engine=memory_no_llm_verify,
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert result["status"] == "completed"
|
||||
assert result["memories_processed"] == 2
|
||||
assert result["memories_failed"] == 0
|
||||
|
||||
# Both memories must have consolidated_at set and consolidation_failed_at NULL
|
||||
async with memory_no_llm_verify._pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, consolidated_at, consolidation_failed_at
|
||||
FROM memory_units
|
||||
WHERE bank_id = $1 AND fact_type = 'experience'
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
assert len(rows) == 2
|
||||
for row in rows:
|
||||
assert row["consolidated_at"] is not None, f"Memory {row['id']} should have consolidated_at set"
|
||||
assert row["consolidation_failed_at"] is None, (
|
||||
f"Memory {row['id']} should NOT have consolidation_failed_at set"
|
||||
)
|
||||
|
||||
# LLM called 5 times: 3 retries failed (batch=2) + 1 succeeded (batch=1) + 1 succeeded (batch=1)
|
||||
consolidation_calls = [c for c in mock_llm.get_mock_calls() if c["scope"] == "consolidation"]
|
||||
assert len(consolidation_calls) == 5
|
||||
|
||||
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_splitting_with_larger_batch(self, memory_no_llm_verify: MemoryEngine, request_context):
|
||||
"""A batch of 4 that always fails at size>1 resolves to 4 individual calls."""
|
||||
bank_id = f"test-split-large-{uuid.uuid4().hex[:8]}"
|
||||
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
async with memory_no_llm_verify._pool.acquire() as conn:
|
||||
await _insert_memories(
|
||||
conn,
|
||||
bank_id,
|
||||
[
|
||||
"Bob plays chess competitively.",
|
||||
"Bob won a regional chess tournament.",
|
||||
"Bob practices tactics every morning.",
|
||||
"Bob coaches youth chess on weekends.",
|
||||
],
|
||||
)
|
||||
|
||||
# Exhaust all 3 retries for batch=4 (calls 1-3 fail), then both batch=2 halves succeed
|
||||
# (calls 4-5). This verifies that halving once is sufficient when batch=2 works.
|
||||
mock_llm = _make_failing_mock_llm(fail_first_n=3)
|
||||
_inject_mock_llm(memory_no_llm_verify, mock_llm)
|
||||
|
||||
result = await run_consolidation_job(
|
||||
memory_engine=memory_no_llm_verify,
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert result["memories_processed"] == 4
|
||||
assert result["memories_failed"] == 0
|
||||
|
||||
async with memory_no_llm_verify._pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT consolidated_at, consolidation_failed_at FROM memory_units "
|
||||
"WHERE bank_id = $1 AND fact_type = 'experience'",
|
||||
bank_id,
|
||||
)
|
||||
assert all(r["consolidated_at"] is not None for r in rows)
|
||||
assert all(r["consolidation_failed_at"] is None for r in rows)
|
||||
|
||||
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestConsolidationFailedAt:
|
||||
"""Verify that consolidation_failed_at is set — and consolidated_at is NOT — when all retries fail."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_memory_permanent_failure(self, memory_no_llm_verify: MemoryEngine, request_context):
|
||||
"""A single memory that exhausts all LLM retries gets consolidation_failed_at, not consolidated_at."""
|
||||
bank_id = f"test-perm-fail-{uuid.uuid4().hex[:8]}"
|
||||
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
async with memory_no_llm_verify._pool.acquire() as conn:
|
||||
(mem_id,) = await _insert_memories(conn, bank_id, ["Carol enjoys painting watercolors."])
|
||||
|
||||
# Always fail
|
||||
mock_llm = _make_failing_mock_llm(fail_first_n=999)
|
||||
_inject_mock_llm(memory_no_llm_verify, mock_llm)
|
||||
|
||||
result = await run_consolidation_job(
|
||||
memory_engine=memory_no_llm_verify,
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert result["memories_failed"] == 1
|
||||
assert result["memories_processed"] == 1
|
||||
|
||||
async with memory_no_llm_verify._pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT consolidated_at, consolidation_failed_at FROM memory_units WHERE id = $1",
|
||||
mem_id,
|
||||
)
|
||||
|
||||
assert row["consolidated_at"] is None, "consolidated_at must NOT be set for a permanently failed memory"
|
||||
assert row["consolidation_failed_at"] is not None, "consolidation_failed_at must be set"
|
||||
|
||||
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_failed_memory_excluded_from_next_run(self, memory_no_llm_verify: MemoryEngine, request_context):
|
||||
"""A memory marked consolidation_failed_at is not re-processed on the next consolidation run."""
|
||||
bank_id = f"test-excluded-{uuid.uuid4().hex[:8]}"
|
||||
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
async with memory_no_llm_verify._pool.acquire() as conn:
|
||||
(mem_id,) = await _insert_memories(conn, bank_id, ["Dave collects vinyl records."])
|
||||
# Manually stamp consolidation_failed_at to simulate a prior failed run
|
||||
await conn.execute(
|
||||
"UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1",
|
||||
mem_id,
|
||||
)
|
||||
|
||||
# Even with a healthy LLM, the memory should be skipped
|
||||
mock_llm = _make_always_success_mock_llm()
|
||||
_inject_mock_llm(memory_no_llm_verify, mock_llm)
|
||||
|
||||
result = await run_consolidation_job(
|
||||
memory_engine=memory_no_llm_verify,
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# No unconsolidated memories to pick up (consolidation_failed_at ≠ NULL, consolidated_at = NULL
|
||||
# but the SELECT filters on consolidated_at IS NULL AND fact_type IN ('experience','world'))
|
||||
assert result["status"] in ("no_new_memories", "completed")
|
||||
if result["status"] == "completed":
|
||||
assert result["memories_processed"] == 0
|
||||
|
||||
# Memory still has consolidation_failed_at set and consolidated_at NULL
|
||||
async with memory_no_llm_verify._pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT consolidated_at, consolidation_failed_at FROM memory_units WHERE id = $1",
|
||||
mem_id,
|
||||
)
|
||||
assert row["consolidated_at"] is None
|
||||
assert row["consolidation_failed_at"] is not None
|
||||
|
||||
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_partial_batch_failure(self, memory_no_llm_verify: MemoryEngine, request_context):
|
||||
"""In a batch of 2, if only the first individual retry fails, the second still succeeds."""
|
||||
bank_id = f"test-partial-fail-{uuid.uuid4().hex[:8]}"
|
||||
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
async with memory_no_llm_verify._pool.acquire() as conn:
|
||||
mem_ids = await _insert_memories(
|
||||
conn,
|
||||
bank_id,
|
||||
[
|
||||
"Eve speaks three languages fluently.",
|
||||
"Eve learned Japanese in two years.",
|
||||
],
|
||||
)
|
||||
|
||||
# Exhaust 3 retries for batch=2 (calls 1-3), exhaust 3 retries for first batch=1 (calls 4-6),
|
||||
# second batch=1 succeeds (call 7)
|
||||
mock_llm = _make_failing_mock_llm(fail_first_n=6)
|
||||
_inject_mock_llm(memory_no_llm_verify, mock_llm)
|
||||
|
||||
result = await run_consolidation_job(
|
||||
memory_engine=memory_no_llm_verify,
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert result["memories_processed"] == 2
|
||||
assert result["memories_failed"] == 1
|
||||
|
||||
async with memory_no_llm_verify._pool.acquire() as conn:
|
||||
rows = {
|
||||
str(r["id"]): r
|
||||
for r in await conn.fetch(
|
||||
"SELECT id, consolidated_at, consolidation_failed_at FROM memory_units "
|
||||
"WHERE bank_id = $1 AND fact_type = 'experience'",
|
||||
bank_id,
|
||||
)
|
||||
}
|
||||
|
||||
# One should have failed, one should have succeeded
|
||||
failed = [r for r in rows.values() if r["consolidation_failed_at"] is not None]
|
||||
succeeded = [r for r in rows.values() if r["consolidated_at"] is not None]
|
||||
assert len(failed) == 1
|
||||
assert len(succeeded) == 1
|
||||
# They must be different memories
|
||||
assert str(failed[0]["id"]) != str(succeeded[0]["id"])
|
||||
|
||||
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestRecoverConsolidation:
|
||||
"""Verify the retry_failed_consolidation() method and the /consolidation/recover endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recover_resets_failed_memories(self, memory_no_llm_verify: MemoryEngine, request_context):
|
||||
"""retry_failed_consolidation resets consolidation_failed_at and consolidated_at."""
|
||||
bank_id = f"test-recover-reset-{uuid.uuid4().hex[:8]}"
|
||||
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
async with memory_no_llm_verify._pool.acquire() as conn:
|
||||
ids = await _insert_memories(
|
||||
conn,
|
||||
bank_id,
|
||||
[
|
||||
"Frank is a competitive cyclist.",
|
||||
"Frank completed the Tour de France route.",
|
||||
],
|
||||
)
|
||||
# Mark both as failed
|
||||
for mem_id in ids:
|
||||
await conn.execute(
|
||||
"UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1",
|
||||
mem_id,
|
||||
)
|
||||
|
||||
result = await memory_no_llm_verify.retry_failed_consolidation(
|
||||
bank_id, request_context=request_context
|
||||
)
|
||||
|
||||
assert result["retried_count"] == 2
|
||||
|
||||
async with memory_no_llm_verify._pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"SELECT consolidated_at, consolidation_failed_at FROM memory_units "
|
||||
"WHERE bank_id = $1 AND fact_type = 'experience'",
|
||||
bank_id,
|
||||
)
|
||||
assert all(r["consolidation_failed_at"] is None for r in rows), "consolidation_failed_at must be cleared"
|
||||
assert all(r["consolidated_at"] is None for r in rows), "consolidated_at must also be cleared"
|
||||
|
||||
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recover_returns_zero_when_none_failed(self, memory_no_llm_verify: MemoryEngine, request_context):
|
||||
"""retry_failed_consolidation returns 0 when no memories have failed."""
|
||||
bank_id = f"test-recover-zero-{uuid.uuid4().hex[:8]}"
|
||||
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
result = await memory_no_llm_verify.retry_failed_consolidation(
|
||||
bank_id, request_context=request_context
|
||||
)
|
||||
|
||||
assert result["retried_count"] == 0
|
||||
|
||||
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recover_then_consolidate_succeeds(self, memory_no_llm_verify: MemoryEngine, request_context):
|
||||
"""After recovery, the memory is picked up by the next consolidation run."""
|
||||
bank_id = f"test-recover-consolidate-{uuid.uuid4().hex[:8]}"
|
||||
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
async with memory_no_llm_verify._pool.acquire() as conn:
|
||||
(mem_id,) = await _insert_memories(conn, bank_id, ["Grace is an expert rock climber."])
|
||||
await conn.execute(
|
||||
"UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1", mem_id
|
||||
)
|
||||
|
||||
# Recover
|
||||
recover_result = await memory_no_llm_verify.retry_failed_consolidation(
|
||||
bank_id, request_context=request_context
|
||||
)
|
||||
assert recover_result["retried_count"] == 1
|
||||
|
||||
# Now consolidate with a healthy LLM
|
||||
mock_llm = _make_always_success_mock_llm()
|
||||
_inject_mock_llm(memory_no_llm_verify, mock_llm)
|
||||
|
||||
run_result = await run_consolidation_job(
|
||||
memory_engine=memory_no_llm_verify,
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert run_result["memories_processed"] == 1
|
||||
assert run_result["memories_failed"] == 0
|
||||
|
||||
async with memory_no_llm_verify._pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"SELECT consolidated_at, consolidation_failed_at FROM memory_units WHERE id = $1",
|
||||
mem_id,
|
||||
)
|
||||
assert row["consolidated_at"] is not None, "Memory should be consolidated after recovery"
|
||||
assert row["consolidation_failed_at"] is None
|
||||
|
||||
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recover_endpoint_via_http(self, memory_no_llm_verify: MemoryEngine, request_context):
|
||||
"""The POST /consolidation/recover endpoint returns the correct retried_count."""
|
||||
import httpx
|
||||
|
||||
from hindsight_api.api.http import create_app
|
||||
|
||||
bank_id = f"test-recover-http-{uuid.uuid4().hex[:8]}"
|
||||
await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
async with memory_no_llm_verify._pool.acquire() as conn:
|
||||
ids = await _insert_memories(
|
||||
conn,
|
||||
bank_id,
|
||||
["Henry is a professional chef.", "Henry trained at Le Cordon Bleu."],
|
||||
)
|
||||
for mem_id in ids:
|
||||
await conn.execute(
|
||||
"UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1", mem_id
|
||||
)
|
||||
|
||||
app = create_app(memory_no_llm_verify, initialize_memory=False)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
response = await client.post(f"/v1/default/banks/{bank_id}/consolidation/recover")
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["retried_count"] == 2
|
||||
|
||||
await memory_no_llm_verify.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
Tests for EntityResolver edge cases.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.entity_resolver import EntityResolver
|
||||
from hindsight_api.pg0 import resolve_database_url
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Unit tests for discard_pending_stats() — no database required
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_pending_stats_clears_both_dicts():
|
||||
"""discard_pending_stats() must remove entries for the current task from
|
||||
both _pending_stats and _pending_cooccurrences."""
|
||||
resolver = EntityResolver(pool=None) # type: ignore[arg-type]
|
||||
key = resolver._task_key()
|
||||
|
||||
resolver._pending_stats[key] = [object()] # type: ignore[list-item]
|
||||
resolver._pending_cooccurrences[key] = [object()] # type: ignore[list-item]
|
||||
|
||||
resolver.discard_pending_stats()
|
||||
|
||||
assert key not in resolver._pending_stats
|
||||
assert key not in resolver._pending_cooccurrences
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_pending_stats_is_idempotent():
|
||||
"""Calling discard_pending_stats() when nothing is pending must not raise."""
|
||||
resolver = EntityResolver(pool=None) # type: ignore[arg-type]
|
||||
resolver.discard_pending_stats()
|
||||
resolver.discard_pending_stats() # second call — still safe
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_pending_stats_does_not_affect_other_task_keys():
|
||||
"""discard_pending_stats() must only remove the current task's entries,
|
||||
leaving entries keyed under other task IDs untouched."""
|
||||
resolver = EntityResolver(pool=None) # type: ignore[arg-type]
|
||||
other_key = -1 # A fake key that can never be a real task id
|
||||
|
||||
resolver._pending_stats[other_key] = [object()] # type: ignore[list-item]
|
||||
resolver._pending_cooccurrences[other_key] = [object()] # type: ignore[list-item]
|
||||
|
||||
resolver.discard_pending_stats() # discards current task's key only
|
||||
|
||||
assert other_key in resolver._pending_stats, "other task's stats must be preserved"
|
||||
assert other_key in resolver._pending_cooccurrences, "other task's cooccurrences must be preserved"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_entities_batch_handles_unicode_lower_conflicts(pg0_db_url):
|
||||
"""
|
||||
Existing entities with PostgreSQL/Python lowercase mismatches should resolve
|
||||
to the conflicted row instead of leaving a missing entity_id.
|
||||
"""
|
||||
resolved_url = await resolve_database_url(pg0_db_url)
|
||||
pool = await asyncpg.create_pool(resolved_url, min_size=1, max_size=2, command_timeout=30)
|
||||
bank_id = f"test-entity-resolver-{uuid.uuid4().hex[:8]}"
|
||||
event_date = datetime(2024, 1, 15, tzinfo=timezone.utc)
|
||||
resolver = EntityResolver(pool=pool, entity_lookup="full")
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
existing_entity_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count)
|
||||
VALUES ($1, $2, $3, $3, 1)
|
||||
RETURNING id
|
||||
""",
|
||||
bank_id,
|
||||
"İstanbul",
|
||||
event_date,
|
||||
)
|
||||
|
||||
resolved_ids = await resolver.resolve_entities_batch(
|
||||
bank_id=bank_id,
|
||||
entities_data=[
|
||||
{
|
||||
"text": "istanbul",
|
||||
"nearby_entities": [],
|
||||
"event_date": event_date,
|
||||
}
|
||||
],
|
||||
context="unicode case mismatch",
|
||||
unit_event_date=event_date,
|
||||
conn=conn,
|
||||
)
|
||||
|
||||
entity_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, canonical_name
|
||||
FROM entities
|
||||
WHERE bank_id = $1
|
||||
ORDER BY canonical_name
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
assert resolved_ids == [existing_entity_id]
|
||||
assert len(entity_rows) == 1
|
||||
assert entity_rows[0]["id"] == existing_entity_id
|
||||
assert entity_rows[0]["canonical_name"] == "İstanbul"
|
||||
finally:
|
||||
await pool.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
|
||||
await pool.close()
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Unit tests for EntityResolver pg_trgm auto-detection (PR #626/#649).
|
||||
|
||||
These tests verify:
|
||||
1. When entity_lookup="trigram" and pg_trgm IS available, the trigram path is used.
|
||||
2. When entity_lookup="trigram" and pg_trgm is NOT available, the resolver falls back
|
||||
to entity_lookup="full" and uses the full-scan path.
|
||||
3. The pg_trgm check is only performed once (_pg_trgm_checked flag prevents re-checking).
|
||||
4. When entity_lookup="full" from the start, the trgm check is never performed.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.entity_resolver import EntityResolver
|
||||
|
||||
|
||||
def _make_conn(pg_trgm_available: bool) -> MagicMock:
|
||||
"""Create a minimal mock asyncpg connection for the pg_trgm availability check."""
|
||||
conn = MagicMock()
|
||||
conn.fetchval = AsyncMock(return_value=pg_trgm_available)
|
||||
conn.fetch = AsyncMock(return_value=[])
|
||||
conn.executemany = AsyncMock()
|
||||
conn.fetchrow = AsyncMock(return_value=None)
|
||||
return conn
|
||||
|
||||
|
||||
def _make_resolver(entity_lookup: str = "trigram") -> EntityResolver:
|
||||
"""Return an EntityResolver with a None pool (not needed for unit tests)."""
|
||||
return EntityResolver(pool=None, entity_lookup=entity_lookup) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestPgTrgmAutoDetection:
|
||||
"""Unit tests for pg_trgm detection logic inside _resolve_entities_batch_impl."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_full_when_pg_trgm_unavailable(self):
|
||||
"""When pg_trgm is absent the resolver switches to 'full' and calls the full-scan path."""
|
||||
resolver = _make_resolver(entity_lookup="trigram")
|
||||
conn = _make_conn(pg_trgm_available=False)
|
||||
|
||||
with (
|
||||
patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])) as mock_full,
|
||||
patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])) as mock_trgm,
|
||||
):
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="test-bank",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
|
||||
# Trigram path must NOT be called
|
||||
mock_trgm.assert_not_called()
|
||||
# Full-scan path must be called as the fallback
|
||||
mock_full.assert_called_once()
|
||||
# Strategy is permanently downgraded
|
||||
assert resolver.entity_lookup == "full"
|
||||
assert resolver._pg_trgm_checked is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_uses_trigram_when_pg_trgm_available(self):
|
||||
"""When pg_trgm is present the trigram path is used."""
|
||||
resolver = _make_resolver(entity_lookup="trigram")
|
||||
conn = _make_conn(pg_trgm_available=True)
|
||||
|
||||
with (
|
||||
patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])) as mock_full,
|
||||
patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])) as mock_trgm,
|
||||
):
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="test-bank",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
|
||||
mock_trgm.assert_called_once()
|
||||
mock_full.assert_not_called()
|
||||
assert resolver.entity_lookup == "trigram"
|
||||
assert resolver._pg_trgm_checked is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pg_trgm_check_performed_only_once(self):
|
||||
"""The fetchval check is only issued on the first call; subsequent calls skip it."""
|
||||
resolver = _make_resolver(entity_lookup="trigram")
|
||||
conn = _make_conn(pg_trgm_available=True)
|
||||
|
||||
with patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])):
|
||||
# First call — check is issued
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="test-bank",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
# Second call — check must NOT be issued again
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="test-bank",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
|
||||
# fetchval (the pg_trgm availability query) should be called exactly once
|
||||
assert conn.fetchval.call_count == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_strategy_skips_pg_trgm_check(self):
|
||||
"""When entity_lookup='full' from the start, no pg_trgm check is ever issued."""
|
||||
resolver = _make_resolver(entity_lookup="full")
|
||||
conn = _make_conn(pg_trgm_available=False)
|
||||
|
||||
with patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])):
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="test-bank",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
|
||||
# fetchval should never be called when entity_lookup is already "full"
|
||||
conn.fetchval.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_is_sticky_across_calls(self):
|
||||
"""After falling back to 'full', subsequent calls also use the full path."""
|
||||
resolver = _make_resolver(entity_lookup="trigram")
|
||||
conn = _make_conn(pg_trgm_available=False)
|
||||
|
||||
with (
|
||||
patch.object(resolver, "_resolve_entities_batch_full", new=AsyncMock(return_value=[])) as mock_full,
|
||||
patch.object(resolver, "_resolve_entities_batch_trigram", new=AsyncMock(return_value=[])) as mock_trgm,
|
||||
):
|
||||
# First call triggers the fallback
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="b",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
# Second call — _pg_trgm_checked is True so no re-check; entity_lookup=="full"
|
||||
await resolver._resolve_entities_batch_impl(
|
||||
conn=conn,
|
||||
bank_id="b",
|
||||
entities_data=[],
|
||||
context="",
|
||||
unit_event_date=None,
|
||||
)
|
||||
|
||||
# Trigram path is never called
|
||||
mock_trgm.assert_not_called()
|
||||
# Full-scan path is called both times
|
||||
assert mock_full.call_count == 2
|
||||
# pg_trgm check was issued exactly once
|
||||
assert conn.fetchval.call_count == 1
|
||||
@@ -5,6 +5,7 @@ End-to-end tests for file retain (upload, convert, retain) functionality.
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
@@ -471,6 +472,77 @@ async def test_file_conversion_creates_separate_retain_operation(memory_no_llm_v
|
||||
assert len(doc["original_text"]) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_file_retain_serializes_datetime_timestamp(memory_no_llm_verify, sample_txt_content):
|
||||
"""Async file retain should accept Python datetimes in task payloads."""
|
||||
from hindsight_api.engine.parsers.base import FileParser
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
bank_id = f"test_file_timestamp_bank_{datetime.now(timezone.utc).timestamp()}"
|
||||
timestamp = datetime(2024, 1, 15, 10, 30, tzinfo=timezone.utc)
|
||||
|
||||
context = RequestContext(internal=True)
|
||||
await memory_no_llm_verify.get_bank_profile(bank_id, request_context=context)
|
||||
|
||||
class MockFile:
|
||||
def __init__(self, content, filename, content_type):
|
||||
self.content = content
|
||||
self.filename = filename
|
||||
self.content_type = content_type
|
||||
|
||||
async def read(self):
|
||||
return self.content
|
||||
|
||||
class TimestampParser(FileParser):
|
||||
async def convert(self, file_data: bytes, filename: str) -> str:
|
||||
return file_data.decode("utf-8")
|
||||
|
||||
def supports(self, filename: str, content_type: str | None = None) -> bool:
|
||||
return filename.endswith(".txt")
|
||||
|
||||
def name(self) -> str:
|
||||
return "timestamp_parser"
|
||||
|
||||
memory_no_llm_verify._parser_registry.register(TimestampParser())
|
||||
|
||||
mock_file = MockFile(sample_txt_content, "timestamped.txt", "text/plain")
|
||||
|
||||
result = await memory_no_llm_verify.submit_async_file_retain(
|
||||
bank_id=bank_id,
|
||||
file_items=[
|
||||
{
|
||||
"file": mock_file,
|
||||
"document_id": "timestamped_doc",
|
||||
"context": "timestamp test",
|
||||
"metadata": {},
|
||||
"tags": [],
|
||||
"timestamp": timestamp,
|
||||
"parser": ["timestamp_parser"],
|
||||
}
|
||||
],
|
||||
document_tags=None,
|
||||
request_context=context,
|
||||
)
|
||||
|
||||
operation_id = result["operation_ids"][0]
|
||||
pool = await memory_no_llm_verify._get_pool()
|
||||
from hindsight_api.engine.memory_engine import get_current_schema
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT status, task_payload->>'timestamp' AS timestamp
|
||||
FROM {get_current_schema()}.async_operations
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
operation_id,
|
||||
)
|
||||
|
||||
assert row is not None
|
||||
assert row["status"] == "completed"
|
||||
assert row["timestamp"] == "2024-01-15T10:30:00+00:00"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_file_conversion_failure_sets_status_to_failed(memory_no_llm_verify, sample_txt_content):
|
||||
"""Test that when file conversion fails, the operation status is set to 'failed' not 'completed'."""
|
||||
|
||||
@@ -89,7 +89,7 @@ async def test_hierarchical_fields_categorization():
|
||||
assert "entity_labels" in configurable
|
||||
|
||||
# Verify count is correct
|
||||
assert len(configurable) == 17
|
||||
assert len(configurable) == 20
|
||||
|
||||
# Verify credential fields (NEVER exposed)
|
||||
assert "llm_api_key" in credentials
|
||||
@@ -402,7 +402,7 @@ async def test_config_get_bank_config_no_static_or_credential_fields_leak(memory
|
||||
assert field in config, f"Expected configurable field '{field}' missing from config"
|
||||
|
||||
# Should have a small number of configurable fields (not hundreds)
|
||||
assert len(config) < 20, f"Too many fields returned: {len(config)}"
|
||||
assert len(config) < 25, f"Too many fields returned: {len(config)}"
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@@ -35,6 +35,7 @@ MODEL_MATRIX = [
|
||||
("gemini", "gemini-2.5-flash"),
|
||||
("gemini", "gemini-2.5-flash-lite"),
|
||||
("gemini", "gemini-3-pro-preview"),
|
||||
("gemini", "gemini-3.1-flash-lite-preview"),
|
||||
# Ollama models (local)
|
||||
("ollama", "gemma3:12b"),
|
||||
("ollama", "gemma3:1b"),
|
||||
@@ -42,6 +43,8 @@ MODEL_MATRIX = [
|
||||
("claude-code", "claude-sonnet-4-20250514"),
|
||||
# OpenAI Codex (uses MCP with Codex-specific models)
|
||||
("openai-codex", "gpt-5.2-codex"),
|
||||
# Bedrock models (via LiteLLM)
|
||||
("bedrock", "us.amazon.nova-2-lite-v1:0"),
|
||||
# Mock provider (for testing)
|
||||
("mock", "mock"),
|
||||
]
|
||||
@@ -77,6 +80,12 @@ def should_skip_provider(provider: str, model: str = "") -> tuple[bool, str]:
|
||||
if provider == "ollama" and "gemma" in model.lower():
|
||||
return True, f"Ollama {model} does not support tool calling"
|
||||
|
||||
# Bedrock needs AWS credentials
|
||||
if provider == "bedrock":
|
||||
if not os.getenv("AWS_ACCESS_KEY_ID"):
|
||||
return True, "No AWS credentials available (set AWS_ACCESS_KEY_ID)"
|
||||
return False, ""
|
||||
|
||||
# Other providers need an API key
|
||||
if provider not in ("ollama", "claude-code", "openai-codex", "mock"):
|
||||
api_key = get_api_key_for_provider(provider)
|
||||
@@ -226,7 +235,7 @@ async def test_llm_provider_api_methods(provider: str, model: str):
|
||||
|
||||
@pytest.mark.parametrize("provider,model", MODEL_MATRIX)
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.timeout(300)
|
||||
@pytest.mark.timeout(600) # 600s: some providers (e.g., bedrock via litellm) need extra time for fact extraction
|
||||
async def test_llm_provider_memory_operations(provider: str, model: str):
|
||||
"""
|
||||
Test LLM provider with actual memory operations: fact extraction and reflect.
|
||||
|
||||
@@ -149,6 +149,16 @@ class TestLargeBatchRetain:
|
||||
call_tracker = {"count": 0, "facts": 0}
|
||||
|
||||
async def mock_llm_call(*args, **kwargs):
|
||||
from hindsight_api.engine.consolidation.consolidator import _ConsolidationBatchResponse
|
||||
|
||||
# Consolidation calls expect a _ConsolidationBatchResponse (not a raw dict),
|
||||
# because consolidation does NOT use skip_validation=True.
|
||||
if kwargs.get("scope") == "consolidation":
|
||||
return_usage = kwargs.get("return_usage", False)
|
||||
if return_usage:
|
||||
return _ConsolidationBatchResponse(), TokenUsage(input_tokens=0, output_tokens=0)
|
||||
return _ConsolidationBatchResponse()
|
||||
|
||||
call_tracker["count"] += 1
|
||||
|
||||
# Extract the content from the user message to generate proportional facts
|
||||
@@ -157,7 +167,7 @@ class TestLargeBatchRetain:
|
||||
mock_facts = create_mock_facts_from_content(user_msg, ratio=1.5)
|
||||
call_tracker["facts"] += len(mock_facts)
|
||||
|
||||
# Return a dict (parsed JSON) since skip_validation=True but the code expects a dict
|
||||
# Return a dict (parsed JSON) — fact extraction uses skip_validation=True
|
||||
response_dict = {"facts": mock_facts}
|
||||
|
||||
return_usage = kwargs.get("return_usage", False)
|
||||
@@ -236,6 +246,14 @@ class TestLargeBatchRetain:
|
||||
logger.info(f"Created {num_items} items with {actual_total_chars:,} chars (should trigger chunking)")
|
||||
|
||||
async def mock_llm_call(*args, **kwargs):
|
||||
from hindsight_api.engine.consolidation.consolidator import _ConsolidationBatchResponse
|
||||
|
||||
if kwargs.get("scope") == "consolidation":
|
||||
return_usage = kwargs.get("return_usage", False)
|
||||
if return_usage:
|
||||
return _ConsolidationBatchResponse(), TokenUsage(input_tokens=0, output_tokens=0)
|
||||
return _ConsolidationBatchResponse()
|
||||
|
||||
messages = kwargs.get("messages", args[0] if args else [])
|
||||
user_msg = messages[-1]["content"] if messages else ""
|
||||
mock_facts = create_mock_facts_from_content(user_msg, ratio=1.0)
|
||||
|
||||
@@ -4,6 +4,9 @@ This test verifies that /mcp/ and /mcp/{bank_id}/ expose different tool sets,
|
||||
and that URLs with or without trailing slashes both work (no 307 redirect).
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp.client.session import ClientSession
|
||||
@@ -278,3 +281,93 @@ async def test_mcp_bank_named_messages_routes_to_single_bank(memory):
|
||||
|
||||
assert "retain" in tools
|
||||
assert "list_banks" not in tools, "Bank 'messages' should route to single-bank mode"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tool_execution_with_different_mcp_and_tenant_tokens(memory):
|
||||
"""Test that MCP tool calls work when MCP_AUTH_TOKEN and TENANT_API_KEY differ.
|
||||
|
||||
Regression test for https://github.com/vectorize-io/hindsight/issues/627
|
||||
When both HINDSIGHT_API_MCP_AUTH_TOKEN and ApiKeyTenantExtension are configured
|
||||
with different values, tool calls should succeed because MCP transport auth
|
||||
already validated the token — the tenant extension should not re-validate.
|
||||
"""
|
||||
from httpx import ASGITransport
|
||||
|
||||
from hindsight_api.api import create_app
|
||||
from hindsight_api.extensions import ApiKeyTenantExtension
|
||||
|
||||
mcp_token = "mcp-secret-token"
|
||||
tenant_key = "tenant-secret-key"
|
||||
|
||||
# Configure ApiKeyTenantExtension with a different key than the MCP token
|
||||
tenant_ext = ApiKeyTenantExtension({"api_key": tenant_key})
|
||||
memory._tenant_extension = tenant_ext
|
||||
|
||||
# Patch MCP_AUTH_TOKEN so the MCP middleware uses legacy auth
|
||||
with patch("hindsight_api.api.mcp.MCP_AUTH_TOKEN", mcp_token):
|
||||
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
# Pass auth header via the httpx client (streamable_http_client doesn't accept headers)
|
||||
async with httpx.AsyncClient(
|
||||
transport=ASGITransport(app=app),
|
||||
base_url="http://test",
|
||||
headers={"Authorization": f"Bearer {mcp_token}"},
|
||||
) as http_client:
|
||||
async with streamable_http_client("http://test/mcp/", http_client=http_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
|
||||
# list_tools should work
|
||||
tools_result = await session.list_tools()
|
||||
tool_names = {t.name for t in tools_result.tools}
|
||||
assert "get_bank" in tool_names
|
||||
|
||||
# Tool execution should work (this was failing before the fix)
|
||||
result = await session.call_tool("list_banks", arguments={})
|
||||
assert result is not None
|
||||
assert len(result.content) > 0
|
||||
parsed = json.loads(result.content[0].text)
|
||||
assert "banks" in parsed
|
||||
assert "error" not in parsed, f"Tool call failed with: {parsed.get('error')}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_rejects_wrong_mcp_token_even_if_matches_tenant_key(memory):
|
||||
"""Test that an invalid MCP token is rejected even if it matches the tenant key.
|
||||
|
||||
When MCP_AUTH_TOKEN is set, the MCP middleware should validate against that token,
|
||||
not the tenant API key.
|
||||
"""
|
||||
from httpx import ASGITransport
|
||||
|
||||
from hindsight_api.api import create_app
|
||||
from hindsight_api.extensions import ApiKeyTenantExtension
|
||||
|
||||
mcp_token = "mcp-secret-token"
|
||||
tenant_key = "tenant-secret-key"
|
||||
|
||||
tenant_ext = ApiKeyTenantExtension({"api_key": tenant_key})
|
||||
memory._tenant_extension = tenant_ext
|
||||
|
||||
with patch("hindsight_api.api.mcp.MCP_AUTH_TOKEN", mcp_token):
|
||||
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
|
||||
# Try connecting with the tenant key (wrong for MCP auth)
|
||||
response = await http_client.post(
|
||||
"http://test/mcp/",
|
||||
headers={
|
||||
"Authorization": f"Bearer {tenant_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
},
|
||||
json={"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}},
|
||||
)
|
||||
assert response.status_code == 401
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests for migration g7h8i9j0k1l2 (backsweep orphaned memory_units).
|
||||
|
||||
Uses a dedicated pg0 instance (port 5562) so the test can control exactly
|
||||
which migrations have run before inserting the orphan seed data.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SCRIPT_LOCATION = str(Path(__file__).parent.parent / "hindsight_api" / "alembic")
|
||||
|
||||
|
||||
def _alembic_cfg(db_url: str) -> Config:
|
||||
cfg = Config()
|
||||
cfg.set_main_option("script_location", _SCRIPT_LOCATION)
|
||||
cfg.set_main_option("sqlalchemy.url", db_url)
|
||||
cfg.set_main_option("prepend_sys_path", ".")
|
||||
cfg.set_main_option("path_separator", "os")
|
||||
return cfg
|
||||
|
||||
|
||||
def _upgrade(db_url: str, revision: str) -> None:
|
||||
command.upgrade(_alembic_cfg(db_url), revision)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixture: fresh database at the revision just before the backsweep
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pre_backsweep_db_url():
|
||||
"""
|
||||
Spin up a dedicated pg0 instance and run all migrations up to (but not
|
||||
including) the backsweep revision so each test can seed orphan data and
|
||||
then apply the backsweep itself.
|
||||
"""
|
||||
from hindsight_api.pg0 import EmbeddedPostgres
|
||||
|
||||
pg0 = EmbeddedPostgres(name="hindsight-backsweep-test", port=5562)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
url = loop.run_until_complete(pg0.ensure_running())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
# Migrate up to the revision just before the backsweep.
|
||||
_upgrade(url, "f6g7h8i9j0k1")
|
||||
return url
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# The test
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_backsweep_removes_orphans_and_preserves_legit_rows(pre_backsweep_db_url):
|
||||
"""
|
||||
Seed four kinds of rows then apply the backsweep migration and verify:
|
||||
|
||||
Rows that MUST be deleted
|
||||
─────────────────────────
|
||||
A. Any fact_type, bank_id missing from banks
|
||||
→ Pass 1 deletes these regardless of fact_type or source links.
|
||||
|
||||
B. observation, bank exists, but ALL source_memory_ids are gone
|
||||
→ Pass 2 deletes these.
|
||||
|
||||
Rows that MUST survive
|
||||
──────────────────────
|
||||
C. observation, bank exists, at least ONE source_memory_id still live
|
||||
→ Pass 2 must not touch these.
|
||||
|
||||
D. Non-observation (world), bank exists, no sources (not relevant)
|
||||
→ Pass 1 must not touch these (bank exists).
|
||||
"""
|
||||
db_url = pre_backsweep_db_url
|
||||
engine = create_engine(db_url)
|
||||
|
||||
alive_bank = f"bank_{uuid.uuid4().hex[:8]}"
|
||||
ghost_bank = f"bank_{uuid.uuid4().hex[:8]}" # never inserted into banks
|
||||
|
||||
# UUIDs for memory units
|
||||
id_pass1_world = uuid.uuid4() # A: world unit, ghost bank
|
||||
id_pass1_obs = uuid.uuid4() # A: observation, ghost bank
|
||||
id_pass2_obs = uuid.uuid4() # B: observation, all sources gone
|
||||
id_keep_obs = uuid.uuid4() # C: observation with one live source
|
||||
id_keep_world = uuid.uuid4() # D: world unit, alive bank
|
||||
id_live_source = uuid.uuid4() # live source for C
|
||||
|
||||
with engine.connect() as conn:
|
||||
# --- banks ---
|
||||
conn.execute(text("INSERT INTO banks (bank_id) VALUES (:b)"), {"b": alive_bank})
|
||||
|
||||
# --- seed memory_units ---
|
||||
def insert_mu(uid, bank, fact_type, sources=None):
|
||||
src_arr = "{" + ",".join(str(s) for s in (sources or [])) + "}"
|
||||
conn.execute(
|
||||
text(
|
||||
"""
|
||||
INSERT INTO memory_units
|
||||
(id, bank_id, text, fact_type, source_memory_ids)
|
||||
VALUES
|
||||
(:id, :bank, :text, :ft, CAST(:src AS uuid[]))
|
||||
"""
|
||||
),
|
||||
{"id": uid, "bank": bank, "text": "test", "ft": fact_type, "src": src_arr},
|
||||
)
|
||||
|
||||
# A: ghost-bank rows (Pass 1 targets)
|
||||
insert_mu(id_pass1_world, ghost_bank, "world")
|
||||
insert_mu(id_pass1_obs, ghost_bank, "observation", sources=[uuid.uuid4()])
|
||||
|
||||
# B: observation with all-dead sources (Pass 2 target)
|
||||
insert_mu(id_pass2_obs, alive_bank, "observation", sources=[uuid.uuid4(), uuid.uuid4()])
|
||||
|
||||
# C: observation with one live source (must survive)
|
||||
insert_mu(id_live_source, alive_bank, "world")
|
||||
insert_mu(id_keep_obs, alive_bank, "observation", sources=[id_live_source, uuid.uuid4()])
|
||||
|
||||
# D: world unit in alive bank (must survive)
|
||||
insert_mu(id_keep_world, alive_bank, "world")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# --- apply the backsweep ---
|
||||
_upgrade(db_url, "g7h8i9j0k1l2")
|
||||
|
||||
# --- verify ---
|
||||
with engine.connect() as conn:
|
||||
def exists(uid):
|
||||
return conn.execute(
|
||||
text("SELECT 1 FROM memory_units WHERE id = :id"), {"id": uid}
|
||||
).fetchone() is not None
|
||||
|
||||
# Must be gone
|
||||
assert not exists(id_pass1_world), "Pass 1: world unit with ghost bank should be deleted"
|
||||
assert not exists(id_pass1_obs), "Pass 1: observation with ghost bank should be deleted"
|
||||
assert not exists(id_pass2_obs), "Pass 2: observation with all-dead sources should be deleted"
|
||||
|
||||
# Must survive
|
||||
assert exists(id_keep_obs), "observation with a live source must not be deleted"
|
||||
assert exists(id_keep_world), "world unit in alive bank must not be deleted"
|
||||
assert exists(id_live_source), "live source memory unit must not be deleted"
|
||||
|
||||
engine.dispose()
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Tests for the 'none' LLM provider mode.
|
||||
|
||||
Verifies that when HINDSIGHT_API_LLM_PROVIDER=none:
|
||||
- Retain defaults to chunks mode (no LLM calls)
|
||||
- Reflect returns 400
|
||||
- Mental model refresh returns 400
|
||||
- Consolidation is skipped
|
||||
- NoneLLM.call() raises LLMNotAvailableError
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from hindsight_api import LLMConfig, LocalSTEmbeddings, MemoryEngine, RequestContext
|
||||
from hindsight_api.api import create_app
|
||||
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
from hindsight_api.engine.providers.none_llm import LLMNotAvailableError, NoneLLM
|
||||
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
|
||||
from hindsight_api.engine.task_backend import SyncTaskBackend
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def request_context():
|
||||
return RequestContext()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def none_memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
|
||||
"""MemoryEngine with provider=none."""
|
||||
mem = MemoryEngine(
|
||||
db_url=pg0_db_url,
|
||||
memory_llm_provider="none",
|
||||
memory_llm_api_key=None,
|
||||
memory_llm_model="none",
|
||||
embeddings=embeddings,
|
||||
cross_encoder=cross_encoder,
|
||||
query_analyzer=query_analyzer,
|
||||
pool_min_size=1,
|
||||
pool_max_size=5,
|
||||
run_migrations=False,
|
||||
task_backend=SyncTaskBackend(),
|
||||
)
|
||||
await mem.initialize()
|
||||
yield mem
|
||||
try:
|
||||
if mem._pool and not mem._pool._closing:
|
||||
await mem.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def none_api_client(none_memory):
|
||||
"""HTTP test client backed by a none-provider MemoryEngine."""
|
||||
app = create_app(none_memory, initialize_memory=False)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
|
||||
# -- Unit tests for NoneLLM ---------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_llm_call_raises():
|
||||
"""NoneLLM.call() should raise LLMNotAvailableError."""
|
||||
llm = NoneLLM(provider="none", api_key="", base_url="", model="none")
|
||||
with pytest.raises(LLMNotAvailableError):
|
||||
await llm.call(messages=[{"role": "user", "content": "hello"}])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_llm_call_with_tools_raises():
|
||||
"""NoneLLM.call_with_tools() should raise LLMNotAvailableError."""
|
||||
llm = NoneLLM(provider="none", api_key="", base_url="", model="none")
|
||||
with pytest.raises(LLMNotAvailableError):
|
||||
await llm.call_with_tools(
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
tools=[{"type": "function", "function": {"name": "test", "parameters": {}}}],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_llm_verify_connection_succeeds():
|
||||
"""NoneLLM.verify_connection() should be a no-op."""
|
||||
llm = NoneLLM(provider="none", api_key="", base_url="", model="none")
|
||||
await llm.verify_connection() # Should not raise
|
||||
|
||||
|
||||
# -- Config validation tests ---------------------------------------------------
|
||||
|
||||
|
||||
def test_config_forces_chunks_mode():
|
||||
"""When provider is 'none', config.validate() forces retain_extraction_mode='chunks'."""
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
config = HindsightConfig.from_env()
|
||||
# Override to none for test
|
||||
config.llm_provider = "none"
|
||||
config.retain_extraction_mode = "facts"
|
||||
config.enable_observations = True
|
||||
config.validate()
|
||||
|
||||
assert config.retain_extraction_mode == "chunks"
|
||||
assert config.enable_observations is False
|
||||
|
||||
|
||||
# -- Integration tests (require database) -------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retain_works_with_none_provider(none_memory, request_context):
|
||||
"""Retain should work with provider=none, storing chunks without LLM calls."""
|
||||
bank_id = f"test_none_retain_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
unit_ids = await none_memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Alice is a software engineer. She works at TechCorp and loves Python.",
|
||||
context="team info",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(unit_ids) > 0, "Should store chunks even without an LLM"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_works_with_none_provider(none_memory, request_context):
|
||||
"""Recall should work with provider=none (uses embeddings, not LLM)."""
|
||||
bank_id = f"test_none_recall_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
await none_memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Alice is a software engineer at TechCorp.",
|
||||
context="team info",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
result = await none_memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Who is Alice?",
|
||||
budget=Budget.LOW,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(result.results) > 0, "Should find results via semantic search"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_raises_with_none_provider(none_memory, request_context):
|
||||
"""Reflect should raise LLMNotAvailableError with provider=none."""
|
||||
bank_id = f"test_none_reflect_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
with pytest.raises(LLMNotAvailableError):
|
||||
await none_memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="What do you know?",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_skipped_with_none_provider(none_memory, request_context):
|
||||
"""Consolidation handler should skip when provider=none."""
|
||||
result = await none_memory._handle_consolidation({"bank_id": "test_bank"})
|
||||
assert result["skipped"] is True
|
||||
assert result["memories_processed"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mental_model_refresh_raises_with_none_provider(none_memory, request_context):
|
||||
"""Mental model refresh should raise LLMNotAvailableError with provider=none."""
|
||||
bank_id = f"test_none_mm_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
with pytest.raises(LLMNotAvailableError):
|
||||
await none_memory.submit_async_refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id="fake-id",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
|
||||
# -- HTTP API tests -----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_reflect_returns_400(none_api_client):
|
||||
"""Reflect endpoint should return 400 when LLM provider is none."""
|
||||
bank_id = f"test_none_http_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
response = await none_api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/reflect",
|
||||
json={"query": "What do you know?"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "none" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_retain_works(none_api_client):
|
||||
"""Retain endpoint should work with provider=none (chunks mode)."""
|
||||
bank_id = f"test_none_http_retain_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
response = await none_api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories",
|
||||
json={"items": [{"content": "Hello world", "context": "test"}]},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_http_recall_works(none_api_client):
|
||||
"""Recall endpoint should work with provider=none."""
|
||||
bank_id = f"test_none_http_recall_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
# Retain first
|
||||
await none_api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories",
|
||||
json={"items": [{"content": "Alice is an engineer.", "context": "test"}]},
|
||||
)
|
||||
|
||||
# Recall
|
||||
response = await none_api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories/recall",
|
||||
json={"query": "Alice"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -97,8 +97,8 @@ def test_per_operation_provider_default_model():
|
||||
|
||||
# Retain should use Anthropic default
|
||||
assert (
|
||||
config.retain_llm_model == "claude-haiku-4-5-20251001"
|
||||
), f"Expected claude-haiku-4-5-20251001, got {config.retain_llm_model}"
|
||||
config.retain_llm_model == "claude-haiku-4-5"
|
||||
), f"Expected claude-haiku-4-5, got {config.retain_llm_model}"
|
||||
|
||||
finally:
|
||||
clear_config_cache()
|
||||
|
||||
@@ -5,8 +5,10 @@ These tests verify:
|
||||
1. Tool name normalization for various LLM output formats
|
||||
2. Recovery from unknown tool calls
|
||||
3. Recovery from tool execution errors
|
||||
4. Wall-clock timeout enforcement
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -416,6 +418,32 @@ class TestReflectAgentMocked:
|
||||
assert result is not None
|
||||
assert result.iterations == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wall_clock_timeout(self, mock_llm: MagicMock, mock_functions: dict[str, AsyncMock]) -> None:
|
||||
"""Test that asyncio.wait_for can enforce a wall-clock timeout on run_reflect_agent."""
|
||||
|
||||
async def slow_llm_call(*args: object, **kwargs: object) -> LLMToolCallResult:
|
||||
await asyncio.sleep(10) # Simulate a slow LLM call
|
||||
return LLMToolCallResult(
|
||||
tool_calls=[LLMToolCall(id="1", name="recall", arguments={"query": "test"})],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
|
||||
mock_llm.call_with_tools.side_effect = slow_llm_call
|
||||
|
||||
with pytest.raises(asyncio.TimeoutError):
|
||||
await asyncio.wait_for(
|
||||
run_reflect_agent(
|
||||
llm_config=mock_llm,
|
||||
bank_id="test-bank",
|
||||
query="test query",
|
||||
bank_profile={"name": "Test", "mission": "Testing"},
|
||||
max_iterations=5,
|
||||
**mock_functions,
|
||||
),
|
||||
timeout=0.1, # Very short timeout to trigger quickly
|
||||
)
|
||||
|
||||
|
||||
class TestContextOverflowHelpers:
|
||||
"""Unit tests for context-overflow detection helpers."""
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Tests for reflect search_observations source_facts_max_tokens configuration.
|
||||
|
||||
Verifies that the source_facts_max_tokens parameter correctly controls
|
||||
whether source facts are included in search_observations recall calls.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.reflect.tools import tool_search_observations
|
||||
from hindsight_api.engine.response_models import RecallResult
|
||||
|
||||
|
||||
def _make_mock_engine(recall_result=None):
|
||||
"""Create a mock memory engine with a recall_async method."""
|
||||
if recall_result is None:
|
||||
recall_result = RecallResult(results=[], source_facts={})
|
||||
engine = MagicMock()
|
||||
engine.recall_async = AsyncMock(return_value=recall_result)
|
||||
return engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_request_context():
|
||||
return MagicMock()
|
||||
|
||||
|
||||
class TestSearchObservationsSourceFacts:
|
||||
"""Test source_facts_max_tokens parameter in tool_search_observations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_disables_source_facts(self, mock_request_context):
|
||||
"""Default source_facts_max_tokens=-1 should disable source facts."""
|
||||
engine = _make_mock_engine()
|
||||
|
||||
await tool_search_observations(
|
||||
engine, "bank-1", "test query", mock_request_context
|
||||
)
|
||||
|
||||
engine.recall_async.assert_called_once()
|
||||
call_kwargs = engine.recall_async.call_args.kwargs
|
||||
assert call_kwargs["include_source_facts"] is False
|
||||
assert "max_source_facts_tokens" not in call_kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_zero_enables_source_facts_unlimited(self, mock_request_context):
|
||||
"""source_facts_max_tokens=0 should enable source facts with no token limit."""
|
||||
engine = _make_mock_engine()
|
||||
|
||||
await tool_search_observations(
|
||||
engine, "bank-1", "test query", mock_request_context,
|
||||
source_facts_max_tokens=0,
|
||||
)
|
||||
|
||||
engine.recall_async.assert_called_once()
|
||||
call_kwargs = engine.recall_async.call_args.kwargs
|
||||
assert call_kwargs["include_source_facts"] is True
|
||||
assert "max_source_facts_tokens" not in call_kwargs
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_positive_enables_source_facts_with_limit(self, mock_request_context):
|
||||
"""source_facts_max_tokens>0 should enable source facts with a token budget."""
|
||||
engine = _make_mock_engine()
|
||||
|
||||
await tool_search_observations(
|
||||
engine, "bank-1", "test query", mock_request_context,
|
||||
source_facts_max_tokens=5000,
|
||||
)
|
||||
|
||||
engine.recall_async.assert_called_once()
|
||||
call_kwargs = engine.recall_async.call_args.kwargs
|
||||
assert call_kwargs["include_source_facts"] is True
|
||||
assert call_kwargs["max_source_facts_tokens"] == 5000
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_negative_one_disables_source_facts(self, mock_request_context):
|
||||
"""Explicit -1 should disable source facts (same as default)."""
|
||||
engine = _make_mock_engine()
|
||||
|
||||
await tool_search_observations(
|
||||
engine, "bank-1", "test query", mock_request_context,
|
||||
source_facts_max_tokens=-1,
|
||||
)
|
||||
|
||||
engine.recall_async.assert_called_once()
|
||||
call_kwargs = engine.recall_async.call_args.kwargs
|
||||
assert call_kwargs["include_source_facts"] is False
|
||||
assert "max_source_facts_tokens" not in call_kwargs
|
||||
|
||||
|
||||
class TestReflectSourceFactsConfig:
|
||||
"""Test that reflect_source_facts_max_tokens is properly wired in HindsightConfig."""
|
||||
|
||||
def test_config_field_exists(self):
|
||||
"""reflect_source_facts_max_tokens should be a valid config field."""
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
import dataclasses
|
||||
|
||||
field_names = {f.name for f in dataclasses.fields(HindsightConfig)}
|
||||
assert "reflect_source_facts_max_tokens" in field_names
|
||||
|
||||
def test_config_is_configurable(self):
|
||||
"""reflect_source_facts_max_tokens should be a configurable (per-bank) field."""
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
assert "reflect_source_facts_max_tokens" in HindsightConfig.get_configurable_fields()
|
||||
|
||||
def test_default_value_is_disabled(self):
|
||||
"""Default should be -1 (disabled)."""
|
||||
from hindsight_api.config import DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS
|
||||
|
||||
assert DEFAULT_REFLECT_SOURCE_FACTS_MAX_TOKENS == -1
|
||||
|
||||
def test_env_var_constant_exists(self):
|
||||
"""Env var constant should be defined."""
|
||||
from hindsight_api.config import ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS
|
||||
|
||||
assert ENV_REFLECT_SOURCE_FACTS_MAX_TOKENS == "HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS"
|
||||
|
||||
@patch.dict("os.environ", {"HINDSIGHT_API_REFLECT_SOURCE_FACTS_MAX_TOKENS": "8000"})
|
||||
def test_from_env_reads_value(self):
|
||||
"""from_env should parse the env var."""
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
config = HindsightConfig.from_env()
|
||||
assert config.reflect_source_facts_max_tokens == 8000
|
||||
@@ -485,3 +485,206 @@ class TestReflectUsesMentalModels:
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestMentalModelReflectOptions:
|
||||
"""Tests for fact_types and exclude_mental_models options stored in the trigger field."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_stores_fact_types(self, memory: MemoryEngine, request_context):
|
||||
"""Trigger field persists fact_types and returns them via get_mental_model."""
|
||||
bank_id = f"test-mm-trigger-ft-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
mm = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Observations only",
|
||||
source_query="Summarize observations",
|
||||
content="content",
|
||||
trigger={"refresh_after_consolidation": False, "fact_types": ["observation"]},
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context)
|
||||
assert fetched["trigger"]["fact_types"] == ["observation"]
|
||||
assert fetched["trigger"]["refresh_after_consolidation"] is False
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_stores_exclude_mental_models(self, memory: MemoryEngine, request_context):
|
||||
"""Trigger field persists exclude_mental_models flag."""
|
||||
bank_id = f"test-mm-trigger-em-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
mm = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="No mental models",
|
||||
source_query="Summarize raw facts",
|
||||
content="content",
|
||||
trigger={"refresh_after_consolidation": False, "exclude_mental_models": True},
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context)
|
||||
assert fetched["trigger"]["exclude_mental_models"] is True
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_trigger_stores_exclude_mental_model_ids(self, memory: MemoryEngine, request_context):
|
||||
"""Trigger field persists exclude_mental_model_ids list."""
|
||||
bank_id = f"test-mm-trigger-eid-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
excluded_ids = ["mm-abc", "mm-xyz"]
|
||||
mm = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Exclude some models",
|
||||
source_query="Summarize",
|
||||
content="content",
|
||||
trigger={"refresh_after_consolidation": False, "exclude_mental_model_ids": excluded_ids},
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
fetched = await memory.get_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context)
|
||||
assert fetched["trigger"]["exclude_mental_model_ids"] == excluded_ids
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_trigger_reflect_options(self, memory: MemoryEngine, request_context):
|
||||
"""update_mental_model persists updated trigger reflect options."""
|
||||
bank_id = f"test-mm-trigger-upd-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
mm = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Initially no filter",
|
||||
source_query="Summarize",
|
||||
content="content",
|
||||
trigger={"refresh_after_consolidation": False},
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
updated = await memory.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mm["id"],
|
||||
trigger={
|
||||
"refresh_after_consolidation": True,
|
||||
"fact_types": ["world", "experience"],
|
||||
"exclude_mental_models": False,
|
||||
"exclude_mental_model_ids": ["mm-skip"],
|
||||
},
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert updated["trigger"]["refresh_after_consolidation"] is True
|
||||
assert updated["trigger"]["fact_types"] == ["world", "experience"]
|
||||
assert updated["trigger"]["exclude_mental_models"] is False
|
||||
assert updated["trigger"]["exclude_mental_model_ids"] == ["mm-skip"]
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestReflectFactTypeFiltering:
|
||||
"""Tests for fact_types and exclude_mental_models filtering in reflect_async."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exclude_mental_models_skips_search_mental_models_tool(
|
||||
self, memory: MemoryEngine, request_context
|
||||
):
|
||||
"""When exclude_mental_models=True, search_mental_models is never called."""
|
||||
bank_id = f"test-reflect-exmm-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
# Create a mental model so the bank has one
|
||||
await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Existing Model",
|
||||
source_query="Q",
|
||||
content="Some content about the team",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
result = await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="Tell me about the team",
|
||||
request_context=request_context,
|
||||
exclude_mental_models=True,
|
||||
)
|
||||
|
||||
tool_names = [tc.tool for tc in result.tool_trace]
|
||||
assert "search_mental_models" not in tool_names, (
|
||||
f"search_mental_models should be excluded but found in: {tool_names}"
|
||||
)
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exclude_observations_via_fact_types(self, memory: MemoryEngine, request_context):
|
||||
"""When fact_types excludes observation, search_observations is never called."""
|
||||
bank_id = f"test-reflect-exobs-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
result = await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="Tell me something",
|
||||
request_context=request_context,
|
||||
fact_types=["world", "experience"],
|
||||
)
|
||||
|
||||
tool_names = [tc.tool for tc in result.tool_trace]
|
||||
assert "search_observations" not in tool_names, (
|
||||
f"search_observations should be excluded but found in: {tool_names}"
|
||||
)
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observation_only_fact_types_skips_recall(self, memory: MemoryEngine, request_context):
|
||||
"""When fact_types=['observation'], recall is never called."""
|
||||
bank_id = f"test-reflect-obsonly-{uuid.uuid4().hex[:8]}"
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
result = await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="Tell me something",
|
||||
request_context=request_context,
|
||||
fact_types=["observation"],
|
||||
)
|
||||
|
||||
tool_names = [tc.tool for tc in result.tool_trace]
|
||||
assert "recall" not in tool_names, f"recall should be excluded but found in: {tool_names}"
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestReflectRequestValidation:
|
||||
"""Tests for ReflectRequest and MentalModelTrigger validation via the HTTP API."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_empty_fact_types_rejected(self, api_client, test_bank_id):
|
||||
"""Passing fact_types=[] to reflect must return 422."""
|
||||
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
|
||||
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/reflect",
|
||||
json={"query": "test", "fact_types": []},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_mental_model_empty_fact_types_rejected(self, api_client, test_bank_id):
|
||||
"""Passing fact_types=[] inside trigger must return 422."""
|
||||
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
|
||||
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/mental-models",
|
||||
json={
|
||||
"name": "Test",
|
||||
"source_query": "Q",
|
||||
"trigger": {"refresh_after_consolidation": False, "fact_types": []},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""
|
||||
Test retain function and chunk storage.
|
||||
"""
|
||||
import pytest
|
||||
import logging
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api import RequestContext
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -60,7 +62,7 @@ async def test_retain_with_chunks(memory, request_context):
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
print(f"\n=== Recall Results (with chunks) ===")
|
||||
print("\n=== Recall Results (with chunks) ===")
|
||||
print(f"Found {len(result.results)} results")
|
||||
|
||||
assert len(result.results) > 0, "Should find facts about Alice"
|
||||
@@ -149,7 +151,7 @@ async def test_chunks_and_entities_follow_fact_order(memory, request_context):
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
print(f"\n=== Recall Results ===")
|
||||
print("\n=== Recall Results ===")
|
||||
print(f"Found {len(result.results)} facts")
|
||||
|
||||
# Extract the order of entities mentioned in facts
|
||||
@@ -421,7 +423,7 @@ async def test_mentioned_at_vs_occurred(memory, request_context):
|
||||
# Verify it's the historical date, not today
|
||||
assert mentioned_dt.year == 2020, f"mentioned_at should be 2020, got {mentioned_dt.year}"
|
||||
|
||||
print(f"✓ Test passed: Historical conversation correctly ingested with event_date=2020")
|
||||
print("✓ Test passed: Historical conversation correctly ingested with event_date=2020")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -489,15 +491,15 @@ async def test_occurred_dates_not_defaulted(memory, request_context):
|
||||
# If occurred_start is set, it means the LLM extracted it
|
||||
# In this case, log it but don't fail (LLM behavior can vary)
|
||||
print(f"⚠ LLM extracted occurred_start: {fact.occurred_start}")
|
||||
print(f" This test expects None for present-tense observations")
|
||||
print(" This test expects None for present-tense observations")
|
||||
else:
|
||||
print(f"✓ occurred_start is correctly None (not defaulted to mentioned_at)")
|
||||
print("✓ occurred_start is correctly None (not defaulted to mentioned_at)")
|
||||
|
||||
if fact.occurred_end is not None:
|
||||
print(f"⚠ LLM extracted occurred_end: {fact.occurred_end}")
|
||||
print(f" This test expects None for present-tense observations")
|
||||
print(" This test expects None for present-tense observations")
|
||||
else:
|
||||
print(f"✓ occurred_end is correctly None (not defaulted to mentioned_at)")
|
||||
print("✓ occurred_end is correctly None (not defaulted to mentioned_at)")
|
||||
|
||||
# At least verify they're not equal to mentioned_at if they are set
|
||||
if fact.occurred_start is not None:
|
||||
@@ -513,7 +515,7 @@ async def test_occurred_dates_not_defaulted(memory, request_context):
|
||||
f"occurred_start={occurred_start_dt}, mentioned_at={mentioned_dt}"
|
||||
)
|
||||
|
||||
print(f"✓ Test passed: occurred dates are not incorrectly defaulted to mentioned_at")
|
||||
print("✓ Test passed: occurred dates are not incorrectly defaulted to mentioned_at")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -585,7 +587,7 @@ async def test_mentioned_at_from_context_string(memory, request_context):
|
||||
else:
|
||||
print(f"⚠ LLM did not extract date from context, fell back to now(): {mentioned_dt}")
|
||||
|
||||
print(f"✓ mentioned_at is always set (never None)")
|
||||
print("✓ mentioned_at is always set (never None)")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -812,34 +814,36 @@ async def test_context_with_batch(memory, request_context):
|
||||
@pytest.mark.asyncio
|
||||
async def test_metadata_storage_and_retrieval(memory, request_context):
|
||||
"""
|
||||
Test that user-defined metadata is preserved.
|
||||
Test that user-defined metadata passed during retain is returned on recall.
|
||||
Metadata allows arbitrary key-value data to be stored with facts.
|
||||
"""
|
||||
bank_id = f"test_metadata_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Store content with custom metadata
|
||||
custom_metadata = {
|
||||
"source": "slack",
|
||||
"channel": "engineering",
|
||||
"importance": "high",
|
||||
"tags": "product,launch"
|
||||
}
|
||||
|
||||
# Note: retain_async doesn't directly support metadata parameter
|
||||
# Metadata would need to be supported in the API layer
|
||||
# For now, we test that the system handles content without errors
|
||||
unit_ids = await memory.retain_async(
|
||||
# Use retain_batch_async which supports the metadata parameter
|
||||
unit_ids_list = await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
content="The product launch is scheduled for March 1st.",
|
||||
context="planning meeting",
|
||||
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
contents=[
|
||||
{
|
||||
"content": "The product launch is scheduled for March 1st.",
|
||||
"context": "planning meeting",
|
||||
"event_date": datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
"metadata": custom_metadata,
|
||||
}
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(unit_ids) > 0, "Should create memory units"
|
||||
assert len(unit_ids_list) > 0, "Should create memory units"
|
||||
assert len(unit_ids_list[0]) > 0, "Should have at least one unit ID"
|
||||
|
||||
# Recall to verify storage worked
|
||||
# Recall and verify metadata is returned
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="When is the product launch?",
|
||||
@@ -851,8 +855,12 @@ async def test_metadata_storage_and_retrieval(memory, request_context):
|
||||
|
||||
assert len(result.results) > 0, "Should recall stored facts"
|
||||
|
||||
print(f"✓ Successfully stored and retrieved facts")
|
||||
print(f" (Note: Metadata support depends on API implementation)")
|
||||
# Verify metadata is present on recalled facts
|
||||
fact = result.results[0]
|
||||
assert fact.metadata is not None, "Metadata should not be null on recall"
|
||||
assert fact.metadata.get("source") == "slack"
|
||||
assert fact.metadata.get("channel") == "engineering"
|
||||
assert fact.metadata.get("importance") == "high"
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -954,7 +962,7 @@ async def test_mixed_content_batch(memory, request_context):
|
||||
short_units = len(unit_ids[0])
|
||||
long_units = len(unit_ids[1])
|
||||
|
||||
print(f"✓ Mixed batch processed successfully")
|
||||
print("✓ Mixed batch processed successfully")
|
||||
print(f" Short content: {short_units} units")
|
||||
print(f" Long content: {long_units} units")
|
||||
|
||||
@@ -1356,7 +1364,7 @@ async def test_chunks_truncation_behavior(memory, request_context):
|
||||
if truncated_chunks:
|
||||
print(f" {len(truncated_chunks)} chunks were truncated due to token limit")
|
||||
else:
|
||||
print(f" No chunks were truncated (content within limit)")
|
||||
print(" No chunks were truncated (content within limit)")
|
||||
|
||||
else:
|
||||
print("✓ No chunks returned (may be under token limit)")
|
||||
@@ -2210,9 +2218,10 @@ async def test_custom_extraction_mode():
|
||||
custom guidelines while keeping structural parts intact.
|
||||
"""
|
||||
import os
|
||||
|
||||
from hindsight_api import LLMConfig
|
||||
from hindsight_api.config import _get_raw_config, clear_config_cache
|
||||
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
|
||||
from hindsight_api.config import clear_config_cache, _get_raw_config
|
||||
|
||||
# Save original env vars
|
||||
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
|
||||
@@ -2284,7 +2293,7 @@ If the text contains both Italian and English content, extract ONLY the Italian
|
||||
if found_english_only:
|
||||
logger.warning(f"⚠ Found English-only keywords in facts: {found_english_only}")
|
||||
logger.warning(f" Facts: {all_facts_text}")
|
||||
logger.warning(f" This may indicate the LLM is not strictly following language-specific custom guidelines")
|
||||
logger.warning(" This may indicate the LLM is not strictly following language-specific custom guidelines")
|
||||
# Log but don't fail - LLM behavior can vary
|
||||
else:
|
||||
logger.info("✓ Successfully extracted only Italian facts, ignored English facts")
|
||||
@@ -2315,6 +2324,213 @@ If the text contains both Italian and English content, extract ONLY the Italian
|
||||
clear_config_cache()
|
||||
|
||||
|
||||
def test_apply_strategy():
|
||||
"""
|
||||
Unit test for apply_strategy:
|
||||
- Known strategy applies overrides on top of resolved config
|
||||
- Unknown strategy returns config unchanged with a warning
|
||||
- Non-hierarchical fields in a strategy are silently ignored
|
||||
- entity_labels and entities_allow_free_form are overridable
|
||||
"""
|
||||
from hindsight_api.config import _get_raw_config, clear_config_cache
|
||||
from hindsight_api.config_resolver import apply_strategy
|
||||
|
||||
clear_config_cache()
|
||||
base_config = _get_raw_config()
|
||||
|
||||
strategies = {
|
||||
"documents": {
|
||||
"retain_extraction_mode": "chunks",
|
||||
"retain_chunk_size": 800,
|
||||
"entities_allow_free_form": False,
|
||||
},
|
||||
"bad_field": {
|
||||
"database_url": "should-be-ignored", # static field, not hierarchical
|
||||
"retain_extraction_mode": "verbose",
|
||||
},
|
||||
}
|
||||
config_with_strategies = base_config.__class__(
|
||||
**{**base_config.__dict__, "retain_strategies": strategies}
|
||||
)
|
||||
|
||||
# Known strategy: overrides applied
|
||||
result = apply_strategy(config_with_strategies, "documents")
|
||||
assert result.retain_extraction_mode == "chunks"
|
||||
assert result.retain_chunk_size == 800
|
||||
assert result.entities_allow_free_form is False
|
||||
|
||||
# Non-hierarchical field silently ignored, hierarchical one applied
|
||||
result2 = apply_strategy(config_with_strategies, "bad_field")
|
||||
assert result2.retain_extraction_mode == "verbose"
|
||||
assert result2.database_url == base_config.database_url # unchanged
|
||||
|
||||
# Unknown strategy: config returned unchanged
|
||||
result3 = apply_strategy(config_with_strategies, "nonexistent")
|
||||
assert result3.retain_extraction_mode == base_config.retain_extraction_mode
|
||||
|
||||
|
||||
def test_collapse_to_verbatim_single_fact_per_chunk():
|
||||
"""
|
||||
Unit test for _collapse_to_verbatim:
|
||||
- One fact per chunk → text overridden with original chunk text
|
||||
- Two facts from same chunk → collapsed to one, entities merged
|
||||
"""
|
||||
from hindsight_api.engine.retain.fact_extraction import _collapse_to_verbatim
|
||||
from hindsight_api.engine.retain.types import ChunkMetadata, ExtractedFact
|
||||
|
||||
chunks = [
|
||||
ChunkMetadata(chunk_text="Alice went to Paris.", fact_count=1, content_index=0, chunk_index=0),
|
||||
ChunkMetadata(chunk_text="Bob fixed the bug yesterday.", fact_count=2, content_index=0, chunk_index=1),
|
||||
]
|
||||
|
||||
facts = [
|
||||
ExtractedFact(fact_text="LLM paraphrase of Alice in Paris", fact_type="world", entities=["Alice", "Paris"], chunk_index=0, content_index=0),
|
||||
ExtractedFact(fact_text="LLM first fact about Bob", fact_type="world", entities=["Bob"], chunk_index=1, content_index=0),
|
||||
ExtractedFact(fact_text="LLM second fact about bug", fact_type="world", entities=["bug"], chunk_index=1, content_index=0),
|
||||
]
|
||||
|
||||
result = _collapse_to_verbatim(facts, chunks)
|
||||
|
||||
assert len(result) == 2, "Should produce exactly one fact per chunk"
|
||||
|
||||
# Chunk 0: text overridden with original chunk text
|
||||
assert result[0].fact_text == "Alice went to Paris.", "Text must be the raw chunk text"
|
||||
assert result[0].entities == ["Alice", "Paris"]
|
||||
|
||||
# Chunk 1: collapsed to one fact, entities merged from both LLM facts
|
||||
assert result[1].fact_text == "Bob fixed the bug yesterday.", "Text must be the raw chunk text"
|
||||
assert "Bob" in result[1].entities
|
||||
assert "bug" in result[1].entities
|
||||
|
||||
|
||||
def test_chunks_extraction_mode():
|
||||
"""
|
||||
Unit test for chunks mode: no LLM, chunks stored as-is, zero token usage.
|
||||
"""
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
from hindsight_api.config import _get_raw_config, clear_config_cache
|
||||
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_contents
|
||||
from hindsight_api.engine.retain.types import RetainContent
|
||||
|
||||
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
|
||||
|
||||
try:
|
||||
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = "chunks"
|
||||
clear_config_cache()
|
||||
|
||||
contents = [
|
||||
RetainContent(
|
||||
content="Alice joined the infrastructure team on March 5, 2024.",
|
||||
event_date=datetime(2024, 3, 10, tzinfo=timezone.utc),
|
||||
entities=[{"text": "Alice"}, {"text": "infrastructure team"}],
|
||||
),
|
||||
RetainContent(content="Bob fixed the critical bug in the payment service."),
|
||||
]
|
||||
|
||||
facts, chunks, usage = asyncio.get_event_loop().run_until_complete(
|
||||
extract_facts_from_contents(
|
||||
contents=contents,
|
||||
llm_config=None, # Must not be called
|
||||
agent_name="TestAgent",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
)
|
||||
|
||||
# One fact per chunk (both contents fit in one chunk each)
|
||||
assert len(facts) == len(chunks) == 2
|
||||
|
||||
# Text preserved exactly
|
||||
assert facts[0].fact_text == contents[0].content
|
||||
assert facts[1].fact_text == contents[1].content
|
||||
|
||||
# No LLM-extracted entities (user-provided entities handled downstream)
|
||||
assert facts[0].entities == []
|
||||
assert facts[1].entities == []
|
||||
|
||||
# Zero token usage
|
||||
assert usage.total_tokens == 0
|
||||
|
||||
logger.info("✓ chunks mode: no LLM call, chunks stored as-is, zero token usage")
|
||||
|
||||
finally:
|
||||
if original_mode is not None:
|
||||
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = original_mode
|
||||
else:
|
||||
os.environ.pop("HINDSIGHT_API_RETAIN_EXTRACTION_MODE", None)
|
||||
clear_config_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_verbatim_extraction_mode():
|
||||
"""
|
||||
Integration test for verbatim extraction mode.
|
||||
|
||||
Verifies that:
|
||||
1. Each chunk produces exactly one fact
|
||||
2. The fact text is the original chunk text, not a paraphrase
|
||||
3. Entities are still extracted by the LLM
|
||||
4. Temporal info (occurred_start) is still extracted
|
||||
"""
|
||||
import os
|
||||
|
||||
from hindsight_api import LLMConfig
|
||||
from hindsight_api.config import _get_raw_config, clear_config_cache
|
||||
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_contents
|
||||
from hindsight_api.engine.retain.types import RetainContent
|
||||
|
||||
original_mode = os.getenv("HINDSIGHT_API_RETAIN_EXTRACTION_MODE")
|
||||
|
||||
try:
|
||||
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = "verbatim"
|
||||
clear_config_cache()
|
||||
|
||||
text = (
|
||||
"Alice joined the infrastructure team on March 5, 2024. "
|
||||
"She holds a CKA certification and has 5 years of Kubernetes experience."
|
||||
)
|
||||
|
||||
llm_config = LLMConfig.for_memory()
|
||||
contents = [RetainContent(content=text, event_date=datetime(2024, 3, 10, tzinfo=timezone.utc), context="onboarding notes")]
|
||||
facts, chunks, _ = await extract_facts_from_contents(
|
||||
contents=contents,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestAgent",
|
||||
config=_get_raw_config(),
|
||||
)
|
||||
|
||||
logger.info(f"Verbatim mode extracted {len(facts)} facts from {len(chunks)} chunks")
|
||||
for i, f in enumerate(facts):
|
||||
logger.info(f" fact[{i}]: {f.fact_text!r} entities={f.entities}")
|
||||
|
||||
# One fact per chunk
|
||||
assert len(facts) == len(chunks), "Verbatim mode must produce exactly one fact per chunk"
|
||||
|
||||
# Text must match the original chunk exactly
|
||||
for fact, chunk in zip(facts, chunks):
|
||||
assert fact.fact_text == chunk.chunk_text, (
|
||||
f"fact_text must equal original chunk text.\n"
|
||||
f" expected: {chunk.chunk_text!r}\n"
|
||||
f" got: {fact.fact_text!r}"
|
||||
)
|
||||
|
||||
# Entities should still be extracted
|
||||
all_entities = [e for f in facts for e in f.entities]
|
||||
assert any("alice" in e.lower() for e in all_entities), (
|
||||
f"Expected entity 'Alice' to be extracted. Entities: {all_entities}"
|
||||
)
|
||||
|
||||
logger.info("✓ Verbatim mode preserves chunk text and still extracts entities")
|
||||
|
||||
finally:
|
||||
if original_mode is not None:
|
||||
os.environ["HINDSIGHT_API_RETAIN_EXTRACTION_MODE"] = original_mode
|
||||
else:
|
||||
os.environ.pop("HINDSIGHT_API_RETAIN_EXTRACTION_MODE", None)
|
||||
clear_config_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retain_batch_with_per_item_tags_on_document(memory, request_context):
|
||||
"""
|
||||
@@ -2349,7 +2565,7 @@ async def test_retain_batch_with_per_item_tags_on_document(memory, request_conte
|
||||
)
|
||||
|
||||
assert len(result) > 0, "Should have retained content"
|
||||
print(f"\n=== Retained content with tags ===")
|
||||
print("\n=== Retained content with tags ===")
|
||||
|
||||
# Retrieve the document
|
||||
doc = await memory.get_document(
|
||||
@@ -2380,6 +2596,7 @@ async def test_retain_batch_with_per_item_tags_on_document(memory, request_conte
|
||||
def test_retain_mission_injected_into_prompt():
|
||||
"""Test that retain_mission is injected as a FOCUS section into any extraction mode."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema
|
||||
|
||||
spec = "Focus on technical decisions and architecture choices only."
|
||||
@@ -2405,6 +2622,7 @@ def test_retain_mission_injected_into_prompt():
|
||||
def test_retain_mission_absent_when_not_set():
|
||||
"""Test that no FOCUS section appears when retain_mission is not set."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from hindsight_api.engine.retain.fact_extraction import _build_extraction_prompt_and_schema
|
||||
|
||||
config = MagicMock()
|
||||
@@ -2421,6 +2639,7 @@ def test_retain_mission_absent_when_not_set():
|
||||
def test_retain_mission_config_loaded_from_env():
|
||||
"""Test that retain_mission is loaded from env and is a configurable field."""
|
||||
import os
|
||||
|
||||
from hindsight_api.config import HindsightConfig, _get_raw_config, clear_config_cache
|
||||
|
||||
original = os.getenv("HINDSIGHT_API_RETAIN_MISSION")
|
||||
@@ -2436,3 +2655,141 @@ def test_retain_mission_config_loaded_from_env():
|
||||
else:
|
||||
os.environ["HINDSIGHT_API_RETAIN_MISSION"] = original
|
||||
clear_config_cache()
|
||||
|
||||
|
||||
def test_strategy_overrides_extraction_mode_for_chunks():
|
||||
"""
|
||||
Unit test: a named strategy with retain_extraction_mode=chunks causes
|
||||
extract_facts_from_contents to skip the LLM and return verbatim chunks.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
from hindsight_api.config import _get_raw_config, clear_config_cache
|
||||
from hindsight_api.config_resolver import apply_strategy
|
||||
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_contents
|
||||
from hindsight_api.engine.retain.types import RetainContent
|
||||
|
||||
clear_config_cache()
|
||||
base_config = _get_raw_config()
|
||||
|
||||
# Build a config that has a strategy overriding to chunks
|
||||
strategies = {"fast": {"retain_extraction_mode": "chunks"}}
|
||||
config_with_strategies = base_config.__class__(
|
||||
**{**base_config.__dict__, "retain_strategies": strategies}
|
||||
)
|
||||
strategy_config = apply_strategy(config_with_strategies, "fast")
|
||||
assert strategy_config.retain_extraction_mode == "chunks"
|
||||
|
||||
contents = [
|
||||
RetainContent(content="Alice deployed the new API on Monday."),
|
||||
RetainContent(content="Bob reviewed the pull request."),
|
||||
]
|
||||
|
||||
facts, chunks, usage = asyncio.get_event_loop().run_until_complete(
|
||||
extract_facts_from_contents(
|
||||
contents=contents,
|
||||
llm_config=None, # chunks must not call the LLM
|
||||
agent_name="TestAgent",
|
||||
config=strategy_config,
|
||||
)
|
||||
)
|
||||
|
||||
assert len(facts) == 2
|
||||
assert facts[0].fact_text == contents[0].content
|
||||
assert facts[1].fact_text == contents[1].content
|
||||
assert usage.total_tokens == 0
|
||||
logger.info("✓ strategy with chunks mode: no LLM, verbatim chunks, zero tokens")
|
||||
|
||||
|
||||
def test_retain_request_per_item_strategy_field():
|
||||
"""
|
||||
Unit test: MemoryItem accepts a strategy field; items with different strategies
|
||||
are grouped correctly by per-item strategy.
|
||||
"""
|
||||
from hindsight_api.api.http import RetainRequest
|
||||
|
||||
request = RetainRequest.model_validate(
|
||||
{
|
||||
"items": [
|
||||
{"content": "Alice joined.", "strategy": "fast"},
|
||||
{"content": "Bob left.", "strategy": "detailed"},
|
||||
{"content": "Carol arrived."}, # no strategy — falls back to bank default
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert request.items[0].strategy == "fast"
|
||||
assert request.items[1].strategy == "detailed"
|
||||
assert request.items[2].strategy is None
|
||||
|
||||
# Simulate grouping logic from api_retain handler
|
||||
strategy_groups: dict = {}
|
||||
for item in request.items:
|
||||
strategy_groups.setdefault(item.strategy, []).append(item.content)
|
||||
|
||||
assert set(strategy_groups.keys()) == {"fast", "detailed", None}
|
||||
assert strategy_groups["fast"] == ["Alice joined."]
|
||||
assert strategy_groups["detailed"] == ["Bob left."]
|
||||
assert strategy_groups[None] == ["Carol arrived."]
|
||||
logger.info("✓ per-item strategy grouping works correctly")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_named_strategy_applied_end_to_end(memory, request_context):
|
||||
"""
|
||||
Integration test: a named strategy stored in bank config is actually applied
|
||||
during retain_batch_async.
|
||||
|
||||
Regression test for the bug where strategy was passed through the HTTP layer
|
||||
but the extraction mode override was silently ignored, always using the bank
|
||||
default (e.g. 'concise') instead of the strategy's override (e.g. 'chunks').
|
||||
"""
|
||||
from hindsight_api.config_resolver import ConfigResolver
|
||||
|
||||
bank_id = f"test_strategy_e2e_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Seed the bank so the row exists before we write config to it
|
||||
# (update_bank_config is a plain UPDATE — it silently no-ops on missing rows)
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[{"content": "seed"}],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Now configure the bank with a named strategy that overrides to chunks
|
||||
await memory._config_resolver.update_bank_config(
|
||||
bank_id,
|
||||
{
|
||||
"retain_extraction_mode": "concise", # bank default
|
||||
"retain_strategies": {
|
||||
"chunks": {"retain_extraction_mode": "chunks"},
|
||||
},
|
||||
},
|
||||
request_context,
|
||||
)
|
||||
|
||||
contents = [{"content": "Alice deployed the new API on Monday."}]
|
||||
|
||||
# Retain using the named strategy
|
||||
unit_ids_by_content, usage = await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=contents,
|
||||
strategy="chunks",
|
||||
request_context=request_context,
|
||||
return_usage=True,
|
||||
)
|
||||
|
||||
# chunks produces exactly one fact per chunk (verbatim) and calls no LLM
|
||||
assert usage.total_tokens == 0, f"chunks should use zero LLM tokens, got {usage.total_tokens}"
|
||||
assert len(unit_ids_by_content) == 1
|
||||
assert len(unit_ids_by_content[0]) == 1, "chunks should produce exactly one fact per content item"
|
||||
|
||||
# Verify the stored fact is the verbatim content
|
||||
facts = await memory.recall_async(bank_id, "Alice", request_context=request_context)
|
||||
assert any("Alice" in f.text for f in facts.results), "Verbatim content should be retrievable"
|
||||
|
||||
logger.info("✓ named strategy 'chunks' with chunks applied end-to-end: no LLM, verbatim storage")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""Tests for _strip_code_fences helper in OpenAI-compatible LLM provider."""
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.providers.openai_compatible_llm import _strip_code_fences
|
||||
|
||||
|
||||
class TestStripCodeFences:
|
||||
"""Test markdown code fence stripping from LLM responses."""
|
||||
|
||||
def test_bare_json_unchanged(self):
|
||||
"""Bare JSON passes through unchanged."""
|
||||
content = '{"facts": [{"what": "test"}]}'
|
||||
assert _strip_code_fences(content) == content
|
||||
|
||||
def test_json_fence_stripped(self):
|
||||
"""```json ... ``` fences are stripped."""
|
||||
content = '```json\n{"facts": [{"what": "test"}]}\n```'
|
||||
assert _strip_code_fences(content) == '{"facts": [{"what": "test"}]}'
|
||||
|
||||
def test_plain_fence_stripped(self):
|
||||
"""``` ... ``` fences without language tag are stripped."""
|
||||
content = '```\n{"facts": [{"what": "test"}]}\n```'
|
||||
assert _strip_code_fences(content) == '{"facts": [{"what": "test"}]}'
|
||||
|
||||
def test_fence_with_trailing_whitespace(self):
|
||||
"""Fences with extra whitespace are handled."""
|
||||
content = '```json\n{"facts": []}\n```\n'
|
||||
result = _strip_code_fences(content)
|
||||
assert result == '{"facts": []}'
|
||||
|
||||
def test_fence_with_leading_whitespace(self):
|
||||
"""Content with leading whitespace before fence."""
|
||||
content = ' ```json\n{"facts": []}\n```'
|
||||
# The function checks for ``` in content, not startswith
|
||||
result = _strip_code_fences(content)
|
||||
assert '{"facts": []}' in result
|
||||
|
||||
def test_no_fences_no_change(self):
|
||||
"""Content without any backticks passes through."""
|
||||
content = "Just some text without fences"
|
||||
assert _strip_code_fences(content) == content
|
||||
|
||||
def test_empty_string(self):
|
||||
"""Empty string passes through."""
|
||||
assert _strip_code_fences("") == ""
|
||||
|
||||
def test_multiline_json(self):
|
||||
"""Multi-line JSON inside fences is preserved."""
|
||||
content = '```json\n{\n "facts": [\n {"what": "line1"},\n {"what": "line2"}\n ]\n}\n```'
|
||||
result = _strip_code_fences(content)
|
||||
assert '"line1"' in result
|
||||
assert '"line2"' in result
|
||||
assert "```" not in result
|
||||
|
||||
def test_malformed_fence_returns_original(self):
|
||||
"""Malformed fences (missing closing) return something parseable."""
|
||||
content = '```json\n{"facts": []}'
|
||||
result = _strip_code_fences(content)
|
||||
# Should attempt to strip and return best effort
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_minimax_style_response(self):
|
||||
"""Real-world MiniMax response format."""
|
||||
content = (
|
||||
"```json\n"
|
||||
"{\n"
|
||||
' "facts": [\n'
|
||||
" {\n"
|
||||
' "what": "Sebastian switched the Hindsight extraction LLM",\n'
|
||||
' "when": "2026-03-21",\n'
|
||||
' "where": "N/A",\n'
|
||||
' "who": "Sebastian",\n'
|
||||
' "why": "MiniMax wraps JSON in code fences",\n'
|
||||
' "fact_kind": "event",\n'
|
||||
' "fact_type": "world",\n'
|
||||
' "entities": [{"text": "Sebastian"}, {"text": "Hindsight"}],\n'
|
||||
' "labels": {"source_type": "stated", "domain": ["infrastructure"]}\n'
|
||||
" }\n"
|
||||
" ]\n"
|
||||
"}\n"
|
||||
"```"
|
||||
)
|
||||
result = _strip_code_fences(content)
|
||||
assert not result.startswith("```")
|
||||
assert not result.endswith("```")
|
||||
# Should be valid JSON
|
||||
import json
|
||||
|
||||
parsed = json.loads(result)
|
||||
assert len(parsed["facts"]) == 1
|
||||
assert parsed["facts"][0]["who"] == "Sebastian"
|
||||
@@ -16,7 +16,16 @@ import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from hindsight_api.api import create_app
|
||||
from hindsight_api.engine.search.tags import build_tags_where_clause_simple, filter_results_by_tags
|
||||
from hindsight_api.engine.search.tags import (
|
||||
TagGroupAnd,
|
||||
TagGroupLeaf,
|
||||
TagGroupNot,
|
||||
TagGroupOr,
|
||||
build_tag_groups_where_clause,
|
||||
build_tags_where_clause_simple,
|
||||
filter_results_by_tag_groups,
|
||||
filter_results_by_tags,
|
||||
)
|
||||
|
||||
# ============================================================================
|
||||
# Unit Tests for tags SQL builder
|
||||
@@ -263,6 +272,327 @@ class TestFilterResultsByTags:
|
||||
assert missing_session not in filtered
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit Tests for build_tag_groups_where_clause (SQL builder)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestBuildTagGroupsWhereClause:
|
||||
"""Unit tests for the compound tag group SQL builder."""
|
||||
|
||||
def test_none_returns_empty(self):
|
||||
"""None tag_groups returns empty clause."""
|
||||
clause, params, next_offset = build_tag_groups_where_clause(None, 3)
|
||||
assert clause == ""
|
||||
assert params == []
|
||||
assert next_offset == 3
|
||||
|
||||
def test_empty_list_returns_empty(self):
|
||||
"""Empty tag_groups list returns empty clause."""
|
||||
clause, params, next_offset = build_tag_groups_where_clause([], 3)
|
||||
assert clause == ""
|
||||
assert params == []
|
||||
assert next_offset == 3
|
||||
|
||||
def test_single_leaf_any_strict(self):
|
||||
"""Single any_strict leaf generates correct SQL."""
|
||||
groups = [TagGroupLeaf(tags=["step:5", "step:8"], match="any_strict")]
|
||||
clause, params, next_offset = build_tag_groups_where_clause(groups, 3)
|
||||
assert clause.startswith("AND ")
|
||||
assert "$3" in clause
|
||||
assert "IS NOT NULL" in clause
|
||||
assert "!= '{}'" in clause
|
||||
assert "&&" in clause
|
||||
assert params == [["step:5", "step:8"]]
|
||||
assert next_offset == 4
|
||||
|
||||
def test_single_leaf_all_strict(self):
|
||||
"""Single all_strict leaf generates @> operator."""
|
||||
groups = [TagGroupLeaf(tags=["user:alice"], match="all_strict")]
|
||||
clause, params, next_offset = build_tag_groups_where_clause(groups, 1)
|
||||
assert "@>" in clause
|
||||
assert "IS NOT NULL" in clause
|
||||
assert params == [["user:alice"]]
|
||||
assert next_offset == 2
|
||||
|
||||
def test_single_leaf_any_includes_untagged(self):
|
||||
"""Single any (non-strict) leaf generates NULL-inclusive clause."""
|
||||
groups = [TagGroupLeaf(tags=["user:alice"], match="any")]
|
||||
clause, params, next_offset = build_tag_groups_where_clause(groups, 1)
|
||||
assert "IS NULL" in clause
|
||||
assert "= '{}'" in clause
|
||||
assert "&&" in clause
|
||||
assert params == [["user:alice"]]
|
||||
assert next_offset == 2
|
||||
|
||||
def test_and_of_two_leaves(self):
|
||||
"""AND of two leaves generates AND-joined clause."""
|
||||
groups = [
|
||||
TagGroupAnd.model_validate(
|
||||
{"and": [
|
||||
{"tags": ["step:5"], "match": "any_strict"},
|
||||
{"tags": ["user:ep_42"], "match": "all_strict"},
|
||||
]}
|
||||
)
|
||||
]
|
||||
clause, params, next_offset = build_tag_groups_where_clause(groups, 3)
|
||||
assert "AND" in clause
|
||||
assert "$3" in clause
|
||||
assert "$4" in clause
|
||||
assert len(params) == 2
|
||||
assert params[0] == ["step:5"]
|
||||
assert params[1] == ["user:ep_42"]
|
||||
assert next_offset == 5
|
||||
|
||||
def test_or_of_two_leaves(self):
|
||||
"""OR of two leaves generates OR-joined clause."""
|
||||
groups = [
|
||||
TagGroupOr.model_validate(
|
||||
{"or": [
|
||||
{"tags": ["step:5"], "match": "any_strict"},
|
||||
{"tags": ["priority:high"], "match": "all_strict"},
|
||||
]}
|
||||
)
|
||||
]
|
||||
clause, params, next_offset = build_tag_groups_where_clause(groups, 1)
|
||||
assert "OR" in clause
|
||||
assert "$1" in clause
|
||||
assert "$2" in clause
|
||||
assert len(params) == 2
|
||||
assert next_offset == 3
|
||||
|
||||
def test_not_wraps_with_not(self):
|
||||
"""NOT group wraps child clause with NOT."""
|
||||
groups = [
|
||||
TagGroupNot.model_validate(
|
||||
{"not": {"tags": ["archived"], "match": "any_strict"}}
|
||||
)
|
||||
]
|
||||
clause, params, next_offset = build_tag_groups_where_clause(groups, 2)
|
||||
assert "NOT" in clause
|
||||
assert "$2" in clause
|
||||
assert len(params) == 1
|
||||
assert next_offset == 3
|
||||
|
||||
def test_nested_and_containing_or(self):
|
||||
"""AND containing an OR generates correct nested SQL."""
|
||||
groups = [
|
||||
TagGroupAnd.model_validate(
|
||||
{"and": [
|
||||
{"tags": ["user:alice"], "match": "all_strict"},
|
||||
{"or": [
|
||||
{"tags": ["step:5"], "match": "any_strict"},
|
||||
{"tags": ["priority:high"], "match": "all_strict"},
|
||||
]},
|
||||
]}
|
||||
)
|
||||
]
|
||||
clause, params, next_offset = build_tag_groups_where_clause(groups, 1)
|
||||
assert "AND" in clause
|
||||
assert "OR" in clause
|
||||
assert len(params) == 3
|
||||
assert next_offset == 4
|
||||
|
||||
def test_param_numbering_sequential(self):
|
||||
"""Params are numbered sequentially starting from param_offset."""
|
||||
groups = [
|
||||
TagGroupAnd.model_validate(
|
||||
{"and": [
|
||||
{"tags": ["a"], "match": "any_strict"},
|
||||
{"tags": ["b"], "match": "any_strict"},
|
||||
{"tags": ["c"], "match": "any_strict"},
|
||||
]}
|
||||
)
|
||||
]
|
||||
clause, params, next_offset = build_tag_groups_where_clause(groups, 5)
|
||||
assert "$5" in clause
|
||||
assert "$6" in clause
|
||||
assert "$7" in clause
|
||||
assert next_offset == 8
|
||||
assert len(params) == 3
|
||||
|
||||
def test_table_alias_applied_to_leaves(self):
|
||||
"""Table alias is prefixed to column name in all leaf clauses."""
|
||||
groups = [TagGroupLeaf(tags=["user:alice"], match="any_strict")]
|
||||
clause, params, next_offset = build_tag_groups_where_clause(groups, 1, table_alias="mu.")
|
||||
assert "mu.tags" in clause
|
||||
|
||||
def test_table_alias_propagates_to_nested(self):
|
||||
"""Table alias propagates to nested leaves (each leaf uses the alias)."""
|
||||
groups = [
|
||||
TagGroupAnd.model_validate(
|
||||
{"and": [
|
||||
{"tags": ["a"], "match": "any_strict"},
|
||||
{"tags": ["b"], "match": "any_strict"},
|
||||
]}
|
||||
)
|
||||
]
|
||||
clause, params, next_offset = build_tag_groups_where_clause(groups, 1, table_alias="mu.")
|
||||
# Each leaf of type any_strict references mu.tags three times (IS NOT NULL, != '{}', &&)
|
||||
# We verify that 'tags' without alias is NOT present, proving the alias is always used
|
||||
assert "mu.tags" in clause
|
||||
# No bare 'tags' keyword without the alias prefix (other than inside the alias itself)
|
||||
import re
|
||||
bare_tags = re.findall(r"(?<!\.)tags", clause)
|
||||
assert len(bare_tags) == 0, f"Found bare 'tags' references without alias: {bare_tags}"
|
||||
|
||||
def test_multiple_top_level_groups_are_anded(self):
|
||||
"""Multiple top-level groups are AND-ed together."""
|
||||
groups = [
|
||||
TagGroupLeaf(tags=["step:5"], match="any_strict"),
|
||||
TagGroupLeaf(tags=["user:ep_42"], match="all_strict"),
|
||||
]
|
||||
clause, params, next_offset = build_tag_groups_where_clause(groups, 1)
|
||||
# Should start with AND and have two param refs joined by AND
|
||||
assert clause.startswith("AND ")
|
||||
assert " AND " in clause[4:] # after the leading "AND "
|
||||
assert "$1" in clause
|
||||
assert "$2" in clause
|
||||
assert len(params) == 2
|
||||
assert next_offset == 3
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit Tests for filter_results_by_tag_groups (Python-side)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestFilterResultsByTagGroups:
|
||||
"""Unit tests for the Python-side compound tag group filter."""
|
||||
|
||||
def test_none_returns_all(self):
|
||||
"""None tag_groups returns all results."""
|
||||
results = [MockResult(["a"]), MockResult(["b"]), MockResult(None)]
|
||||
filtered = filter_results_by_tag_groups(results, None)
|
||||
assert len(filtered) == 3
|
||||
|
||||
def test_empty_list_returns_all(self):
|
||||
"""Empty tag_groups list returns all results."""
|
||||
results = [MockResult(["a"]), MockResult(None)]
|
||||
filtered = filter_results_by_tag_groups(results, [])
|
||||
assert len(filtered) == 2
|
||||
|
||||
def test_single_leaf_any_strict_excludes_untagged(self):
|
||||
"""Single any_strict leaf excludes untagged results."""
|
||||
groups = [TagGroupLeaf(tags=["step:5"], match="any_strict")]
|
||||
results = [MockResult(["step:5"]), MockResult(["step:9"]), MockResult(None)]
|
||||
filtered = filter_results_by_tag_groups(results, groups)
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].tags == ["step:5"]
|
||||
|
||||
def test_single_leaf_all_strict_matches_superset(self):
|
||||
"""Single all_strict leaf matches results that contain all tags."""
|
||||
groups = [TagGroupLeaf(tags=["user:alice", "step:5"], match="all_strict")]
|
||||
results = [
|
||||
MockResult(["user:alice", "step:5"]),
|
||||
MockResult(["user:alice", "step:5", "extra"]),
|
||||
MockResult(["user:alice"]),
|
||||
MockResult(None),
|
||||
]
|
||||
filtered = filter_results_by_tag_groups(results, groups)
|
||||
assert len(filtered) == 2
|
||||
|
||||
def test_and_both_conditions_must_match(self):
|
||||
"""AND group: both leaf conditions must match."""
|
||||
groups = [
|
||||
TagGroupAnd.model_validate(
|
||||
{"and": [
|
||||
{"tags": ["user:alice"], "match": "all_strict"},
|
||||
{"tags": ["step:5"], "match": "any_strict"},
|
||||
]}
|
||||
)
|
||||
]
|
||||
results = [
|
||||
MockResult(["user:alice", "step:5"]), # matches both
|
||||
MockResult(["user:alice"]), # only matches first
|
||||
MockResult(["step:5"]), # only matches second
|
||||
MockResult(None),
|
||||
]
|
||||
filtered = filter_results_by_tag_groups(results, groups)
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].tags == ["user:alice", "step:5"]
|
||||
|
||||
def test_or_either_condition_matches(self):
|
||||
"""OR group: either condition matching is sufficient."""
|
||||
groups = [
|
||||
TagGroupOr.model_validate(
|
||||
{"or": [
|
||||
{"tags": ["step:5"], "match": "any_strict"},
|
||||
{"tags": ["priority:high"], "match": "all_strict"},
|
||||
]}
|
||||
)
|
||||
]
|
||||
results = [
|
||||
MockResult(["step:5"]),
|
||||
MockResult(["priority:high"]),
|
||||
MockResult(["step:5", "priority:high"]),
|
||||
MockResult(["other"]),
|
||||
MockResult(None),
|
||||
]
|
||||
filtered = filter_results_by_tag_groups(results, groups)
|
||||
# step:5, priority:high, and step:5+priority:high all match
|
||||
assert len(filtered) == 3
|
||||
|
||||
def test_not_negation(self):
|
||||
"""NOT group: inverts the child match."""
|
||||
groups = [
|
||||
TagGroupNot.model_validate(
|
||||
{"not": {"tags": ["archived"], "match": "any_strict"}}
|
||||
)
|
||||
]
|
||||
results = [
|
||||
MockResult(["archived"]),
|
||||
MockResult(["active"]),
|
||||
MockResult(["archived", "active"]),
|
||||
MockResult(None),
|
||||
]
|
||||
filtered = filter_results_by_tag_groups(results, groups)
|
||||
# "archived" and "archived+active" should be excluded
|
||||
# "active" and None pass (None is untagged, "any_strict" for "archived" would exclude
|
||||
# untagged, so NOT(exclude untagged) = include untagged)
|
||||
tags_in_filtered = [r.tags for r in filtered]
|
||||
assert ["archived"] not in tags_in_filtered
|
||||
assert ["archived", "active"] not in tags_in_filtered
|
||||
|
||||
def test_nested_and_containing_or(self):
|
||||
"""AND containing OR: nested boolean logic works correctly."""
|
||||
groups = [
|
||||
TagGroupAnd.model_validate(
|
||||
{"and": [
|
||||
{"tags": ["user:alice"], "match": "all_strict"},
|
||||
{"or": [
|
||||
{"tags": ["step:5"], "match": "any_strict"},
|
||||
{"tags": ["priority:high"], "match": "any_strict"},
|
||||
]},
|
||||
]}
|
||||
)
|
||||
]
|
||||
results = [
|
||||
MockResult(["user:alice", "step:5"]), # user:alice AND (step:5 OR ...)
|
||||
MockResult(["user:alice", "priority:high"]), # user:alice AND (... OR priority:high)
|
||||
MockResult(["user:alice"]), # user:alice but neither step nor priority
|
||||
MockResult(["step:5"]), # step:5 but not user:alice
|
||||
MockResult(None),
|
||||
]
|
||||
filtered = filter_results_by_tag_groups(results, groups)
|
||||
assert len(filtered) == 2
|
||||
|
||||
def test_multiple_top_level_groups_are_anded(self):
|
||||
"""Multiple top-level tag groups are AND-ed."""
|
||||
groups = [
|
||||
TagGroupLeaf(tags=["user:alice"], match="all_strict"),
|
||||
TagGroupLeaf(tags=["step:5"], match="any_strict"),
|
||||
]
|
||||
results = [
|
||||
MockResult(["user:alice", "step:5"]), # both match
|
||||
MockResult(["user:alice"]), # only first
|
||||
MockResult(["step:5"]), # only second
|
||||
]
|
||||
filtered = filter_results_by_tag_groups(results, groups)
|
||||
assert len(filtered) == 1
|
||||
assert filtered[0].tags == ["user:alice", "step:5"]
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests for tags in retain/recall/reflect
|
||||
# ============================================================================
|
||||
@@ -956,3 +1286,218 @@ async def test_list_memories_includes_tags(api_client, test_bank_id):
|
||||
assert set(memory_item["tags"]) == set(tags), (
|
||||
f"All {len(tags)} tags should be returned, got: {memory_item['tags']}"
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Integration Tests for tag_groups compound filtering
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_groups_validation_rejects_both_tags_and_tag_groups(api_client, test_bank_id):
|
||||
"""Passing both tags and tag_groups must be rejected (422)."""
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/memories/recall",
|
||||
json={
|
||||
"query": "anything",
|
||||
"tags": ["user:alice"],
|
||||
"tag_groups": [{"tags": ["user:alice"], "match": "any_strict"}],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422, (
|
||||
f"Expected 422 when both tags and tag_groups are set, got {response.status_code}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_groups_leaf_and_filter(api_client):
|
||||
"""
|
||||
Two leaf groups at top level (implicit AND): step filter AND user scope.
|
||||
|
||||
Retain:
|
||||
- Memory A: tags=[step:5, user:alice] ← should match
|
||||
- Memory B: tags=[step:5, user:bob] ← excluded (wrong user)
|
||||
- Memory C: tags=[step:9, user:alice] ← excluded (wrong step)
|
||||
|
||||
tag_groups = [{tags:[step:5], match:any_strict}, {tags:[user:alice], match:all_strict}]
|
||||
Expected: only A.
|
||||
"""
|
||||
bank_id = f"tg_and_{datetime.now().timestamp()}"
|
||||
|
||||
retain = await api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories",
|
||||
json={
|
||||
"items": [
|
||||
{"content": "Alice completed step 5 of the onboarding process.", "tags": ["step:5", "user:alice"]},
|
||||
{"content": "Bob completed step 5 of the onboarding process.", "tags": ["step:5", "user:bob"]},
|
||||
{"content": "Alice completed step 9 of the onboarding process.", "tags": ["step:9", "user:alice"]},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert retain.status_code == 200
|
||||
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories/recall",
|
||||
json={
|
||||
"query": "onboarding step completion",
|
||||
"budget": "mid",
|
||||
"tag_groups": [
|
||||
{"tags": ["step:5"], "match": "any_strict"},
|
||||
{"tags": ["user:alice"], "match": "all_strict"},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
texts = [r["text"] for r in response.json()["results"]]
|
||||
|
||||
assert any("Alice" in t and "step 5" in t for t in texts), "Should find Alice step:5 memory"
|
||||
assert not any("Bob" in t for t in texts), "Should NOT find Bob (wrong user)"
|
||||
assert not any("step 9" in t for t in texts), "Should NOT find step 9 (wrong step)"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_groups_or_compound(api_client):
|
||||
"""
|
||||
OR compound: match user:alice OR user:bob, but not user:carol.
|
||||
|
||||
Retain:
|
||||
- Memory A: tags=[user:alice]
|
||||
- Memory B: tags=[user:bob]
|
||||
- Memory C: tags=[user:carol]
|
||||
|
||||
tag_groups = [{or: [{tags:[user:alice]}, {tags:[user:bob]}]}]
|
||||
Expected: A and B, not C.
|
||||
"""
|
||||
bank_id = f"tg_or_{datetime.now().timestamp()}"
|
||||
|
||||
retain = await api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories",
|
||||
json={
|
||||
"items": [
|
||||
{"content": "Alice is a machine learning engineer.", "tags": ["user:alice"]},
|
||||
{"content": "Bob is a backend software engineer.", "tags": ["user:bob"]},
|
||||
{"content": "Carol is a product manager.", "tags": ["user:carol"]},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert retain.status_code == 200
|
||||
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories/recall",
|
||||
json={
|
||||
"query": "what are the engineers working on",
|
||||
"budget": "mid",
|
||||
"tag_groups": [
|
||||
{"or": [
|
||||
{"tags": ["user:alice"], "match": "any_strict"},
|
||||
{"tags": ["user:bob"], "match": "any_strict"},
|
||||
]},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
texts = [r["text"] for r in response.json()["results"]]
|
||||
|
||||
assert any("Alice" in t for t in texts), "Should find Alice (in OR)"
|
||||
assert any("Bob" in t for t in texts), "Should find Bob (in OR)"
|
||||
assert not any("Carol" in t for t in texts), "Should NOT find Carol (not in OR)"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_groups_not_compound(api_client):
|
||||
"""
|
||||
NOT compound: user:alice AND NOT archived.
|
||||
|
||||
Retain:
|
||||
- Memory A: tags=[user:alice] ← should match
|
||||
- Memory B: tags=[user:alice, archived] ← excluded (archived)
|
||||
- Memory C: tags=[user:bob] ← excluded (wrong user)
|
||||
|
||||
tag_groups = [{tags:[user:alice], match:any_strict}, {not: {tags:[archived], match:any_strict}}]
|
||||
Expected: only A.
|
||||
"""
|
||||
bank_id = f"tg_not_{datetime.now().timestamp()}"
|
||||
|
||||
retain = await api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories",
|
||||
json={
|
||||
"items": [
|
||||
{"content": "Alice joined the data science team this quarter.", "tags": ["user:alice"]},
|
||||
{"content": "Alice left the previous analytics project last year.", "tags": ["user:alice", "archived"]},
|
||||
{"content": "Bob joined the platform engineering team.", "tags": ["user:bob"]},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert retain.status_code == 200
|
||||
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories/recall",
|
||||
json={
|
||||
"query": "team membership",
|
||||
"budget": "mid",
|
||||
"tag_groups": [
|
||||
{"tags": ["user:alice"], "match": "any_strict"},
|
||||
{"not": {"tags": ["archived"], "match": "any_strict"}},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
texts = [r["text"] for r in response.json()["results"]]
|
||||
|
||||
assert any("data science" in t for t in texts), "Should find Alice's active memory"
|
||||
assert not any("analytics project" in t for t in texts), "Should NOT find archived memory"
|
||||
assert not any("Bob" in t for t in texts), "Should NOT find Bob (wrong user)"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_groups_nested_and_containing_or(api_client):
|
||||
"""
|
||||
Nested: user:alice AND (step:5 OR step:8).
|
||||
|
||||
Retain:
|
||||
- Memory A: tags=[user:alice, step:5] ← should match
|
||||
- Memory B: tags=[user:alice, step:8] ← should match
|
||||
- Memory C: tags=[user:alice, step:9] ← excluded (wrong step)
|
||||
- Memory D: tags=[user:bob, step:5] ← excluded (wrong user)
|
||||
|
||||
tag_groups = [{and: [{tags:[user:alice]}, {or:[{tags:[step:5]},{tags:[step:8]}]}]}]
|
||||
Expected: A and B only.
|
||||
"""
|
||||
bank_id = f"tg_nested_{datetime.now().timestamp()}"
|
||||
|
||||
retain = await api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories",
|
||||
json={
|
||||
"items": [
|
||||
{"content": "Alice passed the verification at step 5.", "tags": ["user:alice", "step:5"]},
|
||||
{"content": "Alice passed the verification at step 8.", "tags": ["user:alice", "step:8"]},
|
||||
{"content": "Alice passed the verification at step 9.", "tags": ["user:alice", "step:9"]},
|
||||
{"content": "Bob passed the verification at step 5.", "tags": ["user:bob", "step:5"]},
|
||||
]
|
||||
},
|
||||
)
|
||||
assert retain.status_code == 200
|
||||
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{bank_id}/memories/recall",
|
||||
json={
|
||||
"query": "verification step completion",
|
||||
"budget": "mid",
|
||||
"tag_groups": [
|
||||
{"and": [
|
||||
{"tags": ["user:alice"], "match": "all_strict"},
|
||||
{"or": [
|
||||
{"tags": ["step:5"], "match": "any_strict"},
|
||||
{"tags": ["step:8"], "match": "any_strict"},
|
||||
]},
|
||||
]},
|
||||
],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
texts = [r["text"] for r in response.json()["results"]]
|
||||
|
||||
assert any("Alice" in t and "step 5" in t for t in texts), "Should find Alice step:5"
|
||||
assert any("Alice" in t and "step 8" in t for t in texts), "Should find Alice step:8"
|
||||
assert not any("step 9" in t for t in texts), "Should NOT find step 9"
|
||||
assert not any("Bob" in t for t in texts), "Should NOT find Bob"
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""
|
||||
Unit tests for ValidationResult.accept_with() enrichment (PR #639).
|
||||
|
||||
These tests verify:
|
||||
1. The accept_with() factory creates an accepted result with the correct enrichment fields.
|
||||
2. The engine applies enrichment to retain contents and recall tags/tag_groups.
|
||||
3. RecallContext carries tags/tags_match/tag_groups so validators can read filter state.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.extensions import (
|
||||
OperationValidatorExtension,
|
||||
RecallContext,
|
||||
ReflectContext,
|
||||
RetainContext,
|
||||
ValidationResult,
|
||||
)
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pure unit tests for ValidationResult factory methods
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidationResultAcceptWith:
|
||||
"""Unit tests for the accept_with() factory — no DB needed."""
|
||||
|
||||
def test_accept_is_allowed_with_no_enrichment(self):
|
||||
result = ValidationResult.accept()
|
||||
assert result.allowed is True
|
||||
assert result.contents is None
|
||||
assert result.tags is None
|
||||
assert result.tags_match is None
|
||||
assert result.tag_groups is None
|
||||
|
||||
def test_accept_with_contents(self):
|
||||
contents = [{"content": "enriched text", "tags": ["injected"]}]
|
||||
result = ValidationResult.accept_with(contents=contents)
|
||||
assert result.allowed is True
|
||||
assert result.contents == contents
|
||||
assert result.tags is None
|
||||
assert result.tag_groups is None
|
||||
|
||||
def test_accept_with_tags(self):
|
||||
result = ValidationResult.accept_with(tags=["alpha", "beta"])
|
||||
assert result.allowed is True
|
||||
assert result.tags == ["alpha", "beta"]
|
||||
assert result.contents is None
|
||||
assert result.tag_groups is None
|
||||
|
||||
def test_accept_with_tags_match(self):
|
||||
result = ValidationResult.accept_with(tags=["x"], tags_match="all")
|
||||
assert result.allowed is True
|
||||
assert result.tags_match == "all"
|
||||
|
||||
def test_accept_with_tag_groups(self):
|
||||
tag_groups = [{"tags": ["env:prod"], "match": "all"}]
|
||||
result = ValidationResult.accept_with(tag_groups=tag_groups)
|
||||
assert result.allowed is True
|
||||
assert result.tag_groups == tag_groups
|
||||
|
||||
def test_accept_with_all_fields(self):
|
||||
contents = [{"content": "c"}]
|
||||
tags = ["t1"]
|
||||
tag_groups = [{"tags": ["g1"]}]
|
||||
result = ValidationResult.accept_with(
|
||||
contents=contents,
|
||||
tags=tags,
|
||||
tags_match="any",
|
||||
tag_groups=tag_groups,
|
||||
)
|
||||
assert result.allowed is True
|
||||
assert result.contents == contents
|
||||
assert result.tags == tags
|
||||
assert result.tags_match == "any"
|
||||
assert result.tag_groups == tag_groups
|
||||
|
||||
def test_reject_ignores_enrichment_fields(self):
|
||||
"""reject() always sets allowed=False and leaves enrichment fields at their defaults."""
|
||||
result = ValidationResult.reject("not allowed", status_code=403)
|
||||
assert result.allowed is False
|
||||
assert result.reason == "not allowed"
|
||||
assert result.status_code == 403
|
||||
assert result.contents is None
|
||||
assert result.tags is None
|
||||
|
||||
def test_none_fields_mean_no_modification(self):
|
||||
"""None enrichment fields must not overwrite engine defaults."""
|
||||
result = ValidationResult.accept_with(tags=None, tag_groups=None)
|
||||
assert result.tags is None
|
||||
assert result.tag_groups is None
|
||||
# Engine should interpret None as "keep original" — we verify the contract here.
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Integration tests: engine applies enrichment from validator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _ContentEnrichingValidator(OperationValidatorExtension):
|
||||
"""Validator that injects a tag into every retain content item."""
|
||||
|
||||
def __init__(self, injected_tag: str):
|
||||
super().__init__({})
|
||||
self.injected_tag = injected_tag
|
||||
|
||||
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||
enriched = []
|
||||
for item in ctx.contents:
|
||||
new_item = dict(item)
|
||||
new_item.setdefault("tags", [])
|
||||
new_item["tags"] = list(new_item["tags"]) + [self.injected_tag]
|
||||
enriched.append(new_item)
|
||||
return ValidationResult.accept_with(contents=enriched)
|
||||
|
||||
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
|
||||
class _TagEnrichingValidator(OperationValidatorExtension):
|
||||
"""Validator that injects tags into every recall operation."""
|
||||
|
||||
def __init__(self, forced_tags: list[str]):
|
||||
super().__init__({})
|
||||
self.forced_tags = forced_tags
|
||||
|
||||
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
|
||||
return ValidationResult.accept_with(tags=self.forced_tags, tags_match="all")
|
||||
|
||||
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
|
||||
class _RecallContextCapturingValidator(OperationValidatorExtension):
|
||||
"""Validator that captures the RecallContext for inspection."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__({})
|
||||
self.captured: list[RecallContext] = []
|
||||
|
||||
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_recall(self, ctx: RecallContext) -> ValidationResult:
|
||||
self.captured.append(ctx)
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_with_content_enricher(memory):
|
||||
validator = _ContentEnrichingValidator(injected_tag="validator-injected")
|
||||
memory._operation_validator = validator
|
||||
return memory, validator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_with_tag_enricher(memory):
|
||||
validator = _TagEnrichingValidator(forced_tags=["forced-tag"])
|
||||
memory._operation_validator = validator
|
||||
return memory, validator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def memory_with_recall_context_capture(memory):
|
||||
validator = _RecallContextCapturingValidator()
|
||||
memory._operation_validator = validator
|
||||
return memory, validator
|
||||
|
||||
|
||||
class TestRetainContentEnrichment:
|
||||
"""Engine applies enriched contents returned by validate_retain."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enriched_contents_are_used_for_retain(self, memory_with_content_enricher):
|
||||
"""When validator returns accept_with(contents=...), engine uses those contents."""
|
||||
memory, validator = memory_with_content_enricher
|
||||
bank_id = "test-retain-enrichment"
|
||||
ctx = RequestContext()
|
||||
|
||||
# Retain without any tags — validator should inject "validator-injected"
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[{"content": "Alice is an engineer."}],
|
||||
request_context=ctx,
|
||||
)
|
||||
|
||||
# Retrieve facts tagged with the injected tag to confirm enrichment was applied
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice",
|
||||
tags=["validator-injected"],
|
||||
request_context=ctx,
|
||||
)
|
||||
# The fact should be retrievable via the injected tag
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestRecallTagEnrichment:
|
||||
"""Engine applies enriched tags returned by validate_recall."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enriched_tags_filter_recall_results(self, memory_with_tag_enricher):
|
||||
"""When validator returns accept_with(tags=...), engine filters recall by those tags."""
|
||||
memory, validator = memory_with_tag_enricher
|
||||
bank_id = "test-recall-tag-enrichment"
|
||||
ctx = RequestContext()
|
||||
|
||||
# Retain one fact with the forced tag and one without
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[{"content": "Bob is a designer.", "tags": ["forced-tag"]}],
|
||||
request_context=ctx,
|
||||
)
|
||||
|
||||
# recall is called without tags but validator injects "forced-tag" + match=all
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Bob",
|
||||
request_context=ctx,
|
||||
)
|
||||
# Should still get a result — the injected tag matches the stored fact
|
||||
assert result is not None
|
||||
|
||||
|
||||
class TestRecallContextContainsTagFields:
|
||||
"""RecallContext passed to validate_recall carries tag filter state."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_context_carries_tags(self, memory_with_recall_context_capture):
|
||||
"""tags, tags_match, and tag_groups are present in RecallContext."""
|
||||
memory, validator = memory_with_recall_context_capture
|
||||
bank_id = "test-recall-ctx-tags"
|
||||
ctx = RequestContext()
|
||||
|
||||
await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="test",
|
||||
tags=["env:prod"],
|
||||
tags_match="all",
|
||||
request_context=ctx,
|
||||
)
|
||||
|
||||
assert len(validator.captured) == 1
|
||||
rc = validator.captured[0]
|
||||
assert rc.tags == ["env:prod"]
|
||||
assert rc.tags_match == "all"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_context_tags_default_to_none(self, memory_with_recall_context_capture):
|
||||
"""When caller provides no tags, RecallContext.tags is None."""
|
||||
memory, validator = memory_with_recall_context_capture
|
||||
bank_id = "test-recall-ctx-no-tags"
|
||||
ctx = RequestContext()
|
||||
|
||||
await memory.recall_async(bank_id=bank_id, query="test", request_context=ctx)
|
||||
|
||||
assert len(validator.captured) == 1
|
||||
rc = validator.captured[0]
|
||||
assert rc.tags is None
|
||||
@@ -58,10 +58,14 @@ async def pool(pg0_db_url):
|
||||
async def clean_operations(pool):
|
||||
"""Clean up async_operations table before and after tests."""
|
||||
# Clean before test - covers both 'test-worker-' and 'test_worker_recovery' patterns
|
||||
await pool.execute("DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'")
|
||||
await pool.execute(
|
||||
"DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'"
|
||||
)
|
||||
yield
|
||||
# Clean after test
|
||||
await pool.execute("DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'")
|
||||
await pool.execute(
|
||||
"DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%' OR bank_id LIKE 'test_worker_%'"
|
||||
)
|
||||
|
||||
|
||||
class TestBrokerTaskBackend:
|
||||
@@ -387,9 +391,7 @@ class TestWorkerPoller:
|
||||
"SELECT status, error_message, retry_count FROM async_operations WHERE operation_id = $1",
|
||||
op_id,
|
||||
)
|
||||
assert row["status"] == "failed", (
|
||||
f"Expected 'failed' for plain exception, got '{row['status']}'"
|
||||
)
|
||||
assert row["status"] == "failed", f"Expected 'failed' for plain exception, got '{row['status']}'"
|
||||
assert row["error_message"] is not None
|
||||
assert row["retry_count"] == 0 # not incremented; plain exception = immediate fail
|
||||
|
||||
@@ -1265,7 +1267,9 @@ async def test_worker_fire_and_forget_nonblocking(pool, clean_operations):
|
||||
for i in range(2):
|
||||
op_id = uuid.uuid4()
|
||||
task_ids.append(str(op_id))
|
||||
payload = json.dumps({"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id})
|
||||
payload = json.dumps(
|
||||
{"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id}
|
||||
)
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
@@ -1294,7 +1298,9 @@ async def test_worker_fire_and_forget_nonblocking(pool, clean_operations):
|
||||
for i in range(2):
|
||||
op_id = uuid.uuid4()
|
||||
task_ids.append(str(op_id))
|
||||
payload = json.dumps({"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id})
|
||||
payload = json.dumps(
|
||||
{"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id}
|
||||
)
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
@@ -1364,7 +1370,9 @@ async def test_worker_slot_limits_enforced(pool, clean_operations):
|
||||
await _ensure_bank(pool, bank_id)
|
||||
for i in range(10):
|
||||
op_id = uuid.uuid4()
|
||||
payload = json.dumps({"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id})
|
||||
payload = json.dumps(
|
||||
{"type": "test", "operation_type": "retain", "operation_id": str(op_id), "bank_id": bank_id}
|
||||
)
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
@@ -1396,7 +1404,7 @@ async def test_worker_slot_limits_enforced(pool, clean_operations):
|
||||
completed = 0
|
||||
while completed < 10 and len(tasks_started) < 10:
|
||||
# Release the next batch
|
||||
events_to_release = list(task_events.values())[completed:completed+3]
|
||||
events_to_release = list(task_events.values())[completed : completed + 3]
|
||||
for event in events_to_release:
|
||||
event.set()
|
||||
completed += len(events_to_release)
|
||||
@@ -1417,3 +1425,223 @@ async def test_worker_slot_limits_enforced(pool, clean_operations):
|
||||
await asyncio.wait_for(poll_task, timeout=1.0)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
|
||||
class TestMarkFailedParentPropagation:
|
||||
"""Tests for _mark_failed parent propagation in WorkerPoller.
|
||||
|
||||
When a child retain operation fails via an unhandled exception, the memory
|
||||
engine's transaction is rolled back entirely — including any call to
|
||||
_maybe_update_parent_operation inside the engine. The poller's fallback
|
||||
_mark_failed must detect this and finalise the parent batch_retain itself.
|
||||
"""
|
||||
|
||||
async def _insert_op(
|
||||
self,
|
||||
pool,
|
||||
*,
|
||||
op_id: "uuid.UUID",
|
||||
bank_id: str,
|
||||
operation_type: str,
|
||||
status: str,
|
||||
result_metadata: dict | None = None,
|
||||
) -> None:
|
||||
meta_json = json.dumps(result_metadata if result_metadata is not None else {})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations
|
||||
(operation_id, bank_id, operation_type, status, result_metadata)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
operation_type,
|
||||
status,
|
||||
meta_json,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_failed_finalises_parent_when_last_sibling_fails(self, pool, clean_operations):
|
||||
"""When the last pending child fails, parent batch_retain is marked failed."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(pool, bank_id)
|
||||
|
||||
parent_id = uuid.uuid4()
|
||||
child1_id = uuid.uuid4()
|
||||
child2_id = uuid.uuid4()
|
||||
|
||||
# Parent batch_retain still pending
|
||||
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
|
||||
|
||||
# child1 already completed
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child1_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="completed",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
|
||||
# child2 still processing — this is the one that will fail
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child2_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="processing",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
|
||||
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
|
||||
await poller._mark_failed(str(child2_id), "DB constraint violation", schema=None)
|
||||
|
||||
# child2 must be failed
|
||||
child2_row = await pool.fetchrow(
|
||||
"SELECT status, error_message FROM async_operations WHERE operation_id = $1", child2_id
|
||||
)
|
||||
assert child2_row["status"] == "failed"
|
||||
assert "DB constraint violation" in child2_row["error_message"]
|
||||
|
||||
# parent must now be failed (all siblings done, at least one failed)
|
||||
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
|
||||
assert parent_row["status"] == "failed", (
|
||||
f"Parent should be 'failed' when last sibling fails, got '{parent_row['status']}'"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_failed_finalises_parent_when_last_sibling_is_sole_child(self, pool, clean_operations):
|
||||
"""When the only child fails, parent batch_retain becomes failed."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(pool, bank_id)
|
||||
|
||||
parent_id = uuid.uuid4()
|
||||
child_id = uuid.uuid4()
|
||||
|
||||
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="processing",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
|
||||
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
|
||||
await poller._mark_failed(str(child_id), "unexpected error", schema=None)
|
||||
|
||||
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
|
||||
assert parent_row["status"] == "failed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_failed_does_not_finalise_parent_when_siblings_still_pending(self, pool, clean_operations):
|
||||
"""Parent is NOT updated while other siblings are still processing/pending."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(pool, bank_id)
|
||||
|
||||
parent_id = uuid.uuid4()
|
||||
child1_id = uuid.uuid4()
|
||||
child2_id = uuid.uuid4()
|
||||
|
||||
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
|
||||
|
||||
# child1 is the one failing
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child1_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="processing",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
# child2 is still pending — not done yet
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child2_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="pending",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
|
||||
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
|
||||
await poller._mark_failed(str(child1_id), "early failure", schema=None)
|
||||
|
||||
# child1 is failed
|
||||
child1_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", child1_id)
|
||||
assert child1_row["status"] == "failed"
|
||||
|
||||
# parent must still be pending (child2 not done)
|
||||
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
|
||||
assert parent_row["status"] == "pending", (
|
||||
f"Parent should remain 'pending' while siblings are outstanding, got '{parent_row['status']}'"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mark_failed_no_parent_is_safe(self, pool, clean_operations):
|
||||
"""Operations without a parent (no result_metadata parent_operation_id) fail cleanly."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(pool, bank_id)
|
||||
|
||||
op_id = uuid.uuid4()
|
||||
await self._insert_op(pool, op_id=op_id, bank_id=bank_id, operation_type="retain", status="processing")
|
||||
|
||||
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=lambda x: None)
|
||||
# Must not raise
|
||||
await poller._mark_failed(str(op_id), "standalone failure", schema=None)
|
||||
|
||||
row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", op_id)
|
||||
assert row["status"] == "failed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unhandled_exception_via_execute_task_propagates_to_parent(self, pool, clean_operations):
|
||||
"""End-to-end: executor raises a plain exception, poller calls _mark_failed,
|
||||
which then resolves the parent batch_retain to failed."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
from hindsight_api.worker.poller import ClaimedTask
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(pool, bank_id)
|
||||
|
||||
parent_id = uuid.uuid4()
|
||||
child_id = uuid.uuid4()
|
||||
|
||||
await self._insert_op(pool, op_id=parent_id, bank_id=bank_id, operation_type="batch_retain", status="pending")
|
||||
await self._insert_op(
|
||||
pool,
|
||||
op_id=child_id,
|
||||
bank_id=bank_id,
|
||||
operation_type="retain",
|
||||
status="processing",
|
||||
result_metadata={"parent_operation_id": str(parent_id)},
|
||||
)
|
||||
|
||||
async def crashing_executor(task_dict):
|
||||
raise RuntimeError("Simulated DB constraint violation — transaction rolled back")
|
||||
|
||||
poller = WorkerPoller(pool=pool, worker_id="test-worker-1", executor=crashing_executor)
|
||||
|
||||
task_dict = {"type": "retain", "operation_id": str(child_id), "bank_id": bank_id}
|
||||
claimed_task = ClaimedTask(operation_id=str(child_id), task_dict=task_dict, schema=None)
|
||||
await poller.execute_task(claimed_task)
|
||||
|
||||
completed = await poller.wait_for_active_tasks(timeout=5.0)
|
||||
assert completed, "Task did not complete within timeout"
|
||||
|
||||
child_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", child_id)
|
||||
assert child_row["status"] == "failed"
|
||||
|
||||
parent_row = await pool.fetchrow("SELECT status FROM async_operations WHERE operation_id = $1", parent_id)
|
||||
assert parent_row["status"] == "failed", (
|
||||
f"Parent batch_retain should be 'failed' after child fails via unhandled exception, "
|
||||
f"got '{parent_row['status']}'"
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api"
|
||||
version = "0.4.17"
|
||||
version = "0.4.20"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -15,5 +15,11 @@ dependencies = [
|
||||
[tool.uv.sources]
|
||||
hindsight-api-slim = { workspace = true }
|
||||
|
||||
[project.scripts]
|
||||
hindsight-api = "hindsight_api.main:main"
|
||||
hindsight-worker = "hindsight_api.worker.main:main"
|
||||
hindsight-local-mcp = "hindsight_api.mcp_local:main"
|
||||
hindsight-admin = "hindsight_api.admin.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = []
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hindsight-cli"
|
||||
version = "0.4.17"
|
||||
version = "0.4.20"
|
||||
edition = "2021"
|
||||
authors = ["Hindsight Team"]
|
||||
description = "A beautiful CLI for Hindsight - semantic memory system"
|
||||
|
||||
@@ -601,6 +601,13 @@ impl ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_mental_model_history(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_mental_model_history(bank_id, mental_model_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Directive Methods ---
|
||||
|
||||
pub fn list_directives(&self, bank_id: &str, _verbose: bool) -> Result<types::DirectiveListResponse> {
|
||||
|
||||
@@ -717,6 +717,13 @@ pub fn set_config(
|
||||
llm_model: Option<String>,
|
||||
llm_api_key: Option<String>,
|
||||
llm_base_url: Option<String>,
|
||||
retain_mission: Option<String>,
|
||||
retain_extraction_mode: Option<String>,
|
||||
observations_mission: Option<String>,
|
||||
reflect_mission: Option<String>,
|
||||
disposition_skepticism: Option<i64>,
|
||||
disposition_literalism: Option<i64>,
|
||||
disposition_empathy: Option<i64>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -736,9 +743,30 @@ pub fn set_config(
|
||||
if let Some(base_url) = llm_base_url {
|
||||
updates.insert("llm_base_url".to_string(), serde_json::Value::String(base_url));
|
||||
}
|
||||
if let Some(mission) = retain_mission {
|
||||
updates.insert("retain_mission".to_string(), serde_json::Value::String(mission));
|
||||
}
|
||||
if let Some(mode) = retain_extraction_mode {
|
||||
updates.insert("retain_extraction_mode".to_string(), serde_json::Value::String(mode));
|
||||
}
|
||||
if let Some(mission) = observations_mission {
|
||||
updates.insert("observations_mission".to_string(), serde_json::Value::String(mission));
|
||||
}
|
||||
if let Some(mission) = reflect_mission {
|
||||
updates.insert("reflect_mission".to_string(), serde_json::Value::String(mission));
|
||||
}
|
||||
if let Some(skepticism) = disposition_skepticism {
|
||||
updates.insert("disposition_skepticism".to_string(), serde_json::Value::Number(skepticism.into()));
|
||||
}
|
||||
if let Some(literalism) = disposition_literalism {
|
||||
updates.insert("disposition_literalism".to_string(), serde_json::Value::Number(literalism.into()));
|
||||
}
|
||||
if let Some(empathy) = disposition_empathy {
|
||||
updates.insert("disposition_empathy".to_string(), serde_json::Value::Number(empathy.into()));
|
||||
}
|
||||
|
||||
if updates.is_empty() {
|
||||
return Err(anyhow!("No config updates provided. Use --llm-provider, --llm-model, --llm-api-key, or --llm-base-url".to_string()));
|
||||
return Err(anyhow!("No config updates provided. Use --llm-provider, --llm-model, --retain-mission, --observations-mission, or other flags".to_string()));
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
|
||||
@@ -149,11 +149,12 @@ pub fn update(
|
||||
directive_id: &str,
|
||||
name: Option<String>,
|
||||
content: Option<String>,
|
||||
is_active: Option<bool>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
if name.is_none() && content.is_none() {
|
||||
anyhow::bail!("At least one of --name or --content must be provided");
|
||||
if name.is_none() && content.is_none() && is_active.is_none() {
|
||||
anyhow::bail!("At least one of --name, --content, or --is-active must be provided");
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
@@ -165,7 +166,7 @@ pub fn update(
|
||||
let request = types::UpdateDirectiveRequest {
|
||||
name,
|
||||
content,
|
||||
is_active: None,
|
||||
is_active,
|
||||
priority: None,
|
||||
tags: None,
|
||||
};
|
||||
|
||||
@@ -343,6 +343,7 @@ impl App {
|
||||
include: None,
|
||||
tags: None,
|
||||
tags_match: TagsMatch::Any,
|
||||
tag_groups: None,
|
||||
};
|
||||
|
||||
let result = client.recall(&bank_id, &request, false)
|
||||
@@ -361,6 +362,10 @@ impl App {
|
||||
response_schema: None,
|
||||
tags: None,
|
||||
tags_match: TagsMatch::Any,
|
||||
tag_groups: None,
|
||||
fact_types: None,
|
||||
exclude_mental_models: false,
|
||||
exclude_mental_model_ids: None,
|
||||
};
|
||||
|
||||
let result = client.reflect(&bank_id, &request, false)
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
|
||||
// Import types from generated client
|
||||
use hindsight_client::types::{Budget, ChunkIncludeOptions, IncludeOptions, TagsMatch};
|
||||
use hindsight_client::types::{Budget, ChunkIncludeOptions, FactsIncludeOptions, IncludeOptions, ReflectIncludeOptions, TagsMatch};
|
||||
use serde::Deserialize;
|
||||
use serde_json;
|
||||
|
||||
@@ -43,6 +43,16 @@ fn parse_budget(budget: &str) -> Budget {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to parse tags_match string to TagsMatch enum
|
||||
fn parse_tags_match(tags_match: &Option<String>) -> TagsMatch {
|
||||
match tags_match.as_deref().unwrap_or("any").to_lowercase().as_str() {
|
||||
"all" => TagsMatch::All,
|
||||
"any_strict" => TagsMatch::AnyStrict,
|
||||
"all_strict" => TagsMatch::AllStrict,
|
||||
_ => TagsMatch::Any,
|
||||
}
|
||||
}
|
||||
|
||||
/// List memory units with pagination and optional filters
|
||||
pub fn list(
|
||||
client: &ApiClient,
|
||||
@@ -250,6 +260,8 @@ pub fn recall(
|
||||
trace: bool,
|
||||
include_chunks: bool,
|
||||
chunk_max_tokens: i64,
|
||||
tags: Vec<String>,
|
||||
tags_match: Option<String>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -280,8 +292,9 @@ pub fn recall(
|
||||
trace,
|
||||
query_timestamp: None,
|
||||
include,
|
||||
tags: None,
|
||||
tags_match: TagsMatch::Any,
|
||||
tags: if tags.is_empty() { None } else { Some(tags) },
|
||||
tags_match: parse_tags_match(&tags_match),
|
||||
tag_groups: None,
|
||||
};
|
||||
|
||||
let response = client.recall(agent_id, &request, verbose);
|
||||
@@ -311,6 +324,9 @@ pub fn reflect(
|
||||
context: Option<String>,
|
||||
max_tokens: Option<i64>,
|
||||
schema_path: Option<PathBuf>,
|
||||
tags: Vec<String>,
|
||||
tags_match: Option<String>,
|
||||
include_facts: bool,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -331,15 +347,28 @@ pub fn reflect(
|
||||
None
|
||||
};
|
||||
|
||||
let include = if include_facts {
|
||||
Some(ReflectIncludeOptions {
|
||||
facts: Some(FactsIncludeOptions(serde_json::Map::new())),
|
||||
tool_calls: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = ReflectRequest {
|
||||
query,
|
||||
budget: Some(parse_budget(&budget)),
|
||||
context,
|
||||
max_tokens: max_tokens.unwrap_or(4096),
|
||||
include: None,
|
||||
include,
|
||||
response_schema,
|
||||
tags: None,
|
||||
tags_match: TagsMatch::Any,
|
||||
tags: if tags.is_empty() { None } else { Some(tags) },
|
||||
tags_match: parse_tags_match(&tags_match),
|
||||
tag_groups: None,
|
||||
fact_types: None,
|
||||
exclude_mental_models: false,
|
||||
exclude_mental_model_ids: None,
|
||||
};
|
||||
|
||||
let response = client.reflect(agent_id, &request, verbose);
|
||||
@@ -388,6 +417,7 @@ pub fn retain(
|
||||
entities: None,
|
||||
tags: None,
|
||||
observation_scopes: None,
|
||||
strategy: None,
|
||||
};
|
||||
|
||||
let request = RetainRequest {
|
||||
|
||||
@@ -272,6 +272,55 @@ pub fn refresh(
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the change history of a mental model
|
||||
pub fn history(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
mental_model_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching mental model history..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_mental_model_history(bank_id, mental_model_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(history) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("History: {}", mental_model_id));
|
||||
|
||||
if let Some(entries) = history.as_array() {
|
||||
if entries.is_empty() {
|
||||
println!(" {}", ui::dim("No history entries found."));
|
||||
} else {
|
||||
for entry in entries {
|
||||
let changed_at = entry.get("changed_at").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let previous = entry.get("previous_content").and_then(|v| v.as_str()).unwrap_or("(none)");
|
||||
println!(" {} {}", ui::dim("Changed at:"), changed_at);
|
||||
let preview: String = previous.chars().take(80).collect();
|
||||
let ellipsis = if previous.len() > 80 { "..." } else { "" };
|
||||
println!(" {} {}{}", ui::dim("Previous:"), ui::dim(&preview), ellipsis);
|
||||
println!();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output::print_output(&history, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to print mental model details
|
||||
fn print_mental_model_detail(mental_model: &types::MentalModelResponse) {
|
||||
ui::print_section_header(&mental_model.name);
|
||||
|
||||
@@ -310,6 +310,34 @@ enum BankCommands {
|
||||
/// LLM base URL override
|
||||
#[arg(long)]
|
||||
llm_base_url: Option<String>,
|
||||
|
||||
/// Retain mission: what to focus on during fact extraction
|
||||
#[arg(long)]
|
||||
retain_mission: Option<String>,
|
||||
|
||||
/// Retain extraction mode (concise, verbose, custom)
|
||||
#[arg(long)]
|
||||
retain_extraction_mode: Option<String>,
|
||||
|
||||
/// Observations mission: what to synthesize into durable observations
|
||||
#[arg(long)]
|
||||
observations_mission: Option<String>,
|
||||
|
||||
/// Reflect mission: first-person identity for reflect operations
|
||||
#[arg(long)]
|
||||
reflect_mission: Option<String>,
|
||||
|
||||
/// Disposition skepticism trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
disposition_skepticism: Option<i64>,
|
||||
|
||||
/// Disposition literalism trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
disposition_literalism: Option<i64>,
|
||||
|
||||
/// Disposition empathy trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
disposition_empathy: Option<i64>,
|
||||
},
|
||||
|
||||
/// Reset bank configuration to defaults (remove all overrides)
|
||||
@@ -387,6 +415,14 @@ enum MemoryCommands {
|
||||
/// Maximum tokens for chunks (only used with --include-chunks)
|
||||
#[arg(long, default_value = "8192")]
|
||||
chunk_max_tokens: i64,
|
||||
|
||||
/// Filter by tags (comma-separated, e.g. user:alice,team)
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Vec<String>,
|
||||
|
||||
/// Tag matching mode: any, all, any_strict, all_strict (default: any)
|
||||
#[arg(long)]
|
||||
tags_match: Option<String>,
|
||||
},
|
||||
|
||||
/// Generate answers using bank identity (reflect/reasoning)
|
||||
@@ -412,6 +448,18 @@ enum MemoryCommands {
|
||||
/// Path to JSON schema file for structured output
|
||||
#[arg(short = 's', long)]
|
||||
schema: Option<PathBuf>,
|
||||
|
||||
/// Filter by tags (comma-separated, e.g. user:alice,team)
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Vec<String>,
|
||||
|
||||
/// Tag matching mode: any, all, any_strict, all_strict (default: any)
|
||||
#[arg(long)]
|
||||
tags_match: Option<String>,
|
||||
|
||||
/// Include source facts (based_on) in the response
|
||||
#[arg(long)]
|
||||
include_facts: bool,
|
||||
},
|
||||
|
||||
/// Store (retain) a single memory
|
||||
@@ -678,6 +726,15 @@ enum MentalModelCommands {
|
||||
/// Mental model ID
|
||||
mental_model_id: String,
|
||||
},
|
||||
|
||||
/// Get the change history of a mental model
|
||||
History {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mental model ID
|
||||
mental_model_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -724,6 +781,10 @@ enum DirectiveCommands {
|
||||
/// New content
|
||||
#[arg(long)]
|
||||
content: Option<String>,
|
||||
|
||||
/// Enable or disable the directive
|
||||
#[arg(long)]
|
||||
is_active: Option<bool>,
|
||||
},
|
||||
|
||||
/// Delete a directive
|
||||
@@ -821,8 +882,8 @@ fn run() -> Result<()> {
|
||||
BankCommands::Config { bank_id, overrides_only } => {
|
||||
commands::bank::config(&client, &bank_id, overrides_only, verbose, output_format)
|
||||
}
|
||||
BankCommands::SetConfig { bank_id, llm_provider, llm_model, llm_api_key, llm_base_url } => {
|
||||
commands::bank::set_config(&client, &bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, verbose, output_format)
|
||||
BankCommands::SetConfig { bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, retain_mission, retain_extraction_mode, observations_mission, reflect_mission, disposition_skepticism, disposition_literalism, disposition_empathy } => {
|
||||
commands::bank::set_config(&client, &bank_id, llm_provider, llm_model, llm_api_key, llm_base_url, retain_mission, retain_extraction_mode, observations_mission, reflect_mission, disposition_skepticism, disposition_literalism, disposition_empathy, verbose, output_format)
|
||||
}
|
||||
BankCommands::ResetConfig { bank_id, yes } => {
|
||||
commands::bank::reset_config(&client, &bank_id, yes, verbose, output_format)
|
||||
@@ -837,11 +898,11 @@ fn run() -> Result<()> {
|
||||
MemoryCommands::Get { bank_id, memory_id } => {
|
||||
commands::memory::get(&client, &bank_id, &memory_id, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Recall { bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens } => {
|
||||
commands::memory::recall(&client, &bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, verbose, output_format)
|
||||
MemoryCommands::Recall { bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, tags, tags_match } => {
|
||||
commands::memory::recall(&client, &bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, tags, tags_match, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Reflect { bank_id, query, budget, context, max_tokens, schema } => {
|
||||
commands::memory::reflect(&client, &bank_id, query, budget, context, max_tokens, schema, verbose, output_format)
|
||||
MemoryCommands::Reflect { bank_id, query, budget, context, max_tokens, schema, tags, tags_match, include_facts } => {
|
||||
commands::memory::reflect(&client, &bank_id, query, budget, context, max_tokens, schema, tags, tags_match, include_facts, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Retain { bank_id, content, doc_id, context, r#async } => {
|
||||
commands::memory::retain(&client, &bank_id, content, doc_id, context, r#async, verbose, output_format)
|
||||
@@ -930,6 +991,9 @@ fn run() -> Result<()> {
|
||||
MentalModelCommands::Refresh { bank_id, mental_model_id } => {
|
||||
commands::mental_model::refresh(&client, &bank_id, &mental_model_id, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::History { bank_id, mental_model_id } => {
|
||||
commands::mental_model::history(&client, &bank_id, &mental_model_id, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Directive commands
|
||||
@@ -943,8 +1007,8 @@ fn run() -> Result<()> {
|
||||
DirectiveCommands::Create { bank_id, name, content } => {
|
||||
commands::directive::create(&client, &bank_id, &name, &content, verbose, output_format)
|
||||
}
|
||||
DirectiveCommands::Update { bank_id, directive_id, name, content } => {
|
||||
commands::directive::update(&client, &bank_id, &directive_id, name, content, verbose, output_format)
|
||||
DirectiveCommands::Update { bank_id, directive_id, name, content, is_active } => {
|
||||
commands::directive::update(&client, &bank_id, &directive_id, name, content, is_active, verbose, output_format)
|
||||
}
|
||||
DirectiveCommands::Delete { bank_id, directive_id, yes } => {
|
||||
commands::directive::delete(&client, &bank_id, &directive_id, yes, verbose, output_format)
|
||||
|
||||
@@ -7,7 +7,7 @@ info:
|
||||
name: Apache 2.0
|
||||
url: https://www.apache.org/licenses/LICENSE-2.0.html
|
||||
title: Hindsight HTTP API
|
||||
version: 0.4.17
|
||||
version: 0.4.20
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
@@ -2106,6 +2106,46 @@ paths:
|
||||
summary: Clear all observations
|
||||
tags:
|
||||
- Banks
|
||||
/v1/default/banks/{bank_id}/consolidation/recover:
|
||||
post:
|
||||
description: Reset all memories that were permanently marked as failed during
|
||||
consolidation (after exhausting all LLM retries and adaptive batch splitting)
|
||||
so they are picked up again on the next consolidation run. Does not delete
|
||||
any observations.
|
||||
operationId: recover_consolidation
|
||||
parameters:
|
||||
- explode: false
|
||||
in: path
|
||||
name: bank_id
|
||||
required: true
|
||||
schema:
|
||||
title: Bank Id
|
||||
type: string
|
||||
style: simple
|
||||
- explode: false
|
||||
in: header
|
||||
name: authorization
|
||||
required: false
|
||||
schema:
|
||||
nullable: true
|
||||
type: string
|
||||
style: simple
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RecoverConsolidationResponse'
|
||||
description: Successful Response
|
||||
"422":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/HTTPValidationError'
|
||||
description: Validation Error
|
||||
summary: Recover failed consolidation
|
||||
tags:
|
||||
- Banks
|
||||
/v1/default/banks/{bank_id}/memories/{memory_id}/observations:
|
||||
delete:
|
||||
description: Delete all observations derived from a specific memory and reset
|
||||
@@ -3844,12 +3884,18 @@ components:
|
||||
loc:
|
||||
- ValidationError_loc_inner
|
||||
- ValidationError_loc_inner
|
||||
input: ""
|
||||
ctx: "{}"
|
||||
type: type
|
||||
url: url
|
||||
- msg: msg
|
||||
loc:
|
||||
- ValidationError_loc_inner
|
||||
- ValidationError_loc_inner
|
||||
input: ""
|
||||
ctx: "{}"
|
||||
type: type
|
||||
url: url
|
||||
properties:
|
||||
detail:
|
||||
items:
|
||||
@@ -4014,6 +4060,9 @@ components:
|
||||
type: array
|
||||
observation_scopes:
|
||||
$ref: '#/components/schemas/ObservationScopes'
|
||||
strategy:
|
||||
nullable: true
|
||||
type: string
|
||||
required:
|
||||
- content
|
||||
title: MemoryItem
|
||||
@@ -4031,6 +4080,13 @@ components:
|
||||
id: id
|
||||
trigger:
|
||||
refresh_after_consolidation: false
|
||||
fact_types:
|
||||
- world
|
||||
- world
|
||||
exclude_mental_model_ids:
|
||||
- exclude_mental_model_ids
|
||||
- exclude_mental_model_ids
|
||||
exclude_mental_models: false
|
||||
last_refreshed_at: last_refreshed_at
|
||||
content: content
|
||||
tags:
|
||||
@@ -4046,6 +4102,13 @@ components:
|
||||
id: id
|
||||
trigger:
|
||||
refresh_after_consolidation: false
|
||||
fact_types:
|
||||
- world
|
||||
- world
|
||||
exclude_mental_model_ids:
|
||||
- exclude_mental_model_ids
|
||||
- exclude_mental_model_ids
|
||||
exclude_mental_models: false
|
||||
last_refreshed_at: last_refreshed_at
|
||||
content: content
|
||||
tags:
|
||||
@@ -4072,6 +4135,13 @@ components:
|
||||
id: id
|
||||
trigger:
|
||||
refresh_after_consolidation: false
|
||||
fact_types:
|
||||
- world
|
||||
- world
|
||||
exclude_mental_model_ids:
|
||||
- exclude_mental_model_ids
|
||||
- exclude_mental_model_ids
|
||||
exclude_mental_models: false
|
||||
last_refreshed_at: last_refreshed_at
|
||||
content: content
|
||||
tags:
|
||||
@@ -4126,6 +4196,13 @@ components:
|
||||
description: Trigger settings for a mental model.
|
||||
example:
|
||||
refresh_after_consolidation: false
|
||||
fact_types:
|
||||
- world
|
||||
- world
|
||||
exclude_mental_model_ids:
|
||||
- exclude_mental_model_ids
|
||||
- exclude_mental_model_ids
|
||||
exclude_mental_models: false
|
||||
properties:
|
||||
refresh_after_consolidation:
|
||||
default: false
|
||||
@@ -4133,6 +4210,26 @@ components:
|
||||
\ (real-time mode)"
|
||||
title: Refresh After Consolidation
|
||||
type: boolean
|
||||
fact_types:
|
||||
items:
|
||||
enum:
|
||||
- world
|
||||
- experience
|
||||
- observation
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
exclude_mental_models:
|
||||
default: false
|
||||
description: "If true, exclude all mental models from the reflect loop (skip\
|
||||
\ search_mental_models tool)."
|
||||
title: Exclude Mental Models
|
||||
type: boolean
|
||||
exclude_mental_model_ids:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
title: MentalModelTrigger
|
||||
OperationResponse:
|
||||
description: Response model for a single async operation.
|
||||
@@ -4314,6 +4411,11 @@ components:
|
||||
- all_strict
|
||||
title: Tags Match
|
||||
type: string
|
||||
tag_groups:
|
||||
items:
|
||||
$ref: '#/components/schemas/RecallRequest_tag_groups_inner'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- query
|
||||
title: RecallRequest
|
||||
@@ -4441,6 +4543,17 @@ components:
|
||||
- id
|
||||
- text
|
||||
title: RecallResult
|
||||
RecoverConsolidationResponse:
|
||||
description: Response model for recovering failed consolidation.
|
||||
example:
|
||||
retried_count: 42
|
||||
properties:
|
||||
retried_count:
|
||||
title: Retried Count
|
||||
type: integer
|
||||
required:
|
||||
- retried_count
|
||||
title: RecoverConsolidationResponse
|
||||
ReflectBasedOn:
|
||||
description: "Evidence the response is based on: memories, mental models, and\
|
||||
\ directives."
|
||||
@@ -4620,6 +4733,31 @@ components:
|
||||
- all_strict
|
||||
title: Tags Match
|
||||
type: string
|
||||
tag_groups:
|
||||
items:
|
||||
$ref: '#/components/schemas/RecallRequest_tag_groups_inner'
|
||||
nullable: true
|
||||
type: array
|
||||
fact_types:
|
||||
items:
|
||||
enum:
|
||||
- world
|
||||
- experience
|
||||
- observation
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
exclude_mental_models:
|
||||
default: false
|
||||
description: "If true, exclude all mental models from the reflect loop (skip\
|
||||
\ search_mental_models tool)."
|
||||
title: Exclude Mental Models
|
||||
type: boolean
|
||||
exclude_mental_model_ids:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- query
|
||||
title: ReflectRequest
|
||||
@@ -4785,6 +4923,11 @@ components:
|
||||
operation_id:
|
||||
nullable: true
|
||||
type: string
|
||||
operation_ids:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
usage:
|
||||
$ref: '#/components/schemas/TokenUsage'
|
||||
required:
|
||||
@@ -4829,6 +4972,53 @@ components:
|
||||
title: Max Tokens Per Observation
|
||||
type: integer
|
||||
title: SourceFactsIncludeOptions
|
||||
TagGroupAnd:
|
||||
description: "Compound AND group: all child filters must match."
|
||||
properties:
|
||||
and:
|
||||
items:
|
||||
$ref: '#/components/schemas/RecallRequest_tag_groups_inner'
|
||||
type: array
|
||||
required:
|
||||
- and
|
||||
title: TagGroupAnd
|
||||
TagGroupLeaf:
|
||||
description: "A leaf tag filter: matches memories by tag list and match mode."
|
||||
properties:
|
||||
tags:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
match:
|
||||
default: any_strict
|
||||
enum:
|
||||
- any
|
||||
- all
|
||||
- any_strict
|
||||
- all_strict
|
||||
title: Match
|
||||
type: string
|
||||
required:
|
||||
- tags
|
||||
title: TagGroupLeaf
|
||||
TagGroupNot:
|
||||
description: "Compound NOT group: child filter must NOT match."
|
||||
properties:
|
||||
not:
|
||||
$ref: '#/components/schemas/Not'
|
||||
required:
|
||||
- not
|
||||
title: TagGroupNot
|
||||
TagGroupOr:
|
||||
description: "Compound OR group: at least one child filter must match."
|
||||
properties:
|
||||
or:
|
||||
items:
|
||||
$ref: '#/components/schemas/RecallRequest_tag_groups_inner'
|
||||
type: array
|
||||
required:
|
||||
- or
|
||||
title: TagGroupOr
|
||||
TagItem:
|
||||
description: Single tag with usage count.
|
||||
properties:
|
||||
@@ -5018,7 +5208,10 @@ components:
|
||||
loc:
|
||||
- ValidationError_loc_inner
|
||||
- ValidationError_loc_inner
|
||||
input: ""
|
||||
ctx: "{}"
|
||||
type: type
|
||||
url: url
|
||||
properties:
|
||||
loc:
|
||||
items:
|
||||
@@ -5030,6 +5223,13 @@ components:
|
||||
type:
|
||||
title: Error Type
|
||||
type: string
|
||||
input: {}
|
||||
ctx:
|
||||
title: Context
|
||||
type: object
|
||||
url:
|
||||
title: URL
|
||||
type: string
|
||||
required:
|
||||
- loc
|
||||
- msg
|
||||
@@ -5324,6 +5524,19 @@ components:
|
||||
\ which combinations to use."
|
||||
nullable: true
|
||||
title: ObservationScopes
|
||||
RecallRequest_tag_groups_inner:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/TagGroupLeaf'
|
||||
- $ref: '#/components/schemas/TagGroupAnd'
|
||||
- $ref: '#/components/schemas/TagGroupOr'
|
||||
- $ref: '#/components/schemas/TagGroupNot'
|
||||
Not:
|
||||
anyOf:
|
||||
- $ref: '#/components/schemas/TagGroupLeaf'
|
||||
- $ref: '#/components/schemas/TagGroupAnd'
|
||||
- $ref: '#/components/schemas/TagGroupOr'
|
||||
- $ref: '#/components/schemas/TagGroupNot'
|
||||
title: Not
|
||||
ValidationError_loc_inner:
|
||||
anyOf:
|
||||
- type: string
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
@@ -1023,6 +1023,128 @@ func (a *BanksAPIService) ListBanksExecute(r ApiListBanksRequest) (*BankListResp
|
||||
return localVarReturnValue, localVarHTTPResponse, nil
|
||||
}
|
||||
|
||||
type ApiRecoverConsolidationRequest struct {
|
||||
ctx context.Context
|
||||
ApiService *BanksAPIService
|
||||
bankId string
|
||||
authorization *string
|
||||
}
|
||||
|
||||
func (r ApiRecoverConsolidationRequest) Authorization(authorization string) ApiRecoverConsolidationRequest {
|
||||
r.authorization = &authorization
|
||||
return r
|
||||
}
|
||||
|
||||
func (r ApiRecoverConsolidationRequest) Execute() (*RecoverConsolidationResponse, *http.Response, error) {
|
||||
return r.ApiService.RecoverConsolidationExecute(r)
|
||||
}
|
||||
|
||||
/*
|
||||
RecoverConsolidation Recover failed consolidation
|
||||
|
||||
Reset all memories that were permanently marked as failed during consolidation (after exhausting all LLM retries and adaptive batch splitting) so they are picked up again on the next consolidation run. Does not delete any observations.
|
||||
|
||||
@param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background().
|
||||
@param bankId
|
||||
@return ApiRecoverConsolidationRequest
|
||||
*/
|
||||
func (a *BanksAPIService) RecoverConsolidation(ctx context.Context, bankId string) ApiRecoverConsolidationRequest {
|
||||
return ApiRecoverConsolidationRequest{
|
||||
ApiService: a,
|
||||
ctx: ctx,
|
||||
bankId: bankId,
|
||||
}
|
||||
}
|
||||
|
||||
// Execute executes the request
|
||||
// @return RecoverConsolidationResponse
|
||||
func (a *BanksAPIService) RecoverConsolidationExecute(r ApiRecoverConsolidationRequest) (*RecoverConsolidationResponse, *http.Response, error) {
|
||||
var (
|
||||
localVarHTTPMethod = http.MethodPost
|
||||
localVarPostBody interface{}
|
||||
formFiles []formFile
|
||||
localVarReturnValue *RecoverConsolidationResponse
|
||||
)
|
||||
|
||||
localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "BanksAPIService.RecoverConsolidation")
|
||||
if err != nil {
|
||||
return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()}
|
||||
}
|
||||
|
||||
localVarPath := localBasePath + "/v1/default/banks/{bank_id}/consolidation/recover"
|
||||
localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1)
|
||||
|
||||
localVarHeaderParams := make(map[string]string)
|
||||
localVarQueryParams := url.Values{}
|
||||
localVarFormParams := url.Values{}
|
||||
|
||||
// to determine the Content-Type header
|
||||
localVarHTTPContentTypes := []string{}
|
||||
|
||||
// set Content-Type header
|
||||
localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes)
|
||||
if localVarHTTPContentType != "" {
|
||||
localVarHeaderParams["Content-Type"] = localVarHTTPContentType
|
||||
}
|
||||
|
||||
// to determine the Accept header
|
||||
localVarHTTPHeaderAccepts := []string{"application/json"}
|
||||
|
||||
// set Accept header
|
||||
localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts)
|
||||
if localVarHTTPHeaderAccept != "" {
|
||||
localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept
|
||||
}
|
||||
if r.authorization != nil {
|
||||
parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "")
|
||||
}
|
||||
req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles)
|
||||
if err != nil {
|
||||
return localVarReturnValue, nil, err
|
||||
}
|
||||
|
||||
localVarHTTPResponse, err := a.client.callAPI(req)
|
||||
if err != nil || localVarHTTPResponse == nil {
|
||||
return localVarReturnValue, localVarHTTPResponse, err
|
||||
}
|
||||
|
||||
localVarBody, err := io.ReadAll(localVarHTTPResponse.Body)
|
||||
localVarHTTPResponse.Body.Close()
|
||||
localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody))
|
||||
if err != nil {
|
||||
return localVarReturnValue, localVarHTTPResponse, err
|
||||
}
|
||||
|
||||
if localVarHTTPResponse.StatusCode >= 300 {
|
||||
newErr := &GenericOpenAPIError{
|
||||
body: localVarBody,
|
||||
error: localVarHTTPResponse.Status,
|
||||
}
|
||||
if localVarHTTPResponse.StatusCode == 422 {
|
||||
var v HTTPValidationError
|
||||
err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
newErr.error = err.Error()
|
||||
return localVarReturnValue, localVarHTTPResponse, newErr
|
||||
}
|
||||
newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v)
|
||||
newErr.model = v
|
||||
}
|
||||
return localVarReturnValue, localVarHTTPResponse, newErr
|
||||
}
|
||||
|
||||
err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type"))
|
||||
if err != nil {
|
||||
newErr := &GenericOpenAPIError{
|
||||
body: localVarBody,
|
||||
error: err.Error(),
|
||||
}
|
||||
return localVarReturnValue, localVarHTTPResponse, newErr
|
||||
}
|
||||
|
||||
return localVarReturnValue, localVarHTTPResponse, nil
|
||||
}
|
||||
|
||||
type ApiResetBankConfigRequest struct {
|
||||
ctx context.Context
|
||||
ApiService *BanksAPIService
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
@@ -41,7 +41,7 @@ var (
|
||||
queryDescape = strings.NewReplacer( "%5B", "[", "%5D", "]" )
|
||||
)
|
||||
|
||||
// APIClient manages communication with the Hindsight HTTP API API v0.4.17
|
||||
// APIClient manages communication with the Hindsight HTTP API API v0.4.20
|
||||
// In most cases there should be only one, shared, APIClient.
|
||||
type APIClient struct {
|
||||
cfg *Configuration
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.17
|
||||
API version: 0.4.20
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user