Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0c5153861 | ||
|
|
ef24828356 | ||
|
|
db7a0ad3a5 | ||
|
|
c10c9c89e9 | ||
|
|
1f1462a5f6 | ||
|
|
0727f2d069 | ||
|
|
72c25c97e3 | ||
|
|
8c378b981a | ||
|
|
e2b19d3b38 | ||
|
|
210a40665d | ||
|
|
28dac7c7f8 | ||
|
|
f88f0a3b26 | ||
|
|
e4f8a157c2 | ||
|
|
ef90842f87 | ||
|
|
f68e2e2851 |
+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,99 @@
|
||||
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
|
||||
else
|
||||
echo "type=typescript" >> $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@v5
|
||||
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) ────────────────────
|
||||
|
||||
- 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 }}
|
||||
@@ -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
|
||||
|
||||
@@ -610,24 +430,6 @@ jobs:
|
||||
name: typescript-client
|
||||
path: ./artifacts/typescript-client
|
||||
|
||||
- name: Download OpenClaw Integration
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: openclaw-integration
|
||||
path: ./artifacts/openclaw-integration
|
||||
|
||||
- name: Download AI SDK Integration
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: ai-sdk-integration
|
||||
path: ./artifacts/ai-sdk-integration
|
||||
|
||||
- name: Download Chat Integration
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: chat-integration
|
||||
path: ./artifacts/chat-integration
|
||||
|
||||
- name: Download Control Plane
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -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
|
||||
|
||||
+138
-18
@@ -3,6 +3,7 @@ name: CI
|
||||
on:
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -97,6 +98,30 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/ai-sdk
|
||||
run: npm run build
|
||||
|
||||
test-ai-sdk-integration-deno:
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -199,7 +224,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
|
||||
@@ -421,7 +445,6 @@ jobs:
|
||||
HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
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
|
||||
@@ -484,7 +507,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
|
||||
@@ -587,7 +609,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
|
||||
@@ -686,6 +707,119 @@ jobs:
|
||||
echo "=== API Server Logs ==="
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-typescript-client-deno:
|
||||
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:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
|
||||
@@ -719,7 +853,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
|
||||
@@ -826,7 +959,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
|
||||
@@ -931,7 +1063,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
|
||||
@@ -1038,7 +1169,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
|
||||
@@ -1282,7 +1412,6 @@ jobs:
|
||||
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
|
||||
@@ -1340,7 +1469,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
|
||||
@@ -1396,7 +1524,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
|
||||
@@ -1530,7 +1657,6 @@ jobs:
|
||||
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
|
||||
@@ -1603,9 +1729,6 @@ jobs:
|
||||
|
||||
verify-generated-files:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
@@ -1675,9 +1798,6 @@ jobs:
|
||||
|
||||
check-openapi-compatibility:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: hindsight
|
||||
description: Hindsight helm chart
|
||||
type: application
|
||||
version: 0.4.18
|
||||
appVersion: "0.4.18"
|
||||
version: 0.4.19
|
||||
appVersion: "0.4.19"
|
||||
keywords:
|
||||
- ai
|
||||
- memory
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hindsight-all-slim"
|
||||
version = "0.4.18"
|
||||
version = "0.4.19"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-all"
|
||||
version = "0.4.18"
|
||||
version = "0.4.19"
|
||||
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.18"
|
||||
__version__ = "0.4.19"
|
||||
|
||||
+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")
|
||||
@@ -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
|
||||
@@ -425,6 +425,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
|
||||
@@ -491,6 +496,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):
|
||||
@@ -544,7 +554,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,
|
||||
@@ -1314,6 +1328,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."""
|
||||
|
||||
@@ -3888,6 +3910,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,
|
||||
@@ -4121,7 +4171,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")}
|
||||
@@ -4441,10 +4491,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:
|
||||
@@ -4461,20 +4514,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:
|
||||
@@ -4493,24 +4556,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)
|
||||
@@ -4673,6 +4753,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)
|
||||
|
||||
|
||||
@@ -267,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"
|
||||
@@ -355,7 +356,7 @@ PROVIDER_DEFAULT_MODELS = {
|
||||
"anthropic": "claude-haiku-4-5-20251001",
|
||||
"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",
|
||||
@@ -443,9 +444,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)
|
||||
@@ -719,6 +722,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
|
||||
@@ -849,6 +854,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",
|
||||
@@ -1101,9 +1108,7 @@ 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()
|
||||
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)
|
||||
@@ -1177,6 +1182,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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -633,7 +633,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)
|
||||
|
||||
@@ -561,6 +561,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 +585,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 +714,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"):
|
||||
@@ -1953,6 +1957,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.
|
||||
@@ -2110,6 +2115,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 +2140,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
confidence_score=confidence_score,
|
||||
document_tags=document_tags,
|
||||
operation_id=operation_id,
|
||||
strategy=strategy,
|
||||
outbox_callback=outbox_callback,
|
||||
)
|
||||
|
||||
@@ -2186,6 +2193,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 +2226,13 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Resolve bank-specific config for this operation
|
||||
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
|
||||
|
||||
# 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(
|
||||
@@ -3863,6 +3878,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,
|
||||
@@ -7377,6 +7444,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.
|
||||
|
||||
@@ -7485,6 +7553,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
|
||||
@@ -7599,6 +7669,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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -48,7 +48,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__(
|
||||
|
||||
@@ -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,8 +1078,10 @@ 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
|
||||
@@ -1046,19 +1114,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)
|
||||
@@ -1889,6 +1961,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 +2042,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(
|
||||
@@ -2013,15 +2136,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
|
||||
|
||||
@@ -257,6 +257,8 @@ def main():
|
||||
retain_extraction_mode=config.retain_extraction_mode,
|
||||
retain_mission=config.retain_mission,
|
||||
retain_custom_instructions=config.retain_custom_instructions,
|
||||
retain_default_strategy=config.retain_default_strategy,
|
||||
retain_strategies=config.retain_strategies,
|
||||
retain_batch_tokens=config.retain_batch_tokens,
|
||||
retain_entity_lookup=config.retain_entity_lookup,
|
||||
retain_batch_enabled=config.retain_batch_enabled,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api-slim"
|
||||
version = "0.4.18"
|
||||
version = "0.4.19"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -55,7 +55,7 @@ dependencies = [
|
||||
"filelock>=3.20.1", # TOCTOU race condition fix
|
||||
"authlib>=1.6.6", # Account takeover vulnerability fix
|
||||
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities
|
||||
"claude-agent-sdk>=0.1.27",
|
||||
"claude-agent-sdk>=0.1.27; sys_platform == 'darwin'",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -168,9 +168,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
|
||||
|
||||
@@ -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)
|
||||
@@ -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) == 19
|
||||
|
||||
# Verify credential fields (NEVER exposed)
|
||||
assert "llm_api_key" in credentials
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -851,8 +853,8 @@ 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)")
|
||||
print("✓ Successfully stored and retrieved facts")
|
||||
print(" (Note: Metadata support depends on API implementation)")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -954,7 +956,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 +1358,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 +2212,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 +2287,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 +2318,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 +2559,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 +2590,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 +2616,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 +2633,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 +2649,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)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api"
|
||||
version = "0.4.18"
|
||||
version = "0.4.19"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hindsight-cli"
|
||||
version = "0.4.18"
|
||||
version = "0.4.19"
|
||||
edition = "2021"
|
||||
authors = ["Hindsight Team"]
|
||||
description = "A beautiful CLI for Hindsight - semantic memory system"
|
||||
|
||||
@@ -390,6 +390,7 @@ pub fn retain(
|
||||
entities: None,
|
||||
tags: None,
|
||||
observation_scopes: None,
|
||||
strategy: None,
|
||||
};
|
||||
|
||||
let request = RetainRequest {
|
||||
|
||||
@@ -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.18
|
||||
version: 0.4.19
|
||||
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
|
||||
@@ -4014,6 +4054,9 @@ components:
|
||||
type: array
|
||||
observation_scopes:
|
||||
$ref: '#/components/schemas/ObservationScopes'
|
||||
strategy:
|
||||
nullable: true
|
||||
type: string
|
||||
required:
|
||||
- content
|
||||
title: MemoryItem
|
||||
@@ -4446,6 +4489,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."
|
||||
@@ -4795,6 +4849,11 @@ components:
|
||||
operation_id:
|
||||
nullable: true
|
||||
type: string
|
||||
operation_ids:
|
||||
items:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
usage:
|
||||
$ref: '#/components/schemas/TokenUsage'
|
||||
required:
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
// APIClient manages communication with the Hindsight HTTP API API v0.4.19
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
@@ -29,6 +29,7 @@ type MemoryItem struct {
|
||||
Entities []EntityInput `json:"entities,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
ObservationScopes NullableObservationScopes `json:"observation_scopes,omitempty"`
|
||||
Strategy NullableString `json:"strategy,omitempty"`
|
||||
}
|
||||
|
||||
type _MemoryItem MemoryItem
|
||||
@@ -342,6 +343,48 @@ func (o *MemoryItem) UnsetObservationScopes() {
|
||||
o.ObservationScopes.Unset()
|
||||
}
|
||||
|
||||
// GetStrategy returns the Strategy field value if set, zero value otherwise (both if not set or set to explicit null).
|
||||
func (o *MemoryItem) GetStrategy() string {
|
||||
if o == nil || IsNil(o.Strategy.Get()) {
|
||||
var ret string
|
||||
return ret
|
||||
}
|
||||
return *o.Strategy.Get()
|
||||
}
|
||||
|
||||
// GetStrategyOk returns a tuple with the Strategy field value if set, nil otherwise
|
||||
// and a boolean to check if the value has been set.
|
||||
// NOTE: If the value is an explicit nil, `nil, true` will be returned
|
||||
func (o *MemoryItem) GetStrategyOk() (*string, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return o.Strategy.Get(), o.Strategy.IsSet()
|
||||
}
|
||||
|
||||
// HasStrategy returns a boolean if a field has been set.
|
||||
func (o *MemoryItem) HasStrategy() bool {
|
||||
if o != nil && o.Strategy.IsSet() {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// SetStrategy gets a reference to the given NullableString and assigns it to the Strategy field.
|
||||
func (o *MemoryItem) SetStrategy(v string) {
|
||||
o.Strategy.Set(&v)
|
||||
}
|
||||
// SetStrategyNil sets the value for Strategy to be an explicit nil
|
||||
func (o *MemoryItem) SetStrategyNil() {
|
||||
o.Strategy.Set(nil)
|
||||
}
|
||||
|
||||
// UnsetStrategy ensures that no value is present for Strategy, not even an explicit nil
|
||||
func (o *MemoryItem) UnsetStrategy() {
|
||||
o.Strategy.Unset()
|
||||
}
|
||||
|
||||
func (o MemoryItem) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
@@ -374,6 +417,9 @@ func (o MemoryItem) ToMap() (map[string]interface{}, error) {
|
||||
if o.ObservationScopes.IsSet() {
|
||||
toSerialize["observation_scopes"] = o.ObservationScopes.Get()
|
||||
}
|
||||
if o.Strategy.IsSet() {
|
||||
toSerialize["strategy"] = o.Strategy.Get()
|
||||
}
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/*
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT.
|
||||
|
||||
package hindsight
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// checks if the RecoverConsolidationResponse type satisfies the MappedNullable interface at compile time
|
||||
var _ MappedNullable = &RecoverConsolidationResponse{}
|
||||
|
||||
// RecoverConsolidationResponse Response model for recovering failed consolidation.
|
||||
type RecoverConsolidationResponse struct {
|
||||
RetriedCount int32 `json:"retried_count"`
|
||||
}
|
||||
|
||||
type _RecoverConsolidationResponse RecoverConsolidationResponse
|
||||
|
||||
// NewRecoverConsolidationResponse instantiates a new RecoverConsolidationResponse object
|
||||
// This constructor will assign default values to properties that have it defined,
|
||||
// and makes sure properties required by API are set, but the set of arguments
|
||||
// will change when the set of required properties is changed
|
||||
func NewRecoverConsolidationResponse(retriedCount int32) *RecoverConsolidationResponse {
|
||||
this := RecoverConsolidationResponse{}
|
||||
this.RetriedCount = retriedCount
|
||||
return &this
|
||||
}
|
||||
|
||||
// NewRecoverConsolidationResponseWithDefaults instantiates a new RecoverConsolidationResponse object
|
||||
// This constructor will only assign default values to properties that have it defined,
|
||||
// but it doesn't guarantee that properties required by API are set
|
||||
func NewRecoverConsolidationResponseWithDefaults() *RecoverConsolidationResponse {
|
||||
this := RecoverConsolidationResponse{}
|
||||
return &this
|
||||
}
|
||||
|
||||
// GetRetriedCount returns the RetriedCount field value
|
||||
func (o *RecoverConsolidationResponse) GetRetriedCount() int32 {
|
||||
if o == nil {
|
||||
var ret int32
|
||||
return ret
|
||||
}
|
||||
|
||||
return o.RetriedCount
|
||||
}
|
||||
|
||||
// GetRetriedCountOk returns a tuple with the RetriedCount field value
|
||||
// and a boolean to check if the value has been set.
|
||||
func (o *RecoverConsolidationResponse) GetRetriedCountOk() (*int32, bool) {
|
||||
if o == nil {
|
||||
return nil, false
|
||||
}
|
||||
return &o.RetriedCount, true
|
||||
}
|
||||
|
||||
// SetRetriedCount sets field value
|
||||
func (o *RecoverConsolidationResponse) SetRetriedCount(v int32) {
|
||||
o.RetriedCount = v
|
||||
}
|
||||
|
||||
func (o RecoverConsolidationResponse) MarshalJSON() ([]byte, error) {
|
||||
toSerialize,err := o.ToMap()
|
||||
if err != nil {
|
||||
return []byte{}, err
|
||||
}
|
||||
return json.Marshal(toSerialize)
|
||||
}
|
||||
|
||||
func (o RecoverConsolidationResponse) ToMap() (map[string]interface{}, error) {
|
||||
toSerialize := map[string]interface{}{}
|
||||
toSerialize["retried_count"] = o.RetriedCount
|
||||
return toSerialize, nil
|
||||
}
|
||||
|
||||
func (o *RecoverConsolidationResponse) UnmarshalJSON(data []byte) (err error) {
|
||||
// This validates that all required properties are included in the JSON object
|
||||
// by unmarshalling the object into a generic map with string keys and checking
|
||||
// that every required field exists as a key in the generic map.
|
||||
requiredProperties := []string{
|
||||
"retried_count",
|
||||
}
|
||||
|
||||
allProperties := make(map[string]interface{})
|
||||
|
||||
err = json.Unmarshal(data, &allProperties)
|
||||
|
||||
if err != nil {
|
||||
return err;
|
||||
}
|
||||
|
||||
for _, requiredProperty := range(requiredProperties) {
|
||||
if _, exists := allProperties[requiredProperty]; !exists {
|
||||
return fmt.Errorf("no value given for required property %v", requiredProperty)
|
||||
}
|
||||
}
|
||||
|
||||
varRecoverConsolidationResponse := _RecoverConsolidationResponse{}
|
||||
|
||||
decoder := json.NewDecoder(bytes.NewReader(data))
|
||||
decoder.DisallowUnknownFields()
|
||||
err = decoder.Decode(&varRecoverConsolidationResponse)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*o = RecoverConsolidationResponse(varRecoverConsolidationResponse)
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
type NullableRecoverConsolidationResponse struct {
|
||||
value *RecoverConsolidationResponse
|
||||
isSet bool
|
||||
}
|
||||
|
||||
func (v NullableRecoverConsolidationResponse) Get() *RecoverConsolidationResponse {
|
||||
return v.value
|
||||
}
|
||||
|
||||
func (v *NullableRecoverConsolidationResponse) Set(val *RecoverConsolidationResponse) {
|
||||
v.value = val
|
||||
v.isSet = true
|
||||
}
|
||||
|
||||
func (v NullableRecoverConsolidationResponse) IsSet() bool {
|
||||
return v.isSet
|
||||
}
|
||||
|
||||
func (v *NullableRecoverConsolidationResponse) Unset() {
|
||||
v.value = nil
|
||||
v.isSet = false
|
||||
}
|
||||
|
||||
func NewNullableRecoverConsolidationResponse(val *RecoverConsolidationResponse) *NullableRecoverConsolidationResponse {
|
||||
return &NullableRecoverConsolidationResponse{value: val, isSet: true}
|
||||
}
|
||||
|
||||
func (v NullableRecoverConsolidationResponse) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(v.value)
|
||||
}
|
||||
|
||||
func (v *NullableRecoverConsolidationResponse) UnmarshalJSON(src []byte) error {
|
||||
v.isSet = true
|
||||
return json.Unmarshal(src, &v.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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.18
|
||||
API version: 0.4.19
|
||||
*/
|
||||
|
||||
// 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