Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d5550a279 | ||
|
|
32f99074aa | ||
|
|
54f9ce1dec | ||
|
|
bd0021655e | ||
|
|
8a17cc138b | ||
|
|
36c0f3e25d | ||
|
|
e22ae05f47 | ||
|
|
b57e337fa2 | ||
|
|
c05c491d77 | ||
|
|
fc941d5cae | ||
|
|
576016f5dc | ||
|
|
b3995d1430 | ||
|
|
f519fc4fd0 | ||
|
|
72fd3d59db | ||
|
|
5a61ac50e9 | ||
|
|
1f1716bdb0 | ||
|
|
61a8014f9d | ||
|
|
c5091d29cd | ||
|
|
e82bc56580 | ||
|
|
fa0e63b088 | ||
|
|
27cb7e43e0 | ||
|
|
9e23e83abf | ||
|
|
bdf93f0660 | ||
|
|
b0e8ac0f4d | ||
|
|
f74b577e02 | ||
|
|
3c633e5e16 | ||
|
|
cf0537ba7e | ||
|
|
e5944b63e7 | ||
|
|
37348c859e | ||
|
|
cece2c903c | ||
|
|
d7c73f4342 | ||
|
|
3b9d2db091 | ||
|
|
9790d904e0 | ||
|
|
2463efd0f2 | ||
|
|
6674ee4706 | ||
|
|
57f154454d | ||
|
|
4028dd91f8 | ||
|
|
0e81d1a25e | ||
|
|
8a2388a48f | ||
|
|
48185a4bee | ||
|
|
7e23f8e149 | ||
|
|
f659bb17c4 | ||
|
|
f31f82627c | ||
|
|
e1c6220f0e | ||
|
|
66cbdda3cb | ||
|
|
cf4bd598b4 | ||
|
|
443c94c827 | ||
|
|
26794aab09 | ||
|
|
7863ffeb49 | ||
|
|
9e2890ba81 | ||
|
|
e0e65c44f6 | ||
|
|
6881f63781 | ||
|
|
6cb309f72b | ||
|
|
f9fe6953a3 | ||
|
|
07de798c3b | ||
|
|
cefa75545a | ||
|
|
cd99eef4c5 |
@@ -157,7 +157,16 @@ If any files in `hindsight-integrations/` were added or changed, verify:
|
||||
- **Release process** — check that the integration name is in the `VALID_INTEGRATIONS` array in `scripts/release-integration.sh`. If missing, flag it.
|
||||
- **Code standards** — the integration code must follow all Python style rules (type hints, no raw dicts, no tuple returns, etc.).
|
||||
|
||||
### 10. Review against other coding standards
|
||||
### 10. Check MCP tool registration completeness
|
||||
|
||||
If any new MCP tools were added or existing tools renamed in `hindsight-api-slim/hindsight_api/mcp_tools.py`:
|
||||
- **`_ALL_TOOLS` set** in `mcp_tools.py` — must include the new tool name
|
||||
- **`tools_to_register` default set** in `register_mcp_tools()` in `mcp_tools.py` — must include the new tool name
|
||||
- **`_SINGLE_BANK_TOOLS` set** in `hindsight-api-slim/hindsight_api/api/mcp.py` — must include the new tool if it is bank-scoped (not a bank-management tool like `list_banks`/`create_bank`)
|
||||
- **`MCP_TOOL_GROUPS`** in `hindsight-control-plane/src/components/bank-config-view.tsx` — must include the new tool in the appropriate group for the UI tool selector
|
||||
- **Tool count assertions** in tests (e.g., `test_mcp_tools.py`) — must be updated to reflect the new count
|
||||
|
||||
### 11. Review against other coding standards
|
||||
|
||||
Check the diff for violations of the standards listed above:
|
||||
- Python files at project root (not allowed)
|
||||
@@ -169,7 +178,7 @@ Check the diff for violations of the standards listed above:
|
||||
- Premature abstractions or speculative helpers
|
||||
- Backwards-compatibility hacks (unused vars, re-exports, "removed" comments)
|
||||
|
||||
### 11. Report findings
|
||||
### 12. Report findings
|
||||
|
||||
Present a clear summary organized by severity:
|
||||
|
||||
|
||||
@@ -150,6 +150,55 @@ jobs:
|
||||
path: hindsight-clients/typescript/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-hindsight-all-npm:
|
||||
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'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace=hindsight-all-npm
|
||||
|
||||
- name: Build
|
||||
run: npm run build --workspace=hindsight-all-npm
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: ./hindsight-all-npm
|
||||
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-all-npm
|
||||
run: npm pack
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: hindsight-all-npm
|
||||
path: hindsight-all-npm/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-control-plane:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm
|
||||
@@ -407,7 +456,7 @@ jobs:
|
||||
|
||||
create-github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
|
||||
needs: [release-python-packages, release-typescript-client, release-hindsight-all-npm, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -436,6 +485,12 @@ jobs:
|
||||
name: control-plane
|
||||
path: ./artifacts/control-plane
|
||||
|
||||
- name: Download hindsight-embed npm wrapper
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: hindsight-all-npm
|
||||
path: ./artifacts/hindsight-all-npm
|
||||
|
||||
- name: Download Rust CLI (Linux)
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
@@ -472,6 +527,8 @@ jobs:
|
||||
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
|
||||
# TypeScript client
|
||||
cp artifacts/typescript-client/*.tgz release-assets/ || true
|
||||
# hindsight-embed npm wrapper
|
||||
cp artifacts/hindsight-all-npm/*.tgz release-assets/ || true
|
||||
# Control Plane
|
||||
cp artifacts/control-plane/*.tgz release-assets/ || true
|
||||
# Rust CLI binaries
|
||||
|
||||
+237
-48
@@ -32,6 +32,7 @@ jobs:
|
||||
helm: ${{ steps.filter.outputs.helm }}
|
||||
docs: ${{ steps.filter.outputs.docs }}
|
||||
embed: ${{ steps.filter.outputs.embed }}
|
||||
all-npm: ${{ steps.filter.outputs.all-npm }}
|
||||
hindsight-all: ${{ steps.filter.outputs.hindsight-all }}
|
||||
integration-tests: ${{ steps.filter.outputs.integration-tests }}
|
||||
integrations-openclaw: ${{ steps.filter.outputs.integrations-openclaw }}
|
||||
@@ -43,8 +44,9 @@ jobs:
|
||||
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
|
||||
integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }}
|
||||
integrations-ag2: ${{ steps.filter.outputs.integrations-ag2 }}
|
||||
integrations-hermes: ${{ steps.filter.outputs.integrations-hermes }}
|
||||
integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }}
|
||||
integrations-paperclip: ${{ steps.filter.outputs.integrations-paperclip }}
|
||||
integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }}
|
||||
dev: ${{ steps.filter.outputs.dev }}
|
||||
ci: ${{ steps.filter.outputs.ci }}
|
||||
# Secrets are available for internal PRs, pull_request_review, and workflow_dispatch.
|
||||
@@ -91,6 +93,10 @@ jobs:
|
||||
- '*.md'
|
||||
embed:
|
||||
- 'hindsight-embed/**'
|
||||
all-npm:
|
||||
- 'hindsight-all-npm/**'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
hindsight-all:
|
||||
- 'hindsight-all/**'
|
||||
integration-tests:
|
||||
@@ -113,10 +119,12 @@ jobs:
|
||||
- 'hindsight-integrations/pydantic-ai/**'
|
||||
integrations-ag2:
|
||||
- 'hindsight-integrations/ag2/**'
|
||||
integrations-hermes:
|
||||
- 'hindsight-integrations/hermes/**'
|
||||
integrations-llamaindex:
|
||||
- 'hindsight-integrations/llamaindex/**'
|
||||
integrations-paperclip:
|
||||
- 'hindsight-integrations/paperclip/**'
|
||||
integrations-opencode:
|
||||
- 'hindsight-integrations/opencode/**'
|
||||
dev:
|
||||
- 'hindsight-dev/**'
|
||||
ci:
|
||||
@@ -180,12 +188,12 @@ jobs:
|
||||
- name: Build TypeScript client
|
||||
run: npm run build --workspace=hindsight-clients/typescript
|
||||
|
||||
build-openclaw-integration:
|
||||
build-hindsight-all-npm:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name != 'pull_request_review' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
|
||||
needs.detect-changes.outputs.all-npm == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -198,18 +206,125 @@ jobs:
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace=hindsight-all-npm
|
||||
|
||||
- name: Run tests
|
||||
run: npm test --workspace=hindsight-all-npm
|
||||
|
||||
- name: Build
|
||||
run: npm run build --workspace=hindsight-all-npm
|
||||
|
||||
build-openclaw-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name != 'pull_request_review' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
|
||||
needs.detect-changes.outputs.clients-ts == 'true' ||
|
||||
needs.detect-changes.outputs.all-npm == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
# openclaw depends on two monorepo workspaces via `file:` deps:
|
||||
# @vectorize-io/hindsight-client and @vectorize-io/hindsight-all. Their
|
||||
# `dist/` directories are gitignored, so we must build them first.
|
||||
# Otherwise vitest/tsc in openclaw fails with
|
||||
# "Failed to resolve entry for package ..." on the value imports.
|
||||
- name: Install root workspace dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build hindsight-client (openclaw dep)
|
||||
run: npm run build --workspace=hindsight-clients/typescript
|
||||
|
||||
- name: Build hindsight-all-npm (openclaw dep)
|
||||
run: npm run build --workspace=hindsight-all-npm
|
||||
|
||||
- name: Install openclaw dependencies
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm ci
|
||||
|
||||
# Build must run before tests: one unit test in src/backfill.test.ts
|
||||
# creates a symlink to `$cwd/dist/backfill.js` and calls realpathSync on
|
||||
# it via isDirectExecution(). Without a populated dist/ the realpath call
|
||||
# throws, both paths stay unresolved, and the equality assertion fails.
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm run build
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
smoke-openclaw-install:
|
||||
needs: [detect-changes, build-openclaw-integration]
|
||||
if: >-
|
||||
github.event_name != 'pull_request_review' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-openclaw == 'true' ||
|
||||
needs.detect-changes.outputs.clients-ts == 'true' ||
|
||||
needs.detect-changes.outputs.all-npm == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
# Install the openclaw CLI globally. The smoke test exercises the real
|
||||
# `openclaw plugins install` / `openclaw config set` / `openclaw plugins
|
||||
# doctor` commands — not the in-repo integration tests — so a real CLI
|
||||
# must be on PATH.
|
||||
- name: Install openclaw CLI
|
||||
run: npm install -g openclaw
|
||||
|
||||
- name: Verify openclaw CLI
|
||||
run: openclaw --version
|
||||
|
||||
# openclaw depends on the workspace packages via published version
|
||||
# ranges (^0.1.0 / ^0.5.0), not file: paths, so the smoke test's
|
||||
# `openclaw plugins install <tarball>` resolves them straight from the
|
||||
# npm registry. These builds are just for `npm pack` / local unit
|
||||
# tests, not for resolving the plugin's runtime deps.
|
||||
- name: Install root workspace dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build hindsight-client (openclaw dep)
|
||||
run: npm run build --workspace=hindsight-clients/typescript
|
||||
|
||||
- name: Build hindsight-all-npm (openclaw dep)
|
||||
run: npm run build --workspace=hindsight-all-npm
|
||||
|
||||
- name: Install openclaw dependencies
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm run build
|
||||
run: npm ci
|
||||
|
||||
- name: Run openclaw install smoke test
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: ./scripts/smoke-test.sh
|
||||
|
||||
test-claude-code-integration:
|
||||
needs: [detect-changes]
|
||||
@@ -326,6 +441,37 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/ai-sdk
|
||||
run: npm run test:deno
|
||||
|
||||
test-opencode-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name != 'pull_request_review' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-opencode == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/opencode
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/opencode
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/opencode
|
||||
run: npm run build
|
||||
|
||||
build-chat-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -357,6 +503,37 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/chat
|
||||
run: npm run build
|
||||
|
||||
test-paperclip-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name != 'pull_request_review' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-paperclip == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/paperclip
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/paperclip
|
||||
run: npm run build
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/paperclip
|
||||
run: npm test
|
||||
|
||||
build-control-plane:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -1460,6 +1637,18 @@ jobs:
|
||||
print('Models downloaded successfully')
|
||||
"
|
||||
|
||||
# openclaw depends on @vectorize-io/hindsight-client and
|
||||
# @vectorize-io/hindsight-all via `file:` — their `dist/` directories are
|
||||
# gitignored and must be built before openclaw's npm ci copies them.
|
||||
- name: Install root workspace dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build hindsight-client (openclaw dep)
|
||||
run: npm run build --workspace=hindsight-clients/typescript
|
||||
|
||||
- name: Build hindsight-all-npm (openclaw dep)
|
||||
run: npm run build --workspace=hindsight-all-npm
|
||||
|
||||
- name: Install openclaw integration dependencies
|
||||
working-directory: ./hindsight-integrations/openclaw
|
||||
run: npm ci
|
||||
@@ -1755,43 +1944,6 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/pydantic-ai
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-hermes-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name != 'pull_request_review' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-hermes == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Build hermes integration
|
||||
working-directory: ./hindsight-integrations/hermes
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/hermes
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/hermes
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-llamaindex-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -2413,6 +2565,40 @@ jobs:
|
||||
cd hindsight-dev
|
||||
uv run check-openapi-compatibility /tmp/old-openapi.json ../hindsight-docs/static/openapi.json
|
||||
|
||||
check-cli-coverage:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
github.event_name != 'pull_request_review' &&
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.core == 'true' ||
|
||||
needs.detect-changes.outputs.cli == 'true' ||
|
||||
needs.detect-changes.outputs.dev == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install hindsight-dev dependencies
|
||||
run: |
|
||||
cd hindsight-dev && uv sync --frozen --index-strategy unsafe-best-match
|
||||
|
||||
- name: Check CLI covers every OpenAPI operation
|
||||
run: |
|
||||
cd hindsight-dev
|
||||
uv run cli-coverage-check
|
||||
|
||||
# Report CI status back to the PR for pull_request_review events.
|
||||
# GitHub does not automatically link pull_request_review check runs to the PR,
|
||||
# so we create a commit status on the PR head SHA and post a comment.
|
||||
@@ -2423,11 +2609,14 @@ jobs:
|
||||
- build-api-python-versions
|
||||
- build-typescript-client
|
||||
- build-openclaw-integration
|
||||
- smoke-openclaw-install
|
||||
- test-claude-code-integration
|
||||
- test-codex-integration
|
||||
- build-ai-sdk-integration
|
||||
- test-ai-sdk-integration-deno
|
||||
- test-opencode-integration
|
||||
- build-chat-integration
|
||||
- test-paperclip-integration
|
||||
- build-control-plane
|
||||
- build-docs
|
||||
- test-rust-cli
|
||||
@@ -2446,7 +2635,6 @@ jobs:
|
||||
- test-crewai-integration
|
||||
- test-litellm-integration
|
||||
- test-pydantic-ai-integration
|
||||
- test-hermes-integration
|
||||
- test-llamaindex-integration
|
||||
- test-pip-slim
|
||||
- test-embed
|
||||
@@ -2455,6 +2643,7 @@ jobs:
|
||||
- test-upgrade
|
||||
- verify-generated-files
|
||||
- check-openapi-compatibility
|
||||
- check-cli-coverage
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
statuses: write
|
||||
@@ -2462,7 +2651,7 @@ jobs:
|
||||
steps:
|
||||
- name: Determine overall result
|
||||
id: result
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const needs = ${{ toJSON(needs) }};
|
||||
@@ -2495,7 +2684,7 @@ jobs:
|
||||
core.setOutput('run_url', runUrl);
|
||||
|
||||
- name: Report status to PR
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
await github.rest.repos.createCommitStatus({
|
||||
@@ -2509,7 +2698,7 @@ jobs:
|
||||
});
|
||||
|
||||
- name: Comment on PR
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: hindsight
|
||||
description: Hindsight helm chart
|
||||
type: application
|
||||
version: 0.4.22
|
||||
appVersion: "0.4.22"
|
||||
version: 0.5.0
|
||||
appVersion: "0.5.0"
|
||||
keywords:
|
||||
- ai
|
||||
- memory
|
||||
|
||||
@@ -95,6 +95,27 @@ spec:
|
||||
{{- toYaml .Values.api.readinessProbe | nindent 10 }}
|
||||
resources:
|
||||
{{- toYaml .Values.api.resources | nindent 10 }}
|
||||
{{- if or .Values.api.persistence.modelCache.enabled .Values.api.extraVolumeMounts }}
|
||||
volumeMounts:
|
||||
{{- if .Values.api.persistence.modelCache.enabled }}
|
||||
- name: model-cache
|
||||
mountPath: /home/hindsight/.cache
|
||||
{{- end }}
|
||||
{{- with .Values.api.extraVolumeMounts }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- if or .Values.api.persistence.modelCache.enabled .Values.api.extraVolumes }}
|
||||
volumes:
|
||||
{{- if .Values.api.persistence.modelCache.enabled }}
|
||||
- name: model-cache
|
||||
persistentVolumeClaim:
|
||||
claimName: {{ include "hindsight.fullname" . }}-api-model-cache
|
||||
{{- end }}
|
||||
{{- with .Values.api.extraVolumes }}
|
||||
{{- toYaml . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{{- if and .Values.api.enabled .Values.api.persistence.modelCache.enabled }}
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: {{ include "hindsight.fullname" . }}-api-model-cache
|
||||
labels:
|
||||
{{- include "hindsight.api.labels" . | nindent 4 }}
|
||||
{{- with .Values.api.persistence.modelCache.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 4 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
{{- toYaml .Values.api.persistence.modelCache.accessModes | nindent 4 }}
|
||||
{{- if .Values.api.persistence.modelCache.storageClass }}
|
||||
storageClassName: {{ .Values.api.persistence.modelCache.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.api.persistence.modelCache.size }}
|
||||
{{- end }}
|
||||
@@ -95,6 +95,16 @@ spec:
|
||||
{{- toYaml .Values.worker.readinessProbe | nindent 10 }}
|
||||
resources:
|
||||
{{- toYaml .Values.worker.resources | nindent 10 }}
|
||||
{{- if or .Values.worker.persistence.modelCache.enabled .Values.worker.extraVolumeMounts }}
|
||||
volumeMounts:
|
||||
{{- if .Values.worker.persistence.modelCache.enabled }}
|
||||
- name: model-cache
|
||||
mountPath: /home/hindsight/.cache
|
||||
{{- end }}
|
||||
{{- with .Values.worker.extraVolumeMounts }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
@@ -107,4 +117,26 @@ spec:
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.worker.extraVolumes }}
|
||||
volumes:
|
||||
{{- toYaml . | nindent 6 }}
|
||||
{{- end }}
|
||||
{{- if .Values.worker.persistence.modelCache.enabled }}
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: model-cache
|
||||
{{- with .Values.worker.persistence.modelCache.annotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
accessModes:
|
||||
{{- toYaml .Values.worker.persistence.modelCache.accessModes | nindent 8 }}
|
||||
{{- if .Values.worker.persistence.modelCache.storageClass }}
|
||||
storageClassName: {{ .Values.worker.persistence.modelCache.storageClass }}
|
||||
{{- end }}
|
||||
resources:
|
||||
requests:
|
||||
storage: {{ .Values.worker.persistence.modelCache.size }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -67,6 +67,33 @@ api:
|
||||
# Pod affinity/anti-affinity (overrides global affinity for this component)
|
||||
# affinity: {}
|
||||
|
||||
# Persistent volume for local model cache (reranker, embeddings)
|
||||
# Models are downloaded to /home/hindsight/.cache on first use.
|
||||
# Without persistence, models are re-downloaded on every pod restart.
|
||||
persistence:
|
||||
modelCache:
|
||||
enabled: false
|
||||
size: 5Gi
|
||||
storageClass: ""
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
annotations: {}
|
||||
|
||||
# Extra volume mounts for the api container
|
||||
# e.g.
|
||||
# extraVolumeMounts:
|
||||
# - name: my-volume
|
||||
# mountPath: /mnt/my-volume
|
||||
extraVolumeMounts: []
|
||||
|
||||
# Extra volumes for the api pod
|
||||
# e.g.
|
||||
# extraVolumes:
|
||||
# - name: my-volume
|
||||
# configMap:
|
||||
# name: my-configmap
|
||||
extraVolumes: []
|
||||
|
||||
# Environment variables
|
||||
env:
|
||||
#HINDSIGHT_API_LLM_PROVIDER: "groq"
|
||||
@@ -140,6 +167,32 @@ worker:
|
||||
# Pod affinity/anti-affinity (overrides global affinity for this component)
|
||||
# affinity: {}
|
||||
|
||||
# Persistent volume for local model cache (reranker, embeddings)
|
||||
# Uses volumeClaimTemplates since worker is a StatefulSet.
|
||||
persistence:
|
||||
modelCache:
|
||||
enabled: false
|
||||
size: 5Gi
|
||||
storageClass: ""
|
||||
accessModes:
|
||||
- ReadWriteOnce
|
||||
annotations: {}
|
||||
|
||||
# Extra volume mounts for the worker container
|
||||
# e.g.
|
||||
# extraVolumeMounts:
|
||||
# - name: my-volume
|
||||
# mountPath: /mnt/my-volume
|
||||
extraVolumeMounts: []
|
||||
|
||||
# Extra volumes for the worker pod
|
||||
# e.g.
|
||||
# extraVolumes:
|
||||
# - name: my-volume
|
||||
# configMap:
|
||||
# name: my-configmap
|
||||
extraVolumes: []
|
||||
|
||||
# Secret environment variables (inherited from api.secrets if not specified)
|
||||
secrets: {}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
*.tgz
|
||||
.DS_Store
|
||||
@@ -0,0 +1,80 @@
|
||||
# @vectorize-io/hindsight-all
|
||||
|
||||
Node.js equivalent of the Python [`hindsight-all`](https://pypi.org/project/hindsight-all/) package — programmatic lifecycle manager for a local Hindsight daemon. Use this when you want to embed Hindsight in a Node application without hand-rolling subprocess management.
|
||||
|
||||
This package deliberately does **not** ship an HTTP client. Once the daemon is running, talk to it with [`@vectorize-io/hindsight-client`](https://www.npmjs.com/package/@vectorize-io/hindsight-client) against `server.getBaseUrl()`. The two packages compose — one owns the daemon process, the other owns the HTTP API surface.
|
||||
|
||||
## Requirements
|
||||
|
||||
- **Node.js >= 22** — uses global `fetch` and `AbortSignal.timeout`.
|
||||
- **`uv` / `uvx`** on `PATH` — used to download and run the underlying `hindsight-embed` daemon on first use. Install via <https://docs.astral.sh/uv/>.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @vectorize-io/hindsight-all @vectorize-io/hindsight-client
|
||||
```
|
||||
|
||||
## Example
|
||||
|
||||
```ts
|
||||
import { HindsightServer, consoleLogger } from '@vectorize-io/hindsight-all';
|
||||
import { HindsightClient } from '@vectorize-io/hindsight-client';
|
||||
|
||||
const server = new HindsightServer({
|
||||
profile: 'my-app',
|
||||
port: 9077,
|
||||
env: {
|
||||
HINDSIGHT_API_LLM_PROVIDER: 'anthropic',
|
||||
HINDSIGHT_API_LLM_API_KEY: process.env.ANTHROPIC_API_KEY,
|
||||
HINDSIGHT_API_LLM_MODEL: 'claude-sonnet-4-20250514',
|
||||
HINDSIGHT_EMBED_DAEMON_IDLE_TIMEOUT: '0',
|
||||
},
|
||||
logger: consoleLogger,
|
||||
});
|
||||
|
||||
await server.start();
|
||||
|
||||
const client = new HindsightClient({ baseUrl: server.getBaseUrl() });
|
||||
|
||||
await client.retain('user-123', 'User prefers dark mode and concise answers.', {
|
||||
documentId: 'pref-2026-04-01',
|
||||
});
|
||||
|
||||
const recall = await client.recall('user-123', 'what are the user preferences?');
|
||||
console.log(recall.results);
|
||||
|
||||
await server.stop();
|
||||
```
|
||||
|
||||
For a remote Hindsight API, skip `HindsightServer` entirely and just point `HindsightClient` at the remote URL.
|
||||
|
||||
## Open config — forward-compatible with new daemon flags
|
||||
|
||||
`HindsightServerOptions` is designed so every new environment variable or CLI flag in the underlying Hindsight daemon can be used without waiting for a wrapper release:
|
||||
|
||||
- **`env`** accepts an arbitrary `Record<string, string>`. Every entry is exported into the daemon process and written into the profile config via `--env KEY=VALUE`.
|
||||
- **`extraProfileCreateArgs`** / **`extraDaemonStartArgs`** append raw args to the respective commands.
|
||||
|
||||
## Development against a local checkout
|
||||
|
||||
If you're hacking on the Python `hindsight-embed` package in the same monorepo, point the server at the local path — it'll use `uv run --directory <path>` instead of `uvx`:
|
||||
|
||||
```ts
|
||||
new HindsightServer({
|
||||
embedPackagePath: '/path/to/hindsight-embed',
|
||||
// ...
|
||||
});
|
||||
```
|
||||
|
||||
## API surface
|
||||
|
||||
- `HindsightServer` — daemon lifecycle (`start`, `stop`, `checkHealth`, `getBaseUrl`, `getProfile`).
|
||||
- `Logger` interface plus `silentLogger` (default) and `consoleLogger` helpers.
|
||||
- `getEmbedCommand(opts)` — low-level helper that returns the `[cmd, ...args]` tuple used to invoke the underlying Python CLI.
|
||||
|
||||
For memory operations (retain, recall, reflect, bank management, stats) use [`@vectorize-io/hindsight-client`](https://www.npmjs.com/package/@vectorize-io/hindsight-client).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "@vectorize-io/hindsight-all",
|
||||
"version": "0.5.0",
|
||||
"description": "Node.js programmatic lifecycle manager for Hindsight — embeds a local hindsight daemon in a Node application. Pair with @vectorize-io/hindsight-client for memory operations.",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"hindsight",
|
||||
"hindsight-all",
|
||||
"memory",
|
||||
"ai",
|
||||
"agent",
|
||||
"long-term-memory",
|
||||
"llm",
|
||||
"embedded-server"
|
||||
],
|
||||
"author": "Vectorize <[email protected]>",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/vectorize-io/hindsight.git",
|
||||
"directory": "hindsight-all-npm"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest run src",
|
||||
"test:watch": "vitest src",
|
||||
"prepublishOnly": "npm run clean && npm run build"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"overrides": {
|
||||
"rollup": "^4.59.0",
|
||||
"picomatch": ">=2.3.2 <3.0.0 || >=4.0.4",
|
||||
"vite": ">=8.0.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getEmbedCommand } from './command.js';
|
||||
|
||||
describe('getEmbedCommand', () => {
|
||||
it('defaults to uvx hindsight-embed@latest', () => {
|
||||
expect(getEmbedCommand()).toEqual(['uvx', 'hindsight-embed@latest']);
|
||||
});
|
||||
|
||||
it('honours an explicit version', () => {
|
||||
expect(getEmbedCommand({ embedVersion: '0.5.0' })).toEqual(['uvx', '[email protected]']);
|
||||
});
|
||||
|
||||
it('treats an empty version as latest', () => {
|
||||
expect(getEmbedCommand({ embedVersion: '' })).toEqual(['uvx', 'hindsight-embed@latest']);
|
||||
});
|
||||
|
||||
it('uses uv run --directory when a local path is given', () => {
|
||||
expect(getEmbedCommand({ embedPackagePath: '/abs/path' })).toEqual([
|
||||
'uv',
|
||||
'run',
|
||||
'--directory',
|
||||
'/abs/path',
|
||||
'hindsight-embed',
|
||||
]);
|
||||
});
|
||||
|
||||
it('local path takes precedence over version', () => {
|
||||
expect(
|
||||
getEmbedCommand({ embedPackagePath: '/abs/path', embedVersion: '0.5.0' }),
|
||||
).toEqual(['uv', 'run', '--directory', '/abs/path', 'hindsight-embed']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Resolve the command that invokes the `hindsight-embed` Python CLI.
|
||||
*
|
||||
* - If `embedPackagePath` is set, runs the package from a local checkout via
|
||||
* `uv run --directory <path> hindsight-embed`. Used for in-repo development.
|
||||
* - Otherwise runs it via `uvx hindsight-embed@<version>` so no global install
|
||||
* is required.
|
||||
*
|
||||
* Returns the argv as `[command, ...baseArgs]` suitable for `spawn()` /
|
||||
* `execFile()` (never shell-interpolated).
|
||||
*/
|
||||
export interface EmbedCommandOptions {
|
||||
/** Version spec passed to uvx (e.g. "latest", "0.5.0"). Default: "latest". */
|
||||
embedVersion?: string;
|
||||
/** Local checkout path. When set, overrides `embedVersion` and uses `uv run`. */
|
||||
embedPackagePath?: string;
|
||||
}
|
||||
|
||||
export function getEmbedCommand(opts: EmbedCommandOptions = {}): string[] {
|
||||
if (opts.embedPackagePath) {
|
||||
return ['uv', 'run', '--directory', opts.embedPackagePath, 'hindsight-embed'];
|
||||
}
|
||||
const version = opts.embedVersion && opts.embedVersion.length > 0 ? opts.embedVersion : 'latest';
|
||||
return ['uvx', `hindsight-embed@${version}`];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export { HindsightServer } from './server.js';
|
||||
export { getEmbedCommand } from './command.js';
|
||||
export { silentLogger, consoleLogger } from './logger.js';
|
||||
|
||||
export type { Logger } from './logger.js';
|
||||
export type { EmbedCommandOptions } from './command.js';
|
||||
export type { HindsightServerOptions } from './types.js';
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Pluggable logger interface.
|
||||
*
|
||||
* This package does not own any logging infrastructure — consumers inject
|
||||
* whatever they want (console, pino, openclaw's logger, a no-op). The default
|
||||
* is silent so embedding this package never adds noise to an unrelated app.
|
||||
*/
|
||||
export interface Logger {
|
||||
debug(msg: string): void;
|
||||
info(msg: string): void;
|
||||
warn(msg: string): void;
|
||||
error(msg: string): void;
|
||||
}
|
||||
|
||||
/** Logger that drops every call. Used when no logger is passed. */
|
||||
export const silentLogger: Logger = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
};
|
||||
|
||||
/** Logger that writes to the standard console. Handy for CLIs and tests. */
|
||||
export const consoleLogger: Logger = {
|
||||
debug: (msg) => console.debug(msg),
|
||||
info: (msg) => console.log(msg),
|
||||
warn: (msg) => console.warn(msg),
|
||||
error: (msg) => console.error(msg),
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { HindsightServer } from './server.js';
|
||||
|
||||
describe('HindsightServer construction', () => {
|
||||
it('defaults base URL to http://127.0.0.1:8888', () => {
|
||||
const server = new HindsightServer();
|
||||
expect(server.getBaseUrl()).toBe('http://127.0.0.1:8888');
|
||||
expect(server.getProfile()).toBe('default');
|
||||
});
|
||||
|
||||
it('honours custom profile, port, and host', () => {
|
||||
const server = new HindsightServer({ profile: 'app', port: 9077, host: '0.0.0.0' });
|
||||
expect(server.getProfile()).toBe('app');
|
||||
expect(server.getBaseUrl()).toBe('http://0.0.0.0:9077');
|
||||
});
|
||||
|
||||
it('accepts open env pass-through without complaining about unknown keys', () => {
|
||||
const server = new HindsightServer({
|
||||
env: {
|
||||
HINDSIGHT_API_LLM_PROVIDER: 'openai',
|
||||
HINDSIGHT_API_LLM_MODEL: 'gpt-4o-mini',
|
||||
// A field that does not exist today — should still be accepted
|
||||
HINDSIGHT_FUTURE_FLAG: 'enabled',
|
||||
},
|
||||
});
|
||||
expect(server).toBeInstanceOf(HindsightServer);
|
||||
});
|
||||
|
||||
it('exposes checkHealth that returns false when no daemon is running', async () => {
|
||||
// Random high port that nothing is listening on.
|
||||
const server = new HindsightServer({ port: 1, readyTimeoutMs: 100 });
|
||||
const healthy = await server.checkHealth();
|
||||
expect(healthy).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,322 @@
|
||||
import { spawn } from 'child_process';
|
||||
import { getEmbedCommand } from './command.js';
|
||||
import { silentLogger } from './logger.js';
|
||||
import type { Logger } from './logger.js';
|
||||
import type { HindsightServerOptions } from './types.js';
|
||||
|
||||
const DEFAULT_PORT = 8888;
|
||||
const DEFAULT_HOST = '127.0.0.1';
|
||||
const DEFAULT_PROFILE = 'default';
|
||||
const DEFAULT_READY_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_READY_POLL_INTERVAL_MS = 1_000;
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of a local Hindsight daemon from a Node.js process.
|
||||
*
|
||||
* On {@link start}, this class:
|
||||
* 1. Resolves the `hindsight-embed` command (via `uvx` or a local `uv run`).
|
||||
* 2. Runs `profile create <name> --merge --port <port> [--env K=V ...]`
|
||||
* with every entry in {@link HindsightServerOptions.env} forwarded as
|
||||
* an `--env` flag.
|
||||
* 3. Runs `daemon --profile <name> start` and waits for the start command
|
||||
* to exit.
|
||||
* 4. Polls `http://host:port/health` until it returns `200` or the
|
||||
* `readyTimeoutMs` budget is exhausted.
|
||||
*
|
||||
* On {@link stop}, it runs `daemon --profile <name> stop` and returns once
|
||||
* the command exits (or after a short grace period).
|
||||
*
|
||||
* This is the Node.js equivalent of the Python `hindsight-all` package's
|
||||
* `HindsightServer`: a thin programmatic lifecycle wrapper around the
|
||||
* Hindsight daemon. It does NOT ship an HTTP client — once `start()`
|
||||
* resolves, use `@vectorize-io/hindsight-client` against `getBaseUrl()` for
|
||||
* retain / recall / reflect.
|
||||
*
|
||||
* The class is deliberately transparent about the daemon: new CLI flags or
|
||||
* environment variables never require a code change here — callers can pass
|
||||
* them via `env`, `extraProfileCreateArgs`, or `extraDaemonStartArgs`.
|
||||
*/
|
||||
export class HindsightServer {
|
||||
private readonly profile: string;
|
||||
private readonly port: number;
|
||||
private readonly host: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly embedVersion: string | undefined;
|
||||
private readonly embedPackagePath: string | undefined;
|
||||
private readonly userEnv: Record<string, string | undefined>;
|
||||
private readonly extraProfileCreateArgs: string[];
|
||||
private readonly extraDaemonStartArgs: string[];
|
||||
private readonly platformCpuWorkaround: boolean;
|
||||
private readonly readyTimeoutMs: number;
|
||||
private readonly readyPollIntervalMs: number;
|
||||
private readonly logger: Logger;
|
||||
|
||||
constructor(opts: HindsightServerOptions = {}) {
|
||||
this.profile = opts.profile ?? DEFAULT_PROFILE;
|
||||
this.port = opts.port ?? DEFAULT_PORT;
|
||||
this.host = opts.host ?? DEFAULT_HOST;
|
||||
this.baseUrl = `http://${this.host}:${this.port}`;
|
||||
this.embedVersion = opts.embedVersion;
|
||||
this.embedPackagePath = opts.embedPackagePath;
|
||||
this.userEnv = opts.env ?? {};
|
||||
this.extraProfileCreateArgs = opts.extraProfileCreateArgs ?? [];
|
||||
this.extraDaemonStartArgs = opts.extraDaemonStartArgs ?? [];
|
||||
this.platformCpuWorkaround = opts.platformCpuWorkaround ?? (process.platform === 'darwin');
|
||||
this.readyTimeoutMs = opts.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS;
|
||||
this.readyPollIntervalMs = opts.readyPollIntervalMs ?? DEFAULT_READY_POLL_INTERVAL_MS;
|
||||
this.logger = opts.logger ?? silentLogger;
|
||||
}
|
||||
|
||||
/** The base URL the daemon listens on (`http://host:port`). */
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
|
||||
/** The profile name this server operates on. */
|
||||
getProfile(): string {
|
||||
return this.profile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the daemon is configured and running. Idempotent — the underlying
|
||||
* `profile create --merge` and `daemon start` commands tolerate re-runs.
|
||||
*/
|
||||
async start(): Promise<void> {
|
||||
this.logger.info(`[hindsight] starting daemon for profile "${this.profile}"`);
|
||||
|
||||
const env = this.buildEnv();
|
||||
await this.configureProfile(env);
|
||||
await this.startDaemon(env);
|
||||
await this.waitForReady();
|
||||
|
||||
this.logger.info(`[hindsight] daemon ready at ${this.baseUrl}`);
|
||||
}
|
||||
|
||||
/** Stop the daemon. Never throws — logs and resolves even on failure. */
|
||||
async stop(): Promise<void> {
|
||||
this.logger.info(`[hindsight] stopping daemon for profile "${this.profile}"`);
|
||||
|
||||
const [cmd, ...baseArgs] = getEmbedCommand({
|
||||
embedVersion: this.embedVersion,
|
||||
embedPackagePath: this.embedPackagePath,
|
||||
});
|
||||
const args = [...baseArgs, 'daemon', '--profile', this.profile, 'stop'];
|
||||
|
||||
const child = spawn(cmd, args, { stdio: 'pipe' });
|
||||
this.pipeOutput(child, 'daemon.stop');
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.logger.warn(`[hindsight] daemon stop timed out after 5s`);
|
||||
resolve();
|
||||
}, 5_000);
|
||||
child.on('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
this.logger.info(`[hindsight] daemon stopped`);
|
||||
resolve();
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
this.logger.warn(`[hindsight] error stopping daemon: ${err.message}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Probe `/health` once with a short timeout. */
|
||||
async checkHealth(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/health`, {
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Merge the process env, the caller-supplied `env`, and (on macOS) the
|
||||
* embeddings CPU workaround. Caller-supplied values always win over the
|
||||
* workaround; undefined values are dropped.
|
||||
*/
|
||||
private buildEnv(): NodeJS.ProcessEnv {
|
||||
const merged: NodeJS.ProcessEnv = { ...process.env };
|
||||
|
||||
if (this.platformCpuWorkaround && process.platform === 'darwin') {
|
||||
merged['HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU'] = '1';
|
||||
merged['HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU'] = '1';
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(this.userEnv)) {
|
||||
if (value !== undefined) {
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run `profile create <name> --merge --port <port> [--env K=V ...]`.
|
||||
* Every entry in the merged env that was passed via {@link userEnv} (or
|
||||
* auto-applied by the CPU workaround) is forwarded as `--env`.
|
||||
*/
|
||||
private async configureProfile(env: NodeJS.ProcessEnv): Promise<void> {
|
||||
this.logger.info(`[hindsight] configuring profile "${this.profile}"`);
|
||||
|
||||
const [cmd, ...baseArgs] = getEmbedCommand({
|
||||
embedVersion: this.embedVersion,
|
||||
embedPackagePath: this.embedPackagePath,
|
||||
});
|
||||
const createArgs = [
|
||||
...baseArgs,
|
||||
'profile',
|
||||
'create',
|
||||
this.profile,
|
||||
'--merge',
|
||||
'--port',
|
||||
String(this.port),
|
||||
];
|
||||
|
||||
// Forward every env var that the caller intended for the daemon as --env.
|
||||
// We only forward keys the caller explicitly set (userEnv) plus the CPU
|
||||
// workaround values — not the entire process.env, to avoid leaking random
|
||||
// host state into profile config.
|
||||
const envForProfile = this.collectProfileEnv(env);
|
||||
for (const [key, value] of Object.entries(envForProfile)) {
|
||||
createArgs.push('--env', `${key}=${value}`);
|
||||
}
|
||||
|
||||
createArgs.push(...this.extraProfileCreateArgs);
|
||||
|
||||
await this.runCommand(cmd, createArgs, env, 'profile.create');
|
||||
}
|
||||
|
||||
/** Collect only the env vars that should be written into the profile file. */
|
||||
private collectProfileEnv(env: NodeJS.ProcessEnv): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
|
||||
// 1. User-supplied env — always forwarded.
|
||||
for (const [key, value] of Object.entries(this.userEnv)) {
|
||||
if (value !== undefined) {
|
||||
out[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. CPU workaround — only if auto-applied and not already overridden.
|
||||
if (this.platformCpuWorkaround && process.platform === 'darwin') {
|
||||
const cpuKeys = [
|
||||
'HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU',
|
||||
'HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU',
|
||||
];
|
||||
for (const key of cpuKeys) {
|
||||
if (!(key in out) && env[key] !== undefined) {
|
||||
out[key] = env[key] as string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
private async startDaemon(env: NodeJS.ProcessEnv): Promise<void> {
|
||||
const [cmd, ...baseArgs] = getEmbedCommand({
|
||||
embedVersion: this.embedVersion,
|
||||
embedPackagePath: this.embedPackagePath,
|
||||
});
|
||||
const args = [
|
||||
...baseArgs,
|
||||
'daemon',
|
||||
'--profile',
|
||||
this.profile,
|
||||
'start',
|
||||
...this.extraDaemonStartArgs,
|
||||
];
|
||||
|
||||
await this.runCommand(cmd, args, env, 'daemon.start');
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn `cmd` with `args`, pipe its output through the logger, and resolve
|
||||
* once it exits with code 0. Rejects on non-zero exit or spawn error.
|
||||
*/
|
||||
private async runCommand(
|
||||
cmd: string,
|
||||
args: string[],
|
||||
env: NodeJS.ProcessEnv,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
const child = spawn(cmd, args, { stdio: 'pipe', env });
|
||||
let output = '';
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
output += text;
|
||||
for (const line of text.trimEnd().split('\n')) {
|
||||
if (line) this.logger.info(`[hindsight:${label}] ${line}`);
|
||||
}
|
||||
});
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
const text = data.toString();
|
||||
output += text;
|
||||
for (const line of text.trimEnd().split('\n')) {
|
||||
if (line) this.logger.warn(`[hindsight:${label}] ${line}`);
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
child.on('exit', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`${label} failed with code ${code}: ${output.trim()}`));
|
||||
}
|
||||
});
|
||||
child.on('error', (err) => {
|
||||
reject(new Error(`${label} failed to spawn: ${err.message}`, { cause: err }));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Stream a spawned child's stdout/stderr through the logger without blocking. */
|
||||
private pipeOutput(child: ReturnType<typeof spawn>, label: string): void {
|
||||
child.stdout?.on('data', (data: Buffer) => {
|
||||
for (const line of data.toString().trimEnd().split('\n')) {
|
||||
if (line) this.logger.info(`[hindsight:${label}] ${line}`);
|
||||
}
|
||||
});
|
||||
child.stderr?.on('data', (data: Buffer) => {
|
||||
for (const line of data.toString().trimEnd().split('\n')) {
|
||||
if (line) this.logger.warn(`[hindsight:${label}] ${line}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Poll `/health` until it succeeds or `readyTimeoutMs` elapses. */
|
||||
private async waitForReady(): Promise<void> {
|
||||
const deadline = Date.now() + this.readyTimeoutMs;
|
||||
let attempt = 0;
|
||||
while (Date.now() < deadline) {
|
||||
attempt++;
|
||||
try {
|
||||
const res = await fetch(`${this.baseUrl}/health`, {
|
||||
signal: AbortSignal.timeout(this.readyPollIntervalMs),
|
||||
});
|
||||
if (res.ok) {
|
||||
this.logger.debug(`[hindsight] health check passed (attempt ${attempt})`);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// expected while the daemon is still booting
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, this.readyPollIntervalMs));
|
||||
}
|
||||
throw new Error(
|
||||
`Hindsight daemon did not become ready within ${this.readyTimeoutMs}ms at ${this.baseUrl}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* Options for {@link HindsightServer}.
|
||||
*
|
||||
* The server is intentionally thin and pass-through: anything configurable
|
||||
* on the daemon side (env vars or CLI flags) can be set here without needing
|
||||
* a new dedicated option. Use {@link env} for `HINDSIGHT_*` / `OPENAI_API_KEY` /
|
||||
* custom provider settings, and the two `extra*` arrays to append raw CLI
|
||||
* args to `profile create` or `daemon start`.
|
||||
*
|
||||
* For talking to the daemon after `start()`, use `@vectorize-io/hindsight-client`
|
||||
* against `server.getBaseUrl()`. This package does not ship its own HTTP
|
||||
* client.
|
||||
*/
|
||||
export interface HindsightServerOptions {
|
||||
/** Profile name used for `--profile <name>` on every sub-command. Default: `"default"`. */
|
||||
profile?: string;
|
||||
/** TCP port the daemon listens on. Default: `8888`. */
|
||||
port?: number;
|
||||
/** Hostname the daemon binds to (for health checks). Default: `127.0.0.1`. */
|
||||
host?: string;
|
||||
/** Version of the underlying `hindsight-embed` PyPI package to run via `uvx`. Default: `"latest"`. */
|
||||
embedVersion?: string;
|
||||
/** Local path to a `hindsight-embed` checkout — takes precedence over `embedVersion`. */
|
||||
embedPackagePath?: string;
|
||||
/**
|
||||
* Environment variables passed to the daemon process AND written into the
|
||||
* profile via repeated `--env KEY=VALUE` flags. This is the preferred way
|
||||
* to surface any `HINDSIGHT_API_*` / `HINDSIGHT_EMBED_*` setting — adding a
|
||||
* new daemon env var never requires a wrapper update.
|
||||
*
|
||||
* Values of `undefined` are dropped (so you can spread conditionally).
|
||||
*/
|
||||
env?: Record<string, string | undefined>;
|
||||
/** Extra args appended verbatim to `hindsight-embed profile create <name> --merge ...`. */
|
||||
extraProfileCreateArgs?: string[];
|
||||
/** Extra args appended verbatim to `hindsight-embed daemon --profile <name> start ...`. */
|
||||
extraDaemonStartArgs?: string[];
|
||||
/**
|
||||
* On macOS, automatically set
|
||||
* `HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU=1` and
|
||||
* `HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=1` to avoid Metal/MPS crashes in
|
||||
* daemon mode. Default: `true` on `darwin`, ignored elsewhere. Any value set
|
||||
* explicitly in {@link env} wins over the auto-applied value.
|
||||
*/
|
||||
platformCpuWorkaround?: boolean;
|
||||
/** Max time (ms) to wait for `/health` to return 200. Default: `30_000`. */
|
||||
readyTimeoutMs?: number;
|
||||
/** Polling interval (ms) while waiting for `/health`. Default: `1_000`. */
|
||||
readyPollIntervalMs?: number;
|
||||
/** Optional pluggable logger. Default: silent. */
|
||||
logger?: Logger;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"moduleResolution": "node",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
outDir: 'dist',
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
bundle: true,
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
environment: 'node',
|
||||
},
|
||||
});
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hindsight-all-slim"
|
||||
version = "0.4.22"
|
||||
version = "0.5.0"
|
||||
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.22"
|
||||
version = "0.5.0"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -20,6 +20,9 @@ hindsight-client = { workspace = true }
|
||||
hindsight-embed = { workspace = true }
|
||||
|
||||
[project.optional-dependencies]
|
||||
local-llm = [
|
||||
"hindsight-api-slim[local-llm]>=0.4.17",
|
||||
]
|
||||
test = [
|
||||
"pytest>=7.0.0",
|
||||
"pytest-asyncio>=0.21.0",
|
||||
|
||||
@@ -46,4 +46,4 @@ __all__ = [
|
||||
"RemoteTEICrossEncoder",
|
||||
"LLMConfig",
|
||||
]
|
||||
__version__ = "0.4.22"
|
||||
__version__ = "0.5.0"
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
"""Merge 3 migration heads and add unit_entities composite index
|
||||
|
||||
Revision ID: h3i4j5k6l7m8
|
||||
Revises: a4b5c6d7e8f9, c2d3e4f5g6h7, g2h3i4j5k6l7
|
||||
Create Date: 2026-04-07
|
||||
|
||||
Merges three unmerged migration heads into one, and adds a composite index
|
||||
(entity_id, unit_id) on unit_entities for index-only scans in the LATERAL
|
||||
entity expansion query.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision: str = "h3i4j5k6l7m8"
|
||||
down_revision: str | Sequence[str] | None = ("a4b5c6d7e8f9", "c2d3e4f5g6h7", "g2h3i4j5k6l7")
|
||||
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()
|
||||
# Composite index enables index-only scans for entity_id -> unit_id lookups
|
||||
op.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS idx_unit_entities_entity_unit ON {schema}unit_entities (entity_id, unit_id)"
|
||||
)
|
||||
# Drop the now-redundant single-column index
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}idx_unit_entities_entity")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
schema = _get_schema_prefix()
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}idx_unit_entities_entity_unit")
|
||||
# Restore the single-column index
|
||||
op.execute(f"CREATE INDEX IF NOT EXISTS idx_unit_entities_entity ON {schema}unit_entities (entity_id)")
|
||||
@@ -463,6 +463,12 @@ class MemoryItem(BaseModel):
|
||||
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'.",
|
||||
)
|
||||
update_mode: Literal["replace", "append"] | None = Field(
|
||||
default=None,
|
||||
description="How to handle an existing document with the same document_id. "
|
||||
"'replace' (default) deletes old data and reprocesses from scratch. "
|
||||
"'append' concatenates new content to the existing document text and reprocesses.",
|
||||
)
|
||||
|
||||
@field_validator("timestamp", mode="before")
|
||||
@classmethod
|
||||
@@ -1661,7 +1667,9 @@ class BankTemplateConfig(BaseModel):
|
||||
disposition_skepticism: int | None = Field(default=None, ge=1, le=5, description="Skepticism trait (1-5)")
|
||||
disposition_literalism: int | None = Field(default=None, ge=1, le=5, description="Literalism trait (1-5)")
|
||||
disposition_empathy: int | None = Field(default=None, ge=1, le=5, description="Empathy trait (1-5)")
|
||||
entity_labels: list[str] | None = Field(default=None, description="Controlled vocabulary for entity labels")
|
||||
entity_labels: list[dict[str, Any]] | None = Field(
|
||||
default=None, description="Controlled vocabulary for entity labels"
|
||||
)
|
||||
entities_allow_free_form: bool | None = Field(
|
||||
default=None, description="Allow entities outside the label vocabulary"
|
||||
)
|
||||
@@ -1792,6 +1800,150 @@ class BankTemplateImportResponse(BaseModel):
|
||||
dry_run: bool = Field(default=False, description="True if this was a validation-only run")
|
||||
|
||||
|
||||
def validate_bank_template(manifest: "BankTemplateManifest") -> list[str]:
|
||||
"""Validate a parsed manifest beyond Pydantic's structural checks.
|
||||
|
||||
Returns a list of human-readable error strings (e.g. invalid
|
||||
extraction mode values, conflicting settings).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
if manifest.bank:
|
||||
bank = manifest.bank
|
||||
if bank.retain_extraction_mode is not None:
|
||||
valid_modes = ("concise", "verbose", "custom", "chunks")
|
||||
if bank.retain_extraction_mode not in valid_modes:
|
||||
errors.append(
|
||||
f"bank.retain_extraction_mode: must be one of {valid_modes}, got '{bank.retain_extraction_mode}'"
|
||||
)
|
||||
if bank.retain_custom_instructions and bank.retain_extraction_mode != "custom":
|
||||
errors.append("bank.retain_custom_instructions: requires retain_extraction_mode='custom'")
|
||||
if manifest.mental_models:
|
||||
for i, mm in enumerate(manifest.mental_models):
|
||||
if not mm.name.strip():
|
||||
errors.append(f"mental_models[{i}].name: must not be empty")
|
||||
if not mm.source_query.strip():
|
||||
errors.append(f"mental_models[{i}].source_query: must not be empty")
|
||||
if manifest.directives:
|
||||
for i, d in enumerate(manifest.directives):
|
||||
if not d.name.strip():
|
||||
errors.append(f"directives[{i}].name: must not be empty")
|
||||
if not d.content.strip():
|
||||
errors.append(f"directives[{i}].content: must not be empty")
|
||||
return errors
|
||||
|
||||
|
||||
async def apply_bank_template_manifest(
|
||||
memory,
|
||||
bank_id: str,
|
||||
manifest: "BankTemplateManifest",
|
||||
request_context: "RequestContext",
|
||||
) -> "BankTemplateImportResponse":
|
||||
"""Apply a validated BankTemplateManifest to an existing bank.
|
||||
|
||||
Shared by the /import endpoint and the default-template-on-create hook
|
||||
driven by HINDSIGHT_API_DEFAULT_BANK_TEMPLATE. The bank MUST already
|
||||
exist; caller is responsible for validation (Pydantic + validate_bank_template).
|
||||
"""
|
||||
config_applied = False
|
||||
if manifest.bank:
|
||||
config_updates = manifest.bank.get_config_updates()
|
||||
if config_updates:
|
||||
await memory._config_resolver.update_bank_config(bank_id, config_updates, request_context)
|
||||
config_applied = True
|
||||
|
||||
created_ids: list[str] = []
|
||||
updated_ids: list[str] = []
|
||||
operation_ids: list[str] = []
|
||||
|
||||
if manifest.mental_models:
|
||||
# Fetch existing mental models to decide create vs update
|
||||
existing = await memory.list_mental_models(bank_id=bank_id, request_context=request_context)
|
||||
existing_by_id = {m["id"]: m for m in existing}
|
||||
|
||||
for mm in manifest.mental_models:
|
||||
if mm.id in existing_by_id:
|
||||
await memory.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mm.id,
|
||||
name=mm.name,
|
||||
source_query=mm.source_query,
|
||||
max_tokens=mm.max_tokens,
|
||||
tags=mm.tags if mm.tags else None,
|
||||
trigger=mm.trigger.model_dump() if mm.trigger else None,
|
||||
request_context=request_context,
|
||||
)
|
||||
result = await memory.submit_async_refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mm.id,
|
||||
request_context=request_context,
|
||||
)
|
||||
operation_ids.append(result["operation_id"])
|
||||
updated_ids.append(mm.id)
|
||||
else:
|
||||
mental_model = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name=mm.name,
|
||||
source_query=mm.source_query,
|
||||
content="Generating content...",
|
||||
mental_model_id=mm.id,
|
||||
tags=mm.tags if mm.tags else None,
|
||||
max_tokens=mm.max_tokens,
|
||||
trigger=mm.trigger.model_dump() if mm.trigger else None,
|
||||
request_context=request_context,
|
||||
)
|
||||
result = await memory.submit_async_refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model["id"],
|
||||
request_context=request_context,
|
||||
)
|
||||
operation_ids.append(result["operation_id"])
|
||||
created_ids.append(mm.id)
|
||||
|
||||
directives_created: list[str] = []
|
||||
directives_updated: list[str] = []
|
||||
|
||||
if manifest.directives:
|
||||
existing_directives = await memory.list_directives(
|
||||
bank_id=bank_id, active_only=False, request_context=request_context
|
||||
)
|
||||
existing_by_name = {d["name"]: d for d in existing_directives}
|
||||
|
||||
for directive in manifest.directives:
|
||||
if directive.name in existing_by_name:
|
||||
await memory.update_directive(
|
||||
bank_id=bank_id,
|
||||
directive_id=existing_by_name[directive.name]["id"],
|
||||
content=directive.content,
|
||||
priority=directive.priority,
|
||||
is_active=directive.is_active,
|
||||
tags=directive.tags if directive.tags else None,
|
||||
request_context=request_context,
|
||||
)
|
||||
directives_updated.append(directive.name)
|
||||
else:
|
||||
await memory.create_directive(
|
||||
bank_id=bank_id,
|
||||
name=directive.name,
|
||||
content=directive.content,
|
||||
priority=directive.priority,
|
||||
is_active=directive.is_active,
|
||||
tags=directive.tags if directive.tags else None,
|
||||
request_context=request_context,
|
||||
)
|
||||
directives_created.append(directive.name)
|
||||
|
||||
return BankTemplateImportResponse(
|
||||
bank_id=bank_id,
|
||||
config_applied=config_applied,
|
||||
mental_models_created=created_ids,
|
||||
mental_models_updated=updated_ids,
|
||||
directives_created=directives_created,
|
||||
directives_updated=directives_updated,
|
||||
operation_ids=operation_ids,
|
||||
dry_run=False,
|
||||
)
|
||||
|
||||
|
||||
class OperationResponse(BaseModel):
|
||||
"""Response model for a single async operation."""
|
||||
|
||||
@@ -2677,6 +2829,8 @@ def _register_routes(app: FastAPI):
|
||||
return data
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -3287,6 +3441,8 @@ def _register_routes(app: FastAPI):
|
||||
raise
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -3320,6 +3476,8 @@ def _register_routes(app: FastAPI):
|
||||
raise
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -3484,6 +3642,8 @@ def _register_routes(app: FastAPI):
|
||||
return {"status": "deleted"}
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -4361,38 +4521,6 @@ def _register_routes(app: FastAPI):
|
||||
# Bank Template Import / Export
|
||||
# =====================================================================
|
||||
|
||||
def _validate_template(manifest: BankTemplateManifest) -> list[str]:
|
||||
"""Validate a parsed manifest beyond Pydantic's structural checks.
|
||||
|
||||
Returns a list of human-readable error strings (e.g. invalid
|
||||
extraction mode values, conflicting settings).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
if manifest.bank:
|
||||
bank = manifest.bank
|
||||
if bank.retain_extraction_mode is not None:
|
||||
valid_modes = ("concise", "verbose", "custom", "chunks")
|
||||
if bank.retain_extraction_mode not in valid_modes:
|
||||
errors.append(
|
||||
f"bank.retain_extraction_mode: must be one of {valid_modes}, "
|
||||
f"got '{bank.retain_extraction_mode}'"
|
||||
)
|
||||
if bank.retain_custom_instructions and bank.retain_extraction_mode != "custom":
|
||||
errors.append("bank.retain_custom_instructions: requires retain_extraction_mode='custom'")
|
||||
if manifest.mental_models:
|
||||
for i, mm in enumerate(manifest.mental_models):
|
||||
if not mm.name.strip():
|
||||
errors.append(f"mental_models[{i}].name: must not be empty")
|
||||
if not mm.source_query.strip():
|
||||
errors.append(f"mental_models[{i}].source_query: must not be empty")
|
||||
if manifest.directives:
|
||||
for i, d in enumerate(manifest.directives):
|
||||
if not d.name.strip():
|
||||
errors.append(f"directives[{i}].name: must not be empty")
|
||||
if not d.content.strip():
|
||||
errors.append(f"directives[{i}].content: must not be empty")
|
||||
return errors
|
||||
|
||||
@app.post(
|
||||
"/v1/default/banks/{bank_id}/import",
|
||||
response_model=BankTemplateImportResponse,
|
||||
@@ -4428,7 +4556,7 @@ def _register_routes(app: FastAPI):
|
||||
)
|
||||
|
||||
# Semantic validation beyond Pydantic structural checks
|
||||
validation_errors = _validate_template(body)
|
||||
validation_errors = validate_bank_template(body)
|
||||
if validation_errors:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
@@ -4446,107 +4574,11 @@ def _register_routes(app: FastAPI):
|
||||
# Ensure bank exists (auto-creates with defaults if needed)
|
||||
await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
config_applied = False
|
||||
if body.bank:
|
||||
config_updates = body.bank.get_config_updates()
|
||||
if config_updates:
|
||||
await app.state.memory._config_resolver.update_bank_config(bank_id, config_updates, request_context)
|
||||
config_applied = True
|
||||
|
||||
created_ids: list[str] = []
|
||||
updated_ids: list[str] = []
|
||||
operation_ids: list[str] = []
|
||||
|
||||
if body.mental_models:
|
||||
# Fetch existing mental models to decide create vs update
|
||||
existing = await app.state.memory.list_mental_models(bank_id=bank_id, request_context=request_context)
|
||||
existing_by_id = {m["id"]: m for m in existing}
|
||||
|
||||
for mm in body.mental_models:
|
||||
if mm.id in existing_by_id:
|
||||
# Update existing mental model metadata
|
||||
await app.state.memory.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mm.id,
|
||||
name=mm.name,
|
||||
source_query=mm.source_query,
|
||||
max_tokens=mm.max_tokens,
|
||||
tags=mm.tags if mm.tags else None,
|
||||
trigger=mm.trigger.model_dump() if mm.trigger else None,
|
||||
request_context=request_context,
|
||||
)
|
||||
# Schedule a refresh to regenerate content with updated query
|
||||
result = await app.state.memory.submit_async_refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mm.id,
|
||||
request_context=request_context,
|
||||
)
|
||||
operation_ids.append(result["operation_id"])
|
||||
updated_ids.append(mm.id)
|
||||
else:
|
||||
# Create new mental model
|
||||
mental_model = await app.state.memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name=mm.name,
|
||||
source_query=mm.source_query,
|
||||
content="Generating content...",
|
||||
mental_model_id=mm.id,
|
||||
tags=mm.tags if mm.tags else None,
|
||||
max_tokens=mm.max_tokens,
|
||||
trigger=mm.trigger.model_dump() if mm.trigger else None,
|
||||
request_context=request_context,
|
||||
)
|
||||
result = await app.state.memory.submit_async_refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model["id"],
|
||||
request_context=request_context,
|
||||
)
|
||||
operation_ids.append(result["operation_id"])
|
||||
created_ids.append(mm.id)
|
||||
|
||||
directives_created: list[str] = []
|
||||
directives_updated: list[str] = []
|
||||
|
||||
if body.directives:
|
||||
# Fetch existing directives to decide create vs update (matched by name)
|
||||
existing_directives = await app.state.memory.list_directives(
|
||||
bank_id=bank_id, active_only=False, request_context=request_context
|
||||
)
|
||||
existing_by_name = {d["name"]: d for d in existing_directives}
|
||||
|
||||
for directive in body.directives:
|
||||
if directive.name in existing_by_name:
|
||||
await app.state.memory.update_directive(
|
||||
bank_id=bank_id,
|
||||
directive_id=existing_by_name[directive.name]["id"],
|
||||
content=directive.content,
|
||||
priority=directive.priority,
|
||||
is_active=directive.is_active,
|
||||
tags=directive.tags if directive.tags else None,
|
||||
request_context=request_context,
|
||||
)
|
||||
directives_updated.append(directive.name)
|
||||
else:
|
||||
await app.state.memory.create_directive(
|
||||
bank_id=bank_id,
|
||||
name=directive.name,
|
||||
content=directive.content,
|
||||
priority=directive.priority,
|
||||
is_active=directive.is_active,
|
||||
tags=directive.tags if directive.tags else None,
|
||||
request_context=request_context,
|
||||
)
|
||||
directives_created.append(directive.name)
|
||||
|
||||
return BankTemplateImportResponse(
|
||||
return await apply_bank_template_manifest(
|
||||
memory=app.state.memory,
|
||||
bank_id=bank_id,
|
||||
config_applied=config_applied,
|
||||
mental_models_created=created_ids,
|
||||
mental_models_updated=updated_ids,
|
||||
directives_created=directives_created,
|
||||
directives_updated=directives_updated,
|
||||
operation_ids=operation_ids,
|
||||
dry_run=False,
|
||||
manifest=body,
|
||||
request_context=request_context,
|
||||
)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
@@ -4948,7 +4980,9 @@ def _register_routes(app: FastAPI):
|
||||
from hindsight_api.engine.retain import bank_utils
|
||||
|
||||
# Ensure the bank row exists before inserting into webhooks (FK constraint).
|
||||
await bank_utils.get_bank_profile(pool, bank_id)
|
||||
_, created = await bank_utils.get_or_create_bank_profile(pool, bank_id)
|
||||
if created:
|
||||
await app.state.memory._apply_default_bank_template(bank_id, request_context)
|
||||
|
||||
webhook_id = uuid.uuid4()
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
@@ -5297,6 +5331,8 @@ def _register_routes(app: FastAPI):
|
||||
content_dict["tags"] = item.tags
|
||||
if item.observation_scopes is not None:
|
||||
content_dict["observation_scopes"] = item.observation_scopes
|
||||
if item.update_mode is not None:
|
||||
content_dict["update_mode"] = item.update_mode
|
||||
strategy_groups[effective].append(content_dict)
|
||||
|
||||
if request.async_:
|
||||
|
||||
@@ -97,6 +97,7 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
|
||||
_SINGLE_BANK_TOOLS: frozenset[str] = frozenset(
|
||||
{
|
||||
"retain",
|
||||
"sync_retain",
|
||||
"recall",
|
||||
"reflect",
|
||||
"list_mental_models",
|
||||
@@ -156,24 +157,65 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
|
||||
return mcp
|
||||
|
||||
|
||||
def _get_mcp_tools(mcp: FastMCP) -> dict:
|
||||
"""Get tool name→object mapping, compatible with FastMCP 2.x and 3.x."""
|
||||
# FastMCP 2.x: _tool_manager._tools
|
||||
if hasattr(mcp, "_tool_manager"):
|
||||
return mcp._tool_manager._tools # type: ignore[union-attr]
|
||||
# FastMCP 3.x: _local_provider._components with "tool:" prefix
|
||||
if hasattr(mcp, "_local_provider"):
|
||||
return {
|
||||
k.split(":")[1].split("@")[0]: v
|
||||
for k, v in mcp._local_provider._components.items() # type: ignore[union-attr]
|
||||
if k.startswith("tool:")
|
||||
}
|
||||
msg = "Cannot locate tools on FastMCP instance"
|
||||
raise AttributeError(msg)
|
||||
|
||||
|
||||
def _make_tools_tolerant(mcp: FastMCP) -> None:
|
||||
"""Wrap all tool run methods to strip unknown arguments before validation.
|
||||
"""Wrap all tool run methods to strip unknown arguments and coerce string-encoded JSON.
|
||||
|
||||
LLMs frequently add extra fields like "explanation" or "reasoning" to tool calls.
|
||||
FastMCP's Pydantic TypeAdapter rejects these with "Unexpected keyword argument".
|
||||
This wraps each tool's run() to filter arguments to only known parameters.
|
||||
|
||||
LLMs also frequently serialize list/dict arguments as JSON strings instead of native
|
||||
types (e.g., tags='["a","b"]' instead of tags=["a","b"]). This auto-coerces them.
|
||||
|
||||
This wraps each tool's run() to apply both fixes before validation.
|
||||
"""
|
||||
try:
|
||||
for name, tool in mcp._tool_manager._tools.items(): # type: ignore[unresolved-attribute] # FastMCP 2.x internal; guarded by try/except
|
||||
tools = _get_mcp_tools(mcp)
|
||||
for name, tool in tools.items():
|
||||
if hasattr(tool, "parameters") and tool.parameters:
|
||||
allowed = set(tool.parameters.get("properties", {}).keys())
|
||||
properties = tool.parameters.get("properties", {})
|
||||
allowed = set(properties.keys())
|
||||
|
||||
# Build sets of parameter names that expect array or object types.
|
||||
# Handles both direct types {"type": "array"} and anyOf/oneOf unions
|
||||
# like {"anyOf": [{"type": "array", ...}, {"type": "null"}]}.
|
||||
array_params: set[str] = set()
|
||||
object_params: set[str] = set()
|
||||
for param_name, param_schema in properties.items():
|
||||
_collect_coercible_types(param_schema, param_name, array_params, object_params)
|
||||
|
||||
original_run = tool.run
|
||||
|
||||
async def _tolerant_run(arguments, _allowed=allowed, _orig=original_run):
|
||||
async def _tolerant_run(
|
||||
arguments,
|
||||
_allowed=allowed,
|
||||
_orig=original_run,
|
||||
_array_params=array_params,
|
||||
_object_params=object_params,
|
||||
):
|
||||
extra_keys = set(arguments.keys()) - _allowed
|
||||
if extra_keys:
|
||||
logger.debug(f"Stripping unknown arguments from tool call: {extra_keys}")
|
||||
arguments = {k: v for k, v in arguments.items() if k in _allowed}
|
||||
|
||||
# Coerce string-encoded JSON for list/dict parameters
|
||||
arguments = _coerce_string_json(arguments, _array_params, _object_params)
|
||||
|
||||
return await _orig(arguments)
|
||||
|
||||
# FunctionTool is a Pydantic model with extra='forbid', so use
|
||||
@@ -183,6 +225,59 @@ def _make_tools_tolerant(mcp: FastMCP) -> None:
|
||||
logger.warning(f"Could not make tools tolerant of extra arguments: {e}")
|
||||
|
||||
|
||||
def _collect_coercible_types(schema: dict, param_name: str, array_params: set[str], object_params: set[str]) -> None:
|
||||
"""Check a JSON Schema property and add param_name to array_params/object_params if applicable."""
|
||||
# Direct type
|
||||
schema_type = schema.get("type")
|
||||
if schema_type == "array":
|
||||
array_params.add(param_name)
|
||||
return
|
||||
if schema_type == "object":
|
||||
object_params.add(param_name)
|
||||
return
|
||||
|
||||
# anyOf / oneOf unions (e.g., list[str] | None → {"anyOf": [{"type": "array"}, {"type": "null"}]})
|
||||
for variant in schema.get("anyOf", []) + schema.get("oneOf", []):
|
||||
variant_type = variant.get("type")
|
||||
if variant_type == "array":
|
||||
array_params.add(param_name)
|
||||
return
|
||||
if variant_type == "object":
|
||||
object_params.add(param_name)
|
||||
return
|
||||
|
||||
|
||||
def _coerce_string_json(arguments: dict, array_params: set[str], object_params: set[str]) -> dict:
|
||||
"""Auto-coerce string-encoded JSON arrays/objects to native types.
|
||||
|
||||
LLM agents frequently serialize list and dict tool arguments as JSON strings.
|
||||
This is backward-compatible: native arrays/objects pass through unchanged.
|
||||
"""
|
||||
for param_name in array_params:
|
||||
val = arguments.get(param_name)
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
parsed = json.loads(val)
|
||||
if isinstance(parsed, list):
|
||||
arguments = {**arguments, param_name: parsed}
|
||||
logger.debug(f"Coerced string to list for parameter '{param_name}'")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
for param_name in object_params:
|
||||
val = arguments.get(param_name)
|
||||
if isinstance(val, str):
|
||||
try:
|
||||
parsed = json.loads(val)
|
||||
if isinstance(parsed, dict):
|
||||
arguments = {**arguments, param_name: parsed}
|
||||
logger.debug(f"Coerced string to dict for parameter '{param_name}'")
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
pass
|
||||
|
||||
return arguments
|
||||
|
||||
|
||||
class MCPMiddleware:
|
||||
"""ASGI middleware that intercepts MCP requests and routes to appropriate MCP server.
|
||||
|
||||
|
||||
@@ -178,6 +178,14 @@ ENV_EMBEDDINGS_OPENAI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY"
|
||||
ENV_EMBEDDINGS_OPENAI_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"
|
||||
ENV_EMBEDDINGS_OPENAI_BASE_URL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL"
|
||||
|
||||
# Gemini/Vertex AI embeddings configuration
|
||||
ENV_EMBEDDINGS_GEMINI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_GEMINI_API_KEY"
|
||||
ENV_EMBEDDINGS_GEMINI_MODEL = "HINDSIGHT_API_EMBEDDINGS_GEMINI_MODEL"
|
||||
ENV_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY = "HINDSIGHT_API_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY"
|
||||
ENV_EMBEDDINGS_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_EMBEDDINGS_VERTEXAI_PROJECT_ID"
|
||||
ENV_EMBEDDINGS_VERTEXAI_REGION = "HINDSIGHT_API_EMBEDDINGS_VERTEXAI_REGION"
|
||||
ENV_EMBEDDINGS_VERTEXAI_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_EMBEDDINGS_VERTEXAI_SERVICE_ACCOUNT_KEY"
|
||||
|
||||
# Cohere configuration (separate for embeddings and reranker)
|
||||
ENV_EMBEDDINGS_COHERE_API_KEY = "HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY"
|
||||
ENV_EMBEDDINGS_COHERE_MODEL = "HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL"
|
||||
@@ -186,6 +194,13 @@ ENV_RERANKER_COHERE_API_KEY = "HINDSIGHT_API_RERANKER_COHERE_API_KEY"
|
||||
ENV_RERANKER_COHERE_MODEL = "HINDSIGHT_API_RERANKER_COHERE_MODEL"
|
||||
ENV_RERANKER_COHERE_BASE_URL = "HINDSIGHT_API_RERANKER_COHERE_BASE_URL"
|
||||
|
||||
# OpenRouter configuration (embeddings and reranker)
|
||||
ENV_OPENROUTER_API_KEY = "HINDSIGHT_API_OPENROUTER_API_KEY"
|
||||
ENV_EMBEDDINGS_OPENROUTER_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENROUTER_API_KEY"
|
||||
ENV_EMBEDDINGS_OPENROUTER_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENROUTER_MODEL"
|
||||
ENV_RERANKER_OPENROUTER_API_KEY = "HINDSIGHT_API_RERANKER_OPENROUTER_API_KEY"
|
||||
ENV_RERANKER_OPENROUTER_MODEL = "HINDSIGHT_API_RERANKER_OPENROUTER_MODEL"
|
||||
|
||||
# Deprecated: Legacy shared Cohere API key (for backward compatibility)
|
||||
ENV_COHERE_API_KEY = "HINDSIGHT_API_COHERE_API_KEY"
|
||||
|
||||
@@ -203,6 +218,7 @@ ENV_EMBEDDINGS_LITELLM_SDK_API_KEY = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_K
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MODEL"
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_API_BASE = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_BASE"
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS"
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT"
|
||||
ENV_RERANKER_LITELLM_SDK_API_KEY = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY"
|
||||
ENV_RERANKER_LITELLM_SDK_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL"
|
||||
ENV_RERANKER_LITELLM_SDK_API_BASE = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_BASE"
|
||||
@@ -231,6 +247,11 @@ ENV_RERANKER_ZEROENTROPY_API_KEY = "HINDSIGHT_API_RERANKER_ZEROENTROPY_API_KEY"
|
||||
ENV_RERANKER_ZEROENTROPY_MODEL = "HINDSIGHT_API_RERANKER_ZEROENTROPY_MODEL"
|
||||
ENV_RERANKER_ZEROENTROPY_BASE_URL = "HINDSIGHT_API_RERANKER_ZEROENTROPY_BASE_URL"
|
||||
|
||||
# Google Discovery Engine reranker configuration
|
||||
ENV_RERANKER_GOOGLE_MODEL = "HINDSIGHT_API_RERANKER_GOOGLE_MODEL"
|
||||
ENV_RERANKER_GOOGLE_PROJECT_ID = "HINDSIGHT_API_RERANKER_GOOGLE_PROJECT_ID"
|
||||
ENV_RERANKER_GOOGLE_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_RERANKER_GOOGLE_SERVICE_ACCOUNT_KEY"
|
||||
|
||||
ENV_VECTOR_EXTENSION = "HINDSIGHT_API_VECTOR_EXTENSION"
|
||||
ENV_TEXT_SEARCH_EXTENSION = "HINDSIGHT_API_TEXT_SEARCH_EXTENSION"
|
||||
|
||||
@@ -244,11 +265,14 @@ ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
|
||||
ENV_MCP_ENABLED_TOOLS = "HINDSIGHT_API_MCP_ENABLED_TOOLS"
|
||||
ENV_MCP_STATELESS = "HINDSIGHT_API_MCP_STATELESS"
|
||||
ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API"
|
||||
ENV_DEFAULT_BANK_TEMPLATE = "HINDSIGHT_API_DEFAULT_BANK_TEMPLATE"
|
||||
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
|
||||
ENV_RECALL_MAX_CONCURRENT = "HINDSIGHT_API_RECALL_MAX_CONCURRENT"
|
||||
ENV_RECALL_CONNECTION_BUDGET = "HINDSIGHT_API_RECALL_CONNECTION_BUDGET"
|
||||
ENV_RECALL_MAX_QUERY_TOKENS = "HINDSIGHT_API_RECALL_MAX_QUERY_TOKENS"
|
||||
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
|
||||
ENV_LINK_EXPANSION_PER_ENTITY_LIMIT = "HINDSIGHT_API_LINK_EXPANSION_PER_ENTITY_LIMIT"
|
||||
ENV_LINK_EXPANSION_TIMEOUT = "HINDSIGHT_API_LINK_EXPANSION_TIMEOUT"
|
||||
|
||||
# OpenTelemetry tracing configuration
|
||||
ENV_OTEL_TRACES_ENABLED = "HINDSIGHT_API_OTEL_TRACES_ENABLED"
|
||||
@@ -256,6 +280,7 @@ ENV_OTEL_EXPORTER_OTLP_ENDPOINT = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT"
|
||||
ENV_OTEL_EXPORTER_OTLP_HEADERS = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS"
|
||||
ENV_OTEL_SERVICE_NAME = "HINDSIGHT_API_OTEL_SERVICE_NAME"
|
||||
ENV_OTEL_DEPLOYMENT_ENVIRONMENT = "HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT"
|
||||
ENV_METRICS_INCLUDE_BANK_ID = "HINDSIGHT_API_METRICS_INCLUDE_BANK_ID"
|
||||
|
||||
# Vertex AI configuration
|
||||
ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
|
||||
@@ -320,6 +345,14 @@ ENV_WEBHOOK_SECRET = "HINDSIGHT_API_WEBHOOK_SECRET"
|
||||
ENV_WEBHOOK_EVENT_TYPES = "HINDSIGHT_API_WEBHOOK_EVENT_TYPES"
|
||||
ENV_WEBHOOK_DELIVERY_POLL_INTERVAL_SECONDS = "HINDSIGHT_API_WEBHOOK_DELIVERY_POLL_INTERVAL_SECONDS"
|
||||
|
||||
# Built-in llama.cpp configuration (for provider=llamacpp)
|
||||
ENV_LLAMACPP_MODEL_PATH = "HINDSIGHT_API_LLAMACPP_MODEL_PATH"
|
||||
ENV_LLAMACPP_GPU_LAYERS = "HINDSIGHT_API_LLAMACPP_GPU_LAYERS"
|
||||
ENV_LLAMACPP_CONTEXT_SIZE = "HINDSIGHT_API_LLAMACPP_CONTEXT_SIZE"
|
||||
ENV_LLAMACPP_CHAT_FORMAT = "HINDSIGHT_API_LLAMACPP_CHAT_FORMAT"
|
||||
ENV_LLAMACPP_NO_GRAMMAR = "HINDSIGHT_API_LLAMACPP_NO_GRAMMAR"
|
||||
ENV_LLAMACPP_EXTRA_ARGS = "HINDSIGHT_API_LLAMACPP_EXTRA_ARGS"
|
||||
|
||||
# Optimization flags
|
||||
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
|
||||
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
|
||||
@@ -373,6 +406,7 @@ PROVIDER_DEFAULT_MODELS = {
|
||||
"groq": "openai/gpt-oss-120b",
|
||||
"minimax": "MiniMax-M2.7",
|
||||
"ollama": "gemma3:12b",
|
||||
"llamacpp": "gemma-4-e2b-it",
|
||||
"lmstudio": "local-model",
|
||||
"vertexai": "google/gemini-2.5-flash-lite",
|
||||
"openai-codex": "gpt-5.2-codex",
|
||||
@@ -382,8 +416,16 @@ PROVIDER_DEFAULT_MODELS = {
|
||||
"litellm": "gpt-4o-mini",
|
||||
"bedrock": "us.amazon.nova-2-lite-v1:0",
|
||||
"volcano": "doubao-pro-32k",
|
||||
"openrouter": "qwen/qwen3.5-9b",
|
||||
}
|
||||
DEFAULT_LLM_MODEL = "gpt-4o-mini" # Fallback if provider not in table
|
||||
# Built-in llama.cpp defaults
|
||||
DEFAULT_LLAMACPP_GPU_LAYERS = -1 # -1 = offload all layers to GPU (Metal/CUDA)
|
||||
DEFAULT_LLAMACPP_CONTEXT_SIZE = 8192
|
||||
DEFAULT_LLAMACPP_CHAT_FORMAT = None # None = auto-detect from GGUF metadata
|
||||
DEFAULT_LLAMACPP_NO_GRAMMAR = False # True = disable JSON grammar enforcement (faster but less reliable)
|
||||
DEFAULT_LLAMACPP_EXTRA_ARGS = None # Space-separated extra CLI args for llama.cpp server
|
||||
|
||||
DEFAULT_LLM_MAX_CONCURRENT = 32
|
||||
DEFAULT_LLM_MAX_RETRIES = 10 # Max retry attempts for LLM API calls
|
||||
DEFAULT_LLM_INITIAL_BACKOFF = 1.0 # Initial backoff in seconds for retry exponential backoff
|
||||
@@ -403,6 +445,8 @@ DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
|
||||
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
|
||||
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = False # Security: disabled by default, required for some models
|
||||
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
|
||||
DEFAULT_EMBEDDINGS_GEMINI_MODEL = "gemini-embedding-001"
|
||||
DEFAULT_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY = 768
|
||||
DEFAULT_EMBEDDING_DIMENSION = 384
|
||||
|
||||
DEFAULT_RERANKER_PROVIDER = "local"
|
||||
@@ -424,8 +468,14 @@ DEFAULT_RERANKER_FLASHRANK_CACHE_DIR = None # Use default cache directory
|
||||
DEFAULT_EMBEDDINGS_COHERE_MODEL = "embed-english-v3.0"
|
||||
DEFAULT_RERANKER_COHERE_MODEL = "rerank-english-v3.0"
|
||||
|
||||
# OpenRouter defaults
|
||||
DEFAULT_EMBEDDINGS_OPENROUTER_MODEL = "perplexity/pplx-embed-v1-0.6b"
|
||||
DEFAULT_RERANKER_OPENROUTER_MODEL = "cohere/rerank-v3.5"
|
||||
|
||||
DEFAULT_RERANKER_ZEROENTROPY_MODEL = "zerank-2"
|
||||
|
||||
DEFAULT_RERANKER_GOOGLE_MODEL = "semantic-ranker-default-004"
|
||||
|
||||
# Vector extension (pgvector, vchord, or pgvectorscale)
|
||||
DEFAULT_VECTOR_EXTENSION = "pgvector" # Options: "pgvector", "vchord", "pgvectorscale"
|
||||
|
||||
@@ -440,6 +490,7 @@ DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC: int | None = None
|
||||
|
||||
# LiteLLM SDK defaults
|
||||
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL = "cohere/embed-english-v3.0"
|
||||
DEFAULT_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT = "float"
|
||||
DEFAULT_RERANKER_LITELLM_SDK_MODEL = "cohere/rerank-english-v3.0"
|
||||
|
||||
DEFAULT_HOST = "0.0.0.0"
|
||||
@@ -452,11 +503,14 @@ DEFAULT_MCP_ENABLED = True
|
||||
DEFAULT_MCP_ENABLED_TOOLS: list[str] | None = None # None = all tools enabled
|
||||
DEFAULT_MCP_STATELESS = False # False = stateful (supports SSE/GET); True = stateless (POST-only)
|
||||
DEFAULT_ENABLE_BANK_CONFIG_API = True
|
||||
DEFAULT_DEFAULT_BANK_TEMPLATE: dict | None = None # BankTemplateManifest dict applied to newly-created banks
|
||||
DEFAULT_GRAPH_RETRIEVER = "link_expansion"
|
||||
DEFAULT_RECALL_MAX_CONCURRENT = 32 # Max concurrent recall operations per worker
|
||||
DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall operation
|
||||
DEFAULT_RECALL_MAX_QUERY_TOKENS = 500 # Maximum tokens allowed in recall query
|
||||
DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY = 8 # Max concurrent mental model refreshes
|
||||
DEFAULT_LINK_EXPANSION_PER_ENTITY_LIMIT = 200 # Max target units per entity in graph expansion
|
||||
DEFAULT_LINK_EXPANSION_TIMEOUT = 10.0 # Timeout (seconds) for entity expansion query
|
||||
|
||||
# Retain settings
|
||||
DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call
|
||||
@@ -535,6 +589,7 @@ DEFAULT_DISPOSITION_EMPATHY = None
|
||||
DEFAULT_OTEL_TRACES_ENABLED = False # Disabled by default for backward compatibility
|
||||
DEFAULT_OTEL_SERVICE_NAME = "hindsight-api"
|
||||
DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT = "development"
|
||||
DEFAULT_METRICS_INCLUDE_BANK_ID = False # Disabled by default to avoid high-cardinality OTel metric growth
|
||||
|
||||
# Audit log defaults
|
||||
DEFAULT_AUDIT_LOG_ENABLED = False # Disabled by default
|
||||
@@ -623,6 +678,26 @@ def _get_default_model_for_provider(provider: str) -> str:
|
||||
return PROVIDER_DEFAULT_MODELS.get(provider.lower(), DEFAULT_LLM_MODEL)
|
||||
|
||||
|
||||
def _parse_default_bank_template(raw: str | None) -> dict | None:
|
||||
"""
|
||||
Parse HINDSIGHT_API_DEFAULT_BANK_TEMPLATE as JSON.
|
||||
|
||||
The env var holds a BankTemplateManifest (JSON object) applied verbatim to
|
||||
every newly-created bank. Full Pydantic validation is deferred to bank
|
||||
creation time (to avoid pulling API models into config.py), but we fail
|
||||
fast here if the value is not valid JSON or not a JSON object.
|
||||
"""
|
||||
if raw is None or raw.strip() == "":
|
||||
return DEFAULT_DEFAULT_BANK_TEMPLATE
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid {ENV_DEFAULT_BANK_TEMPLATE}: expected a JSON object, got invalid JSON: {e}") from e
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError(f"Invalid {ENV_DEFAULT_BANK_TEMPLATE}: expected a JSON object, got {type(parsed).__name__}")
|
||||
return parsed
|
||||
|
||||
|
||||
@dataclass
|
||||
class HindsightConfig:
|
||||
"""Configuration container for Hindsight API."""
|
||||
@@ -658,6 +733,14 @@ class HindsightConfig:
|
||||
# Gemini safety settings (None = use Gemini defaults; list of dicts with category/threshold)
|
||||
llm_gemini_safety_settings: list | None
|
||||
|
||||
# Built-in llama.cpp configuration (for provider=llamacpp)
|
||||
llamacpp_model_path: str | None # Path to GGUF file (None = auto-download default)
|
||||
llamacpp_gpu_layers: int # -1 = all layers on GPU, 0 = CPU only
|
||||
llamacpp_context_size: int # Context window size
|
||||
llamacpp_chat_format: str | None # Chat template format (None = auto-detect from GGUF)
|
||||
llamacpp_no_grammar: bool # Disable JSON grammar enforcement (faster, less reliable)
|
||||
llamacpp_extra_args: str | None # Space-separated extra CLI args for llama.cpp server
|
||||
|
||||
# Per-operation LLM configuration (None = use default LLM config)
|
||||
retain_llm_provider: str | None
|
||||
retain_llm_api_key: str | None
|
||||
@@ -699,6 +782,8 @@ class HindsightConfig:
|
||||
embeddings_cohere_api_key: str | None
|
||||
embeddings_cohere_model: str
|
||||
embeddings_cohere_base_url: str | None
|
||||
embeddings_openrouter_api_key: str | None
|
||||
embeddings_openrouter_model: str
|
||||
embeddings_litellm_api_base: str
|
||||
embeddings_litellm_api_key: str | None
|
||||
embeddings_litellm_model: str
|
||||
@@ -706,6 +791,14 @@ class HindsightConfig:
|
||||
embeddings_litellm_sdk_model: str
|
||||
embeddings_litellm_sdk_api_base: str | None
|
||||
embeddings_litellm_sdk_output_dimensions: int | None
|
||||
embeddings_litellm_sdk_encoding_format: str | None
|
||||
# Gemini/Vertex AI embeddings
|
||||
embeddings_gemini_api_key: str | None
|
||||
embeddings_gemini_model: str
|
||||
embeddings_gemini_output_dimensionality: int | None
|
||||
embeddings_vertexai_project_id: str | None
|
||||
embeddings_vertexai_region: str | None
|
||||
embeddings_vertexai_service_account_key: str | None
|
||||
|
||||
# Reranker
|
||||
reranker_provider: str
|
||||
@@ -723,6 +816,8 @@ class HindsightConfig:
|
||||
reranker_cohere_api_key: str | None
|
||||
reranker_cohere_model: str
|
||||
reranker_cohere_base_url: str | None
|
||||
reranker_openrouter_api_key: str | None
|
||||
reranker_openrouter_model: str
|
||||
reranker_litellm_api_base: str
|
||||
reranker_litellm_api_key: str | None
|
||||
reranker_litellm_model: str
|
||||
@@ -733,6 +828,9 @@ class HindsightConfig:
|
||||
reranker_zeroentropy_api_key: str | None
|
||||
reranker_zeroentropy_model: str
|
||||
reranker_zeroentropy_base_url: str | None
|
||||
reranker_google_model: str
|
||||
reranker_google_project_id: str | None
|
||||
reranker_google_service_account_key: str | None
|
||||
|
||||
# Server
|
||||
host: str
|
||||
@@ -744,6 +842,9 @@ class HindsightConfig:
|
||||
mcp_enabled_tools: list[str] | None # None = all tools; explicit list = allowlist
|
||||
mcp_stateless: bool # True = stateless HTTP (POST-only); False = stateful (supports GET/SSE)
|
||||
enable_bank_config_api: bool
|
||||
# Default bank template (static, server-level only). When set, the manifest is applied
|
||||
# to every newly-created bank, overriding the env/config defaults for any fields it sets.
|
||||
default_bank_template: dict | None
|
||||
|
||||
# Recall
|
||||
graph_retriever: str
|
||||
@@ -751,6 +852,8 @@ class HindsightConfig:
|
||||
recall_connection_budget: int
|
||||
recall_max_query_tokens: int
|
||||
mental_model_refresh_concurrency: int
|
||||
link_expansion_per_entity_limit: int
|
||||
link_expansion_timeout: float
|
||||
|
||||
# Retain settings
|
||||
retain_max_completion_tokens: int
|
||||
@@ -850,6 +953,7 @@ class HindsightConfig:
|
||||
otel_exporter_otlp_headers: str | None
|
||||
otel_service_name: str
|
||||
otel_deployment_environment: str
|
||||
metrics_include_bank_id: bool
|
||||
|
||||
# Audit log configuration (static - server-level only)
|
||||
audit_log_enabled: bool # Master switch for audit logging
|
||||
@@ -882,6 +986,10 @@ class HindsightConfig:
|
||||
"reranker_zeroentropy_base_url",
|
||||
# Service Account Keys
|
||||
"llm_vertexai_service_account_key",
|
||||
"embeddings_vertexai_service_account_key",
|
||||
"reranker_google_service_account_key",
|
||||
# Embeddings API keys
|
||||
"embeddings_gemini_api_key",
|
||||
# File storage credentials
|
||||
"file_storage_s3_access_key_id",
|
||||
"file_storage_s3_secret_access_key",
|
||||
@@ -1058,6 +1166,14 @@ class HindsightConfig:
|
||||
or DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
|
||||
# Gemini safety settings (JSON-encoded list of {category, threshold} dicts)
|
||||
llm_gemini_safety_settings=json.loads(os.getenv(ENV_LLM_GEMINI_SAFETY_SETTINGS, "null")),
|
||||
# Built-in llama.cpp configuration
|
||||
llamacpp_model_path=os.getenv(ENV_LLAMACPP_MODEL_PATH) or None,
|
||||
llamacpp_gpu_layers=int(os.getenv(ENV_LLAMACPP_GPU_LAYERS, str(DEFAULT_LLAMACPP_GPU_LAYERS))),
|
||||
llamacpp_context_size=int(os.getenv(ENV_LLAMACPP_CONTEXT_SIZE, str(DEFAULT_LLAMACPP_CONTEXT_SIZE))),
|
||||
llamacpp_chat_format=os.getenv(ENV_LLAMACPP_CHAT_FORMAT) or DEFAULT_LLAMACPP_CHAT_FORMAT,
|
||||
llamacpp_no_grammar=os.getenv(ENV_LLAMACPP_NO_GRAMMAR, str(DEFAULT_LLAMACPP_NO_GRAMMAR)).lower()
|
||||
in ("true", "1"),
|
||||
llamacpp_extra_args=os.getenv(ENV_LLAMACPP_EXTRA_ARGS) or DEFAULT_LLAMACPP_EXTRA_ARGS,
|
||||
# Per-operation LLM config (None = use default)
|
||||
retain_llm_provider=os.getenv(ENV_RETAIN_LLM_PROVIDER) or None,
|
||||
retain_llm_api_key=os.getenv(ENV_RETAIN_LLM_API_KEY) or None,
|
||||
@@ -1146,6 +1262,11 @@ class HindsightConfig:
|
||||
embeddings_cohere_api_key=os.getenv(ENV_EMBEDDINGS_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
|
||||
embeddings_cohere_model=os.getenv(ENV_EMBEDDINGS_COHERE_MODEL, DEFAULT_EMBEDDINGS_COHERE_MODEL),
|
||||
embeddings_cohere_base_url=os.getenv(ENV_EMBEDDINGS_COHERE_BASE_URL) or None,
|
||||
# OpenRouter embeddings (with fallback to shared OpenRouter key, then LLM key)
|
||||
embeddings_openrouter_api_key=os.getenv(ENV_EMBEDDINGS_OPENROUTER_API_KEY)
|
||||
or os.getenv(ENV_OPENROUTER_API_KEY)
|
||||
or os.getenv(ENV_LLM_API_KEY),
|
||||
embeddings_openrouter_model=os.getenv(ENV_EMBEDDINGS_OPENROUTER_MODEL, DEFAULT_EMBEDDINGS_OPENROUTER_MODEL),
|
||||
# LiteLLM embeddings (with backward-compatible fallback to shared config)
|
||||
embeddings_litellm_api_base=os.getenv(ENV_EMBEDDINGS_LITELLM_API_BASE)
|
||||
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
|
||||
@@ -1160,6 +1281,23 @@ class HindsightConfig:
|
||||
embeddings_litellm_sdk_output_dimensions=int(v)
|
||||
if (v := os.getenv(ENV_EMBEDDINGS_LITELLM_SDK_OUTPUT_DIMENSIONS))
|
||||
else None,
|
||||
embeddings_litellm_sdk_encoding_format=os.getenv(
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT, DEFAULT_EMBEDDINGS_LITELLM_SDK_ENCODING_FORMAT
|
||||
),
|
||||
# Gemini/Vertex AI embeddings (with fallback to LLM keys)
|
||||
embeddings_gemini_api_key=os.getenv(ENV_EMBEDDINGS_GEMINI_API_KEY) or os.getenv(ENV_LLM_API_KEY),
|
||||
embeddings_gemini_model=os.getenv(ENV_EMBEDDINGS_GEMINI_MODEL, DEFAULT_EMBEDDINGS_GEMINI_MODEL),
|
||||
embeddings_gemini_output_dimensionality=int(
|
||||
os.getenv(
|
||||
ENV_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY,
|
||||
str(DEFAULT_EMBEDDINGS_GEMINI_OUTPUT_DIMENSIONALITY),
|
||||
)
|
||||
),
|
||||
embeddings_vertexai_project_id=os.getenv(ENV_EMBEDDINGS_VERTEXAI_PROJECT_ID)
|
||||
or os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID),
|
||||
embeddings_vertexai_region=os.getenv(ENV_EMBEDDINGS_VERTEXAI_REGION) or os.getenv(ENV_LLM_VERTEXAI_REGION),
|
||||
embeddings_vertexai_service_account_key=os.getenv(ENV_EMBEDDINGS_VERTEXAI_SERVICE_ACCOUNT_KEY)
|
||||
or os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY),
|
||||
# Reranker
|
||||
reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER),
|
||||
reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL),
|
||||
@@ -1193,6 +1331,11 @@ class HindsightConfig:
|
||||
reranker_cohere_api_key=os.getenv(ENV_RERANKER_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
|
||||
reranker_cohere_model=os.getenv(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL),
|
||||
reranker_cohere_base_url=os.getenv(ENV_RERANKER_COHERE_BASE_URL) or None,
|
||||
# OpenRouter reranker (with fallback to shared OpenRouter key, then LLM key)
|
||||
reranker_openrouter_api_key=os.getenv(ENV_RERANKER_OPENROUTER_API_KEY)
|
||||
or os.getenv(ENV_OPENROUTER_API_KEY)
|
||||
or os.getenv(ENV_LLM_API_KEY),
|
||||
reranker_openrouter_model=os.getenv(ENV_RERANKER_OPENROUTER_MODEL, DEFAULT_RERANKER_OPENROUTER_MODEL),
|
||||
# LiteLLM reranker (with backward-compatible fallback to shared config)
|
||||
reranker_litellm_api_base=os.getenv(ENV_RERANKER_LITELLM_API_BASE)
|
||||
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
|
||||
@@ -1209,6 +1352,12 @@ class HindsightConfig:
|
||||
reranker_zeroentropy_api_key=os.getenv(ENV_RERANKER_ZEROENTROPY_API_KEY),
|
||||
reranker_zeroentropy_model=os.getenv(ENV_RERANKER_ZEROENTROPY_MODEL, DEFAULT_RERANKER_ZEROENTROPY_MODEL),
|
||||
reranker_zeroentropy_base_url=os.getenv(ENV_RERANKER_ZEROENTROPY_BASE_URL) or None,
|
||||
# Google Discovery Engine reranker (with fallback to LLM Vertex AI keys)
|
||||
reranker_google_model=os.getenv(ENV_RERANKER_GOOGLE_MODEL, DEFAULT_RERANKER_GOOGLE_MODEL),
|
||||
reranker_google_project_id=os.getenv(ENV_RERANKER_GOOGLE_PROJECT_ID)
|
||||
or os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID),
|
||||
reranker_google_service_account_key=os.getenv(ENV_RERANKER_GOOGLE_SERVICE_ACCOUNT_KEY)
|
||||
or os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY),
|
||||
# Server
|
||||
host=os.getenv(ENV_HOST, DEFAULT_HOST),
|
||||
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
|
||||
@@ -1222,6 +1371,7 @@ class HindsightConfig:
|
||||
mcp_stateless=os.getenv(ENV_MCP_STATELESS, str(DEFAULT_MCP_STATELESS)).lower() == "true",
|
||||
enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower()
|
||||
== "true",
|
||||
default_bank_template=_parse_default_bank_template(os.getenv(ENV_DEFAULT_BANK_TEMPLATE)),
|
||||
# Recall
|
||||
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
|
||||
recall_max_concurrent=int(os.getenv(ENV_RECALL_MAX_CONCURRENT, str(DEFAULT_RECALL_MAX_CONCURRENT))),
|
||||
@@ -1232,6 +1382,10 @@ class HindsightConfig:
|
||||
mental_model_refresh_concurrency=int(
|
||||
os.getenv(ENV_MENTAL_MODEL_REFRESH_CONCURRENCY, str(DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY))
|
||||
),
|
||||
link_expansion_per_entity_limit=int(
|
||||
os.getenv(ENV_LINK_EXPANSION_PER_ENTITY_LIMIT, str(DEFAULT_LINK_EXPANSION_PER_ENTITY_LIMIT))
|
||||
),
|
||||
link_expansion_timeout=float(os.getenv(ENV_LINK_EXPANSION_TIMEOUT, str(DEFAULT_LINK_EXPANSION_TIMEOUT))),
|
||||
# Optimization flags
|
||||
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
|
||||
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
|
||||
@@ -1368,6 +1522,8 @@ class HindsightConfig:
|
||||
otel_exporter_otlp_headers=os.getenv(ENV_OTEL_EXPORTER_OTLP_HEADERS) or None,
|
||||
otel_service_name=os.getenv(ENV_OTEL_SERVICE_NAME, DEFAULT_OTEL_SERVICE_NAME),
|
||||
otel_deployment_environment=os.getenv(ENV_OTEL_DEPLOYMENT_ENVIRONMENT, DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT),
|
||||
metrics_include_bank_id=os.getenv(ENV_METRICS_INCLUDE_BANK_ID, str(DEFAULT_METRICS_INCLUDE_BANK_ID)).lower()
|
||||
in ("true", "1", "yes"),
|
||||
# Audit log configuration (static, server-level only)
|
||||
audit_log_enabled=os.getenv(ENV_AUDIT_LOG_ENABLED, str(DEFAULT_AUDIT_LOG_ENABLED)).lower() == "true",
|
||||
audit_log_actions=[
|
||||
|
||||
@@ -239,6 +239,15 @@ class ConfigResolver:
|
||||
logger.warning(f"Failed to check permissions for bank {bank_id}: {e}")
|
||||
# Continue without permission check (fail open for backward compatibility)
|
||||
|
||||
# Validate entity_labels structure
|
||||
if "entity_labels" in normalized_updates and normalized_updates["entity_labels"] is not None:
|
||||
from .engine.retain.entity_labels import parse_entity_labels
|
||||
|
||||
try:
|
||||
parse_entity_labels(normalized_updates["entity_labels"])
|
||||
except Exception as e:
|
||||
raise ValueError(f"Invalid entity_labels format: {e}")
|
||||
|
||||
# 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()]
|
||||
|
||||
@@ -20,6 +20,7 @@ from ..config import (
|
||||
DEFAULT_RERANKER_COHERE_MODEL,
|
||||
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR,
|
||||
DEFAULT_RERANKER_FLASHRANK_MODEL,
|
||||
DEFAULT_RERANKER_GOOGLE_MODEL,
|
||||
DEFAULT_RERANKER_LITELLM_MAX_TOKENS_PER_DOC,
|
||||
DEFAULT_RERANKER_LITELLM_MODEL,
|
||||
DEFAULT_RERANKER_LITELLM_SDK_MODEL,
|
||||
@@ -36,6 +37,7 @@ from ..config import (
|
||||
ENV_RERANKER_COHERE_MODEL,
|
||||
ENV_RERANKER_FLASHRANK_CACHE_DIR,
|
||||
ENV_RERANKER_FLASHRANK_MODEL,
|
||||
ENV_RERANKER_GOOGLE_PROJECT_ID,
|
||||
ENV_RERANKER_LITELLM_SDK_API_KEY,
|
||||
ENV_RERANKER_LOCAL_FORCE_CPU,
|
||||
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
|
||||
@@ -1266,6 +1268,164 @@ class JinaMLXCrossEncoder(CrossEncoderModel):
|
||||
return await loop.run_in_executor(None, self._predict_sync, pairs)
|
||||
|
||||
|
||||
class GoogleCrossEncoder(CrossEncoderModel):
|
||||
"""
|
||||
Google Discovery Engine cross-encoder using the Ranking REST API.
|
||||
|
||||
Uses httpx + google-auth for lightweight REST calls (no gRPC/protobuf).
|
||||
Supports ADC (Application Default Credentials) or service account key file.
|
||||
|
||||
Available models:
|
||||
- semantic-ranker-default-004: Best quality, 1024 tokens/record (recommended)
|
||||
- semantic-ranker-fast-004: Lower latency, 1024 tokens/record
|
||||
|
||||
Max 200 records per API request. Location is always "global".
|
||||
"""
|
||||
|
||||
MAX_RECORDS_PER_REQUEST = 200
|
||||
API_BASE = "https://discoveryengine.googleapis.com/v1"
|
||||
SCOPES = ["https://www.googleapis.com/auth/cloud-platform"]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project_id: str,
|
||||
model: str = DEFAULT_RERANKER_GOOGLE_MODEL,
|
||||
service_account_key: str | None = None,
|
||||
location: str = "global",
|
||||
timeout: float = 60.0,
|
||||
):
|
||||
"""
|
||||
Initialize Google Discovery Engine cross-encoder.
|
||||
|
||||
Args:
|
||||
project_id: Google Cloud project ID
|
||||
model: Ranking model name (default: semantic-ranker-default-004)
|
||||
service_account_key: Path to service account JSON key file.
|
||||
If None, uses Application Default Credentials (ADC).
|
||||
location: API location (default: "global")
|
||||
timeout: Request timeout in seconds (default: 60.0)
|
||||
"""
|
||||
self.project_id = project_id
|
||||
self.model = model
|
||||
self.service_account_key = service_account_key
|
||||
self.location = location
|
||||
self.timeout = timeout
|
||||
self._credentials = None
|
||||
self._client: httpx.Client | None = None
|
||||
self._rank_url: str | None = None
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "google"
|
||||
|
||||
def _get_auth_headers(self) -> dict[str, str]:
|
||||
"""Get Authorization header with a fresh access token."""
|
||||
import google.auth.transport.requests
|
||||
|
||||
if not self._credentials.valid:
|
||||
self._credentials.refresh(google.auth.transport.requests.Request())
|
||||
return {"Authorization": f"Bearer {self._credentials.token}"}
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize credentials and HTTP client."""
|
||||
if self._client is not None:
|
||||
return
|
||||
|
||||
auth_method = "ADC" if not self.service_account_key else "service_account"
|
||||
logger.info(
|
||||
f"Reranker: initializing Google Discovery Engine provider "
|
||||
f"(project={self.project_id}, model={self.model}, auth={auth_method})"
|
||||
)
|
||||
if self.service_account_key:
|
||||
try:
|
||||
from google.oauth2 import service_account
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"google-auth is required for GoogleCrossEncoder. Install it with: pip install google-auth"
|
||||
)
|
||||
self._credentials = service_account.Credentials.from_service_account_file(
|
||||
self.service_account_key,
|
||||
scopes=self.SCOPES,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
import google.auth
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"google-auth is required for GoogleCrossEncoder. Install it with: pip install google-auth"
|
||||
)
|
||||
self._credentials, _ = google.auth.default(scopes=self.SCOPES)
|
||||
|
||||
ranking_config = f"projects/{self.project_id}/locations/{self.location}/rankingConfigs/default_ranking_config"
|
||||
self._rank_url = f"{self.API_BASE}/{ranking_config}:rank"
|
||||
self._client = httpx.Client(timeout=self.timeout)
|
||||
|
||||
logger.info("Reranker: Google Discovery Engine provider initialized")
|
||||
|
||||
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""Synchronous predict via REST API."""
|
||||
if not pairs:
|
||||
return []
|
||||
|
||||
# Group pairs by query
|
||||
query_groups: dict[str, list[tuple[int, str]]] = {}
|
||||
for idx, (query, text) in enumerate(pairs):
|
||||
if query not in query_groups:
|
||||
query_groups[query] = []
|
||||
query_groups[query].append((idx, text))
|
||||
|
||||
all_scores = [0.0] * len(pairs)
|
||||
|
||||
for query, indexed_texts in query_groups.items():
|
||||
texts = [text for _, text in indexed_texts]
|
||||
indices = [idx for idx, _ in indexed_texts]
|
||||
|
||||
# Process in batches of MAX_RECORDS_PER_REQUEST
|
||||
for batch_start in range(0, len(texts), self.MAX_RECORDS_PER_REQUEST):
|
||||
batch_texts = texts[batch_start : batch_start + self.MAX_RECORDS_PER_REQUEST]
|
||||
batch_indices = indices[batch_start : batch_start + self.MAX_RECORDS_PER_REQUEST]
|
||||
|
||||
records = [{"id": str(i), "content": text} for i, text in enumerate(batch_texts)]
|
||||
|
||||
response = self._client.post(
|
||||
self._rank_url,
|
||||
headers=self._get_auth_headers(),
|
||||
json={
|
||||
"model": self.model,
|
||||
"query": query,
|
||||
"records": records,
|
||||
"topN": len(records),
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
for record in result.get("records", []):
|
||||
local_idx = int(record["id"])
|
||||
all_scores[batch_indices[local_idx]] = record["score"]
|
||||
|
||||
return all_scores
|
||||
|
||||
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""
|
||||
Score query-document pairs using Google Discovery Engine Ranking API.
|
||||
|
||||
Args:
|
||||
pairs: List of (query, document) tuples to score
|
||||
|
||||
Returns:
|
||||
List of relevance scores (0-1, higher = more relevant)
|
||||
"""
|
||||
if self._client is None:
|
||||
raise RuntimeError("Reranker not initialized. Call initialize() first.")
|
||||
|
||||
if not pairs:
|
||||
return []
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
return await loop.run_in_executor(None, self._predict_sync, pairs)
|
||||
|
||||
|
||||
def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
"""
|
||||
Create a CrossEncoderModel instance based on configuration.
|
||||
@@ -1308,6 +1468,18 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
model=config.reranker_cohere_model,
|
||||
base_url=config.reranker_cohere_base_url,
|
||||
)
|
||||
elif provider == "openrouter":
|
||||
api_key = config.reranker_openrouter_api_key
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"HINDSIGHT_API_RERANKER_OPENROUTER_API_KEY, HINDSIGHT_API_OPENROUTER_API_KEY, "
|
||||
f"or HINDSIGHT_API_LLM_API_KEY is required when {ENV_RERANKER_PROVIDER} is 'openrouter'"
|
||||
)
|
||||
return CohereCrossEncoder(
|
||||
api_key=api_key,
|
||||
model=config.reranker_openrouter_model,
|
||||
base_url="https://openrouter.ai/api/v1/rerank",
|
||||
)
|
||||
elif provider == "flashrank":
|
||||
model = os.environ.get(ENV_RERANKER_FLASHRANK_MODEL, DEFAULT_RERANKER_FLASHRANK_MODEL)
|
||||
cache_dir = os.environ.get(ENV_RERANKER_FLASHRANK_CACHE_DIR, DEFAULT_RERANKER_FLASHRANK_CACHE_DIR)
|
||||
@@ -1341,11 +1513,23 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
api_key=api_key,
|
||||
model=config.reranker_zeroentropy_model,
|
||||
)
|
||||
elif provider == "google":
|
||||
project_id = config.reranker_google_project_id
|
||||
if not project_id:
|
||||
raise ValueError(
|
||||
f"{ENV_RERANKER_GOOGLE_PROJECT_ID} (or HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID) "
|
||||
f"is required when {ENV_RERANKER_PROVIDER} is 'google'"
|
||||
)
|
||||
return GoogleCrossEncoder(
|
||||
project_id=project_id,
|
||||
model=config.reranker_google_model,
|
||||
service_account_key=config.reranker_google_service_account_key,
|
||||
)
|
||||
elif provider == "rrf":
|
||||
return RRFPassthroughCrossEncoder()
|
||||
elif provider == "jina-mlx":
|
||||
return JinaMLXCrossEncoder()
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'zeroentropy', 'flashrank', 'litellm', 'litellm-sdk', 'rrf', 'jina-mlx'"
|
||||
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'zeroentropy', 'google', 'flashrank', 'litellm', 'litellm-sdk', 'rrf', 'jina-mlx'"
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ import httpx
|
||||
|
||||
from ..config import (
|
||||
DEFAULT_EMBEDDINGS_COHERE_MODEL,
|
||||
DEFAULT_EMBEDDINGS_GEMINI_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
|
||||
@@ -28,6 +29,7 @@ from ..config import (
|
||||
DEFAULT_EMBEDDINGS_PROVIDER,
|
||||
DEFAULT_LITELLM_API_BASE,
|
||||
ENV_EMBEDDINGS_COHERE_API_KEY,
|
||||
ENV_EMBEDDINGS_GEMINI_API_KEY,
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_API_KEY,
|
||||
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
|
||||
ENV_EMBEDDINGS_LOCAL_MODEL,
|
||||
@@ -755,6 +757,7 @@ class LiteLLMSDKEmbeddings(Embeddings):
|
||||
output_dimensions: int | None = None,
|
||||
batch_size: int = 100,
|
||||
timeout: float = 60.0,
|
||||
encoding_format: str | None = "float",
|
||||
):
|
||||
"""
|
||||
Initialize LiteLLM SDK embeddings client.
|
||||
@@ -766,6 +769,8 @@ class LiteLLMSDKEmbeddings(Embeddings):
|
||||
output_dimensions: Optional output embedding dimensions (provider-dependent)
|
||||
batch_size: Maximum batch size for embedding requests (default: 100)
|
||||
timeout: Request timeout in seconds (default: 60.0)
|
||||
encoding_format: Encoding format for embeddings (default: "float").
|
||||
Set to None or empty string to omit (needed for Voyage AI, Gemini).
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
@@ -773,6 +778,7 @@ class LiteLLMSDKEmbeddings(Embeddings):
|
||||
self.output_dimensions = output_dimensions
|
||||
self.batch_size = batch_size
|
||||
self.timeout = timeout
|
||||
self.encoding_format = encoding_format or None
|
||||
self._litellm = None # Will be set during initialization
|
||||
self._dimension: int | None = None
|
||||
|
||||
@@ -808,8 +814,9 @@ class LiteLLMSDKEmbeddings(Embeddings):
|
||||
"model": self.model,
|
||||
"input": ["test"],
|
||||
"api_key": self.api_key,
|
||||
"encoding_format": "float",
|
||||
}
|
||||
if self.encoding_format:
|
||||
embed_kwargs["encoding_format"] = self.encoding_format
|
||||
if self.api_base:
|
||||
embed_kwargs["api_base"] = self.api_base
|
||||
if self.output_dimensions is not None:
|
||||
@@ -857,8 +864,9 @@ class LiteLLMSDKEmbeddings(Embeddings):
|
||||
"model": self.model,
|
||||
"input": batch,
|
||||
"api_key": self.api_key,
|
||||
"encoding_format": "float",
|
||||
}
|
||||
if self.encoding_format:
|
||||
embed_kwargs["encoding_format"] = self.encoding_format
|
||||
if self.api_base:
|
||||
embed_kwargs["api_base"] = self.api_base
|
||||
if self.output_dimensions is not None:
|
||||
@@ -884,6 +892,179 @@ class LiteLLMSDKEmbeddings(Embeddings):
|
||||
return all_embeddings
|
||||
|
||||
|
||||
class GeminiEmbeddings(Embeddings):
|
||||
"""
|
||||
Google embeddings via the google.genai SDK.
|
||||
|
||||
Supports both:
|
||||
1. Gemini API (api.generativeai.google.com) with API key authentication
|
||||
2. Vertex AI with service account or Application Default Credentials (ADC)
|
||||
|
||||
Uses the embed_content API: client.models.embed_content(model, contents)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str = DEFAULT_EMBEDDINGS_GEMINI_MODEL,
|
||||
api_key: str | None = None,
|
||||
vertexai_project_id: str | None = None,
|
||||
vertexai_region: str | None = None,
|
||||
vertexai_service_account_key: str | None = None,
|
||||
output_dimensionality: int | None = None,
|
||||
batch_size: int = 100,
|
||||
):
|
||||
self.model = model
|
||||
self.api_key = api_key
|
||||
self.vertexai_project_id = vertexai_project_id
|
||||
self.vertexai_region = vertexai_region or "us-central1"
|
||||
self.vertexai_service_account_key = vertexai_service_account_key
|
||||
self.output_dimensionality = output_dimensionality
|
||||
self.batch_size = batch_size
|
||||
self._client = None
|
||||
self._dimension: int | None = None
|
||||
self._is_vertexai = vertexai_project_id is not None
|
||||
self._embed_config = None # EmbedContentConfig, built during initialize()
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "google"
|
||||
|
||||
@property
|
||||
def dimension(self) -> int:
|
||||
if self._dimension is None:
|
||||
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
|
||||
return self._dimension
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the Google genai client and detect embedding dimension."""
|
||||
if self._client is not None:
|
||||
return
|
||||
|
||||
from google import genai
|
||||
from google.genai import types as genai_types
|
||||
|
||||
if self._is_vertexai:
|
||||
self._init_vertexai(genai)
|
||||
else:
|
||||
self._init_gemini(genai)
|
||||
|
||||
# Build EmbedContentConfig if output_dimensionality is set
|
||||
if self.output_dimensionality is not None:
|
||||
self._embed_config = genai_types.EmbedContentConfig(
|
||||
output_dimensionality=self.output_dimensionality,
|
||||
)
|
||||
|
||||
# Detect dimension via a test embedding (respects output_dimensionality)
|
||||
embed_kwargs = {"model": self.model, "contents": ["test"]}
|
||||
if self._embed_config is not None:
|
||||
embed_kwargs["config"] = self._embed_config
|
||||
|
||||
result = self._client.models.embed_content(**embed_kwargs) # type: ignore[union-attr]
|
||||
if result.embeddings and len(result.embeddings) > 0:
|
||||
self._dimension = len(result.embeddings[0].values)
|
||||
|
||||
auth_mode = "vertex_ai" if self._is_vertexai else "api_key"
|
||||
logger.info(
|
||||
f"Embeddings: google provider initialized (auth: {auth_mode}, model: {self.model}, dim: {self._dimension})"
|
||||
)
|
||||
|
||||
def _init_gemini(self, genai) -> None:
|
||||
"""Initialize Gemini API client with API key."""
|
||||
if not self.api_key:
|
||||
raise ValueError("Gemini embeddings provider requires an API key")
|
||||
|
||||
self._client = genai.Client(api_key=self.api_key)
|
||||
logger.info(f"Embeddings: initializing Gemini provider with model {self.model}")
|
||||
|
||||
def _init_vertexai(self, genai) -> None:
|
||||
"""Initialize Vertex AI client with project, region, and credentials."""
|
||||
if not self.vertexai_project_id:
|
||||
raise ValueError(
|
||||
"HINDSIGHT_API_EMBEDDINGS_VERTEXAI_PROJECT_ID (or HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID) "
|
||||
"is required for Vertex AI embeddings provider."
|
||||
)
|
||||
|
||||
auth_method = "ADC"
|
||||
credentials = None
|
||||
|
||||
if self.vertexai_service_account_key:
|
||||
try:
|
||||
from google.oauth2 import service_account
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"Vertex AI service account auth requires 'google-auth' package. "
|
||||
"Install with: pip install google-auth"
|
||||
)
|
||||
credentials = service_account.Credentials.from_service_account_file(
|
||||
self.vertexai_service_account_key,
|
||||
scopes=["https://www.googleapis.com/auth/cloud-platform"],
|
||||
)
|
||||
auth_method = "service_account"
|
||||
logger.info(f"Embeddings: Vertex AI using service account key: {self.vertexai_service_account_key}")
|
||||
|
||||
# Strip google/ prefix from model name — native SDK uses bare names
|
||||
if self.model.startswith("google/"):
|
||||
self.model = self.model[len("google/") :]
|
||||
|
||||
client_kwargs = {
|
||||
"vertexai": True,
|
||||
"project": self.vertexai_project_id,
|
||||
"location": self.vertexai_region,
|
||||
}
|
||||
if credentials is not None:
|
||||
client_kwargs["credentials"] = credentials
|
||||
|
||||
self._client = genai.Client(**client_kwargs)
|
||||
logger.info(
|
||||
f"Embeddings: initializing Vertex AI provider "
|
||||
f"(project={self.vertexai_project_id}, region={self.vertexai_region}, "
|
||||
f"model={self.model}, auth={auth_method})"
|
||||
)
|
||||
|
||||
def encode(self, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings using the Google genai SDK.
|
||||
|
||||
Args:
|
||||
texts: List of text strings to encode
|
||||
|
||||
Returns:
|
||||
List of embedding vectors
|
||||
"""
|
||||
if self._client is None:
|
||||
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
|
||||
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
all_embeddings = []
|
||||
|
||||
# Process in batches
|
||||
for i in range(0, len(texts), self.batch_size):
|
||||
batch = texts[i : i + self.batch_size]
|
||||
|
||||
embed_kwargs = {"model": self.model, "contents": batch}
|
||||
if self._embed_config is not None:
|
||||
embed_kwargs["config"] = self._embed_config
|
||||
|
||||
result = self._client.models.embed_content(**embed_kwargs)
|
||||
|
||||
all_embeddings.extend([emb.values for emb in result.embeddings])
|
||||
|
||||
# L2-normalize when output_dimensionality is set — Gemini only returns
|
||||
# normalized vectors at full 3072 dims; truncated dims need re-normalization
|
||||
# for accurate cosine similarity.
|
||||
if self.output_dimensionality is not None:
|
||||
import numpy as np
|
||||
|
||||
arr = np.array(all_embeddings)
|
||||
norms = np.linalg.norm(arr, axis=1, keepdims=True)
|
||||
norms[norms == 0] = 1
|
||||
all_embeddings = (arr / norms).tolist()
|
||||
|
||||
return all_embeddings
|
||||
|
||||
|
||||
def create_embeddings_from_env() -> Embeddings:
|
||||
"""
|
||||
Create an Embeddings instance based on configuration.
|
||||
@@ -920,6 +1101,18 @@ def create_embeddings_from_env() -> Embeddings:
|
||||
model = os.environ.get(ENV_EMBEDDINGS_OPENAI_MODEL, DEFAULT_EMBEDDINGS_OPENAI_MODEL)
|
||||
base_url = os.environ.get(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None
|
||||
return OpenAIEmbeddings(api_key=api_key, model=model, base_url=base_url)
|
||||
elif provider == "openrouter":
|
||||
api_key = config.embeddings_openrouter_api_key
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
"HINDSIGHT_API_EMBEDDINGS_OPENROUTER_API_KEY, HINDSIGHT_API_OPENROUTER_API_KEY, "
|
||||
f"or {ENV_LLM_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'openrouter'"
|
||||
)
|
||||
return OpenAIEmbeddings(
|
||||
api_key=api_key,
|
||||
model=config.embeddings_openrouter_model,
|
||||
base_url="https://openrouter.ai/api/v1",
|
||||
)
|
||||
elif provider == "cohere":
|
||||
api_key = config.embeddings_cohere_api_key
|
||||
if not api_key:
|
||||
@@ -946,9 +1139,29 @@ def create_embeddings_from_env() -> Embeddings:
|
||||
model=config.embeddings_litellm_sdk_model,
|
||||
api_base=config.embeddings_litellm_sdk_api_base,
|
||||
output_dimensions=config.embeddings_litellm_sdk_output_dimensions,
|
||||
encoding_format=config.embeddings_litellm_sdk_encoding_format,
|
||||
)
|
||||
elif provider == "google":
|
||||
vertexai_project_id = config.embeddings_vertexai_project_id
|
||||
if vertexai_project_id:
|
||||
api_key = None # Vertex AI uses ADC or service account
|
||||
else:
|
||||
api_key = config.embeddings_gemini_api_key
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"{ENV_EMBEDDINGS_GEMINI_API_KEY} or {ENV_LLM_API_KEY} is required "
|
||||
f"when {ENV_EMBEDDINGS_PROVIDER} is 'google' (set VERTEXAI_PROJECT_ID for Vertex AI auth instead)"
|
||||
)
|
||||
return GeminiEmbeddings(
|
||||
model=config.embeddings_gemini_model,
|
||||
api_key=api_key,
|
||||
vertexai_project_id=vertexai_project_id,
|
||||
vertexai_region=config.embeddings_vertexai_region,
|
||||
vertexai_service_account_key=config.embeddings_vertexai_service_account_key,
|
||||
output_dimensionality=config.embeddings_gemini_output_dimensionality,
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown embeddings provider: {provider}. "
|
||||
f"Supported: 'local', 'tei', 'openai', 'cohere', 'litellm', 'litellm-sdk'"
|
||||
f"Supported: 'local', 'tei', 'openai', 'cohere', 'google', 'litellm', 'litellm-sdk'"
|
||||
)
|
||||
|
||||
@@ -122,6 +122,7 @@ _PROVIDERS_WITHOUT_API_KEY = frozenset(
|
||||
{
|
||||
"ollama",
|
||||
"lmstudio",
|
||||
"llamacpp",
|
||||
"openai-codex",
|
||||
"claude-code",
|
||||
"mock",
|
||||
@@ -178,6 +179,7 @@ def create_llm_provider(
|
||||
CodexLLM,
|
||||
GeminiLLM,
|
||||
LiteLLMLLM,
|
||||
LlamaCppLLM,
|
||||
MockLLM,
|
||||
NoneLLM,
|
||||
OpenAICompatibleLLM,
|
||||
@@ -263,7 +265,25 @@ def create_llm_provider(
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
|
||||
elif provider_lower in ("openai", "groq", "ollama", "lmstudio", "minimax", "volcano"):
|
||||
elif provider_lower == "llamacpp":
|
||||
from ..config import get_config
|
||||
|
||||
config = get_config()
|
||||
return LlamaCppLLM(
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
reasoning_effort=reasoning_effort,
|
||||
model_path=config.llamacpp_model_path,
|
||||
gpu_layers=config.llamacpp_gpu_layers,
|
||||
context_size=config.llamacpp_context_size,
|
||||
chat_format=config.llamacpp_chat_format,
|
||||
no_grammar=config.llamacpp_no_grammar,
|
||||
extra_args=config.llamacpp_extra_args,
|
||||
)
|
||||
|
||||
elif provider_lower in ("openai", "groq", "ollama", "lmstudio", "minimax", "volcano", "openrouter"):
|
||||
return OpenAICompatibleLLM(
|
||||
provider=provider,
|
||||
api_key=api_key,
|
||||
@@ -333,6 +353,7 @@ class LLMProvider:
|
||||
"gemini",
|
||||
"anthropic",
|
||||
"lmstudio",
|
||||
"llamacpp",
|
||||
"vertexai",
|
||||
"openai-codex",
|
||||
"claude-code",
|
||||
@@ -342,6 +363,7 @@ class LLMProvider:
|
||||
"litellm",
|
||||
"bedrock",
|
||||
"volcano",
|
||||
"openrouter",
|
||||
]
|
||||
if self.provider not in valid_providers:
|
||||
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
|
||||
@@ -356,6 +378,8 @@ class LLMProvider:
|
||||
self.base_url = "http://localhost:1234/v1"
|
||||
elif self.provider == "minimax":
|
||||
self.base_url = "https://api.minimax.io/v1"
|
||||
elif self.provider == "openrouter":
|
||||
self.base_url = "https://openrouter.ai/api/v1"
|
||||
|
||||
# Prepare Vertex AI config (if applicable)
|
||||
vertexai_project_id = None
|
||||
@@ -711,8 +735,9 @@ class LLMProvider:
|
||||
return ConfiguredLLMProvider(self, config.llm_gemini_safety_settings)
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
pass
|
||||
"""Clean up resources (e.g. stop llamacpp subprocess)."""
|
||||
if self._provider_impl:
|
||||
await self._provider_impl.cleanup()
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "LLMProvider":
|
||||
|
||||
@@ -1923,6 +1923,18 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
self._initialized = False
|
||||
|
||||
# Clean up LLM providers (e.g. stop llamacpp subprocess)
|
||||
for llm_config in (
|
||||
self._llm_config,
|
||||
self._retain_llm_config,
|
||||
self._reflect_llm_config,
|
||||
self._consolidation_llm_config,
|
||||
):
|
||||
try:
|
||||
await llm_config.cleanup()
|
||||
except Exception as e:
|
||||
logger.warning(f"Error cleaning up LLM provider: {e}")
|
||||
|
||||
# Stop pg0 if we started it
|
||||
if self._pg0 is not None:
|
||||
logger.info("Stopping pg0...")
|
||||
@@ -2146,6 +2158,11 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
f"Each content item in a batch must have a unique document_id to avoid race conditions."
|
||||
)
|
||||
|
||||
# Validate update_mode=append requires document_id
|
||||
for item in contents:
|
||||
if item.get("update_mode") == "append" and not item.get("document_id"):
|
||||
raise ValueError("update_mode='append' requires a document_id")
|
||||
|
||||
# Auto-chunk large batches by token count to avoid timeouts and memory issues
|
||||
# Calculate total token count
|
||||
total_tokens = sum(count_tokens(item.get("content", "")) for item in contents)
|
||||
@@ -3792,7 +3809,14 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
Returns:
|
||||
Dictionary with deletion result
|
||||
|
||||
Raises:
|
||||
ValueError: If unit_id is not a valid UUID
|
||||
"""
|
||||
try:
|
||||
unit_uuid = uuid.UUID(unit_id)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid unit_id: '{unit_id}' is not a valid UUID")
|
||||
await self._authenticate_tenant(request_context)
|
||||
pool = await self._get_pool()
|
||||
invalidated_obs = 0
|
||||
@@ -3802,7 +3826,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Get bank_id and fact_type before deletion
|
||||
row = await conn.fetchrow(
|
||||
f"SELECT bank_id, fact_type FROM {fq_table('memory_units')} WHERE id = $1",
|
||||
unit_id,
|
||||
str(unit_uuid),
|
||||
)
|
||||
bank_id = row["bank_id"] if row else None
|
||||
fact_type = row["fact_type"] if row else None
|
||||
@@ -4697,7 +4721,14 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
Returns:
|
||||
Dict with memory unit data or None if not found
|
||||
|
||||
Raises:
|
||||
ValueError: If memory_id is not a valid UUID
|
||||
"""
|
||||
try:
|
||||
memory_uuid = uuid.UUID(memory_id)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid memory_id: '{memory_id}' is not a valid UUID")
|
||||
await self._authenticate_tenant(request_context)
|
||||
if self._operation_validator:
|
||||
from hindsight_api.extensions import BankReadContext
|
||||
@@ -4715,7 +4746,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = $1 AND bank_id = $2
|
||||
""",
|
||||
memory_id,
|
||||
str(memory_uuid),
|
||||
bank_id,
|
||||
)
|
||||
|
||||
@@ -5122,7 +5153,13 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
ctx = BankReadContext(bank_id=bank_id, operation="get_bank_profile", request_context=request_context)
|
||||
await self._validate_operation(self._operation_validator.validate_bank_read(ctx))
|
||||
pool = await self._get_pool()
|
||||
profile = await bank_utils.get_bank_profile(pool, bank_id)
|
||||
profile, created = await bank_utils.get_or_create_bank_profile(pool, bank_id)
|
||||
|
||||
# Apply HINDSIGHT_API_DEFAULT_BANK_TEMPLATE to freshly-created banks. Done
|
||||
# before reading the resolved config below so the template's overrides
|
||||
# (e.g. reflect_mission, dispositions) are visible on this very call.
|
||||
if created:
|
||||
await self._apply_default_bank_template(bank_id, request_context)
|
||||
|
||||
# reflect_mission and disposition in config take precedence over the legacy DB columns
|
||||
config_dict = await self._config_resolver.get_bank_config(bank_id, request_context)
|
||||
@@ -5147,6 +5184,62 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
"mission": mission,
|
||||
}
|
||||
|
||||
async def _apply_default_bank_template(
|
||||
self,
|
||||
bank_id: str,
|
||||
request_context: "RequestContext",
|
||||
) -> None:
|
||||
"""Apply HINDSIGHT_API_DEFAULT_BANK_TEMPLATE to a freshly-created bank.
|
||||
|
||||
No-op if the env var is unset. A malformed default template is logged
|
||||
and swallowed here rather than raised, so a bad server-level setting
|
||||
cannot wedge bank creation across all callers. Misconfiguration is
|
||||
still surfaced loudly via `logger.error`.
|
||||
"""
|
||||
from ..config import get_config
|
||||
|
||||
template_dict = get_config().default_bank_template
|
||||
if not template_dict:
|
||||
return
|
||||
|
||||
# Lazy import to avoid a cycle (http.py imports memory_engine).
|
||||
from pydantic import ValidationError
|
||||
|
||||
from hindsight_api.api.http import (
|
||||
BankTemplateManifest,
|
||||
apply_bank_template_manifest,
|
||||
validate_bank_template,
|
||||
)
|
||||
|
||||
try:
|
||||
manifest = BankTemplateManifest.model_validate(template_dict)
|
||||
except ValidationError as e:
|
||||
errors = [f"{'.'.join(str(loc) for loc in err['loc'])}: {err['msg']}" for err in e.errors()]
|
||||
logger.error(
|
||||
"HINDSIGHT_API_DEFAULT_BANK_TEMPLATE failed schema validation "
|
||||
f"and will be ignored for bank '{bank_id}': {'; '.join(errors)}"
|
||||
)
|
||||
return
|
||||
|
||||
semantic_errors = validate_bank_template(manifest)
|
||||
if semantic_errors:
|
||||
logger.error(
|
||||
"HINDSIGHT_API_DEFAULT_BANK_TEMPLATE failed semantic validation "
|
||||
f"and will be ignored for bank '{bank_id}': {'; '.join(semantic_errors)}"
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
await apply_bank_template_manifest(
|
||||
memory=self,
|
||||
bank_id=bank_id,
|
||||
manifest=manifest,
|
||||
request_context=request_context,
|
||||
)
|
||||
logger.info(f"Applied HINDSIGHT_API_DEFAULT_BANK_TEMPLATE to newly-created bank '{bank_id}'")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply HINDSIGHT_API_DEFAULT_BANK_TEMPLATE to bank '{bank_id}': {e}")
|
||||
|
||||
async def update_bank_disposition(
|
||||
self,
|
||||
bank_id: str,
|
||||
@@ -6497,6 +6590,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
Returns None if the mental model is not found.
|
||||
Returns a list of history entries (most recent first), each with previous_content and changed_at.
|
||||
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
pool = await self._get_pool()
|
||||
@@ -7757,7 +7851,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
# Ensure the bank row exists before inserting async_operations (which now has a FK).
|
||||
# Banks are created lazily on first retain, but the FK requires the row to exist first.
|
||||
await bank_utils.get_bank_profile(pool, bank_id)
|
||||
_, created = await bank_utils.get_or_create_bank_profile(pool, bank_id)
|
||||
if created:
|
||||
await self._apply_default_bank_template(bank_id, request_context)
|
||||
|
||||
# Create typed metadata for parent operation
|
||||
parent_metadata = BatchRetainParentMetadata(
|
||||
|
||||
@@ -9,6 +9,7 @@ from .claude_code_llm import ClaudeCodeLLM
|
||||
from .codex_llm import CodexLLM
|
||||
from .gemini_llm import GeminiLLM
|
||||
from .litellm_llm import LiteLLMLLM
|
||||
from .llamacpp_llm import LlamaCppLLM
|
||||
from .mock_llm import MockLLM
|
||||
from .none_llm import NoneLLM
|
||||
from .openai_compatible_llm import OpenAICompatibleLLM
|
||||
@@ -18,6 +19,7 @@ __all__ = [
|
||||
"ClaudeCodeLLM",
|
||||
"CodexLLM",
|
||||
"GeminiLLM",
|
||||
"LlamaCppLLM",
|
||||
"LiteLLMLLM",
|
||||
"MockLLM",
|
||||
"NoneLLM",
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"""
|
||||
Built-in llama.cpp LLM provider for fully offline operation.
|
||||
|
||||
Manages a llama-cpp-python server as a subprocess, downloads GGUF models
|
||||
from HuggingFace on first use, and delegates inference to the OpenAI-compatible API.
|
||||
|
||||
Usage:
|
||||
HINDSIGHT_API_LLM_PROVIDER=llamacpp
|
||||
HINDSIGHT_API_LLAMACPP_MODEL_PATH=~/.hindsight/models/gemma-4-E2B-it-Q4_K_M.gguf
|
||||
HINDSIGHT_API_LLAMACPP_GPU_LAYERS=-1 # -1 = all layers on GPU
|
||||
HINDSIGHT_API_LLAMACPP_CONTEXT_SIZE=8192
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from hindsight_api.engine.llm_interface import LLMInterface
|
||||
from hindsight_api.engine.response_models import LLMToolCallResult
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Default GGUF model for offline mode
|
||||
DEFAULT_LLAMACPP_HF_REPO = "bartowski/google_gemma-4-E2B-it-GGUF"
|
||||
DEFAULT_LLAMACPP_HF_FILENAME = "google_gemma-4-E2B-it-Q4_K_M.gguf"
|
||||
DEFAULT_LLAMACPP_MODEL_ALIAS = "gemma-4-e2b-it"
|
||||
|
||||
MODELS_DIR = Path.home() / ".hindsight" / "models"
|
||||
|
||||
# Singleton server instance — shared across all LlamaCppLLM instances
|
||||
# (retain, reflect, consolidation each create their own LLMProvider,
|
||||
# but they should all share one llama.cpp server process)
|
||||
_shared_server: "LlamaCppServer | None" = None
|
||||
_shared_server_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _find_free_port() -> int:
|
||||
"""Find a free TCP port on localhost."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _download_default_model() -> Path:
|
||||
"""Download the default GGUF model from HuggingFace if not already cached.
|
||||
|
||||
Returns:
|
||||
Path to the downloaded GGUF file.
|
||||
"""
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"huggingface-hub is required for automatic model download. "
|
||||
"Install with: pip install 'hindsight-api-slim[local-llm]'"
|
||||
)
|
||||
|
||||
MODELS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
target = MODELS_DIR / DEFAULT_LLAMACPP_HF_FILENAME
|
||||
|
||||
if target.exists():
|
||||
logger.info(f"Using cached model: {target}")
|
||||
return target
|
||||
|
||||
logger.info(
|
||||
f"Downloading {DEFAULT_LLAMACPP_HF_FILENAME} from {DEFAULT_LLAMACPP_HF_REPO} (~3.5 GB, first run only)..."
|
||||
)
|
||||
|
||||
downloaded = hf_hub_download(
|
||||
repo_id=DEFAULT_LLAMACPP_HF_REPO,
|
||||
filename=DEFAULT_LLAMACPP_HF_FILENAME,
|
||||
local_dir=str(MODELS_DIR),
|
||||
)
|
||||
|
||||
logger.info(f"Model downloaded: {downloaded}")
|
||||
return Path(downloaded)
|
||||
|
||||
|
||||
def _resolve_model_path(model_path: str | None) -> Path:
|
||||
"""Resolve the model path, downloading the default if needed.
|
||||
|
||||
Args:
|
||||
model_path: Explicit path to a GGUF file, or None to use the default.
|
||||
|
||||
Returns:
|
||||
Resolved Path to the GGUF file.
|
||||
"""
|
||||
if model_path:
|
||||
p = Path(model_path).expanduser()
|
||||
if not p.exists():
|
||||
raise FileNotFoundError(
|
||||
f"GGUF model not found: {p}\n"
|
||||
f"Set HINDSIGHT_API_LLAMACPP_MODEL_PATH to a valid .gguf file, "
|
||||
f"or remove the setting to auto-download the default model."
|
||||
)
|
||||
return p
|
||||
|
||||
return _download_default_model()
|
||||
|
||||
|
||||
class LlamaCppServer:
|
||||
"""Manages a llama-cpp-python OpenAI-compatible server as a subprocess."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_path: Path,
|
||||
port: int,
|
||||
gpu_layers: int = -1,
|
||||
context_size: int = 8192,
|
||||
chat_format: str | None = None,
|
||||
extra_args: str | None = None,
|
||||
):
|
||||
self.model_path = model_path
|
||||
self.port = port
|
||||
self.gpu_layers = gpu_layers
|
||||
self.context_size = context_size
|
||||
self.chat_format = chat_format
|
||||
self.extra_args = extra_args
|
||||
self._process: subprocess.Popen | None = None
|
||||
|
||||
@property
|
||||
def base_url(self) -> str:
|
||||
return f"http://127.0.0.1:{self.port}/v1"
|
||||
|
||||
async def start(self) -> None:
|
||||
"""Start the llama.cpp server subprocess."""
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"llama_cpp.server",
|
||||
"--model",
|
||||
str(self.model_path),
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(self.port),
|
||||
"--n_gpu_layers",
|
||||
str(self.gpu_layers),
|
||||
"--n_ctx",
|
||||
str(self.context_size),
|
||||
"--flash_attn",
|
||||
"true",
|
||||
"--n_batch",
|
||||
"2048",
|
||||
# Prompt cache: reuse KV cache for repeated system prompts
|
||||
"--cache",
|
||||
"true",
|
||||
]
|
||||
# Only pass chat_format if explicitly set (most GGUF models have it embedded)
|
||||
if self.chat_format:
|
||||
cmd.extend(["--chat_format", self.chat_format])
|
||||
# User-provided extra args (e.g. "--type_k 1 --type_v 1 --n_threads 8")
|
||||
if self.extra_args:
|
||||
cmd.extend(self.extra_args.split())
|
||||
|
||||
logger.info(f"Starting llama.cpp server: {' '.join(cmd)}")
|
||||
|
||||
# Write stderr to a log file to avoid pipe buffer deadlock
|
||||
# (llama.cpp outputs a lot of model metadata on stderr during loading)
|
||||
self._log_path = MODELS_DIR / "llamacpp_server.log"
|
||||
self._log_file = open(self._log_path, "w")
|
||||
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=self._log_file,
|
||||
# Ensure the subprocess is killed when the parent exits
|
||||
preexec_fn=os.setsid if hasattr(os, "setsid") else None,
|
||||
)
|
||||
|
||||
# Wait for the server to be ready
|
||||
await self._wait_for_ready()
|
||||
|
||||
async def _wait_for_ready(self, timeout: float = 120.0) -> None:
|
||||
"""Wait for the llama.cpp server to accept connections."""
|
||||
import httpx
|
||||
|
||||
start = time.monotonic()
|
||||
url = f"http://127.0.0.1:{self.port}/v1/models"
|
||||
last_log = start
|
||||
|
||||
while time.monotonic() - start < timeout:
|
||||
# Check if process died
|
||||
if self._process and self._process.poll() is not None:
|
||||
stderr = ""
|
||||
try:
|
||||
stderr = self._log_path.read_text()[-2000:]
|
||||
except Exception:
|
||||
pass
|
||||
raise RuntimeError(f"llama.cpp server exited with code {self._process.returncode}.\nstderr: {stderr}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, timeout=5.0)
|
||||
if resp.status_code == 200:
|
||||
logger.info(f"llama.cpp server ready on port {self.port}")
|
||||
return
|
||||
except (httpx.ConnectError, httpx.TimeoutException, httpx.ConnectTimeout):
|
||||
pass
|
||||
|
||||
# Log progress every 15s
|
||||
now = time.monotonic()
|
||||
if now - last_log > 15:
|
||||
elapsed = int(now - start)
|
||||
logger.info(f"Waiting for llama.cpp server to load model... ({elapsed}s)")
|
||||
last_log = now
|
||||
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# Timeout — read the log to help debug
|
||||
stderr = ""
|
||||
try:
|
||||
stderr = self._log_path.read_text()[-2000:]
|
||||
except Exception:
|
||||
pass
|
||||
raise TimeoutError(
|
||||
f"llama.cpp server did not become ready within {timeout}s.\n"
|
||||
f"Check model compatibility and available memory.\n"
|
||||
f"Server log: {stderr}"
|
||||
)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the llama.cpp server subprocess."""
|
||||
if self._process is None:
|
||||
return
|
||||
|
||||
logger.info("Stopping llama.cpp server...")
|
||||
try:
|
||||
# Send SIGTERM to the process group
|
||||
if hasattr(os, "killpg"):
|
||||
os.killpg(os.getpgid(self._process.pid), signal.SIGTERM)
|
||||
else:
|
||||
self._process.terminate()
|
||||
|
||||
# Wait up to 10s for graceful shutdown
|
||||
try:
|
||||
self._process.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
if hasattr(os, "killpg"):
|
||||
os.killpg(os.getpgid(self._process.pid), signal.SIGKILL)
|
||||
else:
|
||||
self._process.kill()
|
||||
self._process.wait(timeout=5)
|
||||
except (ProcessLookupError, OSError):
|
||||
pass # Process already exited
|
||||
finally:
|
||||
self._process = None
|
||||
if hasattr(self, "_log_file") and self._log_file:
|
||||
self._log_file.close()
|
||||
self._log_file = None
|
||||
logger.info("llama.cpp server stopped")
|
||||
|
||||
|
||||
class LlamaCppLLM(LLMInterface):
|
||||
"""
|
||||
Built-in llama.cpp provider.
|
||||
|
||||
Manages a llama-cpp-python server subprocess and delegates to OpenAICompatibleLLM
|
||||
for actual inference calls. Handles model downloading and server lifecycle.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
provider: str,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
reasoning_effort: str = "low",
|
||||
model_path: str | None = None,
|
||||
gpu_layers: int = -1,
|
||||
context_size: int = 8192,
|
||||
chat_format: str | None = None,
|
||||
no_grammar: bool = False,
|
||||
extra_args: str | None = None,
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(
|
||||
provider=provider,
|
||||
api_key=api_key or "llamacpp",
|
||||
base_url=base_url or "",
|
||||
model=model or DEFAULT_LLAMACPP_MODEL_ALIAS,
|
||||
reasoning_effort=reasoning_effort,
|
||||
)
|
||||
self._model_path_str = model_path
|
||||
self._gpu_layers = gpu_layers
|
||||
self._context_size = context_size
|
||||
self._chat_format = chat_format
|
||||
self._no_grammar = no_grammar
|
||||
self._extra_args = extra_args
|
||||
self._server: LlamaCppServer | None = None
|
||||
self._delegate: Any = None # OpenAICompatibleLLM, created after server starts
|
||||
self._initialized = False
|
||||
|
||||
async def _ensure_initialized(self) -> None:
|
||||
"""Lazy initialization: download model + start shared server on first use."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
global _shared_server
|
||||
|
||||
from .openai_compatible_llm import OpenAICompatibleLLM
|
||||
|
||||
async with _shared_server_lock:
|
||||
if _shared_server is None:
|
||||
# Resolve and potentially download the model
|
||||
model_path = _resolve_model_path(self._model_path_str)
|
||||
logger.info(f"Using GGUF model: {model_path}")
|
||||
|
||||
# Start the shared llama.cpp server
|
||||
port = _find_free_port()
|
||||
_shared_server = LlamaCppServer(
|
||||
model_path=model_path,
|
||||
port=port,
|
||||
gpu_layers=self._gpu_layers,
|
||||
context_size=self._context_size,
|
||||
chat_format=self._chat_format,
|
||||
extra_args=self._extra_args,
|
||||
)
|
||||
await _shared_server.start()
|
||||
|
||||
self._server = _shared_server
|
||||
|
||||
# Create the delegate that talks to the shared server's OpenAI-compatible API
|
||||
if self._no_grammar:
|
||||
logger.info("Grammar enforcement disabled (HINDSIGHT_API_LLAMACPP_NO_GRAMMAR=true)")
|
||||
self._delegate = OpenAICompatibleLLM(
|
||||
provider="llamacpp",
|
||||
api_key="llamacpp",
|
||||
base_url=self._server.base_url,
|
||||
model=self.model,
|
||||
reasoning_effort=self.reasoning_effort,
|
||||
)
|
||||
|
||||
self._initialized = True
|
||||
|
||||
async def verify_connection(self) -> None:
|
||||
"""Verify the llama.cpp server is running and can generate text."""
|
||||
await self._ensure_initialized()
|
||||
# Make a simple test call to verify the model can actually generate
|
||||
await self._delegate.call(
|
||||
messages=[{"role": "user", "content": "Say 'ok'"}],
|
||||
max_completion_tokens=10,
|
||||
max_retries=2,
|
||||
initial_backoff=0.5,
|
||||
max_backoff=2.0,
|
||||
scope="verification",
|
||||
)
|
||||
logger.info("llama.cpp LLM verification passed")
|
||||
|
||||
async def call(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
response_format: Any | None = None,
|
||||
max_completion_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
scope: str = "memory",
|
||||
max_retries: int = 10,
|
||||
initial_backoff: float = 1.0,
|
||||
max_backoff: float = 60.0,
|
||||
skip_validation: bool = False,
|
||||
strict_schema: bool = False,
|
||||
return_usage: bool = False,
|
||||
) -> Any:
|
||||
"""Delegate call to the OpenAI-compatible API."""
|
||||
await self._ensure_initialized()
|
||||
return await self._delegate.call(
|
||||
messages=messages,
|
||||
response_format=response_format,
|
||||
max_completion_tokens=max_completion_tokens,
|
||||
temperature=temperature,
|
||||
scope=scope,
|
||||
max_retries=max_retries,
|
||||
initial_backoff=initial_backoff,
|
||||
max_backoff=max_backoff,
|
||||
skip_validation=skip_validation,
|
||||
strict_schema=strict_schema,
|
||||
return_usage=return_usage,
|
||||
)
|
||||
|
||||
async def call_with_tools(
|
||||
self,
|
||||
messages: list[dict[str, Any]],
|
||||
tools: list[dict[str, Any]],
|
||||
max_completion_tokens: int | None = None,
|
||||
temperature: float | None = None,
|
||||
scope: str = "tools",
|
||||
max_retries: int = 5,
|
||||
initial_backoff: float = 1.0,
|
||||
max_backoff: float = 30.0,
|
||||
tool_choice: str | dict[str, Any] = "auto",
|
||||
) -> LLMToolCallResult:
|
||||
"""Delegate tool calls to the OpenAI-compatible API."""
|
||||
await self._ensure_initialized()
|
||||
return await self._delegate.call_with_tools(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
max_completion_tokens=max_completion_tokens,
|
||||
temperature=temperature,
|
||||
scope=scope,
|
||||
max_retries=max_retries,
|
||||
initial_backoff=initial_backoff,
|
||||
max_backoff=max_backoff,
|
||||
tool_choice=tool_choice,
|
||||
)
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Stop the shared llama.cpp server."""
|
||||
global _shared_server
|
||||
|
||||
if self._delegate:
|
||||
await self._delegate.cleanup()
|
||||
self._delegate = None
|
||||
|
||||
# Stop the shared server (only the first cleanup call actually stops it)
|
||||
async with _shared_server_lock:
|
||||
if _shared_server is not None:
|
||||
await _shared_server.stop()
|
||||
_shared_server = None
|
||||
|
||||
self._server = None
|
||||
self._initialized = False
|
||||
@@ -100,7 +100,7 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
|
||||
|
||||
# Validate provider
|
||||
valid_providers = ["openai", "groq", "ollama", "lmstudio", "minimax", "volcano"]
|
||||
valid_providers = ["openai", "groq", "ollama", "lmstudio", "llamacpp", "minimax", "volcano", "openrouter"]
|
||||
if self.provider not in valid_providers:
|
||||
raise ValueError(f"OpenAICompatibleLLM only supports: {', '.join(valid_providers)}. Got: {self.provider}")
|
||||
|
||||
@@ -114,13 +114,15 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
self.base_url = "http://localhost:1234/v1"
|
||||
elif self.provider == "minimax":
|
||||
self.base_url = "https://api.minimax.io/v1"
|
||||
elif self.provider == "openrouter":
|
||||
self.base_url = "https://openrouter.ai/api/v1"
|
||||
|
||||
# For ollama/lmstudio, use dummy key if not provided
|
||||
if self.provider in ("ollama", "lmstudio") and not self.api_key:
|
||||
self.api_key = "local"
|
||||
|
||||
# Validate API key for cloud providers
|
||||
if self.provider in ("openai", "groq", "minimax") and not self.api_key:
|
||||
if self.provider in ("openai", "groq", "minimax", "openrouter") and not self.api_key:
|
||||
raise ValueError(f"API key is required for {self.provider}")
|
||||
|
||||
# Service tier configuration (from config, not env vars)
|
||||
@@ -191,6 +193,23 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
|
||||
return None
|
||||
|
||||
def _max_tokens_param_name(self) -> str:
|
||||
"""Return the correct parameter name for limiting response tokens.
|
||||
|
||||
Native OpenAI and Groq accept 'max_completion_tokens'. Mistral and other
|
||||
OpenAI-compatible endpoints that haven't adopted the newer parameter name
|
||||
require 'max_tokens'. Using a custom base_url with the openai provider
|
||||
signals a third-party compatible API, so fall back to 'max_tokens'.
|
||||
"""
|
||||
# Native OpenAI (no custom base URL), Groq, and llamacpp use max_completion_tokens
|
||||
if self.provider in ("groq", "llamacpp"):
|
||||
return "max_completion_tokens"
|
||||
if self.provider == "openai" and not self.base_url:
|
||||
return "max_completion_tokens"
|
||||
# openai with custom base_url, ollama, lmstudio, minimax, volcano —
|
||||
# use the widely-supported max_tokens
|
||||
return "max_tokens"
|
||||
|
||||
async def call(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
@@ -263,9 +282,7 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
# For reasoning models, enforce minimum to ensure space for reasoning + output
|
||||
if is_reasoning_model and max_completion_tokens < 16000:
|
||||
max_completion_tokens = 16000
|
||||
call_params["max_completion_tokens"] = max_completion_tokens
|
||||
|
||||
# Temperature - reasoning models don't support custom temperature
|
||||
call_params[self._max_tokens_param_name()] = max_completion_tokens
|
||||
if temperature is not None and not is_reasoning_model:
|
||||
# MiniMax requires temperature in (0.0, 1.0] — clamp accordingly
|
||||
if self.provider == "minimax":
|
||||
@@ -320,8 +337,13 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
first_msg = call_params["messages"][0]
|
||||
if isinstance(first_msg, dict) and isinstance(first_msg.get("content"), str):
|
||||
first_msg["content"] = schema_msg + "\n\n" + first_msg["content"]
|
||||
if self.provider not in ("lmstudio", "ollama", "volcano"):
|
||||
# LM Studio, Ollama and Volcano don't support json_object response format reliably
|
||||
# Providers that skip json_object grammar enforcement
|
||||
skip_grammar = self.provider in ("lmstudio", "ollama", "volcano")
|
||||
if self.provider == "llamacpp":
|
||||
from hindsight_api.config import get_config
|
||||
|
||||
skip_grammar = get_config().llamacpp_no_grammar
|
||||
if not skip_grammar:
|
||||
call_params["response_format"] = {"type": "json_object"}
|
||||
|
||||
last_exception = None
|
||||
@@ -577,7 +599,7 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
}
|
||||
|
||||
if max_completion_tokens is not None:
|
||||
call_params["max_completion_tokens"] = max_completion_tokens
|
||||
call_params[self._max_tokens_param_name()] = max_completion_tokens
|
||||
if temperature is not None:
|
||||
# MiniMax requires temperature in (0.0, 1.0] — clamp accordingly
|
||||
if self.provider == "minimax":
|
||||
|
||||
@@ -137,7 +137,21 @@ class DateparserQueryAnalyzer(QueryAnalyzer):
|
||||
"RETURN_AS_TIMEZONE_AWARE": False,
|
||||
}
|
||||
|
||||
results = self._search_dates(query, settings=settings)
|
||||
# Wrap dateparser in a defensive try/except. dateparser has been
|
||||
# observed to crash with internal errors (e.g., IndexError from
|
||||
# locale.translate_search) on certain query inputs. A parser bug
|
||||
# should not bring down the whole search/consolidation pipeline —
|
||||
# treat any failure as "no temporal constraint found" so the caller
|
||||
# can fall back to non-temporal retrieval.
|
||||
try:
|
||||
results = self._search_dates(query, settings=settings)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"dateparser raised %s on query (treating as no temporal constraint): %s",
|
||||
type(e).__name__,
|
||||
e,
|
||||
)
|
||||
return QueryAnalysis(temporal_constraint=None)
|
||||
|
||||
if not results:
|
||||
return QueryAnalysis(temporal_constraint=None)
|
||||
|
||||
@@ -113,6 +113,22 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
|
||||
Returns:
|
||||
BankProfile with name, typed DispositionTraits, and mission
|
||||
"""
|
||||
profile, _ = await get_or_create_bank_profile(pool, bank_id)
|
||||
return profile
|
||||
|
||||
|
||||
async def get_or_create_bank_profile(pool, bank_id: str) -> tuple[BankProfile, bool]:
|
||||
"""
|
||||
Get bank profile, auto-creating with defaults if it doesn't exist.
|
||||
|
||||
Same as get_bank_profile, but also returns a flag indicating whether the
|
||||
bank was freshly created on this call. Used by the memory engine to apply
|
||||
the HINDSIGHT_API_DEFAULT_BANK_TEMPLATE hook on first bank creation.
|
||||
|
||||
Returns:
|
||||
Tuple of (BankProfile, created) where created is True if the bank
|
||||
did not exist before this call.
|
||||
"""
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
# Try to get existing bank
|
||||
row = await conn.fetchrow(
|
||||
@@ -129,10 +145,13 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
|
||||
if isinstance(disposition_data, str):
|
||||
disposition_data = json.loads(disposition_data)
|
||||
|
||||
return BankProfile(
|
||||
name=row["name"],
|
||||
disposition=DispositionTraits(**disposition_data),
|
||||
mission=row["mission"] or "",
|
||||
return (
|
||||
BankProfile(
|
||||
name=row["name"],
|
||||
disposition=DispositionTraits(**disposition_data),
|
||||
mission=row["mission"] or "",
|
||||
),
|
||||
False,
|
||||
)
|
||||
|
||||
# Bank doesn't exist, create with defaults.
|
||||
@@ -153,11 +172,15 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
|
||||
internal_id,
|
||||
)
|
||||
|
||||
if inserted:
|
||||
created = inserted is not None
|
||||
if created:
|
||||
# Fresh insert — create per-bank vector indexes (instant on empty bank)
|
||||
await create_bank_vector_indexes(conn, bank_id, str(internal_id))
|
||||
|
||||
return BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), mission="")
|
||||
return (
|
||||
BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), mission=""),
|
||||
created,
|
||||
)
|
||||
|
||||
|
||||
async def update_bank_disposition(pool, bank_id: str, disposition: dict[str, int]) -> None:
|
||||
|
||||
@@ -909,6 +909,7 @@ def _build_user_message(
|
||||
event_date: datetime | None,
|
||||
context: str,
|
||||
metadata: dict[str, str] | None = None,
|
||||
agent_name: str | None = None,
|
||||
) -> str:
|
||||
"""Build user message for fact extraction."""
|
||||
from .orchestrator import parse_datetime_flexible
|
||||
@@ -927,11 +928,15 @@ def _build_user_message(
|
||||
metadata_lines = "\n".join(f" {k}: {v}" for k, v in metadata.items())
|
||||
metadata_section = f"\nMetadata:\n{metadata_lines}"
|
||||
|
||||
narrator_section = ""
|
||||
if agent_name:
|
||||
narrator_section = f'\nNarrator: {agent_name} (AI agent — first-person statements like "I did X" are the agent\'s own actions; classify as "assistant")'
|
||||
|
||||
return f"""Extract facts from the following text chunk.
|
||||
|
||||
Chunk: {chunk_index + 1}/{total_chunks}
|
||||
Event Date: {event_date_str}
|
||||
Context: {sanitized_context}{metadata_section}
|
||||
Context: {sanitized_context}{metadata_section}{narrator_section}
|
||||
|
||||
Text:
|
||||
{sanitized_chunk}"""
|
||||
@@ -995,7 +1000,7 @@ async def _extract_facts_from_chunk(
|
||||
extract_causal_links = config.retain_extract_causal_links
|
||||
|
||||
# Build user message using helper function
|
||||
user_message = _build_user_message(chunk, chunk_index, total_chunks, event_date, context, metadata)
|
||||
user_message = _build_user_message(chunk, chunk_index, total_chunks, event_date, context, metadata, agent_name)
|
||||
|
||||
# Retry logic for JSON validation errors
|
||||
# Use retain-specific overrides if set, otherwise fall back to global LLM config
|
||||
@@ -1055,7 +1060,7 @@ async def _extract_facts_from_chunk(
|
||||
f"LLM response missing 'facts' field or returned empty list. "
|
||||
f"Response: {extraction_response_json}. "
|
||||
f"Input: "
|
||||
f"date: {event_date.isoformat()}, "
|
||||
f"date: {event_date.isoformat() if event_date else 'unset'}, "
|
||||
f"context: {context if context else 'none'}, "
|
||||
f"text: {chunk}"
|
||||
)
|
||||
@@ -1632,7 +1637,13 @@ async def extract_facts_from_contents_batch_api(
|
||||
|
||||
# Build user message using helper function
|
||||
user_message = _build_user_message(
|
||||
chunk, chunk_index_in_content, len(chunks), item.event_date, item.context, item.metadata or None
|
||||
chunk,
|
||||
chunk_index_in_content,
|
||||
len(chunks),
|
||||
item.event_date,
|
||||
item.context,
|
||||
item.metadata or None,
|
||||
agent_name,
|
||||
)
|
||||
|
||||
# Build request body using helper function
|
||||
|
||||
@@ -17,6 +17,23 @@ from .types import ProcessedFact
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_document_content(
|
||||
conn,
|
||||
bank_id: str,
|
||||
document_id: str,
|
||||
) -> str | None:
|
||||
"""Fetch the original_text of an existing document.
|
||||
|
||||
Returns None if the document does not exist.
|
||||
"""
|
||||
row = await conn.fetchval(
|
||||
f"SELECT original_text FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2",
|
||||
document_id,
|
||||
bank_id,
|
||||
)
|
||||
return row
|
||||
|
||||
|
||||
async def insert_facts_batch(
|
||||
conn, bank_id: str, facts: list[ProcessedFact], document_id: str | None = None
|
||||
) -> list[str]:
|
||||
|
||||
@@ -523,6 +523,35 @@ async def retain_batch(
|
||||
except Exception:
|
||||
logger.warning("Failed to persist generated document_id", exc_info=True)
|
||||
|
||||
# --- Append mode: prepend existing document content to new content ---
|
||||
# When update_mode="append", fetch the existing document text and prepend it
|
||||
# so the full document is reprocessed (delta retain will skip unchanged chunks).
|
||||
update_mode = None
|
||||
for item in contents_dicts:
|
||||
item_mode = item.get("update_mode")
|
||||
if item_mode:
|
||||
update_mode = item_mode
|
||||
break
|
||||
|
||||
if update_mode == "append" and effective_doc_id and is_first_batch:
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
existing_text = await fact_storage.get_document_content(conn, bank_id, effective_doc_id)
|
||||
if existing_text:
|
||||
# Prepend existing text as a new content item at the beginning
|
||||
existing_content: RetainContentDict = {"content": existing_text}
|
||||
# Copy context/tags from first item for consistency
|
||||
first = contents_dicts[0]
|
||||
if first.get("context"):
|
||||
existing_content["context"] = first["context"]
|
||||
if first.get("tags"):
|
||||
existing_content["tags"] = first["tags"]
|
||||
contents_dicts = [existing_content, *contents_dicts]
|
||||
# Rebuild contents list to match
|
||||
contents = _build_contents(contents_dicts, document_tags)
|
||||
log_buffer.append(
|
||||
f"[append] Prepended {len(existing_text):,} chars from existing document {effective_doc_id}"
|
||||
)
|
||||
|
||||
# --- Delta retain: check if we can skip unchanged chunks ---
|
||||
if is_first_batch:
|
||||
delta_result = await _try_delta_retain(
|
||||
@@ -1522,7 +1551,12 @@ def _map_results_to_contents(
|
||||
"""Map created unit IDs back to original content items."""
|
||||
facts_by_content: dict[int, list[int]] = {i: [] for i in range(len(contents))}
|
||||
for i, fact in enumerate(extracted_facts):
|
||||
facts_by_content[fact.content_index].append(i)
|
||||
# Normalize content_index: some LLM providers return 1-indexed values.
|
||||
# Clamp to valid range to prevent KeyError.
|
||||
idx = fact.content_index
|
||||
if idx < 0 or idx >= len(contents):
|
||||
idx = min(max(idx, 0), len(contents) - 1) if len(contents) > 0 else 0
|
||||
facts_by_content[idx].append(i)
|
||||
|
||||
result_unit_ids = []
|
||||
unit_idx = 0
|
||||
|
||||
@@ -25,6 +25,9 @@ class RetainContentDict(TypedDict, total=False):
|
||||
observation_scopes: How to scope observations for consolidation (optional).
|
||||
"per_tag" runs one pass per individual tag; "combined" (default) runs a
|
||||
single pass with all tags; a list[list[str]] specifies exact passes.
|
||||
update_mode: How to handle existing documents with the same document_id (optional).
|
||||
"replace" (default) deletes old data and reprocesses. "append" concatenates
|
||||
new content to the existing document and reprocesses.
|
||||
"""
|
||||
|
||||
content: str # Required
|
||||
@@ -37,6 +40,7 @@ class RetainContentDict(TypedDict, total=False):
|
||||
observation_scopes: (
|
||||
Literal["per_tag", "combined", "all_combinations"] | list[list[str]]
|
||||
) # Observation scopes for consolidation
|
||||
update_mode: Literal["replace", "append"]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -6,25 +6,30 @@ stored in memory_links:
|
||||
|
||||
1. Entity links — query-time self-join through unit_entities. Score = number of distinct
|
||||
shared entities between the seed set and each candidate, computed via
|
||||
COUNT(DISTINCT entity_id). More accurate than precomputed entity links.
|
||||
COUNT(DISTINCT entity_id). Uses a LATERAL per-entity cap
|
||||
(graph_per_entity_limit, default 200) to prevent high-fanout entities
|
||||
from exploding the self-join intermediate rows.
|
||||
2. Semantic links — precomputed kNN graph (each new fact linked to its top-5 most
|
||||
similar existing facts at insert time, similarity >= 0.7). Checked
|
||||
in both directions since the graph is not symmetric. Score = weight.
|
||||
3. Causal links — explicit causal chains (causes/caused_by/enables/prevents).
|
||||
Score = weight + 1.0 (boosted as highest-quality signal).
|
||||
|
||||
All three signals are bounded at retain time, so no LATERAL fan-out caps are needed
|
||||
at query time. Each expansion is a simple aggregation over a small result set.
|
||||
Entity expansion is bounded by graph_per_entity_limit (LATERAL cap per entity).
|
||||
A timeout fallback (graph_expansion_timeout) drops entity expansion entirely if the
|
||||
query still exceeds the budget.
|
||||
|
||||
For non-observation fact types the three expansions are issued as a single CTE query
|
||||
(one roundtrip, one connection) with a `source` discriminator column so the Python
|
||||
merge step can apply per-signal score transformations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
|
||||
from ...config import get_config
|
||||
from ..db_utils import acquire_with_retry
|
||||
from ..memory_engine import fq_table
|
||||
from .graph_retrieval import GraphRetriever
|
||||
@@ -59,7 +64,7 @@ async def _find_semantic_seeds(
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end,
|
||||
mentioned_at, fact_type, document_id, chunk_id, tags,
|
||||
mentioned_at, fact_type, document_id, chunk_id, tags, proof_count,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
@@ -262,35 +267,48 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
idx_memory_links_to_type_weight (to_unit_id, link_type, weight DESC)
|
||||
→ replaces costly BitmapAnd of two separate scans
|
||||
"""
|
||||
config = get_config()
|
||||
ml = fq_table("memory_links")
|
||||
mu = fq_table("memory_units")
|
||||
ue = fq_table("unit_entities")
|
||||
|
||||
per_entity_limit = config.link_expansion_per_entity_limit
|
||||
|
||||
# Entity CTE with LATERAL fanout cap.
|
||||
# Every seed entity (including high-frequency ones) is kept, but each
|
||||
# entity's expansion is capped to per_entity_limit target units. The
|
||||
# LATERAL subquery orders by unit_id DESC so the most recently inserted
|
||||
# units are preferred (a recency proxy that is free — it rides the PK
|
||||
# index with no extra sort).
|
||||
entity_cte = f"""
|
||||
seed_entities AS (
|
||||
SELECT DISTINCT ue.entity_id
|
||||
FROM {ue} ue
|
||||
WHERE ue.unit_id = ANY($1::uuid[])
|
||||
),
|
||||
entity_expanded AS (
|
||||
-- Entity co-occurrence via unit_entities self-join.
|
||||
-- Finds units sharing entities with seeds at query time — more accurate
|
||||
-- than precomputed entity links (no stale 50-neighbor cap).
|
||||
-- Score = COUNT(DISTINCT shared entities), mapped to [0,1] via tanh.
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
COUNT(DISTINCT ue_seed.entity_id)::float AS score,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
|
||||
COUNT(DISTINCT se.entity_id)::float AS score,
|
||||
'entity'::text AS source
|
||||
FROM {ue} ue_seed
|
||||
JOIN {ue} ue_target ON ue_seed.entity_id = ue_target.entity_id
|
||||
JOIN {mu} mu ON mu.id = ue_target.unit_id
|
||||
WHERE ue_seed.unit_id = ANY($1::uuid[])
|
||||
AND ue_target.unit_id != ALL($1::uuid[])
|
||||
AND mu.fact_type = $2
|
||||
FROM seed_entities se
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT ue_target.unit_id
|
||||
FROM {ue} ue_target
|
||||
WHERE ue_target.entity_id = se.entity_id
|
||||
AND ue_target.unit_id != ALL($1::uuid[])
|
||||
ORDER BY ue_target.unit_id DESC
|
||||
LIMIT {per_entity_limit}
|
||||
) t
|
||||
JOIN {mu} mu ON mu.id = t.unit_id
|
||||
WHERE mu.fact_type = $2
|
||||
GROUP BY mu.id
|
||||
ORDER BY score DESC
|
||||
LIMIT $3
|
||||
)"""
|
||||
|
||||
all_rows = await conn.fetch(
|
||||
f"""
|
||||
WITH {entity_cte},
|
||||
semantic_causal_cte = f"""
|
||||
semantic_expanded AS (
|
||||
-- Semantic kNN: both outgoing (seeds → their kNN at insert time) and
|
||||
-- incoming (facts inserted after seeds that found seeds as kNN).
|
||||
@@ -298,14 +316,14 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
SELECT
|
||||
id, text, context, event_date, occurred_start,
|
||||
occurred_end, mentioned_at,
|
||||
fact_type, document_id, chunk_id, tags,
|
||||
fact_type, document_id, chunk_id, tags, proof_count,
|
||||
MAX(weight) AS score,
|
||||
'semantic'::text AS source
|
||||
FROM (
|
||||
SELECT
|
||||
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
|
||||
ml.weight
|
||||
FROM {ml} ml
|
||||
JOIN {mu} mu ON mu.id = ml.to_unit_id
|
||||
@@ -317,7 +335,7 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
SELECT
|
||||
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
|
||||
ml.weight
|
||||
FROM {ml} ml
|
||||
JOIN {mu} mu ON mu.id = ml.from_unit_id
|
||||
@@ -328,7 +346,7 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
) sem_raw
|
||||
GROUP BY id, text, context, event_date, occurred_start,
|
||||
occurred_end, mentioned_at,
|
||||
fact_type, document_id, chunk_id, tags
|
||||
fact_type, document_id, chunk_id, tags, proof_count
|
||||
ORDER BY score DESC
|
||||
LIMIT $3
|
||||
),
|
||||
@@ -339,7 +357,7 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
SELECT DISTINCT ON (mu.id)
|
||||
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
|
||||
ml.weight AS score,
|
||||
'causal'::text AS source
|
||||
FROM {ml} ml
|
||||
@@ -350,18 +368,37 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
AND mu.fact_type = $2
|
||||
ORDER BY mu.id, ml.weight DESC
|
||||
LIMIT $3
|
||||
)
|
||||
)"""
|
||||
|
||||
full_query = f"""
|
||||
WITH {entity_cte},
|
||||
{semantic_causal_cte}
|
||||
SELECT * FROM entity_expanded
|
||||
UNION ALL
|
||||
SELECT * FROM semantic_expanded
|
||||
UNION ALL
|
||||
SELECT * FROM causal_expanded
|
||||
""",
|
||||
seed_ids,
|
||||
fact_type,
|
||||
budget,
|
||||
self.causal_weight_threshold,
|
||||
)
|
||||
"""
|
||||
|
||||
params = [seed_ids, fact_type, budget, self.causal_weight_threshold]
|
||||
|
||||
try:
|
||||
all_rows = await asyncio.wait_for(
|
||||
conn.fetch(full_query, *params),
|
||||
timeout=config.link_expansion_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
f"[LinkExpansion] Entity expansion timed out after {config.link_expansion_timeout}s "
|
||||
f"for fact_type={fact_type}, falling back to semantic+causal only"
|
||||
)
|
||||
fallback_query = f"""
|
||||
WITH {semantic_causal_cte}
|
||||
SELECT * FROM semantic_expanded
|
||||
UNION ALL
|
||||
SELECT * FROM causal_expanded
|
||||
"""
|
||||
all_rows = await conn.fetch(fallback_query, *params)
|
||||
|
||||
entity_rows = [r for r in all_rows if r["source"] == "entity"]
|
||||
semantic_rows = [r for r in all_rows if r["source"] == "semantic"]
|
||||
@@ -401,17 +438,31 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
f"{len(source_ids_found)} source_memory_ids found"
|
||||
)
|
||||
|
||||
config = get_config()
|
||||
ue = fq_table("unit_entities")
|
||||
per_entity_limit = config.link_expansion_per_entity_limit
|
||||
|
||||
connected_sources_cte = f"""
|
||||
connected_sources AS (
|
||||
-- Find sources sharing entities with seed observation sources
|
||||
-- via unit_entities self-join (query-time, no precomputed links needed).
|
||||
SELECT DISTINCT ue_target.unit_id AS source_id
|
||||
source_entities AS (
|
||||
SELECT DISTINCT ue_seed.entity_id
|
||||
FROM seed_sources ss
|
||||
JOIN {ue} ue_seed ON ue_seed.unit_id = ss.source_id
|
||||
JOIN {ue} ue_target ON ue_seed.entity_id = ue_target.entity_id
|
||||
WHERE ue_target.unit_id != ss.source_id
|
||||
),
|
||||
connected_sources AS (
|
||||
-- Find sources sharing entities with seed observation sources
|
||||
-- via LATERAL-capped self-join (prevents hub entity fanout).
|
||||
SELECT DISTINCT t.unit_id AS source_id
|
||||
FROM source_entities se
|
||||
CROSS JOIN LATERAL (
|
||||
SELECT ue_target.unit_id
|
||||
FROM {ue} ue_target
|
||||
WHERE ue_target.entity_id = se.entity_id
|
||||
ORDER BY ue_target.unit_id DESC
|
||||
LIMIT {per_entity_limit}
|
||||
) t
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM seed_sources ss WHERE ss.source_id = t.unit_id
|
||||
)
|
||||
)"""
|
||||
|
||||
entity_rows = await conn.fetch(
|
||||
@@ -429,7 +480,7 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
SELECT
|
||||
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.proof_count,
|
||||
(SELECT COUNT(DISTINCT s) FROM unnest(mu.source_memory_ids) s WHERE s = ANY(ca.source_ids))::float AS score
|
||||
FROM {fq_table("memory_units")} mu, connected_array ca
|
||||
WHERE mu.fact_type = 'observation'
|
||||
@@ -453,13 +504,13 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
SELECT
|
||||
id, text, context, event_date, occurred_start,
|
||||
occurred_end, mentioned_at,
|
||||
fact_type, document_id, chunk_id, tags,
|
||||
fact_type, document_id, chunk_id, tags, proof_count,
|
||||
MAX(weight) AS score,
|
||||
'semantic'::text AS source
|
||||
FROM (
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id,
|
||||
mu.chunk_id, mu.tags, ml.weight
|
||||
mu.chunk_id, mu.tags, mu.proof_count, ml.weight
|
||||
FROM {ml} ml JOIN {mu} mu ON mu.id = ml.to_unit_id
|
||||
WHERE ml.from_unit_id = ANY($1::uuid[])
|
||||
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
|
||||
@@ -467,21 +518,21 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
UNION ALL
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id,
|
||||
mu.chunk_id, mu.tags, ml.weight
|
||||
mu.chunk_id, mu.tags, mu.proof_count, ml.weight
|
||||
FROM {ml} ml JOIN {mu} mu ON mu.id = ml.from_unit_id
|
||||
WHERE ml.to_unit_id = ANY($1::uuid[])
|
||||
AND ml.link_type = 'semantic' AND mu.fact_type = 'observation'
|
||||
AND mu.id != ALL($1::uuid[])
|
||||
) sem_raw
|
||||
GROUP BY id, text, context, event_date, occurred_start, occurred_end,
|
||||
mentioned_at, fact_type, document_id, chunk_id, tags
|
||||
mentioned_at, fact_type, document_id, chunk_id, tags, proof_count
|
||||
ORDER BY score DESC LIMIT $2
|
||||
),
|
||||
causal_expanded AS (
|
||||
SELECT DISTINCT ON (mu.id)
|
||||
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id,
|
||||
mu.chunk_id, mu.tags, ml.weight AS score, 'causal'::text AS source
|
||||
mu.chunk_id, mu.tags, mu.proof_count, ml.weight AS score, 'causal'::text AS source
|
||||
FROM {ml} ml JOIN {mu} mu ON ml.to_unit_id = mu.id
|
||||
WHERE ml.from_unit_id = ANY($1::uuid[])
|
||||
AND ml.link_type IN ('causes', 'caused_by', 'enables', 'prevents')
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Cross-encoder neural reranking for search results.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .types import MergedCandidate, ScoredResult
|
||||
@@ -13,6 +14,7 @@ UTC = timezone.utc
|
||||
# so the max combined boost is (1 + alpha/2)^2 ≈ +21% and min is (1 - alpha/2)^2 ≈ -19%.
|
||||
_RECENCY_ALPHA: float = 0.2
|
||||
_TEMPORAL_ALPHA: float = 0.2
|
||||
_PROOF_COUNT_ALPHA: float = 0.1 # Conservative: max ±5% for evidence strength
|
||||
|
||||
|
||||
def apply_combined_scoring(
|
||||
@@ -20,28 +22,40 @@ def apply_combined_scoring(
|
||||
now: datetime,
|
||||
recency_alpha: float = _RECENCY_ALPHA,
|
||||
temporal_alpha: float = _TEMPORAL_ALPHA,
|
||||
proof_count_alpha: float = _PROOF_COUNT_ALPHA,
|
||||
) -> None:
|
||||
"""Apply combined scoring to a list of ScoredResults in-place.
|
||||
|
||||
Uses the cross-encoder score as the primary relevance signal, with recency
|
||||
and temporal proximity applied as multiplicative boosts. This ensures the
|
||||
influence of these secondary signals is always proportional to the base
|
||||
relevance score, regardless of the cross-encoder model's score calibration.
|
||||
Uses the cross-encoder score as the primary relevance signal, with recency,
|
||||
temporal proximity, and proof count applied as multiplicative boosts. This
|
||||
ensures the influence of these secondary signals is always proportional to
|
||||
the base relevance score, regardless of the cross-encoder model's score
|
||||
calibration.
|
||||
|
||||
Formula::
|
||||
|
||||
recency_boost = 1 + recency_alpha * (recency - 0.5) # in [1-α/2, 1+α/2]
|
||||
temporal_boost = 1 + temporal_alpha * (temporal - 0.5) # in [1-α/2, 1+α/2]
|
||||
combined_score = cross_encoder_score_normalized * recency_boost * temporal_boost
|
||||
recency_boost = 1 + recency_alpha * (recency - 0.5) # in [1-α/2, 1+α/2]
|
||||
temporal_boost = 1 + temporal_alpha * (temporal - 0.5) # in [1-α/2, 1+α/2]
|
||||
proof_count_boost = 1 + proof_count_alpha * (proof_norm - 0.5) # in [1-α/2, 1+α/2]
|
||||
combined_score = CE_normalized * recency_boost * temporal_boost * proof_count_boost
|
||||
|
||||
proof_norm maps proof_count using a smooth logarithmic curve centered at 0.5,
|
||||
clamped to [0, 1]:
|
||||
proof_count=1 → 0.5 + 0 = 0.5 (neutral multiplier)
|
||||
proof_count=150 → clamped to 1.0 (max +5% boost)
|
||||
|
||||
Temporal proximity is treated as neutral (0.5) when not set by temporal retrieval,
|
||||
so temporal_boost collapses to 1.0 for non-temporal queries.
|
||||
|
||||
Proof count is treated as neutral (0.5) when not available (non-observation facts),
|
||||
so proof_count_boost collapses to 1.0 for world/experience/opinion facts.
|
||||
|
||||
Args:
|
||||
scored_results: Results from the cross-encoder reranker. Mutated in place.
|
||||
now: Current UTC datetime for recency calculation.
|
||||
recency_alpha: Max relative recency adjustment (default 0.2 → ±10%).
|
||||
temporal_alpha: Max relative temporal adjustment (default 0.2 → ±10%).
|
||||
proof_count_alpha: Max relative proof count adjustment (default 0.1 → ±5%).
|
||||
"""
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=UTC)
|
||||
@@ -59,13 +73,23 @@ def apply_combined_scoring(
|
||||
# Temporal proximity: meaningful only for temporal queries; neutral otherwise.
|
||||
sr.temporal = sr.retrieval.temporal_proximity if sr.retrieval.temporal_proximity is not None else 0.5
|
||||
|
||||
# Proof count: log-normalized evidence strength; neutral for non-observations.
|
||||
proof_count = sr.retrieval.proof_count
|
||||
if proof_count is not None and proof_count >= 1:
|
||||
# Clamp to [0, 1] so extreme counts stay within documented ±5% range
|
||||
proof_norm = min(1.0, max(0.0, 0.5 + (math.log(proof_count) / 10.0)))
|
||||
else:
|
||||
# Neutral baseline is precisely 0.5, ensuring neutral multiplier (1.0)
|
||||
proof_norm = 0.5
|
||||
|
||||
# RRF: kept at 0.0 for trace continuity but excluded from scoring.
|
||||
# RRF is batch-relative (min-max normalised) and redundant after reranking.
|
||||
sr.rrf_normalized = 0.0
|
||||
|
||||
recency_boost = 1.0 + recency_alpha * (sr.recency - 0.5)
|
||||
temporal_boost = 1.0 + temporal_alpha * (sr.temporal - 0.5)
|
||||
sr.combined_score = sr.cross_encoder_score_normalized * recency_boost * temporal_boost
|
||||
proof_count_boost = 1.0 + proof_count_alpha * (proof_norm - 0.5)
|
||||
sr.combined_score = sr.cross_encoder_score_normalized * recency_boost * temporal_boost * proof_count_boost
|
||||
sr.weight = sr.combined_score
|
||||
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ async def retrieve_semantic_bm25_combined(
|
||||
|
||||
cols = (
|
||||
"id, text, context, event_date, occurred_start, occurred_end, mentioned_at, "
|
||||
"fact_type, document_id, chunk_id, tags, metadata"
|
||||
"fact_type, document_id, chunk_id, tags, metadata, proof_count"
|
||||
)
|
||||
table = fq_table("memory_units")
|
||||
|
||||
@@ -336,7 +336,7 @@ async def retrieve_temporal_combined(
|
||||
{groups_clause}
|
||||
),
|
||||
sim_ranked AS (
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.fact_type, mu.proof_count, mu.document_id, mu.chunk_id, mu.tags, mu.metadata,
|
||||
1 - (mu.embedding <=> $1::vector) AS similarity,
|
||||
ROW_NUMBER() OVER (PARTITION BY mu.fact_type ORDER BY mu.embedding <=> $1::vector) AS sim_rn
|
||||
FROM date_ranked dr
|
||||
@@ -344,7 +344,7 @@ async def retrieve_temporal_combined(
|
||||
WHERE dr.rn <= 50
|
||||
AND (1 - (mu.embedding <=> $1::vector)) >= $6
|
||||
)
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, document_id, chunk_id, tags, metadata, similarity
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, fact_type, proof_count, document_id, chunk_id, tags, metadata, similarity
|
||||
FROM sim_ranked
|
||||
WHERE sim_rn <= 10
|
||||
""",
|
||||
|
||||
@@ -62,13 +62,14 @@ def format_facts_for_prompt(facts: list[MemoryFact]) -> str:
|
||||
if fact.context:
|
||||
fact_obj["context"] = fact.context
|
||||
|
||||
# Add occurred_start if available (when the fact occurred)
|
||||
if fact.occurred_start:
|
||||
occurred_start = fact.occurred_start
|
||||
if isinstance(occurred_start, str):
|
||||
fact_obj["occurred_start"] = occurred_start
|
||||
elif isinstance(occurred_start, datetime):
|
||||
fact_obj["occurred_start"] = occurred_start.strftime("%Y-%m-%d %H:%M:%S")
|
||||
# Add temporal fields if available
|
||||
for field_name in ("occurred_start", "occurred_end", "mentioned_at"):
|
||||
value = getattr(fact, field_name, None)
|
||||
if value:
|
||||
if isinstance(value, str):
|
||||
fact_obj[field_name] = value
|
||||
elif isinstance(value, datetime):
|
||||
fact_obj[field_name] = value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
formatted.append(fact_obj)
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ class RetrievalResult:
|
||||
chunk_id: str | None = None
|
||||
tags: list[str] | None = None # Visibility scope tags
|
||||
metadata: dict[str, str] | None = None # User-provided metadata
|
||||
proof_count: int | None = None # Number of supporting memories (observations only)
|
||||
|
||||
# Retrieval-specific scores (only one will be set depending on retrieval method)
|
||||
similarity: float | None = None # Semantic retrieval
|
||||
@@ -72,6 +73,7 @@ class RetrievalResult:
|
||||
chunk_id=row.get("chunk_id"),
|
||||
tags=row.get("tags"),
|
||||
metadata=row.get("metadata"),
|
||||
proof_count=row.get("proof_count"),
|
||||
similarity=row.get("similarity"),
|
||||
bm25_score=row.get("bm25_score"),
|
||||
activation=row.get("activation"),
|
||||
|
||||
@@ -29,6 +29,7 @@ from hindsight_api.models import RequestContext
|
||||
_ALL_TOOLS: frozenset[str] = frozenset(
|
||||
{
|
||||
"retain",
|
||||
"sync_retain",
|
||||
"recall",
|
||||
"reflect",
|
||||
"list_banks",
|
||||
@@ -139,6 +140,7 @@ def build_content_dict(
|
||||
metadata: dict[str, str] | None = None,
|
||||
document_id: str | None = None,
|
||||
strategy: str | None = None,
|
||||
update_mode: str | None = None,
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
"""Build a content dict for retain operations.
|
||||
|
||||
@@ -150,6 +152,7 @@ def build_content_dict(
|
||||
metadata: Optional key-value metadata to attach to the memory
|
||||
document_id: Optional document ID to associate the memory with
|
||||
strategy: Optional named retain strategy override (e.g., 'exact', 'verbose')
|
||||
update_mode: How to handle existing documents ('replace' or 'append')
|
||||
|
||||
Returns:
|
||||
Tuple of (content_dict, error_message). error_message is None if successful.
|
||||
@@ -184,6 +187,8 @@ def build_content_dict(
|
||||
content_dict["document_id"] = document_id
|
||||
if strategy is not None:
|
||||
content_dict["strategy"] = strategy
|
||||
if update_mode is not None:
|
||||
content_dict["update_mode"] = update_mode
|
||||
|
||||
return content_dict, None
|
||||
|
||||
@@ -202,6 +207,7 @@ def register_mcp_tools(
|
||||
"""
|
||||
tools_to_register = config.tools or {
|
||||
"retain",
|
||||
"sync_retain",
|
||||
"recall",
|
||||
"reflect",
|
||||
"list_banks",
|
||||
@@ -235,6 +241,9 @@ def register_mcp_tools(
|
||||
if "retain" in tools_to_register:
|
||||
_register_retain(mcp, memory, config)
|
||||
|
||||
if "sync_retain" in tools_to_register:
|
||||
_register_sync_retain(mcp, memory, config)
|
||||
|
||||
if "recall" in tools_to_register:
|
||||
_register_recall(mcp, memory, config)
|
||||
|
||||
@@ -539,6 +548,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||
document_id: str | None = None,
|
||||
bank_id: str | None = None,
|
||||
strategy: str | None = None,
|
||||
update_mode: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Args:
|
||||
@@ -550,12 +560,15 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||
document_id: Optional document ID to associate this memory with
|
||||
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
|
||||
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
|
||||
update_mode: How to handle existing documents with the same document_id. 'replace' (default) or 'append' (concatenates new content to existing).
|
||||
"""
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"status": "error", "message": "No bank_id configured"}
|
||||
|
||||
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
|
||||
content_dict, error = build_content_dict(
|
||||
content, context, timestamp, tags, metadata, document_id, strategy, update_mode
|
||||
)
|
||||
if error:
|
||||
return {"status": "error", "message": error}
|
||||
|
||||
@@ -590,6 +603,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||
metadata: dict[str, str] | None = None,
|
||||
document_id: str | None = None,
|
||||
strategy: str | None = None,
|
||||
update_mode: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Args:
|
||||
@@ -600,12 +614,15 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||
metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'})
|
||||
document_id: Optional document ID to associate this memory with
|
||||
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
|
||||
update_mode: How to handle existing documents with the same document_id. 'replace' (default) or 'append' (concatenates new content to existing).
|
||||
"""
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"status": "error", "message": "No bank_id configured"}
|
||||
|
||||
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
|
||||
content_dict, error = build_content_dict(
|
||||
content, context, timestamp, tags, metadata, document_id, strategy, update_mode
|
||||
)
|
||||
if error:
|
||||
return {"status": "error", "message": error}
|
||||
|
||||
@@ -630,6 +647,124 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig)
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
def _register_sync_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the sync_retain tool (synchronous retain that waits for completion)."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool()
|
||||
async def sync_retain(
|
||||
content: str,
|
||||
context: str = "general",
|
||||
timestamp: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
document_id: str | None = None,
|
||||
bank_id: str | None = None,
|
||||
strategy: str | None = None,
|
||||
) -> dict:
|
||||
"""Store information to long-term memory and wait for completion.
|
||||
|
||||
Unlike retain (which is asynchronous), this tool blocks until the memory
|
||||
is fully stored and immediately available for recall.
|
||||
|
||||
Args:
|
||||
content: The fact/memory to store (be specific and include relevant details)
|
||||
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
|
||||
timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking.
|
||||
tags: Optional tags for scoped visibility filtering (e.g., ['project:alpha', 'user:123'])
|
||||
metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'})
|
||||
document_id: Optional document ID to associate this memory with
|
||||
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
|
||||
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
|
||||
"""
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"status": "error", "message": "No bank_id configured"}
|
||||
|
||||
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
|
||||
if error:
|
||||
return {"status": "error", "message": error}
|
||||
|
||||
request_context = _get_request_context(config)
|
||||
|
||||
try:
|
||||
result = await memory.retain_batch_async(
|
||||
bank_id=target_bank,
|
||||
contents=[content_dict],
|
||||
request_context=request_context,
|
||||
strategy=content_dict.pop("strategy", None),
|
||||
)
|
||||
memory_ids = [uid for batch in result for uid in batch]
|
||||
return {
|
||||
"status": "completed",
|
||||
"message": "Memory stored successfully",
|
||||
"memory_ids": memory_ids,
|
||||
}
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Sync retain rejected: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sync retain: {e}", exc_info=True)
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool()
|
||||
async def sync_retain(
|
||||
content: str,
|
||||
context: str = "general",
|
||||
timestamp: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
document_id: str | None = None,
|
||||
strategy: str | None = None,
|
||||
) -> dict:
|
||||
"""Store information to long-term memory and wait for completion.
|
||||
|
||||
Unlike retain (which is asynchronous), this tool blocks until the memory
|
||||
is fully stored and immediately available for recall.
|
||||
|
||||
Args:
|
||||
content: The fact/memory to store (be specific and include relevant details)
|
||||
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
|
||||
timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking.
|
||||
tags: Optional tags for scoped visibility filtering (e.g., ['project:alpha', 'user:123'])
|
||||
metadata: Optional key-value metadata to attach (e.g., {'source': 'slack', 'channel': 'general'})
|
||||
document_id: Optional document ID to associate this memory with
|
||||
strategy: Optional named retain strategy (e.g., 'exact' for verbatim storage). Strategies are defined in the bank config.
|
||||
"""
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"status": "error", "message": "No bank_id configured"}
|
||||
|
||||
content_dict, error = build_content_dict(content, context, timestamp, tags, metadata, document_id, strategy)
|
||||
if error:
|
||||
return {"status": "error", "message": error}
|
||||
|
||||
request_context = _get_request_context(config)
|
||||
|
||||
try:
|
||||
result = await memory.retain_batch_async(
|
||||
bank_id=target_bank,
|
||||
contents=[content_dict],
|
||||
request_context=request_context,
|
||||
strategy=content_dict.pop("strategy", None),
|
||||
)
|
||||
memory_ids = [uid for batch in result for uid in batch]
|
||||
return {
|
||||
"status": "completed",
|
||||
"message": "Memory stored successfully",
|
||||
"memory_ids": memory_ids,
|
||||
}
|
||||
except OperationValidationError as e:
|
||||
logger.warning(f"Sync retain rejected: {e}")
|
||||
return {"status": "error", "message": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error in sync retain: {e}", exc_info=True)
|
||||
return {"status": "error", "message": str(e)}
|
||||
|
||||
|
||||
def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the recall tool."""
|
||||
description = config.recall_description or DEFAULT_MCP_RECALL_DESCRIPTION
|
||||
|
||||
@@ -252,6 +252,9 @@ class MetricsCollector(MetricsCollectorBase):
|
||||
|
||||
def __init__(self):
|
||||
self.meter = get_meter()
|
||||
from .config import get_config
|
||||
|
||||
self._include_bank_id = get_config().metrics_include_bank_id
|
||||
|
||||
# Operation latency histogram (in seconds)
|
||||
# Records duration of retain, recall, reflect operations
|
||||
@@ -332,10 +335,11 @@ class MetricsCollector(MetricsCollectorBase):
|
||||
start_time = time.time()
|
||||
attributes = {
|
||||
"operation": operation,
|
||||
"bank_id": bank_id,
|
||||
"source": source,
|
||||
"tenant": _get_tenant(),
|
||||
}
|
||||
if self._include_bank_id:
|
||||
attributes["bank_id"] = bank_id
|
||||
if budget:
|
||||
attributes["budget"] = budget
|
||||
if max_tokens:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api-slim"
|
||||
version = "0.4.22"
|
||||
version = "0.5.0"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -21,7 +21,7 @@ dependencies = [
|
||||
"sqlalchemy>=2.0.44",
|
||||
"alembic>=1.17.1",
|
||||
"pgvector>=0.4.1",
|
||||
"greenlet>=3.2.4",
|
||||
"greenlet>=3.2.4,<3.4.0", # 3.4.0 lacks arm64 wheels for manylinux_2_41
|
||||
"psycopg2-binary>=2.9.11",
|
||||
"tiktoken>=0.12.0",
|
||||
"httpx>=0.27.0",
|
||||
@@ -40,7 +40,7 @@ dependencies = [
|
||||
"anthropic>=0.40.0",
|
||||
"typer>=0.9.0",
|
||||
"cohere>=5.0.0",
|
||||
"litellm>=1.0.0,<=1.82.6", # 1.82.7+ contains a supply chain attack (malicious .pth credential stealer)
|
||||
"litellm>=1.83.0", # 1.82.7/1.82.8 had a supply chain compromise (yanked); 1.83.0+ also fixes GHSA-jjhc-v7c2-5hh6 / GHSA-53mr-6c8q-9789
|
||||
"markitdown[pdf,docx,pptx,xlsx,xls]>=0.1.4", # File to markdown conversion
|
||||
"obstore>=0.4.0", # S3/GCS/Azure object storage client (Rust-backed)
|
||||
"winloop>=0.1.0; sys_platform == 'win32'",
|
||||
@@ -78,6 +78,11 @@ local-ml = [
|
||||
"mlx-lm>=0.31.1",
|
||||
"safetensors>=0.6.2",
|
||||
]
|
||||
local-llm = [
|
||||
# Built-in llama.cpp inference for fully offline operation
|
||||
"llama-cpp-python[server]>=0.3.0",
|
||||
"huggingface-hub>=0.20.0",
|
||||
]
|
||||
embedded-db = [
|
||||
"pg0-embedded>=0.11.0",
|
||||
]
|
||||
|
||||
@@ -34,7 +34,12 @@ async def test_submit_async_retain_includes_document_tags_in_task_payload():
|
||||
contents = [{"content": "Async retain payload test."}]
|
||||
document_tags = ["scope:tools", "user:alice"]
|
||||
|
||||
with patch("hindsight_api.engine.memory_engine.bank_utils.get_bank_profile", new_callable=AsyncMock):
|
||||
# Return (profile, created=False) so the default-template-on-create hook is skipped.
|
||||
with patch(
|
||||
"hindsight_api.engine.memory_engine.bank_utils.get_or_create_bank_profile",
|
||||
new_callable=AsyncMock,
|
||||
return_value=(MagicMock(), False),
|
||||
):
|
||||
result = await MemoryEngine.submit_async_retain(
|
||||
engine,
|
||||
bank_id="bank-1",
|
||||
|
||||
@@ -598,3 +598,182 @@ class TestExport:
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["version"] == "1"
|
||||
|
||||
|
||||
class TestDefaultBankTemplateEnvVar:
|
||||
"""Tests for HINDSIGHT_API_DEFAULT_BANK_TEMPLATE — a server-level env var
|
||||
whose manifest is applied automatically to every newly-created bank."""
|
||||
|
||||
@pytest.fixture
|
||||
def default_template(self):
|
||||
return {
|
||||
"version": "1",
|
||||
"bank": {
|
||||
"reflect_mission": "default-env-mission",
|
||||
"retain_extraction_mode": "verbose",
|
||||
"disposition_empathy": 5,
|
||||
"disposition_skepticism": 1,
|
||||
},
|
||||
"mental_models": [
|
||||
{
|
||||
"id": "default-env-model",
|
||||
"name": "Default Env Model",
|
||||
"source_query": "What is the default?",
|
||||
},
|
||||
],
|
||||
"directives": [
|
||||
{
|
||||
"name": "Default Env Directive",
|
||||
"content": "Follow the default behavior.",
|
||||
"priority": 7,
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def _patched_default_template(self, monkeypatch, default_template):
|
||||
"""Install the default template on the already-initialized global config.
|
||||
|
||||
We can't rely on env-var resolution here: MemoryEngine (and its
|
||||
ConfigResolver) snapshot the global config at fixture init time.
|
||||
Patching the field directly keeps the test deterministic while still
|
||||
exercising the same code path that reads `get_config().default_bank_template`.
|
||||
"""
|
||||
from hindsight_api.config import _get_raw_config
|
||||
|
||||
raw = _get_raw_config()
|
||||
monkeypatch.setattr(raw, "default_bank_template", default_template)
|
||||
yield default_template
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_template_applied_on_new_bank(
|
||||
self, api_client, bank_id, _patched_default_template
|
||||
):
|
||||
"""Creating a new bank applies the default template (config + mental models + directives)."""
|
||||
# Trigger bank auto-creation via GET profile
|
||||
resp = await api_client.put(f"/v1/default/banks/{bank_id}", json={})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# Config from template should be present as bank overrides
|
||||
config_resp = await api_client.get(f"/v1/default/banks/{bank_id}/config")
|
||||
assert config_resp.status_code == 200
|
||||
overrides = config_resp.json()["overrides"]
|
||||
assert overrides["reflect_mission"] == "default-env-mission"
|
||||
assert overrides["retain_extraction_mode"] == "verbose"
|
||||
assert overrides["disposition_empathy"] == 5
|
||||
assert overrides["disposition_skepticism"] == 1
|
||||
|
||||
# Mental model from template should exist
|
||||
mm_resp = await api_client.get(f"/v1/default/banks/{bank_id}/mental-models/default-env-model")
|
||||
assert mm_resp.status_code == 200
|
||||
assert mm_resp.json()["name"] == "Default Env Model"
|
||||
|
||||
# Directive from template should exist
|
||||
dir_resp = await api_client.get(f"/v1/default/banks/{bank_id}/directives")
|
||||
assert dir_resp.status_code == 200
|
||||
names = [d["name"] for d in dir_resp.json()["items"]]
|
||||
assert "Default Env Directive" in names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_template_overrides_env_config_defaults(
|
||||
self, api_client, bank_id, monkeypatch, default_template
|
||||
):
|
||||
"""Fields set by the default template override server-level env-var defaults.
|
||||
|
||||
We point both HINDSIGHT_API_RETAIN_EXTRACTION_MODE (env) and the
|
||||
default template at different values, then confirm the template wins
|
||||
via the per-bank config overrides layer (highest precedence).
|
||||
"""
|
||||
from hindsight_api.config import _get_raw_config
|
||||
|
||||
raw = _get_raw_config()
|
||||
# Simulate an env-level default of "concise", overridden by a template that sets "verbose".
|
||||
monkeypatch.setattr(raw, "retain_extraction_mode", "concise")
|
||||
monkeypatch.setattr(raw, "default_bank_template", default_template)
|
||||
|
||||
resp = await api_client.put(f"/v1/default/banks/{bank_id}", json={})
|
||||
assert resp.status_code == 200
|
||||
|
||||
config_resp = await api_client.get(f"/v1/default/banks/{bank_id}/config")
|
||||
overrides = config_resp.json()["overrides"]
|
||||
# Template value wins at the bank-override layer.
|
||||
assert overrides["retain_extraction_mode"] == "verbose"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_template_not_reapplied_on_existing_bank(
|
||||
self, api_client, bank_id, _patched_default_template
|
||||
):
|
||||
"""Template only applies on FIRST creation; subsequent puts are no-ops."""
|
||||
# First hit creates the bank and applies the template
|
||||
resp = await api_client.put(f"/v1/default/banks/{bank_id}", json={})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# User explicitly overrides a template-set field
|
||||
patch_resp = await api_client.patch(
|
||||
f"/v1/default/banks/{bank_id}/config",
|
||||
json={"updates": {"reflect_mission": "user-override"}},
|
||||
)
|
||||
assert patch_resp.status_code == 200
|
||||
|
||||
# Second put — template must NOT be reapplied (would clobber the override)
|
||||
resp = await api_client.put(f"/v1/default/banks/{bank_id}", json={})
|
||||
assert resp.status_code == 200
|
||||
|
||||
config_resp = await api_client.get(f"/v1/default/banks/{bank_id}/config")
|
||||
assert config_resp.json()["overrides"]["reflect_mission"] == "user-override"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_template_unset_is_noop(self, api_client, bank_id):
|
||||
"""With the env var unset (fixture default), bank creation behaves as before."""
|
||||
resp = await api_client.put(f"/v1/default/banks/{bank_id}", json={})
|
||||
assert resp.status_code == 200
|
||||
|
||||
# No template = no overrides
|
||||
config_resp = await api_client.get(f"/v1/default/banks/{bank_id}/config")
|
||||
assert config_resp.json()["overrides"] == {}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_template_malformed_is_swallowed(
|
||||
self, api_client, bank_id, monkeypatch
|
||||
):
|
||||
"""A malformed default template is logged and ignored — bank creation still succeeds."""
|
||||
from hindsight_api.config import _get_raw_config
|
||||
|
||||
raw = _get_raw_config()
|
||||
# Wrong version number fails Pydantic validation.
|
||||
monkeypatch.setattr(raw, "default_bank_template", {"version": "999"})
|
||||
|
||||
resp = await api_client.put(f"/v1/default/banks/{bank_id}", json={})
|
||||
# Bank creation must not fail even though the template is broken.
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_parse_default_bank_template_valid_json(self, monkeypatch):
|
||||
"""_parse_default_bank_template parses a valid JSON object env var."""
|
||||
from hindsight_api.config import _parse_default_bank_template
|
||||
|
||||
parsed = _parse_default_bank_template('{"version": "1", "bank": {"disposition_empathy": 4}}')
|
||||
assert parsed == {"version": "1", "bank": {"disposition_empathy": 4}}
|
||||
|
||||
def test_parse_default_bank_template_none_or_empty(self):
|
||||
"""Unset / empty env var resolves to None."""
|
||||
from hindsight_api.config import _parse_default_bank_template
|
||||
|
||||
assert _parse_default_bank_template(None) is None
|
||||
assert _parse_default_bank_template("") is None
|
||||
assert _parse_default_bank_template(" ") is None
|
||||
|
||||
def test_parse_default_bank_template_invalid_json_raises(self):
|
||||
"""Invalid JSON fails fast with a clear error."""
|
||||
from hindsight_api.config import _parse_default_bank_template
|
||||
|
||||
with pytest.raises(ValueError, match="HINDSIGHT_API_DEFAULT_BANK_TEMPLATE"):
|
||||
_parse_default_bank_template("not-json")
|
||||
|
||||
def test_parse_default_bank_template_non_object_raises(self):
|
||||
"""Non-object JSON (e.g. array, string) fails fast."""
|
||||
from hindsight_api.config import _parse_default_bank_template
|
||||
|
||||
with pytest.raises(ValueError, match="expected a JSON object"):
|
||||
_parse_default_bank_template("[1, 2, 3]")
|
||||
with pytest.raises(ValueError, match="expected a JSON object"):
|
||||
_parse_default_bank_template('"just a string"')
|
||||
|
||||
@@ -7,7 +7,6 @@ relevance score, independent of the cross-encoder model's score calibration.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -23,13 +22,18 @@ def _make_result(
|
||||
occurred_start: datetime | None = None,
|
||||
temporal_proximity: float | None = None,
|
||||
) -> ScoredResult:
|
||||
retrieval = MagicMock(spec=RetrievalResult)
|
||||
retrieval.occurred_start = occurred_start
|
||||
retrieval.temporal_proximity = temporal_proximity
|
||||
retrieval = RetrievalResult(
|
||||
id="test",
|
||||
text="test",
|
||||
fact_type="world",
|
||||
occurred_start=occurred_start,
|
||||
temporal_proximity=temporal_proximity,
|
||||
)
|
||||
|
||||
candidate = MagicMock(spec=MergedCandidate)
|
||||
candidate.retrieval = retrieval
|
||||
candidate.rrf_score = 0.05
|
||||
candidate = MergedCandidate(
|
||||
retrieval=retrieval,
|
||||
rrf_score=0.05,
|
||||
)
|
||||
|
||||
return ScoredResult(
|
||||
candidate=candidate,
|
||||
|
||||
@@ -139,3 +139,76 @@ async def test_retain_llm_max_retries_overrides_global():
|
||||
assert facts == []
|
||||
# Verify it retried exactly retain_llm_max_retries times
|
||||
assert llm_config.call.call_count == 5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_event_date_with_empty_facts_no_crash():
|
||||
"""
|
||||
When event_date is None and the LLM returns an empty facts list,
|
||||
the debug log should not crash with AttributeError on .isoformat().
|
||||
|
||||
Regression test for https://github.com/vectorize-io/hindsight/issues/874
|
||||
"""
|
||||
from hindsight_api.engine.retain.fact_extraction import _extract_facts_from_chunk
|
||||
|
||||
config = _make_config(llm_max_retries=1)
|
||||
|
||||
# LLM returns a valid dict but with no facts — triggers the debug log path
|
||||
llm_config = _make_llm_config(mock_response={"facts": []})
|
||||
|
||||
with patch(
|
||||
"hindsight_api.engine.retain.fact_extraction._build_extraction_prompt_and_schema",
|
||||
return_value=("system prompt", MagicMock()),
|
||||
):
|
||||
facts, usage = await _extract_facts_from_chunk(
|
||||
chunk="A plain text document with no timestamp.",
|
||||
chunk_index=0,
|
||||
total_chunks=1,
|
||||
event_date=None,
|
||||
context="",
|
||||
llm_config=llm_config,
|
||||
config=config,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
assert facts == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_event_date_with_valid_facts_no_crash():
|
||||
"""
|
||||
When event_date is None but the LLM returns valid facts,
|
||||
extraction should succeed without errors.
|
||||
"""
|
||||
from hindsight_api.engine.retain.fact_extraction import _extract_facts_from_chunk
|
||||
|
||||
config = _make_config(llm_max_retries=1)
|
||||
|
||||
llm_config = _make_llm_config(mock_response={
|
||||
"facts": [
|
||||
{
|
||||
"what": "Alice visited Paris",
|
||||
"when": "2023",
|
||||
"who": "Alice",
|
||||
"why": "vacation",
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
with patch(
|
||||
"hindsight_api.engine.retain.fact_extraction._build_extraction_prompt_and_schema",
|
||||
return_value=("system prompt", MagicMock()),
|
||||
):
|
||||
facts, usage = await _extract_facts_from_chunk(
|
||||
chunk="Alice visited Paris in 2023.",
|
||||
chunk_index=0,
|
||||
total_chunks=1,
|
||||
event_date=None,
|
||||
context="",
|
||||
llm_config=llm_config,
|
||||
config=config,
|
||||
agent_name="test-agent",
|
||||
)
|
||||
|
||||
assert len(facts) == 1
|
||||
assert "Alice visited Paris" in facts[0].fact
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Tests for format_facts_for_prompt in think_utils.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from hindsight_api.engine.response_models import MemoryFact
|
||||
from hindsight_api.engine.search.think_utils import format_facts_for_prompt
|
||||
|
||||
|
||||
def test_format_facts_includes_temporal_fields():
|
||||
"""All temporal fields (occurred_start, occurred_end, mentioned_at) should appear in the JSON."""
|
||||
facts = [
|
||||
MemoryFact(
|
||||
id="fact-1",
|
||||
text="Team offsite in February",
|
||||
fact_type="experience",
|
||||
occurred_start="2024-02-01T00:00:00Z",
|
||||
occurred_end="2024-02-28T23:59:59Z",
|
||||
mentioned_at="2024-03-05T10:00:00Z",
|
||||
)
|
||||
]
|
||||
result = json.loads(format_facts_for_prompt(facts))
|
||||
assert len(result) == 1
|
||||
assert result[0]["text"] == "Team offsite in February"
|
||||
assert result[0]["occurred_start"] == "2024-02-01T00:00:00Z"
|
||||
assert result[0]["occurred_end"] == "2024-02-28T23:59:59Z"
|
||||
assert result[0]["mentioned_at"] == "2024-03-05T10:00:00Z"
|
||||
|
||||
|
||||
def test_format_facts_omits_null_temporal_fields():
|
||||
"""Null temporal fields should not appear in the JSON."""
|
||||
facts = [
|
||||
MemoryFact(
|
||||
id="fact-2",
|
||||
text="The sky is blue",
|
||||
fact_type="world",
|
||||
)
|
||||
]
|
||||
result = json.loads(format_facts_for_prompt(facts))
|
||||
assert len(result) == 1
|
||||
assert "occurred_start" not in result[0]
|
||||
assert "occurred_end" not in result[0]
|
||||
assert "mentioned_at" not in result[0]
|
||||
|
||||
|
||||
def test_format_facts_partial_temporal_fields():
|
||||
"""Only non-null temporal fields should appear."""
|
||||
facts = [
|
||||
MemoryFact(
|
||||
id="fact-3",
|
||||
text="Meeting happened",
|
||||
fact_type="experience",
|
||||
occurred_start="2024-06-01T09:00:00Z",
|
||||
)
|
||||
]
|
||||
result = json.loads(format_facts_for_prompt(facts))
|
||||
assert result[0]["occurred_start"] == "2024-06-01T09:00:00Z"
|
||||
assert "occurred_end" not in result[0]
|
||||
assert "mentioned_at" not in result[0]
|
||||
|
||||
|
||||
def test_format_facts_empty_list():
|
||||
"""Empty list should return '[]'."""
|
||||
assert format_facts_for_prompt([]) == "[]"
|
||||
@@ -0,0 +1,336 @@
|
||||
"""
|
||||
Tests for Google embeddings implementation (Gemini API + Vertex AI).
|
||||
|
||||
These tests cover:
|
||||
1. Initialization (Gemini API key, Vertex AI with ADC/service account)
|
||||
2. Dimension detection via test embedding
|
||||
3. Output dimensionality configuration
|
||||
4. Encode (single text, multiple texts, batching, empty list, uninitialized)
|
||||
5. Provider name and model name normalization
|
||||
6. Factory function (create from env, validation errors)
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.config import (
|
||||
ENV_EMBEDDINGS_GEMINI_API_KEY,
|
||||
ENV_EMBEDDINGS_PROVIDER,
|
||||
HindsightConfig,
|
||||
)
|
||||
from hindsight_api.engine.embeddings import GeminiEmbeddings, create_embeddings_from_env
|
||||
|
||||
|
||||
def _make_mock_embedding(values: list[float]) -> MagicMock:
|
||||
emb = MagicMock()
|
||||
emb.values = values
|
||||
return emb
|
||||
|
||||
|
||||
def _make_mock_embed_result(embeddings_data: list[list[float]]) -> MagicMock:
|
||||
result = MagicMock()
|
||||
result.embeddings = [_make_mock_embedding(v) for v in embeddings_data]
|
||||
return result
|
||||
|
||||
|
||||
def _make_mock_genai(embed_result: Any = None) -> MagicMock:
|
||||
if embed_result is None:
|
||||
embed_result = _make_mock_embed_result([[0.1] * 768])
|
||||
mock_genai = MagicMock()
|
||||
mock_client = MagicMock()
|
||||
mock_client.models.embed_content = MagicMock(return_value=embed_result)
|
||||
mock_genai.Client = MagicMock(return_value=mock_client)
|
||||
return mock_genai
|
||||
|
||||
|
||||
def _make_mock_google_module(mock_genai: MagicMock) -> MagicMock:
|
||||
mod = MagicMock()
|
||||
mod.genai = mock_genai
|
||||
mod.genai.types.EmbedContentConfig = MagicMock(side_effect=lambda **kw: MagicMock(**kw))
|
||||
return mod
|
||||
|
||||
|
||||
def _patch_google_import(mock_genai: MagicMock):
|
||||
original_import = __import__
|
||||
|
||||
def mock_import(name, *args, **kwargs):
|
||||
if name == "google":
|
||||
return _make_mock_google_module(mock_genai)
|
||||
if name == "google.genai":
|
||||
return mock_genai
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
return patch("builtins.__import__", side_effect=mock_import)
|
||||
|
||||
|
||||
class TestGeminiEmbeddings:
|
||||
"""Unit tests for GeminiEmbeddings with mocked google.genai."""
|
||||
|
||||
async def test_initialization_api_key_success(self):
|
||||
"""Test successful Gemini API key initialization."""
|
||||
mock_genai = _make_mock_genai()
|
||||
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
|
||||
|
||||
with _patch_google_import(mock_genai):
|
||||
await emb.initialize()
|
||||
|
||||
assert emb._client is not None
|
||||
assert emb.dimension == 768
|
||||
assert emb.provider_name == "google"
|
||||
assert emb._is_vertexai is False
|
||||
mock_genai.Client.return_value.models.embed_content.assert_called_once()
|
||||
|
||||
async def test_initialization_vertexai_success(self):
|
||||
"""Test successful Vertex AI initialization."""
|
||||
mock_genai = _make_mock_genai()
|
||||
emb = GeminiEmbeddings(
|
||||
model="gemini-embedding-001",
|
||||
vertexai_project_id="test-project",
|
||||
vertexai_region="us-central1",
|
||||
)
|
||||
|
||||
with _patch_google_import(mock_genai):
|
||||
await emb.initialize()
|
||||
|
||||
assert emb._client is not None
|
||||
assert emb.dimension == 768
|
||||
assert emb.provider_name == "google"
|
||||
assert emb._is_vertexai is True
|
||||
mock_genai.Client.assert_called_once_with(
|
||||
vertexai=True,
|
||||
project="test-project",
|
||||
location="us-central1",
|
||||
)
|
||||
|
||||
async def test_initialization_missing_api_key(self):
|
||||
"""Test that missing API key raises ValueError when no vertexai_project_id."""
|
||||
mock_genai = _make_mock_genai()
|
||||
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key=None)
|
||||
|
||||
with _patch_google_import(mock_genai):
|
||||
with pytest.raises(ValueError, match="requires an API key"):
|
||||
await emb.initialize()
|
||||
|
||||
async def test_initialization_vertexai_missing_project_id(self):
|
||||
"""Test that Vertex AI mode requires project_id."""
|
||||
mock_genai = _make_mock_genai()
|
||||
emb = GeminiEmbeddings(model="gemini-embedding-001", vertexai_project_id="temp")
|
||||
emb.vertexai_project_id = None # Simulate misconfiguration
|
||||
|
||||
with _patch_google_import(mock_genai):
|
||||
with pytest.raises(ValueError, match="is required for Vertex AI"):
|
||||
await emb.initialize()
|
||||
|
||||
async def test_initialization_idempotent(self):
|
||||
"""Test that calling initialize() twice is a no-op."""
|
||||
mock_genai = _make_mock_genai()
|
||||
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
|
||||
|
||||
with _patch_google_import(mock_genai):
|
||||
await emb.initialize()
|
||||
first_client = emb._client
|
||||
await emb.initialize()
|
||||
assert emb._client is first_client
|
||||
|
||||
async def test_dimension_detection_via_test_embedding(self):
|
||||
"""Test that dimension is detected via a test embedding call."""
|
||||
test_embed = _make_mock_embed_result([[0.5] * 256])
|
||||
mock_genai = _make_mock_genai(embed_result=test_embed)
|
||||
emb = GeminiEmbeddings(model="some-new-model", api_key="test-key")
|
||||
|
||||
with _patch_google_import(mock_genai):
|
||||
await emb.initialize()
|
||||
|
||||
assert emb.dimension == 256
|
||||
|
||||
async def test_output_dimensionality(self):
|
||||
"""Test that output_dimensionality is passed via EmbedContentConfig."""
|
||||
test_embed = _make_mock_embed_result([[0.1] * 256])
|
||||
mock_genai = _make_mock_genai(embed_result=test_embed)
|
||||
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key", output_dimensionality=256)
|
||||
|
||||
with _patch_google_import(mock_genai):
|
||||
await emb.initialize()
|
||||
|
||||
assert emb.dimension == 256
|
||||
assert emb._embed_config is not None
|
||||
call_kwargs = mock_genai.Client.return_value.models.embed_content.call_args
|
||||
assert "config" in call_kwargs.kwargs
|
||||
|
||||
async def test_no_output_dimensionality(self):
|
||||
"""Test that no EmbedContentConfig is built when output_dimensionality is None."""
|
||||
mock_genai = _make_mock_genai()
|
||||
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key", output_dimensionality=None)
|
||||
|
||||
with _patch_google_import(mock_genai):
|
||||
await emb.initialize()
|
||||
|
||||
assert emb._embed_config is None
|
||||
call_kwargs = mock_genai.Client.return_value.models.embed_content.call_args
|
||||
assert "config" not in call_kwargs.kwargs
|
||||
|
||||
def test_auto_detect_vertexai(self):
|
||||
"""Test that _is_vertexai is auto-detected from vertexai_project_id."""
|
||||
assert GeminiEmbeddings(model="m", api_key="k")._is_vertexai is False
|
||||
assert GeminiEmbeddings(model="m", vertexai_project_id="p")._is_vertexai is True
|
||||
|
||||
def test_encode_single_text(self):
|
||||
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
|
||||
mock_client = MagicMock()
|
||||
mock_client.models.embed_content = MagicMock(return_value=_make_mock_embed_result([[0.1, 0.2, 0.3]]))
|
||||
emb._client = mock_client
|
||||
emb._dimension = 3
|
||||
|
||||
assert emb.encode(["hello"]) == [[0.1, 0.2, 0.3]]
|
||||
|
||||
def test_encode_multiple_texts(self):
|
||||
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
|
||||
mock_client = MagicMock()
|
||||
mock_client.models.embed_content = MagicMock(
|
||||
return_value=_make_mock_embed_result([[0.1, 0.2], [0.3, 0.4], [0.5, 0.6]])
|
||||
)
|
||||
emb._client = mock_client
|
||||
emb._dimension = 2
|
||||
|
||||
result = emb.encode(["a", "b", "c"])
|
||||
assert len(result) == 3
|
||||
assert result[1] == [0.3, 0.4]
|
||||
|
||||
def test_encode_batching(self):
|
||||
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key", batch_size=2)
|
||||
mock_client = MagicMock()
|
||||
mock_client.models.embed_content = MagicMock(
|
||||
side_effect=[_make_mock_embed_result([[0.1], [0.2]]), _make_mock_embed_result([[0.3]])]
|
||||
)
|
||||
emb._client = mock_client
|
||||
emb._dimension = 1
|
||||
|
||||
assert emb.encode(["a", "b", "c"]) == [[0.1], [0.2], [0.3]]
|
||||
assert mock_client.models.embed_content.call_count == 2
|
||||
|
||||
def test_encode_passes_config(self):
|
||||
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
|
||||
mock_client = MagicMock()
|
||||
mock_client.models.embed_content = MagicMock(return_value=_make_mock_embed_result([[0.1, 0.2]]))
|
||||
emb._client = mock_client
|
||||
emb._dimension = 2
|
||||
emb._embed_config = MagicMock()
|
||||
|
||||
emb.encode(["hello"])
|
||||
assert mock_client.models.embed_content.call_args.kwargs["config"] is emb._embed_config
|
||||
|
||||
def test_encode_empty_list(self):
|
||||
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
|
||||
emb._client = MagicMock()
|
||||
emb._dimension = 768
|
||||
assert emb.encode([]) == []
|
||||
|
||||
def test_encode_before_initialization(self):
|
||||
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
emb.encode(["test"])
|
||||
|
||||
def test_dimension_before_initialization(self):
|
||||
emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key")
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
_ = emb.dimension
|
||||
|
||||
def test_provider_name_always_google(self):
|
||||
assert GeminiEmbeddings(model="m", api_key="k").provider_name == "google"
|
||||
assert GeminiEmbeddings(model="m", vertexai_project_id="p").provider_name == "google"
|
||||
|
||||
def test_vertexai_strips_google_prefix(self):
|
||||
mock_genai = _make_mock_genai()
|
||||
emb = GeminiEmbeddings(model="google/gemini-embedding-001", vertexai_project_id="test-project")
|
||||
emb._init_vertexai(mock_genai)
|
||||
assert emb.model == "gemini-embedding-001"
|
||||
|
||||
def test_default_region(self):
|
||||
emb = GeminiEmbeddings(model="m", vertexai_project_id="proj")
|
||||
assert emb.vertexai_region == "us-central1"
|
||||
|
||||
def test_custom_region(self):
|
||||
emb = GeminiEmbeddings(model="m", vertexai_project_id="proj", vertexai_region="europe-west1")
|
||||
assert emb.vertexai_region == "europe-west1"
|
||||
|
||||
|
||||
class TestGeminiEmbeddingsFactory:
|
||||
"""Tests for create_embeddings_from_env() with 'google' provider."""
|
||||
|
||||
def _make_config(self, **overrides) -> HindsightConfig:
|
||||
from dataclasses import fields
|
||||
|
||||
defaults = {}
|
||||
for f in fields(HindsightConfig):
|
||||
if f.type == "str":
|
||||
defaults[f.name] = ""
|
||||
elif f.type == "str | None":
|
||||
defaults[f.name] = None
|
||||
elif f.type == "int":
|
||||
defaults[f.name] = 0
|
||||
elif f.type == "int | None":
|
||||
defaults[f.name] = None
|
||||
elif f.type == "float":
|
||||
defaults[f.name] = 0.0
|
||||
elif f.type == "float | None":
|
||||
defaults[f.name] = None
|
||||
elif f.type == "bool":
|
||||
defaults[f.name] = False
|
||||
elif f.type == "list | None":
|
||||
defaults[f.name] = None
|
||||
else:
|
||||
defaults[f.name] = None
|
||||
|
||||
defaults["embeddings_provider"] = "google"
|
||||
defaults["embeddings_gemini_api_key"] = "test-key"
|
||||
defaults["embeddings_gemini_model"] = "gemini-embedding-001"
|
||||
defaults["embeddings_gemini_output_dimensionality"] = 768
|
||||
defaults["embeddings_vertexai_project_id"] = None
|
||||
defaults["embeddings_vertexai_region"] = None
|
||||
defaults["embeddings_vertexai_service_account_key"] = None
|
||||
|
||||
defaults.update(overrides)
|
||||
return HindsightConfig(**defaults)
|
||||
|
||||
def test_create_with_api_key(self):
|
||||
config = self._make_config()
|
||||
with patch("hindsight_api.config.get_config", return_value=config):
|
||||
emb = create_embeddings_from_env()
|
||||
assert isinstance(emb, GeminiEmbeddings)
|
||||
assert emb.provider_name == "google"
|
||||
assert emb.api_key == "test-key"
|
||||
assert emb._is_vertexai is False
|
||||
|
||||
def test_create_with_vertexai(self):
|
||||
config = self._make_config(
|
||||
embeddings_gemini_api_key=None,
|
||||
embeddings_vertexai_project_id="my-project",
|
||||
embeddings_vertexai_region="us-east1",
|
||||
)
|
||||
with patch("hindsight_api.config.get_config", return_value=config):
|
||||
emb = create_embeddings_from_env()
|
||||
assert isinstance(emb, GeminiEmbeddings)
|
||||
assert emb._is_vertexai is True
|
||||
assert emb.api_key is None
|
||||
assert emb.vertexai_project_id == "my-project"
|
||||
|
||||
def test_create_missing_all_credentials(self):
|
||||
config = self._make_config(embeddings_gemini_api_key=None, embeddings_vertexai_project_id=None)
|
||||
with patch("hindsight_api.config.get_config", return_value=config):
|
||||
with pytest.raises(ValueError, match="is required"):
|
||||
create_embeddings_from_env()
|
||||
|
||||
def test_vertexai_takes_priority(self):
|
||||
config = self._make_config(embeddings_gemini_api_key="key", embeddings_vertexai_project_id="proj")
|
||||
with patch("hindsight_api.config.get_config", return_value=config):
|
||||
emb = create_embeddings_from_env()
|
||||
assert emb._is_vertexai is True
|
||||
assert emb.api_key is None
|
||||
|
||||
def test_create_with_custom_dimensionality(self):
|
||||
config = self._make_config(embeddings_gemini_output_dimensionality=256)
|
||||
with patch("hindsight_api.config.get_config", return_value=config):
|
||||
emb = create_embeddings_from_env()
|
||||
assert emb.output_dimensionality == 256
|
||||
@@ -0,0 +1,275 @@
|
||||
"""
|
||||
Tests for Google Discovery Engine cross-encoder (Ranking REST API).
|
||||
|
||||
These tests cover:
|
||||
1. Initialization (service account, ADC, missing project_id)
|
||||
2. Predict (single query, multiple queries, batching, empty pairs, uninitialized)
|
||||
3. Provider name
|
||||
4. Factory function (create from env, validation errors)
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from hindsight_api.config import (
|
||||
ENV_RERANKER_GOOGLE_PROJECT_ID,
|
||||
ENV_RERANKER_PROVIDER,
|
||||
HindsightConfig,
|
||||
)
|
||||
from hindsight_api.engine.cross_encoder import GoogleCrossEncoder, create_cross_encoder_from_env
|
||||
|
||||
|
||||
def _make_rank_response(records: list[tuple[str, float]]) -> dict:
|
||||
"""Build a JSON response matching the Discovery Engine REST API format."""
|
||||
return {"records": [{"id": rid, "score": score} for rid, score in records]}
|
||||
|
||||
|
||||
def _make_mock_httpx_client(responses: list[dict] | None = None) -> MagicMock:
|
||||
"""Create a mock httpx.Client that returns predefined responses."""
|
||||
mock_client = MagicMock(spec=httpx.Client)
|
||||
if responses:
|
||||
side_effects = []
|
||||
for resp_json in responses:
|
||||
mock_resp = MagicMock(spec=httpx.Response)
|
||||
mock_resp.json.return_value = resp_json
|
||||
mock_resp.raise_for_status.return_value = None
|
||||
side_effects.append(mock_resp)
|
||||
mock_client.post.side_effect = side_effects
|
||||
return mock_client
|
||||
|
||||
|
||||
def _make_mock_credentials() -> MagicMock:
|
||||
"""Create mock credentials with a valid token."""
|
||||
creds = MagicMock()
|
||||
creds.valid = True
|
||||
creds.token = "mock-token"
|
||||
return creds
|
||||
|
||||
|
||||
class TestGoogleCrossEncoder:
|
||||
"""Unit tests for GoogleCrossEncoder with mocked httpx + google-auth."""
|
||||
|
||||
async def test_initialization_adc_success(self):
|
||||
"""Test successful initialization with ADC (no service account key)."""
|
||||
mock_creds = _make_mock_credentials()
|
||||
|
||||
encoder = GoogleCrossEncoder(project_id="test-project")
|
||||
|
||||
with patch("google.auth.default", return_value=(mock_creds, "test-project")):
|
||||
await encoder.initialize()
|
||||
|
||||
assert encoder._client is not None
|
||||
assert encoder._credentials is mock_creds
|
||||
assert encoder.provider_name == "google"
|
||||
assert "test-project" in encoder._rank_url
|
||||
|
||||
async def test_initialization_service_account(self):
|
||||
"""Test initialization with service account key."""
|
||||
mock_creds = _make_mock_credentials()
|
||||
|
||||
encoder = GoogleCrossEncoder(
|
||||
project_id="test-project",
|
||||
service_account_key="/path/to/key.json",
|
||||
)
|
||||
|
||||
with patch(
|
||||
"google.oauth2.service_account.Credentials.from_service_account_file",
|
||||
return_value=mock_creds,
|
||||
):
|
||||
await encoder.initialize()
|
||||
|
||||
assert encoder._client is not None
|
||||
assert encoder._credentials is mock_creds
|
||||
|
||||
async def test_initialization_idempotent(self):
|
||||
"""Test that calling initialize() twice is a no-op."""
|
||||
mock_creds = _make_mock_credentials()
|
||||
encoder = GoogleCrossEncoder(project_id="test-project")
|
||||
|
||||
with patch("google.auth.default", return_value=(mock_creds, "test-project")):
|
||||
await encoder.initialize()
|
||||
first_client = encoder._client
|
||||
await encoder.initialize()
|
||||
assert encoder._client is first_client
|
||||
|
||||
async def test_predict_single_query(self):
|
||||
"""Test prediction with a single query and multiple documents."""
|
||||
mock_creds = _make_mock_credentials()
|
||||
mock_client = _make_mock_httpx_client([
|
||||
_make_rank_response([("1", 0.95), ("0", 0.30)]),
|
||||
])
|
||||
|
||||
encoder = GoogleCrossEncoder(project_id="test-project")
|
||||
with patch("google.auth.default", return_value=(mock_creds, "p")):
|
||||
await encoder.initialize()
|
||||
encoder._client = mock_client
|
||||
|
||||
scores = await encoder.predict([
|
||||
("What is AI?", "AI is artificial intelligence"),
|
||||
("What is AI?", "The sky is blue"),
|
||||
])
|
||||
|
||||
assert len(scores) == 2
|
||||
assert scores[0] == 0.30 # id="0" -> index 0
|
||||
assert scores[1] == 0.95 # id="1" -> index 1
|
||||
mock_client.post.assert_called_once()
|
||||
|
||||
async def test_predict_multiple_queries(self):
|
||||
"""Test prediction with multiple distinct queries."""
|
||||
mock_creds = _make_mock_credentials()
|
||||
mock_client = _make_mock_httpx_client([
|
||||
_make_rank_response([("0", 0.9), ("1", 0.1)]),
|
||||
_make_rank_response([("0", 0.8)]),
|
||||
])
|
||||
|
||||
encoder = GoogleCrossEncoder(project_id="test-project")
|
||||
with patch("google.auth.default", return_value=(mock_creds, "p")):
|
||||
await encoder.initialize()
|
||||
encoder._client = mock_client
|
||||
|
||||
scores = await encoder.predict([
|
||||
("Query A", "Doc A1"),
|
||||
("Query A", "Doc A2"),
|
||||
("Query B", "Doc B1"),
|
||||
])
|
||||
|
||||
assert len(scores) == 3
|
||||
assert scores[0] == 0.9
|
||||
assert scores[1] == 0.1
|
||||
assert scores[2] == 0.8
|
||||
assert mock_client.post.call_count == 2
|
||||
|
||||
async def test_predict_empty_pairs(self):
|
||||
"""Test that empty pairs returns empty list."""
|
||||
mock_creds = _make_mock_credentials()
|
||||
encoder = GoogleCrossEncoder(project_id="test-project")
|
||||
|
||||
with patch("google.auth.default", return_value=(mock_creds, "p")):
|
||||
await encoder.initialize()
|
||||
|
||||
scores = await encoder.predict([])
|
||||
assert scores == []
|
||||
|
||||
async def test_predict_not_initialized(self):
|
||||
"""Test that predict raises if not initialized."""
|
||||
encoder = GoogleCrossEncoder(project_id="test-project")
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
await encoder.predict([("q", "d")])
|
||||
|
||||
async def test_predict_batching(self):
|
||||
"""Test that >200 records are split into batches."""
|
||||
mock_creds = _make_mock_credentials()
|
||||
mock_client = _make_mock_httpx_client([
|
||||
_make_rank_response([(str(i), 0.5) for i in range(200)]),
|
||||
_make_rank_response([(str(i), 0.3) for i in range(50)]),
|
||||
])
|
||||
|
||||
encoder = GoogleCrossEncoder(project_id="test-project")
|
||||
with patch("google.auth.default", return_value=(mock_creds, "p")):
|
||||
await encoder.initialize()
|
||||
encoder._client = mock_client
|
||||
|
||||
pairs = [("same query", f"doc {i}") for i in range(250)]
|
||||
scores = await encoder.predict(pairs)
|
||||
|
||||
assert len(scores) == 250
|
||||
assert mock_client.post.call_count == 2
|
||||
|
||||
async def test_auth_header_sent(self):
|
||||
"""Test that Authorization header is sent with requests."""
|
||||
mock_creds = _make_mock_credentials()
|
||||
mock_creds.token = "test-bearer-token"
|
||||
mock_client = _make_mock_httpx_client([
|
||||
_make_rank_response([("0", 0.9)]),
|
||||
])
|
||||
|
||||
encoder = GoogleCrossEncoder(project_id="test-project")
|
||||
with patch("google.auth.default", return_value=(mock_creds, "p")):
|
||||
await encoder.initialize()
|
||||
encoder._client = mock_client
|
||||
|
||||
await encoder.predict([("q", "d")])
|
||||
|
||||
call_kwargs = mock_client.post.call_args
|
||||
assert call_kwargs.kwargs["headers"]["Authorization"] == "Bearer test-bearer-token"
|
||||
|
||||
def test_provider_name(self):
|
||||
assert GoogleCrossEncoder(project_id="p").provider_name == "google"
|
||||
|
||||
def test_default_model(self):
|
||||
encoder = GoogleCrossEncoder(project_id="p")
|
||||
assert encoder.model == "semantic-ranker-default-004"
|
||||
|
||||
def test_custom_model(self):
|
||||
encoder = GoogleCrossEncoder(project_id="p", model="semantic-ranker-fast-004")
|
||||
assert encoder.model == "semantic-ranker-fast-004"
|
||||
|
||||
def test_default_location(self):
|
||||
encoder = GoogleCrossEncoder(project_id="p")
|
||||
assert encoder.location == "global"
|
||||
|
||||
|
||||
class TestGoogleCrossEncoderFactory:
|
||||
"""Tests for create_cross_encoder_from_env() with 'google' provider."""
|
||||
|
||||
def _make_config(self, **overrides) -> HindsightConfig:
|
||||
from dataclasses import fields
|
||||
|
||||
defaults = {}
|
||||
for f in fields(HindsightConfig):
|
||||
if f.type == "str":
|
||||
defaults[f.name] = ""
|
||||
elif f.type == "str | None":
|
||||
defaults[f.name] = None
|
||||
elif f.type == "int":
|
||||
defaults[f.name] = 0
|
||||
elif f.type == "int | None":
|
||||
defaults[f.name] = None
|
||||
elif f.type == "float":
|
||||
defaults[f.name] = 0.0
|
||||
elif f.type == "float | None":
|
||||
defaults[f.name] = None
|
||||
elif f.type == "bool":
|
||||
defaults[f.name] = False
|
||||
elif f.type == "list | None":
|
||||
defaults[f.name] = None
|
||||
else:
|
||||
defaults[f.name] = None
|
||||
|
||||
defaults["reranker_provider"] = "google"
|
||||
defaults["reranker_google_model"] = "semantic-ranker-default-004"
|
||||
defaults["reranker_google_project_id"] = "test-project"
|
||||
defaults["reranker_google_service_account_key"] = None
|
||||
|
||||
defaults.update(overrides)
|
||||
return HindsightConfig(**defaults)
|
||||
|
||||
def test_create_with_project_id(self):
|
||||
config = self._make_config()
|
||||
with patch("hindsight_api.config.get_config", return_value=config):
|
||||
encoder = create_cross_encoder_from_env()
|
||||
assert isinstance(encoder, GoogleCrossEncoder)
|
||||
assert encoder.provider_name == "google"
|
||||
assert encoder.project_id == "test-project"
|
||||
assert encoder.service_account_key is None
|
||||
|
||||
def test_create_with_service_account(self):
|
||||
config = self._make_config(reranker_google_service_account_key="/path/to/key.json")
|
||||
with patch("hindsight_api.config.get_config", return_value=config):
|
||||
encoder = create_cross_encoder_from_env()
|
||||
assert isinstance(encoder, GoogleCrossEncoder)
|
||||
assert encoder.service_account_key == "/path/to/key.json"
|
||||
|
||||
def test_create_missing_project_id(self):
|
||||
config = self._make_config(reranker_google_project_id=None)
|
||||
with patch("hindsight_api.config.get_config", return_value=config):
|
||||
with pytest.raises(ValueError, match="is required"):
|
||||
create_cross_encoder_from_env()
|
||||
|
||||
def test_create_with_custom_model(self):
|
||||
config = self._make_config(reranker_google_model="semantic-ranker-fast-004")
|
||||
with patch("hindsight_api.config.get_config", return_value=config):
|
||||
encoder = create_cross_encoder_from_env()
|
||||
assert encoder.model == "semantic-ranker-fast-004"
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Tests for LATERAL entity fanout cap in graph expansion.
|
||||
|
||||
Verifies that the per-entity LIMIT in _expand_combined prevents high-fanout
|
||||
entities from exploding the self-join, while still returning entity-based
|
||||
graph results.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_high_fanout_entity_returns_results(memory, request_context):
|
||||
"""
|
||||
A high-fanout entity (appearing in many facts) should still produce
|
||||
graph retrieval results — the LATERAL cap limits rows per entity but
|
||||
does not drop the entity entirely.
|
||||
"""
|
||||
bank_id = f"test_fanout_cap_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Create many facts sharing one common entity ("Acme Corp") plus
|
||||
# a few with a unique entity so we can query for the unique one
|
||||
# and verify graph expansion finds siblings via "Acme Corp".
|
||||
contents = [
|
||||
# Target: unique entity "Zara" shares "Acme Corp" with the rest
|
||||
{
|
||||
"content": "Zara joined Acme Corp as a senior engineer last month",
|
||||
"context": "hr update",
|
||||
"entities": [{"text": "Zara"}, {"text": "Acme Corp"}],
|
||||
},
|
||||
]
|
||||
# Add many facts that all share "Acme Corp" — creates a high-fanout entity
|
||||
for i in range(60):
|
||||
contents.append(
|
||||
{
|
||||
"content": f"Employee {i} completed onboarding at Acme Corp in department {i % 5}",
|
||||
"context": "hr update",
|
||||
"entities": [{"text": f"Employee {i}"}, {"text": "Acme Corp"}],
|
||||
}
|
||||
)
|
||||
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=contents,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
|
||||
# Query for "Zara" — semantic search finds Zara's fact as a seed,
|
||||
# then graph expansion should find other Acme Corp facts via the
|
||||
# shared entity, even though "Acme Corp" has 60+ mentions.
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Zara",
|
||||
budget=Budget.HIGH,
|
||||
max_tokens=4096,
|
||||
enable_trace=True,
|
||||
request_context=request_context,
|
||||
_quiet=True,
|
||||
)
|
||||
|
||||
assert result.results is not None
|
||||
assert len(result.results) > 0
|
||||
|
||||
# Verify graph retrieval ran and found results
|
||||
retrieval_results = result.trace.get("retrieval_results", [])
|
||||
graph_results = [r for r in retrieval_results if r.get("method_name") == "graph"]
|
||||
assert len(graph_results) > 0, "Graph retrieval should have run"
|
||||
|
||||
# At least one graph result should contain Acme Corp content
|
||||
# (found via shared entity, not just semantic similarity)
|
||||
all_texts = [r.text for r in result.results]
|
||||
acme_found = any("Acme Corp" in t for t in all_texts)
|
||||
assert acme_found, "Should find Acme Corp facts via entity graph expansion"
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entity_expansion_timeout_fallback(memory, request_context):
|
||||
"""
|
||||
When graph_expansion_timeout is set very low, entity expansion should
|
||||
time out gracefully and fall back to semantic+causal links only,
|
||||
rather than failing the entire recall.
|
||||
"""
|
||||
bank_id = f"test_timeout_fallback_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{
|
||||
"content": "Alice works on the backend API at TechCorp",
|
||||
"context": "team info",
|
||||
"entities": [{"text": "Alice"}, {"text": "TechCorp"}],
|
||||
},
|
||||
{
|
||||
"content": "Bob maintains the frontend at TechCorp",
|
||||
"context": "team info",
|
||||
"entities": [{"text": "Bob"}, {"text": "TechCorp"}],
|
||||
},
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
from hindsight_api.config import _get_raw_config
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
|
||||
config = _get_raw_config()
|
||||
original_timeout = config.link_expansion_timeout
|
||||
|
||||
try:
|
||||
# Set an impossibly low timeout to force the fallback path
|
||||
config.link_expansion_timeout = 0.0001
|
||||
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice",
|
||||
budget=Budget.MID,
|
||||
max_tokens=2048,
|
||||
enable_trace=True,
|
||||
request_context=request_context,
|
||||
_quiet=True,
|
||||
)
|
||||
|
||||
# Recall should succeed even when entity expansion times out
|
||||
assert result.results is not None
|
||||
assert len(result.results) > 0
|
||||
|
||||
# Alice should still be found via semantic search
|
||||
result_texts = [r.text for r in result.results]
|
||||
alice_found = any("Alice" in t for t in result_texts)
|
||||
assert alice_found, "Should find Alice via semantic search despite graph timeout"
|
||||
finally:
|
||||
config.link_expansion_timeout = original_timeout
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_entity_limit_caps_expansion(memory, request_context):
|
||||
"""
|
||||
With graph_per_entity_limit set to a small value, entity expansion should
|
||||
still work but return fewer results from high-fanout entities.
|
||||
"""
|
||||
bank_id = f"test_per_entity_limit_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Create facts with a shared entity
|
||||
contents = [
|
||||
{
|
||||
"content": "Lead engineer Dana oversees the Widgets project at MegaCorp",
|
||||
"context": "project info",
|
||||
"entities": [{"text": "Dana"}, {"text": "MegaCorp"}],
|
||||
},
|
||||
]
|
||||
for i in range(30):
|
||||
contents.append(
|
||||
{
|
||||
"content": f"MegaCorp hired contractor {i} for the Q4 push",
|
||||
"context": "hiring info",
|
||||
"entities": [{"text": f"Contractor {i}"}, {"text": "MegaCorp"}],
|
||||
}
|
||||
)
|
||||
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=contents,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
from hindsight_api.config import _get_raw_config
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
|
||||
config = _get_raw_config()
|
||||
original_limit = config.link_expansion_per_entity_limit
|
||||
|
||||
try:
|
||||
# Set a very small per-entity limit
|
||||
config.link_expansion_per_entity_limit = 5
|
||||
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Dana",
|
||||
budget=Budget.HIGH,
|
||||
max_tokens=4096,
|
||||
enable_trace=True,
|
||||
request_context=request_context,
|
||||
_quiet=True,
|
||||
)
|
||||
|
||||
# Recall should succeed with the cap
|
||||
assert result.results is not None
|
||||
assert len(result.results) > 0
|
||||
|
||||
# Graph retrieval should have run
|
||||
retrieval_results = result.trace.get("retrieval_results", [])
|
||||
graph_results = [r for r in retrieval_results if r.get("method_name") == "graph"]
|
||||
assert len(graph_results) > 0, "Graph retrieval should have run"
|
||||
finally:
|
||||
config.link_expansion_per_entity_limit = original_limit
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -345,6 +345,65 @@ class TestLiteLLMSDKEmbeddings:
|
||||
assert encode_call_args.kwargs["api_base"] == "https://custom.api.com"
|
||||
assert encode_call_args.kwargs["dimensions"] == 768
|
||||
|
||||
async def test_encoding_format_default_is_float(self, mock_litellm):
|
||||
"""Test that encoding_format defaults to 'float' for backwards compatibility."""
|
||||
with patch(
|
||||
"builtins.__import__",
|
||||
side_effect=lambda name, *args: mock_litellm if name == "litellm" else __import__(name, *args),
|
||||
):
|
||||
emb = LiteLLMSDKEmbeddings(
|
||||
api_key="test_key",
|
||||
model="cohere/embed-english-v3.0",
|
||||
)
|
||||
await emb.initialize()
|
||||
|
||||
init_call_args = mock_litellm.aembedding.call_args
|
||||
assert init_call_args.kwargs["encoding_format"] == "float"
|
||||
|
||||
mock_litellm.embedding.return_value.data = [{"embedding": [0.1] * 768, "index": 0}]
|
||||
emb.encode(["test"])
|
||||
|
||||
encode_call_args = mock_litellm.embedding.call_args
|
||||
assert encode_call_args.kwargs["encoding_format"] == "float"
|
||||
|
||||
async def test_encoding_format_omitted_when_none(self, mock_litellm):
|
||||
"""Test that encoding_format is omitted when set to None (for Voyage AI, Gemini)."""
|
||||
with patch(
|
||||
"builtins.__import__",
|
||||
side_effect=lambda name, *args: mock_litellm if name == "litellm" else __import__(name, *args),
|
||||
):
|
||||
emb = LiteLLMSDKEmbeddings(
|
||||
api_key="test_key",
|
||||
model="voyage/voyage-4-large",
|
||||
encoding_format=None,
|
||||
)
|
||||
await emb.initialize()
|
||||
|
||||
init_call_args = mock_litellm.aembedding.call_args
|
||||
assert "encoding_format" not in init_call_args.kwargs
|
||||
|
||||
mock_litellm.embedding.return_value.data = [{"embedding": [0.1] * 768, "index": 0}]
|
||||
emb.encode(["test"])
|
||||
|
||||
encode_call_args = mock_litellm.embedding.call_args
|
||||
assert "encoding_format" not in encode_call_args.kwargs
|
||||
|
||||
async def test_encoding_format_omitted_when_empty_string(self, mock_litellm):
|
||||
"""Test that encoding_format is omitted when set to empty string."""
|
||||
with patch(
|
||||
"builtins.__import__",
|
||||
side_effect=lambda name, *args: mock_litellm if name == "litellm" else __import__(name, *args),
|
||||
):
|
||||
emb = LiteLLMSDKEmbeddings(
|
||||
api_key="test_key",
|
||||
model="gemini/gemini-embedding-2-preview",
|
||||
encoding_format="",
|
||||
)
|
||||
await emb.initialize()
|
||||
|
||||
init_call_args = mock_litellm.aembedding.call_args
|
||||
assert "encoding_format" not in init_call_args.kwargs
|
||||
|
||||
async def test_openai_invalid_output_dimensions_raises(self, mock_litellm):
|
||||
"""Invalid dimensions fail during initialize() (probe call), not per HTTP request.
|
||||
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
"""Tests for MCP tool argument string-to-JSON coercion (issue #849)."""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.api.mcp import (
|
||||
_coerce_string_json,
|
||||
_collect_coercible_types,
|
||||
_get_mcp_tools,
|
||||
_make_tools_tolerant,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _collect_coercible_types — schema type detection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCollectCoercibleTypes:
|
||||
"""Tests for _collect_coercible_types schema detection."""
|
||||
|
||||
def _run(self, schema: dict, param_name: str = "p") -> tuple[set[str], set[str]]:
|
||||
array_params: set[str] = set()
|
||||
object_params: set[str] = set()
|
||||
_collect_coercible_types(schema, param_name, array_params, object_params)
|
||||
return array_params, object_params
|
||||
|
||||
# --- array types ---
|
||||
|
||||
def test_direct_array_type(self):
|
||||
arrays, objects = self._run({"type": "array", "items": {"type": "string"}})
|
||||
assert "p" in arrays and not objects
|
||||
|
||||
def test_anyof_nullable_array(self):
|
||||
"""list[str] | None → anyOf with array and null."""
|
||||
arrays, objects = self._run(
|
||||
{"anyOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]}
|
||||
)
|
||||
assert "p" in arrays
|
||||
|
||||
def test_oneof_nullable_array(self):
|
||||
"""oneOf variant."""
|
||||
arrays, objects = self._run(
|
||||
{"oneOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]}
|
||||
)
|
||||
assert "p" in arrays
|
||||
|
||||
# --- object types ---
|
||||
|
||||
def test_direct_object_type(self):
|
||||
arrays, objects = self._run({"type": "object"})
|
||||
assert "p" in objects and not arrays
|
||||
|
||||
def test_anyof_nullable_object(self):
|
||||
"""dict[str, str] | None → anyOf with object and null."""
|
||||
arrays, objects = self._run({"anyOf": [{"type": "object"}, {"type": "null"}]})
|
||||
assert "p" in objects
|
||||
|
||||
def test_oneof_nullable_object(self):
|
||||
arrays, objects = self._run({"oneOf": [{"type": "object"}, {"type": "null"}]})
|
||||
assert "p" in objects
|
||||
|
||||
# --- non-coercible types (should be ignored) ---
|
||||
|
||||
def test_string_type_ignored(self):
|
||||
arrays, objects = self._run({"type": "string"})
|
||||
assert not arrays and not objects
|
||||
|
||||
def test_integer_type_ignored(self):
|
||||
arrays, objects = self._run({"type": "integer"})
|
||||
assert not arrays and not objects
|
||||
|
||||
def test_number_type_ignored(self):
|
||||
arrays, objects = self._run({"type": "number"})
|
||||
assert not arrays and not objects
|
||||
|
||||
def test_boolean_type_ignored(self):
|
||||
arrays, objects = self._run({"type": "boolean"})
|
||||
assert not arrays and not objects
|
||||
|
||||
def test_null_type_ignored(self):
|
||||
arrays, objects = self._run({"type": "null"})
|
||||
assert not arrays and not objects
|
||||
|
||||
def test_anyof_string_or_null_ignored(self):
|
||||
"""str | None should not be collected."""
|
||||
arrays, objects = self._run({"anyOf": [{"type": "string"}, {"type": "null"}]})
|
||||
assert not arrays and not objects
|
||||
|
||||
def test_anyof_integer_or_null_ignored(self):
|
||||
arrays, objects = self._run({"anyOf": [{"type": "integer"}, {"type": "null"}]})
|
||||
assert not arrays and not objects
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _coerce_string_json — value coercion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCoerceStringJson:
|
||||
"""Tests for _coerce_string_json argument coercion."""
|
||||
|
||||
# --- list coercion ---
|
||||
|
||||
def test_coerce_string_to_list(self):
|
||||
result = _coerce_string_json(
|
||||
{"tags": '["tag1", "tag2"]', "query": "hello"},
|
||||
array_params={"tags"},
|
||||
object_params=set(),
|
||||
)
|
||||
assert result["tags"] == ["tag1", "tag2"]
|
||||
assert result["query"] == "hello"
|
||||
|
||||
def test_coerce_empty_list_string(self):
|
||||
result = _coerce_string_json({"tags": "[]"}, array_params={"tags"}, object_params=set())
|
||||
assert result["tags"] == []
|
||||
|
||||
def test_native_list_passthrough(self):
|
||||
result = _coerce_string_json({"tags": ["a", "b"]}, array_params={"tags"}, object_params=set())
|
||||
assert result["tags"] == ["a", "b"]
|
||||
|
||||
# --- dict coercion ---
|
||||
|
||||
def test_coerce_string_to_dict(self):
|
||||
result = _coerce_string_json(
|
||||
{"metadata": '{"key": "value"}'},
|
||||
array_params=set(),
|
||||
object_params={"metadata"},
|
||||
)
|
||||
assert result["metadata"] == {"key": "value"}
|
||||
|
||||
def test_coerce_empty_dict_string(self):
|
||||
result = _coerce_string_json({"metadata": "{}"}, array_params=set(), object_params={"metadata"})
|
||||
assert result["metadata"] == {}
|
||||
|
||||
def test_native_dict_passthrough(self):
|
||||
result = _coerce_string_json(
|
||||
{"metadata": {"key": "value"}}, array_params=set(), object_params={"metadata"}
|
||||
)
|
||||
assert result["metadata"] == {"key": "value"}
|
||||
|
||||
# --- non-coercible values left untouched ---
|
||||
|
||||
def test_none_passthrough(self):
|
||||
result = _coerce_string_json({"tags": None}, array_params={"tags"}, object_params=set())
|
||||
assert result["tags"] is None
|
||||
|
||||
def test_invalid_json_string_passthrough(self):
|
||||
result = _coerce_string_json({"tags": "not-json"}, array_params={"tags"}, object_params=set())
|
||||
assert result["tags"] == "not-json"
|
||||
|
||||
def test_wrong_json_type_not_coerced_list(self):
|
||||
"""String that parses to a dict should NOT be coerced for an array param."""
|
||||
result = _coerce_string_json(
|
||||
{"tags": '{"key": "value"}'}, array_params={"tags"}, object_params=set()
|
||||
)
|
||||
assert result["tags"] == '{"key": "value"}'
|
||||
|
||||
def test_wrong_json_type_not_coerced_dict(self):
|
||||
"""String that parses to a list should NOT be coerced for an object param."""
|
||||
result = _coerce_string_json(
|
||||
{"metadata": '["a", "b"]'}, array_params=set(), object_params={"metadata"}
|
||||
)
|
||||
assert result["metadata"] == '["a", "b"]'
|
||||
|
||||
def test_string_param_not_touched(self):
|
||||
"""Strings not in array_params/object_params are never modified."""
|
||||
result = _coerce_string_json(
|
||||
{"query": '["looks", "like", "json"]'},
|
||||
array_params=set(),
|
||||
object_params=set(),
|
||||
)
|
||||
assert result["query"] == '["looks", "like", "json"]'
|
||||
|
||||
def test_integer_param_not_touched(self):
|
||||
result = _coerce_string_json(
|
||||
{"max_tokens": 4096}, array_params=set(), object_params=set()
|
||||
)
|
||||
assert result["max_tokens"] == 4096
|
||||
|
||||
def test_boolean_param_not_touched(self):
|
||||
result = _coerce_string_json(
|
||||
{"verbose": True}, array_params=set(), object_params=set()
|
||||
)
|
||||
assert result["verbose"] is True
|
||||
|
||||
def test_missing_param_no_error(self):
|
||||
result = _coerce_string_json(
|
||||
{"query": "hello"},
|
||||
array_params={"tags"},
|
||||
object_params={"metadata"},
|
||||
)
|
||||
assert result == {"query": "hello"}
|
||||
|
||||
# --- multiple params coerced at once ---
|
||||
|
||||
def test_multiple_params_coerced(self):
|
||||
result = _coerce_string_json(
|
||||
{
|
||||
"tags": '["a", "b"]',
|
||||
"types": '["world"]',
|
||||
"metadata": '{"source": "test"}',
|
||||
"query": "hello",
|
||||
"max_tokens": 4096,
|
||||
},
|
||||
array_params={"tags", "types"},
|
||||
object_params={"metadata"},
|
||||
)
|
||||
assert result["tags"] == ["a", "b"]
|
||||
assert result["types"] == ["world"]
|
||||
assert result["metadata"] == {"source": "test"}
|
||||
assert result["query"] == "hello"
|
||||
assert result["max_tokens"] == 4096
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _make_tools_tolerant — integration test with a real FastMCP tool
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMakeToolsTolerantIntegration:
|
||||
"""Test that _make_tools_tolerant correctly wraps real FastMCP tool functions."""
|
||||
|
||||
def _create_mcp_with_tool(self):
|
||||
"""Create a FastMCP instance with a tool that uses various parameter types."""
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("test")
|
||||
captured = {}
|
||||
|
||||
@mcp.tool(description="test tool with diverse param types")
|
||||
async def test_tool(
|
||||
query: str,
|
||||
max_tokens: int = 100,
|
||||
verbose: bool = False,
|
||||
tags: list[str] | None = None,
|
||||
metadata: dict[str, str] | None = None,
|
||||
) -> dict:
|
||||
"""Test tool.
|
||||
|
||||
Args:
|
||||
query: a string param
|
||||
max_tokens: an integer param
|
||||
verbose: a boolean param
|
||||
tags: an array param
|
||||
metadata: an object param
|
||||
"""
|
||||
captured["query"] = query
|
||||
captured["max_tokens"] = max_tokens
|
||||
captured["verbose"] = verbose
|
||||
captured["tags"] = tags
|
||||
captured["metadata"] = metadata
|
||||
return {"ok": True}
|
||||
|
||||
return mcp, captured
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_coerces_string_encoded_list(self):
|
||||
mcp, captured = self._create_mcp_with_tool()
|
||||
_make_tools_tolerant(mcp)
|
||||
tool = _get_mcp_tools(mcp)["test_tool"]
|
||||
await tool.run({"query": "hi", "tags": '["a", "b"]'})
|
||||
assert captured["tags"] == ["a", "b"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_coerces_string_encoded_dict(self):
|
||||
mcp, captured = self._create_mcp_with_tool()
|
||||
_make_tools_tolerant(mcp)
|
||||
tool = _get_mcp_tools(mcp)["test_tool"]
|
||||
await tool.run({"query": "hi", "metadata": '{"k": "v"}'})
|
||||
assert captured["metadata"] == {"k": "v"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_types_pass_through(self):
|
||||
mcp, captured = self._create_mcp_with_tool()
|
||||
_make_tools_tolerant(mcp)
|
||||
tool = _get_mcp_tools(mcp)["test_tool"]
|
||||
await tool.run({
|
||||
"query": "hi",
|
||||
"max_tokens": 200,
|
||||
"verbose": True,
|
||||
"tags": ["x"],
|
||||
"metadata": {"a": "b"},
|
||||
})
|
||||
assert captured["query"] == "hi"
|
||||
assert captured["max_tokens"] == 200
|
||||
assert captured["verbose"] is True
|
||||
assert captured["tags"] == ["x"]
|
||||
assert captured["metadata"] == {"a": "b"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_strips_extra_args_and_coerces(self):
|
||||
"""Both extra-arg stripping and coercion work together."""
|
||||
mcp, captured = self._create_mcp_with_tool()
|
||||
_make_tools_tolerant(mcp)
|
||||
tool = _get_mcp_tools(mcp)["test_tool"]
|
||||
await tool.run({
|
||||
"query": "hi",
|
||||
"tags": '["x"]',
|
||||
"explanation": "LLM added this",
|
||||
})
|
||||
assert captured["tags"] == ["x"]
|
||||
assert "explanation" not in captured
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_string_param_not_coerced(self):
|
||||
"""A string param whose value happens to look like JSON is NOT coerced."""
|
||||
mcp, captured = self._create_mcp_with_tool()
|
||||
_make_tools_tolerant(mcp)
|
||||
tool = _get_mcp_tools(mcp)["test_tool"]
|
||||
await tool.run({"query": '["this", "is", "a", "string"]'})
|
||||
assert captured["query"] == '["this", "is", "a", "string"]'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_integer_param_not_coerced(self):
|
||||
mcp, captured = self._create_mcp_with_tool()
|
||||
_make_tools_tolerant(mcp)
|
||||
tool = _get_mcp_tools(mcp)["test_tool"]
|
||||
await tool.run({"query": "hi", "max_tokens": 50})
|
||||
assert captured["max_tokens"] == 50
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_boolean_param_not_coerced(self):
|
||||
mcp, captured = self._create_mcp_with_tool()
|
||||
_make_tools_tolerant(mcp)
|
||||
tool = _get_mcp_tools(mcp)["test_tool"]
|
||||
await tool.run({"query": "hi", "verbose": True})
|
||||
assert captured["verbose"] is True
|
||||
@@ -342,7 +342,8 @@ class TestMentalModelToolRegistration:
|
||||
assert "update_bank" in tools
|
||||
assert "delete_bank" in tools
|
||||
assert "clear_memories" in tools
|
||||
assert len(tools) == 29
|
||||
assert "sync_retain" in tools
|
||||
assert len(tools) == 30
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -1107,12 +1108,67 @@ class TestMemoryBrowsingTools:
|
||||
assert '"deleted"' in result
|
||||
assert mock_memory.delete_memory_unit.call_args.kwargs["unit_id"] == "mem-1"
|
||||
|
||||
async def test_get_memory_invalid_uuid(self, mock_memory):
|
||||
mock_memory.get_memory_unit.side_effect = ValueError("Invalid memory_id: 'nonexistent' is not a valid UUID")
|
||||
mcp = _make_mcp_server(mock_memory, {"get_memory"}, include_bank_id=True)
|
||||
result = await _tools(mcp)["get_memory"].fn(memory_id="nonexistent")
|
||||
assert "not a valid UUID" in result
|
||||
|
||||
async def test_get_memory_invalid_uuid_single_bank(self, mock_memory):
|
||||
mock_memory.get_memory_unit.side_effect = ValueError("Invalid memory_id: 'bad' is not a valid UUID")
|
||||
mcp = _make_mcp_server(mock_memory, {"get_memory"}, include_bank_id=False)
|
||||
result = await _tools(mcp)["get_memory"].fn(memory_id="bad")
|
||||
assert "not a valid UUID" in result["error"]
|
||||
|
||||
async def test_delete_memory_invalid_uuid(self, mock_memory):
|
||||
mock_memory.delete_memory_unit.side_effect = ValueError("Invalid unit_id: 'bad' is not a valid UUID")
|
||||
mcp = _make_mcp_server(mock_memory, {"delete_memory"}, include_bank_id=True)
|
||||
result = await _tools(mcp)["delete_memory"].fn(memory_id="bad")
|
||||
assert "not a valid UUID" in result
|
||||
|
||||
async def test_list_memories_single_bank(self, mock_memory):
|
||||
mcp = _make_mcp_server(mock_memory, {"list_memories"}, include_bank_id=False)
|
||||
result = await _tools(mcp)["list_memories"].fn()
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Sync Retain Tool Tests
|
||||
# =========================================================================
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSyncRetainTool:
|
||||
async def test_sync_retain_basic(self, mock_memory):
|
||||
mock_memory.retain_batch_async.return_value = [["unit-1", "unit-2"]]
|
||||
mcp = _make_mcp_server(mock_memory, {"sync_retain"}, include_bank_id=True)
|
||||
result = await _tools(mcp)["sync_retain"].fn(content="test memory")
|
||||
assert result["status"] == "completed"
|
||||
assert result["memory_ids"] == ["unit-1", "unit-2"]
|
||||
|
||||
async def test_sync_retain_single_bank(self, mock_memory):
|
||||
mock_memory.retain_batch_async.return_value = [["unit-1"]]
|
||||
mcp = _make_mcp_server(mock_memory, {"sync_retain"}, include_bank_id=False)
|
||||
result = await _tools(mcp)["sync_retain"].fn(content="test memory")
|
||||
assert result["status"] == "completed"
|
||||
assert result["memory_ids"] == ["unit-1"]
|
||||
|
||||
async def test_sync_retain_with_tags(self, mock_memory):
|
||||
mock_memory.retain_batch_async.return_value = [["unit-1"]]
|
||||
mcp = _make_mcp_server(mock_memory, {"sync_retain"}, include_bank_id=True)
|
||||
result = await _tools(mcp)["sync_retain"].fn(content="test", tags=["project:alpha"])
|
||||
assert result["status"] == "completed"
|
||||
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
|
||||
assert call_kwargs["contents"][0]["tags"] == ["project:alpha"]
|
||||
|
||||
async def test_sync_retain_error(self, mock_memory):
|
||||
mock_memory.retain_batch_async.side_effect = Exception("DB error")
|
||||
mcp = _make_mcp_server(mock_memory, {"sync_retain"}, include_bank_id=True)
|
||||
result = await _tools(mcp)["sync_retain"].fn(content="test")
|
||||
assert result["status"] == "error"
|
||||
assert "DB error" in result["message"]
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Document Tool Tests
|
||||
# =========================================================================
|
||||
|
||||
@@ -76,7 +76,10 @@ class TestMetricsCollector:
|
||||
@pytest.fixture
|
||||
def collector(self, mock_meter):
|
||||
"""Create a MetricsCollector with a mock meter."""
|
||||
with patch("hindsight_api.metrics.get_meter", return_value=mock_meter):
|
||||
mock_config = MagicMock()
|
||||
mock_config.metrics_include_bank_id = False
|
||||
with patch("hindsight_api.metrics.get_meter", return_value=mock_meter), \
|
||||
patch("hindsight_api.config.get_config", return_value=mock_config):
|
||||
return MetricsCollector()
|
||||
|
||||
def test_record_operation_records_duration(self, collector):
|
||||
@@ -95,7 +98,7 @@ class TestMetricsCollector:
|
||||
# Second arg is attributes dict
|
||||
attributes = call_args[0][1]
|
||||
assert attributes["operation"] == "recall"
|
||||
assert attributes["bank_id"] == "test_bank"
|
||||
assert "bank_id" not in attributes # excluded by default to avoid high-cardinality OTel growth
|
||||
assert attributes["source"] == "api"
|
||||
assert attributes["success"] == "true"
|
||||
|
||||
@@ -166,6 +169,21 @@ class TestMetricsCollector:
|
||||
assert reflect_attrs["operation"] == "reflect"
|
||||
assert reflect_attrs["source"] == "api"
|
||||
|
||||
def test_record_operation_includes_bank_id_when_enabled(self):
|
||||
"""Test that bank_id is included in attributes when metrics_include_bank_id is enabled."""
|
||||
mock_config = MagicMock()
|
||||
mock_config.metrics_include_bank_id = True
|
||||
with patch("hindsight_api.metrics.get_meter") as mock_get_meter, \
|
||||
patch("hindsight_api.config.get_config", return_value=mock_config):
|
||||
mock_get_meter.return_value = MagicMock()
|
||||
collector = MetricsCollector()
|
||||
|
||||
with collector.record_operation("recall", bank_id="test_bank", source="api"):
|
||||
pass
|
||||
|
||||
attributes = collector.operation_duration.record.call_args[0][1]
|
||||
assert attributes["bank_id"] == "test_bank"
|
||||
|
||||
|
||||
class TestGetMetricsCollector:
|
||||
"""Tests for the get_metrics_collector function."""
|
||||
@@ -269,7 +287,10 @@ class TestLLMMetrics:
|
||||
@pytest.fixture
|
||||
def collector(self, mock_meter):
|
||||
"""Create a MetricsCollector with a mock meter."""
|
||||
with patch("hindsight_api.metrics.get_meter", return_value=mock_meter):
|
||||
mock_config = MagicMock()
|
||||
mock_config.metrics_include_bank_id = False
|
||||
with patch("hindsight_api.metrics.get_meter", return_value=mock_meter), \
|
||||
patch("hindsight_api.config.get_config", return_value=mock_config):
|
||||
return MetricsCollector()
|
||||
|
||||
def test_record_llm_call_records_duration(self, collector):
|
||||
|
||||
@@ -283,3 +283,37 @@ def test_query_analyzer_couple_weeks_ago(query_analyzer):
|
||||
assert analysis.temporal_constraint.end_date.month == 1 # Jan 8 (1 week before Jan 15)
|
||||
|
||||
|
||||
def test_query_analyzer_dateparser_crash_returns_no_constraint(query_analyzer, monkeypatch, caplog):
|
||||
"""
|
||||
dateparser has been observed to crash with internal errors (e.g.,
|
||||
IndexError from locale.translate_search) on certain query inputs.
|
||||
A parser bug should not propagate up the search/consolidation pipeline —
|
||||
the analyzer should treat any failure as "no temporal constraint found".
|
||||
"""
|
||||
import logging
|
||||
|
||||
reference_date = datetime(2025, 1, 15, 12, 0, 0)
|
||||
|
||||
# Make sure the lazy loader has run so we can monkey-patch the cached call.
|
||||
query_analyzer.load()
|
||||
|
||||
def boom(*args, **kwargs):
|
||||
raise IndexError("list index out of range")
|
||||
|
||||
monkeypatch.setattr(query_analyzer, "_search_dates", boom)
|
||||
|
||||
# Use a query that doesn't match any of the period regex patterns so the
|
||||
# code path actually reaches the dateparser call.
|
||||
query = "tell me what happened recently with the project"
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
analysis = query_analyzer.analyze(query, reference_date)
|
||||
|
||||
assert analysis.temporal_constraint is None, (
|
||||
"dateparser failures should be treated as no temporal constraint, not propagated"
|
||||
)
|
||||
assert any("dateparser" in rec.message for rec in caplog.records), (
|
||||
"Should log a warning when dateparser fails"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Unit tests for proof_count boost in reranking.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
|
||||
from hindsight_api.engine.search.types import RetrievalResult, MergedCandidate, ScoredResult
|
||||
from hindsight_api.engine.search.reranking import apply_combined_scoring
|
||||
|
||||
UTC = timezone.utc
|
||||
|
||||
def create_mock_scored_result(proof_count: int | None = None, ce_score: float = 0.8) -> ScoredResult:
|
||||
"""Helper to create a minimal ScoredResult suitable for scoring tests."""
|
||||
retrieval = RetrievalResult(
|
||||
id=str(uuid4()),
|
||||
text="Test mock fact",
|
||||
fact_type="observation" if proof_count is not None else "world",
|
||||
document_id=str(uuid4()),
|
||||
chunk_id=str(uuid4()),
|
||||
proof_count=proof_count,
|
||||
# Use None for neutral recency so only proof_count changes score
|
||||
occurred_start=None,
|
||||
occurred_end=None
|
||||
)
|
||||
candidate = MergedCandidate(
|
||||
retrieval=retrieval,
|
||||
rrf_score=0.1,
|
||||
)
|
||||
return ScoredResult(
|
||||
candidate=candidate,
|
||||
cross_encoder_score=ce_score,
|
||||
cross_encoder_score_normalized=ce_score,
|
||||
weight=ce_score,
|
||||
)
|
||||
|
||||
def test_proof_count_neutral_when_none():
|
||||
"""Test that when proof_count is None (e.g. non-observation), it gets neutral 0.5 norm."""
|
||||
sr = create_mock_scored_result(proof_count=None, ce_score=0.8)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
apply_combined_scoring([sr], now, proof_count_alpha=0.1)
|
||||
|
||||
# Neutral multiplier means score shouldn't be boosted by proof_count
|
||||
# Since recency is neutral (just created) and temporal is neutral, score should remain unchanged
|
||||
assert sr.combined_score == pytest.approx(0.8, rel=1e-3)
|
||||
|
||||
def test_proof_count_neutral_at_one():
|
||||
"""Test that proof_count=1 gives neutral multiplier."""
|
||||
sr = create_mock_scored_result(proof_count=1, ce_score=0.8)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
apply_combined_scoring([sr], now, proof_count_alpha=0.1)
|
||||
|
||||
# proof_count=1 -> math.log(1) = 0 -> 0.5 + 0/10 = 0.5 (neutral) -> multiplier 1.0
|
||||
assert sr.combined_score == pytest.approx(0.8, rel=1e-3)
|
||||
|
||||
def test_proof_count_increases_with_higher_counts():
|
||||
"""Test that higher proof counts yield strictly higher scores."""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Create results with increasing proof counts
|
||||
sr_5 = create_mock_scored_result(proof_count=5, ce_score=0.8)
|
||||
sr_50 = create_mock_scored_result(proof_count=50, ce_score=0.8)
|
||||
sr_100 = create_mock_scored_result(proof_count=100, ce_score=0.8)
|
||||
|
||||
# Process them
|
||||
apply_combined_scoring([sr_5, sr_50, sr_100], now, proof_count_alpha=0.1)
|
||||
|
||||
# Assure scores strictly increase
|
||||
assert sr_5.combined_score > 0.8
|
||||
assert sr_50.combined_score > sr_5.combined_score
|
||||
assert sr_100.combined_score > sr_50.combined_score
|
||||
|
||||
def test_proof_count_no_hardcoded_cap_at_100():
|
||||
"""Test that proof_count continues to scale within the clamped [0, 1] range."""
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Use values that stay below the clamp ceiling (proof_norm < 1.0)
|
||||
# log(5)/10=0.16, log(20)/10=0.30, log(100)/10=0.46 → all below 0.5 headroom
|
||||
sr_5 = create_mock_scored_result(proof_count=5, ce_score=0.8)
|
||||
sr_20 = create_mock_scored_result(proof_count=20, ce_score=0.8)
|
||||
sr_100 = create_mock_scored_result(proof_count=100, ce_score=0.8)
|
||||
|
||||
apply_combined_scoring([sr_5, sr_20, sr_100], now, proof_count_alpha=0.1)
|
||||
|
||||
# Must strictly increase within the valid range
|
||||
assert sr_20.combined_score > sr_5.combined_score
|
||||
assert sr_100.combined_score > sr_20.combined_score
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Tests for retain update_mode='append' — appends new content to existing documents.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _ts():
|
||||
return datetime.now(timezone.utc).timestamp()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_mode_concatenates_content(memory, request_context):
|
||||
"""
|
||||
When update_mode='append', new content should be appended to the existing
|
||||
document and the full document should be reprocessed. Facts from both
|
||||
old and new content should be recallable.
|
||||
"""
|
||||
bank_id = f"test_append_{_ts()}"
|
||||
document_id = "conversation-append"
|
||||
|
||||
try:
|
||||
# First retain — initial content
|
||||
v1_units = await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Alice works at Google as a software engineer.",
|
||||
context="team info",
|
||||
document_id=document_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(v1_units) > 0, "v1 should create facts"
|
||||
|
||||
doc_v1 = await memory.get_document(document_id, bank_id, request_context=request_context)
|
||||
v1_text = doc_v1["original_text"]
|
||||
assert "Alice works at Google" in v1_text
|
||||
|
||||
# Second retain with append — add new content
|
||||
v2_units = await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{
|
||||
"content": "Bob works at Microsoft as a data scientist.",
|
||||
"context": "team info",
|
||||
"document_id": document_id,
|
||||
"update_mode": "append",
|
||||
}
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Verify document now contains both old and new content
|
||||
doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context)
|
||||
v2_text = doc_v2["original_text"]
|
||||
assert "Alice works at Google" in v2_text, "Original content should be preserved"
|
||||
assert "Bob works at Microsoft" in v2_text, "New content should be appended"
|
||||
|
||||
# Verify facts from both old and new content are recallable
|
||||
result_alice = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Where does Alice work?",
|
||||
budget=Budget.MID,
|
||||
max_tokens=1000,
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(result_alice.results) > 0, "Should recall facts about Alice"
|
||||
|
||||
result_bob = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Where does Bob work?",
|
||||
budget=Budget.MID,
|
||||
max_tokens=1000,
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(result_bob.results) > 0, "Should recall facts about Bob"
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_mode_no_existing_document(memory, request_context):
|
||||
"""
|
||||
When update_mode='append' but no existing document exists,
|
||||
it should behave like a normal retain (no content to prepend).
|
||||
"""
|
||||
bank_id = f"test_append_new_{_ts()}"
|
||||
document_id = "new-doc-append"
|
||||
|
||||
try:
|
||||
units = await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{
|
||||
"content": "Charlie is a product manager at Stripe.",
|
||||
"context": "team info",
|
||||
"document_id": document_id,
|
||||
"update_mode": "append",
|
||||
}
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(units) > 0, "Should create facts even with no existing document"
|
||||
# Flatten if nested
|
||||
flat_units = units[0] if units and isinstance(units[0], list) else units
|
||||
assert len(flat_units) > 0
|
||||
|
||||
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
|
||||
assert "Charlie is a product manager" in doc["original_text"]
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_mode_requires_document_id(memory, request_context):
|
||||
"""update_mode='append' without document_id should raise ValueError."""
|
||||
bank_id = f"test_append_no_docid_{_ts()}"
|
||||
|
||||
with pytest.raises(ValueError, match="update_mode='append' requires a document_id"):
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{
|
||||
"content": "Some content",
|
||||
"update_mode": "append",
|
||||
}
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_mode_multiple_appends(memory, request_context):
|
||||
"""Multiple appends should accumulate content over successive retains."""
|
||||
bank_id = f"test_multi_append_{_ts()}"
|
||||
document_id = "multi-append-doc"
|
||||
|
||||
try:
|
||||
# Initial retain
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Day 1: Alice joined the team.",
|
||||
context="journal",
|
||||
document_id=document_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# First append
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{
|
||||
"content": "Day 2: Alice completed her onboarding.",
|
||||
"context": "journal",
|
||||
"document_id": document_id,
|
||||
"update_mode": "append",
|
||||
}
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Second append
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{
|
||||
"content": "Day 3: Alice shipped her first feature.",
|
||||
"context": "journal",
|
||||
"document_id": document_id,
|
||||
"update_mode": "append",
|
||||
}
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Verify all content is present
|
||||
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
|
||||
text = doc["original_text"]
|
||||
assert "Day 1" in text, "Original content should be present"
|
||||
assert "Day 2" in text, "First append should be present"
|
||||
assert "Day 3" in text, "Second append should be present"
|
||||
|
||||
# All days should be recallable
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="What happened on Alice's first days?",
|
||||
budget=Budget.MID,
|
||||
max_tokens=1000,
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(result.results) > 0, "Should recall facts from all appends"
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replace_mode_is_default(memory, request_context):
|
||||
"""Without update_mode (or update_mode='replace'), retain should replace content."""
|
||||
bank_id = f"test_replace_default_{_ts()}"
|
||||
document_id = "replace-doc"
|
||||
|
||||
try:
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Alice works at Google.",
|
||||
context="team info",
|
||||
document_id=document_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Retain again without update_mode — should replace
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{
|
||||
"content": "Bob works at Microsoft.",
|
||||
"context": "team info",
|
||||
"document_id": document_id,
|
||||
}
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
doc = await memory.get_document(document_id, bank_id, request_context=request_context)
|
||||
text = doc["original_text"]
|
||||
# With replace, only new content should remain
|
||||
assert "Bob works at Microsoft" in text, "New content should be present"
|
||||
assert "Alice works at Google" not in text, "Old content should be replaced"
|
||||
|
||||
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.22"
|
||||
version = "0.5.0"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Hindsight CLI ↔ OpenAPI coverage manifest.
|
||||
#
|
||||
# The CI job `cli-coverage-check` (see hindsight-dev/hindsight_dev/cli_coverage_check.py)
|
||||
# enforces both endpoint-level and parameter-level coverage:
|
||||
#
|
||||
# 1. Every operationId in hindsight-docs/static/openapi.json must be either
|
||||
# called from hindsight-cli/src/**/*.rs (the progenitor-generated client
|
||||
# methods are named identically to the operationId) or listed under
|
||||
# [skip] below with a reason.
|
||||
#
|
||||
# 2. For each operation with a JSON request body, every top-level property
|
||||
# of that body must be either present in hindsight-cli/src/main.rs as a
|
||||
# clap command variant field (`field_name: <type>`) or a `long = "..."`
|
||||
# attribute, OR listed under [fields.<operation_id>] below with a reason.
|
||||
#
|
||||
# Skip entries should explain *why* the field/operation is not exposed (e.g.
|
||||
# flattened into several CLI flags, complex nested struct, available via a
|
||||
# different subcommand).
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Operation-level skips
|
||||
# ---------------------------------------------------------------------------
|
||||
[skip]
|
||||
# (empty — every operation is currently wired)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-operation parameter skips
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
[fields.add_bank_background]
|
||||
update_disposition = "Exposed inverted as --no-update-disposition on `bank background`."
|
||||
|
||||
[fields.create_or_update_bank]
|
||||
disposition = "Flattened into --skepticism / --literalism / --empathy on `bank create`."
|
||||
disposition_skepticism = "Covered by --skepticism; the flat form is an API alias."
|
||||
disposition_literalism = "Covered by --literalism; the flat form is an API alias."
|
||||
disposition_empathy = "Covered by --empathy; the flat form is an API alias."
|
||||
background = "Set via the dedicated `bank background` subcommand."
|
||||
reflect_mission = "Set via `bank set-config --reflect-mission`."
|
||||
retain_mission = "Set via `bank set-config --retain-mission`."
|
||||
retain_extraction_mode = "Set via `bank set-config --retain-extraction-mode`."
|
||||
retain_custom_instructions = "Set via `bank set-config` (hierarchical config)."
|
||||
retain_chunk_size = "Set via `bank set-config` (hierarchical config)."
|
||||
enable_observations = "Set via `bank set-config` (hierarchical config)."
|
||||
observations_mission = "Set via `bank set-config --observations-mission`."
|
||||
|
||||
[fields.update_bank]
|
||||
disposition = "Flattened into --skepticism / --literalism / --empathy on `bank update`."
|
||||
disposition_skepticism = "Covered by --skepticism; the flat form is an API alias."
|
||||
disposition_literalism = "Covered by --literalism; the flat form is an API alias."
|
||||
disposition_empathy = "Covered by --empathy; the flat form is an API alias."
|
||||
background = "Set via the dedicated `bank background` subcommand."
|
||||
reflect_mission = "Set via `bank set-config --reflect-mission`."
|
||||
retain_mission = "Set via `bank set-config --retain-mission`."
|
||||
retain_extraction_mode = "Set via `bank set-config --retain-extraction-mode`."
|
||||
retain_custom_instructions = "Set via `bank set-config` (hierarchical config)."
|
||||
retain_chunk_size = "Set via `bank set-config` (hierarchical config)."
|
||||
enable_observations = "Set via `bank set-config` (hierarchical config)."
|
||||
observations_mission = "Set via `bank set-config --observations-mission`."
|
||||
|
||||
[fields.update_bank_disposition]
|
||||
disposition = "Flattened into --skepticism / --literalism / --empathy on `bank set-disposition`."
|
||||
|
||||
[fields.update_bank_config]
|
||||
updates = "Flattened into per-setting flags (--llm-provider, --llm-model, etc) on `bank set-config`."
|
||||
|
||||
[fields.create_webhook]
|
||||
http_config = "Advanced HTTP customisation (headers/method/timeout/params) is not exposed in the CLI yet; use the JSON API if needed."
|
||||
|
||||
[fields.update_webhook]
|
||||
http_config = "Advanced HTTP customisation (headers/method/timeout/params) is not exposed in the CLI yet; use the JSON API if needed."
|
||||
|
||||
[fields.recall_memories]
|
||||
types = "CLI exposes this as --fact-type (the schema property is named `types` but it holds fact types)."
|
||||
include = "Flattened into --include-chunks / --chunk-max-tokens (facts are always included)."
|
||||
tag_groups = "Complex nested tag filter not yet exposed in the CLI; use --tags / --tags-match for simple cases."
|
||||
|
||||
[fields.reflect]
|
||||
include = "Flattened into --include-facts and related flags."
|
||||
response_schema = "Exposed as --schema (path to a JSON schema file)."
|
||||
tag_groups = "Complex nested tag filter not yet exposed in the CLI; use --tags / --tags-match for simple cases."
|
||||
|
||||
[fields.retain_memories]
|
||||
items = "Constructed from the single positional content argument on `memory retain`."
|
||||
|
||||
[fields.create_mental_model]
|
||||
trigger = "Exposed as --trigger-refresh-after-consolidation on `mental-model create` (other nested trigger fields like fact_types/tag_groups are not exposed yet)."
|
||||
|
||||
[fields.update_mental_model]
|
||||
trigger = "Exposed as --trigger-refresh-after-consolidation on `mental-model update` (other nested trigger fields like fact_types/tag_groups are not exposed yet)."
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hindsight-cli"
|
||||
version = "0.4.22"
|
||||
version = "0.5.0"
|
||||
edition = "2021"
|
||||
authors = ["Hindsight Team"]
|
||||
description = "A beautiful CLI for Hindsight - semantic memory system"
|
||||
|
||||
@@ -118,6 +118,41 @@ run_test "clear memories" "$HINDSIGHT_CLI" memory clear "$TEST_BANK" || FAILED=1
|
||||
# Test 15: List operations
|
||||
run_test "list operations" "$HINDSIGHT_CLI" operation list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# --- Coverage-critical commands (added to ensure CLI exercises every endpoint) ---
|
||||
|
||||
# Test: Set disposition directly (PUT /profile)
|
||||
run_test "bank set-disposition" "$HINDSIGHT_CLI" bank set-disposition "$TEST_BANK" \
|
||||
--skepticism 3 --literalism 3 --empathy 3 || FAILED=1
|
||||
|
||||
# Test: Recover consolidation (no-op when nothing stalled, but exercises the endpoint)
|
||||
run_test "bank consolidation-recover" "$HINDSIGHT_CLI" bank consolidation-recover "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test: Bank template schema
|
||||
run_test "bank template-schema" "$HINDSIGHT_CLI" bank template-schema -o json || FAILED=1
|
||||
|
||||
# Test: Export bank template
|
||||
run_test "bank export-template" "$HINDSIGHT_CLI" bank export-template "$TEST_BANK" -o json || FAILED=1
|
||||
|
||||
# Test: Audit log list + stats
|
||||
run_test "audit list" "$HINDSIGHT_CLI" audit list "$TEST_BANK" -o json || FAILED=1
|
||||
run_test "audit stats" "$HINDSIGHT_CLI" audit stats "$TEST_BANK" -o json || FAILED=1
|
||||
|
||||
# Test: Webhook lifecycle (list / create / update / deliveries / delete)
|
||||
run_test "webhook list (empty)" "$HINDSIGHT_CLI" webhook list "$TEST_BANK" -o json || FAILED=1
|
||||
|
||||
WEBHOOK_OUT=$("$HINDSIGHT_CLI" webhook create "$TEST_BANK" https://example.invalid/hook -o json 2>/tmp/cli-test-output.txt || true)
|
||||
if echo "$WEBHOOK_OUT" | grep -q '"id"'; then
|
||||
echo "Testing: webhook create... OK"
|
||||
WEBHOOK_ID=$(echo "$WEBHOOK_OUT" | sed -n 's/.*"id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1)
|
||||
run_test "webhook update" "$HINDSIGHT_CLI" webhook update "$TEST_BANK" "$WEBHOOK_ID" --enabled false || FAILED=1
|
||||
run_test "webhook deliveries" "$HINDSIGHT_CLI" webhook deliveries "$TEST_BANK" "$WEBHOOK_ID" -o json || FAILED=1
|
||||
run_test "webhook delete" "$HINDSIGHT_CLI" webhook delete "$TEST_BANK" "$WEBHOOK_ID" -y || FAILED=1
|
||||
else
|
||||
echo "Testing: webhook create... FAILED"
|
||||
cat /tmp/cli-test-output.txt | sed 's/^/ /'
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# Test 16: Delete bank
|
||||
run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y || FAILED=1
|
||||
|
||||
|
||||
+608
-84
@@ -4,8 +4,8 @@
|
||||
//! to bridge from the CLI's synchronous code to the async API client.
|
||||
|
||||
use anyhow::Result;
|
||||
use hindsight_client::Client as AsyncClient;
|
||||
pub use hindsight_client::types;
|
||||
use hindsight_client::Client as AsyncClient;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json;
|
||||
use std::collections::HashMap;
|
||||
@@ -76,8 +76,8 @@ impl ApiClient {
|
||||
let runtime = std::sync::Arc::new(tokio::runtime::Runtime::new()?);
|
||||
|
||||
// Create HTTP client with 2-minute timeout and optional auth header
|
||||
let mut client_builder = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(120));
|
||||
let mut client_builder =
|
||||
reqwest::Client::builder().timeout(std::time::Duration::from_secs(120));
|
||||
|
||||
if let Some(key) = api_key {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
@@ -92,7 +92,12 @@ impl ApiClient {
|
||||
let http_client = client_builder.build()?;
|
||||
|
||||
let client = AsyncClient::new_with_client(&base_url, http_client.clone());
|
||||
Ok(ApiClient { client, http_client, base_url, runtime })
|
||||
Ok(ApiClient {
|
||||
client,
|
||||
http_client,
|
||||
base_url,
|
||||
runtime,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_agents(&self, _verbose: bool) -> Result<Vec<types::BankListItem>> {
|
||||
@@ -102,7 +107,11 @@ impl ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_profile(&self, agent_id: &str, _verbose: bool) -> Result<types::BankProfileResponse> {
|
||||
pub fn get_profile(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::BankProfileResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_bank_profile(agent_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
@@ -120,7 +129,12 @@ impl ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_agent_name(&self, agent_id: &str, name: &str, _verbose: bool) -> Result<types::BankProfileResponse> {
|
||||
pub fn update_agent_name(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
name: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::BankProfileResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let request = types::CreateBankRequest {
|
||||
name: Some(name.to_string()),
|
||||
@@ -129,25 +143,45 @@ impl ApiClient {
|
||||
disposition: None,
|
||||
..Default::default()
|
||||
};
|
||||
let response = self.client.create_or_update_bank(agent_id, None, &request).await?;
|
||||
let response = self
|
||||
.client
|
||||
.create_or_update_bank(agent_id, None, &request)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_background(&self, agent_id: &str, content: &str, update_disposition: bool, _verbose: bool) -> Result<types::BackgroundResponse> {
|
||||
pub fn add_background(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
content: &str,
|
||||
update_disposition: bool,
|
||||
_verbose: bool,
|
||||
) -> Result<types::BackgroundResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let request = types::AddBackgroundRequest {
|
||||
content: content.to_string(),
|
||||
update_disposition,
|
||||
};
|
||||
let response = self.client.add_bank_background(agent_id, None, &request).await?;
|
||||
let response = self
|
||||
.client
|
||||
.add_bank_background(agent_id, None, &request)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn recall(&self, agent_id: &str, request: &types::RecallRequest, verbose: bool) -> Result<types::RecallResponse> {
|
||||
pub fn recall(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
request: &types::RecallRequest,
|
||||
verbose: bool,
|
||||
) -> Result<types::RecallResponse> {
|
||||
if verbose {
|
||||
eprintln!("Request body: {}", serde_json::to_string_pretty(request).unwrap_or_default());
|
||||
eprintln!(
|
||||
"Request body: {}",
|
||||
serde_json::to_string_pretty(request).unwrap_or_default()
|
||||
);
|
||||
}
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.recall_memories(agent_id, None, request).await?;
|
||||
@@ -155,14 +189,25 @@ impl ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reflect(&self, agent_id: &str, request: &types::ReflectRequest, _verbose: bool) -> Result<types::ReflectResponse> {
|
||||
pub fn reflect(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
request: &types::ReflectRequest,
|
||||
_verbose: bool,
|
||||
) -> Result<types::ReflectResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.reflect(agent_id, None, request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn retain(&self, agent_id: &str, request: &types::RetainRequest, _async_mode: bool, _verbose: bool) -> Result<MemoryPutResult> {
|
||||
pub fn retain(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
request: &types::RetainRequest,
|
||||
_async_mode: bool,
|
||||
_verbose: bool,
|
||||
) -> Result<MemoryPutResult> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.retain_memories(agent_id, None, request).await?;
|
||||
let result = response.into_inner();
|
||||
@@ -186,7 +231,10 @@ impl ApiClient {
|
||||
verbose: bool,
|
||||
) -> Result<FileRetainResult> {
|
||||
self.runtime.block_on(async {
|
||||
let url = format!("{}/v1/default/banks/{}/files/retain", self.base_url, bank_id);
|
||||
let url = format!(
|
||||
"{}/v1/default/banks/{}/files/retain",
|
||||
self.base_url, bank_id
|
||||
);
|
||||
|
||||
let files_metadata: Vec<serde_json::Value> = files
|
||||
.iter()
|
||||
@@ -210,8 +258,8 @@ impl ApiClient {
|
||||
"files_metadata": files_metadata,
|
||||
});
|
||||
|
||||
let mut form = reqwest::multipart::Form::new()
|
||||
.text("request", request_json.to_string());
|
||||
let mut form =
|
||||
reqwest::multipart::Form::new().text("request", request_json.to_string());
|
||||
|
||||
for (filename, content) in files {
|
||||
let part = reqwest::multipart::Part::bytes(content)
|
||||
@@ -239,10 +287,18 @@ impl ApiClient {
|
||||
|
||||
/// Poll an operation until it completes or fails.
|
||||
/// Returns Ok(true) if completed successfully, Ok(false) if failed, Err if polling error.
|
||||
pub fn poll_operation(&self, agent_id: &str, operation_id: &str, verbose: bool) -> Result<(bool, Option<String>)> {
|
||||
pub fn poll_operation(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
operation_id: &str,
|
||||
verbose: bool,
|
||||
) -> Result<(bool, Option<String>)> {
|
||||
self.runtime.block_on(async {
|
||||
loop {
|
||||
let response = self.client.list_operations(agent_id, None, None, None, None, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.list_operations(agent_id, None, None, None, None, None)
|
||||
.await?;
|
||||
let ops = response.into_inner();
|
||||
|
||||
// Find our operation
|
||||
@@ -267,7 +323,10 @@ impl ApiClient {
|
||||
}
|
||||
_ => {
|
||||
// Unknown status, treat as failed
|
||||
return Ok((false, Some(format!("Unknown status: {}", operation.status))));
|
||||
return Ok((
|
||||
false,
|
||||
Some(format!("Unknown status: {}", operation.status)),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,43 +339,82 @@ impl ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_memory(&self, _agent_id: &str, _unit_id: &str, _verbose: bool) -> Result<types::DeleteResponse> {
|
||||
pub fn delete_memory(
|
||||
&self,
|
||||
_agent_id: &str,
|
||||
_unit_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::DeleteResponse> {
|
||||
// Note: Individual memory deletion is no longer supported in the API
|
||||
anyhow::bail!("Individual memory deletion is no longer supported. Use 'memory clear' to clear all memories.")
|
||||
}
|
||||
|
||||
pub fn clear_memories(&self, agent_id: &str, fact_type: Option<&str>, _verbose: bool) -> Result<types::DeleteResponse> {
|
||||
pub fn clear_memories(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
fact_type: Option<&str>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::DeleteResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.clear_bank_memories(agent_id, None, Some(fact_type)).await?;
|
||||
let response = self
|
||||
.client
|
||||
.clear_bank_memories(agent_id, None, Some(fact_type))
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_documents(&self, agent_id: &str, q: Option<&str>, limit: Option<i32>, offset: Option<i32>, _verbose: bool) -> Result<types::ListDocumentsResponse> {
|
||||
pub fn list_documents(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
q: Option<&str>,
|
||||
limit: Option<i32>,
|
||||
offset: Option<i32>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::ListDocumentsResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_documents(
|
||||
agent_id,
|
||||
limit.map(|l| l as i64),
|
||||
offset.map(|o| o as i64),
|
||||
q,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
).await?;
|
||||
let response = self
|
||||
.client
|
||||
.list_documents(
|
||||
agent_id,
|
||||
limit.map(|l| l as i64),
|
||||
offset.map(|o| o as i64),
|
||||
q,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_document(&self, agent_id: &str, document_id: &str, _verbose: bool) -> Result<types::DocumentResponse> {
|
||||
pub fn get_document(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
document_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::DocumentResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_document(agent_id, document_id, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.get_document(agent_id, document_id, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_document(&self, agent_id: &str, document_id: &str, _verbose: bool) -> Result<types::DeleteResponse> {
|
||||
pub fn delete_document(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
document_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::DeleteResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.delete_document(agent_id, document_id, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.delete_document(agent_id, document_id, None)
|
||||
.await?;
|
||||
let value = response.into_inner();
|
||||
// Convert typed response to DeleteResponse
|
||||
Ok(types::DeleteResponse {
|
||||
@@ -329,7 +427,10 @@ impl ApiClient {
|
||||
|
||||
pub fn list_operations(&self, agent_id: &str, _verbose: bool) -> Result<OperationsResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_operations(agent_id, None, None, None, None, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.list_operations(agent_id, None, None, None, None, None)
|
||||
.await?;
|
||||
let value = response.into_inner();
|
||||
// Convert to JSON Value first, then parse into our type
|
||||
let json_value = serde_json::to_value(&value)?;
|
||||
@@ -338,9 +439,17 @@ impl ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn cancel_operation(&self, agent_id: &str, operation_id: &str, _verbose: bool) -> Result<types::DeleteResponse> {
|
||||
pub fn cancel_operation(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
operation_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::DeleteResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.cancel_operation(agent_id, operation_id, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.cancel_operation(agent_id, operation_id, None)
|
||||
.await?;
|
||||
let value = response.into_inner();
|
||||
// Convert typed response to DeleteResponse
|
||||
Ok(types::DeleteResponse {
|
||||
@@ -351,30 +460,63 @@ impl ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_memories(&self, bank_id: &str, type_filter: Option<&str>, q: Option<&str>, limit: Option<i64>, offset: Option<i64>, _verbose: bool) -> Result<types::ListMemoryUnitsResponse> {
|
||||
pub fn list_memories(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
type_filter: Option<&str>,
|
||||
q: Option<&str>,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::ListMemoryUnitsResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_memories(bank_id, limit, offset, q, type_filter, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.list_memories(bank_id, limit, offset, q, type_filter, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_entities(&self, bank_id: &str, limit: Option<i64>, offset: Option<i64>, _verbose: bool) -> Result<types::EntityListResponse> {
|
||||
pub fn list_entities(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::EntityListResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_entities(bank_id, limit, offset, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.list_entities(bank_id, limit, offset, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_entity(&self, bank_id: &str, entity_id: &str, _verbose: bool) -> Result<types::EntityDetailResponse> {
|
||||
pub fn get_entity(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
entity_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::EntityDetailResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_entity(bank_id, entity_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn regenerate_entity(&self, bank_id: &str, entity_id: &str, _verbose: bool) -> Result<types::EntityDetailResponse> {
|
||||
pub fn regenerate_entity(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
entity_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::EntityDetailResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.regenerate_entity_observations(bank_id, entity_id, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.regenerate_entity_observations(bank_id, entity_id, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
@@ -394,7 +536,12 @@ impl ApiClient {
|
||||
impl ApiClient {
|
||||
// --- Memory Methods ---
|
||||
|
||||
pub fn get_memory(&self, bank_id: &str, memory_id: &str, _verbose: bool) -> Result<serde_json::Value> {
|
||||
pub fn get_memory(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
memory_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_memory(bank_id, memory_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
@@ -410,7 +557,10 @@ impl ApiClient {
|
||||
_verbose: bool,
|
||||
) -> Result<types::BankProfileResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.create_or_update_bank(bank_id, None, request).await?;
|
||||
let response = self
|
||||
.client
|
||||
.create_or_update_bank(bank_id, None, request)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
@@ -454,7 +604,10 @@ impl ApiClient {
|
||||
_verbose: bool,
|
||||
) -> Result<types::GraphDataResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_graph(bank_id, limit, type_filter, None, None, None, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.get_graph(bank_id, limit, type_filter, None, None, None, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
@@ -478,9 +631,15 @@ impl ApiClient {
|
||||
) -> Result<types::BankConfigResponse> {
|
||||
self.runtime.block_on(async {
|
||||
// Convert HashMap to serde_json::Map
|
||||
let updates_map: serde_json::Map<String, serde_json::Value> = updates.into_iter().collect();
|
||||
let request = types::BankConfigUpdate { updates: updates_map };
|
||||
let response = self.client.update_bank_config(bank_id, None, &request).await?;
|
||||
let updates_map: serde_json::Map<String, serde_json::Value> =
|
||||
updates.into_iter().collect();
|
||||
let request = types::BankConfigUpdate {
|
||||
updates: updates_map,
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.update_bank_config(bank_id, None, &request)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
@@ -507,7 +666,10 @@ impl ApiClient {
|
||||
_verbose: bool,
|
||||
) -> Result<types::ListTagsResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_tags(bank_id, limit, offset, q, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.list_tags(bank_id, limit, offset, q, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
@@ -523,9 +685,17 @@ impl ApiClient {
|
||||
|
||||
// --- Operation Methods ---
|
||||
|
||||
pub fn get_operation(&self, bank_id: &str, operation_id: &str, _verbose: bool) -> Result<types::OperationStatusResponse> {
|
||||
pub fn get_operation(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
operation_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::OperationStatusResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_operation_status(bank_id, operation_id, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.get_operation_status(bank_id, operation_id, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
@@ -548,16 +718,31 @@ impl ApiClient {
|
||||
|
||||
// --- Mental Model Methods ---
|
||||
|
||||
pub fn list_mental_models(&self, bank_id: &str, _verbose: bool) -> Result<types::MentalModelListResponse> {
|
||||
pub fn list_mental_models(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::MentalModelListResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_mental_models(bank_id, None, None, None, None, None, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.list_mental_models(bank_id, None, None, None, None, None, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_mental_model(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<types::MentalModelResponse> {
|
||||
pub fn get_mental_model(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
mental_model_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::MentalModelResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_mental_model(bank_id, mental_model_id, None, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.get_mental_model(bank_id, mental_model_id, None, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
@@ -569,7 +754,10 @@ impl ApiClient {
|
||||
_verbose: bool,
|
||||
) -> Result<types::CreateMentalModelResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.create_mental_model(bank_id, None, request).await?;
|
||||
let response = self
|
||||
.client
|
||||
.create_mental_model(bank_id, None, request)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
@@ -582,44 +770,86 @@ impl ApiClient {
|
||||
_verbose: bool,
|
||||
) -> Result<types::MentalModelResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.update_mental_model(bank_id, mental_model_id, None, request).await?;
|
||||
let response = self
|
||||
.client
|
||||
.update_mental_model(bank_id, mental_model_id, None, request)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_mental_model(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<serde_json::Value> {
|
||||
pub fn delete_mental_model(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
mental_model_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.delete_mental_model(bank_id, mental_model_id, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.delete_mental_model(bank_id, mental_model_id, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh_mental_model(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<types::AsyncOperationSubmitResponse> {
|
||||
pub fn refresh_mental_model(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
mental_model_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::AsyncOperationSubmitResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.refresh_mental_model(bank_id, mental_model_id, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.refresh_mental_model(bank_id, mental_model_id, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_mental_model_history(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<serde_json::Value> {
|
||||
pub fn get_mental_model_history(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
mental_model_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_mental_model_history(bank_id, mental_model_id, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.get_mental_model_history(bank_id, mental_model_id, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Directive Methods ---
|
||||
|
||||
pub fn list_directives(&self, bank_id: &str, _verbose: bool) -> Result<types::DirectiveListResponse> {
|
||||
pub fn list_directives(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::DirectiveListResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_directives(bank_id, None, None, None, None, None, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.list_directives(bank_id, None, None, None, None, None, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_directive(&self, bank_id: &str, directive_id: &str, _verbose: bool) -> Result<types::DirectiveResponse> {
|
||||
pub fn get_directive(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
directive_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::DirectiveResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_directive(bank_id, directive_id, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.get_directive(bank_id, directive_id, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
@@ -644,28 +874,47 @@ impl ApiClient {
|
||||
_verbose: bool,
|
||||
) -> Result<types::DirectiveResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.update_directive(bank_id, directive_id, None, request).await?;
|
||||
let response = self
|
||||
.client
|
||||
.update_directive(bank_id, directive_id, None, request)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_directive(&self, bank_id: &str, directive_id: &str, _verbose: bool) -> Result<serde_json::Value> {
|
||||
pub fn delete_directive(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
directive_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.delete_directive(bank_id, directive_id, None).await?;
|
||||
let response = self
|
||||
.client
|
||||
.delete_directive(bank_id, directive_id, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Consolidation Methods ---
|
||||
|
||||
pub fn trigger_consolidation(&self, bank_id: &str, _verbose: bool) -> Result<types::ConsolidationResponse> {
|
||||
pub fn trigger_consolidation(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::ConsolidationResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.trigger_consolidation(bank_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_observations(&self, bank_id: &str, _verbose: bool) -> Result<types::DeleteResponse> {
|
||||
pub fn clear_observations(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::DeleteResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.clear_observations(bank_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
@@ -682,16 +931,291 @@ impl ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Webhooks, audit logs, bank templates, and other endpoints added for full
|
||||
// OpenAPI coverage. Enforced by `uv run cli-coverage-check` in hindsight-dev.
|
||||
// ============================================================================
|
||||
|
||||
impl ApiClient {
|
||||
// --- Webhook Methods ---
|
||||
|
||||
pub fn list_webhooks(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::WebhookListResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_webhooks(bank_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_webhook(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
request: &types::CreateWebhookRequest,
|
||||
_verbose: bool,
|
||||
) -> Result<types::WebhookResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.create_webhook(bank_id, None, request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_webhook(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
webhook_id: &str,
|
||||
request: &types::UpdateWebhookRequest,
|
||||
_verbose: bool,
|
||||
) -> Result<types::WebhookResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self
|
||||
.client
|
||||
.update_webhook(bank_id, webhook_id, None, request)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_webhook(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
webhook_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::DeleteResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self
|
||||
.client
|
||||
.delete_webhook(bank_id, webhook_id, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_webhook_deliveries(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
webhook_id: &str,
|
||||
cursor: Option<&str>,
|
||||
limit: Option<i64>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::WebhookDeliveryListResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self
|
||||
.client
|
||||
.list_webhook_deliveries(bank_id, webhook_id, cursor, limit, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Audit Log Methods ---
|
||||
|
||||
pub fn list_audit_logs(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
action: Option<&str>,
|
||||
transport: Option<&str>,
|
||||
start_date: Option<&str>,
|
||||
end_date: Option<&str>,
|
||||
limit: Option<u64>,
|
||||
offset: Option<u64>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::AuditLogListResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let limit_nz = limit.and_then(std::num::NonZeroU64::new);
|
||||
let response = self
|
||||
.client
|
||||
.list_audit_logs(
|
||||
bank_id, action, end_date, limit_nz, offset, start_date, transport, None,
|
||||
)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn audit_log_stats(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
action: Option<&str>,
|
||||
period: Option<&str>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::AuditLogStatsResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self
|
||||
.client
|
||||
.audit_log_stats(bank_id, action, period, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Bank Template Methods ---
|
||||
|
||||
pub fn get_bank_template_schema(&self, _verbose: bool) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_bank_template_schema().await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn export_bank_template(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::BankTemplateManifest> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.export_bank_template(bank_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
/// Import a bank template manifest. The OpenAPI spec does not declare a
|
||||
/// request body for this endpoint, so the progenitor-generated client does
|
||||
/// not expose one — we POST the manifest JSON via raw HTTP instead.
|
||||
pub fn import_bank_template(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
manifest: &serde_json::Value,
|
||||
dry_run: bool,
|
||||
verbose: bool,
|
||||
) -> Result<types::BankTemplateImportResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let mut url = format!("{}/v1/default/banks/{}/import", self.base_url, bank_id);
|
||||
if dry_run {
|
||||
url.push_str("?dry_run=true");
|
||||
}
|
||||
if verbose {
|
||||
eprintln!("POST {}", url);
|
||||
}
|
||||
let response = self.http_client.post(&url).json(manifest).send().await?;
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let text = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Import failed ({}): {}", status, text);
|
||||
}
|
||||
let result: types::BankTemplateImportResponse = response.json().await?;
|
||||
Ok(result)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Document Methods ---
|
||||
|
||||
pub fn update_document(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
document_id: &str,
|
||||
tags: Option<Vec<String>>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::UpdateDocumentResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let request = types::UpdateDocumentRequest { tags };
|
||||
let response = self
|
||||
.client
|
||||
.update_document(bank_id, document_id, None, &request)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Memory Observation Methods ---
|
||||
|
||||
pub fn get_observation_history(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
memory_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self
|
||||
.client
|
||||
.get_observation_history(bank_id, memory_id, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_memory_observations(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
memory_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::ClearMemoryObservationsResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self
|
||||
.client
|
||||
.clear_memory_observations(bank_id, memory_id, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Operation Methods ---
|
||||
|
||||
pub fn retry_operation(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
operation_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::RetryOperationResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self
|
||||
.client
|
||||
.retry_operation(bank_id, operation_id, None)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Consolidation Recovery ---
|
||||
|
||||
pub fn recover_consolidation(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::RecoverConsolidationResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.recover_consolidation(bank_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Bank Disposition ---
|
||||
|
||||
pub fn update_bank_disposition(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
skepticism: u64,
|
||||
literalism: u64,
|
||||
empathy: u64,
|
||||
_verbose: bool,
|
||||
) -> Result<types::BankProfileResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let to_nz = |v: u64| -> Result<std::num::NonZeroU64> {
|
||||
std::num::NonZeroU64::new(v)
|
||||
.ok_or_else(|| anyhow::anyhow!("disposition traits must be 1-5"))
|
||||
};
|
||||
let request = types::UpdateDispositionRequest {
|
||||
disposition: types::DispositionTraits {
|
||||
skepticism: to_nz(skepticism)?,
|
||||
literalism: to_nz(literalism)?,
|
||||
empathy: to_nz(empathy)?,
|
||||
},
|
||||
};
|
||||
let response = self
|
||||
.client
|
||||
.update_bank_disposition(bank_id, None, &request)
|
||||
.await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types from the generated client for use in commands
|
||||
pub use types::{
|
||||
BankProfileResponse,
|
||||
MemoryItem,
|
||||
RecallRequest,
|
||||
RecallResponse,
|
||||
RecallResult,
|
||||
ReflectRequest,
|
||||
ReflectResponse,
|
||||
RetainRequest,
|
||||
BankProfileResponse, MemoryItem, RecallRequest, RecallResponse, RecallResult, ReflectRequest,
|
||||
ReflectResponse, RetainRequest,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Audit log commands.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::api::ApiClient;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
|
||||
/// List audit log entries for a bank
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn list(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
action: Option<String>,
|
||||
transport: Option<String>,
|
||||
start_date: Option<String>,
|
||||
end_date: Option<String>,
|
||||
limit: Option<u64>,
|
||||
offset: Option<u64>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching audit logs..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.list_audit_logs(
|
||||
bank_id,
|
||||
action.as_deref(),
|
||||
transport.as_deref(),
|
||||
start_date.as_deref(),
|
||||
end_date.as_deref(),
|
||||
limit,
|
||||
offset,
|
||||
verbose,
|
||||
);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let result = response?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Audit logs: {}", bank_id));
|
||||
println!(
|
||||
" {} {} ({} total)",
|
||||
ui::dim("Showing:"),
|
||||
result.items.len(),
|
||||
result.total
|
||||
);
|
||||
println!();
|
||||
if result.items.is_empty() {
|
||||
println!(" {}", ui::dim("No audit log entries."));
|
||||
} else {
|
||||
for entry in &result.items {
|
||||
let started = entry.started_at.as_deref().unwrap_or("-");
|
||||
let duration = entry
|
||||
.duration_ms
|
||||
.map(|d| format!("{}ms", d))
|
||||
.unwrap_or_else(|| "-".to_string());
|
||||
println!(
|
||||
" {} {} [{}] {}",
|
||||
ui::dim(started),
|
||||
ui::gradient_start(&entry.action),
|
||||
entry.transport,
|
||||
duration
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get audit log statistics for a bank
|
||||
pub fn stats(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
action: Option<String>,
|
||||
period: Option<String>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching audit log stats..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.audit_log_stats(bank_id, action.as_deref(), period.as_deref(), verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let result = response?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Audit stats: {}", bank_id));
|
||||
println!(" {} {}", ui::dim("Period:"), result.period);
|
||||
println!(" {} {}", ui::dim("Start:"), result.start);
|
||||
println!(" {} {}", ui::dim("Bucket:"), result.trunc);
|
||||
println!();
|
||||
if result.buckets.is_empty() {
|
||||
println!(" {}", ui::dim("No activity in this period."));
|
||||
} else {
|
||||
for bucket in &result.buckets {
|
||||
let json = serde_json::to_value(bucket)?;
|
||||
println!(" {}", json);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use anyhow::{anyhow, Result};
|
||||
use crate::api::ApiClient;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
pub fn list(client: &ApiClient, verbose: bool, output_format: OutputFormat) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
@@ -32,11 +32,16 @@ pub fn list(client: &ApiClient, verbose: bool, output_format: OutputFormat) -> R
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn disposition(client: &ApiClient, bank_id: &str, verbose: bool, output_format: OutputFormat) -> Result<()> {
|
||||
pub fn disposition(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching disposition..."))
|
||||
} else {
|
||||
@@ -58,11 +63,16 @@ pub fn disposition(client: &ApiClient, bank_id: &str, verbose: bool, output_form
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: OutputFormat) -> Result<()> {
|
||||
pub fn stats(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching statistics..."))
|
||||
} else {
|
||||
@@ -80,9 +90,21 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Statistics: {}", bank_id));
|
||||
|
||||
println!(" {} {}", ui::dim("memory units:"), ui::gradient_start(&stats.total_nodes.to_string()));
|
||||
println!(" {} {}", ui::dim("links:"), ui::gradient_mid(&stats.total_links.to_string()));
|
||||
println!(" {} {}", ui::dim("documents:"), ui::gradient_end(&stats.total_documents.to_string()));
|
||||
println!(
|
||||
" {} {}",
|
||||
ui::dim("memory units:"),
|
||||
ui::gradient_start(&stats.total_nodes.to_string())
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
ui::dim("links:"),
|
||||
ui::gradient_mid(&stats.total_links.to_string())
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
ui::dim("documents:"),
|
||||
ui::gradient_end(&stats.total_documents.to_string())
|
||||
);
|
||||
println!();
|
||||
|
||||
println!("{}", ui::gradient_text("─── Memory Units by Type ───"));
|
||||
@@ -90,7 +112,11 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou
|
||||
fact_types.sort_by_key(|(k, _)| *k);
|
||||
for (i, (fact_type, count)) in fact_types.iter().enumerate() {
|
||||
let t = i as f32 / fact_types.len().max(1) as f32;
|
||||
println!(" {:<10} {}", fact_type, ui::gradient(&count.to_string(), t));
|
||||
println!(
|
||||
" {:<10} {}",
|
||||
fact_type,
|
||||
ui::gradient(&count.to_string(), t)
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
@@ -99,7 +125,11 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou
|
||||
link_types.sort_by_key(|(k, _)| *k);
|
||||
for (i, (link_type, count)) in link_types.iter().enumerate() {
|
||||
let t = i as f32 / link_types.len().max(1) as f32;
|
||||
println!(" {:<10} {}", link_type, ui::gradient(&count.to_string(), t));
|
||||
println!(
|
||||
" {:<10} {}",
|
||||
link_type,
|
||||
ui::gradient(&count.to_string(), t)
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
@@ -108,7 +138,11 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou
|
||||
fact_type_links.sort_by_key(|(k, _)| *k);
|
||||
for (i, (fact_type, count)) in fact_type_links.iter().enumerate() {
|
||||
let t = i as f32 / fact_type_links.len().max(1) as f32;
|
||||
println!(" {:<10} {}", fact_type, ui::gradient(&count.to_string(), t));
|
||||
println!(
|
||||
" {:<10} {}",
|
||||
fact_type,
|
||||
ui::gradient(&count.to_string(), t)
|
||||
);
|
||||
}
|
||||
println!();
|
||||
|
||||
@@ -141,11 +175,17 @@ pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: Ou
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_name(client: &ApiClient, bank_id: &str, name: &str, verbose: bool, output_format: OutputFormat) -> Result<()> {
|
||||
pub fn update_name(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
name: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Updating bank name..."))
|
||||
} else {
|
||||
@@ -167,7 +207,7 @@ pub fn update_name(client: &ApiClient, bank_id: &str, name: &str, verbose: bool,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +217,7 @@ pub fn update_background(
|
||||
content: &str,
|
||||
no_update_disposition: bool,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let current_profile = if !no_update_disposition {
|
||||
client.get_profile(bank_id, verbose).ok()
|
||||
@@ -204,9 +244,10 @@ pub fn update_background(
|
||||
println!("\n{}", profile.mission);
|
||||
|
||||
if !no_update_disposition {
|
||||
if let (Some(old_p), Some(new_p)) =
|
||||
(current_profile.as_ref().map(|p| p.disposition.clone()), &profile.disposition)
|
||||
{
|
||||
if let (Some(old_p), Some(new_p)) = (
|
||||
current_profile.as_ref().map(|p| p.disposition.clone()),
|
||||
&profile.disposition,
|
||||
) {
|
||||
println!("\nDisposition changes:");
|
||||
println!(" Skepticism: {} → {}", old_p.skepticism, new_p.skepticism);
|
||||
println!(" Literalism: {} → {}", old_p.literalism, new_p.literalism);
|
||||
@@ -218,7 +259,7 @@ pub fn update_background(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,7 +370,12 @@ pub fn update(
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
if name.is_none() && mission_text.is_none() && skepticism.is_none() && literalism.is_none() && empathy.is_none() {
|
||||
if name.is_none()
|
||||
&& mission_text.is_none()
|
||||
&& skepticism.is_none()
|
||||
&& literalism.is_none()
|
||||
&& empathy.is_none()
|
||||
{
|
||||
anyhow::bail!("At least one field must be provided (--name, --mission, --skepticism, --literalism, --empathy)");
|
||||
}
|
||||
|
||||
@@ -407,20 +453,27 @@ pub fn graph(
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Memory Graph: {}", bank_id));
|
||||
|
||||
println!(" {} {}", ui::dim("Nodes:"), ui::gradient_start(&result.nodes.len().to_string()));
|
||||
println!(" {} {}", ui::dim("Edges:"), ui::gradient_end(&result.edges.len().to_string()));
|
||||
println!(
|
||||
" {} {}",
|
||||
ui::dim("Nodes:"),
|
||||
ui::gradient_start(&result.nodes.len().to_string())
|
||||
);
|
||||
println!(
|
||||
" {} {}",
|
||||
ui::dim("Edges:"),
|
||||
ui::gradient_end(&result.edges.len().to_string())
|
||||
);
|
||||
println!();
|
||||
|
||||
// Show sample of nodes
|
||||
if !result.nodes.is_empty() {
|
||||
println!("{}", ui::gradient_text("─── Sample Nodes ───"));
|
||||
for node in result.nodes.iter().take(5) {
|
||||
let fact_type = node.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let id = node.get("id")
|
||||
let fact_type = node
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let id = node.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
println!(" {} [{}]", ui::dim(id), fact_type);
|
||||
if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
|
||||
let preview: String = text.chars().take(60).collect();
|
||||
@@ -429,12 +482,18 @@ pub fn graph(
|
||||
}
|
||||
}
|
||||
if result.nodes.len() > 5 {
|
||||
println!(" {} more...", ui::dim(&format!("+ {}", result.nodes.len() - 5)));
|
||||
println!(
|
||||
" {} more...",
|
||||
ui::dim(&format!("+ {}", result.nodes.len() - 5))
|
||||
);
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
println!("{}", ui::dim("Use JSON output for full graph data: -o json"));
|
||||
println!(
|
||||
"{}",
|
||||
ui::dim("Use JSON output for full graph data: -o json")
|
||||
);
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
@@ -449,7 +508,7 @@ pub fn delete(
|
||||
bank_id: &str,
|
||||
yes: bool,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
// Confirmation prompt unless -y flag is used
|
||||
if !yes && output_format == OutputFormat::Pretty {
|
||||
@@ -494,7 +553,7 @@ pub fn delete(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,7 +586,11 @@ pub fn consolidate(
|
||||
ui::print_success("Consolidation triggered");
|
||||
println!(" {} {}", ui::dim("Operation ID:"), operation_id);
|
||||
if result.deduplicated {
|
||||
println!(" {} {}", ui::dim("Note:"), "Reusing existing pending consolidation task");
|
||||
println!(
|
||||
" {} {}",
|
||||
ui::dim("Note:"),
|
||||
"Reusing existing pending consolidation task"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
@@ -544,7 +607,13 @@ pub fn consolidate(
|
||||
// Poll for completion
|
||||
if output_format == OutputFormat::Pretty {
|
||||
println!();
|
||||
println!("{}", ui::dim(&format!("Polling every {}s for completion...", poll_interval)));
|
||||
println!(
|
||||
"{}",
|
||||
ui::dim(&format!(
|
||||
"Polling every {}s for completion...",
|
||||
poll_interval
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
@@ -561,7 +630,10 @@ pub fn consolidate(
|
||||
match op.map(|o| o.status.as_str()) {
|
||||
Some("completed") => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Consolidation completed ({}s)", elapsed));
|
||||
ui::print_success(&format!(
|
||||
"Consolidation completed ({}s)",
|
||||
elapsed
|
||||
));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -571,7 +643,10 @@ pub fn consolidate(
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("Unknown error");
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_error(&format!("Consolidation failed: {}", error_msg));
|
||||
ui::print_error(&format!(
|
||||
"Consolidation failed: {}",
|
||||
error_msg
|
||||
));
|
||||
}
|
||||
std::process::exit(1);
|
||||
}
|
||||
@@ -582,7 +657,10 @@ pub fn consolidate(
|
||||
}
|
||||
None => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_warning(&format!("Operation {} not found in list", operation_id));
|
||||
ui::print_warning(&format!(
|
||||
"Operation {} not found in list",
|
||||
operation_id
|
||||
));
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -732,37 +810,67 @@ pub fn set_config(
|
||||
let mut updates: HashMap<String, serde_json::Value> = HashMap::new();
|
||||
|
||||
if let Some(provider) = llm_provider {
|
||||
updates.insert("llm_provider".to_string(), serde_json::Value::String(provider));
|
||||
updates.insert(
|
||||
"llm_provider".to_string(),
|
||||
serde_json::Value::String(provider),
|
||||
);
|
||||
}
|
||||
if let Some(model) = llm_model {
|
||||
updates.insert("llm_model".to_string(), serde_json::Value::String(model));
|
||||
}
|
||||
if let Some(api_key) = llm_api_key {
|
||||
updates.insert("llm_api_key".to_string(), serde_json::Value::String(api_key));
|
||||
updates.insert(
|
||||
"llm_api_key".to_string(),
|
||||
serde_json::Value::String(api_key),
|
||||
);
|
||||
}
|
||||
if let Some(base_url) = llm_base_url {
|
||||
updates.insert("llm_base_url".to_string(), serde_json::Value::String(base_url));
|
||||
updates.insert(
|
||||
"llm_base_url".to_string(),
|
||||
serde_json::Value::String(base_url),
|
||||
);
|
||||
}
|
||||
if let Some(mission) = retain_mission {
|
||||
updates.insert("retain_mission".to_string(), serde_json::Value::String(mission));
|
||||
updates.insert(
|
||||
"retain_mission".to_string(),
|
||||
serde_json::Value::String(mission),
|
||||
);
|
||||
}
|
||||
if let Some(mode) = retain_extraction_mode {
|
||||
updates.insert("retain_extraction_mode".to_string(), serde_json::Value::String(mode));
|
||||
updates.insert(
|
||||
"retain_extraction_mode".to_string(),
|
||||
serde_json::Value::String(mode),
|
||||
);
|
||||
}
|
||||
if let Some(mission) = observations_mission {
|
||||
updates.insert("observations_mission".to_string(), serde_json::Value::String(mission));
|
||||
updates.insert(
|
||||
"observations_mission".to_string(),
|
||||
serde_json::Value::String(mission),
|
||||
);
|
||||
}
|
||||
if let Some(mission) = reflect_mission {
|
||||
updates.insert("reflect_mission".to_string(), serde_json::Value::String(mission));
|
||||
updates.insert(
|
||||
"reflect_mission".to_string(),
|
||||
serde_json::Value::String(mission),
|
||||
);
|
||||
}
|
||||
if let Some(skepticism) = disposition_skepticism {
|
||||
updates.insert("disposition_skepticism".to_string(), serde_json::Value::Number(skepticism.into()));
|
||||
updates.insert(
|
||||
"disposition_skepticism".to_string(),
|
||||
serde_json::Value::Number(skepticism.into()),
|
||||
);
|
||||
}
|
||||
if let Some(literalism) = disposition_literalism {
|
||||
updates.insert("disposition_literalism".to_string(), serde_json::Value::Number(literalism.into()));
|
||||
updates.insert(
|
||||
"disposition_literalism".to_string(),
|
||||
serde_json::Value::Number(literalism.into()),
|
||||
);
|
||||
}
|
||||
if let Some(empathy) = disposition_empathy {
|
||||
updates.insert("disposition_empathy".to_string(), serde_json::Value::Number(empathy.into()));
|
||||
updates.insert(
|
||||
"disposition_empathy".to_string(),
|
||||
serde_json::Value::Number(empathy.into()),
|
||||
);
|
||||
}
|
||||
|
||||
if updates.is_empty() {
|
||||
@@ -832,7 +940,10 @@ pub fn reset_config(
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Configuration reset to defaults for bank '{}'", bank_id));
|
||||
ui::print_success(&format!(
|
||||
"Configuration reset to defaults for bank '{}'",
|
||||
bank_id
|
||||
));
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
@@ -841,3 +952,188 @@ pub fn reset_config(
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set disposition traits (skepticism, literalism, empathy) via PUT /profile
|
||||
pub fn set_disposition(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
skepticism: u64,
|
||||
literalism: u64,
|
||||
empathy: u64,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Updating disposition..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response =
|
||||
client.update_bank_disposition(bank_id, skepticism, literalism, empathy, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let profile = response?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Disposition updated for bank '{}'", bank_id));
|
||||
ui::print_disposition(&profile);
|
||||
} else {
|
||||
output::print_output(&profile, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Recover from a stalled consolidation
|
||||
pub fn consolidation_recover(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Recovering consolidation..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.recover_consolidation(bank_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let result = response?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Consolidation recovered for bank '{}'", bank_id));
|
||||
let json = serde_json::to_value(&result)?;
|
||||
println!(
|
||||
" {}",
|
||||
serde_json::to_string_pretty(&json).unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Export a bank template manifest (bank config + mental models + directives)
|
||||
pub fn export_template(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
out_path: Option<std::path::PathBuf>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Exporting bank template..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.export_bank_template(bank_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let manifest = response?;
|
||||
let json = serde_json::to_string_pretty(&manifest)?;
|
||||
|
||||
if let Some(path) = out_path {
|
||||
std::fs::write(&path, &json)
|
||||
.map_err(|e| anyhow!("Failed to write {}: {}", path.display(), e))?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Template written to {}", path.display()));
|
||||
}
|
||||
} else if output_format == OutputFormat::Pretty {
|
||||
println!("{}", json);
|
||||
} else {
|
||||
output::print_output(&manifest, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import a bank template manifest from a JSON file
|
||||
pub fn import_template(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
manifest_path: &std::path::Path,
|
||||
dry_run: bool,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let raw = std::fs::read_to_string(manifest_path)
|
||||
.map_err(|e| anyhow!("Failed to read {}: {}", manifest_path.display(), e))?;
|
||||
let manifest: serde_json::Value = serde_json::from_str(&raw)
|
||||
.map_err(|e| anyhow!("Invalid JSON in {}: {}", manifest_path.display(), e))?;
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
let msg = if dry_run {
|
||||
"Validating bank template (dry run)..."
|
||||
} else {
|
||||
"Importing bank template..."
|
||||
};
|
||||
Some(ui::create_spinner(msg))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.import_bank_template(bank_id, &manifest, dry_run, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let result = response?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
if dry_run {
|
||||
ui::print_success(&format!("Template for bank '{}' validated", bank_id));
|
||||
} else {
|
||||
ui::print_success(&format!("Template imported into bank '{}'", bank_id));
|
||||
}
|
||||
println!(" directives created: {:?}", result.directives_created);
|
||||
println!(" directives updated: {:?}", result.directives_updated);
|
||||
println!(
|
||||
" mental models created: {:?}",
|
||||
result.mental_models_created
|
||||
);
|
||||
println!(
|
||||
" mental models updated: {:?}",
|
||||
result.mental_models_updated
|
||||
);
|
||||
println!(" config applied: {}", result.config_applied);
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetch the bank template JSON schema
|
||||
pub fn template_schema(
|
||||
client: &ApiClient,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching template schema..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_bank_template_schema(verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let schema = response?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
println!("{}", serde_json::to_string_pretty(&schema)?);
|
||||
} else {
|
||||
output::print_output(&schema, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -99,11 +99,13 @@ pub fn get(
|
||||
}
|
||||
|
||||
/// Create a new directive
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
name: &str,
|
||||
content: &str,
|
||||
priority: i64,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -117,7 +119,7 @@ pub fn create(
|
||||
name: name.to_string(),
|
||||
content: content.to_string(),
|
||||
is_active: true,
|
||||
priority: 0,
|
||||
priority,
|
||||
tags: vec![],
|
||||
};
|
||||
|
||||
@@ -143,6 +145,7 @@ pub fn create(
|
||||
}
|
||||
|
||||
/// Update a directive
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn update(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
@@ -150,11 +153,14 @@ pub fn update(
|
||||
name: Option<String>,
|
||||
content: Option<String>,
|
||||
is_active: Option<bool>,
|
||||
priority: Option<i64>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
if name.is_none() && content.is_none() && is_active.is_none() {
|
||||
anyhow::bail!("At least one of --name, --content, or --is-active must be provided");
|
||||
if name.is_none() && content.is_none() && is_active.is_none() && priority.is_none() {
|
||||
anyhow::bail!(
|
||||
"At least one of --name, --content, --is-active, or --priority must be provided"
|
||||
);
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
@@ -167,7 +173,7 @@ pub fn update(
|
||||
name,
|
||||
content,
|
||||
is_active,
|
||||
priority: None,
|
||||
priority,
|
||||
tags: None,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use anyhow::Result;
|
||||
use chrono::{Duration as ChronoDuration, NaiveDate, Utc};
|
||||
use std::collections::BTreeMap;
|
||||
use crate::api::ApiClient;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
use anyhow::Result;
|
||||
use chrono::{Duration as ChronoDuration, NaiveDate, Utc};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub fn list(
|
||||
client: &ApiClient,
|
||||
@@ -26,7 +26,13 @@ pub fn list(
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.list_documents(agent_id, query.as_deref(), Some(limit), Some(offset), verbose);
|
||||
let response = client.list_documents(
|
||||
agent_id,
|
||||
query.as_deref(),
|
||||
Some(limit),
|
||||
Some(offset),
|
||||
verbose,
|
||||
);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
@@ -35,13 +41,25 @@ pub fn list(
|
||||
match response {
|
||||
Ok(docs_response) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_info(&format!("Documents for bank '{}' (total: {})", agent_id, docs_response.total));
|
||||
ui::print_info(&format!(
|
||||
"Documents for bank '{}' (total: {})",
|
||||
agent_id, docs_response.total
|
||||
));
|
||||
for doc in &docs_response.items {
|
||||
let id = doc.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let created = doc.get("created_at").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let updated = doc.get("updated_at").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let created = doc
|
||||
.get("created_at")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let updated = doc
|
||||
.get("updated_at")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let text_len = doc.get("text_length").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let mem_count = doc.get("memory_unit_count").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let mem_count = doc
|
||||
.get("memory_unit_count")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
|
||||
println!("\n Document ID: {}", id);
|
||||
println!(" Created: {}", created);
|
||||
@@ -54,7 +72,7 @@ pub fn list(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +105,7 @@ fn list_with_date(
|
||||
let mut filtered_count = 0;
|
||||
|
||||
for doc in all_docs {
|
||||
let created_at = doc.get("created_at")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
let created_at = doc.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
|
||||
|
||||
// Parse the date part (YYYY-MM-DD) from created_at
|
||||
let doc_date = created_at.split('T').next().unwrap_or("");
|
||||
@@ -126,7 +142,10 @@ fn list_with_date(
|
||||
println!(" {} ({} documents)", date_str, docs.len());
|
||||
for doc in docs {
|
||||
let id = doc.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
let mem_count = doc.get("memory_unit_count").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let mem_count = doc
|
||||
.get("memory_unit_count")
|
||||
.and_then(|v| v.as_i64())
|
||||
.unwrap_or(0);
|
||||
println!(" - {} ({} memories)", id, mem_count);
|
||||
}
|
||||
println!();
|
||||
@@ -224,7 +243,7 @@ pub fn get(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,6 +279,45 @@ pub fn delete(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a document (currently only supports replacing tags)
|
||||
pub fn update(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
document_id: &str,
|
||||
tags: Option<Vec<String>>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
if tags.is_none() {
|
||||
anyhow::bail!("At least one of --tags must be provided");
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Updating document..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.update_document(bank_id, document_id, tags, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let result = response?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Document '{}' updated", document_id));
|
||||
let json = serde_json::to_value(&result)?;
|
||||
println!(
|
||||
" {}",
|
||||
serde_json::to_string_pretty(&json).unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -3,13 +3,16 @@ use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::api::{ApiClient, RecallRequest, ReflectRequest, MemoryItem, RetainRequest};
|
||||
use crate::api::{ApiClient, MemoryItem, RecallRequest, ReflectRequest, RetainRequest};
|
||||
use crate::config;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
|
||||
// Import types from generated client
|
||||
use hindsight_client::types::{Budget, ChunkIncludeOptions, FactsIncludeOptions, IncludeOptions, ReflectIncludeOptions, TagsMatch};
|
||||
use hindsight_client::types::{
|
||||
Budget, ChunkIncludeOptions, FactsIncludeOptions, IncludeOptions, ReflectIncludeOptions,
|
||||
TagsMatch,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json;
|
||||
|
||||
@@ -45,7 +48,12 @@ fn parse_budget(budget: &str) -> Budget {
|
||||
|
||||
// Helper function to parse tags_match string to TagsMatch enum
|
||||
fn parse_tags_match(tags_match: &Option<String>) -> TagsMatch {
|
||||
match tags_match.as_deref().unwrap_or("any").to_lowercase().as_str() {
|
||||
match tags_match
|
||||
.as_deref()
|
||||
.unwrap_or("any")
|
||||
.to_lowercase()
|
||||
.as_str()
|
||||
{
|
||||
"all" => TagsMatch::All,
|
||||
"any_strict" => TagsMatch::AnyStrict,
|
||||
"all_strict" => TagsMatch::AllStrict,
|
||||
@@ -86,13 +94,19 @@ pub fn list(
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Memories: {} (showing {}-{})", bank_id, offset + 1, offset + result.items.len() as i64));
|
||||
ui::print_section_header(&format!(
|
||||
"Memories: {} (showing {}-{})",
|
||||
bank_id,
|
||||
offset + 1,
|
||||
offset + result.items.len() as i64
|
||||
));
|
||||
|
||||
if result.items.is_empty() {
|
||||
println!(" {}", ui::dim("No memories found."));
|
||||
} else {
|
||||
for item in &result.items {
|
||||
let fact_type = item.get("type")
|
||||
let fact_type = item
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let type_t = match fact_type {
|
||||
@@ -102,9 +116,7 @@ pub fn list(
|
||||
_ => 0.5,
|
||||
};
|
||||
|
||||
let id = item.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let id = item.get("id").and_then(|v| v.as_str()).unwrap_or("unknown");
|
||||
|
||||
println!(
|
||||
" {} {}",
|
||||
@@ -172,7 +184,11 @@ pub fn get(
|
||||
|
||||
ui::print_section_header(&format!("Memory: {}", memory_id));
|
||||
|
||||
println!(" {} {}", ui::dim("Type:"), ui::gradient(&fact_type.to_uppercase(), type_t));
|
||||
println!(
|
||||
" {} {}",
|
||||
ui::dim("Type:"),
|
||||
ui::gradient(&fact_type.to_uppercase(), type_t)
|
||||
);
|
||||
println!(" {} {}", ui::dim("ID:"), result.id);
|
||||
|
||||
if let Some(doc_id) = &result.document_id {
|
||||
@@ -234,12 +250,9 @@ pub fn get(
|
||||
fn is_supported_file(path: &std::path::Path) -> bool {
|
||||
const SUPPORTED_EXTENSIONS: &[&str] = &[
|
||||
// Documents
|
||||
"pdf", "docx", "doc", "pptx", "ppt", "xlsx", "xls",
|
||||
// Images (OCR)
|
||||
"jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff",
|
||||
// Web / markup
|
||||
"html", "htm",
|
||||
// Text / data
|
||||
"pdf", "docx", "doc", "pptx", "ppt", "xlsx", "xls", // Images (OCR)
|
||||
"jpg", "jpeg", "png", "gif", "bmp", "webp", "tiff", // Web / markup
|
||||
"html", "htm", // Text / data
|
||||
"txt", "md", "csv", "json", "yaml", "yml", "toml", "xml", "rst", "adoc", "log",
|
||||
// Audio (transcription)
|
||||
"mp3", "wav", "ogg", "flac",
|
||||
@@ -250,6 +263,7 @@ fn is_supported_file(path: &std::path::Path) -> bool {
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn recall(
|
||||
client: &ApiClient,
|
||||
agent_id: &str,
|
||||
@@ -262,6 +276,7 @@ pub fn recall(
|
||||
chunk_max_tokens: i64,
|
||||
tags: Vec<String>,
|
||||
tags_match: Option<String>,
|
||||
query_timestamp: Option<String>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -286,11 +301,15 @@ pub fn recall(
|
||||
|
||||
let request = RecallRequest {
|
||||
query,
|
||||
types: if fact_type.is_empty() { None } else { Some(fact_type) },
|
||||
types: if fact_type.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(fact_type)
|
||||
},
|
||||
budget: Some(parse_budget(&budget)),
|
||||
max_tokens,
|
||||
trace,
|
||||
query_timestamp: None,
|
||||
query_timestamp,
|
||||
include,
|
||||
tags: if tags.is_empty() { None } else { Some(tags) },
|
||||
tags_match: parse_tags_match(&tags_match),
|
||||
@@ -312,10 +331,11 @@ pub fn recall(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn reflect(
|
||||
client: &ApiClient,
|
||||
agent_id: &str,
|
||||
@@ -327,6 +347,9 @@ pub fn reflect(
|
||||
tags: Vec<String>,
|
||||
tags_match: Option<String>,
|
||||
include_facts: bool,
|
||||
fact_types: Option<Vec<String>>,
|
||||
exclude_mental_models: bool,
|
||||
exclude_mental_model_ids: Option<Vec<String>>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -340,8 +363,9 @@ pub fn reflect(
|
||||
let response_schema = if let Some(path) = schema_path {
|
||||
let schema_content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read schema file: {}", path.display()))?;
|
||||
let schema: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&schema_content)
|
||||
.with_context(|| format!("Failed to parse JSON schema from: {}", path.display()))?;
|
||||
let schema: serde_json::Map<String, serde_json::Value> =
|
||||
serde_json::from_str(&schema_content)
|
||||
.with_context(|| format!("Failed to parse JSON schema from: {}", path.display()))?;
|
||||
Some(schema)
|
||||
} else {
|
||||
None
|
||||
@@ -356,6 +380,21 @@ pub fn reflect(
|
||||
None
|
||||
};
|
||||
|
||||
// Map the CLI fact-type strings (world, experience, observation) into the
|
||||
// generated FactTypesItem enum. Unknown values are dropped — the server
|
||||
// would reject them anyway.
|
||||
let mapped_fact_types = fact_types.as_ref().map(|types| {
|
||||
types
|
||||
.iter()
|
||||
.filter_map(|t| match t.to_lowercase().as_str() {
|
||||
"world" => Some(hindsight_client::types::FactTypesItem::World),
|
||||
"experience" => Some(hindsight_client::types::FactTypesItem::Experience),
|
||||
"observation" => Some(hindsight_client::types::FactTypesItem::Observation),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let request = ReflectRequest {
|
||||
query,
|
||||
budget: Some(parse_budget(&budget)),
|
||||
@@ -366,9 +405,9 @@ pub fn reflect(
|
||||
tags: if tags.is_empty() { None } else { Some(tags) },
|
||||
tags_match: parse_tags_match(&tags_match),
|
||||
tag_groups: None,
|
||||
fact_types: None,
|
||||
exclude_mental_models: false,
|
||||
exclude_mental_model_ids: None,
|
||||
fact_types: mapped_fact_types,
|
||||
exclude_mental_models,
|
||||
exclude_mental_model_ids,
|
||||
};
|
||||
|
||||
let response = client.reflect(agent_id, &request, verbose);
|
||||
@@ -386,10 +425,11 @@ pub fn reflect(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn retain(
|
||||
client: &ApiClient,
|
||||
agent_id: &str,
|
||||
@@ -397,6 +437,7 @@ pub fn retain(
|
||||
doc_id: Option<String>,
|
||||
context: Option<String>,
|
||||
r#async: bool,
|
||||
document_tags: Option<Vec<String>>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -418,12 +459,13 @@ pub fn retain(
|
||||
tags: None,
|
||||
observation_scopes: None,
|
||||
strategy: None,
|
||||
update_mode: None,
|
||||
};
|
||||
|
||||
let request = RetainRequest {
|
||||
items: vec![item],
|
||||
async_: r#async,
|
||||
document_tags: None,
|
||||
document_tags,
|
||||
};
|
||||
|
||||
let response = client.retain(agent_id, &request, r#async, verbose);
|
||||
@@ -450,7 +492,7 @@ pub fn retain(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -617,7 +659,7 @@ pub fn delete(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -687,10 +729,85 @@ pub fn clear(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the observation history for a memory unit
|
||||
pub fn history(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
memory_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching observation history..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_observation_history(bank_id, memory_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let result = response?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
println!("{}", serde_json::to_string_pretty(&result)?);
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear the observations attached to a specific memory unit
|
||||
pub fn clear_observations(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
memory_id: &str,
|
||||
yes: bool,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
if !yes && output_format == OutputFormat::Pretty {
|
||||
let msg = format!(
|
||||
"Clear observations for memory '{}'? They will be re-derived on next consolidation.",
|
||||
memory_id
|
||||
);
|
||||
if !ui::prompt_confirmation(&msg)? {
|
||||
ui::print_info("Operation cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Clearing observations..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.clear_memory_observations(bank_id, memory_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let result = response?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Cleared observations for memory '{}'", memory_id));
|
||||
let json = serde_json::to_value(&result)?;
|
||||
println!(
|
||||
" {}",
|
||||
serde_json::to_string_pretty(&json).unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -699,8 +816,17 @@ mod tests {
|
||||
#[test]
|
||||
fn test_is_supported_file_text_extensions() {
|
||||
let supported = [
|
||||
"file.txt", "file.md", "file.json", "file.yaml", "file.yml",
|
||||
"file.toml", "file.xml", "file.csv", "file.log", "file.rst", "file.adoc",
|
||||
"file.txt",
|
||||
"file.md",
|
||||
"file.json",
|
||||
"file.yaml",
|
||||
"file.yml",
|
||||
"file.toml",
|
||||
"file.xml",
|
||||
"file.csv",
|
||||
"file.log",
|
||||
"file.rst",
|
||||
"file.adoc",
|
||||
];
|
||||
for filename in supported {
|
||||
assert!(
|
||||
@@ -714,9 +840,16 @@ mod tests {
|
||||
#[test]
|
||||
fn test_is_supported_file_binary_extensions() {
|
||||
let supported = [
|
||||
"file.pdf", "file.docx", "file.pptx", "file.xlsx",
|
||||
"file.png", "file.jpg", "file.jpeg", "file.gif",
|
||||
"file.mp3", "file.wav",
|
||||
"file.pdf",
|
||||
"file.docx",
|
||||
"file.pptx",
|
||||
"file.xlsx",
|
||||
"file.png",
|
||||
"file.jpg",
|
||||
"file.jpeg",
|
||||
"file.gif",
|
||||
"file.mp3",
|
||||
"file.wav",
|
||||
];
|
||||
for filename in supported {
|
||||
assert!(
|
||||
@@ -738,9 +871,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_is_supported_file_unsupported_extensions() {
|
||||
let unsupported = [
|
||||
"file.exe", "file.bin", "file.zip", "file.tar", "file.gz",
|
||||
];
|
||||
let unsupported = ["file.exe", "file.bin", "file.zip", "file.tar", "file.gz"];
|
||||
for filename in unsupported {
|
||||
assert!(
|
||||
!is_supported_file(Path::new(filename)),
|
||||
|
||||
@@ -95,12 +95,16 @@ pub fn get(
|
||||
}
|
||||
|
||||
/// Create a new mental model
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
name: &str,
|
||||
source_query: &str,
|
||||
id: Option<&str>,
|
||||
tags: Vec<String>,
|
||||
max_tokens: i64,
|
||||
trigger_refresh_after_consolidation: bool,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -110,13 +114,28 @@ pub fn create(
|
||||
None
|
||||
};
|
||||
|
||||
// Only send a trigger when the user opted in, so the server's default
|
||||
// behaviour is preserved otherwise.
|
||||
let trigger = if trigger_refresh_after_consolidation {
|
||||
Some(types::MentalModelTriggerInput {
|
||||
refresh_after_consolidation: true,
|
||||
exclude_mental_models: false,
|
||||
exclude_mental_model_ids: None,
|
||||
fact_types: None,
|
||||
tag_groups: None,
|
||||
tags_match: None,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = types::CreateMentalModelRequest {
|
||||
id: id.map(|s| s.to_string()),
|
||||
name: name.to_string(),
|
||||
source_query: source_query.to_string(),
|
||||
max_tokens: 2048,
|
||||
tags: vec![],
|
||||
trigger: None,
|
||||
max_tokens,
|
||||
tags,
|
||||
trigger,
|
||||
};
|
||||
|
||||
let response = client.create_mental_model(bank_id, &request, verbose);
|
||||
@@ -139,16 +158,29 @@ pub fn create(
|
||||
}
|
||||
|
||||
/// Update a mental model
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn update(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
mental_model_id: &str,
|
||||
name: Option<String>,
|
||||
source_query: Option<String>,
|
||||
max_tokens: Option<i64>,
|
||||
tags: Option<Vec<String>>,
|
||||
trigger_refresh_after_consolidation: Option<bool>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
if name.is_none() {
|
||||
anyhow::bail!("--name must be provided");
|
||||
if name.is_none()
|
||||
&& source_query.is_none()
|
||||
&& max_tokens.is_none()
|
||||
&& tags.is_none()
|
||||
&& trigger_refresh_after_consolidation.is_none()
|
||||
{
|
||||
anyhow::bail!(
|
||||
"At least one of --name, --source-query, --max-tokens, --tags, or \
|
||||
--trigger-refresh-after-consolidation must be provided"
|
||||
);
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
@@ -157,12 +189,23 @@ pub fn update(
|
||||
None
|
||||
};
|
||||
|
||||
// Only build a trigger override when the user actually passed the flag;
|
||||
// sending None leaves the existing trigger config untouched on the server.
|
||||
let trigger = trigger_refresh_after_consolidation.map(|refresh| types::MentalModelTriggerInput {
|
||||
refresh_after_consolidation: refresh,
|
||||
exclude_mental_models: false,
|
||||
exclude_mental_model_ids: None,
|
||||
fact_types: None,
|
||||
tag_groups: None,
|
||||
tags_match: None,
|
||||
});
|
||||
|
||||
let request = types::UpdateMentalModelRequest {
|
||||
name,
|
||||
source_query: None,
|
||||
max_tokens: None,
|
||||
tags: None,
|
||||
trigger: None,
|
||||
source_query,
|
||||
max_tokens,
|
||||
tags,
|
||||
trigger,
|
||||
};
|
||||
|
||||
let response = client.update_mental_model(bank_id, mental_model_id, &request, verbose);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod audit;
|
||||
pub mod bank;
|
||||
pub mod chunk;
|
||||
pub mod directive;
|
||||
@@ -6,6 +7,7 @@ pub mod entity;
|
||||
pub mod explore;
|
||||
pub mod health;
|
||||
pub mod memory;
|
||||
pub mod operation;
|
||||
pub mod mental_model;
|
||||
pub mod operation;
|
||||
pub mod tag;
|
||||
pub mod webhook;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use anyhow::Result;
|
||||
use crate::api::ApiClient;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn list(
|
||||
client: &ApiClient,
|
||||
@@ -27,7 +27,10 @@ pub fn list(
|
||||
if ops_response.operations.is_empty() {
|
||||
ui::print_info("No operations found");
|
||||
} else {
|
||||
ui::print_info(&format!("Found {} operation(s)", ops_response.operations.len()));
|
||||
ui::print_info(&format!(
|
||||
"Found {} operation(s)",
|
||||
ops_response.operations.len()
|
||||
));
|
||||
for op in &ops_response.operations {
|
||||
println!("\n Operation ID: {}", op.id);
|
||||
println!(" Type: {}", op.task_type);
|
||||
@@ -43,7 +46,7 @@ pub fn list(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +131,40 @@ pub fn cancel(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e)
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Retry a failed async operation
|
||||
pub fn retry(
|
||||
client: &ApiClient,
|
||||
agent_id: &str,
|
||||
operation_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Retrying operation..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.retry_operation(agent_id, operation_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let result = response?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Operation '{}' retried", operation_id));
|
||||
let json = serde_json::to_value(&result)?;
|
||||
println!(
|
||||
" {}",
|
||||
serde_json::to_string_pretty(&json).unwrap_or_default()
|
||||
);
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Webhook commands for managing event delivery hooks.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::api::ApiClient;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
|
||||
use hindsight_client::types;
|
||||
|
||||
/// List webhooks for a bank
|
||||
pub fn list(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching webhooks..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.list_webhooks(bank_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let result = response?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Webhooks: {}", bank_id));
|
||||
if result.items.is_empty() {
|
||||
println!(" {}", ui::dim("No webhooks configured."));
|
||||
} else {
|
||||
for wh in &result.items {
|
||||
let status = if wh.enabled {
|
||||
ui::gradient_start("enabled")
|
||||
} else {
|
||||
ui::dim("disabled")
|
||||
};
|
||||
println!(" {} [{}] {}", ui::gradient_start(&wh.id), status, wh.url);
|
||||
if !wh.event_types.is_empty() {
|
||||
println!(" events: {}", wh.event_types.join(", "));
|
||||
}
|
||||
println!();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a new webhook
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
url: &str,
|
||||
event_types: Vec<String>,
|
||||
enabled: bool,
|
||||
secret: Option<String>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Creating webhook..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let effective_events = if event_types.is_empty() {
|
||||
vec!["consolidation.completed".to_string()]
|
||||
} else {
|
||||
event_types
|
||||
};
|
||||
|
||||
let request = types::CreateWebhookRequest {
|
||||
enabled,
|
||||
event_types: effective_events,
|
||||
http_config: None,
|
||||
secret,
|
||||
url: url.to_string(),
|
||||
};
|
||||
|
||||
let response = client.create_webhook(bank_id, &request, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let wh = response?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Webhook '{}' created", wh.id));
|
||||
println!(" URL: {}", wh.url);
|
||||
println!(" Events: {}", wh.event_types.join(", "));
|
||||
} else {
|
||||
output::print_output(&wh, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update a webhook
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn update(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
webhook_id: &str,
|
||||
url: Option<String>,
|
||||
event_types: Option<Vec<String>>,
|
||||
enabled: Option<bool>,
|
||||
secret: Option<String>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
if url.is_none() && event_types.is_none() && enabled.is_none() && secret.is_none() {
|
||||
anyhow::bail!(
|
||||
"At least one of --url, --event-types, --enabled, or --secret must be provided"
|
||||
);
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Updating webhook..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = types::UpdateWebhookRequest {
|
||||
enabled,
|
||||
event_types,
|
||||
http_config: None,
|
||||
secret,
|
||||
url,
|
||||
};
|
||||
|
||||
let response = client.update_webhook(bank_id, webhook_id, &request, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let wh = response?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Webhook '{}' updated", wh.id));
|
||||
} else {
|
||||
output::print_output(&wh, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a webhook
|
||||
pub fn delete(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
webhook_id: &str,
|
||||
yes: bool,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
if !yes && output_format == OutputFormat::Pretty {
|
||||
let message = format!(
|
||||
"Are you sure you want to delete webhook '{}'? This cannot be undone.",
|
||||
webhook_id
|
||||
);
|
||||
if !ui::prompt_confirmation(&message)? {
|
||||
ui::print_info("Operation cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Deleting webhook..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.delete_webhook(bank_id, webhook_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let result = response?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
if result.success {
|
||||
ui::print_success(&format!("Webhook '{}' deleted", webhook_id));
|
||||
} else {
|
||||
ui::print_error("Failed to delete webhook");
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// List recent delivery attempts for a webhook
|
||||
pub fn deliveries(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
webhook_id: &str,
|
||||
cursor: Option<String>,
|
||||
limit: Option<i64>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching deliveries..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response =
|
||||
client.list_webhook_deliveries(bank_id, webhook_id, cursor.as_deref(), limit, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
let result = response?;
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Deliveries for {}", webhook_id));
|
||||
if result.items.is_empty() {
|
||||
println!(" {}", ui::dim("No delivery attempts recorded."));
|
||||
} else {
|
||||
for d in &result.items {
|
||||
println!(
|
||||
" {} [{}] {} — attempts: {}",
|
||||
ui::gradient_start(&d.id),
|
||||
d.event_type,
|
||||
d.last_response_status
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "-".to_string()),
|
||||
d.attempts
|
||||
);
|
||||
if let Some(err) = &d.last_error {
|
||||
println!(" {} {}", ui::dim("error:"), err);
|
||||
}
|
||||
}
|
||||
if let Some(cursor) = &result.next_cursor {
|
||||
println!();
|
||||
println!(" {} {}", ui::dim("next cursor:"), cursor);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+863
-88
File diff suppressed because it is too large
Load Diff
@@ -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.22
|
||||
version: 0.5.0
|
||||
servers:
|
||||
- url: /
|
||||
paths:
|
||||
@@ -3570,7 +3570,7 @@ components:
|
||||
type: integer
|
||||
entity_labels:
|
||||
items:
|
||||
type: string
|
||||
additionalProperties: {}
|
||||
nullable: true
|
||||
type: array
|
||||
entities_allow_free_form:
|
||||
@@ -4784,6 +4784,12 @@ components:
|
||||
strategy:
|
||||
nullable: true
|
||||
type: string
|
||||
update_mode:
|
||||
enum:
|
||||
- replace
|
||||
- append
|
||||
nullable: true
|
||||
type: string
|
||||
required:
|
||||
- content
|
||||
title: MemoryItem
|
||||
|
||||
@@ -3,7 +3,7 @@ Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
API version: 0.4.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
// APIClient manages communication with the Hindsight HTTP API API v0.5.0
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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.22
|
||||
API version: 0.5.0
|
||||
*/
|
||||
|
||||
// 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