diff --git a/.claude/skills/code-review/SKILL.md b/.claude/skills/code-review/SKILL.md index 87cda217a6..f7776d87d2 100644 --- a/.claude/skills/code-review/SKILL.md +++ b/.claude/skills/code-review/SKILL.md @@ -172,7 +172,8 @@ For each non-trivial change: If any files in `hindsight-integrations/` were added or changed, verify: - **Tests exist** — the integration must have tests that simulate/exercise the external framework (not just pure unit tests of helpers). Check for a `tests/` directory with meaningful test files. - **CI job exists** — check `.github/workflows/test.yml` for a corresponding `test--integration` job. If missing, flag it. -- **Release process** — check that the integration name is in the `VALID_INTEGRATIONS` array in `scripts/release-integration.sh`. If missing, flag it. +- **Release process** — check that the integration name is in the `VALID_INTEGRATIONS` array in `scripts/release-integration.sh` AND in the `INTEGRATIONS` dict in `hindsight-dev/hindsight_dev/generate_changelog.py` (the changelog generator keeps its own list; a release fails at the changelog step if the name is missing there). If either is missing, flag it. +- **Docs gallery + sidebar entry** — the integration must have an entry in `hindsight-docs/src/data/integrations.json`. This file is the **single source of truth** that drives both the integrations gallery and the docs sidebar (the sidebar category is injected from it at render time across all docs versions). The entry needs an internal `/sdks/integrations/` `link` and a matching page at `hindsight-docs/docs-integrations/.md(x)`. The `hindsight-docs/scripts/check-integrations.mjs` build step enforces both directions — forward: every internal JSON entry has a doc page; reverse: every released tag (`integrations//vX.Y.Z`) appears in the JSON (private infra like `cloudflare-oauth-proxy` is in the script's `EXCLUDED` set). Flag any integration that is released (or being released) but missing from `integrations.json`, and any JSON entry without a doc page. Do **not** hand-edit `versioned_sidebars/*.json` to add integration links — they are positional placeholders filled from the JSON. - **Code standards** — the integration code must follow all Python style rules (type hints, no raw dicts, no tuple returns, etc.). ### 10. Check MCP tool registration completeness @@ -191,6 +192,18 @@ If a migration adds a new PostgreSQL table (look for `CREATE TABLE` / `op.create - The guard test `test_backup_tables_covers_entire_schema` in `tests/test_admin_backup_restore.py` enforces this — flag it as a **must fix** if a new table is absent from `BACKUP_TABLES`. - Oracle-only tables (e.g. `observation_sources`) are intentionally excluded — admin backup/restore is PostgreSQL-only. +### 11b. Check new config flags update the env template + +If the diff adds a new configuration field (a new `ENV_*` / `HINDSIGHT_*` env var +in `hindsight-api-slim/hindsight_api/config.py`): +- **`.env.example`** (repo root) — must add the variable (commented if optional) + alongside the docs entry in `hindsight-docs/docs/developer/configuration.md`. + A flag added to `config.py` but absent from `.env.example` is a **should fix**. +- **`hindsight-embed/hindsight_embed/env.example`** — the bundled copy must stay + byte-identical to the repo-root `.env.example` (it seeds embed/profile configs). + The `test_bundled_template_matches_repo_root` sync test fails on drift; if the + root file changed without re-copying, flag it as a **must fix**. + ### 12. Review against other coding standards Check the diff for violations of the standards listed above: @@ -217,6 +230,7 @@ Present a clear summary organized by severity: - Direct DB access (raw SQL / `acquire_with_retry` / `fq_table`) in an `api/` handler instead of a `MemoryEngine` method - Tenant-scoped data accessed without authentication enforced in the engine (`_authenticate_tenant` / `get_bank_profile`) - New integration missing tests, CI job, or release-integration.sh entry +- Released/added integration missing from `hindsight-docs/src/data/integrations.json`, or a JSON entry with no `docs-integrations/` page (fails the docs build via `check-integrations.mjs`) - New PostgreSQL table missing from `BACKUP_TABLES` in `admin/cli.py` (silent data loss on restore) **Should fix** — issues that hurt code quality: diff --git a/.claude/skills/hs-release/SKILL.md b/.claude/skills/hs-release/SKILL.md new file mode 100644 index 0000000000..1bf9598ecd --- /dev/null +++ b/.claude/skills/hs-release/SKILL.md @@ -0,0 +1,116 @@ +--- +name: hs-release +description: Cut a core Hindsight release (vX.Y.Z) and open the changelog + blog PR. Use when asked to cut/start a release, bump the version, or publish a new Hindsight version. +user_invocable: true +--- + +# Hindsight Release + +Cut a **core** Hindsight release and open the accompanying changelog/blog PR. This is for the core +product version (API, clients, CLI, control plane, Helm). **Integrations are versioned +independently** — use `scripts/release-integration.sh` for those, not this skill. + +The release is **irreversible and outward-facing**: it tags a version and pushes it straight to +`main`, which triggers CI that publishes packages to PyPI / npm / Helm. Confirm the version number +and that the intended fixes are already merged to `main` before you start. + +## Step 0 — Pre-flight + +1. **Decide the base.** A release is cut from the latest `origin/main`, never from a feature + branch. `git fetch origin --tags` first. Confirm the "couple of fixes" the user means are + actually merged to `main` (`git log v..origin/main --oneline`). +2. **Find where `main` is checked out.** `main` is often already checked out in a sibling worktree + (`git worktree list`). You **cannot** check out `main` in a second worktree — run the release in + the worktree that already holds it. If that worktree is dirty with throwaway cruft + (`.next-*` tsconfig paths, screenshots), `git stash push -u`, fast-forward to `origin/main`, + run the release, then `git stash pop`. +3. **Pitfall:** never pipe the checkout in an `&&` chain like + `git checkout main 2>&1 | tail && git reset --hard ...` — the pipe's exit status is `tail`'s + (always 0), so a failed checkout won't stop the chain and the `reset` fires on the **wrong + branch**. Check out as its own command and verify `git branch --show-current` before resetting. + +## Step 1 — Cut the release + +Run from the worktree on a clean `main`: + +```bash +./scripts/release.sh # e.g. 0.8.1 (no leading v) +``` + +`release.sh` bumps the version in every component, regenerates the OpenAPI spec + all client SDKs, +updates docs versioning, commits `Release v`, tags `v`, and **pushes the commit +and tag directly to `main`**. The push triggers the `Release` GitHub Actions workflow that builds +and publishes the packages. It is **not** a PR. + +Verify after: `gh run list --limit 5` should show the `Release v` workflow running, and +`git ls-remote --tags origin v` should return the tag. + +## Step 2 — Changelog + blog PR (separate) + +Done **after** the tag exists, as its own PR (precedent: v0.8.0 = #2053, v0.8.1 = #2080). Work on a +branch off the new `main`: + +```bash +git checkout -b docs-changelog- origin/main +``` + +Only spin up a separate worktree (`git worktree add ../hindsight-changelog- -b +docs-changelog- origin/main`) if you can't get a clean checkout otherwise — e.g. `main` is +held in another worktree and the current one has work you don't want to disturb. + +**Branch naming:** use the `docs-` (hyphen) convention, e.g. `docs-changelog-0.8.1`. A remote +branch literally named `docs` exists, so any `docs/...` branch is rejected on push with +`directory file conflict`. + +### Changelog + +```bash +uv run --directory hindsight-dev generate-changelog +``` + +LLM-summarizes the commits between the previous tag and `v` and prepends an entry to +`hindsight-docs/src/pages/changelog/index.md`. Requires `OPENAI_API_KEY` (already in the repo +`.env`). It excludes `hindsight-integrations/` source, but new integrations whose commits also +touched docs will still appear — that matches precedent, leave them in the **changelog**. + +### Blog post + +Hand-write `hindsight-docs/blog/YYYY-MM-DD-version-X-Y-Z.md` (mirror an existing one; patch +releases are short — see `2026-06-02-version-0-7-2.md`). Guidance: + +- **Explain user impact, not internals/mechanism.** Lead with what the user can now do and what to + set. Config/env-var names are fine (developer-facing), code symbols and internals are not. +- **Do not list integrations in the release blog.** The core blog covers core engine / API / + ops changes; each integration ships its own changelog. (Integrations may still appear in the + generated `changelog/index.md` — that's fine; just keep them out of the blog.) +- Call out an upgrade recommendation when there are operational/data-integrity fixes. +- Validate formatting: `npx prettier --check `. + +### Sync the docs skill + +```bash +./scripts/generate-docs-skill.sh +``` + +Refreshes `skills/hindsight-docs/references/changelog/index.md`. It will also bump +`skills/hindsight-docs/references/openapi.json` by one version — `release.sh` regenerates the skill +*before* bumping OpenAPI, so the skill copy lags a version in the release commit; this step syncs +it. Expect a one-line `version` diff there; keep it. + +### Commit, push, PR + +```bash +git add -A +git commit --no-verify -m "docs: changelog and blog post for v" +git push -u origin docs-changelog- +gh pr create --base main --title "docs: changelog and blog post for v" --body "..." +``` + +Expected files in the PR: the changelog entry, the new blog post, the regenerated skill changelog +mirror, and the skill `openapi.json` version sync. + +## Cleanup + +If you created a temporary worktree, remove it once the PR is up +(`git worktree remove ../hindsight-changelog-`; the branch stays on origin). Restore any +stash you popped in Step 0. diff --git a/.env.example b/.env.example index 27e208f9f8..bfd7f9ccfb 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,13 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1 HINDSIGHT_API_HOST=0.0.0.0 HINDSIGHT_API_PORT=8888 HINDSIGHT_API_LOG_LEVEL=info +# Optional retain chunking override for structured logs/transcripts. +# Unset uses HINDSIGHT_API_RETAIN_CHUNK_SIZE as the structured-chunk limit. +# HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE= + +# Dry-run extraction preview endpoint (POST /memories/dry-run-extract). Enabled by default; it makes +# a real LLM call but stores nothing. Set to false to remove the endpoint (returns 404). +# HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=true # Base Path / Reverse Proxy Support (Optional) # Set these when deploying behind a reverse proxy with path-based routing @@ -59,6 +66,7 @@ HINDSIGHT_API_LOG_LEVEL=info # HINDSIGHT_API_READ_DATABASE_URL= # Optional read-replica URL. When set, recall queries (semantic, BM25, graph, temporal) flow through a separate pool against this URL, offloading the primary. Typically points to a read-only endpoint (CNPG's -ro service or Aurora reader endpoint). # HINDSIGHT_API_MIGRATION_DATABASE_URL= # Direct PostgreSQL URL for migrations (bypasses PgBouncer). Falls back to DATABASE_URL. # HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public) +# HINDSIGHT_API_MIGRATION_CONCURRENCY=1 # Tenant schemas to migrate concurrently (PG only, each in its own process; per-schema work stays sequential). Each worker has ~1-2s startup cost + uses ~3 DB connections, so it only pays off with many schemas (tens+) or slow migrations; keep concurrency*3 <= spare max_connections. Default: 1 (sequential). # Vector Extension (Optional - uses pgvector by default) # Options: "pgvector" (default), "vchord", "pgvectorscale" (DiskANN) @@ -79,6 +87,18 @@ HINDSIGHT_API_LOG_LEVEL=info # korean_lindera/lindera(korean), ngram(min,max), edge_ngram(min,max) # HINDSIGHT_API_TEXT_SEARCH_EXTENSION_PG_SEARCH_TOKENIZER= +# File Parser (Optional - uses markitdown by default) +# HINDSIGHT_API_FILE_PARSER=markitdown +# Enable image OCR for MarkItDown using an OpenAI-compatible OCR/vision endpoint. +# These OCR settings are independent from HINDSIGHT_API_LLM_* because MarkItDown +# uses the OpenAI SDK directly and requires Chat Completions image input support. +# When OCR is enabled, API_KEY, BASE_URL, and MODEL are required. +# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED=false +# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY= +# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL= +# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL= +# HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT= + # Embeddings Configuration (Optional - uses local by default) # Provider: "local" (default), "onnx", "tei", "openai", "cohere", "google", "openrouter", "zeroentropy", "litellm", or "litellm-sdk" # HINDSIGHT_API_EMBEDDINGS_PROVIDER=local @@ -142,6 +162,10 @@ HINDSIGHT_API_LOG_LEVEL=info # Custom service name and environment (optional, defaults: hindsight-api, development) # HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production # HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production +# +# Expose async-operation queue + consolidation-backlog gauges on /metrics. +# Runs periodic per-schema COUNT queries on a background task (disabled by default). +# HINDSIGHT_API_METRICS_BACKLOG_ENABLED=true # ----------------------------------------------------------------------------- # Control Plane (Optional) diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 5ace4600a1..0000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,6 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 6e9587ef2d..6978a0330e 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -22,6 +22,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + with: + fetch-depth: 0 # fetch tags so check-released-integrations can see them - uses: actions/setup-node@v6 with: node-version: 20 diff --git a/.github/workflows/perf-test.yml b/.github/workflows/perf-test.yml index 2987d053e7..c78000d8bb 100644 --- a/.github/workflows/perf-test.yml +++ b/.github/workflows/perf-test.yml @@ -23,6 +23,7 @@ on: - retain - recall - recall-with-observations + - recall-temporal - consolidation - graph-maintenance default: "" diff --git a/.github/workflows/release-integration.yml b/.github/workflows/release-integration.yml index 560cdc96f8..451ae783aa 100644 --- a/.github/workflows/release-integration.yml +++ b/.github/workflows/release-integration.yml @@ -9,7 +9,11 @@ jobs: publish: runs-on: ubuntu-latest permissions: - id-token: write # for PyPI trusted publishing + id-token: write # for PyPI trusted publishing + build-provenance attestations + attestations: write # for actions/attest-build-provenance (Obsidian assets) + # No `contents: write`: we never create releases in this repo. The Obsidian + # plugin's distribution release is pushed to its dedicated repo using + # OBSIDIAN_DIST_TOKEN (see the "Mirror Obsidian plugin" step below). steps: - uses: actions/checkout@v6 @@ -112,6 +116,71 @@ jobs: working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }} run: npm run build + # Build-provenance attestations for the Obsidian release assets (community-store + # recommendation). Runs after the build so main.js exists. The assets are + # released in the dedicated repo while the build runs here, so users verify at + # owner scope: `gh attestation verify main.js --owner vectorize-io`. + - name: Attest Obsidian plugin build provenance + if: steps.type.outputs.type == 'typescript' && steps.info.outputs.integration == 'obsidian' + uses: actions/attest-build-provenance@v2 + with: + subject-path: | + hindsight-integrations/obsidian/main.js + hindsight-integrations/obsidian/styles.css + + # ── Obsidian plugin — mirror to its dedicated repo + cut the BRAT release ── + # We do NOT create a GitHub Release in this monorepo: per-integration + # releases pollute the repo's release list (it's for the core product) and + # steal the "Latest" badge, and BRAT / the community store read a repo's + # *latest* release — not a tag — so they can't target a tag in a monorepo. + # + # Instead this monorepo stays the source of truth, and on each obsidian + # release we mirror hindsight-integrations/obsidian/ → the *root* of + # github.com/vectorize-io/hindsight-obsidian (git subtree, history + # preserved) and cut the BRAT / community-store release *there*. + # + # Requires secret OBSIDIAN_DIST_TOKEN — a token with `contents: write` on + # vectorize-io/hindsight-obsidian (fine-grained PAT or app installation + # token). The dedicated repo is generated; never edit it directly. + - name: Mirror Obsidian plugin to its dedicated repo + if: steps.type.outputs.type == 'typescript' && steps.info.outputs.integration == 'obsidian' + env: + DIST_TOKEN: ${{ secrets.OBSIDIAN_DIST_TOKEN }} + run: | + set -euo pipefail + VERSION="${{ steps.info.outputs.version }}" + DIST_REPO="vectorize-io/hindsight-obsidian" + OBS_DIR="hindsight-integrations/obsidian" + + # `git subtree split` needs full history; the default checkout is shallow. + git fetch --unshallow 2>/dev/null || true + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # The runner injects the default GITHUB_TOKEN as an http.extraheader via + # an *included* config file (/home/runner/work/_temp/git-credentials-*.config), + # so `git config --local --unset-all` can't remove it and it authenticates + # the push as github-actions[bot] (no access to the dedicated repo → 403). + # The documented way to drop an inherited extraheader is to RESET the list + # with an empty value: since command-line `-c` is read last, the empty + # value clears the accumulated headers (including the included one) at + # request-build time. The dist token then comes from the push URL → a + # single Authorization header. + git subtree split --prefix="$OBS_DIR" -b _obs_dist + git -c "http.https://github.com/.extraheader=" \ + push "https://x-access-token:${DIST_TOKEN}@github.com/${DIST_REPO}.git" _obs_dist:main + + # Cut the BRAT / community-store release. Bare version tag (e.g. 0.1.0) + # to match manifest.json — idempotent so re-runs just refresh the assets. + export GH_TOKEN="$DIST_TOKEN" + ASSETS="$OBS_DIR/main.js $OBS_DIR/manifest.json $OBS_DIR/styles.css" + NOTES="Hindsight for Obsidian v${VERSION}. Install via BRAT (add ${DIST_REPO}) or copy main.js/manifest.json/styles.css into /.obsidian/plugins/hindsight/." + if gh release view "$VERSION" --repo "$DIST_REPO" >/dev/null 2>&1; then + gh release upload "$VERSION" $ASSETS --repo "$DIST_REPO" --clobber + else + gh release create "$VERSION" $ASSETS --repo "$DIST_REPO" --title "$VERSION" --notes "$NOTES" + fi + - name: Publish TypeScript package to npm if: steps.type.outputs.type == 'typescript' working-directory: ./hindsight-integrations/${{ steps.info.outputs.integration }} @@ -121,7 +190,12 @@ jobs: EXIT_CODE=$? echo "$OUTPUT" if [ $EXIT_CODE -ne 0 ]; then - if echo "$OUTPUT" | grep -q "cannot publish over"; then + # Treat "already published" as success so re-pointed-tag re-runs stay green. + # "cannot publish over" = the version exists. TLOG_CREATE_ENTRY_ERROR / 409 + # "equivalent entry already exists in the transparency log" = the identical + # --provenance artifact was already logged on a prior run (Sigstore tlog is + # idempotent); the package is published, so this is benign. + if echo "$OUTPUT" | grep -qE "cannot publish over|TLOG_CREATE_ENTRY_ERROR|already exists in the transparency log"; then echo "Package version already published, skipping..." exit 0 fi diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index da4cdced0c..06e02499e7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,9 +32,14 @@ jobs: integration-tests: ${{ steps.filter.outputs.integration-tests }} integrations-openclaw: ${{ steps.filter.outputs.integrations-openclaw }} integrations-ai-sdk: ${{ steps.filter.outputs.integrations-ai-sdk }} + integrations-agent-framework: ${{ steps.filter.outputs.integrations-agent-framework }} + integrations-composio: ${{ steps.filter.outputs.integrations-composio }} integrations-chat: ${{ steps.filter.outputs.integrations-chat }} integrations-claude-code: ${{ steps.filter.outputs.integrations-claude-code }} + integrations-cline: ${{ steps.filter.outputs.integrations-cline }} integrations-codex: ${{ steps.filter.outputs.integrations-codex }} + integrations-continue: ${{ steps.filter.outputs.integrations-continue }} + integrations-cursor-cli: ${{ steps.filter.outputs.integrations-cursor-cli }} integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }} integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }} integrations-pydantic-ai: ${{ steps.filter.outputs.integrations-pydantic-ai }} @@ -44,11 +49,15 @@ jobs: integrations-llamaindex: ${{ steps.filter.outputs.integrations-llamaindex }} integrations-paperclip: ${{ steps.filter.outputs.integrations-paperclip }} integrations-opencode: ${{ steps.filter.outputs.integrations-opencode }} + integrations-cursor: ${{ steps.filter.outputs.integrations-cursor }} + integrations-zed: ${{ steps.filter.outputs.integrations-zed }} integrations-n8n: ${{ steps.filter.outputs.integrations-n8n }} + integrations-zapier: ${{ steps.filter.outputs.integrations-zapier }} integrations-cloudflare-oauth-proxy: ${{ steps.filter.outputs.integrations-cloudflare-oauth-proxy }} integrations-superagent: ${{ steps.filter.outputs.integrations-superagent }} integrations-lockfiles: ${{ steps.filter.outputs.integrations-lockfiles }} integrations-openai-agents: ${{ steps.filter.outputs.integrations-openai-agents }} + integrations-openhands: ${{ steps.filter.outputs.integrations-openhands }} integrations-pipecat: ${{ steps.filter.outputs.integrations-pipecat }} integrations-agentcore: ${{ steps.filter.outputs.integrations-agentcore }} integrations-smolagents: ${{ steps.filter.outputs.integrations-smolagents }} @@ -58,6 +67,9 @@ jobs: integrations-vapi: ${{ steps.filter.outputs.integrations-vapi }} integrations-flowise: ${{ steps.filter.outputs.integrations-flowise }} integrations-google-adk: ${{ steps.filter.outputs.integrations-google-adk }} + integrations-obsidian: ${{ steps.filter.outputs.integrations-obsidian }} + integrations-omo: ${{ steps.filter.outputs.integrations-omo }} + integrations-haystack: ${{ steps.filter.outputs.integrations-haystack }} tools-agent-sdk: ${{ steps.filter.outputs.tools-agent-sdk }} integrations-roo-code: ${{ steps.filter.outputs.integrations-roo-code }} dev: ${{ steps.filter.outputs.dev }} @@ -111,6 +123,9 @@ jobs: docs: - 'hindsight-docs/**' - '*.md' + # Integration changes can add/rename integrations, which the docs + # build's integrations check validates against integrations.json. + - 'hindsight-integrations/**' embed: - 'hindsight-embed/**' all-npm: @@ -125,12 +140,22 @@ jobs: - 'hindsight-integrations/openclaw/**' integrations-ai-sdk: - 'hindsight-integrations/ai-sdk/**' + integrations-agent-framework: + - 'hindsight-integrations/agent-framework/**' + integrations-composio: + - 'hindsight-integrations/composio/**' integrations-chat: - 'hindsight-integrations/chat/**' integrations-claude-code: - 'hindsight-integrations/claude-code/**' + integrations-cline: + - 'hindsight-integrations/cline/**' integrations-codex: - 'hindsight-integrations/codex/**' + integrations-continue: + - 'hindsight-integrations/continue/**' + integrations-cursor-cli: + - 'hindsight-integrations/cursor-cli/**' integrations-crewai: - 'hindsight-integrations/crewai/**' integrations-litellm: @@ -145,12 +170,20 @@ jobs: - 'hindsight-integrations/langgraph/**' integrations-llamaindex: - 'hindsight-integrations/llamaindex/**' + integrations-haystack: + - 'hindsight-integrations/haystack/**' integrations-paperclip: - 'hindsight-integrations/paperclip/**' integrations-opencode: - 'hindsight-integrations/opencode/**' + integrations-cursor: + - 'hindsight-integrations/cursor/**' + integrations-zed: + - 'hindsight-integrations/zed/**' integrations-n8n: - 'hindsight-integrations/n8n/**' + integrations-zapier: + - 'hindsight-integrations/zapier/**' integrations-cloudflare-oauth-proxy: - 'hindsight-integrations/cloudflare-oauth-proxy/**' integrations-superagent: @@ -161,6 +194,8 @@ jobs: - 'scripts/check-integration-lockfiles.sh' integrations-openai-agents: - 'hindsight-integrations/openai-agents/**' + integrations-openhands: + - 'hindsight-integrations/openhands/**' integrations-pipecat: - 'hindsight-integrations/pipecat/**' integrations-agentcore: @@ -179,6 +214,10 @@ jobs: - 'hindsight-integrations/flowise/**' integrations-google-adk: - 'hindsight-integrations/google-adk/**' + integrations-obsidian: + - 'hindsight-integrations/obsidian/**' + integrations-omo: + - 'hindsight-integrations/omo/**' tools-agent-sdk: - 'hindsight-tools/hindsight-agent-sdk/**' integrations-roo-code: @@ -439,6 +478,126 @@ jobs: working-directory: ./hindsight-integrations/claude-code run: python -m pytest tests/ -v + test-cursor-integration: + needs: [detect-changes] + if: >- + github.event_name != 'pull_request_review' && + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.integrations-cursor == '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 Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install pytest + run: pip install pytest + + - name: Run tests + working-directory: ./hindsight-integrations/cursor + run: python -m pytest tests/ -v + + test-zed-integration: + needs: [detect-changes] + if: >- + github.event_name != 'pull_request_review' && + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.integrations-zed == '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 Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install package and pytest + working-directory: ./hindsight-integrations/zed + # Installs the package (incl. the zstandard runtime dep) so the threads.db + # reader tests can decompress Zed's zstd blobs. + run: pip install -e . pytest + + - name: Run tests + working-directory: ./hindsight-integrations/zed + # PR CI runs only the deterministic bucket; the real-LLM E2E bucket + # (requires_real_llm) needs a live Hindsight server and runs separately. + run: python -m pytest tests/ -v -m "not requires_real_llm" + + test-omo-integration: + needs: [detect-changes] + if: >- + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.integrations-omo == 'true' || + needs.detect-changes.outputs.ci == 'true') + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || '' }} + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Install pytest + run: pip install pytest + + - name: Run tests + working-directory: ./hindsight-integrations/omo + run: python -m pytest tests/ -v + + test-cline-integration: + needs: [detect-changes] + if: >- + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.integrations-cline == 'true' || + needs.detect-changes.outputs.ci == 'true') + runs-on: ubuntu-latest + timeout-minutes: 30 + + 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 cline integration + working-directory: ./hindsight-integrations/cline + run: uv build + + - name: Install dependencies + working-directory: ./hindsight-integrations/cline + run: uv sync --frozen + + - name: Run tests + working-directory: ./hindsight-integrations/cline + run: uv run pytest tests -v + test-codex-integration: needs: [detect-changes] if: >- @@ -465,6 +624,43 @@ jobs: working-directory: ./hindsight-integrations/codex run: python -m pytest tests/ -v + test-cursor-cli-integration: + needs: [detect-changes] + if: >- + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.integrations-cursor-cli == 'true' || + needs.detect-changes.outputs.ci == 'true') + runs-on: ubuntu-latest + timeout-minutes: 30 + + 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 cursor-cli integration + working-directory: ./hindsight-integrations/cursor-cli + run: uv build + + - name: Install dependencies + working-directory: ./hindsight-integrations/cursor-cli + run: uv sync --frozen + + - name: Run tests + working-directory: ./hindsight-integrations/cursor-cli + run: uv run pytest tests -v + build-ai-sdk-integration: needs: [detect-changes] if: >- @@ -590,6 +786,37 @@ jobs: working-directory: ./hindsight-integrations/n8n run: npm run build + test-zapier-integration: + needs: [detect-changes] + if: >- + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.integrations-zapier == 'true' || + needs.detect-changes.outputs.ci == 'true') + runs-on: ubuntu-latest + timeout-minutes: 30 + + 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/zapier + run: npm install --no-fund --no-audit + + - name: Validate app definition + working-directory: ./hindsight-integrations/zapier + run: npm run validate + + - name: Run tests + working-directory: ./hindsight-integrations/zapier + run: npm test + test-hindsight-agent-sdk: needs: [detect-changes] if: >- @@ -838,17 +1065,28 @@ jobs: 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: '3.11' + python-version-file: ".python-version" - - name: Install pytest - run: pip install pytest + - name: Build roo-code integration + working-directory: ./hindsight-integrations/roo-code + run: uv build + + - name: Install dependencies + working-directory: ./hindsight-integrations/roo-code + run: uv sync --frozen - name: Run tests working-directory: ./hindsight-integrations/roo-code - run: python -m pytest tests/ -v + run: uv run pytest tests -v build-control-plane: needs: [detect-changes] @@ -930,6 +1168,7 @@ jobs: - uses: actions/checkout@v6 with: ref: ${{ github.event.pull_request.head.sha || '' }} + fetch-depth: 0 # fetch tags so check-released-integrations can see them - name: Set up Node.js uses: actions/setup-node@v6 @@ -938,6 +1177,12 @@ jobs: cache: 'npm' cache-dependency-path: package-lock.json + # Fail fast before the (slow) build: every integrations.json entry must have a + # doc page, and every released integration tag must be in integrations.json. + # Needs no npm install (pure Node) and uses the tags fetched above. + - name: Check integrations (single source of truth) + run: node hindsight-docs/scripts/check-integrations.mjs + - name: Install dependencies run: npm ci --workspace=hindsight-docs @@ -2862,6 +3107,88 @@ jobs: # (requires_real_llm) needs a live Hindsight server and runs separately. run: uv run pytest tests -v -m "not requires_real_llm" + test-composio-integration: + needs: [detect-changes] + if: >- + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.integrations-composio == 'true' || + needs.detect-changes.outputs.ci == 'true') + runs-on: ubuntu-latest + timeout-minutes: 30 + + 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 composio integration + working-directory: ./hindsight-integrations/composio + run: uv build + + - name: Install dependencies + working-directory: ./hindsight-integrations/composio + run: uv sync --frozen + + - name: Lint + working-directory: ./hindsight-integrations/composio + run: uv run ruff check . + + - name: Run tests + working-directory: ./hindsight-integrations/composio + # PR CI runs only the deterministic bucket; the real-LLM E2E bucket + # (requires_real_llm) needs a live Hindsight server and runs separately. + run: uv run pytest tests -v -m "not requires_real_llm" + + test-continue-integration: + needs: [detect-changes] + if: >- + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.integrations-continue == 'true' || + needs.detect-changes.outputs.ci == 'true') + runs-on: ubuntu-latest + timeout-minutes: 30 + + 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 continue integration + working-directory: ./hindsight-integrations/continue + run: uv build + + - name: Install dependencies + working-directory: ./hindsight-integrations/continue + run: uv sync --frozen + + - name: Run tests + working-directory: ./hindsight-integrations/continue + # PR CI runs only the deterministic bucket; the real-LLM E2E bucket + # (requires_real_llm) needs a live Hindsight server and runs separately. + run: uv run pytest tests -v -m "not requires_real_llm" + test-smolagents-integration: needs: [detect-changes] if: >- @@ -2965,6 +3292,84 @@ jobs: working-directory: ./hindsight-integrations/flowise run: npm test + test-obsidian-integration: + needs: [detect-changes] + if: >- + github.event_name != 'pull_request_review' && + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.integrations-obsidian == '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/obsidian + run: npm install --no-audit --no-fund + + - name: Type check + working-directory: ./hindsight-integrations/obsidian + run: npx tsc --noEmit + + - name: Build + working-directory: ./hindsight-integrations/obsidian + run: npm run build + + - name: Run tests + working-directory: ./hindsight-integrations/obsidian + run: npm test + + test-agent-framework-integration: + needs: [detect-changes] + if: >- + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.integrations-agent-framework == 'true' || + needs.detect-changes.outputs.ci == 'true') + runs-on: ubuntu-latest + timeout-minutes: 30 + + 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 agent-framework integration + working-directory: ./hindsight-integrations/agent-framework + run: uv build + + - name: Install dependencies + working-directory: ./hindsight-integrations/agent-framework + run: uv sync --frozen + + - name: Lint + working-directory: ./hindsight-integrations/agent-framework + run: uv run ruff check . + + - name: Run tests + working-directory: ./hindsight-integrations/agent-framework + # PR CI runs only the deterministic bucket; the real-LLM E2E bucket + # (requires_real_llm) needs a live Hindsight server and runs separately. + run: uv run pytest tests -v -m "not requires_real_llm" + test-crewai-integration: needs: [detect-changes] if: >- @@ -3233,6 +3638,45 @@ jobs: # (requires_real_llm) needs a live Hindsight server and runs separately. run: uv run pytest tests -v -m "not requires_real_llm" + test-haystack-integration: + needs: [detect-changes] + if: >- + github.event_name != 'pull_request_review' && + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.integrations-haystack == '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 haystack integration + working-directory: ./hindsight-integrations/haystack + run: uv build + + - name: Install dependencies + working-directory: ./hindsight-integrations/haystack + run: uv sync --frozen + + - name: Run tests + working-directory: ./hindsight-integrations/haystack + # PR CI runs only the deterministic bucket; the real-LLM E2E bucket + # (requires_real_llm) needs a live Hindsight server and runs separately. + run: uv run pytest tests -v -m "not requires_real_llm" + test-openai-agents-integration: needs: [detect-changes] if: >- @@ -3272,6 +3716,45 @@ jobs: # (requires_real_llm) needs a live Hindsight server and runs separately. run: uv run pytest tests -v -m "not requires_real_llm" + test-openhands-integration: + needs: [detect-changes] + if: >- + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.integrations-openhands == 'true' || + needs.detect-changes.outputs.ci == 'true') + runs-on: ubuntu-latest + timeout-minutes: 30 + + 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 openhands integration + working-directory: ./hindsight-integrations/openhands + run: uv build + + - name: Install dependencies + working-directory: ./hindsight-integrations/openhands + run: uv sync --frozen + + - name: Run tests + working-directory: ./hindsight-integrations/openhands + # PR CI runs only the deterministic bucket; the real-LLM E2E bucket + # (requires_real_llm) needs a live Hindsight server and runs separately. + run: uv run pytest tests -v -m "not requires_real_llm" + test-claude-agent-sdk-integration: needs: [detect-changes] if: >- @@ -3416,6 +3899,49 @@ jobs: echo "=== API Server Logs ===" cat /tmp/slim-api-server.log 2>/dev/null || true + verify-embed-control-center-bundle: + # The control center UI (Preact + Tailwind) is built with Vite and its static + # output is committed (served as-is by the embed's Python http.server, no Node + # at runtime). We can't byte-diff the committed bundle against a fresh build — + # Vite's content-hashed asset filenames aren't reproducible across the CI + # runner's OS/arch vs the committer's. So instead verify: (1) the committed + # bundle is a real, wired Vite build (index.html references JS/CSS that exist), + # and (2) the source still builds cleanly. + needs: [detect-changes] + if: >- + github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.embed == 'true' || + needs.detect-changes.outputs.ci == 'true' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha || '' }} + + - name: Setup Node + uses: actions/setup-node@v6 + with: + node-version: '22' + + # Check the committed bundle BEFORE building (the build overwrites static/). + - name: Verify the committed bundle is wired + working-directory: ./hindsight-embed/hindsight_embed/control_center + run: | + test -f static/index.html || { echo "::error::static/index.html missing — run 'npm run build' in control_center/ui and commit static/"; exit 1; } + js=$(grep -oE 'assets/[A-Za-z0-9_.-]+\.js' static/index.html | head -1) + css=$(grep -oE 'assets/[A-Za-z0-9_.-]+\.css' static/index.html | head -1) + { [ -n "$js" ] && [ -f "static/$js" ]; } || { echo "::error::index.html does not reference a committed JS bundle — rebuild the UI and commit static/"; exit 1; } + { [ -n "$css" ] && [ -f "static/$css" ]; } || { echo "::error::index.html does not reference a committed CSS bundle — rebuild the UI and commit static/"; exit 1; } + echo "committed bundle is wired ✓" + + - name: Verify the source builds cleanly + working-directory: ./hindsight-embed/hindsight_embed/control_center/ui + run: | + npm ci + npm run build + echo "control center UI builds ✓" + test-embed: needs: [detect-changes] if: >- @@ -3631,7 +4157,8 @@ jobs: target="$RUNNER_TEMP/install-test" PYTHONPATH="$target" python -c " from hindsight_embed.daemon_embed_manager import DaemonEmbedManager - cmd = DaemonEmbedManager()._find_api_command() + # api_version is only used for the uvx fallback; the binary branch ignores it. + cmd = DaemonEmbedManager()._find_api_command('0.0.0') print('Resolved command:', cmd) assert len(cmd) == 1 and cmd[0].endswith('hindsight-api.exe'), ( f'Expected sibling hindsight-api.exe, got {cmd!r}. ' @@ -3992,6 +4519,60 @@ jobs: fi done + # Dead-code detection beyond what ruff's F401/F841 catch (those are already + # BLOCKING via the ruff config + the verify-generated-files job). + # + # - knip (control plane): BLOCKING on unused files / dependencies / unlisted + # dependencies. These are unambiguous — an orphaned file or a dead + # package.json entry — so they fail the build. + # - vulture (Python) + knip unused *exports*: ADVISORY only. vulture's + # function/argument heuristics false-positive on FastAPI/SQLAlchemy/Pydantic + # patterns, and the control plane intentionally keeps an unused shadcn/ui + # component surface, so these are surfaced in the step summary, not gated. + check-unused-code: + needs: [detect-changes] + if: >- + (github.event_name == 'workflow_dispatch' || + needs.detect-changes.outputs.core == 'true' || + needs.detect-changes.outputs.control-plane == 'true' || + needs.detect-changes.outputs.ci == 'true') + runs-on: ubuntu-latest + timeout-minutes: 15 + 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 Node.js + uses: actions/setup-node@v6 + with: + node-version: '20' + cache: 'npm' + cache-dependency-path: package-lock.json + + - name: Install Control Plane dependencies + run: npm install --workspace=hindsight-control-plane + + - name: knip — unused files / dependencies (blocking) + working-directory: hindsight-control-plane + run: npx --yes knip@5 --no-progress --include files,dependencies,unlisted + + - name: Advisory scan — vulture + knip exports + continue-on-error: true + run: | + { + echo '## Dead-code scan (advisory)' + echo '' + echo '```' + ./scripts/hooks/check-unused.sh 2>&1 | sed 's/\x1b\[[0-9;]*m//g' + echo '```' + } | tee -a "$GITHUB_STEP_SUMMARY" + verify-generated-files: runs-on: ubuntu-latest timeout-minutes: 30 @@ -4173,10 +4754,14 @@ jobs: - build-openclaw-integration - smoke-openclaw-install - test-claude-code-integration + - test-cursor-integration + - test-cline-integration - test-codex-integration + - test-cursor-cli-integration - build-ai-sdk-integration - test-ai-sdk-integration-deno - test-opencode-integration + - test-omo-integration - test-cloudflare-oauth-proxy-integration - build-chat-integration - test-paperclip-integration @@ -4205,9 +4790,12 @@ jobs: - test-integration - test-ag2-integration - test-autogen-integration + - test-continue-integration - test-smolagents-integration - test-dify-integration - test-flowise-integration + - test-obsidian-integration + - test-agent-framework-integration - test-crewai-integration - test-langgraph-integration - test-superagent-integration @@ -4215,10 +4803,13 @@ jobs: - test-pydantic-ai-integration - test-llamaindex-integration - test-openai-agents-integration + - test-openhands-integration - test-agentcore-integration + - test-haystack-integration - test-pip-slim - test-embed - test-embed-windows + - verify-embed-control-center-bundle - test-hindsight-all - test-hindsight-agent-sdk - test-claude-agent-sdk-integration diff --git a/.gitignore b/.gitignore index 59ab6e95c8..2adde0e7cd 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,8 @@ node_modules/ # Environment variables and local config .env +.env.bak* +.env.*.bak docker-compose.yml docker-compose.override.yml @@ -59,4 +61,5 @@ hindsight-integrations/_drafts/ # Changelog is now tracked in hindsight-docs/src/pages/changelog.md # CHANGELOG.md -blog-post* \ No newline at end of file +blog-post* +.worktrees/ diff --git a/CLAUDE.md b/CLAUDE.md index 7865db1da8..b2c2769e7e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -216,6 +216,18 @@ migration file dispatches through `run_for_dialect`, which calls either ./scripts/hooks/lint.sh ``` +Dead-code detection runs in CI (the `check-unused-code` job) at two levels: +- **Blocking:** unused imports (ruff `F401`) and variables (`F841`) — `lint.sh` auto-removes + them and `verify-generated-files` fails on any leftover diff; and **knip** for orphaned + control-plane files / unused (or unlisted) `package.json` dependencies. +- **Advisory:** whole unused Python functions (vulture) and unused control-plane *exports* + (the shadcn/ui surface is kept on purpose) — surfaced, not gated. + +Run both locally with: +```bash +./scripts/hooks/check-unused.sh +``` + **After completing any implementation work, run `/code-review`** to verify your changes against project standards (missing tests, dead code, type safety, etc.). Fix any "must fix" issues before considering the task done. **MANDATORY: Run `/code-review` before pushing code or creating a pull request.** Do not push or create a PR until all "must fix" issues are resolved. @@ -315,7 +327,10 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten ``` 2. **main.py** (`hindsight-api-slim/hindsight_api/main.py`): - - Add field to the manual `HindsightConfig()` constructor call (search for "CLI override") + - No change is needed for ordinary environment-backed config fields. The CLI starts from `_get_raw_config()`, + so new `HindsightConfig` fields are carried through automatically. + - If the new field should be overridable by a CLI flag, add the argparse option in `_parse_cli_args()` and include + that field in the `dataclasses.replace(config, ...)` call near the "CLI override" comment. 3. **Use hierarchical config in MemoryEngine**: ```python @@ -335,6 +350,16 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten - Add to appropriate section table with Variable, Description, Default - Mark if it's hierarchical (can be overridden per-bank) +6. **Env template** (`.env.example`): + - Add the variable to the appropriate section, commented if optional, with a + short inline comment describing it (mirror the documentation entry). + - This file is the single source of truth for the env template: + `scripts/dev/setup.sh` copies it to `.env`, and `hindsight-embed` ships a + bundled copy (`hindsight-embed/hindsight_embed/env.example`) that seeds + embed/profile configs. After editing `.env.example`, re-copy it to the + embed package (`cp .env.example hindsight-embed/hindsight_embed/env.example`) + or the `test_bundled_template_matches_repo_root` sync test will fail. + #### Hierarchical vs Static Guidelines **Hierarchical** (per-bank overridable): @@ -351,7 +376,7 @@ Fields must be categorized as either **hierarchical** (can be overridden per-ten ```bash cp .env.example .env -# Edit .env with LLM API key +# Edit .env with the LLM provider/model and credentials for your setup # Python deps uv sync --directory hindsight-api-slim/ @@ -360,10 +385,10 @@ uv sync --directory hindsight-api-slim/ npm install ``` -Required env vars: +Common LLM settings: - `HINDSIGHT_API_LLM_PROVIDER`: openai, anthropic, gemini, groq, minimax, ollama, lmstudio -- `HINDSIGHT_API_LLM_API_KEY`: Your API key -- `HINDSIGHT_API_LLM_MODEL`: Model name (e.g., gpt-4o-mini, claude-sonnet-4-20250514) +- `HINDSIGHT_API_LLM_API_KEY`: API key for providers that require one +- `HINDSIGHT_API_LLM_MODEL`: Model name (defaults are provider-specific) Optional (uses local models by default): - `HINDSIGHT_API_EMBEDDINGS_PROVIDER`: local (default) or tei diff --git a/README.md b/README.md index bec34b7b7b..418c5c7971 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,6 @@ [![CI](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/release.yml) [![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3nhbm4w29-LeSJ5Ixi6j8PdiYOCPlOgg) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -[![gitcgr](https://gitcgr.com/badge/vectorize-io/hindsight.svg)](https://gitcgr.com/vectorize-io/hindsight) ![PyPI - Downloads](https://img.shields.io/pypi/dm/hindsight-api?label=PyPI) ![NPM Downloads](https://img.shields.io/npm/dm/%40vectorize-io%2Fhindsight-client?logoColor=orange&label=NPM&color=blue&link=https%3A%2F%2Fwww.npmjs.com%2Fpackage%2F%40vectorize-io%2Fhindsight-client)
@@ -143,6 +142,8 @@ main(); pip install hindsight-all -U ``` +On Intel (x86_64) Macs, install `hindsight-all-slim` instead — see [Supported Platforms](#supported-platforms). + ```python import os from hindsight import HindsightServer, HindsightClient @@ -300,6 +301,19 @@ client.reflect(bank_id="my-bank", query="What should I know about Alice?") [![Star History Chart](https://api.star-history.com/svg?repos=vectorize-io/hindsight&type=date&legend=top-left)](https://www.star-history.com/#vectorize-io/hindsight&type=date&legend=top-left) --- +## Supported Platforms + +| Platform | Docker | Bare Metal (pip) | Embedded DB (pg0) | +|----------|--------|------------------|--------------------| +| **Linux** (x86_64, ARM64) | ✅ | ✅ | ✅ | +| **macOS** (Apple Silicon / arm64) | ✅ | ✅ | ✅ | +| **macOS** (Intel / x86_64) | ✅ | ⚠️ | ✅ | +| **Windows** (x86_64) | ✅ | ✅ | ✅ | + +⚠️ Intel Macs: use `hindsight-all-slim` — see the [installation guide](https://hindsight.vectorize.io/developer/installation#supported-platforms) for details. + +--- + ## Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md). diff --git a/deno.lock b/deno.lock index a357e81371..1321b68a8f 100644 --- a/deno.lock +++ b/deno.lock @@ -77,7 +77,6 @@ "npm:@radix-ui/react-dropdown-menu@^2.1.16", "npm:@radix-ui/react-label@^2.1.8", "npm:@radix-ui/react-popover@^1.1.15", - "npm:@radix-ui/react-radio-group@^1.3.8", "npm:@radix-ui/react-select@^2.2.6", "npm:@radix-ui/react-slider@^1.3.6", "npm:@radix-ui/react-slot@^1.2.4", diff --git a/docker/docker-compose/vchord/docker-compose.yaml b/docker/docker-compose/vchord/docker-compose.yaml index 6ca29ac651..186f05d6e7 100644 --- a/docker/docker-compose/vchord/docker-compose.yaml +++ b/docker/docker-compose/vchord/docker-compose.yaml @@ -1,6 +1,6 @@ name: hindsight # Docker Compose file for Hindsight with PostgreSQL and vectorchord -# docker compose -f docker/docker-compose/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/docker-compose.yaml up -d +# docker compose -f docker/docker-compose/vchord/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/vchord/docker-compose.yaml up -d # Make sure to set the required environment variables before running: # - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user # - Configure LLM provider variables as needed (see below in the hindsight service) diff --git a/helm/hindsight/Chart.yaml b/helm/hindsight/Chart.yaml index cb1a9ecec4..8670a0799c 100644 --- a/helm/hindsight/Chart.yaml +++ b/helm/hindsight/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: hindsight description: Hindsight helm chart type: application -version: 0.7.2 -appVersion: "0.7.2" +version: 0.8.3 +appVersion: "0.8.3" keywords: - ai - memory diff --git a/helm/hindsight/README.md b/helm/hindsight/README.md index d0c55b09e5..349af712d0 100644 --- a/helm/hindsight/README.md +++ b/helm/hindsight/README.md @@ -66,13 +66,13 @@ helm install hindsight ./helm/hindsight -n hindsight --create-namespace -f value | Parameter | Description | Default | |-----------|-------------|---------| -| `version` | Default image tag for all components | `0.1.0` | +| `version` | Default image tag for all components | Chart `appVersion` | | `api.enabled` | Enable the API component | `true` | -| `api.image.repository` | API image repository | `hindsight/api` | +| `api.image.repository` | API image repository | `ghcr.io/vectorize-io/hindsight-api` | | `api.image.tag` | API image tag (defaults to `version`) | - | | `api.service.port` | API service port | `8888` | | `controlPlane.enabled` | Enable the control plane | `true` | -| `controlPlane.image.repository` | Control plane image repository | `hindsight/control-plane` | +| `controlPlane.image.repository` | Control plane image repository | `ghcr.io/vectorize-io/hindsight-control-plane` | | `controlPlane.image.tag` | Control plane image tag (defaults to `version`) | - | | `controlPlane.service.port` | Control plane service port | `3000` | | `postgresql.enabled` | Deploy PostgreSQL as subchart | `true` | diff --git a/helm/hindsight/values.yaml b/helm/hindsight/values.yaml index d37ee61f79..6d799dc0cd 100644 --- a/helm/hindsight/values.yaml +++ b/helm/hindsight/values.yaml @@ -13,9 +13,6 @@ # - Any other env vars you want to inject # existingSecret: "my-hindsight-secret" -# Global settings -replicaCount: 1 - # Image settings for api api: enabled: true diff --git a/hindsight-all-npm/package.json b/hindsight-all-npm/package.json index 1faea46b70..bd14d5b14a 100644 --- a/hindsight-all-npm/package.json +++ b/hindsight-all-npm/package.json @@ -1,6 +1,6 @@ { "name": "@vectorize-io/hindsight-all", - "version": "0.7.2", + "version": "0.8.3", "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", diff --git a/hindsight-all-slim/pyproject.toml b/hindsight-all-slim/pyproject.toml index ca7ff44c5f..8044958d2b 100644 --- a/hindsight-all-slim/pyproject.toml +++ b/hindsight-all-slim/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "hindsight-all-slim" -version = "0.7.2" +version = "0.8.3" description = "Hindsight: Agent Memory That Works Like Human Memory - Slim All-in-One Bundle" readme = "README.md" requires-python = ">=3.11" dependencies = [ - "hindsight-api-slim==0.7.2", + "hindsight-api-slim==0.8.3", "hindsight-client>=0.0.7", "hindsight-embed>=0.1.0", ] diff --git a/hindsight-all/pyproject.toml b/hindsight-all/pyproject.toml index 6aef45303f..a0fa0d202d 100644 --- a/hindsight-all/pyproject.toml +++ b/hindsight-all/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "hatchling.build" [project] name = "hindsight-all" -version = "0.7.2" +version = "0.8.3" description = "Hindsight: Agent Memory That Works Like Human Memory - All-in-One Bundle" readme = "README.md" requires-python = ">=3.11" dependencies = [ - "hindsight-api-slim[all]==0.7.2", + "hindsight-api-slim[all]==0.8.3", "hindsight-client>=0.0.7", "hindsight-embed>=0.1.0", ] @@ -21,7 +21,7 @@ hindsight-embed = { workspace = true } [project.optional-dependencies] local-llm = [ - "hindsight-api-slim[local-llm]==0.7.2", + "hindsight-api-slim[local-llm]==0.8.3", ] test = [ "pytest>=7.0.0", diff --git a/hindsight-api-slim/hindsight_api/__init__.py b/hindsight-api-slim/hindsight_api/__init__.py index 01c47e3f65..9970d802e7 100644 --- a/hindsight-api-slim/hindsight_api/__init__.py +++ b/hindsight-api-slim/hindsight_api/__init__.py @@ -53,4 +53,4 @@ "RemoteTEICrossEncoder", "LLMConfig", ] -__version__ = "0.7.2" +__version__ = "0.8.3" diff --git a/hindsight-api-slim/hindsight_api/_vector_index.py b/hindsight-api-slim/hindsight_api/_vector_index.py index e966a5747a..1733c1cc32 100644 --- a/hindsight-api-slim/hindsight_api/_vector_index.py +++ b/hindsight-api-slim/hindsight_api/_vector_index.py @@ -54,23 +54,20 @@ # pre-dispatcher code (internal benchmarks tuned around our embedding count # and recall floor; see the link_utils / pool init call sites for the # latency-vs-recall framing). -# - vchord exposes vchordrq.probes (no default; see VectorChord issue #392) -# and vchordrq.epsilon (default 1.9). probes = 10 / 30 are starting -# defaults pending a workload-specific sweep — vchordrq's recall curve -# shape differs from HNSW's, so the pgvector numbers don't translate -# directly. Revisit with a per-cluster benchmark once we have production -# recall data; until then these are deliberately conservative on the -# high-recall path. We leave epsilon at its default; tightening it is a -# separate trade-off. +# - vchord exposes vchordrq.probes, but its shape must match the index's +# build.internal.lists hierarchy. VectorChord 1.1 added per-index fallback +# parameters for this reason: a session GUC overrides every vchordrq index, +# and a single value can be invalid for listless or mixed-layout indexes. +# Hindsight's built-in vchord clause does not set lists, so the safe default +# is no session-level probe override; deployments that partition vchordrq +# indexes should attach probes to the index storage parameters instead. # - pgvectorscale / pg_diskann / scann do not expose an equivalent per-statement # knob in the engine today, so the dispatcher returns no statements for them. _ANN_TUNING_LOW_LATENCY: dict[str, tuple[tuple[str, str], ...]] = { "pgvector": (("hnsw.ef_search", "60"),), - "vchord": (("vchordrq.probes", "10"),), } _ANN_TUNING_HIGH_RECALL: dict[str, tuple[tuple[str, str], ...]] = { "pgvector": (("hnsw.ef_search", "200"),), - "vchord": (("vchordrq.probes", "30"),), } _EXTENSION_INSTALL_SQL = { diff --git a/hindsight-api-slim/hindsight_api/admin/cli.py b/hindsight-api-slim/hindsight_api/admin/cli.py index 7f6d1ad48f..eac03e5029 100644 --- a/hindsight-api-slim/hindsight_api/admin/cli.py +++ b/hindsight-api-slim/hindsight_api/admin/cli.py @@ -49,6 +49,7 @@ "entities", "chunks", "memory_units", + "invalidated_memory_units", "unit_entities", "entity_cooccurrences", "memory_links", @@ -257,12 +258,7 @@ async def _run_migration( embedding_dimension: int | None = None, ) -> list[str]: """Resolve database URL and run migrations for one schema or all discovered schemas.""" - from ..migrations import ( - ensure_embedding_dimension, - ensure_text_search_extension, - ensure_vector_extension, - run_migrations, - ) + from ..migrations import run_migrations_for_schemas is_pg0, instance_name, _ = parse_pg0_url(db_url) if is_pg0: @@ -283,32 +279,21 @@ async def _run_migration( # Preserve order while removing duplicates. schemas = list(dict.fromkeys(schemas)) - for schema in schemas: - run_migrations(resolved_url, schema=schema, migration_database_url=config.migration_database_url) - - if embedding_dimension is not None: - for schema in schemas: - ensure_embedding_dimension( - resolved_url, - embedding_dimension, - schema=schema, - vector_extension=config.vector_extension, - ) - - for schema in schemas: - ensure_vector_extension( - resolved_url, - vector_extension=config.vector_extension, - schema=schema, - ) - - for schema in schemas: - ensure_text_search_extension( - resolved_url, - text_search_extension=config.text_search_extension, - pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer, - schema=schema, - ) + # Migrate up to `migration_concurrency` schemas at once (each in its own + # process); within a schema the work stays sequential. Run off the event + # loop so the process pool's blocking joins don't stall it. + await asyncio.to_thread( + run_migrations_for_schemas, + resolved_url, + schemas, + concurrency=config.migration_concurrency, + migration_database_url=config.migration_database_url, + embedding_dimension=embedding_dimension, + vector_extension=config.vector_extension, + text_search_extension=config.text_search_extension, + pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer, + ensure_extensions=True, + ) return schemas @@ -463,7 +448,8 @@ def import_bank_command( typer.echo( f"Imported bank '{result.bank_id}': {result.documents_imported} doc(s), " f"{result.facts_imported} fact(s), {result.observations_imported} observation(s), " - f"{result.mental_models_imported} mental model(s), {result.directives_imported} directive(s), " + f"{result.mental_models_imported} mental model(s), " + f"{result.mental_model_history_imported} mm-history row(s), {result.directives_imported} directive(s), " f"{result.webhooks_imported} webhook(s), {result.history_rows_imported} history row(s)" ) diff --git a/hindsight-api-slim/hindsight_api/alembic/versions/2071c7518f88_add_memory_links_bank_id_index.py b/hindsight-api-slim/hindsight_api/alembic/versions/2071c7518f88_add_memory_links_bank_id_index.py new file mode 100644 index 0000000000..b2aa1073b0 --- /dev/null +++ b/hindsight-api-slim/hindsight_api/alembic/versions/2071c7518f88_add_memory_links_bank_id_index.py @@ -0,0 +1,105 @@ +"""Add a composite index on memory_links(bank_id, link_type) (PostgreSQL). + +``bank_id`` was added to ``memory_links`` in ``c5d6e7f8a9b0`` precisely so that +bank-scoped reads (e.g. the stats endpoint) could filter on the link table +directly instead of joining ``memory_units`` — that JOIN took 18+ seconds on +banks with millions of links. The column landed without an index, so every +``bank_id = $1`` predicate still falls back to a sequential scan over the whole +table. + +This adds the missing btree. It is composite on ``(bank_id, link_type)`` rather +than ``bank_id`` alone because the hot query is the stats endpoint's +``SELECT link_type, COUNT(*) ... WHERE bank_id = $1 GROUP BY link_type``: a +``(bank_id, link_type)`` index serves that filter, grouping and count as an +index-only scan, never touching the heap, whereas a ``bank_id``-only index would +still have to read every matching row to recover ``link_type``. ``link_type`` is +low-cardinality (only ``temporal``/``semantic``/``caused_by`` are written — +entity edges were dropped in ``e9b2c7d1f3a4``), so the trailing column adds +little to the index size while removing the heap fetch. + +The Oracle baseline (``o1a2b3c4d5e6``) already creates ``idx_ml_bank_id`` on +``memory_links(bank_id)``; that single-column index already covers Oracle's +bank-scoped filter, so the Oracle slot here is intentionally absent and only the +PostgreSQL dialect gets the composite index. + +``memory_links`` can hold tens of millions of rows, so the index is built +CONCURRENTLY to avoid taking a write lock on the table. CONCURRENTLY cannot run +inside a transaction block, so the statement runs in an ``autocommit_block()``; +``IF NOT EXISTS`` keeps it idempotent across retries and re-migrated tenant +schemas. A CONCURRENTLY build interrupted partway (lock conflict, disk +pressure, signal) leaves the index behind as *invalid*; ``IF NOT EXISTS`` would +then skip over it forever, so the upgrade first drops any invalid leftover of +this name before (re)creating it. + +Revision ID: 2071c7518f88 +Revises: a1d3f5b7c9e2 +Create Date: 2026-06-16 +""" + +from collections.abc import Sequence + +from alembic import context, op +from sqlalchemy import text + +from hindsight_api.alembic._dialect import run_for_dialect + +revision: str = "2071c7518f88" +down_revision: str | Sequence[str] | None = "a1d3f5b7c9e2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_INDEX_NAME = "idx_memory_links_bank_id_link_type" + + +def _get_schema_prefix() -> str: + """Schema-qualifier for raw SQL on PG (multi-tenant search_path).""" + schema = context.config.get_main_option("target_schema") + return f'"{schema}".' if schema else "" + + +def _pg_upgrade() -> None: + bind = op.get_bind() + # `or None` collapses an unset option and an explicit empty string into NULL + # so the COALESCE below falls back to current_schema() in both cases. + target_schema = context.config.get_main_option("target_schema") or None + schema = _get_schema_prefix() + + # CREATE INDEX CONCURRENTLY cannot run inside a transaction block; the + # autocommit_block runs each statement outside Alembic's migration + # transaction. + with op.get_context().autocommit_block(): + # A CONCURRENTLY build that errored on a previous run leaves an INVALID + # index of this name behind. `CREATE INDEX ... IF NOT EXISTS` would see + # that relation and skip, so bank_id queries would keep seq-scanning. + # Drop only the invalid leftover — never a healthy index — so the retry + # actually rebuilds a usable one. + leftover_invalid = bind.execute( + text( + "SELECT NOT i.indisvalid " + "FROM pg_class c " + "JOIN pg_index i ON c.oid = i.indexrelid " + "JOIN pg_namespace n ON c.relnamespace = n.oid " + "WHERE c.relname = :index_name " + " AND n.nspname = COALESCE(:target_schema, current_schema())" + ), + {"index_name": _INDEX_NAME, "target_schema": target_schema}, + ).scalar() + if leftover_invalid: + op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_INDEX_NAME}") + + # IF NOT EXISTS keeps the create idempotent across retries and schemas. + op.execute(f"CREATE INDEX CONCURRENTLY IF NOT EXISTS {_INDEX_NAME} ON {schema}memory_links(bank_id, link_type)") + + +def _pg_downgrade() -> None: + schema = _get_schema_prefix() + with op.get_context().autocommit_block(): + op.execute(f"DROP INDEX CONCURRENTLY IF EXISTS {schema}{_INDEX_NAME}") + + +def upgrade() -> None: + run_for_dialect(pg=_pg_upgrade) + + +def downgrade() -> None: + run_for_dialect(pg=_pg_downgrade) diff --git a/hindsight-api-slim/hindsight_api/alembic/versions/a1d3f5b7c9e2_widen_remaining_bank_id_to_text.py b/hindsight-api-slim/hindsight_api/alembic/versions/a1d3f5b7c9e2_widen_remaining_bank_id_to_text.py new file mode 100644 index 0000000000..73e5e5243e --- /dev/null +++ b/hindsight-api-slim/hindsight_api/alembic/versions/a1d3f5b7c9e2_widen_remaining_bank_id_to_text.py @@ -0,0 +1,85 @@ +"""Repair: widen the remaining live ``bank_id`` columns from VARCHAR(64) to TEXT on PostgreSQL. + +Follow-up to ``c3e5a7b9d1f4`` (issue #2106), which widened the two *history* +tables (``observation_history``, ``mental_model_history``) to ``TEXT`` after the +narrow ``VARCHAR(64)`` declaration bricked startup. The same VARCHAR(64) / TEXT +inconsistency still affects the live tables that store a user-supplied +``bank_id``: + +* ``directives`` -- created VARCHAR(64) in ``p1k2l3m4n5o6`` +* ``mental_models`` -- VARCHAR(64) (origin ``pinned_reflections`` in + ``n9i0j1k2l3m4``; recreated in ``h3c4d5e6f7g8``) + +``mental_model_versions`` is intentionally *not* widened here: it is created in +``j5e6f7g8h9i0`` but dropped (``DROP TABLE ... CASCADE``) in ``o0j1k2l3m4n5`` and +never recreated on the upgrade path, so it does not exist at head. Issuing +``ALTER TABLE mental_model_versions ...`` would raise ``UndefinedTable`` and -- +because migrations run inside the lifespan-startup transaction -- roll the whole +migration back, bricking the API. (It is unrelated to the live +``mental_model_history`` table widened by ``c3e5a7b9d1f4``.) + +``banks.bank_id`` is ``TEXT`` (unbounded), so a deployment can create a bank +whose id exceeds 64 chars -- the 78-char hierarchical org-unit shape reported in +issue #2106 -- and the bank insert succeeds. The next write that propagates that +id (``create_directive``, ``create_mental_model`` / consolidation, or +mental-model versioning) then aborts with:: + + psycopg2.errors.StringDataRightTruncation: value too long for type + character varying(64) + +i.e. a 500 on core write endpoints, instead of the startup brick that +``c3e5a7b9d1f4`` already repaired. + +``ALTER COLUMN ... TYPE TEXT`` is a no-op on a column that is already ``TEXT``, +so every upgrade path converges on ``TEXT``. These tables are per-tenant (they +live in each tenant schema, not ``public``), so this runs for every migrated +schema via the search-path-aware prefix -- the same mechanism as +``c3e5a7b9d1f4``. + +PostgreSQL only: these tables are created by PostgreSQL-only migrations +(``run_for_dialect(pg=...)``); on Oracle they are absent or already +``VARCHAR2(256)`` (consistent, never truncates), so the Oracle slot is +intentionally absent -- mirroring ``c3e5a7b9d1f4``. + +Revision ID: a1d3f5b7c9e2 +Revises: c3e5a7b9d1f4 +Create Date: 2026-06-13 +""" + +from collections.abc import Sequence + +from alembic import context, op + +from hindsight_api.alembic._dialect import run_for_dialect + +revision: str = "a1d3f5b7c9e2" +down_revision: str | Sequence[str] | None = "c3e5a7b9d1f4" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _get_schema_prefix() -> str: + """Schema-qualifier for raw SQL on PG (multi-tenant search_path).""" + schema = context.config.get_main_option("target_schema") + return f'"{schema}".' if schema else "" + + +def _pg_upgrade() -> None: + schema = _get_schema_prefix() + op.execute(f"ALTER TABLE {schema}directives ALTER COLUMN bank_id TYPE TEXT") + op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN bank_id TYPE TEXT") + + +def _pg_downgrade() -> None: + # No-op: narrowing back to VARCHAR(64) could truncate real data and would + # re-introduce the bug this migration repairs. The column types are owned by + # the migrations that created the tables. + pass + + +def upgrade() -> None: + run_for_dialect(pg=_pg_upgrade) + + +def downgrade() -> None: + run_for_dialect(pg=_pg_downgrade) diff --git a/hindsight-api-slim/hindsight_api/alembic/versions/a7b8c9d0e1f2_split_history_into_own_tables.py b/hindsight-api-slim/hindsight_api/alembic/versions/a7b8c9d0e1f2_split_history_into_own_tables.py index 0d69fe7e96..1f134f531a 100644 --- a/hindsight-api-slim/hindsight_api/alembic/versions/a7b8c9d0e1f2_split_history_into_own_tables.py +++ b/hindsight-api-slim/hindsight_api/alembic/versions/a7b8c9d0e1f2_split_history_into_own_tables.py @@ -62,7 +62,7 @@ def _pg_upgrade() -> None: CREATE TABLE IF NOT EXISTS {schema}mental_model_history ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, mental_model_id VARCHAR(64) NOT NULL, - bank_id VARCHAR(64) NOT NULL, + bank_id TEXT NOT NULL, content JSONB NOT NULL, changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), FOREIGN KEY (mental_model_id, bank_id) @@ -83,7 +83,7 @@ def _pg_upgrade() -> None: CREATE TABLE IF NOT EXISTS {schema}observation_history ( id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, observation_id UUID NOT NULL, - bank_id VARCHAR(64) NOT NULL, + bank_id TEXT NOT NULL, content JSONB NOT NULL, changed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), FOREIGN KEY (observation_id) diff --git a/hindsight-api-slim/hindsight_api/alembic/versions/b2d4f6a8c1e3_repair_maintenance_routines_public.py b/hindsight-api-slim/hindsight_api/alembic/versions/b2d4f6a8c1e3_repair_maintenance_routines_public.py new file mode 100644 index 0000000000..59b84aba01 --- /dev/null +++ b/hindsight-api-slim/hindsight_api/alembic/versions/b2d4f6a8c1e3_repair_maintenance_routines_public.py @@ -0,0 +1,156 @@ +"""Repair: install maintenance routines on the ``public`` / base-schema run. + +The original maintenance-routines migration (``e5f6a7b8c9d0``) only created the +shared ``public.banks_needing_consolidation()`` and +``public.schemas_with_expired_rows(...)`` routines when the run had *no* +``target_schema`` at all. But the single-tenant runtime always migrates an +explicit schema — which defaults to ``public`` — so on every default +PostgreSQL deployment the migration was stamped as applied while the functions +were never created. Background maintenance then logs:: + + Retention sweep failed for llm_requests: function public.schemas_with_expired_rows(...) does not exist + Consolidation reconcile discovery failed: function public.banks_needing_consolidation() does not exist + +See https://github.com/vectorize-io/hindsight/issues/2056. + +Because ``e5f6a7b8c9d0`` is already stamped on affected ``0.8.0`` databases, +editing it would not re-run it there. This forward migration re-installs the +functions idempotently (``CREATE OR REPLACE``) on the run that targets the +shared ``public`` schema (base run with no ``target_schema``, or an explicit +``target_schema=public``), self-healing already-upgraded deployments and +covering fresh upgrades from earlier versions. + +Per-tenant runs against a non-``public`` schema still skip it: re-issuing +``CREATE OR REPLACE FUNCTION public....`` from each concurrent tenant migration +aborts with ``tuple concurrently updated`` on the ``pg_proc`` catalog row, and +the base/public run has already created the functions for every tenant to use. +Runs that target ``public`` are serialized by the per-schema migration advisory +lock, so only one wins the create. + +PostgreSQL only — the worker poller and these tables are not wired for Oracle, +so the Oracle slot is intentionally absent (mirrors ``e5f6a7b8c9d0``). + +Revision ID: b2d4f6a8c1e3 +Revises: e5f6a7b8c9d0 +Create Date: 2026-06-08 +""" + +from collections.abc import Sequence + +from alembic import context, op + +from hindsight_api.alembic._dialect import run_for_dialect + +revision: str = "b2d4f6a8c1e3" +down_revision: str | Sequence[str] | None = "e5f6a7b8c9d0" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _should_install_public_routines(target_schema: str | None) -> bool: + """True for the run that must (re)create the shared ``public.*`` routines. + + The routines physically live in ``public`` (hard-coded ``public.`` qualifier + in the SQL below), so they must be installed exactly once — on the base run + (no ``target_schema``) or on the run that explicitly targets ``public``. A + run against any other tenant schema skips it to avoid concurrent + ``CREATE OR REPLACE`` on the same ``pg_proc`` row. + """ + return not target_schema or target_schema == "public" + + +def _pg_upgrade() -> None: + if not _should_install_public_routines(context.config.get_main_option("target_schema")): + return + # Banks with eligible-but-unscheduled facts and no in-flight consolidation. + # Auto-consolidation is filtered here only at the bank level (cheap prune); + # the full hierarchical resolution (global -> tenant -> bank, plus + # enable_observations) is done by the caller for the small returned set. + op.execute( + """ + CREATE OR REPLACE FUNCTION public.banks_needing_consolidation() + RETURNS TABLE(schema_name text, bank_id text) + LANGUAGE plpgsql STABLE + AS $fn$ + DECLARE + sch text; + BEGIN + FOR sch IN + SELECT n.nspname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = 'memory_units' AND c.relkind = 'r' + LOOP + RETURN QUERY EXECUTE format($q$ + SELECT %1$L::text, m.bank_id + FROM %1$I.memory_units m + JOIN %1$I.banks b ON b.bank_id = m.bank_id + WHERE m.consolidated_at IS NULL + AND m.consolidation_failed_at IS NULL + AND m.fact_type IN ('experience', 'world') + AND COALESCE(b.config -> 'enable_auto_consolidation', 'true'::jsonb) <> 'false'::jsonb + AND NOT EXISTS ( + SELECT 1 FROM %1$I.async_operations o + WHERE o.bank_id = m.bank_id + AND o.operation_type = 'consolidation' + AND o.status IN ('pending', 'processing') + ) + GROUP BY m.bank_id + $q$, sch); + END LOOP; + END; + $fn$; + """ + ) + + # Schemas holding at least one row of p_table older than p_days. p_ts_col is + # the timestamp column to compare. Returns nothing when p_days <= 0 + # (retention disabled). + op.execute( + """ + CREATE OR REPLACE FUNCTION public.schemas_with_expired_rows( + p_table text, p_ts_col text, p_days int + ) + RETURNS SETOF text + LANGUAGE plpgsql STABLE + AS $fn$ + DECLARE + sch text; + has_expired boolean; + BEGIN + IF p_days IS NULL OR p_days <= 0 THEN + RETURN; + END IF; + FOR sch IN + SELECT n.nspname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = p_table AND c.relkind = 'r' + LOOP + EXECUTE format( + 'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I < NOW() - make_interval(days => $1))', + sch, p_table, p_ts_col + ) INTO has_expired USING p_days; + IF has_expired THEN + RETURN NEXT sch; + END IF; + END LOOP; + END; + $fn$; + """ + ) + + +def _pg_downgrade() -> None: + # No-op: ``e5f6a7b8c9d0`` owns the lifecycle of these functions and drops + # them on its own downgrade. This migration only ever (re)creates them, so + # there is nothing to undo without racing that migration's DROP. + pass + + +def upgrade() -> None: + run_for_dialect(pg=_pg_upgrade) + + +def downgrade() -> None: + run_for_dialect(pg=_pg_downgrade) diff --git a/hindsight-api-slim/hindsight_api/alembic/versions/c3e5a7b9d1f4_widen_history_bank_id_to_text.py b/hindsight-api-slim/hindsight_api/alembic/versions/c3e5a7b9d1f4_widen_history_bank_id_to_text.py new file mode 100644 index 0000000000..50e1f4d811 --- /dev/null +++ b/hindsight-api-slim/hindsight_api/alembic/versions/c3e5a7b9d1f4_widen_history_bank_id_to_text.py @@ -0,0 +1,75 @@ +"""Repair: widen ``*_history.bank_id`` from VARCHAR(64) to TEXT on PostgreSQL. + +The original split-history migration (``a7b8c9d0e1f2``) declared +``observation_history.bank_id`` and ``mental_model_history.bank_id`` as +``VARCHAR(64)`` on PostgreSQL. But ``memory_units.bank_id`` — the backfill +source for observations — is ``TEXT`` (unbounded), as are ``banks``, +``documents`` and ``entities``. Any deployment whose ``bank_id`` exceeds 64 +characters aborts the backfill ``INSERT`` with:: + + psycopg2.errors.StringDataRightTruncation: value too long for type + character varying(64) + +Because the migration runs in ``lifespan`` startup inside a transaction, the +whole migration rolls back and the API never comes up — unrecoverable from the +running container. See https://github.com/vectorize-io/hindsight/issues/2106. + +``a7b8c9d0e1f2`` itself has been corrected to create the column as ``TEXT``, +which unblocks deployments that *failed* (the migration rolled back, so it +re-runs the fixed DDL). This forward migration covers deployments that already +*succeeded* with the narrow ``VARCHAR(64)`` column — where editing +``a7b8c9d0e1f2`` has no effect because it will not re-run — by widening the +column in place. ``ALTER COLUMN ... TYPE TEXT`` is a no-op on a column that is +already ``TEXT`` (fresh installs and re-run failures), so every upgrade path +converges on ``TEXT``. + +The history tables are per-tenant (they live in each tenant schema, not +``public``), so this runs for every migrated schema via the search-path-aware +prefix — unlike the shared-``public`` routines repaired in ``b2d4f6a8c1e3``. + +PostgreSQL only. On Oracle both ``memory_units.bank_id`` and the history +``bank_id`` columns are already ``VARCHAR2(256)`` (consistent, never +truncates), so the Oracle slot is intentionally absent. + +Revision ID: c3e5a7b9d1f4 +Revises: c9a1b2d3e4f5 +Create Date: 2026-06-10 +""" + +from collections.abc import Sequence + +from alembic import context, op + +from hindsight_api.alembic._dialect import run_for_dialect + +revision: str = "c3e5a7b9d1f4" +down_revision: str | Sequence[str] | None = "c9a1b2d3e4f5" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _get_schema_prefix() -> str: + """Schema-qualifier for raw SQL on PG (multi-tenant search_path).""" + schema = context.config.get_main_option("target_schema") + return f'"{schema}".' if schema else "" + + +def _pg_upgrade() -> None: + schema = _get_schema_prefix() + op.execute(f"ALTER TABLE {schema}observation_history ALTER COLUMN bank_id TYPE TEXT") + op.execute(f"ALTER TABLE {schema}mental_model_history ALTER COLUMN bank_id TYPE TEXT") + + +def _pg_downgrade() -> None: + # No-op: narrowing back to VARCHAR(64) could truncate real data and would + # re-introduce the bug this migration repairs. The column type is owned by + # ``a7b8c9d0e1f2``'s lifecycle. + pass + + +def upgrade() -> None: + run_for_dialect(pg=_pg_upgrade) + + +def downgrade() -> None: + run_for_dialect(pg=_pg_downgrade) diff --git a/hindsight-api-slim/hindsight_api/alembic/versions/c9a1b2d3e4f5_add_invalidated_memory_units.py b/hindsight-api-slim/hindsight_api/alembic/versions/c9a1b2d3e4f5_add_invalidated_memory_units.py new file mode 100644 index 0000000000..e0d3fa0985 --- /dev/null +++ b/hindsight-api-slim/hindsight_api/alembic/versions/c9a1b2d3e4f5_add_invalidated_memory_units.py @@ -0,0 +1,108 @@ +"""Add invalidated_memory_units table for curation (edit/invalidate). + +Curation keeps the recall hot-path (``memory_units``) clean by *moving* +invalidated facts into a sibling archive table rather than flagging them in +place. If a row is in ``memory_units`` it is live; if it is in +``invalidated_memory_units`` it has been retired. Recall/consolidation/graph +queries never need a state predicate — the rows simply aren't there. + +The archive mirrors ``memory_units`` column-for-column — except ``embedding``, +which it never keeps: the archive is cold storage, never a recall surface, and +revert recomputes the embedding from the unit's text/dates/entities. Keeping no +archive vector also means a later embedding-model switch (which re-dimensions +``memory_units``) can't trip a dimension mismatch on the move (#2209). Plus: +- ``invalidation_reason`` optional free text recorded on invalidate +- ``invalidated_at`` when it was retired +- ``entity_ids`` snapshot of the unit's entity associations, so revert + can restore them (``unit_entities`` is cascade-deleted + when the live row is removed) + +This migration also adds ``edited_at`` to ``memory_units``: set whenever a user +edits a memory's fields (text, context, dates, fact_type, entities) via curation. +NULL means never manually modified; a non-NULL value answers "has the user ever +changed this?" with the time of the last edit (distinct from ``updated_at``, +which background operations also bump). It is added to ``memory_units`` *before* +the archive is cloned below, so the archive inherits the column and the marker +travels with a fact when it is invalidated. + +Revision ID: c9a1b2d3e4f5 +Revises: b2d4f6a8c1e3 +Create Date: 2026-06-03 +""" + +from collections.abc import Sequence + +from alembic import context, op + +from hindsight_api.alembic._dialect import run_for_dialect + +revision: str = "c9a1b2d3e4f5" +down_revision: str | Sequence[str] | None = "b2d4f6a8c1e3" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _pg_schema_prefix() -> str: + schema = context.config.get_main_option("target_schema") + return f'"{schema}".' if schema else "" + + +def _pg_upgrade() -> None: + schema = _pg_schema_prefix() + # Add edited_at to the live table FIRST so the archive's LIKE clone below + # inherits it (keeps the two tables column-for-column identical for round-trip). + op.execute(f"ALTER TABLE {schema}memory_units ADD COLUMN IF NOT EXISTS edited_at TIMESTAMPTZ") + # LIKE ... INCLUDING DEFAULTS clones every memory_units column (incl. + # edited_at) so an invalidated row can move back verbatim. We deliberately + # omit indexes/constraints — the archive is cold storage, not a recall + # surface; only the lookups below need indexing. + op.execute( + f"CREATE TABLE IF NOT EXISTS {schema}invalidated_memory_units (LIKE {schema}memory_units INCLUDING DEFAULTS)" + ) + # ...then drop the inherited embedding: the archive never stores one (revert + # recomputes it), so it isn't created here only to be dropped again later by + # d4f6a8c2e1b3. That migration still runs as a no-op (DROP ... IF EXISTS) on + # fresh DBs and does the real drop on DBs created before this column was removed. + op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS embedding") + op.execute( + f"ALTER TABLE {schema}invalidated_memory_units " + f"ADD COLUMN IF NOT EXISTS invalidation_reason TEXT, " + f"ADD COLUMN IF NOT EXISTS invalidated_at TIMESTAMPTZ DEFAULT now(), " + f"ADD COLUMN IF NOT EXISTS entity_ids UUID[]" + ) + op.execute(f"CREATE UNIQUE INDEX IF NOT EXISTS idx_invalidated_mu_id ON {schema}invalidated_memory_units (id)") + op.execute( + f"CREATE INDEX IF NOT EXISTS idx_invalidated_mu_bank " + f"ON {schema}invalidated_memory_units (bank_id, invalidated_at)" + ) + # Deleting a document (or bank) should clear its archived facts too, mirroring + # the memory_units → documents cascade. + op.execute( + f""" + DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'invalidated_mu_document_fkey') THEN + ALTER TABLE {schema}invalidated_memory_units + ADD CONSTRAINT invalidated_mu_document_fkey + FOREIGN KEY (document_id, bank_id) + REFERENCES {schema}documents(id, bank_id) ON DELETE CASCADE; + END IF; END $$; + """ + ) + + +def _pg_downgrade() -> None: + schema = _pg_schema_prefix() + # Drops the archive (and its inherited edited_at) wholesale, then removes + # edited_at from the live table. + op.execute(f"DROP TABLE IF EXISTS {schema}invalidated_memory_units") + op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS edited_at") + + +def upgrade() -> None: + # PG-only: Oracle gets the table from the baseline snapshot, matching the + # convention used by sibling column/index migrations in this tree. + run_for_dialect(pg=_pg_upgrade) + + +def downgrade() -> None: + run_for_dialect(pg=_pg_downgrade) diff --git a/hindsight-api-slim/hindsight_api/alembic/versions/d4f6a8c2e1b3_drop_archive_embedding_column.py b/hindsight-api-slim/hindsight_api/alembic/versions/d4f6a8c2e1b3_drop_archive_embedding_column.py new file mode 100644 index 0000000000..b09b90b2b7 --- /dev/null +++ b/hindsight-api-slim/hindsight_api/alembic/versions/d4f6a8c2e1b3_drop_archive_embedding_column.py @@ -0,0 +1,93 @@ +"""Drop the embedding column from the curation archive (invalidated_memory_units). + +The archive is cold storage, never a recall surface, so it has no business +keeping an embedding. Earlier curation code copied the live row's embedding into +``invalidated_memory_units`` on invalidate; the engine now leaves it out on +invalidate and recomputes it on revert, so the column is dead weight. + +Dropping it makes "the archive holds no embedding" a schema-enforced invariant +rather than a convention the move queries have to honour, and removes a latent +failure mode (#2209): after an embedding-model switch the live tables are +re-dimensioned but the archive was not, so a stale old-dimension embedding in +the archive tripped a vector-dimension mismatch on the INSERT … SELECT +round-trip. With no column at all, there is nothing to mismatch. + +The creation sites no longer add the column (the PG ``LIKE`` clone in +c9a1b2d3e4f5 drops it; the Oracle baseline omits it), so on a fresh database +this migration is a no-op (DROP ... IF EXISTS / Oracle ORA-00904 swallow). It +does the real work on databases created before the column was removed there. + +DROP COLUMN is a metadata-only operation on both PostgreSQL and Oracle 23ai (no +table rewrite), so it is cheap even across many tenant schemas. The downgrade +re-adds an unconstrained vector column (any dimension) — empty, since the +embeddings are intentionally discarded. + +Revision ID: d4f6a8c2e1b3 +Revises: a1d3f5b7c9e2 +Create Date: 2026-06-15 +""" + +from collections.abc import Sequence + +from alembic import context, op + +from hindsight_api.alembic._dialect import run_for_dialect + +revision: str = "d4f6a8c2e1b3" +down_revision: str | Sequence[str] | None = "a1d3f5b7c9e2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _pg_schema_prefix() -> str: + """Schema-qualifier for raw SQL on PG (multi-tenant search_path).""" + schema = context.config.get_main_option("target_schema") + return f'"{schema}".' if schema else "" + + +def _pg_upgrade() -> None: + schema = _pg_schema_prefix() + op.execute(f"ALTER TABLE {schema}invalidated_memory_units DROP COLUMN IF EXISTS embedding") + + +def _pg_downgrade() -> None: + schema = _pg_schema_prefix() + # Unconstrained `vector` (no dimension) so the re-added column accepts any + # model's embeddings; it comes back empty regardless. + op.execute(f"ALTER TABLE {schema}invalidated_memory_units ADD COLUMN IF NOT EXISTS embedding vector") + + +def _oracle_upgrade() -> None: + # Oracle has no `DROP COLUMN IF EXISTS`; swallow ORA-00904 (column does not + # exist) so the migration is idempotent and safe on a fresh schema whose + # baseline already omits the column. + op.execute( + """ + BEGIN + EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units DROP COLUMN embedding'; + EXCEPTION WHEN OTHERS THEN + IF SQLCODE != -904 THEN RAISE; END IF; + END; + """ + ) + + +def _oracle_downgrade() -> None: + # Swallow ORA-01430 (column already exists) for idempotency. + op.execute( + """ + BEGIN + EXECUTE IMMEDIATE 'ALTER TABLE invalidated_memory_units ADD (embedding VECTOR)'; + EXCEPTION WHEN OTHERS THEN + IF SQLCODE != -1430 THEN RAISE; END IF; + END; + """ + ) + + +def upgrade() -> None: + run_for_dialect(pg=_pg_upgrade, oracle=_oracle_upgrade) + + +def downgrade() -> None: + run_for_dialect(pg=_pg_downgrade, oracle=_oracle_downgrade) diff --git a/hindsight-api-slim/hindsight_api/alembic/versions/e1f2a3b4c5d6_merge_heads_embedding_drop_and_links_index.py b/hindsight-api-slim/hindsight_api/alembic/versions/e1f2a3b4c5d6_merge_heads_embedding_drop_and_links_index.py new file mode 100644 index 0000000000..fca2f36258 --- /dev/null +++ b/hindsight-api-slim/hindsight_api/alembic/versions/e1f2a3b4c5d6_merge_heads_embedding_drop_and_links_index.py @@ -0,0 +1,39 @@ +"""Merge two divergent migration heads. + +``d4f6a8c2e1b3`` (drop the curation-archive embedding column) and +``2071c7518f88`` (add the memory_links(bank_id, link_type) index) were authored +in parallel off the same parent (``a1d3f5b7c9e2``) and merged independently, +leaving the DAG with two heads. This is a no-op merge that re-unifies them so +``alembic upgrade head`` is unambiguous again (enforced by +``tests/test_alembic_dag.py::test_single_head``). + +Revision ID: e1f2a3b4c5d6 +Revises: d4f6a8c2e1b3, 2071c7518f88 +Create Date: 2026-06-16 +""" + +from collections.abc import Sequence + +from hindsight_api.alembic._dialect import run_for_dialect + +revision: str = "e1f2a3b4c5d6" +down_revision: str | Sequence[str] | None = ("d4f6a8c2e1b3", "2071c7518f88") +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _pg_upgrade() -> None: + # Pure DAG merge — both parents already applied their schema changes. + pass + + +def _pg_downgrade() -> None: + pass + + +def upgrade() -> None: + run_for_dialect(pg=_pg_upgrade) + + +def downgrade() -> None: + run_for_dialect(pg=_pg_downgrade) diff --git a/hindsight-api-slim/hindsight_api/alembic/versions/e5f6a7b8c9d0_add_maintenance_routines.py b/hindsight-api-slim/hindsight_api/alembic/versions/e5f6a7b8c9d0_add_maintenance_routines.py new file mode 100644 index 0000000000..2cd6e3ea6c --- /dev/null +++ b/hindsight-api-slim/hindsight_api/alembic/versions/e5f6a7b8c9d0_add_maintenance_routines.py @@ -0,0 +1,153 @@ +"""Add server-side routines for background maintenance sweeps. + +Installs two PL/pgSQL discovery routines in the ``public`` schema. Both loop +over every schema that actually holds the relevant table (via ``pg_class``), so +a single function call covers all tenants in one round-trip instead of the +per-tenant query storm that a client-side loop would create at thousands of +tenants. + +- ``public.banks_needing_consolidation()`` -> (schema_name, bank_id) for banks + that have eligible-but-unscheduled facts (``consolidated_at IS NULL AND + consolidation_failed_at IS NULL`` for consolidatable fact types), have + auto-consolidation not explicitly disabled at the bank level, and have no + consolidation operation already pending/processing. Drives the periodic + reconcile that re-schedules consolidation after a terminal failure left facts + stranded (see HINDSIGHT_API_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS). + +- ``public.schemas_with_expired_rows(p_table, p_ts_col, p_days)`` -> schema + names that hold at least one ``p_table`` row older than ``p_days``. Drives the + cross-tenant retention sweeps for ``audit_log`` and ``llm_requests``; the loop + then issues a DELETE only against the returned schemas. + +These are read-only (STABLE) discovery routines — the caller performs the +enqueue/DELETE — so installing them never mutates data. + +PostgreSQL only — the worker poller and these tables are not wired for Oracle, +so the Oracle slot is intentionally absent (mirrors the audit_log / llm_requests +table migrations). The routines live in ``public`` and are CREATE OR REPLACE, so +running this migration once per tenant schema is idempotent. + +Revision ID: e5f6a7b8c9d0 +Revises: a7b8c9d0e1f2 +Create Date: 2026-06-05 +""" + +from collections.abc import Sequence + +from alembic import context, op + +from hindsight_api.alembic._dialect import run_for_dialect + +revision: str = "e5f6a7b8c9d0" +down_revision: str | Sequence[str] | None = "a7b8c9d0e1f2" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def _is_base_schema_run() -> bool: + """True only for the base-schema migration (no per-tenant target_schema). + + These routines live in the shared ``public`` schema, so they must be created + exactly once. Running ``CREATE OR REPLACE FUNCTION public....`` again from each + concurrent per-tenant migration aborts with ``tuple concurrently updated`` on + the ``pg_proc`` catalog row, so tenant runs skip it (the base run already + created the function for every tenant to use). + """ + return not context.config.get_main_option("target_schema") + + +def _pg_upgrade() -> None: + if not _is_base_schema_run(): + return + # Banks with eligible-but-unscheduled facts and no in-flight consolidation. + # Auto-consolidation is filtered here only at the bank level (cheap prune); + # the full hierarchical resolution (global -> tenant -> bank, plus + # enable_observations) is done by the caller for the small returned set. + op.execute( + """ + CREATE OR REPLACE FUNCTION public.banks_needing_consolidation() + RETURNS TABLE(schema_name text, bank_id text) + LANGUAGE plpgsql STABLE + AS $fn$ + DECLARE + sch text; + BEGIN + FOR sch IN + SELECT n.nspname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = 'memory_units' AND c.relkind = 'r' + LOOP + RETURN QUERY EXECUTE format($q$ + SELECT %1$L::text, m.bank_id + FROM %1$I.memory_units m + JOIN %1$I.banks b ON b.bank_id = m.bank_id + WHERE m.consolidated_at IS NULL + AND m.consolidation_failed_at IS NULL + AND m.fact_type IN ('experience', 'world') + AND COALESCE(b.config -> 'enable_auto_consolidation', 'true'::jsonb) <> 'false'::jsonb + AND NOT EXISTS ( + SELECT 1 FROM %1$I.async_operations o + WHERE o.bank_id = m.bank_id + AND o.operation_type = 'consolidation' + AND o.status IN ('pending', 'processing') + ) + GROUP BY m.bank_id + $q$, sch); + END LOOP; + END; + $fn$; + """ + ) + + # Schemas holding at least one row of p_table older than p_days. p_ts_col is + # the timestamp column to compare. Returns nothing when p_days <= 0 + # (retention disabled). + op.execute( + """ + CREATE OR REPLACE FUNCTION public.schemas_with_expired_rows( + p_table text, p_ts_col text, p_days int + ) + RETURNS SETOF text + LANGUAGE plpgsql STABLE + AS $fn$ + DECLARE + sch text; + has_expired boolean; + BEGIN + IF p_days IS NULL OR p_days <= 0 THEN + RETURN; + END IF; + FOR sch IN + SELECT n.nspname + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relname = p_table AND c.relkind = 'r' + LOOP + EXECUTE format( + 'SELECT EXISTS (SELECT 1 FROM %I.%I WHERE %I < NOW() - make_interval(days => $1))', + sch, p_table, p_ts_col + ) INTO has_expired USING p_days; + IF has_expired THEN + RETURN NEXT sch; + END IF; + END LOOP; + END; + $fn$; + """ + ) + + +def _pg_downgrade() -> None: + if not _is_base_schema_run(): + return + op.execute("DROP FUNCTION IF EXISTS public.banks_needing_consolidation()") + op.execute("DROP FUNCTION IF EXISTS public.schemas_with_expired_rows(text, text, int)") + + +def upgrade() -> None: + run_for_dialect(pg=_pg_upgrade) + + +def downgrade() -> None: + run_for_dialect(pg=_pg_downgrade) diff --git a/hindsight-api-slim/hindsight_api/alembic/versions/o1a2b3c4d5e6_oracle_baseline.py b/hindsight-api-slim/hindsight_api/alembic/versions/o1a2b3c4d5e6_oracle_baseline.py index ee1007ff71..a9dc66f7ba 100644 --- a/hindsight-api-slim/hindsight_api/alembic/versions/o1a2b3c4d5e6_oracle_baseline.py +++ b/hindsight-api-slim/hindsight_api/alembic/versions/o1a2b3c4d5e6_oracle_baseline.py @@ -122,6 +122,7 @@ text_signals CLOB, consolidation_failed_at TIMESTAMP WITH TIME ZONE, search_vector CLOB, + edited_at TIMESTAMP WITH TIME ZONE, created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, CONSTRAINT pk_memory_units PRIMARY KEY (id), @@ -138,6 +139,50 @@ PARTITION BY LIST (bank_id) AUTOMATIC (PARTITION p_default VALUES ('__default__')) """, + # Cold archive for curation: invalidated facts are MOVED here out of + # memory_units so the recall hot-path never sees them. Mirrors memory_units + # plus invalidation bookkeeping and an entity-id snapshot for lossless revert. + # No `embedding` column: the archive is cold storage and revert recomputes the + # embedding, so there is no archive vector to fall out of sync with the live + # model's dimension on a model switch (#2209). + """ + CREATE TABLE IF NOT EXISTS invalidated_memory_units ( + id RAW(16) NOT NULL, + bank_id VARCHAR2(256) NOT NULL, + document_id VARCHAR2(512), + chunk_id VARCHAR2(512), + text CLOB NOT NULL, + context CLOB, + event_date TIMESTAMP WITH TIME ZONE NOT NULL, + occurred_start TIMESTAMP WITH TIME ZONE, + occurred_end TIMESTAMP WITH TIME ZONE, + mentioned_at TIMESTAMP WITH TIME ZONE, + fact_type VARCHAR2(64) DEFAULT 'world' NOT NULL, + confidence_score BINARY_DOUBLE, + access_count NUMBER(10) DEFAULT 0 NOT NULL, + consolidated_at TIMESTAMP WITH TIME ZONE, + observation_scopes CLOB CONSTRAINT imu_obs_scopes_json CHECK (observation_scopes IS JSON OR observation_scopes IS NULL), + tags CLOB DEFAULT '[]' NOT NULL, + metadata CLOB DEFAULT '{}' NOT NULL + CONSTRAINT imu_metadata_json CHECK (metadata IS JSON), + proof_count NUMBER(10) DEFAULT 1, + source_memory_ids CLOB, + history CLOB DEFAULT '[]' + CONSTRAINT imu_history_json CHECK (history IS JSON OR history IS NULL), + text_signals CLOB, + consolidation_failed_at TIMESTAMP WITH TIME ZONE, + search_vector CLOB, + edited_at TIMESTAMP WITH TIME ZONE, + created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, + invalidation_reason CLOB, + invalidated_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP, + entity_ids CLOB CONSTRAINT imu_entity_ids_json CHECK (entity_ids IS JSON OR entity_ids IS NULL), + CONSTRAINT pk_invalidated_memory_units PRIMARY KEY (id), + CONSTRAINT fk_imu_document FOREIGN KEY (document_id, bank_id) + REFERENCES documents(id, bank_id) ON DELETE CASCADE + ) + """, """ CREATE TABLE IF NOT EXISTS entities ( id RAW(16) DEFAULT SYS_GUID() NOT NULL, diff --git a/hindsight-api-slim/hindsight_api/alembic/versions/x9s0t1u2v3w4_add_bank_config_column.py b/hindsight-api-slim/hindsight_api/alembic/versions/x9s0t1u2v3w4_add_bank_config_column.py index b555fe6c7e..3a9c4acb70 100644 --- a/hindsight-api-slim/hindsight_api/alembic/versions/x9s0t1u2v3w4_add_bank_config_column.py +++ b/hindsight-api-slim/hindsight_api/alembic/versions/x9s0t1u2v3w4_add_bank_config_column.py @@ -16,9 +16,7 @@ from collections.abc import Sequence -import sqlalchemy as sa from alembic import context, op -from sqlalchemy.dialects.postgresql import JSONB from hindsight_api.alembic._dialect import run_for_dialect diff --git a/hindsight-api-slim/hindsight_api/api/disconnect.py b/hindsight-api-slim/hindsight_api/api/disconnect.py new file mode 100644 index 0000000000..04410c8160 --- /dev/null +++ b/hindsight-api-slim/hindsight_api/api/disconnect.py @@ -0,0 +1,97 @@ +"""Client-disconnect detection that works behind ``BaseHTTPMiddleware``. + +``Request.is_disconnected()`` is the obvious way to notice an abandoned HTTP +request, but it is silently broken once any ``@app.middleware("http")`` +(Starlette ``BaseHTTPMiddleware``) is installed: that middleware runs the route +in a child task behind anyio memory streams, so the ``http.disconnect`` ASGI +event never reaches the route's ``Request``. This app has such middlewares, so +the recall/reflect cancellation in #2122/#2127 never actually fired in +production — the disconnect was never observed. + +This pure-ASGI middleware sits *outside* the ``BaseHTTPMiddleware`` layer, where +it still owns the real ``receive`` channel. For the recall and reflect routes it +drains ``receive`` in a background task and trips a :class:`CancellationToken` +the moment ``http.disconnect`` arrives, stashing the token on the ASGI ``scope``. +The route copies that token onto its ``RequestContext`` and the engine checks it +at stage boundaries — so abandoned work stops instead of running to completion. + +It only wraps recall/reflect (small JSON bodies); every other request — uploads, +MCP streams, etc. — passes straight through untouched, so there is no buffering +or latency cost elsewhere. +""" + +from __future__ import annotations + +import asyncio +import contextlib +from collections.abc import Awaitable, Callable, MutableMapping +from typing import Any + +from ..cancellation import CancellationToken + +# Key under which the per-request CancellationToken is stored on the ASGI scope. +# A dedicated top-level scope key (not scope["state"]) avoids any interaction +# with Starlette's per-request state copying. +SCOPE_CANCELLATION_TOKEN = "hindsight.cancellation_token" + +_CLIENT_DISCONNECTED_REASON = "client disconnected" + +Scope = MutableMapping[str, Any] +Receive = Callable[[], Awaitable[MutableMapping[str, Any]]] +Send = Callable[[MutableMapping[str, Any]], Awaitable[None]] + + +def _should_monitor(path: str) -> bool: + """Only the two long-running, abandon-prone read endpoints need monitoring.""" + return path.endswith("/memories/recall") or path.endswith("/reflect") + + +class ClientDisconnectCancellationMiddleware: + """Trip a scope-level CancellationToken when the client disconnects. + + Must be installed *outside* any ``BaseHTTPMiddleware`` so it owns the real + ASGI ``receive`` channel. + """ + + def __init__(self, app: Callable) -> None: + self.app = app + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or not _should_monitor(scope.get("path", "")): + await self.app(scope, receive, send) + return + + token = CancellationToken() + scope[SCOPE_CANCELLATION_TOKEN] = token + + # The downstream app still needs to read the request body, so we cannot + # simply consume `receive` ourselves. Instead a single pump task drains + # the real channel, forwards every message to a queue the app reads from, + # and trips the token the instant `http.disconnect` shows up — which the + # app would otherwise never pull once it has finished reading the body. + queue: asyncio.Queue = asyncio.Queue() + + async def pump() -> None: + while True: + message = await receive() + if message["type"] == "http.disconnect": + token.cancel(_CLIENT_DISCONNECTED_REASON) + await queue.put(message) + return + await queue.put(message) + + async def proxied_receive() -> MutableMapping[str, Any]: + return await queue.get() + + pump_task = asyncio.create_task(pump()) + try: + await self.app(scope, proxied_receive, send) + finally: + pump_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await pump_task + + +def get_scope_cancellation_token(scope: Scope) -> CancellationToken | None: + """Return the CancellationToken the middleware attached, if any.""" + return scope.get(SCOPE_CANCELLATION_TOKEN) diff --git a/hindsight-api-slim/hindsight_api/api/http.py b/hindsight-api-slim/hindsight_api/api/http.py index 68e7d3ad4a..705f50aff3 100644 --- a/hindsight-api-slim/hindsight_api/api/http.py +++ b/hindsight-api-slim/hindsight_api/api/http.py @@ -10,13 +10,16 @@ import logging import re import uuid +from collections.abc import Awaitable from contextlib import asynccontextmanager -from datetime import datetime, timezone -from typing import Any, Literal +from datetime import datetime +from typing import Any, Literal, TypeVar from fastapi import Depends, FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile from fastapi.middleware.gzip import GZipMiddleware +from hindsight_api.api.disconnect import ClientDisconnectCancellationMiddleware, get_scope_cancellation_token +from hindsight_api.cancellation import OperationCancelledError from hindsight_api.engine.audit import ( AuditEntry, AuditLogger, @@ -41,13 +44,78 @@ def _parse_metadata(metadata: Any) -> dict[str, Any]: return {} -from typing import Callable +from collections.abc import Iterable +from types import UnionType +from typing import Callable, Union, get_args, get_origin +from fastapi.routing import APIRoute from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from hindsight_api import MemoryEngine +def _annotation_is_nullable(annotation: Any) -> bool: + """True if the annotation is a Union that includes None (i.e. ``X | None``).""" + if get_origin(annotation) in (Union, UnionType): + return any(arg is type(None) for arg in get_args(annotation)) + return False + + +def _iter_models(annotation: Any) -> Iterable[type[BaseModel]]: + """Yield every Pydantic model referenced by an annotation, recursing through generics.""" + if isinstance(annotation, type) and issubclass(annotation, BaseModel): + yield annotation + return + for arg in get_args(annotation): + yield from _iter_models(arg) + + +def _model_has_required_nullable(model: type[BaseModel], seen: set[type[BaseModel]]) -> bool: + """True if the model (or any nested model) declares a required *and* nullable field. + + Such a field is in the OpenAPI ``required`` set but may serialize to null, so dropping + it (via ``exclude_none``) would omit a key that strict generated clients expect to be + present. Routes whose response model contains one of these must keep emitting nulls to + stay wire-compatible with already-generated clients. + """ + if model in seen: + return False + seen.add(model) + for field in model.model_fields.values(): + annotation = field.annotation + if field.is_required() and _annotation_is_nullable(annotation): + return True + for nested in _iter_models(annotation): + if _model_has_required_nullable(nested, seen): + return True + return False + + +def _response_model_has_required_nullable(response_model: Any) -> bool: + seen: set[type[BaseModel]] = set() + return any(_model_has_required_nullable(model, seen) for model in _iter_models(response_model)) + + +class ExcludeNoneRoute(APIRoute): + """Route class that drops null fields from responses, preserving wire compatibility. + + ``response_model_exclude_none`` is enabled automatically for every route whose response + model has no required-and-nullable field. Routes that *do* have such a field (where an + omitted key would break strict clients) are left untouched and keep emitting nulls. + An explicit ``response_model_exclude_none`` passed to the route decorator is respected. + """ + + def __init__(self, *args: Any, **kwargs: Any) -> None: + response_model = kwargs.get("response_model") + if ( + not kwargs.get("response_model_exclude_none") + and response_model is not None + and not _response_model_has_required_nullable(response_model) + ): + kwargs["response_model_exclude_none"] = True + super().__init__(*args, **kwargs) + + def FieldWithDefault(default_factory: Callable, **kwargs) -> Any: """ Field wrapper that ensures default_factory values appear in OpenAPI schema. @@ -80,16 +148,64 @@ def FieldWithDefault(default_factory: Callable, **kwargs) -> Any: from hindsight_api.config import get_config -from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding, fq_table +from hindsight_api.engine.memory_engine import Budget, _current_schema, _get_tiktoken_encoding from hindsight_api.engine.providers.none_llm import LLMNotAvailableError -from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, MemoryFact, TokenUsage +from hindsight_api.engine.response_models import ( + VALID_RECALL_FACT_TYPES, + DryRunExtractionResult, + MemoryFact, + TokenUsage, +) from hindsight_api.engine.search.tags import TagGroup, TagsMatch from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension -from hindsight_api.metrics import create_metrics_collector, get_metrics_collector, initialize_metrics +from hindsight_api.metrics import ( + create_metrics_collector, + get_metrics_collector, + initialize_metrics, + normalize_http_endpoint, +) from hindsight_api.models import RequestContext logger = logging.getLogger(__name__) +# 499 is the de facto reverse-proxy status for "client closed request". +_CLIENT_CLOSED_REQUEST_STATUS_CODE = 499 + +_T = TypeVar("_T") + + +async def run_cancellable_on_disconnect( + http_request: Request, + request_context: RequestContext, + coro: Awaitable[_T], + *, + operation: str, + bank_id: str, +) -> _T: + """Run an engine coroutine, aborting it with 499 if the client disconnects. + + Shared by the recall and reflect handlers. The actual disconnect detection + lives in ``ClientDisconnectCancellationMiddleware`` (a pure-ASGI middleware + installed outside the ``BaseHTTPMiddleware`` layer), which attaches a + :class:`CancellationToken` to the ASGI scope and trips it on + ``http.disconnect``. Here we simply hand that token to the engine via + ``RequestContext`` — the engine checks it at stage/iteration boundaries — and + translate the resulting ``OperationCancelledError`` into 499 so abandoned + work stops instead of running to completion (issue #2122). + + Note: ``Request.is_disconnected()`` is deliberately NOT used — it silently + never fires behind ``BaseHTTPMiddleware``, which is why the original #2127 + implementation did not actually cancel anything in this app. + """ + token = get_scope_cancellation_token(http_request.scope) + if token is not None: + request_context.cancellation = token + try: + return await coro + except OperationCancelledError as e: + logger.info(f"[{operation.upper()} CANCELLED] bank={bank_id} reason={e.reason}") + raise HTTPException(status_code=_CLIENT_CLOSED_REQUEST_STATUS_CODE, detail=e.reason) from e + class EntityIncludeOptions(BaseModel): """Options for including entity observations in recall results.""" @@ -224,7 +340,7 @@ class RecallResult(BaseModel): id: str text: str - type: str | None = None # fact type: world, experience, opinion, observation + type: str | None = None # fact type: world, experience, observation entities: list[str] | None = None # Entity names mentioned in this fact context: str | None = None occurred_start: str | None = None # ISO format date when the event started @@ -504,13 +620,16 @@ def coerce_tags(cls, v): return [v] return v - observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = Field( + observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = Field( default=None, title="ObservationScopes", description=( "How to scope observations during consolidation. " "'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. " "'combined' (default) runs a single pass with all tags together. " + "'shared' runs a single pass over one global, untagged scope, so memories consolidate together " + "regardless of their tags — useful for deduplicating across volatile per-call provenance tags " + "(e.g. per-session ids) while keeping those tags on the source facts. " "A list of tag lists runs one pass per inner list, giving full control over which combinations to use." ), ) @@ -811,7 +930,7 @@ class ReflectFact(BaseModel): text: str = Field( description="Fact text. When type='observation', this contains markdown-formatted consolidated knowledge" ) - type: str | None = None # fact type: world, experience, opinion, observation + type: str | None = None # fact type: world, experience, observation context: str | None = None occurred_start: str | None = None occurred_end: str | None = None @@ -1108,7 +1227,14 @@ class CreateBankRequest(BaseModel): ) retain_chunk_size: int | None = Field( default=None, - description="Maximum token size for each content chunk during retain.", + description="Target maximum characters for each content chunk during retain.", + ) + retain_structured_chunk_size: int | None = Field( + default=None, + description=( + "Maximum characters for a single JSONL line or conversation turn to keep whole during retain. " + "Defaults to retain_chunk_size when unset." + ), ) enable_observations: bool | None = Field( default=None, @@ -1148,6 +1274,7 @@ def get_config_updates(self) -> dict[str, Any]: "retain_extraction_mode", "retain_custom_instructions", "retain_chunk_size", + "retain_structured_chunk_size", "enable_observations", "observations_mission", ): @@ -1239,6 +1366,33 @@ class GraphDataResponse(BaseModel): limit: int +class ObservationScope(BaseModel): + """A distinct observation scope: an exact tag set plus its observation count.""" + + tags: list[str] = Field( + description="The exact tag set defining this scope (normalized order). Empty list is the global/untagged scope." + ) + count: int = Field(description="Number of observations that live under this scope") + + +class ObservationScopesResponse(BaseModel): + """Response model for the observation scopes enumeration endpoint.""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "scopes": [ + {"tags": ["user:alice"], "count": 12}, + {"tags": ["user:alice", "project:apollo"], "count": 4}, + {"tags": [], "count": 2}, + ] + } + } + ) + + scopes: list[ObservationScope] = Field(description="Distinct observation scopes, most populous first") + + class ListMemoryUnitsResponse(BaseModel): """Response model for list memory units endpoint.""" @@ -1268,6 +1422,39 @@ class ListMemoryUnitsResponse(BaseModel): offset: int +class DryRunExtractRequest(BaseModel): + """Request to run fact extraction ONLY (no resolution/links/embeddings/persistence). + + Every field below the content/context/date is a prompt-affecting override applied just for this + call — used to preview what a candidate retain mission (or any extraction setting) would extract, + without changing the bank. Unset (null) fields fall back to the bank's resolved config. + """ + + content: str = Field(description="Text to extract facts from (e.g. a document or a single chunk).") + context: str = Field(default="", description="Optional context about the content.") + # Named `timestamp` to match the retain item payload (retain maps timestamp -> event_date internally). + timestamp: datetime | None = Field( + default=None, description="Reference timestamp for resolving relative times (ISO 8601)." + ) + agent_name: str | None = Field(default=None, description="Narrator override (memory owner) primed in the prompt.") + # --- prompt-affecting config overrides (null = use the bank's value) --- + retain_mission: str | None = None + retain_extraction_mode: str | None = None + retain_custom_instructions: str | None = None + retain_extract_causal_links: bool | None = None + retain_chunk_size: int | None = None + entity_labels: list | None = None + entities_allow_free_form: bool | None = None + llm_output_language: str | None = None + + @field_validator("content") + @classmethod + def validate_content(cls, v: str) -> str: + if not v.strip(): + raise ValueError("content cannot be empty") + return v + + class ListDocumentsResponse(BaseModel): """Response model for list documents endpoint.""" @@ -1352,7 +1539,8 @@ class DocumentResponse(BaseModel): id: str bank_id: str - original_text: str + # None when document text storage is disabled (HINDSIGHT_API_STORE_DOCUMENT_TEXT=false). + original_text: str | None content_hash: str | None created_at: str updated_at: str @@ -1363,6 +1551,12 @@ class DocumentResponse(BaseModel): tags: list[str] = FieldWithDefault(list, description="Tags associated with this document") document_metadata: dict[str, Any] | None = Field(default=None, description="Document metadata") retain_params: dict[str, Any] | None = Field(default=None, description="Parameters used during retain") + observation_scopes: str | list[list[str]] | None = Field( + default=None, + description="The observation_scopes spec configured at retain time (e.g. 'all_combinations', " + "'per_tag', or explicit tag-set lists), captured into retain_params. None when none was set " + "(default 'combined' scoping) or for documents retained before this was captured.", + ) class UpdateDocumentRequest(BaseModel): @@ -1389,6 +1583,83 @@ class UpdateDocumentResponse(BaseModel): success: bool = True +class UpdateMemoryRequest(BaseModel): + """Request model for curating a single memory unit (edit / invalidate / revert). + + Provide ``text`` to correct the fact, and/or ``state`` to invalidate + ('invalidated') or revert ('valid') it. ``reason`` is optional free text + recorded on the memory. At least one of ``text`` or ``state`` must be set. + Only world/experience facts can be curated; observations are derived. + """ + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "state": "invalidated", + "reason": "superseded: server decommissioned 2026-06-01", + } + } + ) + + text: str | None = Field( + default=None, + description="New fact text. Re-embeds the memory, drops its derived " + "observations and links, and triggers re-consolidation.", + ) + context: str | None = Field( + default=None, + description="New context for the fact. '' clears it; omit to leave unchanged.", + ) + occurred_start: str | None = Field( + default=None, + description="New occurred-range start (ISO 8601). '' clears it; omit to leave unchanged.", + ) + occurred_end: str | None = Field( + default=None, + description="New occurred-range end (ISO 8601). '' clears it; omit to leave unchanged.", + ) + fact_type: str | None = Field( + default=None, + description="Reclassify the fact: 'world' or 'experience'. Omit to leave unchanged.", + ) + entities: list[str] | None = Field( + default=None, + description="Replace the fact's entities. Names are resolved/find-or-created " + "the same way retain does; '[]' detaches all entities. Omit to leave unchanged.", + ) + state: str | None = Field( + default=None, + description="Curation state: 'invalidated' to soft-retire the memory " + "(excluded from recall/consolidation, links and derived observations " + "pruned, moved to the archive) or 'valid' to revert. Reversible.", + ) + reason: str | None = Field( + default=None, + description="Optional free-text reason recorded when invalidating.", + ) + + @model_validator(mode="after") + def _require_an_edit(self) -> "UpdateMemoryRequest": + if all( + v is None + for v in ( + self.text, + self.context, + self.occurred_start, + self.occurred_end, + self.fact_type, + self.entities, + self.state, + ) + ): + raise ValueError("Provide at least one field to update.") + if self.state is not None and self.state not in ("valid", "invalidated"): + raise ValueError("state must be 'valid' or 'invalidated'.") + if self.fact_type is not None and self.fact_type not in ("world", "experience"): + raise ValueError("fact_type must be 'world' or 'experience'.") + return self + + class DeleteDocumentResponse(BaseModel): """Response model for delete document endpoint.""" @@ -1538,6 +1809,51 @@ class BankStatsResponse(BaseModel): total_observations: int = Field(default=0, description="Total number of observations") +class LlmOperationHealth(BaseModel): + """LLM connectivity status for a single operation. Status only — no provider/model/ + endpoint/error, so the probe never discloses the LLM configuration.""" + + operation: Literal["retain", "consolidation", "reflect"] = Field( + # Distinct title so the generated clients don't collide this inline enum with the + # async-operation "operation" enum (progenitor names Rust types from the title). + title="LlmHealthOperation", + description="Operation whose LLM was probed", + ) + ok: bool = Field(description="True only when the probe connected successfully") + status: Literal["connected", "not_configured", "auth_failed", "unreachable", "timeout"] = Field( + # Distinct title — otherwise this inline enum's default title "Status" collides + # with the async-operation status enum and breaks the generated Rust client/CLI. + title="LlmHealthStatus", + description="'connected'; 'not_configured' (provider is 'none'); 'auth_failed' (rejected — " + "usually a wrong/expired API key); 'unreachable' (call failed); 'timeout'", + ) + latency_ms: float | None = Field(default=None, description="Round-trip latency of the probe call") + + +class BankLlmHealthResponse(BaseModel): + """Per-bank LLM connectivity probe across retain/consolidation/reflect. Operations + that share a configuration are probed once. Discloses status only — never the + provider, model, endpoint, API key, or raw error.""" + + model_config = ConfigDict( + json_schema_extra={ + "example": { + "bank_id": "user123", + "operations": [ + {"operation": "retain", "ok": True, "status": "connected", "latency_ms": 412.0}, + {"operation": "consolidation", "ok": True, "status": "connected", "latency_ms": 412.0}, + {"operation": "reflect", "ok": False, "status": "not_configured", "latency_ms": None}, + ], + } + } + ) + + bank_id: str = Field(description="Bank identifier") + operations: list[LlmOperationHealth] = Field( + description="Connectivity status per operation (retain, consolidation, reflect)" + ) + + class MemoryTimeseriesBucket(BaseModel): """One bucket in the memory ingestion time-series.""" @@ -1830,7 +2146,14 @@ class BankTemplateConfig(BaseModel): retain_custom_instructions: str | None = Field( default=None, description="Custom extraction prompt (when mode='custom')" ) - retain_chunk_size: int | None = Field(default=None, description="Max token size for each content chunk") + retain_chunk_size: int | None = Field(default=None, description="Target max characters for each content chunk") + retain_structured_chunk_size: int | None = Field( + default=None, + description=( + "Max characters for a single JSONL line or conversation turn to keep whole; " + "defaults to retain_chunk_size when unset" + ), + ) enable_observations: bool | None = Field(default=None, description="Toggle observation consolidation") observations_mission: str | None = Field(default=None, description="Controls what gets synthesised") disposition_skepticism: int | None = Field(default=None, ge=1, le=5, description="Skepticism trait (1-5)") @@ -1866,6 +2189,16 @@ class BankTemplateConfig(BaseModel): max_observations_per_scope: int | None = Field( default=None, description="Max observations to retain per consolidation scope" ) + observation_scope_limits: list[dict[str, Any]] | None = Field( + default=None, + description=( + "Per-scope overrides of max_observations_per_scope: " + '[{"scope": ["run_*", "shared"], "limit": 1}]. Each scope is a list of ' + "fnmatch tag-globs; a consolidation scope matches under exact cover " + "(every tag matched by a glob and every glob matched by a tag). The first " + "matching rule wins; unmatched scopes fall back to max_observations_per_scope." + ), + ) reflect_source_facts_max_tokens: int | None = Field( default=None, description="Max tokens of source facts per reflect call" ) @@ -2413,11 +2746,15 @@ class FeaturesInfo(BaseModel): mcp: bool = Field(description="Whether MCP (Model Context Protocol) server is enabled") worker: bool = Field(description="Whether the background worker is enabled") bank_config_api: bool = Field(description="Whether per-bank configuration API is enabled") + bank_llm_health: bool = Field(description="Whether the per-bank LLM connectivity probe is enabled") file_upload_api: bool = Field(description="Whether file upload/conversion API is enabled") document_export_api: bool = Field(description="Whether the document export endpoint is enabled") document_import_api: bool = Field(description="Whether the document import endpoint is enabled") audit_log: bool = Field(description="Whether audit logging is enabled") llm_trace: bool = Field(description="Whether per-bank LLM request tracing is enabled") + store_document_text: bool = Field( + description="Whether raw source text is persisted. When false, document/chunk source text is not stored." + ) class VersionResponse(BaseModel): @@ -2459,7 +2796,7 @@ class CreateWebhookRequest(BaseModel): secret: str | None = Field(default=None, description="HMAC-SHA256 signing secret (optional)") event_types: list[str] = Field( default=["consolidation.completed"], - description="List of event types to deliver. Currently supported: 'consolidation.completed'", + description="List of event types to deliver. Supported: 'retain.completed', 'consolidation.completed', 'memory_defense.triggered'.", ) enabled: bool = Field(default=True, description="Whether this webhook is active") http_config: WebhookHttpConfig = Field( @@ -2569,7 +2906,6 @@ def _make_audited_http(audit_logger_getter: Callable[[], AuditLogger | None]): from datetime import datetime as _dt from datetime import timezone as _tz from functools import wraps - from typing import Callable as _Callable def audited(action: str, *, request_param: str | None = "request"): """Decorator that wraps an HTTP handler with audit logging. @@ -2800,6 +3136,10 @@ async def lifespan(app: FastAPI): root_path=config.base_path, ) + # Drop null fields from responses (omit `"x": null`) for routes where it's wire-safe. + # Must be set before any route is registered so @app. decorators pick it up. + app.router.route_class = ExcludeNoneRoute + # IMPORTANT: Set memory on app.state immediately, don't wait for lifespan # This is required for mounted sub-applications where lifespan may not fire app.state.memory = memory @@ -2909,17 +3249,9 @@ async def unknown_params_middleware(request, call_next): @app.middleware("http") async def http_metrics_middleware(request, call_next): """Record HTTP request metrics.""" - # Normalize endpoint path to reduce cardinality - # Replace UUIDs and numeric IDs with placeholders - import re - - from starlette.requests import Request - - path = request.url.path - # Replace UUIDs - path = re.sub(r"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", "/{id}", path) - # Replace numeric IDs - path = re.sub(r"/\d+(?=/|$)", "/{id}", path) + # Template id segments (bank ids, UUIDs, numeric ids) so the endpoint + # metric label stays bounded-cardinality. + path = normalize_http_endpoint(request.url.path) status_code = [500] # Default to 500, will be updated metrics_collector = get_metrics_collector() @@ -2944,6 +3276,13 @@ async def http_metrics_middleware(request, call_next): app.include_router(root_router) logging.info("HTTP extension root router mounted") + # Client-disconnect cancellation for recall/reflect. Added LAST so it sits + # OUTSIDE the @app.middleware("http") (BaseHTTPMiddleware) layers above — + # that placement is mandatory: BaseHTTPMiddleware breaks + # Request.is_disconnected(), so the only way to observe an abandoned request + # is to own the raw ASGI receive channel from outside it (issue #2122). + app.add_middleware(ClientDisconnectCancellationMiddleware) + return app @@ -3000,6 +3339,7 @@ def precheck_for(operation: str): async def _precheck_dep( bank_id: str, + request: Request, request_context: RequestContext = Depends(get_request_context), ) -> None: validator = getattr(app.state.memory, "_operation_validator", None) @@ -3008,10 +3348,20 @@ async def _precheck_dep( from hindsight_api.extensions import PrecheckContext await app.state.memory._authenticate_tenant(request_context) + cl_header = request.headers.get("content-length") + content_length: int | None = None + if cl_header is not None: + try: + parsed = int(cl_header) + except ValueError: + parsed = -1 + if parsed >= 0: + content_length = parsed ctx = PrecheckContext( operation=operation, bank_id=bank_id, request_context=request_context, + content_length=content_length, ) result = await validator.precheck(ctx) if not result.allowed: @@ -3080,11 +3430,13 @@ async def version_endpoint() -> VersionResponse: mcp=config.mcp_enabled, worker=config.worker_enabled, bank_config_api=config.enable_bank_config_api, + bank_llm_health=config.enable_bank_llm_health, file_upload_api=config.enable_file_upload_api, document_export_api=config.enable_document_export_api, document_import_api=config.enable_document_import_api, audit_log=config.audit_log_enabled, llm_trace=config.llm_trace_enabled, + store_document_text=config.store_document_text, ), ) @@ -3106,7 +3458,7 @@ async def metrics_endpoint(): "/v1/default/banks/{bank_id}/graph", response_model=GraphDataResponse, summary="Get memory graph data", - description="Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion).", + description="Retrieve graph data for visualization, optionally filtered by type (world/experience/observation).", operation_id="get_graph", tags=["Memory"], ) @@ -3159,6 +3511,8 @@ async def api_list( type: str | None = None, q: str | None = None, consolidation_state: str | None = None, + state: str | None = None, + document_id: str | None = None, limit: int = 100, offset: int = 0, request_context: RequestContext = Depends(get_request_context), @@ -3171,7 +3525,7 @@ async def api_list( Args: bank_id: Memory Bank ID (from path) - type: Filter by fact type (world, experience, opinion) + type: Filter by fact type (world, experience, observation) q: Search query for full-text search (searches text and context) consolidation_state: Filter by consolidation state for source memories (world/experience). One of 'failed', 'pending', or 'done'. @@ -3184,6 +3538,8 @@ async def api_list( fact_type=type, search_query=q, consolidation_state=consolidation_state, + state=state, + document_id=document_id, limit=limit, offset=offset, request_context=request_context, @@ -3202,6 +3558,75 @@ async def api_list( logger.error(f"Error in /v1/default/banks/{bank_id}/memories/list: {error_detail}") raise HTTPException(status_code=500, detail=str(e)) + async def _require_dry_run_enabled() -> None: + """Feature-flag gate for dry-run extraction. + + Declared as a dependency BEFORE ``precheck_for("dry_run_extract")`` so a + disabled route returns 404 regardless of tenant/billing state — FastAPI + resolves path-operation dependencies in signature order, so this runs + first and preserves the original "disabled → 404" contract. + """ + if not get_config().enable_dry_run_extract: + raise HTTPException( + status_code=404, + detail="Dry-run extraction is disabled. Set HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=true to re-enable.", + ) + + @app.post( + "/v1/default/banks/{bank_id}/memories/dry-run-extract", + response_model=DryRunExtractionResult, + summary="Dry-run fact extraction (preview, no persistence)", + description=( + "Preview what the retain step would extract from text WITHOUT changing the bank — no " + "entity resolution, links, embeddings, or persistence. Returns the candidate facts and " + "the LLM token usage. Every prompt-affecting setting (retain mission, extraction mode, " + "chunk size, …) is overridable in the body to A/B a candidate config against the bank's " + "current one. This is a read-only tool: nothing is stored." + ), + operation_id="dry_run_extract_memories", + tags=["Memory"], + ) + async def api_dry_run_extract( + bank_id: str, + body: DryRunExtractRequest, + request_context: RequestContext = Depends(get_request_context), + _enabled: None = Depends(_require_dry_run_enabled), + _precheck: None = Depends(precheck_for("dry_run_extract")), + ): + try: + override_fields = ( + "retain_mission", + "retain_extraction_mode", + "retain_custom_instructions", + "retain_extract_causal_links", + "retain_chunk_size", + "entity_labels", + "entities_allow_free_form", + "llm_output_language", + ) + overrides = {f: getattr(body, f) for f in override_fields if getattr(body, f) is not None} + return await app.state.memory.extract_dry_run( + bank_id, + body.content, + context=body.context or "", + event_date=body.timestamp, + overrides=overrides, + agent_name=body.agent_name, + request_context=request_context, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except OperationValidationError as e: + raise HTTPException(status_code=e.status_code, detail=e.reason) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in /v1/default/banks/{bank_id}/memories/dry-run-extract: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + @app.get( "/v1/default/banks/{bank_id}/memories/{memory_id}", summary="Get memory unit", @@ -3237,6 +3662,53 @@ async def api_get_memory( logger.error(f"Error in /v1/default/banks/{bank_id}/memories/{memory_id}: {error_detail}") raise HTTPException(status_code=500, detail=str(e)) + @app.patch( + "/v1/default/banks/{bank_id}/memories/{memory_id}", + summary="Curate memory unit", + description="Edit a memory's text and/or change its curation state " + "(invalidate / revert). Invalidated memories are excluded from recall, " + "consolidation, and graph maintenance but kept for audit (reversible). " + "Only world/experience facts can be curated; observations are derived.", + operation_id="update_memory", + tags=["Memory"], + ) + async def api_update_memory( + bank_id: str, + memory_id: str, + request: UpdateMemoryRequest, + request_context: RequestContext = Depends(get_request_context), + ): + """Curate a single memory unit (edit text / invalidate / revert).""" + try: + data = await app.state.memory.update_memory_unit( + bank_id=bank_id, + memory_id=memory_id, + text=request.text, + context=request.context, + occurred_start=request.occurred_start, + occurred_end=request.occurred_end, + new_fact_type=request.fact_type, + entities=request.entities, + state=request.state, + reason=request.reason, + request_context=request_context, + ) + if data is None: + raise HTTPException(status_code=404, detail=f"Memory unit '{memory_id}' not found") + 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: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/memories/{memory_id}: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + @app.get( "/v1/default/banks/{bank_id}/memories/{memory_id}/history", summary="Get observation history", @@ -3285,6 +3757,7 @@ async def api_get_observation_history( async def api_recall( bank_id: str, request: RecallRequest, + http_request: Request, request_context: RequestContext = Depends(get_request_context), _precheck: None = Depends(precheck_for("recall")), ): @@ -3340,25 +3813,34 @@ async def api_recall( "recall", bank_id=bank_id, source="api", budget=request.budget.value, max_tokens=request.max_tokens ): recall_start = time.time() - core_result = await app.state.memory.recall_async( + # Cancel the recall if the client disconnects: the engine checks + # request_context at each stage boundary and aborts abandoned + # work rather than running it to completion (issue #2122). + core_result = await run_cancellable_on_disconnect( + http_request, + request_context, + app.state.memory.recall_async( + bank_id=bank_id, + query=request.query, + budget=request.budget, + max_tokens=request.max_tokens, + enable_trace=request.trace, + fact_type=fact_types, + question_date=question_date, + include_entities=include_entities, + max_entity_tokens=max_entity_tokens, + include_chunks=include_chunks, + max_chunk_tokens=max_chunk_tokens, + include_source_facts=include_source_facts, + max_source_facts_tokens=max_source_facts_tokens, + max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation, + request_context=request_context, + tags=request.tags, + tags_match=request.tags_match, + tag_groups=request.tag_groups, + ), + operation="recall", bank_id=bank_id, - query=request.query, - budget=request.budget, - max_tokens=request.max_tokens, - enable_trace=request.trace, - fact_type=fact_types, - question_date=question_date, - include_entities=include_entities, - max_entity_tokens=max_entity_tokens, - include_chunks=include_chunks, - max_chunk_tokens=max_chunk_tokens, - include_source_facts=include_source_facts, - max_source_facts_tokens=max_source_facts_tokens, - max_source_facts_tokens_per_observation=max_source_facts_tokens_per_observation, - request_context=request_context, - tags=request.tags, - tags_match=request.tags_match, - tag_groups=request.tag_groups, ) # Convert core MemoryFact objects to API RecallResult objects (excluding internal metrics) @@ -3462,11 +3944,11 @@ def _fact_to_result(fact: "MemoryFact") -> RecallResult: "/v1/default/banks/{bank_id}/reflect", response_model=ReflectResponse, summary="Reflect and generate answer", - description="Reflect and formulate an answer using bank identity, world facts, and opinions.\n\n" + description="Reflect and formulate an answer using bank identity, world facts, observations, and mental models.\n\n" "This endpoint:\n" "1. Retrieves experience (conversations and events)\n" "2. Retrieves world facts relevant to the query\n" - "3. Retrieves existing opinions (bank's perspectives)\n" + "3. Retrieves observations and mental models (bank's synthesized perspectives)\n" "4. Uses LLM to formulate a contextual answer\n" "5. Returns plain text answer and the facts used", operation_id="reflect", @@ -3476,6 +3958,7 @@ def _fact_to_result(fact: "MemoryFact") -> RecallResult: async def api_reflect( bank_id: str, request: ReflectRequest, + http_request: Request, request_context: RequestContext = Depends(get_request_context), _precheck: None = Depends(precheck_for("reflect")), ): @@ -3489,20 +3972,30 @@ async def api_reflect( # Use the memory system's reflect_async method (record metrics) with metrics.record_operation("reflect", bank_id=bank_id, source="api", budget=request.budget.value): - core_result = await app.state.memory.reflect_async( + # Cancel the reflect if the client disconnects: the agent loop + # checks request_context between iterations and the nested recall + # checks at its stage boundaries, so abandoned work stops instead + # of running to completion (issue #2122). + core_result = await run_cancellable_on_disconnect( + http_request, + request_context, + app.state.memory.reflect_async( + bank_id=bank_id, + query=query, + budget=request.budget, + context=None, # Deprecated, now concatenated with query + max_tokens=request.max_tokens, + response_schema=request.response_schema, + request_context=request_context, + tags=request.tags, + tags_match=request.tags_match, + tag_groups=request.tag_groups, + fact_types=request.fact_types, + exclude_mental_models=request.exclude_mental_models, + exclude_mental_model_ids=request.exclude_mental_model_ids, + ), + operation="reflect", bank_id=bank_id, - query=query, - budget=request.budget, - context=None, # Deprecated, now concatenated with query - max_tokens=request.max_tokens, - response_schema=request.response_schema, - request_context=request_context, - tags=request.tags, - tags_match=request.tags_match, - tag_groups=request.tag_groups, - fact_types=request.fact_types, - exclude_mental_models=request.exclude_mental_models, - exclude_mental_model_ids=request.exclude_mental_model_ids, ) # Build based_on (memories + mental_models + directives) if facts are requested @@ -3668,6 +4161,45 @@ async def api_stats( logger.error(f"Error in /v1/default/banks/{bank_id}/stats: {error_detail}") raise HTTPException(status_code=500, detail=str(e)) + @app.post( + "/v1/default/banks/{bank_id}/health/llm", + response_model=BankLlmHealthResponse, + summary="Test the bank's LLM connectivity", + description="Probe the LLMs this bank would use for retain / consolidation / reflect with one minimal call " + "each (configs shared across operations are probed once), so you can discover 'not configured / unreachable' " + "instead of a silent stall. Deliberate action (makes a real provider call); not for polling. Returns status " + "only — never the provider, model, endpoint, API key, or raw error. Disable with " + "HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH=false.", + operation_id="test_bank_llm", + tags=["Banks"], + ) + async def api_bank_llm_health(bank_id: str, request_context: RequestContext = Depends(get_request_context)): + """Probe per-bank LLM connectivity.""" + if not get_config().enable_bank_llm_health: + raise HTTPException( + status_code=404, + detail="Bank LLM health check is disabled. Set HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH=true to enable.", + ) + try: + result = await app.state.memory.check_bank_llm(bank_id, request_context=request_context) + return BankLlmHealthResponse( + bank_id=result.bank_id, + operations=[ + LlmOperationHealth(operation=op.operation, ok=op.ok, status=op.status, latency_ms=op.latency_ms) + for op in result.operations + ], + ) + except OperationValidationError as e: + raise HTTPException(status_code=e.status_code, detail=e.reason) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in POST /v1/default/banks/{bank_id}/health/llm: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + @app.get( "/v1/default/banks/{bank_id}/stats/memories-timeseries", response_model=MemoriesTimeseriesResponse, @@ -5486,6 +6018,35 @@ async def api_clear_observations(bank_id: str, request_context: RequestContext = logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/observations: {error_detail}") raise HTTPException(status_code=500, detail=str(e)) + @app.get( + "/v1/default/banks/{bank_id}/observations/scopes", + response_model=ObservationScopesResponse, + summary="List observation scopes", + description=( + "Enumerate the distinct scopes across a bank's observations. Each observation lives " + "under a scope: the exact set of tags it was consolidated with. Returns every distinct " + "scope (tag order normalized) with the number of observations in it; the empty tag list " + "is the global/untagged scope. Use a returned scope with the graph endpoint " + "(tags= & tags_match=exact) to filter observations to exactly that scope." + ), + operation_id="list_observation_scopes", + tags=["Memory"], + ) + async def api_list_observation_scopes(bank_id: str, request_context: RequestContext = Depends(get_request_context)): + """List the distinct observation scopes (exact tag sets) for a bank.""" + try: + return await app.state.memory.list_observation_scopes(bank_id, request_context=request_context) + except OperationValidationError as e: + raise HTTPException(status_code=e.status_code, detail=e.reason) + except (AuthenticationError, HTTPException): + raise + except Exception as e: + import traceback + + error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" + logger.error(f"Error in GET /v1/default/banks/{bank_id}/observations/scopes: {error_detail}") + raise HTTPException(status_code=500, detail=str(e)) + @app.post( "/v1/default/banks/{bank_id}/consolidation/recover", response_model=RecoverConsolidationResponse, @@ -5627,6 +6188,15 @@ async def api_update_bank_config( app.state.memory._operation_validator.validate_bank_write(ctx) ) + # Validate Memory Defense policy shape before persisting. + if "memory_defense" in request.updates and request.updates["memory_defense"] is not None: + from hindsight_api.extensions.memory_defense import parse_policy + + try: + parse_policy(request.updates["memory_defense"]) + except ValueError as exc: + raise HTTPException(status_code=422, detail=f"invalid memory_defense policy: {exc}") + # Update config via config resolver (validates configurable fields and permissions) await app.state.memory._config_resolver.update_bank_config(bank_id, request.updates, request_context) @@ -6155,7 +6725,21 @@ async def api_retain( raise HTTPException(status_code=e.status_code, detail=e.reason) except (AuthenticationError, HTTPException): raise + except ValueError as e: + # Invalid request parameters (e.g. duplicate document_ids, or + # update_mode='append' when document text storage is disabled) are + # client errors, not server faults. + raise HTTPException(status_code=400, detail=str(e)) except Exception as e: + from dataclasses import asdict + + from hindsight_api.engine.retain.orchestrator import MemoryDefenseAllBlockedError + + if isinstance(e, MemoryDefenseAllBlockedError): + raise HTTPException( + status_code=422, + detail={"violations": [asdict(v) for v in e.violations]}, + ) import traceback # Create a summary of the input for debugging @@ -6182,7 +6766,7 @@ async def api_retain( description="Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories.\n\n" "This endpoint handles file upload, conversion, and memory creation in a single operation.\n\n" "**Features:**\n" - "- Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription)\n" + "- Supports PDF, DOCX, PPTX, XLSX, images (parser-dependent OCR), audio (with transcription)\n" "- Automatic file-to-markdown conversion using pluggable parsers\n" "- Files stored in object storage (PostgreSQL by default, S3 for production)\n" "- Each file becomes a separate document with optional metadata/tags\n" @@ -6274,7 +6858,6 @@ def _validate_parsers(parsers: list[str], context: str) -> None: _validate_parsers(_resolve_parser(request_data.parser), "request-level parser") # Prepare file items and calculate total batch size - import io file_items = [] total_batch_size = 0 @@ -6353,14 +6936,14 @@ async def read(self): "/v1/default/banks/{bank_id}/memories", response_model=DeleteResponse, summary="Clear memory bank memories", - description="Delete memory units for a memory bank. Optionally filter by type (world, experience, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved.", + description="Delete memory units for a memory bank. Optionally filter by type (world, experience, observation) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved.", operation_id="clear_bank_memories", tags=["Memory"], ) @audited("clear_memories", request_param=None) async def api_clear_bank_memories( bank_id: str, - type: str | None = Query(None, description="Optional fact type filter (world, experience, opinion)"), + type: str | None = Query(None, description="Optional fact type filter (world, experience, observation)"), request_context: RequestContext = Depends(get_request_context), ): """Clear memories for a memory bank, optionally filtered by type.""" diff --git a/hindsight-api-slim/hindsight_api/api/mcp.py b/hindsight-api-slim/hindsight_api/api/mcp.py index 9e76e8824b..9f365e39be 100644 --- a/hindsight-api-slim/hindsight_api/api/mcp.py +++ b/hindsight-api-slim/hindsight_api/api/mcp.py @@ -9,7 +9,7 @@ from hindsight_api import MemoryEngine from hindsight_api import __version__ as HINDSIGHT_VERSION -from hindsight_api.config import _get_raw_config +from hindsight_api.config import DEFAULT_MCP_RECALL_DESCRIPTION, DEFAULT_MCP_RETAIN_DESCRIPTION, _get_raw_config from hindsight_api.engine.memory_engine import _current_schema from hindsight_api.extensions import MCPExtension, load_extension from hindsight_api.extensions.tenant import AuthenticationError @@ -78,6 +78,19 @@ def get_current_mcp_authenticated() -> bool: return _current_mcp_authenticated.get() +def _build_mcp_tool_descriptions(extra_instructions: str | None) -> tuple[str | None, str | None]: + """Return custom retain/recall descriptions when server-level MCP instructions are set.""" + if not isinstance(extra_instructions, str): + return None, None + + extra_instructions = extra_instructions.strip() + if not extra_instructions: + return None, None + + suffix = f"\n\nAdditional instructions: {extra_instructions}" + return DEFAULT_MCP_RETAIN_DESCRIPTION + suffix, DEFAULT_MCP_RECALL_DESCRIPTION + suffix + + def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP: """ Create and configure the Hindsight MCP server. @@ -113,6 +126,8 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP: "delete_directive", "list_memories", "get_memory", + "update_memory", + "invalidate_memory", "list_documents", "get_document", "delete_document", @@ -133,6 +148,10 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP: allowed = frozenset(global_config.mcp_enabled_tools) base_tools = (base_tools if base_tools is not None else _ALL_TOOLS) & allowed + retain_description, recall_description = _build_mcp_tool_descriptions( + getattr(global_config, "mcp_instructions", None) + ) + # Configure and register tools using shared module config = MCPToolsConfig( bank_id_resolver=get_current_bank_id, @@ -142,6 +161,8 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP: mcp_authenticated_resolver=get_current_mcp_authenticated, # Propagate MCP pre-auth flag include_bank_id_param=multi_bank, tools=base_tools, + retain_description=retain_description, + recall_description=recall_description, ) register_mcp_tools(mcp, memory, config) diff --git a/hindsight-api-slim/hindsight_api/cancellation.py b/hindsight-api-slim/hindsight_api/cancellation.py new file mode 100644 index 0000000000..307880f183 --- /dev/null +++ b/hindsight-api-slim/hindsight_api/cancellation.py @@ -0,0 +1,85 @@ +"""Cooperative cancellation for long-running engine operations. + +Recall runs as a staged pipeline whose heavy stages — graph expansion and +cross-encoder reranking — execute in worker threads (``run_in_executor``) that +asyncio task cancellation cannot interrupt once they have started. Cancelling +the awaiting task only unblocks the ``await``; the thread keeps burning CPU to +completion. So rather than rely on task cancellation, callers thread a +``CancellationToken`` through ``RequestContext`` and the engine checks it at +stage boundaries (``raise_if_cancelled``), bailing out *before* dispatching the +next expensive stage. + +This is cooperative by design: it cannot stop a computation already inside a +worker thread, but it does stop an abandoned recall from progressing into — or +past — that work, which is what starves the instance in issue #2122. The token +lives on ``RequestContext``, so any operation that receives one (recall today; +reflect/consolidation/MCP later) can adopt the same checkpoints, and any driver +(client disconnect today; a deadline tomorrow) can fire it. +""" + +from __future__ import annotations + +import asyncio + + +class OperationCancelledError(Exception): + """Raised at a checkpoint when the operation has been cancelled. + + Carries the ``reason`` set by whoever cancelled (e.g. "client disconnected") + so the HTTP layer can translate it into the appropriate status code instead + of a generic 500. + + NOTE: this is a plain ``Exception`` on purpose, NOT ``BaseException``. The + recall/reflect pipelines have broad ``except Exception`` handlers that would + otherwise swallow it — those handlers re-raise ``OperationCancelledError`` + explicitly (see ``_search_with_retries``) so cancellation propagates to the + HTTP layer. A ``BaseException`` would dodge those handlers but also slip past + legitimate ``isinstance(result, Exception)`` checks (e.g. the reflect agent's + ``asyncio.gather(..., return_exceptions=True)`` tool-result handling), which + expect every non-tuple result to be an ``Exception``. + """ + + def __init__(self, reason: str = "operation cancelled") -> None: + super().__init__(reason) + self.reason = reason + + +class CancellationToken: + """A one-shot, cooperative cancellation signal. + + Cheap to poll (``raise_if_cancelled``) at stage boundaries and awaitable + (``wait``) so a driver task can block until cancellation. Safe to share + across an engine call tree; polling is a no-op until something cancels, and + cancellation is idempotent (the first reason wins). + """ + + __slots__ = ("_event", "_reason") + + def __init__(self) -> None: + self._event = asyncio.Event() + self._reason = "operation cancelled" + + def cancel(self, reason: str = "operation cancelled") -> None: + """Signal cancellation. Idempotent; the first reason recorded wins.""" + if not self._event.is_set(): + self._reason = reason + self._event.set() + + @property + def cancelled(self) -> bool: + """Whether cancellation has been signalled.""" + return self._event.is_set() + + @property + def reason(self) -> str: + """The reason recorded by the first ``cancel`` call.""" + return self._reason + + def raise_if_cancelled(self) -> None: + """Raise ``OperationCancelledError`` if cancellation has been signalled.""" + if self._event.is_set(): + raise OperationCancelledError(self._reason) + + async def wait(self) -> None: + """Block until cancellation is signalled.""" + await self._event.wait() diff --git a/hindsight-api-slim/hindsight_api/config.py b/hindsight-api-slim/hindsight_api/config.py index 3b4a6887ef..e9e8484b62 100644 --- a/hindsight-api-slim/hindsight_api/config.py +++ b/hindsight-api-slim/hindsight_api/config.py @@ -141,9 +141,12 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: ENV_LLM_REASONING_EFFORT = "HINDSIGHT_API_LLM_REASONING_EFFORT" ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER" ENV_LLM_OPENAI_SERVICE_TIER = "HINDSIGHT_API_LLM_OPENAI_SERVICE_TIER" +ENV_LLM_BEDROCK_SERVICE_TIER = "HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER" +ENV_LLM_GEMINI_SERVICE_TIER = "HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER" ENV_LLM_EXTRA_BODY = "HINDSIGHT_API_LLM_EXTRA_BODY" ENV_LLM_DEFAULT_HEADERS = "HINDSIGHT_API_LLM_DEFAULT_HEADERS" ENV_LLM_STRICT_SCHEMA = "HINDSIGHT_API_LLM_STRICT_SCHEMA" +ENV_LLM_SEND_BANK_AS_USER = "HINDSIGHT_API_LLM_SEND_BANK_AS_USER" # LiteLLM Router chain — provider-specific config consumed by the "litellmrouter" # provider. Each entry is a deployment; the Router tries them in declared order and @@ -156,11 +159,26 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: # Defaults for service tiers DEFAULT_LLM_GROQ_SERVICE_TIER = "auto" # "on_demand", "flex", or "auto" DEFAULT_LLM_OPENAI_SERVICE_TIER = None # None (default) or "flex" (50% cheaper) +DEFAULT_LLM_BEDROCK_SERVICE_TIER = None # None (default), "flex", "priority", or "reserved" +DEFAULT_LLM_GEMINI_SERVICE_TIER = None # None (default) or "flex" (50% cheaper best-effort tier) DEFAULT_LLM_EXTRA_BODY = None # None = no extra body params; JSON dict merged into OpenAI extra_body DEFAULT_LLM_DEFAULT_HEADERS = ( None # None = no extra headers; JSON dict passed as default_headers to provider SDK clients ) + +def parse_gemini_service_tier(value: str | None) -> str | None: + """Normalize and validate the Gemini service tier.""" + tier = value or None + valid_tiers = (None, "flex") + if tier not in valid_tiers: + raise ValueError( + f"Invalid HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER: " + f"{tier!r}. Must be one of: {', '.join(t for t in valid_tiers if t is not None)}." + ) + return tier + + # Per-operation LLM configuration (optional, falls back to global LLM config) ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER" ENV_RETAIN_LLM_API_KEY = "HINDSIGHT_API_RETAIN_LLM_API_KEY" @@ -252,6 +270,7 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: 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" +ENV_RERANKER_OPENROUTER_BASE_URL = "HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL" # ZeroEntropy configuration (embeddings) ENV_EMBEDDINGS_ZEROENTROPY_API_KEY = "HINDSIGHT_API_EMBEDDINGS_ZEROENTROPY_API_KEY" @@ -309,6 +328,7 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: ENV_RERANKER_LITELLM_SDK_TIMEOUT = "HINDSIGHT_API_RERANKER_LITELLM_SDK_TIMEOUT" ENV_RERANKER_GOOGLE_TIMEOUT = "HINDSIGHT_API_RERANKER_GOOGLE_TIMEOUT" ENV_RERANKER_MAX_CANDIDATES = "HINDSIGHT_API_RERANKER_MAX_CANDIDATES" +ENV_SEMANTIC_MIN_SIMILARITY = "HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY" ENV_RERANKER_FLASHRANK_MODEL = "HINDSIGHT_API_RERANKER_FLASHRANK_MODEL" ENV_RERANKER_FLASHRANK_CACHE_DIR = "HINDSIGHT_API_RERANKER_FLASHRANK_CACHE_DIR" ENV_RERANKER_FLASHRANK_CPU_MEM_ARENA = "HINDSIGHT_API_RERANKER_FLASHRANK_CPU_MEM_ARENA" @@ -349,7 +369,10 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED" ENV_MCP_ENABLED_TOOLS = "HINDSIGHT_API_MCP_ENABLED_TOOLS" ENV_MCP_STATELESS = "HINDSIGHT_API_MCP_STATELESS" +ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS" ENV_ENABLE_BANK_CONFIG_API = "HINDSIGHT_API_ENABLE_BANK_CONFIG_API" +ENV_ENABLE_BANK_LLM_HEALTH = "HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH" +ENV_ENABLE_DRY_RUN_EXTRACT = "HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT" 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" @@ -368,6 +391,7 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: 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" +ENV_METRICS_BACKLOG_ENABLED = "HINDSIGHT_API_METRICS_BACKLOG_ENABLED" # Vertex AI configuration ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID" @@ -390,6 +414,7 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: # Retain settings ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS" ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE" +ENV_RETAIN_STRUCTURED_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE" ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS" ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE" ENV_RETAIN_MISSION = "HINDSIGHT_API_RETAIN_MISSION" @@ -416,6 +441,11 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: ENV_FILE_STORAGE_AZURE_ACCOUNT_KEY = "HINDSIGHT_API_FILE_STORAGE_AZURE_ACCOUNT_KEY" ENV_FILE_PARSER = "HINDSIGHT_API_FILE_PARSER" ENV_FILE_PARSER_ALLOWLIST = "HINDSIGHT_API_FILE_PARSER_ALLOWLIST" +ENV_FILE_PARSER_MARKITDOWN_OCR_ENABLED = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED" +ENV_FILE_PARSER_MARKITDOWN_OCR_API_KEY = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY" +ENV_FILE_PARSER_MARKITDOWN_OCR_BASE_URL = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL" +ENV_FILE_PARSER_MARKITDOWN_OCR_MODEL = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL" +ENV_FILE_PARSER_MARKITDOWN_OCR_PROMPT = "HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT" ENV_FILE_PARSER_IRIS_TOKEN = "HINDSIGHT_API_FILE_PARSER_IRIS_TOKEN" ENV_FILE_PARSER_IRIS_ORG_ID = "HINDSIGHT_API_FILE_PARSER_IRIS_ORG_ID" ENV_FILE_PARSER_LLAMA_PARSE_API_KEY = "HINDSIGHT_API_FILE_PARSER_LLAMA_PARSE_API_KEY" @@ -423,6 +453,7 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: ENV_FILE_CONVERSION_MAX_BATCH_SIZE = "HINDSIGHT_API_FILE_CONVERSION_MAX_BATCH_SIZE" ENV_ENABLE_FILE_UPLOAD_API = "HINDSIGHT_API_ENABLE_FILE_UPLOAD_API" ENV_FILE_DELETE_AFTER_RETAIN = "HINDSIGHT_API_FILE_DELETE_AFTER_RETAIN" +ENV_STORE_DOCUMENT_TEXT = "HINDSIGHT_API_STORE_DOCUMENT_TEXT" # Document transfer (export/import documents between banks without re-running the LLM) ENV_ENABLE_DOCUMENT_EXPORT_API = "HINDSIGHT_API_ENABLE_DOCUMENT_EXPORT_API" @@ -437,6 +468,7 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: ENV_CONSOLIDATION_DEDUP_THRESHOLD = "HINDSIGHT_API_CONSOLIDATION_DEDUP_THRESHOLD" ENV_CONSOLIDATION_LLM_PARALLELISM = "HINDSIGHT_API_CONSOLIDATION_LLM_PARALLELISM" ENV_CONSOLIDATION_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS" +ENV_CONSOLIDATION_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_CONSOLIDATION_MAX_COMPLETION_TOKENS" ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS" ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION = ( "HINDSIGHT_API_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS_PER_OBSERVATION" @@ -445,6 +477,7 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: ENV_CONSOLIDATION_MAX_ATTEMPTS = "HINDSIGHT_API_CONSOLIDATION_MAX_ATTEMPTS" ENV_OBSERVATIONS_MISSION = "HINDSIGHT_API_OBSERVATIONS_MISSION" ENV_MAX_OBSERVATIONS_PER_SCOPE = "HINDSIGHT_API_MAX_OBSERVATIONS_PER_SCOPE" +ENV_OBSERVATION_SCOPE_LIMITS = "HINDSIGHT_API_OBSERVATION_SCOPE_LIMITS" ENV_ENABLE_OBSERVATION_HISTORY = "HINDSIGHT_API_ENABLE_OBSERVATION_HISTORY" ENV_OBSERVATION_HISTORY_MAX_ENTRIES = "HINDSIGHT_API_OBSERVATION_HISTORY_MAX_ENTRIES" ENV_ENABLE_MENTAL_MODEL_HISTORY = "HINDSIGHT_API_ENABLE_MENTAL_MODEL_HISTORY" @@ -470,6 +503,7 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: # Database migrations ENV_RUN_MIGRATIONS_ON_STARTUP = "HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP" +ENV_MIGRATION_CONCURRENCY = "HINDSIGHT_API_MIGRATION_CONCURRENCY" # Database connection pool ENV_DB_POOL_MIN_SIZE = "HINDSIGHT_API_DB_POOL_MIN_SIZE" @@ -552,6 +586,9 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: ENV_LLM_TRACE_RETENTION_DAYS = "HINDSIGHT_API_LLM_TRACE_RETENTION_DAYS" ENV_LLM_TRACE_MAX_CHARS = "HINDSIGHT_API_LLM_TRACE_MAX_CHARS" +# Background maintenance settings +ENV_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS = "HINDSIGHT_API_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS" + # Disposition settings ENV_DISPOSITION_SKEPTICISM = "HINDSIGHT_API_DISPOSITION_SKEPTICISM" ENV_DISPOSITION_LITERALISM = "HINDSIGHT_API_DISPOSITION_LITERALISM" @@ -587,6 +624,7 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: "volcano": "doubao-pro-32k", "openrouter": "qwen/qwen3.5-9b", "fireworks": "accounts/fireworks/models/llama-v3p1-8b-instruct", + "nous": "deepseek/deepseek-v4-flash", } DEFAULT_LLM_MODEL = "gpt-4o-mini" # Fallback if provider not in table # Built-in llama.cpp defaults @@ -610,6 +648,7 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: DEFAULT_LLM_MAX_BACKOFF = 60.0 # Max backoff cap in seconds for retry exponential backoff DEFAULT_LLM_TIMEOUT = 120.0 # seconds DEFAULT_LLM_REASONING_EFFORT = "low" +DEFAULT_LLM_SEND_BANK_AS_USER = False # Opt-in: tag provider calls with user= # Vertex AI defaults DEFAULT_LLM_VERTEXAI_PROJECT_ID = None # Required for Vertex AI @@ -661,6 +700,7 @@ def normalize_config_dict(config: dict[str, Any]) -> dict[str, Any]: DEFAULT_RERANKER_LITELLM_SDK_TIMEOUT = 60.0 DEFAULT_RERANKER_GOOGLE_TIMEOUT = 60.0 DEFAULT_RERANKER_MAX_CANDIDATES = 300 +DEFAULT_SEMANTIC_MIN_SIMILARITY = 0.3 # Minimum BM25 score a row must exceed to enter fusion. 0.0 gates out # zero-score (non-matching) rows on backends — notably VectorChord — whose # operator ranks every document rather than pre-filtering to term matches. @@ -729,6 +769,7 @@ def _parse_strategy_boosts(raw: str | None) -> dict[str, str]: # OpenRouter defaults DEFAULT_EMBEDDINGS_OPENROUTER_MODEL = "perplexity/pplx-embed-v1-0.6b" DEFAULT_RERANKER_OPENROUTER_MODEL = "cohere/rerank-v3.5" +DEFAULT_RERANKER_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1/rerank" # ZeroEntropy defaults DEFAULT_EMBEDDINGS_ZEROENTROPY_MODEL = "zembed-1" @@ -785,7 +826,15 @@ def _parse_strategy_boosts(raw: str | None) -> dict[str, str]: 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_MCP_INSTRUCTIONS = None DEFAULT_ENABLE_BANK_CONFIG_API = True +# Dry-run extraction is a preview tool that makes a real LLM call but stores nothing. Enabled by +# default; set HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=false to remove the endpoint (e.g. to cap +# provider cost/abuse on untrusted deployments). +DEFAULT_ENABLE_DRY_RUN_EXTRACT = True +# The per-bank LLM connectivity probe makes a real provider call, so it's OFF by +# default (cost/abuse concerns) and must be explicitly enabled to expose the endpoint. +DEFAULT_ENABLE_BANK_LLM_HEALTH = False 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 @@ -821,10 +870,15 @@ def _parse_strategy_boosts(raw: str | None) -> dict[str, str]: DEFAULT_FILE_STORAGE_TYPE = "native" # PostgreSQL BYTEA storage DEFAULT_FILE_PARSER = "markitdown" # Default parser fallback chain (comma-separated, e.g. "iris,markitdown") DEFAULT_FILE_PARSER_ALLOWLIST = None # Allowlist of parsers clients may request (None = all registered parsers) +DEFAULT_FILE_PARSER_MARKITDOWN_OCR_ENABLED = False +DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT = """You are a precise OCR transcription engine. + +Transcribe only the visible text in the image. Do not describe the image, summarize it, translate it, infer missing content, or add commentary. Preserve the original language, wording, numbers, punctuation, capitalization, and reading order. Reconstruct headings, lists, key-value fields, stamps, and tables as clean Markdown when the layout is clear. If text is unreadable or uncertain, write [unclear] for that span. Return only the extracted Markdown.""" DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE_MB = 100 # Max total batch size in MB (all files combined) DEFAULT_FILE_CONVERSION_MAX_BATCH_SIZE = 10 # Max files per batch upload DEFAULT_ENABLE_FILE_UPLOAD_API = True # Enable file upload endpoint DEFAULT_FILE_DELETE_AFTER_RETAIN = True # Delete file bytes after retain (saves storage) +DEFAULT_STORE_DOCUMENT_TEXT = True # Persist raw source text in documents.original_text / chunks.chunk_text # Document transfer defaults (export/import enabled by default; gated independently) DEFAULT_ENABLE_DOCUMENT_EXPORT_API = True @@ -861,6 +915,10 @@ def _parse_strategy_boosts(raw: str | None) -> dict[str, str]: # scopes degrade to sequential automatically; matches retain_max_concurrent. ) DEFAULT_CONSOLIDATION_MAX_TOKENS = 512 # Max tokens for recall when finding related observations +# Unset by default: the key is omitted from the LLM call so every provider keeps its current implicit output +# budget — 100% backwards compatible. Operators on providers with a low hidden default (notably Bedrock imported +# models, which cap at 4096 and truncate structured consolidation JSON) set this explicitly to fix #1939. +DEFAULT_CONSOLIDATION_MAX_COMPLETION_TOKENS = None DEFAULT_CONSOLIDATION_RECALL_BUDGET = "low" # Budget level for consolidation recall (low/mid/high) DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS = ( 4096 # Total token budget for source facts in consolidation recall (-1 = unlimited) @@ -870,9 +928,16 @@ def _parse_strategy_boosts(raw: str | None) -> dict[str, str]: ) DEFAULT_OBSERVATIONS_MISSION = None # Declarative spec of what observations are for this bank DEFAULT_MAX_OBSERVATIONS_PER_SCOPE = -1 # Max observations per tag scope (-1 = unlimited) +# Per-scope overrides of the cap above: list of {"scope": [tag-globs], "limit": int}. +# First rule whose pattern exact-covers a scope's tags wins; else the default above. +DEFAULT_OBSERVATION_SCOPE_LIMITS: list | None = None # Database migrations DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True +# Number of tenant schemas to migrate concurrently. Each schema runs in its own +# process (Alembic's command.upgrade() is not thread-safe); within a schema the +# work is always sequential. 1 = fully sequential (the safe default). +DEFAULT_MIGRATION_CONCURRENCY = 1 # Database connection pool DEFAULT_DB_POOL_MIN_SIZE = 5 @@ -927,6 +992,7 @@ def _parse_strategy_boosts(raw: str | None) -> dict[str, str]: 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 +DEFAULT_METRICS_BACKLOG_ENABLED = False # Disabled by default: runs periodic per-schema COUNT queries # Audit log defaults DEFAULT_AUDIT_LOG_ENABLED = False # Disabled by default @@ -939,6 +1005,12 @@ def _parse_strategy_boosts(raw: str | None) -> dict[str, str]: DEFAULT_LLM_TRACE_RETENTION_DAYS = 1 # Retain trace rows for 1 day by default DEFAULT_LLM_TRACE_MAX_CHARS = 50000 # Truncate stored input/output beyond this many chars +# Background maintenance defaults +# Periodic reconcile that re-schedules consolidation for banks with eligible-but-unscheduled +# facts (e.g. after a consolidation operation failed terminally and left them unscheduled). +# 0 disables the reconcile sweep. +DEFAULT_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS = 300 + # Default MCP tool descriptions (can be customized via env vars) DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory. @@ -1044,6 +1116,63 @@ def _parse_optional_positive_int(name: str, raw: str | None) -> int | None: return _parse_positive_int(name, raw, 1) +def _validate_retain_chunking_int(name: str, value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"{name} must be an integer, got {value!r}") + if value < 1: + raise ValueError(f"{name} must be >= 1, got {value}") + return value + + +def validate_retain_chunking_config( + retain_chunk_size: Any, + retain_structured_chunk_size: Any, + *, + retain_chunk_size_name: str = "retain_chunk_size", + retain_structured_chunk_size_name: str = "retain_structured_chunk_size", +) -> None: + """Validate retain chunking size fields. + + Defaults emit field-style names ("retain_chunk_size") so API/PATCH callers + don't have to override them. The startup validator (HindsightConfig.validate) + overrides to env-style names ("HINDSIGHT_API_RETAIN_CHUNK_SIZE") for env + misconfig errors. + """ + _validate_retain_chunking_int(retain_chunk_size_name, retain_chunk_size) + if retain_structured_chunk_size is None: + return + _validate_retain_chunking_int( + retain_structured_chunk_size_name, + retain_structured_chunk_size, + ) + + +def validate_retain_completion_token_budget( + *, + llm_provider: str, + retain_max_completion_tokens: int, + retain_chunk_size: int, + retain_llm_model: str | None = None, + llm_model: str | None = None, + retain_llm_provider: str | None = None, + retain_max_completion_tokens_name: str = "retain_max_completion_tokens", + retain_chunk_size_name: str = "retain_chunk_size", +) -> None: + """Validate that retain LLM output capacity exceeds the configured chunk size.""" + if llm_provider == "none" or retain_max_completion_tokens > retain_chunk_size: + return + raise ValueError( + f"Invalid configuration: {retain_max_completion_tokens_name} " + f"({retain_max_completion_tokens}) must be greater than " + f"{retain_chunk_size_name} ({retain_chunk_size}). " + f"\n\nYou have two options to fix this:" + f"\n 1. Increase {retain_max_completion_tokens_name} to a value > {retain_chunk_size}" + f"\n 2. Use a model that supports at least {retain_max_completion_tokens} output tokens" + f"\n (current model: {retain_llm_model or llm_model}, " + f"provider: {retain_llm_provider or llm_provider})" + ) + + def _parse_optional_choice(name: str, raw: str | None, allowed: frozenset[str]) -> str | None: """Parse an optional string env var constrained to a small allowlist.""" if raw is None or raw == "": @@ -1196,6 +1325,8 @@ class HindsightConfig: llm_reasoning_effort: str llm_groq_service_tier: str # Groq: "on_demand", "flex", or "auto" llm_openai_service_tier: str | None # OpenAI: None (default) or "flex" (50% cheaper) + llm_bedrock_service_tier: str | None # Bedrock: None (default), "flex", "priority", or "reserved" + llm_gemini_service_tier: str | None # Gemini: None (default) or "flex" (50% cheaper) llm_extra_body: ( dict | None ) # Extra body params merged into OpenAI-compatible API calls (e.g. {"chat_template_kwargs": {"enable_thinking": true}}) @@ -1203,6 +1334,11 @@ class HindsightConfig: dict | None ) # Custom headers passed as default_headers to provider SDK clients (e.g. {"X-Component-Id": "hindsight"} for proxies / request tracing) llm_strict_schema: bool # Grammar-enforce structured output via the provider's strongest schema mode (see DEFAULT_LLM_STRICT_SCHEMA) + # Tags outbound OpenAI-compatible LLM + embedding calls with `user=` for + # per-bank cost attribution. Downstream cost gateways (OpenRouter usage accounting, + # LiteLLM, Helicone) key attribution on the OpenAI `user` field. Opt-in; never + # overrides a `user` the caller already set. + llm_send_bank_as_user: bool # LiteLLM Router chain (provider-specific; consumed by the "litellmrouter" provider). # List of deployment dicts evaluated in order with fallback on transient errors. @@ -1324,6 +1460,7 @@ class HindsightConfig: reranker_tei_max_concurrent: int reranker_tei_http_timeout: float reranker_max_candidates: int + semantic_min_similarity: float bm25_min_score: float recall_max_candidates_per_source: int recall_strategy_boosts: dict[str, str] @@ -1333,6 +1470,7 @@ class HindsightConfig: reranker_cohere_timeout: float reranker_openrouter_api_key: str | None reranker_openrouter_model: str + reranker_openrouter_base_url: str reranker_openrouter_timeout: float reranker_litellm_api_base: str reranker_litellm_api_key: str | None @@ -1369,7 +1507,10 @@ class HindsightConfig: mcp_enabled: bool 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) + mcp_instructions: str | None # Additional instructions appended to retain/recall MCP tool descriptions enable_bank_config_api: bool + enable_bank_llm_health: bool + enable_dry_run_extract: 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 @@ -1388,6 +1529,7 @@ class HindsightConfig: # Retain settings retain_max_completion_tokens: int retain_chunk_size: int + retain_structured_chunk_size: int | None retain_extract_causal_links: bool retain_extraction_mode: str retain_mission: str | None @@ -1422,6 +1564,7 @@ class HindsightConfig: file_conversion_max_batch_size: int # Max files per request enable_file_upload_api: bool file_delete_after_retain: bool + store_document_text: bool # When False, store NULL original_text / empty chunk_text enable_document_export_api: bool enable_document_import_api: bool @@ -1438,12 +1581,17 @@ class HindsightConfig: consolidation_llm_batch_size: int consolidation_llm_parallelism: int consolidation_max_tokens: int + consolidation_max_completion_tokens: int | None consolidation_recall_budget: str consolidation_source_facts_max_tokens: int consolidation_source_facts_max_tokens_per_observation: int consolidation_max_attempts: int observations_mission: str | None max_observations_per_scope: int + # Per-scope observation caps overriding max_observations_per_scope. + # Raw JSON shape: [{"scope": ["run_*", "shared"], "limit": 1}, ...] + # (validated/applied in engine.consolidation.consolidator._effective_scope_limit) + observation_scope_limits: list | None # Entity labels (controlled vocabulary of key:value classification labels extracted at retain time) # List of label group dicts: [{key, description, type, optional, values: [{value, description}]}] @@ -1452,6 +1600,10 @@ class HindsightConfig: # When False: only label entities are extracted (or no entities at all if no labels configured) entities_allow_free_form: bool + # Memory Defense policy (dict matching DefensePolicy schema — validated on write) + # None = Memory Defense disabled / not configured for this bank + memory_defense: dict | None + # Reflect agent settings reflect_mission: str | None reflect_source_facts_max_tokens: int @@ -1486,6 +1638,7 @@ class HindsightConfig: # Database migrations run_migrations_on_startup: bool + migration_concurrency: int # Database connection pool db_pool_min_size: int @@ -1519,6 +1672,7 @@ class HindsightConfig: otel_service_name: str otel_deployment_environment: str metrics_include_bank_id: bool + metrics_backlog_enabled: bool # Audit log configuration (static - server-level only) audit_log_enabled: bool # Master switch for audit logging @@ -1531,6 +1685,11 @@ class HindsightConfig: llm_trace_retention_days: int # -1 = keep forever, >0 = delete after N days llm_trace_max_chars: int # Truncate stored input/output beyond this many chars + # Background maintenance configuration (static - server-level only) + # Interval for the periodic sweep that re-schedules consolidation for banks with + # eligible-but-unscheduled facts. 0 = disabled. + consolidation_reconcile_interval_seconds: int + # Webhook configuration (static - server-level only, not per-bank) webhook_url: str | None # Global webhook URL (None = disabled) webhook_secret: str | None # HMAC signing secret (None = unsigned) @@ -1548,6 +1707,11 @@ class HindsightConfig: embeddings_zeroentropy_encoding_format: str = DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT embeddings_zeroentropy_batch_size: int = DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE embeddings_zeroentropy_latency: str | None = DEFAULT_EMBEDDINGS_ZEROENTROPY_LATENCY + file_parser_markitdown_ocr_enabled: bool = DEFAULT_FILE_PARSER_MARKITDOWN_OCR_ENABLED + file_parser_markitdown_ocr_api_key: str | None = None + file_parser_markitdown_ocr_base_url: str | None = None + file_parser_markitdown_ocr_model: str | None = None + file_parser_markitdown_ocr_prompt: str = DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT # Class-level sets for configuration categorization @@ -1571,6 +1735,7 @@ class HindsightConfig: "embeddings_tei_base_url", "reranker_tei_base_url", "reranker_cohere_base_url", + "reranker_openrouter_base_url", "embeddings_zeroentropy_base_url", "reranker_zeroentropy_base_url", "reranker_siliconflow_base_url", @@ -1587,6 +1752,8 @@ class HindsightConfig: "file_storage_gcs_service_account_key", "file_storage_azure_account_key", # File parser credentials + "file_parser_markitdown_ocr_api_key", + "file_parser_markitdown_ocr_base_url", "file_parser_iris_token", "file_parser_llama_parse_api_key", } @@ -1599,6 +1766,7 @@ class HindsightConfig: "mcp_enabled_tools", # Retention settings (behavioral) "retain_chunk_size", + "retain_structured_chunk_size", "retain_extraction_mode", "retain_mission", "retain_custom_instructions", @@ -1618,6 +1786,7 @@ class HindsightConfig: "consolidation_source_facts_max_tokens_per_observation", "observations_mission", "max_observations_per_scope", + "observation_scope_limits", # Reflect settings "reflect_mission", "reflect_source_facts_max_tokens", @@ -1641,6 +1810,8 @@ class HindsightConfig: "disposition_empathy", # Gemini safety settings (controls content filtering for Gemini/VertexAI providers) "llm_gemini_safety_settings", + # Memory Defense policy (validated against DefensePolicy schema on write) + "memory_defense", } @property @@ -1731,6 +1902,24 @@ def validate(self) -> None: self.text_search_extension_pg_search_tokenizer ) + if not 0.0 <= self.semantic_min_similarity <= 1.0: + raise ValueError( + f"Invalid semantic_min_similarity: {self.semantic_min_similarity}. Must be between 0.0 and 1.0" + ) + + # Validate bedrock_service_tier + valid_bedrock_tiers = (None, "flex", "priority", "reserved") + if self.llm_bedrock_service_tier not in valid_bedrock_tiers: + raise ValueError( + f"Invalid HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER: " + f"{self.llm_bedrock_service_tier!r}. Must be one of: " + f"{', '.join(t for t in valid_bedrock_tiers if t is not None)}. " + f"Note: 'standard' is not a valid Bedrock service tier -- use unset for default tier." + ) + + # Validate gemini_service_tier + self.llm_gemini_service_tier = parse_gemini_service_tier(self.llm_gemini_service_tier) + # When LLM provider is "none", force chunks-only mode and disable LLM-dependent features if self.llm_provider == "none": self.retain_extraction_mode = "chunks" @@ -1740,20 +1929,23 @@ def validate(self) -> None: "disabling observations/consolidation. Reflect will return HTTP 400." ) - # RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE - # to ensure the LLM has enough output capacity to extract facts from chunks - # (not applicable when provider is "none" since no LLM calls are made) - if self.llm_provider != "none" and self.retain_max_completion_tokens <= self.retain_chunk_size: - raise ValueError( - f"Invalid configuration: HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS " - f"({self.retain_max_completion_tokens}) must be greater than " - f"HINDSIGHT_API_RETAIN_CHUNK_SIZE ({self.retain_chunk_size}). " - f"\n\nYou have two options to fix this:" - f"\n 1. Increase HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS to a value > {self.retain_chunk_size}" - f"\n 2. Use a model that supports at least {self.retain_max_completion_tokens} output tokens" - f"\n (current model: {self.retain_llm_model or self.llm_model}, " - f"provider: {self.retain_llm_provider or self.llm_provider})" - ) + validate_retain_chunking_config( + self.retain_chunk_size, + self.retain_structured_chunk_size, + retain_chunk_size_name="HINDSIGHT_API_RETAIN_CHUNK_SIZE", + retain_structured_chunk_size_name="HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE", + ) + + validate_retain_completion_token_budget( + llm_provider=self.llm_provider, + retain_max_completion_tokens=self.retain_max_completion_tokens, + retain_chunk_size=self.retain_chunk_size, + retain_llm_model=self.retain_llm_model, + llm_model=self.llm_model, + retain_llm_provider=self.retain_llm_provider, + retain_max_completion_tokens_name="HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS", + retain_chunk_size_name="HINDSIGHT_API_RETAIN_CHUNK_SIZE", + ) # Warn if local ML dependencies are missing when configured. # Don't hard-fail here — the actual ImportError fires at model init time @@ -1844,9 +2036,17 @@ def from_env(cls) -> "HindsightConfig": llm_reasoning_effort=os.getenv(ENV_LLM_REASONING_EFFORT, DEFAULT_LLM_REASONING_EFFORT), llm_groq_service_tier=os.getenv(ENV_LLM_GROQ_SERVICE_TIER, DEFAULT_LLM_GROQ_SERVICE_TIER), llm_openai_service_tier=os.getenv(ENV_LLM_OPENAI_SERVICE_TIER, DEFAULT_LLM_OPENAI_SERVICE_TIER), + llm_bedrock_service_tier=os.getenv(ENV_LLM_BEDROCK_SERVICE_TIER) or None, + llm_gemini_service_tier=( + parse_gemini_service_tier(os.getenv(ENV_LLM_GEMINI_SERVICE_TIER) or DEFAULT_LLM_GEMINI_SERVICE_TIER) + if llm_provider.lower() == "gemini" + else None + ), llm_extra_body=json.loads(os.getenv(ENV_LLM_EXTRA_BODY, "null")), llm_default_headers=json.loads(os.getenv(ENV_LLM_DEFAULT_HEADERS, "null")), llm_strict_schema=os.getenv(ENV_LLM_STRICT_SCHEMA, str(DEFAULT_LLM_STRICT_SCHEMA)).lower() in ("true", "1"), + llm_send_bank_as_user=os.getenv(ENV_LLM_SEND_BANK_AS_USER, str(DEFAULT_LLM_SEND_BANK_AS_USER)).lower() + in ("true", "1"), llm_litellmrouter_config=_parse_llm_router_config(ENV_LLM_LITELLMROUTER_CONFIG), # Vertex AI llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID, @@ -2108,6 +2308,7 @@ def from_env(cls) -> "HindsightConfig": os.getenv(ENV_RERANKER_TEI_HTTP_TIMEOUT, str(DEFAULT_RERANKER_TEI_HTTP_TIMEOUT)) ), reranker_max_candidates=int(os.getenv(ENV_RERANKER_MAX_CANDIDATES, str(DEFAULT_RERANKER_MAX_CANDIDATES))), + semantic_min_similarity=float(os.getenv(ENV_SEMANTIC_MIN_SIMILARITY, str(DEFAULT_SEMANTIC_MIN_SIMILARITY))), bm25_min_score=float(os.getenv(ENV_BM25_MIN_SCORE, str(DEFAULT_BM25_MIN_SCORE))), recall_max_candidates_per_source=int( os.getenv(ENV_RECALL_MAX_CANDIDATES_PER_SOURCE, str(DEFAULT_RECALL_MAX_CANDIDATES_PER_SOURCE)) @@ -2125,6 +2326,9 @@ def from_env(cls) -> "HindsightConfig": 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), + reranker_openrouter_base_url=os.getenv( + ENV_RERANKER_OPENROUTER_BASE_URL, DEFAULT_RERANKER_OPENROUTER_BASE_URL + ), reranker_openrouter_timeout=float( os.getenv(ENV_RERANKER_OPENROUTER_TIMEOUT, str(DEFAULT_RERANKER_OPENROUTER_TIMEOUT)) ), @@ -2187,8 +2391,13 @@ def from_env(cls) -> "HindsightConfig": if os.getenv(ENV_MCP_ENABLED_TOOLS) else DEFAULT_MCP_ENABLED_TOOLS, mcp_stateless=os.getenv(ENV_MCP_STATELESS, str(DEFAULT_MCP_STATELESS)).lower() == "true", + mcp_instructions=os.getenv(ENV_MCP_INSTRUCTIONS) or DEFAULT_MCP_INSTRUCTIONS, + enable_bank_llm_health=os.getenv(ENV_ENABLE_BANK_LLM_HEALTH, str(DEFAULT_ENABLE_BANK_LLM_HEALTH)).lower() + == "true", enable_bank_config_api=os.getenv(ENV_ENABLE_BANK_CONFIG_API, str(DEFAULT_ENABLE_BANK_CONFIG_API)).lower() == "true", + enable_dry_run_extract=os.getenv(ENV_ENABLE_DRY_RUN_EXTRACT, str(DEFAULT_ENABLE_DRY_RUN_EXTRACT)).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), @@ -2218,6 +2427,10 @@ def from_env(cls) -> "HindsightConfig": os.getenv(ENV_RETAIN_MAX_COMPLETION_TOKENS, str(DEFAULT_RETAIN_MAX_COMPLETION_TOKENS)) ), retain_chunk_size=int(os.getenv(ENV_RETAIN_CHUNK_SIZE, str(DEFAULT_RETAIN_CHUNK_SIZE))), + retain_structured_chunk_size=_parse_optional_positive_int( + ENV_RETAIN_STRUCTURED_CHUNK_SIZE, + os.getenv(ENV_RETAIN_STRUCTURED_CHUNK_SIZE), + ), retain_extract_causal_links=os.getenv( ENV_RETAIN_EXTRACT_CAUSAL_LINKS, str(DEFAULT_RETAIN_EXTRACT_CAUSAL_LINKS) ).lower() @@ -2258,6 +2471,18 @@ def from_env(cls) -> "HindsightConfig": file_parser_allowlist=_parse_str_list(os.getenv(ENV_FILE_PARSER_ALLOWLIST)) if os.getenv(ENV_FILE_PARSER_ALLOWLIST) else None, + file_parser_markitdown_ocr_enabled=os.getenv( + ENV_FILE_PARSER_MARKITDOWN_OCR_ENABLED, + str(DEFAULT_FILE_PARSER_MARKITDOWN_OCR_ENABLED), + ).lower() + in ("1", "true", "yes", "on"), + file_parser_markitdown_ocr_api_key=os.getenv(ENV_FILE_PARSER_MARKITDOWN_OCR_API_KEY) or None, + file_parser_markitdown_ocr_base_url=os.getenv(ENV_FILE_PARSER_MARKITDOWN_OCR_BASE_URL) or None, + file_parser_markitdown_ocr_model=os.getenv(ENV_FILE_PARSER_MARKITDOWN_OCR_MODEL) or None, + file_parser_markitdown_ocr_prompt=os.getenv( + ENV_FILE_PARSER_MARKITDOWN_OCR_PROMPT, + DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT, + ), file_parser_iris_token=os.getenv(ENV_FILE_PARSER_IRIS_TOKEN) or None, file_parser_iris_org_id=os.getenv(ENV_FILE_PARSER_IRIS_ORG_ID) or None, file_parser_llama_parse_api_key=os.getenv(ENV_FILE_PARSER_LLAMA_PARSE_API_KEY) or None, @@ -2273,6 +2498,7 @@ def from_env(cls) -> "HindsightConfig": ENV_FILE_DELETE_AFTER_RETAIN, str(DEFAULT_FILE_DELETE_AFTER_RETAIN) ).lower() == "true", + store_document_text=os.getenv(ENV_STORE_DOCUMENT_TEXT, str(DEFAULT_STORE_DOCUMENT_TEXT)).lower() == "true", enable_document_export_api=os.getenv( ENV_ENABLE_DOCUMENT_EXPORT_API, str(DEFAULT_ENABLE_DOCUMENT_EXPORT_API) ).lower() @@ -2334,6 +2560,11 @@ def from_env(cls) -> "HindsightConfig": consolidation_max_tokens=int( os.getenv(ENV_CONSOLIDATION_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_MAX_TOKENS)) ), + consolidation_max_completion_tokens=( + int(os.getenv(ENV_CONSOLIDATION_MAX_COMPLETION_TOKENS)) + if os.getenv(ENV_CONSOLIDATION_MAX_COMPLETION_TOKENS) + else DEFAULT_CONSOLIDATION_MAX_COMPLETION_TOKENS + ), consolidation_recall_budget=os.getenv(ENV_CONSOLIDATION_RECALL_BUDGET, DEFAULT_CONSOLIDATION_RECALL_BUDGET), consolidation_source_facts_max_tokens=int( os.getenv(ENV_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_SOURCE_FACTS_MAX_TOKENS)) @@ -2351,10 +2582,14 @@ def from_env(cls) -> "HindsightConfig": max_observations_per_scope=int( os.getenv(ENV_MAX_OBSERVATIONS_PER_SCOPE, str(DEFAULT_MAX_OBSERVATIONS_PER_SCOPE)) ), + observation_scope_limits=json.loads(os.getenv(ENV_OBSERVATION_SCOPE_LIMITS, "null")) + or DEFAULT_OBSERVATION_SCOPE_LIMITS, entity_labels=None, entities_allow_free_form=True, + memory_defense=None, # Database migrations run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true", + migration_concurrency=int(os.getenv(ENV_MIGRATION_CONCURRENCY, str(DEFAULT_MIGRATION_CONCURRENCY))), # Database connection pool db_pool_min_size=int(os.getenv(ENV_DB_POOL_MIN_SIZE, str(DEFAULT_DB_POOL_MIN_SIZE))), db_pool_max_size=int(os.getenv(ENV_DB_POOL_MAX_SIZE, str(DEFAULT_DB_POOL_MAX_SIZE))), @@ -2438,6 +2673,8 @@ def from_env(cls) -> "HindsightConfig": 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"), + metrics_backlog_enabled=os.getenv(ENV_METRICS_BACKLOG_ENABLED, str(DEFAULT_METRICS_BACKLOG_ENABLED)).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=[ @@ -2455,6 +2692,13 @@ def from_env(cls) -> "HindsightConfig": os.getenv(ENV_LLM_TRACE_RETENTION_DAYS, str(DEFAULT_LLM_TRACE_RETENTION_DAYS)) ), llm_trace_max_chars=int(os.getenv(ENV_LLM_TRACE_MAX_CHARS, str(DEFAULT_LLM_TRACE_MAX_CHARS))), + # Background maintenance configuration (static, server-level only) + consolidation_reconcile_interval_seconds=int( + os.getenv( + ENV_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS, + str(DEFAULT_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS), + ) + ), # Webhook configuration (static, server-level only) webhook_url=os.getenv(ENV_WEBHOOK_URL) or DEFAULT_WEBHOOK_URL, webhook_secret=os.getenv(ENV_WEBHOOK_SECRET) or DEFAULT_WEBHOOK_SECRET, diff --git a/hindsight-api-slim/hindsight_api/config_resolver.py b/hindsight-api-slim/hindsight_api/config_resolver.py index ef41e1c892..e744bd6e71 100644 --- a/hindsight-api-slim/hindsight_api/config_resolver.py +++ b/hindsight-api-slim/hindsight_api/config_resolver.py @@ -8,6 +8,7 @@ multiple API servers. """ +import asyncio import json import logging from dataclasses import asdict, replace @@ -18,6 +19,8 @@ HindsightConfig, _get_raw_config, normalize_config_dict, + validate_retain_chunking_config, + validate_retain_completion_token_budget, ) from hindsight_api.engine.memory_engine import fq_table from hindsight_api.extensions.tenant import TenantExtension @@ -29,6 +32,35 @@ logger = logging.getLogger(__name__) +def _validate_retain_strategy_chunking(base_config: HindsightConfig, strategies: Any) -> None: + """Validate retain strategy chunking with the same semantics as apply_strategy().""" + if not isinstance(strategies, dict): + return + configurable = HindsightConfig.get_configurable_fields() + for strategy_name, overrides in strategies.items(): + if not isinstance(overrides, dict): + raise ValueError(f"Invalid retain strategy {strategy_name!r}: must be an object") + filtered = {k: v for k, v in overrides.items() if k in configurable} + if not filtered: + continue + try: + resolved = replace(base_config, **filtered) + validate_retain_chunking_config( + resolved.retain_chunk_size, + resolved.retain_structured_chunk_size, + ) + validate_retain_completion_token_budget( + llm_provider=resolved.llm_provider, + retain_max_completion_tokens=resolved.retain_max_completion_tokens, + retain_chunk_size=resolved.retain_chunk_size, + retain_llm_model=resolved.retain_llm_model, + llm_model=resolved.llm_model, + retain_llm_provider=resolved.retain_llm_provider, + ) + except ValueError as e: + raise ValueError(f"Invalid retain strategy {strategy_name!r}: {e}") from e + + class ConfigResolver: """Resolves hierarchical configuration with tenant/bank overrides.""" @@ -46,6 +78,26 @@ def __init__(self, backend: "DatabaseBackend", tenant_extension: TenantExtension self._configurable_fields = HindsightConfig.get_configurable_fields() self._credential_fields = HindsightConfig.get_credential_fields() + async def _resolve_parent_config_dict(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]: + """Resolve global + tenant config before bank-level overrides.""" + config_dict = asdict(self._global_config) + + if self.tenant_extension and context: + try: + tenant_overrides = await self.tenant_extension.get_tenant_config(context) + if tenant_overrides: + # Normalize keys and filter to configurable fields only + normalized_tenant = normalize_config_dict(tenant_overrides) + configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields} + config_dict.update(configurable_tenant) + logger.debug( + f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}" + ) + except Exception as e: + logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}") + + return config_dict + async def resolve_full_config(self, bank_id: str, context: RequestContext | None = None) -> HindsightConfig: """ Resolve full HindsightConfig for a bank with hierarchical overrides applied. @@ -65,23 +117,7 @@ async def resolve_full_config(self, bank_id: str, context: RequestContext | None Returns: Complete HindsightConfig with hierarchical overrides applied """ - # Start with global config (all fields) - config_dict = asdict(self._global_config) - - # Load tenant config overrides (if tenant extension available) - if self.tenant_extension and context: - try: - tenant_overrides = await self.tenant_extension.get_tenant_config(context) - if tenant_overrides: - # Normalize keys and filter to configurable fields only - normalized_tenant = normalize_config_dict(tenant_overrides) - configurable_tenant = {k: v for k, v in normalized_tenant.items() if k in self._configurable_fields} - config_dict.update(configurable_tenant) - logger.debug( - f"Applied tenant config overrides for bank {bank_id}: {list(configurable_tenant.keys())}" - ) - except Exception as e: - logger.warning(f"Failed to load tenant config for bank {bank_id}: {e}") + config_dict = await self._resolve_parent_config_dict(bank_id, context) # Load bank config overrides bank_overrides = await self._load_bank_config(bank_id) @@ -92,6 +128,10 @@ async def resolve_full_config(self, bank_id: str, context: RequestContext | None # Return full config object (dataclass doesn't have __init__ that accepts kwargs, so we update the object) # Create a new config instance by copying the global config and updating fields resolved_config = HindsightConfig(**config_dict) + validate_retain_chunking_config( + resolved_config.retain_chunk_size, + resolved_config.retain_structured_chunk_size, + ) return resolved_config async def get_bank_config(self, bank_id: str, context: RequestContext | None = None) -> dict[str, Any]: @@ -122,26 +162,83 @@ async def get_bank_config(self, bank_id: str, context: RequestContext | None = N resolved_config = await self.resolve_full_config(bank_id, context) config_dict = asdict(resolved_config) - # SECURITY: Filter to only configurable fields (exclude static/infrastructure) - filtered = {k: v for k, v in config_dict.items() if k in self._configurable_fields} + # SECURITY: drop static/infrastructure + credential fields, then permission-filter. + filtered = self._strip_static_and_credential_fields(config_dict) + return await self._apply_permission_filter(filtered, bank_id, context) - # SECURITY: Remove ALL credential fields (API keys, base URLs, etc.) - filtered = {k: v for k, v in filtered.items() if k not in self._credential_fields} + def _strip_static_and_credential_fields(self, config_dict: dict[str, Any]) -> dict[str, Any]: + """Keep only configurable, non-credential fields. + + SECURITY: excludes static/infrastructure fields and ALL credential fields + (API keys, base URLs, etc.) so a resolved config is safe to return over the API. + """ + return { + k: v for k, v in config_dict.items() if k in self._configurable_fields and k not in self._credential_fields + } - # PERMISSIONS: Further filter based on tenant/bank permissions + async def _apply_permission_filter( + self, filtered: dict[str, Any], bank_id: str, context: RequestContext | None + ) -> dict[str, Any]: + """Further restrict already-stripped config to the tenant/bank permission allow-list. + + On extension error, leaves ``filtered`` unchanged (parity with the historical + single-bank path: a permissions lookup failure must not leak or drop fields). + """ + if not (self.tenant_extension and context): + return filtered + try: + allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id) + if allowed_fields is not None: # None means "allow all" + filtered = {k: v for k, v in filtered.items() if k in allowed_fields} + logger.debug( + f"Applied permission filter for bank {bank_id}: allowed={len(allowed_fields)} fields, " + f"returned={len(filtered)} fields" + ) + except Exception as e: + logger.warning(f"Failed to load permissions for bank {bank_id}: {e}") + return filtered + + async def get_bank_configs( + self, bank_ids: list[str], context: RequestContext | None = None + ) -> dict[str, dict[str, Any]]: + """Batch variant of :meth:`get_bank_config` for many banks. + + Equivalent to calling ``get_bank_config`` per bank, but resolves the + global + tenant base once and loads every bank's ``banks.config`` JSONB + in a single query, instead of one config round-trip per bank. Used by + ``list_banks`` to overlay disposition + mission without an N+1. + + Returns a mapping of bank_id -> filtered configurable-field dict. A bank + with no config row still appears, mapped to the global+tenant base. + """ + if not bank_ids: + return {} + + # Global + tenant base, resolved once (tenant override is per-request, not per-bank). + base_dict = asdict(self._global_config) if self.tenant_extension and context: try: - allowed_fields = await self.tenant_extension.get_allowed_config_fields(context, bank_id) - if allowed_fields is not None: # None means "allow all" - filtered = {k: v for k, v in filtered.items() if k in allowed_fields} - logger.debug( - f"Applied permission filter for bank {bank_id}: allowed={len(allowed_fields)} fields, " - f"returned={len(filtered)} fields" - ) + tenant_overrides = await self.tenant_extension.get_tenant_config(context) + if tenant_overrides: + normalized_tenant = normalize_config_dict(tenant_overrides) + base_dict.update({k: v for k, v in normalized_tenant.items() if k in self._configurable_fields}) except Exception as e: - logger.warning(f"Failed to load permissions for bank {bank_id}: {e}") - - return filtered + logger.warning(f"Failed to load tenant config for bulk resolve: {e}") + + # All bank overrides in one query, then merge + strip per bank. + bank_overrides = await self._load_bank_configs(bank_ids) + stripped = { + bank_id: self._strip_static_and_credential_fields({**base_dict, **bank_overrides.get(bank_id, {})}) + for bank_id in bank_ids + } + + # Permission filter is per-bank; resolve concurrently when an extension is present. + if not (self.tenant_extension and context): + return stripped + permission_filtered = await asyncio.gather( + *(self._apply_permission_filter(stripped[bank_id], bank_id, context) for bank_id in bank_ids) + ) + return dict(zip(bank_ids, permission_filtered, strict=True)) async def _load_bank_config(self, bank_id: str) -> dict[str, Any]: """ @@ -180,6 +277,45 @@ async def _load_bank_config(self, bank_id: str) -> dict[str, Any]: return {} + async def _load_bank_configs(self, bank_ids: list[str]) -> dict[str, dict[str, Any]]: + """Bulk variant of :meth:`_load_bank_config`: load many banks' overrides in one query. + + Returns a mapping of bank_id -> normalized active overrides. Banks with no row + (or an empty/all-tombstone config) are simply absent from the mapping. + """ + result: dict[str, dict[str, Any]] = {} + if not bank_ids: + return result + try: + async with self._backend.acquire() as conn: + rows = await conn.fetch( + f""" + SELECT bank_id, config FROM {fq_table("banks")} WHERE bank_id = ANY($1) + """, + bank_ids, + ) + for row in rows: + config_data = row["config"] + if not config_data: + continue + # Handle case where JSONB is returned as JSON string + if isinstance(config_data, str): + config_data = json.loads(config_data) + + # Normalize keys (handle both env var format and Python field format) + normalized = normalize_config_dict(config_data) + + # Only active overrides for configurable fields. JSON null is a tombstone + # for "Server Default" in the bank-config UI and must not override defaults. + overrides = { + k: v for k, v in normalized.items() if k in self._configurable_fields and v is not None + } + if overrides: + result[row["bank_id"]] = overrides + except Exception as e: + logger.error(f"Failed to bulk-load bank configs: {e}") + return result + async def update_bank_config( self, bank_id: str, updates: dict[str, Any], context: RequestContext | None = None ) -> None: @@ -266,6 +402,29 @@ async def update_bank_config( # Validate recall budget fields _validate_recall_budget_updates(normalized_updates) + chunking_fields_updated = ( + "retain_chunk_size" in normalized_updates + or "retain_structured_chunk_size" in normalized_updates + or "retain_strategies" in normalized_updates + ) + if chunking_fields_updated: + config_dict = await self._resolve_parent_config_dict(bank_id, context) + active_bank_overrides = await self._load_bank_config(bank_id) + for key, value in normalized_updates.items(): + if key not in self._configurable_fields: + continue + if value is None: + active_bank_overrides.pop(key, None) + else: + active_bank_overrides[key] = value + config_dict.update(active_bank_overrides) + base_config = HindsightConfig(**config_dict) + validate_retain_chunking_config( + base_config.retain_chunk_size, + base_config.retain_structured_chunk_size, + ) + _validate_retain_strategy_chunking(base_config, base_config.retain_strategies) + # Persist the override. Banks are created lazily (on first retain), so a # PATCH that precedes any ingestion would otherwise UPDATE zero rows and # silently no-op while returning 200. Ensure the bank row exists first @@ -364,7 +523,8 @@ def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConf A strategy is a named set of hierarchical field overrides stored in config.retain_strategies. Any field in _HIERARCHICAL_FIELDS can be overridden, including retain_extraction_mode, retain_chunk_size, - entity_labels, entities_allow_free_form, etc. + retain_structured_chunk_size, entity_labels, + entities_allow_free_form, etc. Unknown strategy names log a warning and return config unchanged. Unknown or non-hierarchical fields in the strategy are silently ignored. @@ -386,4 +546,17 @@ def apply_strategy(config: HindsightConfig, strategy_name: str) -> HindsightConf return config logger.debug(f"Applying retain strategy '{strategy_name}': {list(filtered.keys())}") - return replace(config, **filtered) + resolved = replace(config, **filtered) + validate_retain_chunking_config( + resolved.retain_chunk_size, + resolved.retain_structured_chunk_size, + ) + validate_retain_completion_token_budget( + llm_provider=resolved.llm_provider, + retain_max_completion_tokens=resolved.retain_max_completion_tokens, + retain_chunk_size=resolved.retain_chunk_size, + retain_llm_model=resolved.retain_llm_model, + llm_model=resolved.llm_model, + retain_llm_provider=resolved.retain_llm_provider, + ) + return resolved diff --git a/hindsight-api-slim/hindsight_api/engine/audit.py b/hindsight-api-slim/hindsight_api/engine/audit.py index 0a3857a1ba..08f718dabb 100644 --- a/hindsight-api-slim/hindsight_api/engine/audit.py +++ b/hindsight-api-slim/hindsight_api/engine/audit.py @@ -107,11 +107,11 @@ def _safe_json(data: Any) -> str | None: return None -_SWEEP_INTERVAL_SECONDS = 3600 # Run retention sweep every hour - - class AuditLogger: - """Fire-and-forget audit log writer with optional retention sweep.""" + """Fire-and-forget audit log writer. + + Retention of old rows is handled by the background :class:`MaintenanceLoop`. + """ def __init__( self, @@ -119,14 +119,11 @@ def __init__( schema_getter: Callable[[], str], enabled: bool, allowed_actions: list[str], - retention_days: int = -1, ) -> None: self._pool_getter = pool_getter self._schema_getter = schema_getter self._enabled = enabled self._allowed_actions: frozenset[str] | None = frozenset(allowed_actions) if allowed_actions else None - self._retention_days = retention_days - self._sweep_task: asyncio.Task | None = None def is_enabled(self, action: str) -> bool: """Check if audit logging is enabled for this action.""" @@ -176,48 +173,6 @@ async def _safe_log(self, entry: AuditEntry) -> None: except Exception as e: logger.warning(f"Audit log write failed for action={entry.action}: {e}") - def start_retention_sweep(self) -> None: - """Start the periodic retention sweep if retention is configured.""" - if self._retention_days <= 0 or not self._enabled: - return - try: - self._sweep_task = asyncio.create_task(self._sweep_loop()) - except RuntimeError: - logger.debug("Cannot start retention sweep: no running event loop") - - async def stop_retention_sweep(self) -> None: - """Stop the periodic retention sweep.""" - if self._sweep_task and not self._sweep_task.done(): - self._sweep_task.cancel() - try: - await self._sweep_task - except asyncio.CancelledError: - pass - self._sweep_task = None - - async def _sweep_loop(self) -> None: - """Periodically delete audit log entries older than retention_days.""" - while True: - await self._run_sweep() - await asyncio.sleep(_SWEEP_INTERVAL_SECONDS) - - async def _run_sweep(self) -> None: - """Delete expired audit log entries. Concurrent-safe via row-level deletes.""" - pool = self._pool_getter() - if pool is None: - return - try: - schema = self._schema_getter() - table = f"{schema}.audit_log" - async with acquire_with_retry(pool, max_retries=1) as conn: - result = await conn.execute( - f"DELETE FROM {table} WHERE started_at < NOW() - INTERVAL '{self._retention_days} days'" - ) - if result and result != "DELETE 0": - logger.info(f"Audit log retention sweep: {result}") - except Exception as e: - logger.warning(f"Audit log retention sweep failed: {e}") - @asynccontextmanager async def audit_context( diff --git a/hindsight-api-slim/hindsight_api/engine/bank_attribution.py b/hindsight-api-slim/hindsight_api/engine/bank_attribution.py new file mode 100644 index 0000000000..d97cfd4c4d --- /dev/null +++ b/hindsight-api-slim/hindsight_api/engine/bank_attribution.py @@ -0,0 +1,34 @@ +"""Per-bank provider cost attribution via the OpenAI ``user`` field. + +Shared by the OpenAI-compatible LLM path and the OpenAI embeddings path so both +tag outbound requests identically. Opt-in via ``HINDSIGHT_API_LLM_SEND_BANK_AS_USER``; +downstream cost gateways (OpenRouter usage accounting, LiteLLM, Helicone) key spend +on the OpenAI ``user`` field. + +Note: when enabled, the bank id is transmitted to the upstream provider as the +end-user identifier. Banks that are themselves end-user identifiers are therefore +forwarded to the provider — which is exactly what the OpenAI ``user`` field is for, +but operators should opt in with that in mind. +""" + +from typing import Any + + +def apply_bank_attribution(request: dict[str, Any]) -> None: + """Tag ``request`` with ``user=`` for per-bank cost attribution. + + Mutates ``request`` in place. No-op when the flag is off, no bank is in context, + or the caller already set ``user`` — we never override an explicit value. + """ + if "user" in request: + return + # Lazy imports: memory_engine imports the embeddings/provider modules that call + # this, so a top-level import of memory_engine here would be circular. + from ..config import get_config + from .memory_engine import get_current_bank_id + + if not get_config().llm_send_bank_as_user: + return + bank_id = get_current_bank_id() + if bank_id: + request["user"] = bank_id diff --git a/hindsight-api-slim/hindsight_api/engine/chinese_temporal_periods.py b/hindsight-api-slim/hindsight_api/engine/chinese_temporal_periods.py new file mode 100644 index 0000000000..40048279c6 --- /dev/null +++ b/hindsight-api-slim/hindsight_api/engine/chinese_temporal_periods.py @@ -0,0 +1,1799 @@ +"""Chinese period extraction helpers for DateparserQueryAnalyzer. + +Chinese period expressions need a dedicated rule set because dateparser +frequently returns None, single-day windows for whole periods, or substring +false positives for Chinese queries. +""" + +import calendar +import re +import unicodedata +from datetime import datetime, timedelta + +from hindsight_api.engine.temporal_periods import NO_TEMPORAL_CONSTRAINT, DateRange, NoTemporalConstraintSentinel + +# Normalize only Chinese temporal vocabulary before regex matching; keep the original query for dateparser fallback. +_CHINESE_TEMPORAL_TRANSLATION = str.maketrans( + "週禮個兩幾後這過來現倆內間號當數開時鐘鍾頭", + "周礼个两几后这过来现俩内间号当数开时钟钟头", +) +_CHINESE_NUMERAL_PREFIX_CHARS = "一二三四五六七八九十百千万零〇○两俩半前年0-9" +_CHINESE_NUMERAL_CHARS = "零〇○一二两俩三四五六七八九十百千万" +_CHINESE_OPTIONAL_PERIOD_MARKER = r"(?:一个|个|一)?" +_CHINESE_TEMPORAL_FOLLOWER_CHARS = frozenset( + " \t\r\n" + ".,!?;:()[]{}<>\"'" + ",。!?;:()【】《》“”‘’、" + "的得地了过着吗呢吧呀啊嘛么和及与或至到起内里中时后前份" + "有要去做干说聊谈讨论查找看问见给提记想开买吃喝玩用学写发订安排" + "帮测试部署收入申请下雨代码改动工资回总复统整比哪怎什谁几多少会活事项费录信消新天计划我你他她它咱" + "才再能还還已又曾" + "討論計劃劃會議費記錄訊開說談寫發買訂問見給學錄" +) +_CHINESE_TEMPORAL_FOLLOWER_PREFIXES = ( + "是否", + "是不是", + "已经", + "已經", + "曾经", + "曾經", + "的时候", + "期间", + "以内", + "以来", + "之前", + "左右", + "上午", + "下午", + "早上", + "晚上", + "中午", + "凌晨", + "转账", + "付款", + "经费", + "报销", + "拜访", + "支出", + "阅读", + "清晨", + "傍晚", + "黄昏", + "夜里", + "半夜", + "午夜", + "读", + "紀錄", + "資料", + "報告", + "報表", + "日誌", + "日記", + "總結", +) + + +def _is_cjk_character(char: str) -> bool: + return "\u4e00" <= char <= "\u9fff" + + +def is_embedded_cjk_dateparser_match(query: str, matched_text: str) -> bool: + """Return true when dateparser matched a Chinese date token inside a larger word.""" + if not any(_is_cjk_character(char) for char in matched_text): + return False + + matches = list(re.finditer(re.escape(matched_text), query)) + if not matches: + return False + + def is_embedded(match: re.Match[str]) -> bool: + has_cjk_prefix = match.start() > 0 and _is_cjk_character(query[match.start() - 1]) + has_cjk_suffix = match.end() < len(query) and _is_cjk_character(query[match.end()]) + return has_cjk_prefix or has_cjk_suffix + + return all(is_embedded(match) for match in matches) + + +def extract_chinese_period(query: str, reference_date: datetime) -> DateRange | NoTemporalConstraintSentinel | None: + """ + Extract Chinese period-based temporal expressions. + + These need special handling as they represent date ranges, not single dates. + """ + query = unicodedata.normalize("NFKC", query).translate(_CHINESE_TEMPORAL_TRANSLATION) + has_cjk_text = any(_is_cjk_character(char) for char in query) + + def constraint(start: datetime, end: datetime) -> DateRange: + return ( + start.replace(hour=0, minute=0, second=0, microsecond=0), + end.replace(hour=23, minute=59, second=59, microsecond=999999), + ) + + def subtract_months(months: int) -> datetime: + month_index = reference_date.month - months - 1 + year = reference_date.year + month_index // 12 + month = month_index % 12 + 1 + day = min(reference_date.day, calendar.monthrange(year, month)[1]) + return reference_date.replace(year=year, month=month, day=day) + + def month_end(year: int, month: int) -> datetime: + return datetime(year, month, calendar.monthrange(year, month)[1]) + + def add_months(base_date: datetime, months: int) -> datetime: + month_index = base_date.month + months - 1 + year = base_date.year + month_index // 12 + month = month_index % 12 + 1 + day = min(base_date.day, calendar.monthrange(year, month)[1]) + return base_date.replace(year=year, month=month, day=day) + + def add_years(base_date: datetime, years: int) -> datetime: + year = base_date.year + years + day = min(base_date.day, calendar.monthrange(year, base_date.month)[1]) + return base_date.replace(year=year, day=day) + + def has_chinese_temporal_context(match: re.Match[str]) -> bool: + if match.end() >= len(query): + return True + suffix = query[match.end() :] + if any(suffix.startswith(prefix) for prefix in _CHINESE_TEMPORAL_FOLLOWER_PREFIXES): + return True + return suffix[0] in _CHINESE_TEMPORAL_FOLLOWER_CHARS + + def chinese_search(pattern: str) -> re.Match[str] | None: + if not has_cjk_text: + return None + for match in re.finditer(pattern, query): + if has_chinese_temporal_context(match): + return match + return None + + def chinese_digit_value(char: str) -> int | None: + if char in "零〇○": + return 0 + if char in ("两", "俩"): + return 2 + index = "一二三四五六七八九".find(char) + return index + 1 if index >= 0 else None + + def parse_chinese_number(text: str) -> int | None: + if text.isdigit(): + return int(text) + if text == "廿": + return 20 + if text.startswith("廿") and len(text) == 2: + digit = chinese_digit_value(text[1]) + return 20 + digit if digit is not None else None + if text == "卅": + return 30 + if text.startswith("卅") and len(text) == 2: + digit = chinese_digit_value(text[1]) + return 30 + digit if digit is not None else None + if not any(unit in text for unit in "十百千万"): + if len(text) != 1: + return None + return chinese_digit_value(text) + + total = 0 + section = 0 + number = 0 + for char in text: + digit = chinese_digit_value(char) + if digit is not None: + number = digit + continue + if char == "万": + section = (section + number) * 10000 + total += section + section = 0 + number = 0 + continue + unit = {"十": 10, "百": 100, "千": 1000}.get(char) + if unit is None: + return None + section += (number or 1) * unit + number = 0 + return total + section + number + + def parse_chinese_digit_year(year_text: str) -> int | None: + digits = "" + for char in year_text: + if char in "零〇○oO": + digits += "0" + continue + index = "一二三四五六七八九".find(char) + if index < 0: + return None + digits += str(index + 1) + return int(digits) if len(digits) == 4 else None + + def parse_chinese_month(month_text: str) -> int | None: + if month_text.isdigit(): + month = int(month_text) + return month if 1 <= month <= 12 else None + if month_text == "十": + return 10 + if month_text == "十一": + return 11 + if month_text == "十二": + return 12 + index = "一二三四五六七八九".find(month_text) + return index + 1 if index >= 0 else None + + def parse_chinese_day(day_text: str) -> int | None: + day = parse_chinese_number(day_text) + return day if day is not None and 1 <= day <= 31 else None + + def parse_chinese_quarter(quarter_text: str) -> int | None: + quarter_text = quarter_text.removeprefix("第") + if quarter_text.isdigit(): + quarter = int(quarter_text) + return quarter if 1 <= quarter <= 4 else None + index = "一二三四".find(quarter_text) + return index + 1 if index >= 0 else None + + def parse_chinese_weekday(weekday_text: str) -> int | None: + if weekday_text in ("日", "天", "7"): + return 6 + if weekday_text in "123456": + return int(weekday_text) - 1 + index = "一二三四五六".find(weekday_text) + return index if index >= 0 else None + + def quarter_period(year: int, quarter: int) -> DateRange: + start_month = (quarter - 1) * 3 + 1 + end_month = start_month + 2 + return constraint(datetime(year, start_month, 1), month_end(year, end_month)) + + def shifted_quarter_period(offset: int) -> DateRange: + current_quarter = (reference_date.month - 1) // 3 + 1 + quarter_index = current_quarter + offset - 1 + year = reference_date.year + quarter_index // 4 + quarter = quarter_index % 4 + 1 + return quarter_period(year, quarter) + + def chinese_month_period(match: re.Match[str]) -> DateRange | None: + year_text = match.group(1) + year = int(year_text) if year_text.isdigit() else parse_chinese_digit_year(year_text) + month_num = parse_chinese_month(match.group(2)) + if year is None or month_num is None: + return None + return constraint(datetime(year, month_num, 1), month_end(year, month_num)) + + def relative_year_month_period(year: int, month_text: str) -> DateRange | None: + month_num = parse_chinese_month(month_text) + if month_num is None: + return None + return constraint(datetime(year, month_num, 1), month_end(year, month_num)) + + def month_phase_period(year: int, month: int, phase: str) -> DateRange: + phase = phase.removeprefix("月") + if phase in ("初", "上旬"): + return constraint(datetime(year, month, 1), datetime(year, month, 10)) + if phase in ("中", "中旬"): + return constraint(datetime(year, month, 11), datetime(year, month, 20)) + return constraint(datetime(year, month, 21), month_end(year, month)) + + def year_edge_period(year: int, phase: str) -> DateRange: + phase = phase.removeprefix("年") + if phase == "初": + return constraint(datetime(year, 1, 1), datetime(year, 1, 31)) + return constraint(datetime(year, 12, 1), datetime(year, 12, 31)) + + relative_year_pattern = ( + r"下一个年度|下一年度|下年度|下一年|大后年|明年|后年|今年|本年|" + r"上一年度|上一个年度|上年度|上一年|去年|上年|前一年|大前年|前年" + ) + chinese_year_pattern = r"\d{4}|[零〇○o一二三四五六七八九]{4}" + chinese_month_pattern = r"十二|十一|十|[一二三四五六七八九]|1[0-2]|0?[1-9]" + chinese_day_pattern = r"[0-2]?[0-9]|3[01]|卅一|卅|廿[一二三四五六七八九]?|三十一|三十|二十[一二三四五六七八九]?|十[一二三四五六七八九]?|[一二两俩三四五六七八九]" + chinese_month_phase_pattern = r"上旬|中旬|下旬|月初|月中(?![了过])|月末|月底|月尾|初|中(?![了过])|末|底|尾" + chinese_boundary_suffix_pattern = r"\s*(?:以?前|之前|以?后|之后)" + chinese_month_boundary_suffix_pattern = r"份?\s*(?:以?前|之前|以?后|之后)" + chinese_since_suffix_pattern = r"(?:以来|至今|到现在|到目前|迄今|截至目前|截止目前|截至现在|截止现在|起|开始)" + chinese_relative_past_suffix_pattern = r"(?:以?前|之前)" + chinese_relative_future_suffix_pattern = r"(?:以?后|之后)" + chinese_range_separator = r"\s*(?:到|至|[-~~—])\s*" + + def relative_year_number(year_text: str) -> int: + if year_text in ("今年", "本年"): + return reference_date.year + if year_text in ("明年", "下年度", "下一年度", "下一个年度", "下一年"): + return reference_date.year + 1 + if year_text == "后年": + return reference_date.year + 2 + if year_text == "大后年": + return reference_date.year + 3 + if year_text in ("去年", "上年", "上一年度", "上一个年度", "上年度", "上一年", "前一年"): + return reference_date.year - 1 + if year_text == "大前年": + return reference_date.year - 3 + return reference_date.year - 2 + + def fixed_day_offset(day_text: str) -> int: + return { + "大大后天": 4, + "大后天": 3, + "后天": 2, + "明天": 1, + "明日": 1, + "今天": 0, + "今日": 0, + "本日": 0, + "当日": 0, + "当天": 0, + "昨天": -1, + "昨日": -1, + "前天": -2, + "大前天": -3, + "大大前天": -4, + }[day_text] + + def daypart_day_offset(daypart_text: str) -> int: + if daypart_text in ("昨晚", "昨夜"): + return -1 + if daypart_text in ("前晚", "前夜"): + return -2 + if daypart_text in ("明早", "明晚", "明夜"): + return 1 + return 0 + + def relative_period_offset(period: str | None) -> int: + if period in ("上上", "大上"): + return -2 + if period == "上": + return -1 + if period in ("下下", "大下"): + return 2 + if period == "下": + return 1 + return 0 + + def relative_week_start(period: str | None) -> datetime: + return ( + reference_date - timedelta(days=reference_date.weekday()) + timedelta(weeks=relative_period_offset(period)) + ) + + def relative_weekend_period(period: str | None) -> DateRange: + start = relative_week_start(period) + sat = start + timedelta(days=5) + return constraint(sat, sat + timedelta(days=1)) + + def relative_month_start(period: str | None) -> datetime: + return add_months(reference_date.replace(day=1), relative_period_offset(period)) + + def exact_day_constraint(year: int, month_text: str, day_text: str) -> DateRange | None: + d = exact_day_datetime(year, month_text, day_text) + return None if d is None else constraint(d, d) + + def exact_day_datetime(year: int, month_text: str, day_text: str) -> datetime | None: + month = parse_chinese_month(month_text) + day = parse_chinese_day(day_text) + if month is None or day is None: + return None + if day > calendar.monthrange(year, month)[1]: + return None + return datetime(year, month, day) + + def bare_month_day_constraint(month_text: str, day_text: str) -> DateRange | None: + d = bare_month_day_datetime(month_text, day_text) + return None if d is None else constraint(d, d) + + def bare_month_day_datetime(month_text: str, day_text: str) -> datetime | None: + month = parse_chinese_month(month_text) + day = parse_chinese_day(day_text) + if month is None or day is None: + return None + year = reference_date.year + if day > calendar.monthrange(year, month)[1]: + return None + d = datetime(year, month, day) + if d.date() > reference_date.date(): + year -= 1 + if day > calendar.monthrange(year, month)[1]: + return None + d = datetime(year, month, day) + return d + + def relative_month_day_constraint(period: str, day_text: str) -> DateRange | None: + d = relative_month_day_datetime(period, day_text) + return None if d is None else constraint(d, d) + + def relative_month_day_datetime(period: str, day_text: str) -> datetime | None: + day = parse_chinese_day(day_text) + if day is None: + return None + start = relative_month_start(period) + if day > calendar.monthrange(start.year, start.month)[1]: + return None + return datetime(start.year, start.month, day) + + def weekday_datetime(period: str | None, weekday_text: str) -> datetime | None: + weekday = parse_chinese_weekday(weekday_text) + if weekday is None: + return None + return relative_week_start(period) + timedelta(days=weekday) + + def bare_month_phase_period(month_text: str, phase: str) -> DateRange | None: + month = parse_chinese_month(month_text) + if month is None: + return None + result = month_phase_period(reference_date.year, month, phase) + if result[0].date() <= reference_date.date(): + return result + return month_phase_period(reference_date.year - 1, month, phase) + + def bare_month_period(month_text: str) -> DateRange | None: + month = parse_chinese_month(month_text) + if month is None: + return None + year = reference_date.year + start = datetime(year, month, 1) + if start.date() > reference_date.date(): + year -= 1 + start = datetime(year, month, 1) + return constraint(start, month_end(year, month)) + + def since_constraint(start: datetime) -> DateRange | NoTemporalConstraintSentinel: + if start.date() > reference_date.date(): + return NO_TEMPORAL_CONSTRAINT + return constraint(start, reference_date) + + def since_from_period( + period: DateRange | None, + ) -> DateRange | NoTemporalConstraintSentinel | None: + if period is None: + return None + return since_constraint(period[0]) + + def since_from_day(day: datetime | None) -> DateRange | NoTemporalConstraintSentinel | None: + if day is None: + return None + return since_constraint(day) + + def relative_offset_datetime(amount: int, unit: str, direction: int) -> datetime: + if unit in ("天", "日"): + return reference_date + timedelta(days=direction * amount) + if unit in ("周", "星期", "礼拜"): + return reference_date + timedelta(weeks=direction * amount) + if unit == "月": + return add_months(reference_date, direction * amount) + return add_years(reference_date, direction * amount) + + def point_constraint_at_offset(amount: int, unit: str, direction: int) -> DateRange: + d = relative_offset_datetime(amount, unit, direction) + return constraint(d, d) + + def window_to_reference(amount: int, unit: str) -> DateRange: + return constraint(relative_offset_datetime(amount, unit, -1), reference_date) + + def window_from_reference(amount: int, unit: str) -> DateRange: + return constraint(reference_date, relative_offset_datetime(amount, unit, 1)) + + # Chinese rule guide + # + # dateparser's Chinese coverage is uneven for period expressions: it often returns None, a single-day + # constraint for a whole period, or a substring false positive. Keep explicit Chinese rules here so callers + # get stable range semantics instead of falling through to dateparser. + # + # The matching order below is intentional: + # - Multi-token ranges and "since" forms come before bare periods, so "去年六月以来" is not swallowed as + # "去年" and "下周末" is not swallowed as "下周". + # - Open future starts such as "明天起", "下周起", and "三天后开始" return the sentinel for no temporal + # constraint. The API model only represents closed ranges, and inventing an end date would be misleading. + # - Fixed day words such as "前天", "大后天", and "明天" are exact single days. + # - Exact count offsets such as "两天前", "三个月前", and "半年后" are exact points at that offset. + # - Fuzzy colloquial offsets such as "前两天", "几天前", "一两周前", and "两三个月后" are ranges. + # - Rolling windows such as "过去三天", "最近几周", "这几个月", and "未来半年" are ranges anchored at + # reference_date. + # - Calendar periods such as "本周", "下个月", "去年", "2024年六月", "今年上半年", "第一季度", + # "月初", and "年末" expand to the corresponding calendar range. + # - Weekends are two-day calendar ranges. Bare "周末" means the nearest upcoming/current weekend for the + # reference week; prefixed forms such as "上周末", "这周末", and "下下周末" use the requested week. + # + # Chinese has no reliable whitespace word boundary, so explicit Chinese rules go through chinese_search(): + # a match is accepted at query end, or when the following text looks like temporal context. Individual + # regexes still carry prefix/suffix guards for known compounds. The dateparser fallback has a separate + # embedded-CJK filter; the English fallback path only uses a small false-positive token list. + + if chinese_search(r"大{3,}(前|后)天"): + return NO_TEMPORAL_CONSTRAINT + + if chinese_search(r"明后两?天"): + return constraint(reference_date + timedelta(days=1), reference_date + timedelta(days=2)) + + if chinese_search(r"今明两?天"): + return constraint(reference_date, reference_date + timedelta(days=1)) + + if chinese_search(r"昨今两?天"): + return constraint(reference_date - timedelta(days=1), reference_date) + + day_range_match = chinese_search( + rf"(大大后天|大后天|后天|明天|明日|今天|今日|本日|昨天|昨日|大大前天|大前天|前天)" + rf"{chinese_range_separator}" + r"(大大后天|大后天|后天|明天|明日|今天|今日|本日|昨天|昨日|大大前天|大前天|前天)" + ) + if day_range_match: + first = reference_date + timedelta(days=fixed_day_offset(day_range_match.group(1))) + second = reference_date + timedelta(days=fixed_day_offset(day_range_match.group(2))) + return constraint(min(first, second), max(first, second)) + + if chinese_search(r"(每个?|各|隔)(周|星期|礼拜)(?:(?:周|星期|礼拜))?([一二三四五六日天1-7])"): + return NO_TEMPORAL_CONSTRAINT + + if chinese_search( + rf"(每个?|各|隔)(周|星期|礼拜)(?:(?:周|星期|礼拜))?([一二三四五六日天1-7])" + rf"{chinese_range_separator}" + r"(?:(周|星期|礼拜)(?:(?:周|星期|礼拜))?)?([一二三四五六日天1-7])" + ): + return NO_TEMPORAL_CONSTRAINT + + weekday_range_match = chinese_search( + rf"(? calendar.monthrange(end_year, end_month)[1]: + return None + end = datetime(end_year, end_month, end_day) + return constraint(min(start, end), max(start, end)) + + absolute_date_since_match = chinese_search( + rf"({chinese_year_pattern})\s*年\s*({chinese_month_pattern})\s*月\s*({chinese_day_pattern})" + rf"(?:日|号){chinese_since_suffix_pattern}" + ) + if absolute_date_since_match: + year_text = absolute_date_since_match.group(1) + year = int(year_text) if year_text.isdigit() else parse_chinese_digit_year(year_text) + if year is None: + return None + return since_from_day( + exact_day_datetime(year, absolute_date_since_match.group(2), absolute_date_since_match.group(3)) + ) + + relative_year_date_since_match = chinese_search( + rf"({relative_year_pattern})\s*({chinese_month_pattern})\s*月\s*({chinese_day_pattern})" + rf"(?:日|号){chinese_since_suffix_pattern}" + ) + if relative_year_date_since_match: + return since_from_day( + exact_day_datetime( + relative_year_number(relative_year_date_since_match.group(1)), + relative_year_date_since_match.group(2), + relative_year_date_since_match.group(3), + ) + ) + + relative_month_day_since_match = chinese_search( + rf"(? 1: + return window_to_reference(amount, unit) + + rolling_this_few_match = chinese_search(r"这几个?(天|日|周|星期|礼拜|月|年)") + if rolling_this_few_match: + unit = rolling_this_few_match.group(1) + return window_to_reference(5, unit) + + front_count_days_match = chinese_search(rf"前([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)个?(天|日)") + if front_count_days_match: + amount_text = front_count_days_match.group(1) + amount = parse_chinese_number(amount_text) + if amount is not None and (amount > 2 or amount_text.isdigit()): + return window_to_reference(amount, front_count_days_match.group(2)) + + front_count_period_match = chinese_search(rf"前([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)个?(周|星期|礼拜|月|年)") + if front_count_period_match: + amount = parse_chinese_number(front_count_period_match.group(1)) + unit = front_count_period_match.group(2) + if amount is not None and amount > 1: + return window_to_reference(amount, unit) + + rolling_past_adjacent_match = chinese_search( + r"(过去|近|最近)(一两|[两二]三|三两|三四|四五|五六|六七|七八|八九|九十)" + r"个?(天|日|周|星期|礼拜|月|年)" + ) + if rolling_past_adjacent_match: + amount_text = rolling_past_adjacent_match.group(2) + end_amount = 3 if amount_text in ("一两", "三两") else parse_chinese_number(amount_text[-1]) + unit = rolling_past_adjacent_match.group(3) + if end_amount is not None: + return constraint(relative_offset_datetime(end_amount, unit, -1), reference_date) + + rolling_past_few_match = chinese_search(r"(过去|近|最近)几个?(天|日|周|星期|礼拜|月|年)") + if rolling_past_few_match: + unit = rolling_past_few_match.group(2) + return window_to_reference(5, unit) + + rolling_past_match = chinese_search( + rf"(过去|近|最近)([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)个?(天|日|周|星期|礼拜|月|年)" + ) + if rolling_past_match: + amount = parse_chinese_number(rolling_past_match.group(2)) + unit = rolling_past_match.group(3) + if amount is not None: + return window_to_reference(amount, unit) + + rolling_past_half_match = chinese_search(r"(过去|近|最近)半个?(月|年)") + if rolling_past_half_match: + unit = rolling_past_half_match.group(2) + if unit == "月": + return constraint(reference_date - timedelta(days=15), reference_date) + return constraint(subtract_months(6), reference_date) + + within_half_match = chinese_search(r"半个?(月|年)(?:以内|之内|内)") + if within_half_match: + unit = within_half_match.group(1) + if unit == "月": + return constraint(reference_date - timedelta(days=15), reference_date) + return constraint(subtract_months(6), reference_date) + + within_count_match = chinese_search( + rf"([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)(个?)(天|日|周|星期|礼拜|月|年)(?:以内|之内|内)" + ) + if within_count_match: + amount_text = within_count_match.group(1) + measure = within_count_match.group(2) + unit = within_count_match.group(3) + amount = parse_chinese_number(amount_text) + if amount is not None: + if unit == "月" and measure != "个": + pass + elif unit == "年" and amount_text.isdigit() and len(amount_text) == 4: + pass + else: + return window_to_reference(amount, unit) + + rolling_past_hour_match = chinese_search(rf"(过去|近|最近)([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)个?(小时|钟头)") + if rolling_past_hour_match: + hours = parse_chinese_number(rolling_past_hour_match.group(2)) + if hours is not None: + return constraint(reference_date - timedelta(hours=hours), reference_date) + + rolling_future_hour_match = chinese_search(rf"(未来|接下来|往后)([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)个?(小时|钟头)") + if rolling_future_hour_match: + hours = parse_chinese_number(rolling_future_hour_match.group(2)) + if hours is not None: + return constraint(reference_date, reference_date + timedelta(hours=hours)) + + rolling_future_adjacent_match = chinese_search( + r"(未来|接下来|往后)" + r"(一两|[两二]三|三四|四五|五六|六七|七八|八九|九十)个?(天|日|周|星期|礼拜|月|年)" + ) + if rolling_future_adjacent_match: + amount_text = rolling_future_adjacent_match.group(2) + if amount_text == "一两": + end_amount = 3 + else: + end_amount = parse_chinese_number(amount_text[-1]) + unit = rolling_future_adjacent_match.group(3) + if end_amount is not None: + return window_from_reference(end_amount, unit) + + rolling_future_few_match = chinese_search(r"(未来|接下来|往后)几个?(天|日|周|星期|礼拜|月|年)") + if rolling_future_few_match: + unit = rolling_future_few_match.group(2) + return window_from_reference(5, unit) + + rolling_future_match = chinese_search( + rf"(未来|接下来|往后)([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)个?(天|日|周|星期|礼拜|月|年)" + ) + if rolling_future_match: + amount = parse_chinese_number(rolling_future_match.group(2)) + unit = rolling_future_match.group(3) + if amount is not None: + return window_from_reference(amount, unit) + + rolling_future_half_match = chinese_search(r"(未来|接下来|往后)半个?(月|年)") + if rolling_future_half_match: + unit = rolling_future_half_match.group(2) + if unit == "月": + return constraint(reference_date, reference_date + timedelta(days=15)) + return constraint(reference_date, add_months(reference_date, 6)) + + absolute_year_quarter_since_match = chinese_search( + rf"({chinese_year_pattern})\s*年\s*(第?[一二三四1-4])季(?:度)?{chinese_since_suffix_pattern}" + ) + if absolute_year_quarter_since_match: + year_text = absolute_year_quarter_since_match.group(1) + year = int(year_text) if year_text.isdigit() else parse_chinese_digit_year(year_text) + quarter = parse_chinese_quarter(absolute_year_quarter_since_match.group(2)) + if year is None or quarter is None: + return None + return since_from_period(quarter_period(year, quarter)) + + relative_year_quarter_since_match = chinese_search( + rf"({relative_year_pattern})\s*(第?[一二三四1-4])季(?:度)?{chinese_since_suffix_pattern}" + ) + if relative_year_quarter_since_match: + quarter = parse_chinese_quarter(relative_year_quarter_since_match.group(2)) + if quarter is None: + return None + return since_from_period( + quarter_period(relative_year_number(relative_year_quarter_since_match.group(1)), quarter) + ) + + if chinese_search(rf"(这|本|当){_CHINESE_OPTIONAL_PERIOD_MARKER}季(?:度)?{chinese_since_suffix_pattern}"): + return since_from_period(shifted_quarter_period(0)) + + if chinese_search( + rf"(? list[list[str]] | None: Returns ``None`` for the default ``combined``-mode single pass (caller uses the memory's own tags). Returns a list[list[str]] when the memory requested - multi-pass scoping (``per_tag``, ``all_combinations``, or an explicit list). + multi-pass scoping (``per_tag``, ``all_combinations``, ``shared``, or an + explicit list). + + ``shared`` resolves to ``[[]]`` — a single pass over the empty (untagged) + scope. The created observation carries no tags and recall/dedup match it with + ``tags_match="any"``, so every memory consolidates into one shared observation + regardless of its own tags. Use it to deduplicate across volatile per-call + provenance tags (e.g. per-session ids) without dropping those tags from the + source facts. """ parsed = _parse_observation_scopes(memory) tags = list(memory.get("tags") or []) @@ -345,6 +354,8 @@ def _resolve_obs_tags_list(memory: dict[str, Any]) -> list[list[str]] | None: if not tags: return None return [list(c) for r in range(1, len(tags) + 1) for c in combinations(tags, r)] + if parsed == "shared": + return [[]] if parsed == "combined" or parsed is None: return None return parsed # explicit list[list[str]] @@ -361,6 +372,7 @@ def _resolve_write_scopes(memory: dict[str, Any]) -> list[frozenset[str]]: - ``combined`` / ``None`` -> ``[frozenset(memory.tags)]`` - ``per_tag`` -> ``[frozenset({t}) for t in memory.tags]`` - ``all_combinations`` -> one frozenset per nonempty subset of tags + - ``shared`` -> ``[frozenset()]`` (the single untagged scope) - explicit ``list[list[str]]`` -> one frozenset per declared scope Empty-tag memories collapse to a single ``frozenset()`` in all modes so they @@ -375,6 +387,8 @@ def _resolve_write_scopes(memory: dict[str, Any]) -> list[frozenset[str]]: if not tags: return [frozenset()] return [frozenset(c) for r in range(1, len(tags) + 1) for c in combinations(tags, r)] + if parsed == "shared": + return [frozenset()] if parsed == "combined" or parsed is None: return [frozenset(tags)] return [frozenset(s) for s in parsed] # explicit list[list[str]] @@ -435,6 +449,13 @@ class _CreateAction(BaseModel): def sanitize_text(cls, v: str) -> str: return sanitize_llm_output(v) or "" + @field_validator("source_fact_ids", mode="before") + @classmethod + def ensure_list(cls, v: str | list[str]) -> list[str]: + if isinstance(v, str): + return [v] + return v + class _UpdateAction(BaseModel): text: str @@ -447,6 +468,13 @@ class _UpdateAction(BaseModel): def sanitize_text(cls, v: str) -> str: return sanitize_llm_output(v) or "" + @field_validator("source_fact_ids", mode="before") + @classmethod + def ensure_list(cls, v: str | list[str]) -> list[str]: + if isinstance(v, str): + return [v] + return v + class _DeleteAction(BaseModel): observation_id: str # UUID of the observation to remove @@ -523,6 +551,86 @@ async def _count_observations_for_scope( ) +@dataclass(frozen=True) +class _ScopeLimitRule: + """One ``observation_scope_limits`` rule: a scope pattern -> an observation cap. + + ``globs`` is a tuple of fnmatch tag-globs describing one consolidation scope. + A concrete scope (the set of ``fact_tags`` for a consolidation pass) matches + under *exact cover*: every tag is matched by some glob AND every glob matches + some tag. So ``["shared"]`` matches the scope ``{shared}`` but not + ``{run_1, shared}``, and ``["run_*", "shared"]`` matches ``{run_1, shared}`` + but not ``{shared}``. + + ``limit`` is the cap applied to matching scopes (-1 = unlimited, 0 = no new + observations, >0 = hard cap), mirroring ``max_observations_per_scope``. + """ + + globs: tuple[str, ...] + limit: int + + +def _parse_scope_limit_rules(raw: Any) -> list[_ScopeLimitRule]: + """Parse the raw ``observation_scope_limits`` config into ordered rules. + + The config round-trips as JSON through env and the bank-config API, so this + is defensive: malformed entries are skipped rather than raising, and list + order is preserved (first match wins in :func:`_effective_scope_limit`). + """ + if not isinstance(raw, list): + return [] + rules: list[_ScopeLimitRule] = [] + for entry in raw: + if not isinstance(entry, dict): + continue + scope = entry.get("scope") + limit = entry.get("limit") + if not isinstance(scope, list) or not scope: + continue + if not all(isinstance(g, str) and g for g in scope): + continue + # bool is an int subclass — reject True/False masquerading as a limit. + if not isinstance(limit, int) or isinstance(limit, bool): + continue + rules.append(_ScopeLimitRule(globs=tuple(scope), limit=limit)) + return rules + + +def _scope_matches_globs(globs: tuple[str, ...], tags: list[str]) -> bool: + """Exact-cover match between a scope pattern and a concrete tag set. + + True iff every tag is covered by at least one glob AND every glob covers at + least one tag (no uncovered tags, no vacuous globs). Untagged scopes never + match, so a scope limit never applies to untagged observations (consistent + with the ``and fact_tags`` guard at the call site). Matching is + case-sensitive (``fnmatchcase``) for deterministic cross-platform behaviour. + """ + tagset = set(tags) + if not tagset: + return False + if not all(any(fnmatchcase(t, g) for g in globs) for t in tagset): + return False + if not all(any(fnmatchcase(t, g) for t in tagset) for g in globs): + return False + return True + + +def _effective_scope_limit(config: Any, fact_tags: list[str]) -> int: + """Resolve the observation cap for one concrete consolidation scope. + + The first rule in ``observation_scope_limits`` whose pattern exact-covers + ``fact_tags`` wins; otherwise falls back to the bank-wide + ``max_observations_per_scope``. Wildcards live only here, matched against the + already-resolved concrete tags — the SQL count stays exact and indexed. + """ + if config is None: + return -1 + for rule in _parse_scope_limit_rules(getattr(config, "observation_scope_limits", None)): + if _scope_matches_globs(rule.globs, fact_tags): + return rule.limit + return config.max_observations_per_scope + + def _build_response_model(max_creates: int | None = None) -> type[_ConsolidationBatchResponse]: """Build a response model, optionally constraining max creates via JSON schema.""" if max_creates is None or max_creates < 0: @@ -546,6 +654,7 @@ def __init__(self, bank_id: str): self.start_time = time.time() self.lines: list[str] = [] self.timings: dict[str, float] = {} + self.timing_counts: dict[str, int] = {} self.llm_calls: int = 0 self.total_obs_in_context: int = 0 self.total_prompt_chars: int = 0 @@ -555,11 +664,13 @@ def log(self, message: str) -> None: self.lines.append(message) def record_timing(self, key: str, duration: float) -> None: - """Record a timing measurement.""" - if key in self.timings: - self.timings[key] += duration - else: - self.timings[key] = duration + """Record a timing measurement. + + Tracks both total seconds and call count so the summary can + distinguish one slow call from many fast calls in aggregate. + """ + self.timings[key] = self.timings.get(key, 0.0) + duration + self.timing_counts[key] = self.timing_counts.get(key, 0) + 1 def record_llm_call(self, obs_count: int, prompt_chars: int) -> None: """Record stats for a single LLM call.""" @@ -582,6 +693,8 @@ def merge_from(self, other: "ConsolidationPerfLog") -> None: """ for key, value in other.timings.items(): self.timings[key] = self.timings.get(key, 0.0) + value + for key, count in other.timing_counts.items(): + self.timing_counts[key] = self.timing_counts.get(key, 0) + count self.llm_calls += other.llm_calls self.total_obs_in_context += other.total_obs_in_context self.total_prompt_chars += other.total_prompt_chars @@ -1182,16 +1295,22 @@ async def _run_group( f"{stats['skipped']} skipped)" ) - # Add timing breakdown + # Add timing breakdown. Each phase is recorded once per call, so the count + # disambiguates a single slow call from many fast calls — important for + # operators triaging "the recall phase took 15s" log lines, where the + # total is the sum of many serial sub-calls rather than one slow query. + def _fmt(key: str) -> str: + total = perf.timings[key] + count = perf.timing_counts.get(key, 0) + if count > 1: + avg_ms = total * 1000.0 / count + return f"{key}={total:.3f}s ({count} calls, avg={avg_ms:.0f}ms)" + return f"{key}={total:.3f}s" + timing_parts = [] - if "recall" in perf.timings: - timing_parts.append(f"recall={perf.timings['recall']:.3f}s") - if "llm" in perf.timings: - timing_parts.append(f"llm={perf.timings['llm']:.3f}s") - if "embedding" in perf.timings: - timing_parts.append(f"embedding={perf.timings['embedding']:.3f}s") - if "db_write" in perf.timings: - timing_parts.append(f"db_write={perf.timings['db_write']:.3f}s") + for key in ("recall", "llm", "embedding", "db_write"): + if key in perf.timings: + timing_parts.append(_fmt(key)) if perf.llm_calls > 0: timing_parts.append(f"avg_obs={perf.total_obs_in_context / perf.llm_calls:.1f}") @@ -1403,11 +1522,15 @@ async def _process_memory_batch( # All memories in the batch share the same tag set (enforced by batching) fact_tags = memories[0].get("tags") or [] if memories else [] - # 2b. Compute remaining observation slots for this scope (if limit configured) - max_obs = config.max_observations_per_scope if config is not None else -1 + # 2b. Compute remaining observation slots for this scope (if limit configured). + # The cap is resolved per-scope: an observation_scope_limits rule may override + # the bank-wide max_observations_per_scope for scopes matching its tag pattern. + max_obs = _effective_scope_limit(config, fact_tags) remaining_observation_slots: int | None = None - if max_obs > 0 and fact_tags: - current_count = await _count_observations_for_scope(conn, bank_id, fact_tags) + if max_obs >= 0 and fact_tags: + # max_obs == 0 means "no new observations": there are no slots regardless + # of the current count, so skip the count query for that case. + current_count = await _count_observations_for_scope(conn, bank_id, fact_tags) if max_obs > 0 else 0 remaining_observation_slots = max(max_obs - current_count, 0) if remaining_observation_slots == 0: logger.info( @@ -2045,7 +2168,7 @@ def _fact_line(m: dict[str, Any]) -> str: # Build capacity note for the prompt when observation limit is configured observation_capacity_note: str | None = None - if remaining_observation_slots is not None and max_observations_per_scope > 0: + if remaining_observation_slots is not None and max_observations_per_scope >= 0: if remaining_observation_slots == 0: observation_capacity_note = ( f"OBSERVATION LIMIT REACHED ({max_observations_per_scope}/{max_observations_per_scope}). " @@ -2116,6 +2239,12 @@ def _fact_line(m: dict[str, Any]) -> str: "response_format": response_model, "scope": "consolidation", } + # Only request an explicit output budget when configured. Left unset by default the key is + # omitted, so each provider keeps its implicit default (backwards compatible). Operators on + # providers with a low hidden cap (notably Bedrock imported models, which truncate structured + # consolidation JSON) set HINDSIGHT_API_CONSOLIDATION_MAX_COMPLETION_TOKENS to fix it. + if config.consolidation_max_completion_tokens is not None: + call_kwargs["max_completion_tokens"] = config.consolidation_max_completion_tokens if inner_max_retries is not None: call_kwargs["max_retries"] = inner_max_retries if cached_prefix_name is not None: diff --git a/hindsight-api-slim/hindsight_api/engine/cross_encoder.py b/hindsight-api-slim/hindsight_api/engine/cross_encoder.py index 368e50e91e..6f59504477 100644 --- a/hindsight-api-slim/hindsight_api/engine/cross_encoder.py +++ b/hindsight-api-slim/hindsight_api/engine/cross_encoder.py @@ -27,35 +27,21 @@ DEFAULT_RERANKER_LITELLM_MODEL, DEFAULT_RERANKER_LITELLM_SDK_MODEL, DEFAULT_RERANKER_LOCAL_BATCH_SIZE, - DEFAULT_RERANKER_LOCAL_FORCE_CPU, - DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT, DEFAULT_RERANKER_LOCAL_MODEL, - DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE, - DEFAULT_RERANKER_PROVIDER, DEFAULT_RERANKER_SILICONFLOW_BASE_URL, DEFAULT_RERANKER_SILICONFLOW_MODEL, DEFAULT_RERANKER_TEI_BATCH_SIZE, - DEFAULT_RERANKER_TEI_HTTP_TIMEOUT, DEFAULT_RERANKER_TEI_MAX_CONCURRENT, DEFAULT_RERANKER_ZEROENTROPY_MODEL, DEFAULT_ZEROENTROPY_BASE_URL, ENV_RERANKER_ALIBABA_API_KEY, ENV_RERANKER_COHERE_API_KEY, - ENV_RERANKER_COHERE_MODEL, ENV_RERANKER_FLASHRANK_CACHE_DIR, ENV_RERANKER_FLASHRANK_CPU_MEM_ARENA, 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, - ENV_RERANKER_LOCAL_MODEL, - ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE, ENV_RERANKER_PROVIDER, ENV_RERANKER_SILICONFLOW_API_KEY, - ENV_RERANKER_TEI_BATCH_SIZE, - ENV_RERANKER_TEI_HTTP_TIMEOUT, - ENV_RERANKER_TEI_MAX_CONCURRENT, ENV_RERANKER_TEI_URL, ENV_RERANKER_ZEROENTROPY_API_KEY, ) @@ -304,7 +290,6 @@ def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]: - bucket_batching: sort pairs by token length to reduce padding waste (36-54% speedup) - batch_size: explicit batch size for predict() calls (MPS optimal: 32) """ - import numpy as np try: if self.bucket_batching and len(pairs) > 1: @@ -1199,7 +1184,7 @@ class LiteLLMSDKCrossEncoder(CrossEncoderModel): def __init__( self, - api_key: str, + api_key: str | None = None, model: str = DEFAULT_RERANKER_LITELLM_SDK_MODEL, api_base: str | None = None, timeout: float = 60.0, @@ -1209,7 +1194,8 @@ def __init__( Initialize LiteLLM SDK cross-encoder client. Args: - api_key: API key for the reranking provider + api_key: API key for the reranking provider (optional — omit for + providers that use ambient credentials, e.g. AWS Bedrock with IAM) model: Model name with provider prefix (e.g., "deepinfra/Qwen3-reranker-8B") api_base: Custom base URL for API (optional) timeout: Request timeout in seconds (default: 60.0) @@ -1284,8 +1270,9 @@ async def predict(self, pairs: list[tuple[str, str]]) -> list[float]: "model": self.model, "query": query, "documents": texts, - "api_key": self.api_key, } + if self.api_key: + rerank_kwargs["api_key"] = self.api_key if self.api_base: rerank_kwargs["api_base"] = self.api_base @@ -1678,7 +1665,7 @@ def create_cross_encoder_from_env() -> CrossEncoderModel: return CohereCrossEncoder( api_key=api_key, model=config.reranker_openrouter_model, - base_url="https://openrouter.ai/api/v1/rerank", + base_url=config.reranker_openrouter_base_url, timeout=config.reranker_openrouter_timeout, ) elif provider == "flashrank": @@ -1697,13 +1684,8 @@ def create_cross_encoder_from_env() -> CrossEncoderModel: timeout=config.reranker_litellm_timeout, ) elif provider == "litellm-sdk": - api_key = config.reranker_litellm_sdk_api_key - if not api_key: - raise ValueError( - f"{ENV_RERANKER_LITELLM_SDK_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'litellm-sdk'" - ) return LiteLLMSDKCrossEncoder( - api_key=api_key, + api_key=config.reranker_litellm_sdk_api_key or None, model=config.reranker_litellm_sdk_model, api_base=config.reranker_litellm_sdk_api_base, max_tokens_per_doc=config.reranker_litellm_max_tokens_per_doc, diff --git a/hindsight-api-slim/hindsight_api/engine/db/ops.py b/hindsight-api-slim/hindsight_api/engine/db/ops.py index 02f9fde347..37ac66c846 100644 --- a/hindsight-api-slim/hindsight_api/engine/db/ops.py +++ b/hindsight-api-slim/hindsight_api/engine/db/ops.py @@ -19,7 +19,6 @@ from abc import ABC, abstractmethod from dataclasses import dataclass from typing import Any -from uuid import UUID from .base import DatabaseConnection from .result import ResultRow diff --git a/hindsight-api-slim/hindsight_api/engine/db/ops_oracle.py b/hindsight-api-slim/hindsight_api/engine/db/ops_oracle.py index 59032a5edc..c9d9046882 100644 --- a/hindsight-api-slim/hindsight_api/engine/db/ops_oracle.py +++ b/hindsight-api-slim/hindsight_api/engine/db/ops_oracle.py @@ -8,8 +8,6 @@ import json import uuid as uuid_mod from datetime import UTC, datetime -from typing import Any -from uuid import UUID from .base import DatabaseConnection from .ops import DataAccessOps, TagListingParts diff --git a/hindsight-api-slim/hindsight_api/engine/db/ops_postgresql.py b/hindsight-api-slim/hindsight_api/engine/db/ops_postgresql.py index 586ef11ade..ca5e913ec1 100644 --- a/hindsight-api-slim/hindsight_api/engine/db/ops_postgresql.py +++ b/hindsight-api-slim/hindsight_api/engine/db/ops_postgresql.py @@ -4,11 +4,6 @@ efficient batch operations. """ -import json -from datetime import UTC, datetime -from typing import Any -from uuid import UUID - from .base import DatabaseConnection from .ops import DataAccessOps, TagListingParts from .result import ResultRow @@ -620,7 +615,6 @@ async def expand_observations( per_entity_limit: int, ) -> tuple[list[ResultRow], list[ResultRow], list[ResultRow]]: # v0.5.6 array ops: unnest, &&, COUNT(DISTINCT) on source_memory_ids. - from ..schema import fq_table entity_rows = await conn.fetch( f""" diff --git a/hindsight-api-slim/hindsight_api/engine/db/optional_routines.py b/hindsight-api-slim/hindsight_api/engine/db/optional_routines.py index ca3f807cac..1360df5caf 100644 --- a/hindsight-api-slim/hindsight_api/engine/db/optional_routines.py +++ b/hindsight-api-slim/hindsight_api/engine/db/optional_routines.py @@ -106,6 +106,15 @@ class OptionalRoutine: deployment. * Should be cheap and idempotent — called every poll cycle (~30s). + The poller trusts the result wholesale: any schema the routine does + not return is treated as having no work this cycle. It does NOT + second-guess omissions with a per-schema scan — that would re-run the + exact queries this routine exists to avoid. Consequently the routine + is *only* appropriate for multi-tenant deployments. Single-schema + (default/public only) installs should NOT create it: the per-schema + fallback below is a single cheap EXISTS check that covers ``public`` + correctly and cannot starve. + Fallback when the routine is absent: per-schema ``EXISTS`` queries from Python (~4ms per schema). The server-side path is a single- round-trip optimisation worth ~200ms in deployments with thousands diff --git a/hindsight-api-slim/hindsight_api/engine/embeddings.py b/hindsight-api-slim/hindsight_api/engine/embeddings.py index 4ecaff1e3b..af5effbddd 100644 --- a/hindsight-api-slim/hindsight_api/engine/embeddings.py +++ b/hindsight-api-slim/hindsight_api/engine/embeddings.py @@ -26,11 +26,8 @@ DEFAULT_EMBEDDINGS_GEMINI_MODEL, DEFAULT_EMBEDDINGS_LITELLM_MODEL, DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL, - DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU, DEFAULT_EMBEDDINGS_LOCAL_MODEL, - DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE, DEFAULT_EMBEDDINGS_OPENAI_MODEL, - DEFAULT_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_ZEROENTROPY_BATCH_SIZE, DEFAULT_EMBEDDINGS_ZEROENTROPY_DIMENSIONS, DEFAULT_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT, @@ -40,13 +37,6 @@ DEFAULT_ZEROENTROPY_BASE_URL, ENV_EMBEDDINGS_COHERE_API_KEY, ENV_EMBEDDINGS_GEMINI_API_KEY, - ENV_EMBEDDINGS_LOCAL_FORCE_CPU, - ENV_EMBEDDINGS_LOCAL_MODEL, - ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE, - ENV_EMBEDDINGS_ONNX_DIMENSIONS, - ENV_EMBEDDINGS_ONNX_MODEL_ID, - ENV_EMBEDDINGS_ONNX_MODEL_PATH, - ENV_EMBEDDINGS_ONNX_TOKENIZER_NAME_OR_PATH, ENV_EMBEDDINGS_OPENAI_API_KEY, ENV_EMBEDDINGS_OPENAI_BASE_URL, ENV_EMBEDDINGS_OPENAI_MODEL, @@ -57,6 +47,7 @@ ENV_EMBEDDINGS_ZEROENTROPY_ENCODING_FORMAT, ENV_LLM_API_KEY, ) +from .bank_attribution import apply_bank_attribution logger = logging.getLogger(__name__) @@ -199,7 +190,7 @@ async def initialize(self) -> None: device = "cpu" logger.info("Embeddings: forcing CPU mode") else: - # Check for GPU (CUDA) or Apple Silicon (MPS) + # Check for GPU (CUDA), Apple Silicon (MPS), or Intel XPU # Wrap in try-except to gracefully handle any device detection issues # (e.g., in CI environments or when PyTorch is built without GPU support) device = "cpu" # Default to CPU @@ -207,10 +198,13 @@ async def initialize(self) -> None: has_gpu = torch.cuda.is_available() or ( hasattr(torch.backends, "mps") and torch.backends.mps.is_available() ) + # Intel Arc XPU support — torch.xpu is available when the XPU build is loaded + if not has_gpu and hasattr(torch, "xpu"): + has_gpu = torch.xpu.is_available() if has_gpu: - device = None # Let sentence-transformers auto-detect GPU/MPS + device = None # Let sentence-transformers auto-detect GPU/MPS/XPU except Exception as e: - logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}") + logger.warning(f"Failed to detect GPU/MPS/XPU, falling back to CPU: {e}") # Suppress verbose transformers warnings during model loading # This suppresses the "UNEXPECTED" warnings from BertModel which are harmless @@ -705,6 +699,7 @@ def encode(self, texts: list[str]) -> list[list[float]]: } if self.dimensions is not None: request["dimensions"] = self.dimensions + apply_bank_attribution(request) response = self._client.embeddings.create(**request) @@ -717,7 +712,8 @@ def encode(self, texts: list[str]) -> list[list[float]]: class CodexOAuthEmbeddings(OpenAIEmbeddings): """ - OpenAI embeddings using the Codex/ChatGPT OAuth token from ``~/.codex/auth.json``. + OpenAI embeddings using the Codex/ChatGPT OAuth token from the Codex + ``auth.json`` (``$CODEX_HOME/auth.json``, or ``~/.codex/auth.json`` when unset). Codex OAuth is an LLM-provider auth path in Hindsight, but the same bearer token can also authenticate against the standard OpenAI embeddings endpoint. This keeps @@ -1347,6 +1343,21 @@ def encode(self, texts: list[str]) -> list[list[float]]: return all_embeddings +# Gemini Embedding 2+ multimodal models return a SINGLE aggregated embedding +# for a multi-input request instead of one vector per input (see +# https://ai.google.dev/gemini-api/docs/embeddings#embedding-aggregation). For +# these models we must embed one input per call to preserve the 1:1 input→vector +# alignment the rest of the pipeline relies on. The marker matches preview and GA +# names (e.g. "gemini-embedding-2-preview", "gemini-embedding-2"), with or +# without a "google/" or "models/" prefix. +_GEMINI_AGGREGATING_MODEL_MARKER = "gemini-embedding-2" + + +def _gemini_model_aggregates_inputs(model: str) -> bool: + """Whether the model aggregates a multi-input request into one embedding.""" + return _GEMINI_AGGREGATING_MODEL_MARKER in model.lower() + + class GeminiEmbeddings(Embeddings): """ Google embeddings via the google.genai SDK. @@ -1356,6 +1367,10 @@ class GeminiEmbeddings(Embeddings): 2. Vertex AI with service account or Application Default Credentials (ADC) Uses the embed_content API: client.models.embed_content(model, contents) + + Gemini Embedding 2+ multimodal models aggregate a multi-input request into a + single embedding, so for those the batch size is forced to 1 (one input per + call) to keep one vector per input. """ def __init__( @@ -1510,9 +1525,13 @@ def encode(self, texts: list[str]) -> list[list[float]]: all_embeddings = [] + # Gemini Embedding 2+ multimodal models return one aggregated vector for a + # multi-input request, so embed one input per call to keep 1:1 alignment. + batch_size = 1 if _gemini_model_aggregates_inputs(self.model) else self.batch_size + # Process in batches - for i in range(0, len(texts), self.batch_size): - batch = texts[i : i + self.batch_size] + for i in range(0, len(texts), batch_size): + batch = texts[i : i + batch_size] embed_kwargs = {"model": self.model, "contents": batch} if self._embed_config is not None: @@ -1520,7 +1539,13 @@ def encode(self, texts: list[str]) -> list[list[float]]: result = self._client.models.embed_content(**embed_kwargs) - all_embeddings.extend([emb.values for emb in result.embeddings]) + embeddings = result.embeddings or [] + if len(embeddings) != len(batch): + raise RuntimeError( + f"Gemini embeddings backend returned {len(embeddings)} vectors for " + f"{len(batch)} input texts (model {self.model}); expected exact 1:1 alignment" + ) + all_embeddings.extend([emb.values for emb in embeddings]) # L2-normalize when output_dimensionality is set — Gemini only returns # normalized vectors at full 3072 dims; truncated dims need re-normalization diff --git a/hindsight-api-slim/hindsight_api/engine/entity_resolver.py b/hindsight-api-slim/hindsight_api/engine/entity_resolver.py index 18124fc3e9..d40e994d61 100644 --- a/hindsight-api-slim/hindsight_api/engine/entity_resolver.py +++ b/hindsight-api-slim/hindsight_api/engine/entity_resolver.py @@ -834,14 +834,12 @@ async def resolve_entity( best_candidate = None best_score = 0.0 - best_name_similarity = 0.0 nearby_entity_set = {e["text"].lower() for e in nearby_entities if e["text"] != entity_text} for row in candidates: candidate_id = row["id"] canonical_name = row["canonical_name"] - metadata = row["metadata"] last_seen = row["last_seen"] score = 0.0 @@ -888,7 +886,6 @@ async def resolve_entity( if score > best_score: best_score = score best_candidate = candidate_id - best_name_similarity = name_similarity # Threshold for considering it the same entity threshold = 0.6 diff --git a/hindsight-api-slim/hindsight_api/engine/interface.py b/hindsight-api-slim/hindsight_api/engine/interface.py index 08cf00ad23..6f6f64fe16 100644 --- a/hindsight-api-slim/hindsight_api/engine/interface.py +++ b/hindsight-api-slim/hindsight_api/engine/interface.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from hindsight_api.engine.memory_engine import Budget + from hindsight_api.engine.memory_engine import BankLlmHealthInfo, Budget from hindsight_api.engine.response_models import RecallResult, ReflectResult from hindsight_api.engine.search.tags import TagsMatch from hindsight_api.models import RequestContext @@ -483,6 +483,20 @@ async def get_bank_freshness( """ ... + @abstractmethod + async def check_bank_llm( + self, + bank_id: str, + *, + request_context: "RequestContext", + ) -> "BankLlmHealthInfo": + """ + Probe the LLM consolidation would use for this bank. Deliberate connectivity + test (one real minimal call); never returns the API key. See + MemoryEngine.check_bank_llm. + """ + ... + @abstractmethod async def get_entity( self, diff --git a/hindsight-api-slim/hindsight_api/engine/llm_interface.py b/hindsight-api-slim/hindsight_api/engine/llm_interface.py index a7760a4e39..b1cf67c9c4 100644 --- a/hindsight-api-slim/hindsight_api/engine/llm_interface.py +++ b/hindsight-api-slim/hindsight_api/engine/llm_interface.py @@ -6,9 +6,10 @@ """ from abc import ABC, abstractmethod +from datetime import datetime from typing import Any -from .response_models import LLMToolCallResult, TokenUsage +from .response_models import LLMToolCallResult class LLMInterface(ABC): @@ -252,3 +253,11 @@ class OutputTooLongError(Exception): """ pass + + +class ProviderRateLimitResetError(Exception): + """Raised when an upstream provider says quota will reopen at a known time.""" + + def __init__(self, retry_at: datetime, message: str = "") -> None: + self.retry_at = retry_at + super().__init__(message) diff --git a/hindsight-api-slim/hindsight_api/engine/llm_trace.py b/hindsight-api-slim/hindsight_api/engine/llm_trace.py index fd682fc5e7..16d6e9218f 100644 --- a/hindsight-api-slim/hindsight_api/engine/llm_trace.py +++ b/hindsight-api-slim/hindsight_api/engine/llm_trace.py @@ -35,8 +35,6 @@ logger = logging.getLogger(__name__) -_SWEEP_INTERVAL_SECONDS = 3600 # Run retention sweep every hour - # ── bank/operation attribution (carried across the async call chain) ────────── @@ -310,8 +308,8 @@ class LLMTraceRecorder: Implements ``record_llm_call`` so it can be registered with :func:`hindsight_api.tracing.register_span_recorder`. Writes are - fire-and-forget and never surface errors into the calling path; an optional - retention sweep deletes rows older than ``retention_days``. + fire-and-forget and never surface errors into the calling path. Retention of + old rows is handled by the background :class:`MaintenanceLoop`. """ def __init__( @@ -320,16 +318,13 @@ def __init__( schema_getter: Callable[[], str], enabled: bool, allowed_scopes: list[str], - retention_days: int = -1, max_chars: int = 50000, ) -> None: self._pool_getter = pool_getter self._schema_getter = schema_getter self._enabled = enabled self._allowed_scopes: frozenset[str] | None = frozenset(allowed_scopes) if allowed_scopes else None - self._retention_days = retention_days self._max_chars = max_chars - self._sweep_task: asyncio.Task | None = None # In-flight fire-and-forget write tasks, bucketed by trace_id so # attach_memory_ids can await only *its own* operation's writes before the # post-operation UPDATE (otherwise the UPDATE could race ahead of the @@ -543,46 +538,3 @@ async def _attach_memory_ids(self, bank_id: str | None, trace_id: str, patch: di ) except Exception as e: logger.warning(f"LLM trace memory_id attach failed for trace={trace_id}: {e}") - - # ── retention sweep ─────────────────────────────────────────────────────── - - def start_retention_sweep(self) -> None: - """Start the periodic retention sweep if retention is configured.""" - if self._retention_days <= 0 or not self._enabled: - return - try: - self._sweep_task = asyncio.create_task(self._sweep_loop()) - except RuntimeError: - logger.debug("Cannot start llm trace retention sweep: no running event loop") - - async def stop_retention_sweep(self) -> None: - """Stop the periodic retention sweep.""" - if self._sweep_task and not self._sweep_task.done(): - self._sweep_task.cancel() - try: - await self._sweep_task - except asyncio.CancelledError: - pass - self._sweep_task = None - - async def _sweep_loop(self) -> None: - while True: - await self._run_sweep() - await asyncio.sleep(_SWEEP_INTERVAL_SECONDS) - - async def _run_sweep(self) -> None: - """Delete trace rows older than retention_days. Concurrent-safe.""" - pool = self._pool_getter() - if pool is None: - return - try: - schema = self._schema_getter() - table = f"{schema}.llm_requests" - async with acquire_with_retry(pool, max_retries=1) as conn: - result = await conn.execute( - f"DELETE FROM {table} WHERE started_at < NOW() - INTERVAL '{self._retention_days} days'" - ) - if result and result != "DELETE 0": - logger.info(f"LLM trace retention sweep: {result}") - except Exception as e: - logger.warning(f"LLM trace retention sweep failed: {e}") diff --git a/hindsight-api-slim/hindsight_api/engine/llm_wrapper.py b/hindsight-api-slim/hindsight_api/engine/llm_wrapper.py index 53345f291a..a3f75a2502 100644 --- a/hindsight-api-slim/hindsight_api/engine/llm_wrapper.py +++ b/hindsight-api-slim/hindsight_api/engine/llm_wrapper.py @@ -10,15 +10,10 @@ import time import uuid from contextlib import AsyncExitStack -from pathlib import Path -from typing import Any - -import httpx -from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError +from typing import TYPE_CHECKING, Any # Vertex AI imports (conditional - for LLMProvider to pass credentials to GeminiLLM) try: - import google.auth from google.oauth2 import service_account VERTEXAI_AVAILABLE = True @@ -27,16 +22,14 @@ from ..config import ( DEFAULT_LLM_MAX_CONCURRENT, - DEFAULT_LLM_TIMEOUT, ENV_CONSOLIDATION_LLM_MAX_CONCURRENT, - ENV_LLM_GROQ_SERVICE_TIER, ENV_LLM_MAX_CONCURRENT, - ENV_LLM_TIMEOUT, ENV_REFLECT_LLM_MAX_CONCURRENT, ENV_RETAIN_LLM_MAX_CONCURRENT, ) -from ..metrics import get_metrics_collector -from .response_models import TokenUsage + +if TYPE_CHECKING: + from .response_models import LLMToolCallResult # Seed applied to every Groq request for deterministic behavior. DEFAULT_LLM_SEED = 4242 @@ -232,6 +225,7 @@ def parse_llm_json(raw: str) -> Any: "litellm", "litellmrouter", "bedrock", + "nous", } ) @@ -249,6 +243,7 @@ def create_llm_provider( reasoning_effort: str, groq_service_tier: str | None = None, openai_service_tier: str | None = None, + bedrock_service_tier: str | None = None, extra_body: dict[str, Any] | None = None, default_headers: dict[str, str] | None = None, vertexai_project_id: str | None = None, @@ -257,6 +252,7 @@ def create_llm_provider( gemini_safety_settings: list | None = None, prompt_cache_enabled: bool = False, litellmrouter_config: dict[str, Any] | None = None, + gemini_service_tier: str | None = None, ) -> Any: # Returns LLMInterface """ Factory function to create the appropriate LLM provider implementation. @@ -269,6 +265,8 @@ def create_llm_provider( reasoning_effort: Reasoning effort level for supported providers. groq_service_tier: Groq service tier (for Groq provider) - "on_demand", "flex", or "auto". openai_service_tier: OpenAI service tier (for OpenAI provider) - None (default) or "flex" (50% cheaper). + bedrock_service_tier: Bedrock service tier (for Bedrock provider) - None (default), "flex", "priority", or "reserved". + gemini_service_tier: Gemini service tier (for Gemini provider) - None (default) or "flex" (50% cheaper). extra_body: Extra request-body params merged into the provider's native call. Threaded into OpenAI-compatible, Fireworks, Anthropic, Gemini/ VertexAI and LiteLLM providers (each merges them in its own parameter @@ -284,7 +282,6 @@ def create_llm_provider( Returns: LLMInterface implementation for the specified provider. """ - from .llm_interface import LLMInterface from .providers import ( AnthropicLLM, ClaudeCodeLLM, @@ -300,6 +297,12 @@ def create_llm_provider( ) provider_lower = provider.lower() + if provider_lower == "gemini": + from ..config import parse_gemini_service_tier + + gemini_service_tier = parse_gemini_service_tier(gemini_service_tier) + else: + gemini_service_tier = None if provider_lower == "openai-codex": return CodexLLM( @@ -348,6 +351,7 @@ def create_llm_provider( vertexai_region=vertexai_region, vertexai_credentials=vertexai_credentials, gemini_safety_settings=gemini_safety_settings, + gemini_service_tier=gemini_service_tier, prompt_cache_enabled=prompt_cache_enabled, extra_body=extra_body, ) @@ -401,6 +405,7 @@ def create_llm_provider( model=bedrock_model, reasoning_effort=reasoning_effort, extra_body=extra_body, + bedrock_service_tier=bedrock_service_tier, ) elif provider_lower == "llamacpp": @@ -434,6 +439,21 @@ def create_llm_provider( extra_body=extra_body, ) + elif provider_lower == "nous": + # Nous Portal is OpenAI-compatible on the wire; NousLLM adds rotating + # inference:invoke JWT auth read natively from ~/.hermes/auth.json + # (no static api_key, no hermes_cli dependency — same shape as Codex). + from hindsight_api.engine.providers.nous_llm import NousLLM + + return NousLLM( + provider=provider, + api_key=api_key, + base_url=base_url, + model=model, + reasoning_effort=reasoning_effort, + extra_body=extra_body, + ) + elif provider_lower in ( "openai", "groq", @@ -478,11 +498,13 @@ def __init__( reasoning_effort: str = "low", groq_service_tier: str | None = None, openai_service_tier: str | None = None, + bedrock_service_tier: str | None = None, gemini_safety_settings: list | None = None, prompt_cache_enabled: bool = False, extra_body: dict[str, Any] | None = None, default_headers: dict[str, str] | None = None, litellmrouter_config: dict[str, Any] | None = None, + gemini_service_tier: str | None = None, ): """ Initialize LLM provider. @@ -495,6 +517,8 @@ def __init__( reasoning_effort: Reasoning effort level for supported providers. groq_service_tier: Groq service tier ("on_demand", "flex", "auto") - from config. openai_service_tier: OpenAI service tier (None or "flex") - from config. + bedrock_service_tier: Bedrock service tier (None, "flex", "priority", "reserved") - from config. + gemini_service_tier: Gemini service tier (None or "flex") - from config. gemini_safety_settings: Safety settings for Gemini/VertexAI providers. extra_body: Extra request-body params merged into the provider's native call (OpenAI-compatible, Fireworks, Anthropic, Gemini/VertexAI, LiteLLM). @@ -517,6 +541,8 @@ def __init__( # Service tiers from hierarchical config (not env vars) self.groq_service_tier = groq_service_tier self.openai_service_tier = openai_service_tier + self.bedrock_service_tier = bedrock_service_tier + self.gemini_service_tier = gemini_service_tier # Gemini safety settings (instance default; can be overridden per-request via context var) self.gemini_safety_settings = gemini_safety_settings # Gemini prompt caching: when True, retain extraction (and any future @@ -563,6 +589,7 @@ def __init__( "zai", "opencode-go", "fireworks", + "nous", ] if self.provider not in valid_providers: raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}") @@ -587,6 +614,8 @@ def __init__( self.base_url = "https://api.z.ai/api/coding/paas/v4" elif self.provider == "opencode-go": self.base_url = "https://opencode.ai/zen/go/v1" + elif self.provider == "nous": + self.base_url = "https://inference-api.nousresearch.com/v1" # Prepare Vertex AI config (if applicable) vertexai_project_id = None @@ -642,6 +671,22 @@ def __init__( except Exception: pass # Config may not be initialized in test environments + if self.provider == "gemini": + from ..config import parse_gemini_service_tier + + self.gemini_service_tier = parse_gemini_service_tier(self.gemini_service_tier) + + if self.provider == "gemini" and self.gemini_service_tier is None: + from ..config import _get_raw_config + + try: + raw_config = _get_raw_config() + self.gemini_service_tier = raw_config.llm_gemini_service_tier + except Exception: + pass # Config may not be initialized in test environments + elif self.provider != "gemini": + self.gemini_service_tier = None + # Prompt-prefix caching is a provider-agnostic toggle (default on): resolve # it from the static server config for every provider when the caller didn't # pass an explicit override. Providers that don't support caching ignore the @@ -679,6 +724,8 @@ def __init__( reasoning_effort=self.reasoning_effort, groq_service_tier=self.groq_service_tier, openai_service_tier=self.openai_service_tier, + bedrock_service_tier=self.bedrock_service_tier, + gemini_service_tier=self.gemini_service_tier, extra_body=self.extra_body, default_headers=self.default_headers, vertexai_project_id=vertexai_project_id, @@ -1004,7 +1051,9 @@ def clear_mock_calls(self) -> None: def _load_codex_auth(self) -> tuple[str, str]: """ - Load OAuth credentials from ~/.codex/auth.json. + Load OAuth credentials from the Codex ``auth.json``. + + Honors ``CODEX_HOME`` (falling back to ``~/.codex``). Returns: Tuple of (access_token, account_id). @@ -1013,7 +1062,9 @@ def _load_codex_auth(self) -> tuple[str, str]: FileNotFoundError: If auth file doesn't exist. ValueError: If auth file is invalid. """ - auth_file = Path.home() / ".codex" / "auth.json" + from .providers.codex_auth import default_codex_auth_file + + auth_file = default_codex_auth_file() if not auth_file.exists(): raise FileNotFoundError( @@ -1120,12 +1171,15 @@ def from_env(cls) -> "LLMProvider": DEFAULT_LLM_REASONING_EFFORT, ENV_LLM_API_KEY, ENV_LLM_BASE_URL, + ENV_LLM_BEDROCK_SERVICE_TIER, ENV_LLM_DEFAULT_HEADERS, ENV_LLM_EXTRA_BODY, + ENV_LLM_GEMINI_SERVICE_TIER, ENV_LLM_MODEL, ENV_LLM_PROVIDER, ENV_LLM_REASONING_EFFORT, _get_default_model_for_provider, + parse_gemini_service_tier, ) provider = os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER) @@ -1151,6 +1205,12 @@ def from_env(cls) -> "LLMProvider": reasoning_effort=os.getenv(ENV_LLM_REASONING_EFFORT, DEFAULT_LLM_REASONING_EFFORT), extra_body=extra_body, default_headers=default_headers, + bedrock_service_tier=os.getenv(ENV_LLM_BEDROCK_SERVICE_TIER) or None, + gemini_service_tier=( + parse_gemini_service_tier(os.getenv(ENV_LLM_GEMINI_SERVICE_TIER)) + if provider.lower() == "gemini" + else None + ), ) diff --git a/hindsight-api-slim/hindsight_api/engine/maintenance.py b/hindsight-api-slim/hindsight_api/engine/maintenance.py new file mode 100644 index 0000000000..38ba617fc7 --- /dev/null +++ b/hindsight-api-slim/hindsight_api/engine/maintenance.py @@ -0,0 +1,214 @@ +"""Background maintenance loop. + +A single periodic loop that drives all of Hindsight's recurring housekeeping +from one place, so we don't spawn a separate ``asyncio`` task per concern: + +- **Retention sweeps** (hourly): delete ``audit_log`` and ``llm_requests`` rows + older than their configured retention, across *all* tenant schemas. +- **Consolidation reconcile** (configurable, default 5 min): re-schedule + consolidation for banks that have eligible-but-unscheduled facts and no + in-flight consolidation. This recovers facts that were stranded when a + consolidation operation failed terminally and left them with + ``consolidated_at IS NULL AND consolidation_failed_at IS NULL`` and nothing to + re-trigger them. + +The loop wakes on a short fixed tick and runs each job when its own +``last_run + interval`` is due (run-at-start, then on interval), so adding jobs +with different cadences doesn't burst CPU. Cross-tenant discovery goes through +server-side PL/pgSQL routines (``public.schemas_with_expired_rows`` and +``public.banks_needing_consolidation``) — one round-trip each — instead of a +per-schema query storm, which matters at thousands of tenants. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import TYPE_CHECKING + +from ..config import HindsightConfig, get_config +from ..models import RequestContext +from .db_utils import acquire_with_retry +from .schema import _is_oracle + +if TYPE_CHECKING: + from .memory_engine import MemoryEngine + +logger = logging.getLogger(__name__) + +# Short tick so jobs with different cadences share one loop without per-job tasks. +_TICK_SECONDS = 60 +# Retention sweeps are not time-sensitive; hourly matches the previous per-sweep cadence. +_RETENTION_INTERVAL_SECONDS = 3600 + + +class MaintenanceLoop: + """Owns the single periodic maintenance task for a :class:`MemoryEngine`.""" + + def __init__(self, engine: "MemoryEngine") -> None: + self._engine = engine + self._task: asyncio.Task | None = None + self._stop = asyncio.Event() + # Monotonic timestamps of the last run per job, keyed by job name. + self._last_run: dict[str, float] = {} + + # ── lifecycle ────────────────────────────────────────────────────────── + + def start(self) -> None: + """Start the loop if any maintenance job is enabled. Idempotent.""" + if self._task and not self._task.done(): + return + # PostgreSQL-only: the retention sweeps target PG-only tables (audit_log, + # llm_requests) and the reconcile relies on PG-only PL/pgSQL routines + # installed by the maintenance-routines migration. Oracle support is + # intentionally absent (mirrors that PG-only migration). + if _is_oracle(): + logger.debug("Maintenance loop not started: PostgreSQL-only") + return + if not self._any_job_enabled(): + logger.debug("Maintenance loop not started: no jobs enabled") + return + self._stop.clear() + try: + self._task = asyncio.create_task(self._run()) + except RuntimeError: + logger.debug("Cannot start maintenance loop: no running event loop") + + async def stop(self) -> None: + """Stop the loop and wait for the current tick to finish.""" + self._stop.set() + if self._task and not self._task.done(): + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + @staticmethod + def _any_job_enabled() -> bool: + cfg = get_config() + reconcile_on = cfg.consolidation_reconcile_interval_seconds > 0 + audit_on = cfg.audit_log_enabled and cfg.audit_log_retention_days > 0 + llm_on = cfg.llm_trace_enabled and cfg.llm_trace_retention_days > 0 + return reconcile_on or audit_on or llm_on + + # ── loop ─────────────────────────────────────────────────────────────── + + async def _run(self) -> None: + while not self._stop.is_set(): + try: + await self._tick() + except Exception: + logger.exception("Maintenance tick failed") + try: + await asyncio.wait_for(self._stop.wait(), timeout=_TICK_SECONDS) + except asyncio.TimeoutError: + pass + + def _is_due(self, job: str, interval_seconds: int) -> bool: + """True if ``job`` has never run or its interval has elapsed; marks it run now.""" + now = time.monotonic() + last = self._last_run.get(job) + if last is not None and (now - last) < interval_seconds: + return False + self._last_run[job] = now + return True + + async def _tick(self) -> None: + cfg = get_config() + if self._is_due("retention", _RETENTION_INTERVAL_SECONDS): + await self._run_retention(cfg) + interval = cfg.consolidation_reconcile_interval_seconds + if interval > 0 and self._is_due("reconcile", interval): + await self._run_reconcile() + + # ── retention ────────────────────────────────────────────────────────── + + async def _run_retention(self, cfg: HindsightConfig) -> None: + # Retention days are static server-level config, so one global cutoff + # applies to every tenant schema (the routine sweeps them all). + if cfg.audit_log_enabled and cfg.audit_log_retention_days > 0: + await self._purge_expired("audit_log", "started_at", cfg.audit_log_retention_days) + if cfg.llm_trace_enabled and cfg.llm_trace_retention_days > 0: + await self._purge_expired("llm_requests", "started_at", cfg.llm_trace_retention_days) + + async def _purge_expired(self, table: str, ts_col: str, days: int) -> None: + """Delete rows older than ``days`` from ``table`` across every tenant schema.""" + backend = self._engine._backend + try: + async with acquire_with_retry(backend, max_retries=1) as conn: + rows = await conn.fetch( + "SELECT * FROM public.schemas_with_expired_rows($1, $2, $3)", table, ts_col, days + ) + for row in rows: + schema = row[0] + # schema names come from pg_class; quote defensively all the same. + qschema = '"' + schema.replace('"', '""') + '"' + result = await conn.execute( + f"DELETE FROM {qschema}.{table} WHERE {ts_col} < NOW() - make_interval(days => $1)", + days, + ) + if result and result != "DELETE 0": + logger.info(f"Retention sweep {schema}.{table}: {result}") + except Exception as e: + logger.warning(f"Retention sweep failed for {table}: {e}") + + # ── consolidation reconcile ────────────────────────────────────────────── + + async def _run_reconcile(self) -> None: + """Re-schedule consolidation for banks with eligible-but-unscheduled facts.""" + engine = self._engine + try: + async with acquire_with_retry(engine._backend, max_retries=1) as conn: + rows = await conn.fetch("SELECT schema_name, bank_id FROM public.banks_needing_consolidation()") + except Exception as e: + logger.warning(f"Consolidation reconcile discovery failed: {e}") + return + if not rows: + return + + # Only enqueue into schemas the worker actually polls (tenant discovery), + # otherwise the op would never be claimed and would block future reconciles + # for that bank. The tenant_id (when the extension provides one) lets + # config resolution honor tenant-level overrides. + try: + tenants = await engine._tenant_extension.list_tenants() + except Exception as e: + logger.warning(f"Consolidation reconcile tenant discovery failed: {e}") + return + tenant_by_schema = {t.schema: t for t in tenants} + default_schema = get_config().database_schema + + from .memory_engine import _current_schema + + submitted = 0 + skipped_unknown = 0 + for row in rows: + schema = row["schema_name"] + bank_id = row["bank_id"] + tenant = tenant_by_schema.get(schema) + if tenant is None and schema != default_schema: + skipped_unknown += 1 + continue + tenant_id = tenant.tenant_id if tenant else None + token = _current_schema.set(schema) + try: + context = RequestContext(internal=True, tenant_id=tenant_id) + resolved = await engine._config_resolver.resolve_full_config(bank_id, context) + # Mirror the retain-time auto-consolidation gate (memory_engine): both + # observations and auto-consolidation must be enabled for this bank. + if not (resolved.enable_observations and resolved.enable_auto_consolidation): + continue + await engine.submit_async_consolidation(bank_id=bank_id, request_context=context) + submitted += 1 + except Exception as e: + logger.warning(f"Consolidation reconcile failed for bank {bank_id} in {schema}: {e}") + finally: + _current_schema.reset(token) + + if submitted or skipped_unknown: + logger.info( + f"Consolidation reconcile: scheduled {submitted} bank(s)" + + (f", skipped {skipped_unknown} in unrecognized schema(s)" if skipped_unknown else "") + ) diff --git a/hindsight-api-slim/hindsight_api/engine/memory_engine.py b/hindsight-api-slim/hindsight_api/engine/memory_engine.py index 7ad4607765..0b04224ec4 100644 --- a/hindsight-api-slim/hindsight_api/engine/memory_engine.py +++ b/hindsight-api-slim/hindsight_api/engine/memory_engine.py @@ -11,19 +11,23 @@ import asyncio import contextvars +import functools +import inspect import json import logging +import sys import time import uuid from collections.abc import Awaitable, Callable from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta, timezone -from typing import TYPE_CHECKING, Any, Literal, cast, overload +from typing import TYPE_CHECKING, Any, Literal, ParamSpec, TypeVar, cast, overload import asyncpg import httpx from .._vector_index import ann_search_tuning_settings, configured_vector_extension +from ..cancellation import OperationCancelledError from ..config import ( DEFAULT_RECALL_CHUNKS_MAX_TOKENS, DEFAULT_RECALL_INCLUDE_CHUNKS, @@ -33,8 +37,6 @@ HindsightConfig, get_config, ) -from ..db_url import to_libpq_url -from ..metrics import get_metrics_collector from ..tracing import create_operation_span from ..utils import mask_network_location from ..worker.exceptions import DeferOperation, RetryTaskAt @@ -43,6 +45,7 @@ from .bank_stats_cache import BankStatsCache from .db import DatabaseBackend, create_database_backend from .db_budget import budgeted_operation +from .llm_interface import ProviderRateLimitResetError from .llm_trace import ( LLMRequestEntry, LLMRequestListResponse, @@ -55,15 +58,21 @@ from .operation_metadata import ( BatchRetainChildMetadata, BatchRetainParentMetadata, - ConsolidationMetadata, - RefreshMentalModelMetadata, - RetainMetadata, + RetainExtractionErrors, + RetainOutcomeAggregate, + RetainOutcomeMetadata, ) from .sql import SQLDialect, create_sql_dialect # Context variable for current schema (async-safe, per-task isolation) # Note: default is None, actual default comes from config via get_current_schema() _current_schema: contextvars.ContextVar[str | None] = contextvars.ContextVar("current_schema", default=None) + +# Context variable for the bank an operation runs for (async-safe, per-task isolation). +# Set by the engine wherever it learns the bank (recall/retain/batch/task execution) so +# downstream provider calls can attribute spend per bank — e.g. tagging the OpenAI `user` +# field for cost gateways. None outside a bank-scoped operation. +_current_bank_id: contextvars.ContextVar[str | None] = contextvars.ContextVar("current_bank_id", default=None) MENTAL_MODEL_PENDING_CONTENT = "Generating content..." @@ -76,6 +85,44 @@ def get_current_schema() -> str: return schema +def get_current_bank_id() -> str | None: + """Get the bank id of the in-flight operation, or None outside a bank-scoped context.""" + return _current_bank_id.get() + + +_P = ParamSpec("_P") +_R = TypeVar("_R") + + +def _bind_bank_id( + arg: str = "bank_id", key: str | None = None +) -> Callable[[Callable[_P, Awaitable[_R]]], Callable[_P, Awaitable[_R]]]: + """Bind ``_current_bank_id`` to an argument of the wrapped coroutine for the call's duration. + + ``arg`` names the parameter carrying the bank id; ``key`` optionally pulls it out of a + dict-valued argument (e.g. ``task_dict["bank_id"]``). Token-based set/reset (including on + exception) keeps the binding scoped to the call. + """ + + def decorate(func: Callable[_P, Awaitable[_R]]) -> Callable[_P, Awaitable[_R]]: + sig = inspect.signature(func) + + @functools.wraps(func) + async def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R: + value = sig.bind(*args, **kwargs).arguments.get(arg) + if key is not None and isinstance(value, dict): + value = value.get(key) + token = _current_bank_id.set(value if isinstance(value, str) else None) + try: + return await func(*args, **kwargs) + finally: + _current_bank_id.reset(token) + + return wrapper + + return decorate + + def count_tokens(text: str) -> int: """Count tokens in text using tiktoken (cl100k_base encoding for GPT-4/3.5).""" return len(_get_tiktoken_encoding().encode(text)) @@ -134,6 +181,76 @@ def _json_default(obj: Any) -> str: _CONSOLIDATION_RETRY_BACKOFF_BASE_SECONDS = 5 _CONSOLIDATION_RETRY_BACKOFF_MAX_SECONDS = 1800 # 30 min cap +# Upper bound on the per-bank LLM connectivity probe so a hung provider can't wedge +# the request. The probe is a deliberate, non-polled action (POST .../health/llm). +_LLM_PROBE_TIMEOUT_SECONDS = 10.0 + +# Substrings that identify an authentication/authorization failure across providers. +# A wrong API key is the single most common probe failure, so it gets its own status. +_AUTH_ERROR_MARKERS = ( + "401", + "403", + "unauthorized", + "invalid api key", + "invalid_api_key", + "incorrect api key", + "api key not valid", + "api_key_invalid", + "authentication", + "permission denied", + "permissiondenied", +) + + +def _is_auth_error(error: Exception) -> bool: + """Whether a probe exception looks like an auth failure (typically a bad API key). + + Walks the exception chain for an HTTP 401/403 status code, then falls back to + matching known auth markers in the (provider-wrapped) message. Used only to pick a + status label — the raw error itself is never returned to the client. + """ + seen: list[Exception] = [] + current: BaseException | None = error + for _ in range(6): # bounded walk to avoid pathological cycles + if current is None or current in seen: + break + seen.append(current) # type: ignore[arg-type] + code = getattr(current, "status_code", None) or getattr(current, "code", None) + if code in (401, 403, "401", "403"): + return True + current = current.__cause__ or current.__context__ + text = " ".join(str(item) for item in seen).lower() + return any(marker in text for marker in _AUTH_ERROR_MARKERS) + + +@dataclass +class LlmOperationHealthInfo: + """Connectivity status for one operation's LLM. Status only — deliberately carries + no provider/model/endpoint/error so the probe never leaks the LLM configuration.""" + + operation: str + ok: bool + status: str + latency_ms: float | None + + +@dataclass +class BankLlmHealthInfo: + """Per-bank LLM connectivity probe across retain/consolidation/reflect (see + MemoryEngine.check_bank_llm).""" + + bank_id: str + operations: list[LlmOperationHealthInfo] + + +@dataclass +class _LlmProbeOutcome: + """Internal result of probing a single LLM client (before it's tagged per operation).""" + + ok: bool + status: str + latency_ms: float | None + def _consolidation_retry_backoff_seconds(retry_count: int) -> int: """Capped exponential backoff: 5, 10, 20, 40, 80, 160, 320, 640, 1280, 1800, 1800, …""" @@ -214,16 +331,12 @@ def validate_sql_schema(sql: str) -> None: ) -import asyncpg -import numpy as np -from pydantic import BaseModel, Field - from .cross_encoder import CrossEncoderModel from .embeddings import Embeddings, create_embeddings_from_env from .interface import MemoryEngineInterface if TYPE_CHECKING: - from hindsight_api.extensions import OperationValidatorExtension, TenantExtension + from hindsight_api.extensions import OperationValidatorExtension, TenantExtension, ValidationResult from hindsight_api.models import RequestContext from .audit import AuditLogListResponse, AuditLogStatsResponse @@ -232,21 +345,18 @@ def validate_sql_schema(sql: str) -> None: from enum import Enum -from ..metrics import get_metrics_collector from ..pg0 import EmbeddedPostgres, parse_pg0_url from .entity_resolver import EntityResolver from .llm_wrapper import LLMConfig, requires_api_key, sanitize_llm_output, sanitize_text from .query_analyzer import QueryAnalyzer from .reflect import run_reflect_agent -from .reflect.prompts import DELTA_SYSTEM_PROMPT, build_delta_prompt from .reflect.tools import tool_expand, tool_recall, tool_search_mental_models, tool_search_observations from .response_models import ( VALID_RECALL_FACT_TYPES, - EntityObservation, + DryRunExtractionResult, EntityState, LLMCallTrace, MemoryFact, - ObservationRef, ReflectResult, TokenUsage, ToolCallTrace, @@ -254,7 +364,6 @@ def validate_sql_schema(sql: str) -> None: from .response_models import RecallResult as RecallResultModel from .retain import bank_utils, embedding_utils from .retain.types import RetainContentDict -from .search import think_utils from .search.reranking import CrossEncoderReranker, apply_combined_scoring from .search.tags import TagGroup, TagsMatch, build_tag_groups_where_clause, build_tags_where_clause from .search.types import ScoredResult @@ -317,6 +426,12 @@ class _SubBatchSplit: document_body_overrides: list[str | None] = field(default_factory=list) +@dataclass(frozen=True) +class _RetainChunkingConfig: + chunk_size: int + structured_chunk_size: int | None + + def _split_contents_into_sub_batches( contents: list[RetainContentDict], tokens_per_batch: int, @@ -326,7 +441,7 @@ def _split_contents_into_sub_batches( Any single item that already exceeds the budget is chunked via ``fact_extraction.chunk_text`` (paragraph/sentence aware, or - conversation-turn aware for JSON arrays) and each chunk becomes its + conversation-turn aware for JSON arrays and JSONL) and each chunk becomes its own single-item sub-batch. Without this, an oversized single item would pass through as a ``1/1`` sub-batch holding the entire payload — which contradicts the splitter's log and lets the @@ -630,6 +745,37 @@ def _resolve_refresh_tag_filtering( return RefreshTagFiltering(tags=model_tags, tags_match=tags_match, tag_groups=None) +@dataclass +class ResolvedDispositionMission: + """Disposition + mission after overlaying resolved bank config on the legacy columns.""" + + disposition: dict[str, int] + mission: str + + +def _overlay_bank_config_disposition_mission( + disposition: dict[str, int], mission: str, config_dict: dict[str, Any] +) -> ResolvedDispositionMission: + """Overlay resolved bank config on top of the legacy banks.disposition / + banks.mission column values. + + ``reflect_mission`` and ``disposition_*`` in the resolved bank config take + precedence over the legacy DB columns. Shared by ``get_bank_profile`` and + ``list_banks`` so the single-bank and list paths return identical + disposition + mission for the same bank. + """ + resolved_mission = config_dict.get("reflect_mission") or mission + cfg_skep = config_dict.get("disposition_skepticism") + cfg_lit = config_dict.get("disposition_literalism") + cfg_emp = config_dict.get("disposition_empathy") + resolved_disposition = { + "skepticism": cfg_skep if cfg_skep is not None else disposition["skepticism"], + "literalism": cfg_lit if cfg_lit is not None else disposition["literalism"], + "empathy": cfg_emp if cfg_emp is not None else disposition["empathy"], + } + return ResolvedDispositionMission(disposition=resolved_disposition, mission=resolved_mission) + + class MemoryEngine(MemoryEngineInterface): """ Advanced memory system using temporal and semantic linking with PostgreSQL. @@ -820,6 +966,8 @@ def __init__( extra_body=config.llm_extra_body, default_headers=config.llm_default_headers, litellmrouter_config=config.llm_litellmrouter_config, + bedrock_service_tier=config.llm_bedrock_service_tier, + gemini_service_tier=config.llm_gemini_service_tier, ) # Store client and model for convenience (deprecated: use _llm_config.call() instead) @@ -852,6 +1000,8 @@ def __init__( extra_body=config.llm_extra_body, default_headers=config.llm_default_headers, litellmrouter_config=config.retain_llm_litellmrouter_config or config.llm_litellmrouter_config, + bedrock_service_tier=config.llm_bedrock_service_tier, + gemini_service_tier=config.llm_gemini_service_tier, ) # Reflect LLM config - for think/observe operations (can use lighter models) @@ -879,6 +1029,8 @@ def __init__( extra_body=config.llm_extra_body, default_headers=config.llm_default_headers, litellmrouter_config=config.reflect_llm_litellmrouter_config or config.llm_litellmrouter_config, + bedrock_service_tier=config.llm_bedrock_service_tier, + gemini_service_tier=config.llm_gemini_service_tier, ) # Consolidation LLM config - for mental model consolidation (can use efficient models) @@ -906,6 +1058,8 @@ def __init__( extra_body=config.llm_extra_body, default_headers=config.llm_default_headers, litellmrouter_config=config.consolidation_llm_litellmrouter_config or config.llm_litellmrouter_config, + bedrock_service_tier=config.llm_bedrock_service_tier, + gemini_service_tier=config.llm_gemini_service_tier, ) # Initialize cross-encoder reranker (cached for performance) @@ -930,7 +1084,6 @@ def __init__( schema_getter=get_current_schema, enabled=config.audit_log_enabled, allowed_actions=config.audit_log_actions, - retention_days=config.audit_log_retention_days, ) # Per-bank LLM request tracer (disabled by default). Registered as a @@ -941,13 +1094,18 @@ def __init__( schema_getter=get_current_schema, enabled=config.llm_trace_enabled, allowed_scopes=config.llm_trace_scopes, - retention_days=config.llm_trace_retention_days, max_chars=config.llm_trace_max_chars, ) from ..tracing import register_span_recorder register_span_recorder(self._llm_recorder) + # Background maintenance loop (retention sweeps + consolidation reconcile), + # created in initialize() once the pool/backend is ready. + from .maintenance import MaintenanceLoop + + self._maintenance_loop: MaintenanceLoop | None = None + # Backpressure mechanism: limit concurrent searches to prevent overwhelming the database # Configurable via HINDSIGHT_API_RECALL_MAX_CONCURRENT (default: 50) self._search_semaphore = asyncio.Semaphore(get_config().recall_max_concurrent) @@ -971,6 +1129,35 @@ def __init__( tenant_extension = DefaultTenantExtension(config={}) self._tenant_extension = tenant_extension + # Load memory defense extension; default to the regex extension when the + # env var is unset. Lazy imports avoid a circular dependency: + # extensions/__init__ imports MCPExtension which imports MemoryEngine at + # module level. + from ..extensions.builtin.memory_defense_regex import ( # noqa: PLC0415 + MemoryDefenseRegexExtension, + ) + from ..extensions.context import DefaultExtensionContext # noqa: PLC0415 + from ..extensions.loader import load_extension # noqa: PLC0415 + from ..extensions.memory_defense import MemoryDefenseExtension # noqa: PLC0415 + + # Build the extension context now; webhook_manager is populated later in + # initialize() once the pool is ready. current_schema is a per-request + # value written by _authenticate() and execute_task(). + self._ext_ctx = DefaultExtensionContext( + database_url=config.database_url or "", + memory_engine=self, + webhook_manager=None, + current_schema=None, + ) + + loaded = load_extension("MEMORY_DEFENSE", MemoryDefenseExtension, context=self._ext_ctx) + if loaded is not None: + self._memory_defense: MemoryDefenseExtension = loaded + else: + regex_defense = MemoryDefenseRegexExtension({}) + regex_defense.set_context(self._ext_ctx) + self._memory_defense = regex_defense + # Cache for get_bank_stats — short TTL + concurrent-loader coalescing. # The query joins memory_links to memory_units and can be a multi-second # parallel scan on large banks; a single polling client used to be able @@ -1006,7 +1193,7 @@ async def _validate_operation(self, validation_coro) -> "ValidationResult | None if self._operation_validator is None: return None - from hindsight_api.extensions import OperationValidationError, ValidationResult + from hindsight_api.extensions import OperationValidationError result = await validation_coro if not result.allowed: @@ -1050,6 +1237,7 @@ async def _authenticate_tenant(self, request_context: "RequestContext | None") - tenant_context = await self._tenant_extension.authenticate(request_context) _current_schema.set(tenant_context.schema_name) + self._ext_ctx.current_schema = tenant_context.schema_name return tenant_context.schema_name async def _handle_import_documents(self, task_dict: dict[str, Any]): @@ -1517,6 +1705,7 @@ async def _handle_refresh_mental_model(self, task_dict: dict[str, Any]): logger.info(f"[REFRESH_MENTAL_MODEL_TASK] Completed for bank_id={bank_id}, mental_model_id={mental_model_id}") + @_bind_bank_id("task_dict", key="bank_id") async def execute_task(self, task_dict: dict[str, Any]): """ Execute a task by routing it to the appropriate handler. @@ -1535,6 +1724,7 @@ async def execute_task(self, task_dict: dict[str, Any]): schema = task_dict.pop("_schema", None) if schema: _current_schema.set(schema) + self._ext_ctx.current_schema = schema # Check if operation was cancelled (only for tasks with operation_id) if operation_id: @@ -1600,6 +1790,9 @@ async def execute_task(self, task_dict: dict[str, Any]): audit_entry.response = {"status": "completed", "operation_id": operation_id} + except ProviderRateLimitResetError as e: + logger.warning(f"Task deferred until provider quota resets at {e.retry_at}: {e}") + raise DeferOperation(exec_date=e.retry_at, reason=str(e)) from e except RetryTaskAt: # Task-owned retry: let the poller handle scheduling raise @@ -2038,6 +2231,47 @@ async def _mark_operation_completed(self, operation_id: str): except Exception as e: logger.error(f"Failed to mark operation as completed {operation_id}: {e}") + async def _write_retain_outcome_metadata(self, operation_id: str | None, unit_ids: list[list[str]]) -> None: + """Persist completed retain outcome fields before the operation is marked completed.""" + if not operation_id: + return + + unit_ids_count = sum(len(group) for group in unit_ids) + try: + backend = await self._get_backend() + async with acquire_with_retry(backend) as conn: + row = await conn.fetchrow( + f"SELECT result_metadata FROM {fq_table('async_operations')} WHERE operation_id = $1", + uuid.UUID(operation_id), + ) + if not row: + return + + metadata = conn.parse_json(row["result_metadata"]) or {} + extraction_errors = RetainExtractionErrors() + extraction_errors.merge_metadata(metadata) + outcome = RetainOutcomeMetadata( + unit_ids_count=unit_ids_count, + extraction_errors_count=extraction_errors.count, + extraction_errors_sample=extraction_errors.sample, + ) + + await conn.execute( + f""" + UPDATE {fq_table("async_operations")} + SET result_metadata = COALESCE(result_metadata, '{{}}'::jsonb) || $2::jsonb, + updated_at = now() + WHERE operation_id = $1 + """, + uuid.UUID(operation_id), + json.dumps(outcome.to_dict()), + ) + except Exception as e: + # Best-effort, but log loudly: the whole point of this metadata is to + # give clients a reliable success/silent-failure signal, so a missing + # write silently regresses them to the ambiguous pre-fix behaviour. + logger.warning(f"Failed to write retain outcome metadata for {operation_id}: {e}") + async def _mark_operation_completed_and_fire_webhook( self, operation_id: str, @@ -2149,14 +2383,16 @@ async def _maybe_update_parent_operation(self, child_operation_id: str, conn): # Get all sibling operations (including this one). # This query runs in the same transaction, so it sees the current - # child's updated status. Pull error_message too so a parent that + # child's updated status. Pull result_metadata for completed + # children so the parent exposes the same outcome counters as the + # individual retain operations. Pull error_message too so a parent that # fails can inherit a representative child reason -- otherwise # downstream consumers (dashboards, alert filters) lose the actual # cause once a batch has children. See the worker poller's # _summarise_child_error_messages for the propagation rationale. siblings = await conn.fetch( f""" - SELECT status, error_message + SELECT status, error_message, result_metadata FROM {fq_table("async_operations")} WHERE bank_id = $1 AND result_metadata::jsonb @> $2::jsonb @@ -2198,14 +2434,22 @@ async def _maybe_update_parent_operation(self, child_operation_id: str, conn): ) elif all_completed: new_status = "completed" + outcome_aggregate = RetainOutcomeAggregate() + for sibling in siblings: + sibling_metadata = conn.parse_json(sibling["result_metadata"]) or {} + outcome_aggregate.add_metadata(sibling_metadata) await conn.execute( f""" UPDATE {fq_table("async_operations")} - SET status = $2, updated_at = NOW(), completed_at = NOW() + SET status = $2, + result_metadata = COALESCE(result_metadata, '{{}}'::jsonb) || $3::jsonb, + updated_at = NOW(), + completed_at = NOW() WHERE operation_id = $1 """, uuid.UUID(parent_operation_id), new_status, + json.dumps(outcome_aggregate.to_outcome_metadata().to_dict()), ) logger.info(f"Updated parent operation {parent_operation_id} to status '{new_status}' (all children done)") @@ -2336,7 +2580,7 @@ async def verify_llm(): f"Configuration error: HINDSIGHT_API_RETAIN_BATCH_ENABLED=true " f"but the retain LLM provider '{self._retain_llm_config.provider}' " f"does not support the batch API. Either switch to a provider " - f"that supports batch operations (e.g. 'openai', 'groq') or " + f"that supports batch operations (e.g. 'openai', 'groq', 'gemini') or " f"set HINDSIGHT_API_RETAIN_BATCH_ENABLED=false." ) @@ -2372,6 +2616,28 @@ async def verify_llm(): f"first-time model download legitimately needs more time." ) from e + # Normalize torch's process-global default dtype back to float32 after the + # concurrent local model loads. transformers' dtype context manager (entered + # by SentenceTransformer / CrossEncoder / from_pretrained) does a + # NON-thread-safe save/restore of the global default dtype: when an fp16 and + # an fp32 model load in parallel above, an unlucky interleave can leave the + # default stuck at float16, after which every encode() emits NaN vectors that + # pgvector rejects ("NaN not allowed in vector") on MPS, or raises + # "c10::Half != float" on CPU — non-deterministically across restarts. By the + # time gather() returns, all load threads have joined, so resetting the + # default here is race-free, keeps the loads fully parallel, and converges on + # the float32 inference state a healthy boot already reaches. torch is only + # imported (in sys.modules) if a local provider actually loaded a model. + # See https://github.com/vectorize-io/hindsight/issues/2162. + torch_mod = sys.modules.get("torch") + if torch_mod is not None and torch_mod.get_default_dtype() != torch_mod.float32: + logger.warning( + "torch default dtype was left at %s after concurrent model init; " + "restoring float32 to avoid NaN embedding vectors (issue #2162).", + torch_mod.get_default_dtype(), + ) + torch_mod.set_default_dtype(torch_mod.float32) + # Run database migrations if enabled if self._run_migrations: if not self.db_url: @@ -2387,50 +2653,39 @@ async def verify_llm(): tenants = await self._tenant_extension.list_tenants() if tenants: logger.info(f"Running migrations on {len(tenants)} schema(s)...") - for tenant in tenants: - schema = tenant.schema - if schema: - schema = self._backend.normalize_schema(schema) - self._backend.run_migrations(self.db_url, schema=schema) - logger.info("Schema migrations completed") - - # PG-specific post-migration steps: ensure vector/text search extensions - # and embedding dimensions match configuration. These are no-ops for - # non-PG backends since they use different indexing strategies. - if self._backend.supports_bm25: - from ..migrations import ( - ensure_embedding_dimension, - ensure_text_search_extension, - ensure_vector_extension, - ) - - if tenants: - for tenant in tenants: - schema = tenant.schema - if schema: - ensure_embedding_dimension( - self.db_url, - self.embeddings.dimension, - schema=schema, - vector_extension=config.vector_extension, - ) - - for tenant in tenants: - schema = tenant.schema - if schema: - ensure_vector_extension( - self.db_url, vector_extension=config.vector_extension, schema=schema - ) - + if self._database_backend_type == "postgresql": + # PG: fan out across schemas (up to migration_concurrency, each + # in its own process) and fold the PG-specific post-migration + # extension/dimension sync into the same per-schema unit. Run + # off the event loop so the process pool's blocking joins don't + # stall it. + from ..migrations import run_migrations_for_schemas + + schemas = [tenant.schema for tenant in tenants if tenant.schema] + await asyncio.to_thread( + run_migrations_for_schemas, + self.db_url, + schemas, + concurrency=config.migration_concurrency, + migration_database_url=config.migration_database_url, + embedding_dimension=self.embeddings.dimension, + vector_extension=config.vector_extension, + text_search_extension=config.text_search_extension, + pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer, + ensure_extensions=self._backend.supports_bm25, + ) + else: + # Oracle and other backends: Alembic's non-thread-safe globals + # and the absence of per-schema extension steps make parallelism + # unnecessary; run sequentially via the backend's own runner. + # normalize_schema() maps PG's "public" default to None (the + # connecting user's schema) on Oracle. for tenant in tenants: - schema = tenant.schema - if schema: - ensure_text_search_extension( - self.db_url, - text_search_extension=config.text_search_extension, - pg_search_tokenizer=config.text_search_extension_pg_search_tokenizer, - schema=schema, + if tenant.schema: + self._backend.run_migrations( + self.db_url, schema=self._backend.normalize_schema(tenant.schema) ) + logger.info("Schema migrations completed") logger.info(f"Connecting to database at {mask_network_location(self.db_url)}") @@ -2457,11 +2712,9 @@ async def _init_connection(conn: asyncpg.Connection) -> None: await conn.execute('SET search_path TO "$user", public, bm25_catalog, tokenizer_catalog') # SET (not SET LOCAL) so per-backend ANN tuning persists for the - # connection lifetime. Each backend exposes its own GUC: pgvector - # uses hnsw.ef_search, vchord uses vchordrq.probes. The dispatcher - # returns the right one for the configured extension, tuned for - # the higher recall the per-fact_type semantic queries in - # retrieve_semantic_bm25_combined() need. + # connection lifetime. The dispatcher returns only safe, portable + # knobs for the configured extension; VectorChord probe tuning is + # index-shaped and should be stored on vchordrq indexes instead. for guc, value in ann_search_tuning_settings(configured_vector_extension(), kind="high_recall"): try: await conn.execute(f"SET {guc} = {value}") @@ -2539,7 +2792,15 @@ async def _init_connection(conn: asyncpg.Connection) -> None: self._parser_registry = FileParserRegistry() try: - self._parser_registry.register(MarkitdownParser()) + self._parser_registry.register( + MarkitdownParser( + ocr_enabled=config.file_parser_markitdown_ocr_enabled, + ocr_api_key=config.file_parser_markitdown_ocr_api_key, + ocr_base_url=config.file_parser_markitdown_ocr_base_url, + ocr_model=config.file_parser_markitdown_ocr_model, + ocr_prompt=config.file_parser_markitdown_ocr_prompt, + ) + ) logger.debug("Registered markitdown parser") except ImportError: logger.warning("markitdown not available - file parsing disabled") @@ -2578,6 +2839,9 @@ async def _init_connection(conn: asyncpg.Connection) -> None: global_webhooks=webhook_global, tenant_extension=self._tenant_extension, ) + # Propagate the now-ready webhook manager to the extension context so + # that the Memory Defense extension can fire webhooks. + self._ext_ctx.webhook_manager = self._webhook_manager logger.debug("Webhook manager initialized") # Long-lived HTTP client for webhook delivery tasks @@ -2587,11 +2851,13 @@ async def _init_connection(conn: asyncpg.Connection) -> None: self._task_backend.set_executor(self.execute_task) await self._task_backend.initialize() - # Start audit log retention sweep (if configured) - self._audit_logger.start_retention_sweep() + # Start the background maintenance loop: cross-tenant retention sweeps + # (audit_log, llm_requests) plus the consolidation reconcile that + # re-schedules banks with eligible-but-unscheduled facts. + from .maintenance import MaintenanceLoop - # Start LLM trace retention sweep (if configured) - self._llm_recorder.start_retention_sweep() + self._maintenance_loop = MaintenanceLoop(self) + self._maintenance_loop.start() self._initialized = True logger.info("Memory system initialized (pool and task backend started)") @@ -2676,11 +2942,11 @@ async def close(self): """Close the connection pool and shutdown background workers.""" logger.info("close() started") - # Stop audit log retention sweep - await self._audit_logger.stop_retention_sweep() + # Stop the background maintenance loop (retention sweeps + reconcile) + if self._maintenance_loop is not None: + await self._maintenance_loop.stop() - # Stop LLM trace retention sweep and unregister the recorder - await self._llm_recorder.stop_retention_sweep() + # Unregister the LLM trace recorder span hook from ..tracing import unregister_span_recorder unregister_span_recorder(self._llm_recorder) @@ -2789,6 +3055,7 @@ def retain( ctx = request_context if request_context is not None else RC() return asyncio.run(self.retain_async(bank_id, content, context, event_date, request_context=ctx)) + @_bind_bank_id() async def retain_async( self, bank_id: str, @@ -2835,6 +3102,7 @@ async def retain_async( # Return the first (and only) list of unit IDs return result[0] if result else [] + @_bind_bank_id() async def retain_batch_async( self, bank_id: str, @@ -2921,7 +3189,7 @@ async def retain_batch_async( ) result = await self._validate_operation(self._operation_validator.validate_retain(ctx)) if result and result.contents is not None: - contents = result.contents + contents = cast(list[RetainContentDict], result.contents) # Engine-owned copy: the orchestrator clears per-item "content" strings # after building the document's combined text (memory pressure @@ -2964,6 +3232,19 @@ async def retain_batch_async( if item.get("update_mode") == "append" and not item.get("document_id"): raise ValueError("update_mode='append' requires a document_id") + # Append mode rebuilds the full document by reading back the previously + # stored original_text and prepending it. With store_document_text + # disabled there is no stored text to read, so the append would silently + # drop all prior content — reject it explicitly instead. + if not get_config().store_document_text: + for item in contents: + if item.get("update_mode") == "append": + raise ValueError( + "update_mode='append' is not supported when HINDSIGHT_API_STORE_DOCUMENT_TEXT " + "is disabled: the prior document text is not stored and cannot be appended to. " + "Use update_mode='replace' instead." + ) + # 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) @@ -3018,7 +3299,7 @@ async def retain_batch_async( # with, so the offsets match the chunk_index values it assigns. from .retain import fact_extraction, fact_storage - sub_chunk_size = await self._resolve_retain_chunk_size(bank_id, request_context, strategy) + chunking_config = await self._resolve_retain_chunking_config(bank_id, request_context, strategy) chunk_offsets: dict[str, int] = {} # In update_mode="append", retain_batch prepends the existing document @@ -3041,7 +3322,11 @@ async def retain_batch_async( existing_text = await fact_storage.get_document_content(conn, bank_id, append_doc_id) if existing_text: append_prepend_chunks[append_doc_id] = len( - fact_extraction.chunk_text(existing_text, sub_chunk_size) + fact_extraction.chunk_text( + existing_text, + chunking_config.chunk_size, + structured_chunk_size=chunking_config.structured_chunk_size, + ) ) for i, (sub_batch, sub_origins) in enumerate(zip(sub_batches, origin_indices), 1): @@ -3071,6 +3356,28 @@ async def retain_batch_async( sub_doc_id = document_id or (sub_batch[0].get("document_id") if len(sub_batch) == 1 else None) sub_offset = chunk_offsets.get(sub_doc_id, 0) if sub_doc_id else 0 + # Count the chunks this sub-batch will produce BEFORE handing it + # to the orchestrator. retain_batch consumes (pops) each item's + # "content" while streaming, so reading it back after the call + # yields "" — and chunk_text("") returns [""] (count 1), + # advancing the per-document cursor by 1 regardless of the real + # chunk count. For slices that each span several chunks the next + # sub-batch then restarts ~1 slot in, colliding chunk_ids and + # overwriting earlier chunks (only ~1 new chunk survives per + # sub-batch). Capture it here while content is still present. + sub_chunk_count = 0 + if sub_doc_id: + sub_chunk_count = sum( + len( + fact_extraction.chunk_text( + item.get("content", "") or "", + chunking_config.chunk_size, + structured_chunk_size=chunking_config.structured_chunk_size, + ) + ) + for item in sub_batch + ) + sub_results, sub_usage, sub_processed = await self._retain_batch_async_internal( bank_id=bank_id, contents=sub_batch, @@ -3090,14 +3397,10 @@ async def retain_batch_async( ) # Advance the document's chunk_index cursor by the number of - # chunks this sub-batch produced (computed with the same chunk - # size the orchestrator uses), so the next sub-batch sharing the - # document continues the sequence. + # chunks this sub-batch produced (counted above, before the + # orchestrator consumed the content), so the next sub-batch + # sharing the document continues the sequence. if sub_doc_id: - sub_chunk_count = sum( - len(fact_extraction.chunk_text(item.get("content", "") or "", sub_chunk_size)) - for item in sub_batch - ) # retain_batch only prepends the existing body on the global # first sub-batch (is_first_batch == i == 1), so fold its chunk # count in only there. @@ -3143,6 +3446,8 @@ async def retain_batch_async( # Progress for this path is emitted by the streaming pipeline as # "storing N/total chunks" via progress_callback (see _retain_batch_async_internal). + await self._write_retain_outcome_metadata(operation_id, result) + # Call post-operation hook if validator is configured if self._operation_validator: from hindsight_api.extensions import RetainResult @@ -3204,13 +3509,13 @@ async def _submit_post_insert_maintenance( except Exception as e: logger.warning(f"Failed to submit graph maintenance task for bank {bank_id}: {e}") - async def _resolve_retain_chunk_size( + async def _resolve_retain_chunking_config( self, bank_id: str, request_context: "RequestContext", strategy: str | None, - ) -> int: - """Resolve the effective ``retain_chunk_size`` for a bank. + ) -> _RetainChunkingConfig: + """Resolve the effective retain chunking settings for a bank. Mirrors the bank-config + strategy resolution that ``_retain_batch_async_internal`` applies before handing config to the @@ -3224,7 +3529,10 @@ async def _resolve_retain_chunk_size( effective_strategy = strategy or resolved_config.retain_default_strategy if effective_strategy: resolved_config = apply_strategy(resolved_config, effective_strategy) - return getattr(resolved_config, "retain_chunk_size", 3000) + return _RetainChunkingConfig( + chunk_size=getattr(resolved_config, "retain_chunk_size", 3000), + structured_chunk_size=getattr(resolved_config, "retain_structured_chunk_size", None), + ) async def _retain_batch_async_internal( self, @@ -3267,7 +3575,7 @@ async def _retain_batch_async_internal( # Use the new modular orchestrator from .retain import orchestrator - backend = await self._get_backend() + await self._get_backend() # Resolve bank-specific config for this operation resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context) @@ -3310,6 +3618,9 @@ async def _retain_batch_async_internal( # Stream chunk-level "storing N/total" progress to the operation row as # the document's chunks commit (more useful than the coarse sub-batch tick). progress_callback=self._write_operation_progress, + webhook_manager=self._webhook_manager, + memory_defense_extension=self._memory_defense, + audit_logger=self._audit_logger, ) # Map the created facts onto this retain's trace so the trace view can # show which memories the ingestion produced. result[0] is the @@ -3400,7 +3711,7 @@ async def import_documents_async( parse_archive(archive_bytes) await self._authenticate_tenant(request_context) - backend = await self._get_backend() + await self._get_backend() # Ensure the bank (and its per-bank vector indexes) exist before inserts. # Import has no single write transaction to join — the archive is written # by a worker later — so the bank is created on its own connection. @@ -3544,6 +3855,7 @@ def recall( ) ) + @_bind_bank_id() async def recall_async( self, bank_id: str, @@ -3614,6 +3926,12 @@ async def recall_async( # Authenticate tenant and set schema in context (for fq_table()) await self._authenticate_tenant(request_context) + # Cooperative cancellation checkpoint: if the client already disconnected + # while this request waited to be scheduled, abort before doing any work + # (issue #2122). Further checkpoints sit at each pipeline stage boundary + # inside _search_with_retries. + request_context.raise_if_cancelled() + # Sanitize the query at ingress: a client may serialize a half-emoji as a # lone UTF-16 surrogate, which crashes downstream logging, the embedder, and # the cross-encoder tokenizer with an HTTP 500 (see issue #1875). Cleaning it @@ -3729,6 +4047,10 @@ async def recall_async( reranking=reranking, ) break # Success - exit retry loop + except OperationCancelledError: + # Client disconnected — propagate to the HTTP layer (499); + # not a failure to retry or report via the post-op hook. + raise except Exception as e: # Check if it's a connection error (PG or Oracle) is_connection_error = ( @@ -3938,6 +4260,11 @@ async def _search_with_retries( tracer.record_query_embedding(query_embedding) tracer.add_phase_metric("generate_query_embedding", step_duration) + # Cancellation checkpoint: bail before the DB-heavy retrieval stage + # if the client has gone away (issue #2122). + if request_context is not None: + request_context.raise_if_cancelled() + # Step 2: Optimized parallel retrieval using batched queries # - Semantic + BM25 combined in 1 CTE query for ALL fact types # - Graph runs per fact type (complex traversal) @@ -3950,9 +4277,6 @@ async def _search_with_retries( retrieve_all_fact_types_parallel, ) - # Track each retrieval start time - retrieval_start = time.time() - retrieval_span = tracer_otel.start_span("hindsight.recall_retrieval") retrieval_span.set_attribute("hindsight.bank_id", bank_id) retrieval_span.set_attribute("hindsight.fact_types", ",".join(fact_type)) @@ -4063,10 +4387,7 @@ async def _search_with_retries( f"graph {pre_cap_counts[2]}->{len(graph_results)}" ) - retrieval_duration = time.time() - retrieval_start - step_duration = time.time() - step_start - total_retrievals = len(fact_type) * (4 if temporal_results else 3) # Format per-method timings timing_parts = [ f"semantic={len(semantic_results)}({aggregated_timings['semantic']:.3f}s)", @@ -4265,6 +4586,15 @@ def to_tuple_format(results): merged_candidates = merged_candidates[:reranker_max_candidates] if reranking == "cross_encoder": + # Cancellation checkpoint: the cross-encoder rerank is the + # single most CPU-expensive stage and runs in a worker thread + # that cannot be interrupted once dispatched (issue #2122). + # Skip it entirely if the client already disconnected during + # retrieval, rather than burning ~2 CPUs producing a result + # nobody will read. + if request_context is not None: + request_context.raise_if_cancelled() + # Ensure reranker is initialized (for lazy initialization mode) await reranker_instance.ensure_initialized() scored_results = await reranker_instance.rerank(query, merged_candidates) @@ -4346,6 +4676,12 @@ def to_tuple_format(results): {"reranker_type": rerank_kind, "candidates_reranked": len(scored_results)}, ) + # Cancellation checkpoint: reranking is done; skip the remaining + # enrichment (chunk/entity/source-fact fetches, each its own DB work) + # if the client disconnected while we were reranking (issue #2122). + if request_context is not None: + request_context.raise_if_cancelled() + # Step 5: Truncate to thinking_budget * 2 for token filtering rerank_limit = thinking_budget * 2 top_scored = scored_results[:rerank_limit] @@ -4731,6 +5067,11 @@ def _make_source_fact(sid: str, r: Any) -> MemoryFact: source_facts=source_facts_dict, ) + except OperationCancelledError: + # Client disconnected mid-recall — propagate the cancellation so the + # HTTP layer can return 499. Must precede the broad handler below, + # which would otherwise bury it inside a RuntimeError (issue #2122). + raise except Exception as e: # Use repr(e) so exceptions with empty __str__ (e.g. raise SomeError()) # still emit a discriminating class+args string into operations.error_message. @@ -4925,6 +5266,12 @@ async def get_document( # document_metadata is sourced from retain_params.metadata document_metadata = retain_params_parsed.get("metadata") if retain_params_parsed else None + # observation_scopes is captured into retain_params at retain time + # (see _build_retain_params); surface it as a top-level field so the + # UI can show which scoping was requested. Only present for documents + # retained after this was added. + observation_scopes = retain_params_parsed.get("observation_scopes") if retain_params_parsed else None + return { "id": doc["id"], "bank_id": doc["bank_id"], @@ -4941,6 +5288,7 @@ async def get_document( "tags": list(doc["tags"]) if doc["tags"] else [], "document_metadata": document_metadata or None, "retain_params": retain_params_parsed or None, + "observation_scopes": observation_scopes or None, } async def delete_document( @@ -5359,6 +5707,12 @@ async def delete_bank( bank_id, fact_type, ) + # Curation archive holds invalidated facts of the same types. + await conn.execute( + f"DELETE FROM {fq_table('invalidated_memory_units')} WHERE bank_id = $1 AND fact_type = $2", + bank_id, + fact_type, + ) if unit_ids: invalidated_obs = await self._delete_stale_observations_for_memories( @@ -5386,6 +5740,12 @@ async def delete_bank( # Delete memory units (cascades to unit_entities, memory_links) await conn.execute(f"DELETE FROM {fq_table('memory_units')} WHERE bank_id = $1", bank_id) + # Curation archive (rows with NULL document_id aren't covered by + # the documents cascade, so clear by bank explicitly). + await conn.execute( + f"DELETE FROM {fq_table('invalidated_memory_units')} WHERE bank_id = $1", bank_id + ) + # Delete entities (cascades to unit_entities, entity_cooccurrences, memory_links with entity_id) await conn.execute(f"DELETE FROM {fq_table('entities')} WHERE bank_id = $1", bank_id) @@ -5478,6 +5838,46 @@ async def clear_observations( return {"deleted_count": count or 0} + async def list_observation_scopes( + self, + bank_id: str, + *, + request_context: "RequestContext", + ) -> dict[str, Any]: + """List the distinct scopes across a bank's observations. + + Every consolidated observation lives under a "scope": the exact set of + tags it was consolidated with. This enumerates each distinct scope (tag + order normalized so ``[a, b]`` and ``[b, a]`` collapse) together with the + number of observations in it. The empty list ``[]`` is the "global" scope + of untagged observations. Results are ordered most-populous first. + + Returns: + Dict with ``scopes``: list of ``{"tags": list[str], "count": int}``. + """ + await self._authenticate_tenant(request_context) + if self._operation_validator: + from hindsight_api.extensions import BankReadContext + + ctx = BankReadContext(bank_id=bank_id, operation="list_observation_scopes", request_context=request_context) + await self._validate_operation(self._operation_validator.validate_bank_read(ctx)) + backend = await self._get_backend() + async with acquire_with_retry(backend) as conn: + rows = await conn.fetch( + f""" + SELECT scope, COUNT(*) AS count + FROM ( + SELECT COALESCE(ARRAY(SELECT unnest(tags) ORDER BY 1), '{{}}'::text[]) AS scope + FROM {fq_table("memory_units")} + WHERE bank_id = $1 AND fact_type = 'observation' + ) s + GROUP BY scope + ORDER BY count DESC, scope + """, + bank_id, + ) + return {"scopes": [{"tags": list(r["scope"]), "count": r["count"]} for r in rows]} + async def retry_failed_consolidation( self, bank_id: str, @@ -5592,6 +5992,385 @@ async def clear_observations_for_memory( return {"deleted_count": deleted_count} + async def _reembed_memory_text( + self, + *, + text: str, + occurred_start: datetime | None, + occurred_end: datetime | None, + mentioned_at: datetime | None, + entities: list[str], + ) -> str | None: + """Recompute a memory unit's embedding string the same way retain does. + + Mirrors the retain pipeline's date+entity augmentation so an edited or + reverted memory embeds identically to a freshly-retained one. Returns the + pgvector string form (or None if the embedder produced nothing). + """ + from .retain import embedding_processing + from .retain.types import ExtractedFact + + shim = ExtractedFact( + fact_text=text, + fact_type="world", + entities=list(entities or []), + occurred_start=occurred_start, + occurred_end=occurred_end, + mentioned_at=mentioned_at, + ) + augmented = embedding_processing.augment_texts_with_dates([shim], self._format_readable_date) + embeddings = await embedding_processing.generate_embeddings_batch(self.embeddings, augmented) + return str(embeddings[0]) if embeddings else None + + async def _memory_unit_columns(self, conn) -> str: + """Comma-joined, quoted ordinal column list of ``memory_units``. + + Used to move a row verbatim between ``memory_units`` and the curation + archive (``invalidated_memory_units``) without hardcoding the + migration-evolving column set — the archive is created via + ``LIKE memory_units`` so the lists line up. + """ + rows = await conn.fetch( + f"SELECT a.attname FROM pg_attribute a " + f"WHERE a.attrelid = '{fq_table('memory_units')}'::regclass " + f"AND a.attnum > 0 AND NOT a.attisdropped ORDER BY a.attnum" + ) + return ", ".join(f'"{r["attname"]}"' for r in rows) + + async def update_memory_unit( + self, + bank_id: str, + memory_id: str, + *, + text: str | None = None, + context: str | None = None, + occurred_start: str | None = None, + occurred_end: str | None = None, + new_fact_type: str | None = None, + entities: list[str] | None = None, + state: str | None = None, + reason: str | None = None, + request_context: "RequestContext", + ) -> dict[str, Any] | None: + """Curate a single raw memory unit: edit its fields and/or change its state. + + Invalidation keeps the recall hot-path clean by *moving* the row between + tables rather than flagging it: live facts live in ``memory_units``, + invalidated ones in ``invalidated_memory_units``. Recall/consolidation/ + graph queries therefore need no state predicate. + + - **Edit** (``text``/``context``/``occurred_start``/``occurred_end``/ + ``new_fact_type``/``entities``): correct what the LLM extracted. + Re-embeds (text + dates + entities feed the embedding), drops derived + observations + links, and re-consolidates. For date/context fields, + ``""`` clears to NULL and ``None`` leaves unchanged; ``new_fact_type`` + must be world/experience. ``entities`` (when not None) replaces the + unit's entity set: names are resolved/find-or-created via the same + resolver retain uses, ``unit_entities`` + cooccurrence are rebuilt, and + ``[]`` detaches all entities. Entities orphaned by the swap, and any + now-stale cooccurrence rows, are reclaimed by the graph-maintenance + sweep that this edit submits (entity edges live in ``unit_entities``, + not ``memory_links``, so there is nothing to relink directly). + - **Invalidate** (``state='invalidated'``): move the row to the archive + (cascade-pruning its links/entity associations and re-deriving dependent + observations). The archive is cold storage, so the embedding is dropped + (only an entity-id snapshot travels with it). + - **Revert** (``state='valid'``): move the row back, restore its entity + associations, recompute its embedding, and re-consolidate. + + Only ``world``/``experience`` facts can be curated — observations are + derived and regenerate from their sources. Returns the updated memory + (same shape as :meth:`get_memory_unit`) or None if not found. + """ + try: + memory_uuid = uuid.UUID(memory_id) + except ValueError: + raise ValueError(f"Invalid memory_id: '{memory_id}' is not a valid UUID") + if state is not None and state not in ("valid", "invalidated"): + raise ValueError(f"Invalid state '{state}': expected 'valid' or 'invalidated'.") + if text is not None and not text.strip(): + raise ValueError("text must not be empty.") + if new_fact_type is not None and new_fact_type not in ("world", "experience"): + raise ValueError(f"Invalid fact_type '{new_fact_type}': expected 'world' or 'experience'.") + # Normalize the entity list up front: drop blanks/whitespace and de-dup + # case-insensitively (the resolver would coalesce these anyway). A + # provided-but-empty list means "detach all entities"; None means leave + # the unit's entities untouched. + new_entities: list[str] | None = None + if entities is not None: + seen_names: set[str] = set() + new_entities = [] + for name in entities: + cleaned = name.strip() + if cleaned and cleaned.lower() not in seen_names: + seen_names.add(cleaned.lower()) + new_entities.append(cleaned) + + def _parse_edit_date(value: str | None) -> datetime | None: + # "" clears to NULL; an ISO date/datetime parses (UTC if naive). + if not value: + return None + dt = datetime.fromisoformat(value) + return dt if dt.tzinfo else dt.replace(tzinfo=UTC) + + await self._authenticate_tenant(request_context) + if self._operation_validator: + from hindsight_api.extensions import BankWriteContext + + ctx = BankWriteContext(bank_id=bank_id, operation="update_memory_unit", request_context=request_context) + await self._validate_operation(self._operation_validator.validate_bank_write(ctx)) + + backend = await self._get_backend() + from .graph_maintenance import enqueue_relink_victims + from .retain.link_utils import resolve_entities_only + + # Resolve the bank's entity-label taxonomy once when re-resolving entities, + # so corrected entities are matched with the same rules retain uses. + entity_labels = None + if new_entities is not None: + edit_config = await self._config_resolver.resolve_full_config(bank_id, request_context) + entity_labels = getattr(edit_config, "entity_labels", None) + + mu = fq_table("memory_units") + arch = fq_table("invalidated_memory_units") + ue = fq_table("unit_entities") + ml = fq_table("memory_links") + ent = fq_table("entities") + + need_consolidation = False + need_graph = False + found = False + + async with acquire_with_retry(backend) as conn: + async with conn.transaction(): + live = await conn.fetchrow( + f"SELECT text, context, fact_type, event_date, occurred_start, occurred_end, mentioned_at " + f"FROM {mu} WHERE id = $1 AND bank_id = $2", + str(memory_uuid), + bank_id, + ) + archived = None + if not live: + archived = await conn.fetchrow( + f"SELECT fact_type FROM {arch} WHERE id = $1 AND bank_id = $2", + str(memory_uuid), + bank_id, + ) + record = live or archived + if record is None: + return None + found = True + current_fact_type = record["fact_type"] + if current_fact_type not in ("experience", "world"): + raise ValueError( + f"Memory '{memory_id}' is a {current_fact_type}; only world/experience facts can be " + "curated. Observations are derived and regenerate from their sources." + ) + + collist = await self._memory_unit_columns(conn) + # The archive is cold storage, never a recall surface, so the schema gives it + # no `embedding` column at all (dropped in d4f6a8c2e1b3). The move in/out is + # therefore over every memory_units column EXCEPT embedding; on revert the + # embedding is recomputed from the unit's text/dates/entities below. This makes + # a model switch (which re-dimensions memory_units) structurally unable to trip + # a vector-dimension mismatch on the INSERT … SELECT round-trip (#2209). + arch_cols = ", ".join(c for c in (s.strip() for s in collist.split(",")) if c != '"embedding"') + + # --- Edit fields (live rows only): text / context / dates / fact_type / entities --- + doing_edit = any( + v is not None for v in (text, context, occurred_start, occurred_end, new_fact_type) + ) or (new_entities is not None) + if doing_edit: + if not live: + raise ValueError("Cannot edit an invalidated memory; revert it to 'valid' first.") + new_text = text if text is not None else live["text"] + new_context = (context or None) if context is not None else live["context"] + new_fact = new_fact_type if new_fact_type is not None else live["fact_type"] + new_occ_start = ( + _parse_edit_date(occurred_start) if occurred_start is not None else live["occurred_start"] + ) + new_occ_end = _parse_edit_date(occurred_end) if occurred_end is not None else live["occurred_end"] + # event_date (NOT NULL, legacy single date + used by temporal links) + # tracks the occurred start when it's set. + new_event_date = new_occ_start or live["event_date"] + + # Rebuild the unit's entity set FIRST, so the re-embed below picks + # up the corrected canonical names. Reuses retain's resolver + # (find-or-create + cooccurrence) rather than touching entities + # directly. Orphaned entities + stale cooccurrence are swept by + # the graph-maintenance run this edit submits. + if new_entities is not None: + entity_date = new_occ_start or live["mentioned_at"] + _resolved_ids, _e2u, unit_to_entity_ids = await resolve_entities_only( + self.entity_resolver, + conn, + bank_id, + [str(memory_uuid)], + [new_text], + new_context or "", + [entity_date], + [[{"text": name, "type": "CONCEPT"} for name in new_entities]], + entity_labels=entity_labels, + ) + await conn.execute(f"DELETE FROM {ue} WHERE unit_id = $1", str(memory_uuid)) + resolved_for_unit = unit_to_entity_ids.get(str(memory_uuid), []) + if resolved_for_unit: + await self.entity_resolver.link_units_to_entities_batch( + [(str(memory_uuid), eid, entity_date) for eid in resolved_for_unit], + conn=conn, + ) + + ent_rows = await conn.fetch( + f"SELECT e.canonical_name FROM {ue} ue JOIN {ent} e ON ue.entity_id = e.id " + f"WHERE ue.unit_id = $1", + str(memory_uuid), + ) + new_emb = await self._reembed_memory_text( + text=new_text, + occurred_start=new_occ_start, + occurred_end=new_occ_end, + mentioned_at=live["mentioned_at"], + entities=[r["canonical_name"] for r in ent_rows], + ) + await enqueue_relink_victims(conn, bank_id, [memory_id], ops=backend.ops) + await conn.execute( + f""" + UPDATE {mu} + SET text = $3, context = $4, fact_type = $5, occurred_start = $6, + occurred_end = $7, event_date = $8, embedding = $9::vector, + consolidated_at = NULL, consolidation_failed_at = NULL, + edited_at = now(), updated_at = now() + WHERE id = $1 AND bank_id = $2 + """, + str(memory_uuid), + bank_id, + new_text, + new_context, + new_fact, + new_occ_start, + new_occ_end, + new_event_date, + new_emb, + ) + await conn.execute(f"DELETE FROM {ml} WHERE from_unit_id = $1 OR to_unit_id = $1", str(memory_uuid)) + await self._delete_stale_observations_for_memories(conn, bank_id, [memory_id]) + need_consolidation = True + need_graph = True + + # --- Invalidate: move live → archive --- + if state == "invalidated" and live: + entity_ids = [ + r["entity_id"] + for r in await conn.fetch(f"SELECT entity_id FROM {ue} WHERE unit_id = $1", str(memory_uuid)) + ] + # Capture relink victims BEFORE the row (and its links) disappear. + await enqueue_relink_victims(conn, bank_id, [memory_id], ops=backend.ops) + await conn.execute( + f"INSERT INTO {arch} ({arch_cols}, invalidation_reason, invalidated_at, entity_ids) " + f"SELECT {arch_cols}, $2, now(), $3::uuid[] FROM {mu} WHERE id = $1 AND bank_id = $4", + str(memory_uuid), + reason, + entity_ids, + bank_id, + ) + # Cascade prunes unit_entities + memory_links; sweep runs after + # the delete so it also catches a racing observation insert. + await conn.execute(f"DELETE FROM {mu} WHERE id = $1 AND bank_id = $2", str(memory_uuid), bank_id) + await self._delete_stale_observations_for_memories(conn, bank_id, [memory_id]) + need_consolidation = True + need_graph = True + elif state == "invalidated" and archived and reason is not None: + # Already archived — just update the recorded reason. + await conn.execute( + f"UPDATE {arch} SET invalidation_reason = $3 WHERE id = $1 AND bank_id = $2", + str(memory_uuid), + bank_id, + reason, + ) + + # --- Revert: move archive → live --- + elif state == "valid" and archived: + arch_row = await conn.fetchrow( + f"SELECT entity_ids FROM {arch} WHERE id = $1 AND bank_id = $2", str(memory_uuid), bank_id + ) + # The archive has no embedding column (see arch_cols above), so the live + # row's embedding defaults to NULL on the way back and is recomputed below + # once entities are restored. + await conn.execute( + f"INSERT INTO {mu} ({arch_cols}) SELECT {arch_cols} FROM {arch} WHERE id = $1 AND bank_id = $2", + str(memory_uuid), + bank_id, + ) + # Re-consolidate from scratch; links are rebuilt by graph maintenance. + await conn.execute( + f"UPDATE {mu} SET consolidated_at = NULL, consolidation_failed_at = NULL, updated_at = now() " + f"WHERE id = $1 AND bank_id = $2", + str(memory_uuid), + bank_id, + ) + # Restore entity associations for entities that still exist (some may + # have been pruned as orphans after the original move). + if arch_row and arch_row["entity_ids"]: + await conn.execute( + f"INSERT INTO {ue} (unit_id, entity_id) " + f"SELECT $1, eid FROM unnest($2::uuid[]) AS eid " + f"WHERE EXISTS (SELECT 1 FROM {ent} e WHERE e.id = eid AND e.bank_id = $3) " + f"ON CONFLICT DO NOTHING", + str(memory_uuid), + arch_row["entity_ids"], + bank_id, + ) + # Recompute the embedding (the archive doesn't keep one) so the reverted + # unit is searchable again, using the now-current model's dimension and the + # restored entity set — mirroring how an edit re-embeds. + reverted = await conn.fetchrow( + f"SELECT text, occurred_start, occurred_end, mentioned_at FROM {mu} " + f"WHERE id = $1 AND bank_id = $2", + str(memory_uuid), + bank_id, + ) + if reverted: + ent_rows = await conn.fetch( + f"SELECT e.canonical_name FROM {ue} ue JOIN {ent} e ON ue.entity_id = e.id " + f"WHERE ue.unit_id = $1", + str(memory_uuid), + ) + new_emb = await self._reembed_memory_text( + text=reverted["text"], + occurred_start=reverted["occurred_start"], + occurred_end=reverted["occurred_end"], + mentioned_at=reverted["mentioned_at"], + entities=[r["canonical_name"] for r in ent_rows], + ) + if new_emb is not None: + await conn.execute( + f"UPDATE {mu} SET embedding = $3::vector WHERE id = $1 AND bank_id = $2", + str(memory_uuid), + bank_id, + new_emb, + ) + await conn.execute(f"DELETE FROM {arch} WHERE id = $1 AND bank_id = $2", str(memory_uuid), bank_id) + need_consolidation = True + need_graph = True + + if not found: + return None + + if need_consolidation: + config = await self._config_resolver.resolve_full_config(bank_id, request_context) + if config.enable_auto_consolidation: + try: + await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context) + except Exception as e: + logger.warning(f"Failed to submit consolidation after curating memory in bank {bank_id}: {e}") + if need_graph: + try: + await self.submit_async_graph_maintenance(bank_id=bank_id, request_context=request_context) + except Exception as e: + logger.warning(f"Failed to submit graph maintenance after curating memory in bank {bank_id}: {e}") + + return await self.get_memory_unit(bank_id=bank_id, memory_id=memory_id, request_context=request_context) + async def run_consolidation( self, bank_id: str, @@ -5718,6 +6497,10 @@ async def get_graph_data( query_conditions.append(tag_clause.removeprefix("AND ")) param_count += 1 query_params.append(tags) + elif tags_match == "exact": + # Exact match with no tags is the "global" scope: rows that carry no + # tags at all. (Other match modes treat empty tags as "no filter".) + query_conditions.append("(tags IS NULL OR tags = '{}')") where_clause = "WHERE " + " AND ".join(query_conditions) if query_conditions else "" @@ -5758,9 +6541,10 @@ async def get_graph_data( source_memory_ids.extend(unit["source_memory_ids"]) source_memory_ids = list(set(source_memory_ids)) # Deduplicate - # Fetch non-entity links where BOTH endpoints are in the visible set (or - # source memories). Entity edges are derived below from unit_entities so - # we don't materialize them in memory_links anymore. + # Fetch links where BOTH endpoints are in the visible set (or source + # memories). Entity edges are derived below from unit_entities so we + # don't materialize them in memory_links anymore (dropped in migration + # e9b2c7d1f3a4) — no link_type filter is needed. # Cap at 10k edges — the UI can't usefully render more, and uncapped queries # on highly-connected graphs (e.g. 1000 nodes with 500k+ edges) are too slow. max_edges = 10000 @@ -5774,8 +6558,7 @@ async def get_graph_data( ml.weight, NULL::text AS entity_name FROM {fq_table("memory_links")} ml - WHERE ml.link_type <> 'entity' - AND ml.from_unit_id = ANY($1::uuid[]) + WHERE ml.from_unit_id = ANY($1::uuid[]) AND ml.to_unit_id = ANY($1::uuid[]) ORDER BY ml.weight DESC NULLS LAST LIMIT $2 @@ -6052,6 +6835,84 @@ async def get_graph_data( return {"nodes": nodes, "edges": edges, "table_rows": table_rows, "total_units": total_count, "limit": limit} + # Prompt-affecting settings overridable per dry-run extraction call. + _EXTRACTION_OVERRIDE_FIELDS = frozenset( + { + "retain_mission", + "retain_extraction_mode", + "retain_custom_instructions", + "retain_extract_causal_links", + "retain_chunk_size", + "entity_labels", + "entities_allow_free_form", + "llm_output_language", + } + ) + + async def extract_dry_run( + self, + bank_id: str, + content: str, + *, + context: str = "", + event_date: "datetime | None" = None, + overrides: dict | None = None, + agent_name: str | None = None, + request_context: "RequestContext", + ) -> "DryRunExtractionResult": + """Run fact extraction ONLY — no entity resolution, links, embeddings, or persistence. + + Returns candidate facts (a subset of the ``list_memory_units`` item shape) plus the LLM token + usage, so callers can diff a mission's extraction output against stored memories without + mutating the bank. Every prompt-affecting setting is overridable per call via ``overrides`` + (e.g. to test a candidate retain mission); ``agent_name`` overrides the narrator. + Side-effect-free and idempotent. + """ + from .response_models import ExtractedFact + from .retain import bank_utils, fact_extraction + + # Resolve the tenant schema before touching any bank-scoped data (config, bank profile). + await self._authenticate_tenant(request_context) + resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context) + if self._llm_config.provider == "none": + resolved_config.retain_extraction_mode = "chunks" + + for key, value in (overrides or {}).items(): + if key not in self._EXTRACTION_OVERRIDE_FIELDS: + raise ValueError( + f"Unsupported extraction override '{key}'. Allowed: {sorted(self._EXTRACTION_OVERRIDE_FIELDS)}" + ) + setattr(resolved_config, key, value) + + backend = await self._get_backend() + # Narrator primes the "Narrator:" line in the prompt — resolve it the same way retain does. + if agent_name is None: + profile = await bank_utils.get_bank_profile(backend, bank_id) + profile_name = profile["name"] if profile else bank_id + agent_name = None if profile_name == bank_id else profile_name + + retain_llm = self._retain_llm_config.with_config(resolved_config, bank_id=bank_id, operation="retain") + facts, _chunks, usage = await fact_extraction.extract_facts_from_text( + text=content, + event_date=event_date, + llm_config=retain_llm, + agent_name=agent_name or "", + config=resolved_config, + context=context, + ) + + extracted = [ + ExtractedFact( + text=fact.fact, + fact_type=fact.fact_type, + occurred_start=fact.occurred_start, + occurred_end=fact.occurred_end, + entities=[e.text for e in (fact.entities or []) if getattr(e, "text", None)], + ) + for fact in facts + ] + return DryRunExtractionResult(facts=extracted, usage=usage) + async def list_memory_units( self, bank_id: str, @@ -6059,6 +6920,8 @@ async def list_memory_units( fact_type: str | None = None, search_query: str | None = None, consolidation_state: str | None = None, + state: str | None = None, + document_id: str | None = None, limit: int = 100, offset: int = 0, request_context: "RequestContext", @@ -6070,6 +6933,10 @@ async def list_memory_units( bank_id: Filter by bank ID fact_type: Filter by fact type (world, experience) search_query: Full-text search query (searches text and context fields) + document_id: Optional filter to a single source document. + state: Optional curation-state filter ('valid' or 'invalidated'). + Invalidated facts live in a separate archive table; 'invalidated' + reads that archive. Omitted/('valid') lists live facts. consolidation_state: Optional filter on consolidation state. One of 'failed' (consolidation permanently failed and awaiting recovery), 'pending' (not yet consolidated, no failure), or @@ -6088,6 +6955,12 @@ async def list_memory_units( ctx = BankReadContext(bank_id=bank_id, operation="list_memory_units", request_context=request_context) await self._validate_operation(self._operation_validator.validate_bank_read(ctx)) + if state is not None and state not in ("valid", "invalidated"): + raise ValueError(f"Invalid state '{state}': expected 'valid' or 'invalidated'.") + # Invalidated facts live in a separate archive table; pick the source + # accordingly. Default (state is None) lists live facts. + is_archived = state == "invalidated" + source_table = fq_table("invalidated_memory_units") if is_archived else fq_table("memory_units") backend = await self._get_backend() async with acquire_with_retry(backend) as conn: # Build query conditions @@ -6105,6 +6978,11 @@ async def list_memory_units( query_conditions.append(f"fact_type = ${param_count}") query_params.append(fact_type) + if document_id: + param_count += 1 + query_conditions.append(f"document_id = ${param_count}") + query_params.append(document_id) + if search_query: # Full-text search on text and context fields using ILIKE param_count += 1 @@ -6134,7 +7012,7 @@ async def list_memory_units( # Get total count count_query = f""" SELECT COUNT(*) as total - FROM {fq_table("memory_units")} + FROM {source_table} {where_clause} """ count_result = await conn.fetchrow(count_query, *query_params) @@ -6149,10 +7027,18 @@ async def list_memory_units( offset_param = f"${param_count}" query_params.append(offset) + # The archive carries invalidation bookkeeping; the live table doesn't. + curation_cols = ( + "invalidation_reason, invalidated_at" + if is_archived + else "NULL::text AS invalidation_reason, NULL::timestamptz AS invalidated_at" + ) units = await conn.fetch( f""" - SELECT id, text, event_date, context, fact_type, mentioned_at, occurred_start, occurred_end, chunk_id, proof_count, tags, consolidated_at, consolidation_failed_at - FROM {fq_table("memory_units")} + SELECT id, text, event_date, context, fact_type, document_id, + mentioned_at, occurred_start, occurred_end, chunk_id, proof_count, + tags, consolidated_at, consolidation_failed_at, edited_at, {curation_cols} + FROM {source_table} {where_clause} ORDER BY mentioned_at DESC NULLS LAST, created_at DESC LIMIT {limit_param} OFFSET {offset_param} @@ -6198,6 +7084,7 @@ async def list_memory_units( "context": row["context"] if row["context"] else "", "date": row["event_date"].isoformat() if row["event_date"] else "", "fact_type": row["fact_type"], + "document_id": row["document_id"], "mentioned_at": row["mentioned_at"].isoformat() if row["mentioned_at"] else None, "occurred_start": row["occurred_start"].isoformat() if row["occurred_start"] else None, "occurred_end": row["occurred_end"].isoformat() if row["occurred_end"] else None, @@ -6209,6 +7096,10 @@ async def list_memory_units( "consolidation_failed_at": ( row["consolidation_failed_at"].isoformat() if row["consolidation_failed_at"] else None ), + "state": "invalidated" if is_archived else "valid", + "invalidation_reason": row["invalidation_reason"], + "invalidated_at": row["invalidated_at"].isoformat() if row["invalidated_at"] else None, + "edited_at": row["edited_at"].isoformat() if row["edited_at"] else None, } ) @@ -6246,18 +7137,29 @@ async def get_memory_unit( await self._validate_operation(self._operation_validator.validate_bank_read(ctx)) backend = await self._get_backend() async with acquire_with_retry(backend) as conn: - # Get the memory unit (include source_memory_ids for mental models) + # Get the memory unit (include source_memory_ids for mental models). + # Curation moves invalidated facts to invalidated_memory_units, so fall + # back to the archive (with its invalidation bookkeeping) on a miss. + select_cols = ( + "id, text, context, event_date, occurred_start, occurred_end, " + "mentioned_at, fact_type, document_id, chunk_id, tags, source_memory_ids, " + "observation_scopes, edited_at" + ) row = await conn.fetchrow( - f""" - SELECT id, text, context, event_date, occurred_start, occurred_end, - mentioned_at, fact_type, document_id, chunk_id, tags, source_memory_ids, - observation_scopes - FROM {fq_table("memory_units")} - WHERE id = $1 AND bank_id = $2 - """, + f"SELECT {select_cols}, NULL::text AS invalidation_reason, NULL::timestamptz AS invalidated_at " + f"FROM {fq_table('memory_units')} WHERE id = $1 AND bank_id = $2", str(memory_uuid), bank_id, ) + unit_state = "valid" + if not row: + row = await conn.fetchrow( + f"SELECT {select_cols}, invalidation_reason, invalidated_at " + f"FROM {fq_table('invalidated_memory_units')} WHERE id = $1 AND bank_id = $2", + str(memory_uuid), + bank_id, + ) + unit_state = "invalidated" if not row: return None @@ -6285,6 +7187,10 @@ async def get_memory_unit( "chunk_id": str(row["chunk_id"]) if row["chunk_id"] else None, "tags": row["tags"] if row["tags"] else [], "observation_scopes": row["observation_scopes"] if row["observation_scopes"] else None, + "state": unit_state, + "invalidation_reason": row["invalidation_reason"], + "invalidated_at": row["invalidated_at"].isoformat() if row["invalidated_at"] else None, + "edited_at": row["edited_at"].isoformat() if row["edited_at"] else None, } # For observations, include source_memory_ids @@ -7287,25 +8193,15 @@ async def get_bank_profile( # 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) - mission = config_dict.get("reflect_mission") or profile["mission"] - - # Overlay disposition from config if explicitly set; fall back to DB values db_disp = profile["disposition"] db_disp_dict = db_disp.model_dump() if hasattr(db_disp, "model_dump") else dict(db_disp) - cfg_skep = config_dict.get("disposition_skepticism") - cfg_lit = config_dict.get("disposition_literalism") - cfg_emp = config_dict.get("disposition_empathy") - disposition = { - "skepticism": cfg_skep if cfg_skep is not None else db_disp_dict["skepticism"], - "literalism": cfg_lit if cfg_lit is not None else db_disp_dict["literalism"], - "empathy": cfg_emp if cfg_emp is not None else db_disp_dict["empathy"], - } + resolved = _overlay_bank_config_disposition_mission(db_disp_dict, profile["mission"], config_dict) return { "bank_id": bank_id, "name": profile["name"], - "disposition": disposition, - "mission": mission, + "disposition": resolved.disposition, + "mission": resolved.mission, } async def _ensure_bank_exists( @@ -7437,7 +8333,7 @@ async def update_bank_disposition( bank_id=bank_id, operation="update_bank_disposition", request_context=request_context ) await self._validate_operation(self._operation_validator.validate_bank_write(ctx)) - backend = await self._get_backend() + await self._get_backend() await bank_utils.update_bank_disposition(self._backend, bank_id, disposition) async def set_bank_mission( @@ -7464,7 +8360,7 @@ async def set_bank_mission( ctx = BankWriteContext(bank_id=bank_id, operation="set_bank_mission", request_context=request_context) await self._validate_operation(self._operation_validator.validate_bank_write(ctx)) - backend = await self._get_backend() + await self._get_backend() await bank_utils.set_bank_mission(self._backend, bank_id, mission) return {"bank_id": bank_id, "mission": mission} @@ -7493,7 +8389,7 @@ async def merge_bank_mission( ctx = BankWriteContext(bank_id=bank_id, operation="merge_bank_mission", request_context=request_context) await self._validate_operation(self._operation_validator.validate_bank_write(ctx)) - backend = await self._get_backend() + await self._get_backend() return await bank_utils.merge_bank_mission(self._backend, self._reflect_llm_config, bank_id, new_info) async def list_banks( @@ -7511,7 +8407,7 @@ async def list_banks( List of dicts with bank_id, name, disposition, mission, created_at, updated_at """ await self._authenticate_tenant(request_context) - backend = await self._get_backend() + await self._get_backend() banks = await bank_utils.list_banks(self._backend) if self._operation_validator: from hindsight_api.extensions import BankListContext @@ -7520,6 +8416,17 @@ async def list_banks( BankListContext(banks=banks, request_context=request_context) ) banks = result.banks + # Overlay resolved bank config (reflect_mission + disposition_*) on top of the + # legacy banks.disposition / banks.mission columns, mirroring get_bank_profile so + # the list and get paths return identical disposition + mission for a bank. + # Resolve every bank's config in one batch (single config-column query + a single + # tenant-config resolve) rather than one round-trip per bank. + configs = await self._config_resolver.get_bank_configs([bank["bank_id"] for bank in banks], request_context) + for bank in banks: + resolved = _overlay_bank_config_disposition_mission( + bank["disposition"], bank["mission"], configs.get(bank["bank_id"], {}) + ) + bank["disposition"], bank["mission"] = resolved.disposition, resolved.mission return banks # ==================== Reflect Methods ==================== @@ -7551,12 +8458,15 @@ async def reflect_async( """ Reflect and formulate an answer using an agentic loop with tools. - The reflect agent iteratively uses tools to: + The reflect agent iteratively uses read-only tools to: 1. lookup: Get mental models (synthesized knowledge) 2. recall: Search facts (semantic + temporal retrieval) - 3. learn: Create/update mental models with new insights + 3. search observations: Retrieve prior observations 4. expand: Get chunk/document context for memories + Reflect is read-only: it synthesizes an answer from the bank's stored + memories and persists nothing. + The agent starts with empty context and must call tools to gather information. On the last iteration, tools are removed to force a final text response. @@ -7600,6 +8510,12 @@ async def reflect_async( # Authenticate tenant and set schema in context (for fq_table()) await self._authenticate_tenant(request_context) + # Cooperative cancellation checkpoint: if the client already disconnected + # while this request waited to be scheduled, abort before doing any work + # (issue #2122). The agentic loop re-checks between iterations, and the + # nested recall tool checks at its own stage boundaries. + request_context.raise_if_cancelled() + # Validate operation if validator is configured if self._operation_validator: from hindsight_api.extensions import ReflectContext @@ -7685,6 +8601,11 @@ async def search_mental_models_fn(q: str, max_results: int = 5) -> dict[str, Any if recall_include_chunks is not None else config_dict.get("recall_include_chunks", DEFAULT_RECALL_INCLUDE_CHUNKS) ) + # With document text storage disabled there is no raw chunk text, so + # fetching chunks would only attach empty strings to every recall + # result. Force it off (pairs with excluding the expand tool below). + if not get_config().store_document_text: + effective_recall_include_chunks = False effective_recall_max_tokens = ( recall_max_tokens_override if recall_max_tokens_override is not None @@ -7807,6 +8728,7 @@ async def expand_fn(memory_ids: list[str], depth: str) -> dict[str, Any]: budget=effective_budget, max_context_tokens=max_context_tokens, llm_output_language=getattr(resolved_reflect_config, "llm_output_language", None), + cancel_check=request_context.raise_if_cancelled, ), timeout=wall_timeout, ) @@ -8430,11 +9352,16 @@ async def _compute_bank_stats(self, bank_id: str) -> dict[str, Any]: # per-fact-type slice, and it tolerates empty maps (the section # prints with no rows). Response keys are kept populated below for # schema stability so existing SDK deserializers don't break. + # No link_type filter: entity edges are no longer stored in + # memory_links (dropped in migration e9b2c7d1f3a4 — derived on demand + # from unit_entities), so only temporal/semantic/caused_by rows exist + # here. Omitting the predicate lets the (bank_id, link_type) index + # serve this bank-scoped GROUP BY as an index-only scan. non_entity_link_rows = await conn.fetch( f""" SELECT link_type, COUNT(*) as count FROM {fq_table("memory_links")} - WHERE bank_id = $1 AND link_type <> 'entity' + WHERE bank_id = $1 GROUP BY link_type """, bank_id, @@ -8568,6 +9495,68 @@ async def get_bank_freshness( "failed_consolidation": row["failed"] or 0, } + async def _probe_llm(self, llm: Any) -> _LlmProbeOutcome: + """Probe one LLM client (status only). The detailed provider error is logged + server-side, never returned, so the probe leaks nothing about the LLM config.""" + # NoneLLM.verify_connection() is a no-op that succeeds, so detect "no LLM" by + # the provider name rather than the probe result. + if llm.provider == "none": + return _LlmProbeOutcome(ok=False, status="not_configured", latency_ms=None) + start = time.monotonic() + try: + await asyncio.wait_for(llm.verify_connection(), timeout=_LLM_PROBE_TIMEOUT_SECONDS) + return _LlmProbeOutcome(ok=True, status="connected", latency_ms=(time.monotonic() - start) * 1000) + except (TimeoutError, asyncio.TimeoutError): + return _LlmProbeOutcome(ok=False, status="timeout", latency_ms=(time.monotonic() - start) * 1000) + except Exception as e: + logger.warning("LLM connectivity probe failed (provider=%s): %s", llm.provider, e) + # A bad API key is the most common cause, so surface it distinctly (the + # category leaks nothing — the raw error is only logged above). + status = "auth_failed" if _is_auth_error(e) else "unreachable" + return _LlmProbeOutcome(ok=False, status=status, latency_ms=(time.monotonic() - start) * 1000) + + async def check_bank_llm( + self, + bank_id: str, + *, + request_context: "RequestContext", + ) -> BankLlmHealthInfo: + """Probe the LLMs this bank would use for retain / consolidation / reflect (#2034). + + Deliberate, non-polled connectivity test so callers discover "not configured / + unreachable" instead of a silent stall. Each operation can resolve to a different + LLM, but they often share one; identical configs are probed **once** (keyed on + provider/model/base_url/api_key) and the result fanned out. Returns status only — + never the provider/model/endpoint or the raw error (those are logged server-side). + """ + await self._authenticate_tenant(request_context) + if self._operation_validator: + from hindsight_api.extensions import BankReadContext + + ctx = BankReadContext(bank_id=bank_id, operation="get_bank_stats", request_context=request_context) + await self._validate_operation(self._operation_validator.validate_bank_read(ctx)) + + per_operation_llm = [ + ("retain", self._retain_llm_config), + ("consolidation", self._consolidation_llm_config), + ("reflect", self._reflect_llm_config), + ] + # Dedup key includes api_key so two ops with the same provider/model/url but + # different keys are still probed separately. Keys never leave this method. + probed: dict[tuple, _LlmProbeOutcome] = {} + operations: list[LlmOperationHealthInfo] = [] + for operation, llm in per_operation_llm: + key = (llm.provider, llm.model, llm.base_url, llm.api_key) + if key not in probed: + probed[key] = await self._probe_llm(llm) + outcome = probed[key] + operations.append( + LlmOperationHealthInfo( + operation=operation, ok=outcome.ok, status=outcome.status, latency_ms=outcome.latency_ms + ) + ) + return BankLlmHealthInfo(bank_id=bank_id, operations=operations) + async def get_memories_timeseries( self, bank_id: str, @@ -8714,87 +9703,6 @@ async def get_entity( "observations": [], } - def _parse_observations(self, observations_raw: list): - """Parse raw observation dicts into typed Observation models. - - Returns list of Observation models with computed trend/evidence_span/evidence_count. - """ - from .reflect.observations import Observation, ObservationEvidence - - observations: list[Observation] = [] - for obs in observations_raw: - if not isinstance(obs, dict): - continue - - try: - parsed = Observation( - title=obs.get("title", ""), - content=obs.get("content", ""), - evidence=[ - ObservationEvidence( - memory_id=ev.get("memory_id", ""), - quote=ev.get("quote", ""), - relevance=ev.get("relevance", ""), - timestamp=ev.get("timestamp"), - ) - for ev in obs.get("evidence", []) - if isinstance(ev, dict) - ], - created_at=obs.get("created_at"), - ) - observations.append(parsed) - except Exception as e: - logger.warning(f"Failed to parse observation: {e}") - continue - - return observations - - async def _count_memories_since( - self, - bank_id: str, - since_timestamp: str | None, - backend=None, - ) -> int: - """ - Count memories created after a given timestamp. - - Args: - bank_id: Bank identifier - since_timestamp: ISO timestamp string. If None, returns total count. - backend: Optional database backend (uses default if not provided) - - Returns: - Number of memories created since the timestamp - """ - if backend is None: - backend = await self._get_backend() - - async with acquire_with_retry(backend) as conn: - if since_timestamp: - # Parse the timestamp - from datetime import datetime - - try: - ts = datetime.fromisoformat(since_timestamp.replace("Z", "+00:00")) - except ValueError: - # Invalid timestamp, return total count - ts = None - - if ts: - count = await conn.fetchval( - f"SELECT COUNT(*) FROM {fq_table('memory_units')} WHERE bank_id = $1 AND created_at > $2", - bank_id, - ts, - ) - return count or 0 - - # No timestamp or invalid, return total count - count = await conn.fetchval( - f"SELECT COUNT(*) FROM {fq_table('memory_units')} WHERE bank_id = $1", - bank_id, - ) - return count or 0 - async def _delete_stale_observations_for_memories( self, conn, @@ -8811,149 +9719,6 @@ async def _delete_stale_observations_for_memories( return await delete_stale_observations_for_memories(conn, bank_id, fact_ids, ops=self._backend.ops) - # ========================================================================= - # MENTAL MODELS (CONSOLIDATED) - Read-only access to auto-consolidated mental models - # ========================================================================= - - async def list_mental_models_consolidated( - self, - bank_id: str, - *, - tags: list[str] | None = None, - tags_match: str = "any", - limit: int = 100, - offset: int = 0, - request_context: "RequestContext", - ) -> list[dict[str, Any]]: - """List auto-consolidated observations for a bank. - - Observations are stored in memory_units with fact_type='observation'. - They are automatically created and updated by the consolidation engine. - - Args: - bank_id: Bank identifier - tags: Optional tags to filter by - tags_match: How to match tags - 'any', 'all', or 'exact' - limit: Maximum number of results - offset: Offset for pagination - request_context: Request context for authentication - - Returns: - List of observation dicts - """ - await self._authenticate_tenant(request_context) - backend = await self._get_backend() - - async with acquire_with_retry(backend) as conn: - # Build tag filter - tag_filter = "" - params: list[Any] = [bank_id, limit, offset] - if tags: - if tags_match == "all": - tag_filter = " AND tags @> $4::varchar[]" - elif tags_match == "exact": - tag_filter = " AND tags = $4::varchar[]" - else: # any - tag_filter = " AND tags && $4::varchar[]" - params.append(tags) - - rows = await conn.fetch( - f""" - SELECT id, bank_id, text, proof_count, tags, source_memory_ids, created_at, updated_at - FROM {fq_table("memory_units")} - WHERE bank_id = $1 AND fact_type = 'observation' {tag_filter} - ORDER BY updated_at DESC NULLS LAST - LIMIT $2 OFFSET $3 - """, - *params, - ) - - return [self._row_to_observation_consolidated(row) for row in rows] - - async def get_observation_consolidated( - self, - bank_id: str, - observation_id: str, - *, - include_source_memories: bool = True, - request_context: "RequestContext", - ) -> dict[str, Any] | None: - """Get a single observation by ID. - - Args: - bank_id: Bank identifier - observation_id: Observation ID - include_source_memories: Whether to include full source memory details - request_context: Request context for authentication - - Returns: - Observation dict or None if not found - """ - await self._authenticate_tenant(request_context) - backend = await self._get_backend() - - async with acquire_with_retry(backend) as conn: - row = await conn.fetchrow( - f""" - SELECT id, bank_id, text, proof_count, tags, source_memory_ids, created_at, updated_at - FROM {fq_table("memory_units")} - WHERE bank_id = $1 AND id = $2 AND fact_type = 'observation' - """, - bank_id, - observation_id, - ) - - if not row: - return None - - result = self._row_to_observation_consolidated(row) - - # Fetch source memories if requested and source_memory_ids exist - if include_source_memories and result.get("source_memory_ids"): - source_ids = [uuid.UUID(sid) if isinstance(sid, str) else sid for sid in result["source_memory_ids"]] - source_rows = await conn.fetch( - f""" - SELECT id, text, fact_type, context, occurred_start, mentioned_at - FROM {fq_table("memory_units")} - WHERE id = ANY($1::uuid[]) - ORDER BY mentioned_at DESC NULLS LAST - """, - source_ids, - ) - result["source_memories"] = [ - { - "id": str(r["id"]), - "text": r["text"], - "type": r["fact_type"], - "context": r["context"], - "occurred_start": r["occurred_start"].isoformat() if r["occurred_start"] else None, - "mentioned_at": r["mentioned_at"].isoformat() if r["mentioned_at"] else None, - } - for r in source_rows - ] - - return result - - def _row_to_observation_consolidated(self, row: Any) -> dict[str, Any]: - """Convert a database row to an observation dict.""" - # Convert source_memory_ids to strings - source_memory_ids = row.get("source_memory_ids") or [] - source_memory_ids = [str(sid) for sid in source_memory_ids] - - return { - "id": str(row["id"]), - "bank_id": row["bank_id"], - "text": row["text"], - "proof_count": row["proof_count"] or 1, - # Deprecated inline field — full history via GET .../{id}/history. - "history": [], - "tags": row["tags"] or [], - "source_memory_ids": source_memory_ids, - "source_memories": [], # Populated separately when fetching full details - "created_at": row["created_at"].isoformat() if row["created_at"] else None, - "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None, - } - # ========================================================================= # MENTAL MODELS CRUD # ========================================================================= @@ -9412,6 +10177,13 @@ async def refresh_mental_model( ) based_on_serialized_payload[fact_type] = serialized_facts + # Facts from this reflect only — for the structured-delta LLM prompt. + # Accumulated based_on below is audit/grounding; re-sending all historical + # facts each refresh blows past provider input limits (e.g. Z.ai 1261). + delta_supporting_facts: list[dict[str, Any]] = [] + for _facts in based_on_serialized_payload.values(): + delta_supporting_facts.extend(_facts) + # In delta mode, based_on must accumulate: the mental model is # grounded on ALL facts ever used, not just the latest delta's new # ones. Merge previous based_on with current, deduplicating by id. @@ -9438,8 +10210,8 @@ async def refresh_mental_model( # drift is structurally impossible. Falls back to the full candidate # markdown if either the structuring or the LLM op call fails. from .reflect.delta_ops import ( - DeltaOperationList, apply_operations, + parse_delta_operation_list, ) from .reflect.prompts import ( STRUCTURED_DELTA_SYSTEM_PROMPT, @@ -9474,9 +10246,7 @@ async def refresh_mental_model( current_doc = None if current_doc is not None: - supporting_facts: list[dict[str, Any]] = [] - for _ftype, facts in based_on_serialized_payload.items(): - supporting_facts.extend(facts) + supporting_facts = delta_supporting_facts # No new facts since last refresh — skip the delta LLM call # and preserve existing content unchanged. @@ -9504,7 +10274,7 @@ async def refresh_mental_model( doc_max_tokens = mental_model.get("max_tokens") or 2048 delta_max_tokens = max(2048, int(doc_max_tokens * 1.5)) user_prompt = build_structured_delta_prompt( - current_document_json=current_doc.model_dump_json(indent=2), + current_document_json=current_doc.model_dump_json(), candidate_markdown=reflect_result.text, supporting_facts=supporting_facts, source_query=current_source_query, @@ -9525,19 +10295,7 @@ async def refresh_mental_model( temperature=0.0, scope="mental_model_delta_ops", ) - op_list: DeltaOperationList - if isinstance(raw, DeltaOperationList): - op_list = raw - elif isinstance(raw, dict): - op_list = DeltaOperationList.model_validate(raw) - else: - text = (raw or "").strip() - # Strip optional fenced code block. - if text.startswith("```"): - text = text.split("\n", 1)[1] if "\n" in text else "" - if text.endswith("```"): - text = text[:-3].rstrip() - op_list = DeltaOperationList.model_validate_json(text) + op_list = parse_delta_operation_list(raw) outcome = apply_operations(current_doc, op_list.operations) final_structured = outcome.document final_content = render_document(outcome.document) @@ -10421,10 +11179,6 @@ async def list_operations( # Parent operations have their status updated when all children complete/fail operation_list = [] for row in operations: - # Map DB status to API status (pending includes processing) - db_status = row["status"] - api_status = "pending" if db_status in ("pending", "processing") else db_status - result_metadata = conn.parse_json(row["result_metadata"]) or {} next_retry_at = row["next_retry_at"] @@ -10519,7 +11273,6 @@ async def get_operation_status( child_statuses = [] all_done = True any_failed = False - all_completed = True for child_row in child_rows: raw_crm = child_row["result_metadata"] @@ -10540,9 +11293,6 @@ async def get_operation_status( all_done = False if child_status == "failed": any_failed = True - if child_status != "completed": - all_completed = False - # Self-healing: if parent status is out of sync with children, update it if all_done and api_status == "pending": correct_status = "failed" if any_failed else "completed" diff --git a/hindsight-api-slim/hindsight_api/engine/operation_metadata.py b/hindsight-api-slim/hindsight_api/engine/operation_metadata.py index c1afcf5ffd..e12fbb697a 100644 --- a/hindsight-api-slim/hindsight_api/engine/operation_metadata.py +++ b/hindsight-api-slim/hindsight_api/engine/operation_metadata.py @@ -5,8 +5,10 @@ The metadata is exposed in the API for debugging purposes and may change without notice. """ -from dataclasses import asdict, dataclass -from typing import Any +from dataclasses import asdict, dataclass, field +from typing import Any, Mapping + +MAX_EXTRACTION_ERROR_SAMPLES = 5 @dataclass @@ -48,6 +50,79 @@ def to_dict(self) -> dict[str, Any]: return asdict(self) +@dataclass +class RetainExtractionErrors: + """Non-fatal fact extraction failures observed inside one retain operation.""" + + count: int = 0 + sample: list[str] = field(default_factory=list) + + def add(self, message: str) -> None: + """Record one extraction error while keeping the stored sample bounded.""" + self.count += 1 + if len(self.sample) < MAX_EXTRACTION_ERROR_SAMPLES: + self.sample.append(message[:500]) + + def merge_metadata(self, metadata: Mapping[str, Any]) -> None: + """Merge errors already present on an operation result_metadata object.""" + self.count += int(metadata.get("extraction_errors_count") or 0) + + sample = metadata.get("extraction_errors_sample") or [] + if isinstance(sample, str): + sample = [sample] + if isinstance(sample, list): + for entry in sample: + if isinstance(entry, str) and len(self.sample) < MAX_EXTRACTION_ERROR_SAMPLES: + self.sample.append(entry[:500]) + + def to_dict(self) -> dict[str, Any]: + """Convert to the public result_metadata field shape.""" + data: dict[str, Any] = {"extraction_errors_count": self.count} + if self.sample: + data["extraction_errors_sample"] = self.sample + return data + + +@dataclass +class RetainOutcomeMetadata: + """Machine-readable outcome metadata for a completed retain operation.""" + + unit_ids_count: int + extraction_errors_count: int = 0 + extraction_errors_sample: list[str] = field(default_factory=list) + + def to_dict(self) -> dict[str, Any]: + """Convert to dict for JSON serialization, omitting empty optional samples.""" + data: dict[str, Any] = { + "unit_ids_count": self.unit_ids_count, + "extraction_errors_count": self.extraction_errors_count, + } + if self.extraction_errors_sample: + data["extraction_errors_sample"] = self.extraction_errors_sample[:MAX_EXTRACTION_ERROR_SAMPLES] + return data + + +@dataclass +class RetainOutcomeAggregate: + """Aggregate retain outcome metadata from child retain operations.""" + + unit_ids_count: int = 0 + extraction_errors: RetainExtractionErrors = field(default_factory=RetainExtractionErrors) + + def add_metadata(self, metadata: Mapping[str, Any]) -> None: + """Fold one child operation's result_metadata into the aggregate.""" + self.unit_ids_count += int(metadata.get("unit_ids_count") or 0) + self.extraction_errors.merge_metadata(metadata) + + def to_outcome_metadata(self) -> RetainOutcomeMetadata: + """Return the aggregate in the public result_metadata field shape.""" + return RetainOutcomeMetadata( + unit_ids_count=self.unit_ids_count, + extraction_errors_count=self.extraction_errors.count, + extraction_errors_sample=self.extraction_errors.sample, + ) + + @dataclass class ConsolidationMetadata: """Metadata for consolidation operations.""" diff --git a/hindsight-api-slim/hindsight_api/engine/parsers/markitdown.py b/hindsight-api-slim/hindsight_api/engine/parsers/markitdown.py index 370e5d2a0c..063a1ce063 100644 --- a/hindsight-api-slim/hindsight_api/engine/parsers/markitdown.py +++ b/hindsight-api-slim/hindsight_api/engine/parsers/markitdown.py @@ -3,43 +3,116 @@ import asyncio import logging import tempfile +from dataclasses import dataclass from pathlib import Path +from hindsight_api.config import DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT + from .base import FileParser logger = logging.getLogger(__name__) +@dataclass(frozen=True) +class MarkitdownOcrOptions: + """OpenAI-compatible OCR options passed through to MarkItDown.""" + + # Keep this typed as object so the OpenAI SDK import stays lazy for non-OCR users. + llm_client: object + llm_model: str + llm_prompt: str + + class MarkitdownParser(FileParser): """ Markitdown file parser. Uses Microsoft's markitdown library to convert various file formats - to markdown including PDF, Office docs, images (via OCR), audio, HTML. + to markdown including PDF, Office docs, images with optional OCR, + audio, HTML. Supported formats: - PDF (.pdf) - Word (.docx, .doc) - PowerPoint (.pptx, .ppt) - Excel (.xlsx, .xls) - - Images (.jpg, .jpeg, .png) - with OCR + - Images (.jpg, .jpeg, .png) - optional OCR - HTML (.html, .htm) - Text (.txt, .md) - Audio (.mp3, .wav) - with transcription """ - def __init__(self): + def __init__( + self, + *, + ocr_enabled: bool = False, + ocr_api_key: str | None = None, + ocr_base_url: str | None = None, + ocr_model: str | None = None, + ocr_prompt: str | None = None, + ): """Initialize markitdown parser.""" # Lazy import to avoid requiring markitdown for all users try: from markitdown import MarkItDown - - self._markitdown = MarkItDown() except ImportError as e: raise ImportError( "markitdown package is required for file parsing. Install with: pip install markitdown" ) from e + self._ocr_enabled = ocr_enabled + if ocr_enabled: + ocr_options = self._build_ocr_options( + api_key=ocr_api_key, + base_url=ocr_base_url, + model=ocr_model, + prompt=ocr_prompt, + ) + self._markitdown = MarkItDown( + llm_client=ocr_options.llm_client, + llm_model=ocr_options.llm_model, + llm_prompt=ocr_options.llm_prompt, + ) + else: + self._markitdown = MarkItDown() + + def _build_ocr_options( + self, + *, + api_key: str | None, + base_url: str | None, + model: str | None, + prompt: str | None, + ) -> MarkitdownOcrOptions: + """Build MarkItDown options for OpenAI-compatible image OCR.""" + if not model or not model.strip(): + raise ValueError( + "Markitdown OCR is enabled but no model is configured. " + "Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL to an OpenAI-compatible OCR/vision model " + "with image-input support." + ) + if not api_key: + raise ValueError( + "Markitdown OCR is enabled but no API key is configured. " + "Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY." + ) + if not base_url or not base_url.strip(): + raise ValueError( + "Markitdown OCR is enabled but no base URL is configured. " + "Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL to an OpenAI-compatible OCR/vision endpoint." + ) + + try: + from openai import OpenAI + except ImportError as e: + raise RuntimeError("openai package is required when Markitdown OCR is enabled.") from e + + return MarkitdownOcrOptions( + llm_client=OpenAI(api_key=api_key, base_url=base_url.strip()), + llm_model=model.strip(), + llm_prompt=prompt or DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT, + ) + async def convert(self, file_data: bytes, filename: str) -> str: """Parse file to markdown using markitdown.""" # markitdown is synchronous, so we run it in executor to avoid blocking @@ -48,6 +121,13 @@ async def convert(self, file_data: bytes, filename: str) -> str: def _convert_sync(self, file_data: bytes, filename: str) -> str: """Synchronous parsing (runs in thread pool).""" + if self._is_image_file(filename) and not self._ocr_enabled: + raise RuntimeError( + "Image OCR is not enabled for the markitdown parser. " + "Set HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED=true and configure an OpenAI-compatible " + "OCR/vision endpoint with image-input support, or choose an OCR-capable parser." + ) + # Write to temp file (markitdown requires file path) with tempfile.NamedTemporaryFile(suffix=Path(filename).suffix, delete=False) as tmp: tmp.write(file_data) @@ -73,6 +153,11 @@ def _convert_sync(self, file_data: bytes, filename: str) -> str: except Exception: pass + @staticmethod + def _is_image_file(filename: str) -> bool: + """Return whether the file type needs OCR to extract useful text.""" + return Path(filename).suffix.lower() in {".jpg", ".jpeg", ".png"} + def supports(self, filename: str, content_type: str | None = None) -> bool: """Check if markitdown supports this file type.""" # Supported extensions (from markitdown docs) @@ -85,7 +170,7 @@ def supports(self, filename: str, content_type: str | None = None) -> bool: ".ppt", ".xlsx", ".xls", - # Images (with OCR) + # Images (optional OCR) ".jpg", ".jpeg", ".png", diff --git a/hindsight-api-slim/hindsight_api/engine/providers/anthropic_llm.py b/hindsight-api-slim/hindsight_api/engine/providers/anthropic_llm.py index 679387bbcc..3f5a1f4048 100644 --- a/hindsight-api-slim/hindsight_api/engine/providers/anthropic_llm.py +++ b/hindsight-api-slim/hindsight_api/engine/providers/anthropic_llm.py @@ -14,7 +14,7 @@ import time from typing import Any -from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError +from hindsight_api.engine.llm_interface import LLMInterface from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage from hindsight_api.metrics import get_metrics_collector diff --git a/hindsight-api-slim/hindsight_api/engine/providers/claude_code_llm.py b/hindsight-api-slim/hindsight_api/engine/providers/claude_code_llm.py index ce2272555e..0b8523f94f 100644 --- a/hindsight-api-slim/hindsight_api/engine/providers/claude_code_llm.py +++ b/hindsight-api-slim/hindsight_api/engine/providers/claude_code_llm.py @@ -15,7 +15,7 @@ from pydantic import ValidationError -from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError +from hindsight_api.engine.llm_interface import LLMInterface from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage from hindsight_api.metrics import get_metrics_collector diff --git a/hindsight-api-slim/hindsight_api/engine/providers/codex_auth.py b/hindsight-api-slim/hindsight_api/engine/providers/codex_auth.py index 8893e6be43..6478781802 100644 --- a/hindsight-api-slim/hindsight_api/engine/providers/codex_auth.py +++ b/hindsight-api-slim/hindsight_api/engine/providers/codex_auth.py @@ -60,6 +60,22 @@ ) +def default_codex_auth_file() -> Path: + """Return the path to Codex's ``auth.json``. + + Honors the ``CODEX_HOME`` environment variable — the same variable the + canonical ``@openai/codex`` CLI uses to relocate its config/credentials + directory — and falls back to ``~/.codex`` when it is unset or empty. + + Resolved lazily on each call (rather than cached at import time) so that + the environment is read at the point of use. + """ + codex_home = os.environ.get("CODEX_HOME") + if codex_home: + return Path(codex_home) / "auth.json" + return Path.home() / ".codex" / "auth.json" + + class CodexRefreshExpiredError(RuntimeError): """Raised when the Codex refresh_token itself is no longer valid. @@ -86,7 +102,7 @@ class CodexAuthManager: The OAuth refresh token. May be ``None`` when the auth file omits it; the provider still works as a one-shot loader in that case. auth_file: - Path to ``~/.codex/auth.json``. Used for re-reading the refresh token + Path to the Codex ``auth.json``. Used for re-reading the refresh token on demand and for atomic persistence of rotated credentials. """ @@ -115,7 +131,8 @@ def from_file(cls, auth_file: Path | None = None) -> "CodexAuthManager": Parameters ---------- auth_file: - Defaults to ``~/.codex/auth.json``. + Defaults to ``$CODEX_HOME/auth.json`` (or ``~/.codex/auth.json`` + when ``CODEX_HOME`` is unset). Raises ------ @@ -126,7 +143,7 @@ def from_file(cls, auth_file: Path | None = None) -> "CodexAuthManager": ``auth_mode``. """ if auth_file is None: - auth_file = Path.home() / ".codex" / "auth.json" + auth_file = default_codex_auth_file() if not auth_file.exists(): raise FileNotFoundError(f"Codex auth file not found: {auth_file}. Run 'codex auth login' to authenticate.") diff --git a/hindsight-api-slim/hindsight_api/engine/providers/codex_llm.py b/hindsight-api-slim/hindsight_api/engine/providers/codex_llm.py index 3f94ffc924..802dff26b9 100644 --- a/hindsight-api-slim/hindsight_api/engine/providers/codex_llm.py +++ b/hindsight-api-slim/hindsight_api/engine/providers/codex_llm.py @@ -2,8 +2,9 @@ OpenAI Codex LLM provider using ChatGPT Plus/Pro OAuth authentication. This provider enables using ChatGPT Plus/Pro subscriptions for API calls -without separate OpenAI Platform API credits. It uses OAuth tokens from -~/.codex/auth.json and communicates with the ChatGPT backend API. +without separate OpenAI Platform API credits. It uses OAuth tokens from the +Codex ``auth.json`` (``$CODEX_HOME/auth.json``, or ``~/.codex/auth.json`` when +``CODEX_HOME`` is unset) and communicates with the ChatGPT backend API. Tokens are refreshed automatically: the provider decodes the access_token JWT's ``exp`` claim and proactively refreshes via @@ -24,7 +25,7 @@ import httpx -from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError +from hindsight_api.engine.llm_interface import LLMInterface from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage from hindsight_api.metrics import get_metrics_collector @@ -35,6 +36,7 @@ _CODEX_TOKEN_REFRESH_SKEW_SECONDS, CodexAuthManager, CodexRefreshExpiredError, + default_codex_auth_file, ) # Re-export for backward compatibility (tests import from this module). @@ -55,14 +57,15 @@ class CodexLLM(LLMInterface): """ LLM provider using OpenAI Codex OAuth authentication. - Authenticates using ChatGPT Plus/Pro credentials stored in ~/.codex/auth.json - and makes API calls to chatgpt.com/backend-api/codex/responses. + Authenticates using ChatGPT Plus/Pro credentials stored in the Codex + ``auth.json`` (honoring ``CODEX_HOME``, default ``~/.codex``) and makes API + calls to chatgpt.com/backend-api/codex/responses. """ def __init__( self, provider: str, - api_key: str, # Will be ignored, reads from ~/.codex/auth.json + api_key: str, # Will be ignored, reads from the Codex auth.json (CODEX_HOME or ~/.codex) base_url: str, model: str, reasoning_effort: str = "low", @@ -81,12 +84,14 @@ def __init__( refresh_token = self._load_codex_refresh_token() logger.info(f"Loaded Codex OAuth credentials for account: {account_id}") except Exception as e: + auth_file = default_codex_auth_file() raise RuntimeError( - f"Failed to load Codex OAuth credentials from ~/.codex/auth.json: {e}\n\n" + f"Failed to load Codex OAuth credentials from {auth_file}: {e}\n\n" "To set up Codex authentication:\n" "1. Install Codex CLI: npm install -g @openai/codex\n" "2. Login: codex auth login\n" - "3. Verify: ls ~/.codex/auth.json\n\n" + f"3. Verify: ls {auth_file}\n\n" + "(Set CODEX_HOME to use a credentials directory other than ~/.codex.)\n\n" "Or use a different provider (openai, anthropic, gemini) with API keys." ) from e @@ -94,7 +99,7 @@ def __init__( access_token=access_token, account_id=account_id, refresh_token=refresh_token, - auth_file=Path.home() / ".codex" / "auth.json", + auth_file=default_codex_auth_file(), ) # Use ChatGPT backend API endpoint. Codex auth is tied to @@ -156,7 +161,7 @@ def _auth_file(self, v: Path) -> None: def _load_codex_auth(self) -> tuple[str, str]: """ - Load OAuth credentials from ~/.codex/auth.json. + Load OAuth credentials from the Codex ``auth.json`` (CODEX_HOME or ~/.codex). Returns: Tuple of (access_token, account_id). @@ -165,7 +170,7 @@ def _load_codex_auth(self) -> tuple[str, str]: FileNotFoundError: If auth file doesn't exist. ValueError: If auth file is invalid. """ - auth_file = Path.home() / ".codex" / "auth.json" + auth_file = default_codex_auth_file() if not auth_file.exists(): raise FileNotFoundError( @@ -197,9 +202,7 @@ def _load_codex_refresh_token(self) -> str | None: pre- and post-``__init__`` because it does not depend on ``_auth_manager`` being constructed yet. """ - auth_file = ( - self._auth_manager._auth_file if hasattr(self, "_auth_manager") else Path.home() / ".codex" / "auth.json" - ) + auth_file = self._auth_manager._auth_file if hasattr(self, "_auth_manager") else default_codex_auth_file() return CodexAuthManager.load_refresh_token_from_file(auth_file) @staticmethod @@ -397,7 +400,6 @@ async def call( } url = f"{self.base_url}/codex/responses" - last_exception = None # Manual attempt tracking instead of ``for attempt in range(...)`` so # that the reactive-refresh path can retry once without consuming a @@ -428,7 +430,6 @@ async def call( if attempt < max_retries: backoff = min(initial_backoff * (2**attempt), max_backoff) await asyncio.sleep(backoff) - last_exception = e attempt += 1 continue raise @@ -490,7 +491,6 @@ async def call( return result except httpx.HTTPStatusError as e: - last_exception = e status_code = e.response.status_code # Auth error: try one OAuth refresh + retry before giving up. @@ -549,7 +549,6 @@ async def call( raise except httpx.RequestError as e: - last_exception = e if attempt < max_retries: backoff = min(initial_backoff * (2**attempt), max_backoff) logger.warning(f"Codex connection error (attempt {attempt + 1}/{max_retries + 1}): {e}") @@ -564,10 +563,6 @@ async def call( logger.error(f"Unexpected Codex error: {type(e).__name__}: {e}") raise - if last_exception: - raise last_exception - raise RuntimeError("Codex call failed after all retries") - async def _parse_sse_stream(self, response: httpx.Response) -> str: """ Parse Server-Sent Events (SSE) stream from Codex API. diff --git a/hindsight-api-slim/hindsight_api/engine/providers/gemini_llm.py b/hindsight-api-slim/hindsight_api/engine/providers/gemini_llm.py index ad5c7d05dc..9c2c5cd3a3 100644 --- a/hindsight-api-slim/hindsight_api/engine/providers/gemini_llm.py +++ b/hindsight-api-slim/hindsight_api/engine/providers/gemini_llm.py @@ -8,9 +8,9 @@ import asyncio import base64 +import io import json import logging -import os import time from contextvars import ContextVar from typing import Any @@ -19,7 +19,7 @@ from google.genai import errors as genai_errors from google.genai import types as genai_types -from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError +from hindsight_api.engine.llm_interface import LLMInterface from hindsight_api.engine.llm_wrapper import parse_llm_json from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage from hindsight_api.metrics import get_metrics_collector @@ -35,7 +35,6 @@ # Vertex AI imports (optional) try: - import google.auth from google.oauth2 import service_account VERTEXAI_AVAILABLE = True @@ -43,6 +42,14 @@ VERTEXAI_AVAILABLE = False +def _to_int(value: Any) -> int: + """Coerce Gemini's optional/string completion counts to int, defaulting to 0.""" + try: + return int(value) + except (ValueError, TypeError): + return 0 + + class GeminiLLM(LLMInterface): """ LLM provider for Google Gemini and Vertex AI. @@ -69,6 +76,7 @@ def __init__( # Safety settings: None means use Gemini's defaults self._safety_settings: list | None = kwargs.get("gemini_safety_settings") + self._service_tier: str | None = kwargs.get("gemini_service_tier") # User-configured extra params merged into the GenerateContentConfig of # every call. Gemini's request body nests generation params, so we expose @@ -99,6 +107,16 @@ def _init_gemini(self) -> None: self._client = genai.Client(api_key=self.api_key) logger.info(f"Gemini API: model={self.model}") + def _apply_service_tier(self, config_kwargs: dict[str, Any]) -> None: + if not self._service_tier: + return + + http_options = dict(config_kwargs.get("http_options") or {}) + extra_body = dict(http_options.get("extra_body") or {}) + extra_body.setdefault("service_tier", self._service_tier) + http_options["extra_body"] = extra_body + config_kwargs["http_options"] = http_options + def _init_vertexai(self, **kwargs: Any) -> None: """Initialize Vertex AI client with project, region, and credentials.""" # Extract Vertex AI config from kwargs @@ -266,6 +284,7 @@ async def call( def _build_generation_config(use_cache: bool) -> "genai_types.GenerateContentConfig | None": # Seed with user-configured extra params; explicit settings below win. config_kwargs: dict[str, Any] = dict(self._extra_body) + self._apply_service_tier(config_kwargs) if use_cache: config_kwargs["cached_content"] = cached_prefix elif system_instruction: @@ -597,6 +616,7 @@ async def call_with_tools( def _build_tools_config(use_cache: bool) -> "genai_types.GenerateContentConfig": # Seed with user-configured extra params; explicit settings below win. config_kwargs: dict[str, Any] = dict(self._extra_body) + self._apply_service_tier(config_kwargs) if use_cache: config_kwargs["cached_content"] = cached_prefix else: @@ -826,6 +846,282 @@ async def get_or_create_cached_prefix( tools=tools, ) + # ── Batch API (Gemini API only — not Vertex AI) ───────────────────────── + # + # Google's Gemini Batch API gives a flat 50% discount on input + output + # tokens with a 24h completion SLA (https://ai.google.dev/gemini-api/docs/batch-api). + # The retain orchestrator and ``fact_extraction`` consumer speak the + # OpenAI-batch interface contract, so these overrides translate that shape + # to/from Gemini's file-upload → ``batches.create`` → ``batches.get`` → + # download flow — nothing downstream changes (same pattern as FireworksLLM). + # + # Interface contract preserved (see fact_extraction.py result handling):: + # result["response"]["body"]["choices"][0]["message"]["content"] + + async def supports_batch_api(self) -> bool: + """True for the Gemini API; False for Vertex AI. + + Only ``provider="gemini"`` is supported: it exposes the file-upload + Batch API used below. Vertex AI's batch path is GCS/BigQuery-backed (no + file-upload analogue), so it stays unsupported here — the startup + validation then surfaces a clear error instead of silently falling back + to synchronous, full-price calls. + """ + return self.provider == "gemini" + + async def submit_batch( + self, + requests: list[dict[str, Any]], + endpoint: str = "/v1/chat/completions", + completion_window: str = "24h", + ) -> dict[str, Any]: + """Submit a batch of (OpenAI-shaped) requests to the Gemini Batch API.""" + if not await self.supports_batch_api(): + raise NotImplementedError(f"Batch API not supported for provider: {self.provider}") + + # endpoint/completion_window are part of the shared LLMInterface batch + # contract (used by the OpenAI path) but have no analogue on Gemini: the + # request shape is fixed (generateContent) and the SLA is server-side. + # Kept for signature compatibility with the shared retain driver. + logger.info(f"Submitting Gemini batch with {len(requests)} requests") + + jsonl = self._translate_requests(requests) + + # Upload the JSONL as a Gemini file (mime_type must be "jsonl"; a + # BytesIO has no path for the SDK to infer it from). + file_obj = io.BytesIO(jsonl.encode("utf-8")) + uploaded = await self._client.aio.files.upload( + file=file_obj, + config=genai_types.UploadFileConfig(mime_type="jsonl", display_name="hindsight-batch-input"), + ) + + batch = await self._client.aio.batches.create( + model=self.model, + src=uploaded.name, + config=genai_types.CreateBatchJobConfig(display_name="hindsight-batch"), + ) + + logger.info(f"Gemini batch submitted: {batch.name}, state={self._state_name(batch.state)}") + + return { + "batch_id": batch.name, + "status": self._normalize_state(batch.state), + "input_file_id": uploaded.name, + "request_count": len(requests), + } + + async def get_batch_status(self, batch_id: str) -> dict[str, Any]: + """Get the status of a Gemini batch job, in the shared status shape.""" + if not await self.supports_batch_api(): + raise NotImplementedError(f"Batch API not supported for provider: {self.provider}") + + batch = await self._client.aio.batches.get(name=batch_id) + + stats = batch.completion_stats + successful = _to_int(getattr(stats, "successful_count", None)) if stats else 0 + failed = _to_int(getattr(stats, "failed_count", None)) if stats else 0 + incomplete = _to_int(getattr(stats, "incomplete_count", None)) if stats else 0 + + result: dict[str, Any] = { + "batch_id": batch.name, + "status": self._normalize_state(batch.state), + "request_counts": { + "total": successful + failed + incomplete, + "completed": successful, + "failed": failed, + }, + } + + if batch.dest and getattr(batch.dest, "file_name", None): + result["output_file_id"] = batch.dest.file_name + if batch.error: + result["errors"] = self._error_to_dict(batch.error) + + return result + + async def retrieve_batch_results(self, batch_id: str) -> list[dict[str, Any]]: + """Download and normalize completed Gemini batch results.""" + if not await self.supports_batch_api(): + raise NotImplementedError(f"Batch API not supported for provider: {self.provider}") + + batch = await self._client.aio.batches.get(name=batch_id) + + status = self._normalize_state(batch.state) + if status != "completed": + raise ValueError(f"Gemini batch {batch_id} is not completed yet (state: {self._state_name(batch.state)})") + + dest = batch.dest + if not dest or not getattr(dest, "file_name", None): + raise ValueError( + f"Gemini batch {batch_id} completed but reported no output file " + f"(submit_batch always uses file mode, so this is unexpected)" + ) + + content = await self._client.aio.files.download(file=dest.file_name) + text = content.decode("utf-8") if isinstance(content, (bytes, bytearray)) else str(content) + + # The output is a JSONL error file plus results merged into one stream; + # error lines carry an `error` so partial failures surface per key + # instead of vanishing (JOB_STATE_PARTIALLY_SUCCEEDED maps to completed). + results: list[dict[str, Any]] = [] + for line in text.strip().split("\n"): + if line.strip(): + results.append(self._normalize_output_line(json.loads(line))) + + logger.info(f"Retrieved {len(results)} results for Gemini batch {batch_id}") + return results + + # ----- pure translation/normalization helpers (unit-tested) ---------- + + @staticmethod + def _translate_requests(requests: list[dict[str, Any]]) -> str: + """OpenAI batch requests -> Gemini batch input JSONL. + + Each output line is ``{"key": , "request": }``; + the model is supplied to ``batches.create`` so it is omitted per-line. + """ + lines = [] + for req in requests: + gemini_request = GeminiLLM._openai_body_to_gemini_request(req.get("body") or {}) + lines.append(json.dumps({"key": req.get("custom_id"), "request": gemini_request}, ensure_ascii=False)) + return "\n".join(lines) + + @staticmethod + def _openai_body_to_gemini_request(body: dict[str, Any]) -> dict[str, Any]: + """OpenAI chat-completions body -> Gemini ``GenerateContentRequest`` JSON. + + Mirrors the synchronous ``call`` path: system messages become + ``systemInstruction``; a ``response_format`` json_schema forces JSON + output (``responseMimeType``), appends the schema as a textual hint, and + grammar-enforces via ``responseJsonSchema`` when ``strict`` is set. + """ + system_texts: list[str] = [] + contents: list[dict[str, Any]] = [] + for msg in body.get("messages") or []: + role = msg.get("role", "user") + text = msg.get("content", "") or "" + if role == "system": + system_texts.append(text) + elif role == "assistant": + contents.append({"role": "model", "parts": [{"text": text}]}) + else: + contents.append({"role": "user", "parts": [{"text": text}]}) + + generation_config: dict[str, Any] = {} + if body.get("temperature") is not None: + generation_config["temperature"] = body["temperature"] + if body.get("max_completion_tokens") is not None: + generation_config["maxOutputTokens"] = body["max_completion_tokens"] + + response_format = body.get("response_format") + if isinstance(response_format, dict) and response_format.get("type") == "json_schema": + json_schema = response_format.get("json_schema") or {} + schema = json_schema.get("schema") + generation_config["responseMimeType"] = "application/json" + if schema: + system_texts.append( + "You must respond with valid JSON matching this schema:\n" + json.dumps(schema, ensure_ascii=False) + ) + if json_schema.get("strict"): + generation_config["responseJsonSchema"] = schema + + request: dict[str, Any] = {"contents": contents} + if system_texts: + request["systemInstruction"] = {"parts": [{"text": "\n\n".join(system_texts)}]} + if generation_config: + request["generationConfig"] = generation_config + return request + + @staticmethod + def _normalize_output_line(line: dict[str, Any]) -> dict[str, Any]: + """Gemini batch output line -> OpenAI-batch-output shape. + + Target: ``{"custom_id", "response": {"body": {"choices": [...], "usage": {...}}}, "error"}`` + so the consumer's ``result["response"]["body"]["choices"][0]...`` works and + it can read ``body["usage"]`` for token accounting (the consumer reports + zero usage otherwise). + """ + custom_id = line.get("key") if line.get("key") is not None else line.get("custom_id") + error = line.get("error") + if error: + return {"custom_id": custom_id, "response": None, "error": error} + + response = line.get("response") or {} + body: dict[str, Any] = {"choices": [{"message": {"content": GeminiLLM._extract_text_from_response(response)}}]} + usage = GeminiLLM._usage_from_response(response) + if usage is not None: + body["usage"] = usage + return {"custom_id": custom_id, "response": {"body": body}, "error": None} + + @staticmethod + def _extract_text_from_response(response: dict[str, Any]) -> str: + """Concatenate the text parts of a (JSON) GenerateContentResponse.""" + candidates = response.get("candidates") or [] + if not candidates: + return "" + content = candidates[0].get("content") or {} + parts = content.get("parts") or [] + return "".join(p.get("text", "") for p in parts if isinstance(p, dict) and p.get("text")) + + @staticmethod + def _usage_from_response(response: dict[str, Any]) -> dict[str, Any] | None: + """Gemini ``usageMetadata`` -> OpenAI-shaped ``usage`` block, or None. + + The batch consumer accumulates token usage from ``body["usage"]`` using + OpenAI key names, so translate here to keep the output contract uniform + across providers. Handles both the REST camelCase (downloaded JSONL) and + snake_case spellings defensively. + """ + meta = response.get("usageMetadata") or response.get("usage_metadata") + if not isinstance(meta, dict): + return None + prompt = meta.get("promptTokenCount") or meta.get("prompt_token_count") or 0 + completion = meta.get("candidatesTokenCount") or meta.get("candidates_token_count") or 0 + total = meta.get("totalTokenCount") or meta.get("total_token_count") or 0 + return {"prompt_tokens": prompt, "completion_tokens": completion, "total_tokens": total} + + @staticmethod + def _normalize_state(state: Any) -> str: + """Gemini ``JobState`` -> the retain driver's status strings. + + Unknown / in-flight states map to ``in_progress`` so the driver keeps + polling; ``PARTIALLY_SUCCEEDED`` maps to ``completed`` (per-line errors + surface the partial failures during retrieval). + """ + name = GeminiLLM._state_name(state).upper() + if name in ("JOB_STATE_SUCCEEDED", "JOB_STATE_PARTIALLY_SUCCEEDED"): + return "completed" + if name == "JOB_STATE_FAILED": + return "failed" + if name in ("JOB_STATE_CANCELLED", "JOB_STATE_CANCELLING"): + return "cancelled" + if name == "JOB_STATE_EXPIRED": + return "expired" + return "in_progress" + + @staticmethod + def _state_name(state: Any) -> str: + """Extract the bare ``JOB_STATE_*`` name from a JobState enum or string.""" + if state is None: + return "" + name = getattr(state, "name", None) + if name: + return str(name) + text = str(state) + if "." in text: + text = text.rsplit(".", 1)[-1] + return text + + @staticmethod + def _error_to_dict(error: Any) -> dict[str, Any]: + """Coerce a Gemini JobError into a JSON-serializable dict for logging.""" + if hasattr(error, "model_dump"): + try: + return error.model_dump(exclude_none=True) + except Exception: + pass + return {"message": str(error)} + async def cleanup(self) -> None: """Clean up resources (close connections, etc.).""" # Gemini client doesn't require explicit cleanup diff --git a/hindsight-api-slim/hindsight_api/engine/providers/litellm_llm.py b/hindsight-api-slim/hindsight_api/engine/providers/litellm_llm.py index b0d73a52ef..a92422a3ce 100644 --- a/hindsight-api-slim/hindsight_api/engine/providers/litellm_llm.py +++ b/hindsight-api-slim/hindsight_api/engine/providers/litellm_llm.py @@ -15,9 +15,13 @@ import asyncio import json import logging +import os import time from typing import Any +from litellm.exceptions import Timeout as LiteLLMTimeout + +from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage from hindsight_api.metrics import get_metrics_collector @@ -47,12 +51,15 @@ def __init__( base_url: str, model: str, reasoning_effort: str = "low", - timeout: float = 300.0, + timeout: float | None = None, extra_body: dict[str, Any] | None = None, + bedrock_service_tier: str | None = None, **kwargs: Any, ): super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs) - self.timeout = timeout + # ``None`` falls back to HINDSIGHT_API_LLM_TIMEOUT, then DEFAULT_LLM_TIMEOUT — never None, + # so the hard ``asyncio.wait_for`` backstop in ``call`` is always bounded. + self.timeout = timeout if timeout is not None else float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))) self._litellm: Any = None # User-configured extra params merged as top-level kwargs into every # completion call so LiteLLM normalizes them per-provider (e.g. maps @@ -60,6 +67,7 @@ def __init__( # drops any the target model rejects (litellm.drop_params=True below). # Sourced from llm_extra_body (env: HINDSIGHT_API_LLM_EXTRA_BODY). self._extra_body: dict[str, Any] = extra_body or {} + self.bedrock_service_tier = bedrock_service_tier try: import litellm @@ -119,6 +127,10 @@ def _build_common_kwargs( for key, value in self._extra_body.items(): kwargs.setdefault(key, value) + # Bedrock service tier: flex (50% cheaper), priority, or reserved + if self.model.startswith("bedrock/") and self.bedrock_service_tier is not None: + kwargs["service_tier"] = self.bedrock_service_tier + return kwargs # ── per-model output-tokens cap (shared with Router subclass) ──────────── @@ -203,7 +215,10 @@ async def call( if attempt > 0: set_stage(f"llm.{self._stage_label}.{scope}.attempt={attempt + 1}/{max_retries + 1}") try: - response = await self._acompletion(**call_kwargs) + response = await asyncio.wait_for( + self._acompletion(**call_kwargs), + timeout=self.timeout, + ) content = response.choices[0].message.content or "" finish_reason = response.choices[0].finish_reason @@ -298,6 +313,25 @@ async def call( logger.error(f"LiteLLM returned invalid JSON after {max_retries + 1} attempts") raise + except (TimeoutError, asyncio.TimeoutError, LiteLLMTimeout) as e: + # litellm/httpx don't always honor their own ``timeout=`` (e.g. a connection held + # open with no token progress), so ``wait_for`` is the hard cap that cancels a hung + # call regardless — otherwise one straggler pins a worker slot and stalls its gather. + last_exception = e + exc_name = type(e).__name__ + if attempt < max_retries: + logger.warning( + f"LiteLLM call exceeded timeout={self.timeout}s ({exc_name}, scope={scope}), retrying..." + ) + backoff = min(initial_backoff * (2**attempt), max_backoff) + await asyncio.sleep(backoff) + continue + logger.error( + f"LiteLLM call timed out after {self.timeout}s on {attempt + 1} attempts " + f"({exc_name}, scope={scope})" + ) + raise + except Exception as e: error_str = str(e).lower() # Fast fail on auth errors @@ -348,7 +382,10 @@ async def call_with_tools( if attempt > 0: set_stage(f"llm.{self._stage_label}.tools.attempt={attempt + 1}/{max_retries + 1}") try: - response = await self._acompletion(**call_kwargs) + response = await asyncio.wait_for( + self._acompletion(**call_kwargs), + timeout=self.timeout, + ) message = response.choices[0].message content = message.content @@ -418,6 +455,23 @@ async def call_with_tools( output_tokens=output_tokens, ) + except (TimeoutError, asyncio.TimeoutError, LiteLLMTimeout) as e: + # See ``call`` — hard cap so a hung completion cannot block + # forever and pin a worker slot / concurrency permit. + last_exception = e + exc_name = type(e).__name__ + if attempt < max_retries: + logger.warning( + f"LiteLLM tool call exceeded timeout={self.timeout}s ({exc_name}, scope={scope}), retrying..." + ) + await asyncio.sleep(min(initial_backoff * (2**attempt), max_backoff)) + continue + logger.error( + f"LiteLLM tool call timed out after {self.timeout}s on {attempt + 1} attempts " + f"({exc_name}, scope={scope})" + ) + raise + except Exception as e: error_str = str(e).lower() if "401" in error_str or "403" in error_str or "unauthorized" in error_str: diff --git a/hindsight-api-slim/hindsight_api/engine/providers/litellm_router_llm.py b/hindsight-api-slim/hindsight_api/engine/providers/litellm_router_llm.py index 599557ba32..42e631649a 100644 --- a/hindsight-api-slim/hindsight_api/engine/providers/litellm_router_llm.py +++ b/hindsight-api-slim/hindsight_api/engine/providers/litellm_router_llm.py @@ -67,7 +67,7 @@ def __init__( model: str, config: dict[str, Any], reasoning_effort: str = "low", - timeout: float = 300.0, + timeout: float | None = None, **kwargs: Any, ): super().__init__( diff --git a/hindsight-api-slim/hindsight_api/engine/providers/nous_auth.py b/hindsight-api-slim/hindsight_api/engine/providers/nous_auth.py new file mode 100644 index 0000000000..ed248f121e --- /dev/null +++ b/hindsight-api-slim/hindsight_api/engine/providers/nous_auth.py @@ -0,0 +1,463 @@ +""" +Native Nous Portal OAuth authentication manager. + +The Nous Portal inference endpoint (https://inference-api.nousresearch.com/v1) +speaks the OpenAI-compatible wire format but authenticates with a short-lived, +inference-scoped JWT rather than a static API key. Hermes obtains that JWT once +via an interactive browser login (``hermes portal``) and persists the resulting +OAuth state — ``access_token`` + ``refresh_token`` — under ``providers.nous`` in +``~/.hermes/auth.json``. + +This manager reads that file *directly* and refreshes the access token itself, +exactly mirroring ``codex_auth.py`` (read ``~/.codex/auth.json`` + native +refresh). It deliberately does **not** import the Hermes ``hermes_cli`` package: +that package is the interactive CLI, not a library Hindsight can depend on. The +refresh request shape is mirrored from Hermes' own resolver +(``POST {portal}/api/oauth/token`` with an ``x-nous-refresh-token`` header and a +``grant_type=refresh_token`` form body), so server-side changes affect both +clients identically. The inference bearer is the access token itself — in +Hermes' state the ``agent_key`` field is literally ``= access_token``. + +Single-use refresh tokens +------------------------- +Nous refresh tokens are single-use with server-side reuse-detection: if two +processes refresh with the same ``refresh_token``, or a rotated token is not +persisted back, the Portal revokes the whole session as a theft signal. Because +Hindsight shares ``~/.hermes/auth.json`` with a possibly-running Hermes agent, +every refresh here is performed while holding the **same cross-process advisory +lock Hermes uses** (``~/.hermes/auth.lock`` via ``fcntl.flock``) and re-reads the +latest ``refresh_token`` from disk under that lock before exchanging it. That is +the protocol Hermes follows too, so the two coordinate safely through the file. + +Usage +----- + mgr = NousAuthManager.from_file() + token = mgr.ensure_fresh_token() # proactive; refreshes if near expiry + ... # use token as Bearer + mgr.refresh_tokens(force=True) # reactive, on a 401 +""" + +from __future__ import annotations + +import base64 +import binascii +import contextlib +import json +import logging +import os +import tempfile +import threading +import time +from collections.abc import Iterator +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +import httpx + +try: + import fcntl +except ImportError: # pragma: no cover - Windows + fcntl = None # type: ignore[assignment] + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Constants — mirrored from Hermes' canonical Nous resolver +# (hermes_cli/auth.py: DEFAULT_NOUS_* and _refresh_access_token). Endpoints and +# client id are overridable via the same env vars Hermes honours, so a staging +# Portal or a future change can be pointed at without a code change. +# --------------------------------------------------------------------------- + +_NOUS_PORTAL_BASE_URL = ( + os.environ.get("HERMES_PORTAL_BASE_URL") + or os.environ.get("NOUS_PORTAL_BASE_URL") + or "https://portal.nousresearch.com" +) +_NOUS_INFERENCE_BASE_URL = os.environ.get("NOUS_INFERENCE_BASE_URL") or "https://inference-api.nousresearch.com/v1" +_NOUS_CLIENT_ID = "hermes-cli" + +# Proactively refresh this many seconds before the JWT ``exp`` claim — matches +# the 120s skew Hermes' own runtime resolver uses for Nous. +_NOUS_TOKEN_REFRESH_SKEW_SECONDS = 120 + +# OAuth error codes the Portal returns when the refresh_token itself is no +# longer usable. These are terminal — retrying will not succeed; the user must +# re-run ``hermes portal``. +_NOUS_TERMINAL_REFRESH_ERROR_CODES = frozenset( + {"invalid_grant", "invalid_token", "refresh_token_reused", "refresh_token_expired"} +) + +_AUTH_LOCK_TIMEOUT_SECONDS = 20.0 + + +def _default_auth_file() -> Path: + return Path.home() / ".hermes" / "auth.json" + + +class NousNotLoggedInError(RuntimeError): + """Raised when ``~/.hermes/auth.json`` has no usable Nous OAuth state. + + Remediation: run ``hermes portal`` to log in to Nous Portal. + """ + + +class NousRefreshExpiredError(RuntimeError): + """Raised when the Nous refresh_token itself is permanently invalid. + + The user must re-run ``hermes portal`` to obtain new credentials. Callers + should surface a clear remediation message and stop retrying. + """ + + +@contextlib.contextmanager +def _hermes_auth_lock(auth_file: Path, timeout_seconds: float = _AUTH_LOCK_TIMEOUT_SECONDS) -> Iterator[None]: + """Cross-process advisory lock on the Hermes auth store. + + Uses ``.lock`` (i.e. ``~/.hermes/auth.lock``) with + ``fcntl.flock(LOCK_EX)`` — the exact same lock file and primitive Hermes' + ``_auth_store_lock`` takes — so a refresh here is mutually exclusive with a + concurrently-running Hermes agent. Degrades to a no-op (with a debug log) + where ``fcntl`` is unavailable (Windows); the single-process in-memory lock + still serialises this process's own refreshes. + """ + if fcntl is None: # pragma: no cover - Windows + logger.debug("fcntl unavailable; Nous refresh proceeds without a cross-process lock.") + yield + return + + lock_path = auth_file.with_suffix(".lock") + lock_path.parent.mkdir(parents=True, exist_ok=True) + with open(lock_path, "a+") as lock_file: + deadline = time.monotonic() + max(1.0, timeout_seconds) + while True: + try: + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + break + except (BlockingIOError, OSError): + if time.monotonic() >= deadline: + raise TimeoutError("Timed out waiting for the Hermes auth store lock") from None + time.sleep(0.05) + try: + yield + finally: + with contextlib.suppress(OSError): + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + + +class NousAuthManager: + """Sync Nous Portal OAuth credential manager. + + Holds the access_token + refresh_token in memory and handles + proactive/reactive refresh. A ``threading.Lock`` gives single-flight + semantics within the process; the cross-process ``fcntl`` lock guards + against a concurrent Hermes agent (see module docstring). + """ + + def __init__( + self, + access_token: str, + refresh_token: str | None, + auth_file: Path, + *, + portal_base_url: str = _NOUS_PORTAL_BASE_URL, + inference_base_url: str = _NOUS_INFERENCE_BASE_URL, + client_id: str = _NOUS_CLIENT_ID, + ) -> None: + self.access_token = access_token + self.refresh_token = refresh_token + self._auth_file = auth_file + self._portal_base_url = portal_base_url.rstrip("/") + self._inference_base_url = inference_base_url.rstrip("/") + self._client_id = client_id + self._lock = threading.Lock() + self._http_client = httpx.Client(timeout=30.0) + + # ------------------------------------------------------------------ + # Construction + # ------------------------------------------------------------------ + + @classmethod + def from_file(cls, auth_file: Path | None = None) -> "NousAuthManager": + """Build a manager from ``providers.nous`` in the Hermes auth store. + + Raises + ------ + NousNotLoggedInError: + If the file is missing, unreadable, or has no Nous OAuth state with + an ``access_token``. + """ + if auth_file is None: + auth_file = _default_auth_file() + + if not auth_file.exists(): + raise NousNotLoggedInError( + f"Hermes auth file not found: {auth_file}. Run 'hermes portal' to log in to Nous Portal." + ) + + try: + with open(auth_file) as f: + data = json.load(f) + except (OSError, json.JSONDecodeError) as e: + raise NousNotLoggedInError(f"Could not read Hermes auth file {auth_file}: {type(e).__name__}") from e + + state = cls._nous_state(data) + if not state: + raise NousNotLoggedInError( + "Hermes is not logged into Nous Portal (no providers.nous OAuth state). Run 'hermes portal'." + ) + + access_token = state.get("access_token") + if not isinstance(access_token, str) or not access_token: + raise NousNotLoggedInError("Nous OAuth state has no access_token. Re-authenticate with 'hermes portal'.") + + return cls( + access_token=access_token, + refresh_token=state.get("refresh_token"), + auth_file=auth_file, + portal_base_url=cls._optional_url(state.get("portal_base_url")) or _NOUS_PORTAL_BASE_URL, + inference_base_url=cls._optional_url(state.get("inference_base_url")) or _NOUS_INFERENCE_BASE_URL, + client_id=str(state.get("client_id") or _NOUS_CLIENT_ID), + ) + + @staticmethod + def _nous_state(data: dict[str, Any]) -> dict[str, Any]: + """Pull the ``providers.nous`` state dict out of a loaded auth store.""" + providers = data.get("providers") + if not isinstance(providers, dict): + return {} + state = providers.get("nous") + return state if isinstance(state, dict) else {} + + @staticmethod + def _optional_url(value: Any) -> str | None: + return value.rstrip("/") if isinstance(value, str) and value.strip() else None + + @property + def base_url(self) -> str: + return self._inference_base_url + + # ------------------------------------------------------------------ + # Token state + # ------------------------------------------------------------------ + + @staticmethod + def load_refresh_token_from_file(auth_file: Path) -> str | None: + """Read ``providers.nous.refresh_token`` from ``auth_file``. + + Returns ``None`` when the file is unreadable or omits the field. Does + not raise — the caller degrades to using the in-memory token. + """ + try: + with open(auth_file) as f: + data = json.load(f) + except (OSError, json.JSONDecodeError): + return None + return NousAuthManager._nous_state(data).get("refresh_token") + + @staticmethod + def _decode_jwt_exp_unixtime(token: str) -> int | None: + """Return the JWT ``exp`` claim as a unix timestamp, or None on failure. + + The signature is not verified — the server is the source of truth on + acceptance. This only schedules proactive refresh. + """ + try: + parts = token.split(".") + if len(parts) < 2: + return None + payload_b64 = parts[1] + padding = "=" * (-len(payload_b64) % 4) + payload = json.loads(base64.urlsafe_b64decode(payload_b64 + padding).decode("utf-8")) + exp = payload.get("exp") + return int(exp) if exp is not None else None + except (ValueError, TypeError, json.JSONDecodeError, binascii.Error): + return None + + def _token_is_stale(self, skew_seconds: int = _NOUS_TOKEN_REFRESH_SKEW_SECONDS) -> bool: + """True when the cached access_token is past expiry (with skew). + + Returns False when expiry cannot be determined — we'd rather use a + possibly-expired token and recover via the reactive 401 path than + refresh aggressively on every request when ``exp`` is unparseable. + """ + exp = self._decode_jwt_exp_unixtime(self.access_token) + if exp is None: + return False + return exp <= int(time.time()) + skew_seconds + + # ------------------------------------------------------------------ + # Persistence + # ------------------------------------------------------------------ + + def _persist_state_atomic(self, updated: dict[str, Any]) -> None: + """Patch ``providers.nous`` in ``_auth_file`` and write atomically. + + Re-reads the on-disk store first so fields written by Hermes (other + providers, the credential pool, rotated tokens) are never clobbered, + then patches only the Nous OAuth fields and ``os.replace``s into place + (atomic on POSIX within the same filesystem). Must be called while + holding :func:`_hermes_auth_lock`. + """ + try: + with open(self._auth_file) as f: + loaded = json.load(f) + current: dict[str, Any] = loaded if isinstance(loaded, dict) else {} + except (OSError, json.JSONDecodeError): + current = {} + + providers = current.get("providers") + if not isinstance(providers, dict): + providers = {} + current["providers"] = providers + state = providers.get("nous") + if not isinstance(state, dict): + state = {} + providers["nous"] = state + + state.update(updated) + # The inference bearer is the access token itself; keep agent_key in + # sync so Hermes' own resolver/status sees the rotation too. + state["agent_key"] = updated.get("access_token", state.get("access_token")) + current["updated_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + parent = self._auth_file.parent + parent.mkdir(parents=True, exist_ok=True) + fd, tmp_path = tempfile.mkstemp(prefix=".auth.", suffix=".json.tmp", dir=str(parent)) + try: + with os.fdopen(fd, "w") as f: + json.dump(current, f, indent=2) + f.flush() + os.fsync(f.fileno()) + with contextlib.suppress(OSError): + os.chmod(tmp_path, 0o600) + os.replace(tmp_path, self._auth_file) + except Exception: + with contextlib.suppress(OSError): + os.unlink(tmp_path) + raise + + # ------------------------------------------------------------------ + # Refresh + # ------------------------------------------------------------------ + + @staticmethod + def _extract_oauth_error_code(response: httpx.Response) -> str | None: + """Pull the OAuth error code out of a 4xx refresh response, if present.""" + try: + body = response.json() + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(body, dict): + return None + err = body.get("error") + if isinstance(err, str): + return err + if isinstance(err, dict) and isinstance(err.get("code"), str): + return err["code"] + code = body.get("error_code") + return code if isinstance(code, str) else None + + def refresh_tokens(self, reason: str = "", *, force: bool = False) -> None: + """Single-flight Nous OAuth token refresh. + + Serialised through ``self._lock`` (in-process single-flight) and + :func:`_hermes_auth_lock` (cross-process, vs a running Hermes agent). + The latest ``refresh_token`` is re-read from disk under the lock before + the exchange — single-use tokens make using a stale in-memory RT a + session-revoking mistake. + + Raises + ------ + NousRefreshExpiredError: + On a terminal refresh error (expired/reused/invalid grant). + RuntimeError: + For other refresh failures (network, 5xx, missing refresh_token). + """ + token_before_lock = self.access_token + with self._lock: + if force: + if self.access_token != token_before_lock: + return # another caller already refreshed while we waited + elif not self._token_is_stale(): + return + + with _hermes_auth_lock(self._auth_file): + # Re-read the freshest refresh_token persisted by whoever rotated + # last (this process or Hermes). Using a stale RT is exactly what + # trips the Portal's single-use reuse-detection. + disk_rt = self.load_refresh_token_from_file(self._auth_file) + if disk_rt: + self.refresh_token = disk_rt + + if not self.refresh_token: + raise RuntimeError( + "Nous access_token is expired but no refresh_token is available. " + "Run 'hermes portal' to re-authenticate." + ) + + log_reason = f" ({reason})" if reason else "" + logger.info(f"Refreshing Nous Portal access_token{log_reason}") + + try: + response = self._http_client.post( + f"{self._portal_base_url}/api/oauth/token", + headers={"x-nous-refresh-token": self.refresh_token}, + data={"grant_type": "refresh_token", "client_id": self._client_id}, + timeout=30.0, + ) + except httpx.RequestError as e: + raise RuntimeError(f"Nous OAuth refresh network error: {type(e).__name__}") from e + + if response.status_code != 200: + code = self._extract_oauth_error_code(response) + if code in _NOUS_TERMINAL_REFRESH_ERROR_CODES or response.status_code in (400, 401): + raise NousRefreshExpiredError( + f"Nous refresh_token is no longer valid (status={response.status_code}, " + f"error={code or 'none'}). Run 'hermes portal' to re-authenticate." + ) + raise RuntimeError(f"Nous OAuth refresh failed with HTTP {response.status_code}") + + try: + body = response.json() + except (json.JSONDecodeError, ValueError) as e: + raise RuntimeError(f"Nous OAuth refresh returned non-JSON body: {e}") from e + + new_access = body.get("access_token") + if not new_access: + raise RuntimeError("Nous OAuth refresh returned no access_token") + new_refresh = body.get("refresh_token") or self.refresh_token + + # Update in-memory state first so waiters see fresh credentials + # even if the disk write fails. + self.access_token = new_access + self.refresh_token = new_refresh + + persisted: dict[str, Any] = {"access_token": new_access, "refresh_token": new_refresh} + expires_in = body.get("expires_in") + if isinstance(expires_in, (int, float)): + persisted["expires_at"] = datetime.fromtimestamp( + time.time() + float(expires_in), tz=timezone.utc + ).isoformat() + try: + self._persist_state_atomic(persisted) + except OSError as e: + logger.warning( + f"Nous refresh succeeded but persisting auth.json failed: {type(e).__name__}. " + "In-memory credentials are current; the on-disk rotated token was not saved." + ) + logger.info("Nous Portal access_token refreshed successfully") + + def ensure_fresh_token(self) -> str: + """Refresh proactively if near/at expiry, then return the bearer token. + + Cheap when fresh (a JWT exp decode + comparison). + """ + if self._token_is_stale(): + self.refresh_tokens(reason="proactive (token near expiry)") + return self.access_token + + def close(self) -> None: + """Close the underlying HTTP client.""" + self._http_client.close() diff --git a/hindsight-api-slim/hindsight_api/engine/providers/nous_llm.py b/hindsight-api-slim/hindsight_api/engine/providers/nous_llm.py new file mode 100644 index 0000000000..f279b21e9b --- /dev/null +++ b/hindsight-api-slim/hindsight_api/engine/providers/nous_llm.py @@ -0,0 +1,167 @@ +""" +Nous Portal LLM provider for Hindsight. + +Thin wrapper over :class:`OpenAICompatibleLLM`. The Nous Portal speaks the +OpenAI chat-completions wire format, so all request/response handling is +inherited unchanged. The only thing Nous needs on top is a rotating, +inference-scoped JWT (there is no static API key in the Hermes login flow), +which :class:`NousAuthManager` reads from ``~/.hermes/auth.json`` and refreshes +natively — the same pattern as the Codex provider, with no dependency on the +``hermes_cli`` package. See ``nous_auth.py`` for the auth mechanics. + +Configure with:: + + llm_provider = "nous" + llm_base_url = "https://inference-api.nousresearch.com/v1" # or omit + llm_model = "deepseek/deepseek-v4-flash" # any Nous slug + +No API key is set in config; the token comes from the shared Hermes auth store +after a one-time ``hermes portal`` login. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from openai import APIStatusError, AsyncOpenAI + +from hindsight_api.engine.providers.nous_auth import ( + NousAuthManager, + NousNotLoggedInError, + NousRefreshExpiredError, +) +from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM + +logger = logging.getLogger(__name__) + +__all__ = ["NousLLM", "NousAuthManager", "NousNotLoggedInError", "NousRefreshExpiredError"] + + +class NousLLM(OpenAICompatibleLLM): + """OpenAI-compatible provider for the Nous Portal with rotating-JWT auth.""" + + def __init__( + self, + provider: str, + api_key: str, # Ignored — the token is read from ~/.hermes/auth.json + base_url: str, + model: str, + reasoning_effort: str = "low", + **kwargs: Any, + ): + try: + self._auth = NousAuthManager.from_file() + except NousNotLoggedInError as e: + raise RuntimeError( + f"Failed to load Nous Portal credentials: {e}\n\n" + "To set up Nous authentication:\n" + "1. Install Hermes: https://hermes-agent.nousresearch.com\n" + "2. Log in to Nous Portal: hermes portal\n" + "3. Verify: hermes portal status\n\n" + "Or use a different provider (openai, anthropic, gemini) with an API key." + ) from e + + # Single-flight async refresh lock — concurrent coroutines racing toward + # an expired token produce one network refresh. + self._auth_lock = asyncio.Lock() + + token = self._auth.access_token + resolved_base = base_url or self._auth.base_url + # Parent validates provider against a fixed list; present as "openai" + # (identical wire format) while retaining the true identity for logs. + super().__init__( + provider="openai", + api_key=token, + base_url=resolved_base, + model=model, + reasoning_effort=reasoning_effort, + **kwargs, + ) + self._nous_provider_name = provider + logger.info( + "Nous LLM initialized: model=%s base_url=%s (rotating inference:invoke JWT)", + self.model, + self.base_url, + ) + + # ------------------------------------------------------------------ + # Token lifecycle + # ------------------------------------------------------------------ + + def _rebuild_client(self) -> None: + """Rebuild the OpenAI SDK client against the current token.""" + self.api_key = self._auth.access_token + self._client = AsyncOpenAI( + api_key=self.api_key, + base_url=self.base_url, + max_retries=0, + timeout=self.timeout, + ) + + async def _ensure_fresh_token(self) -> None: + """Proactively refresh if the JWT is near expiry; rebuild on change. + + Cheap when fresh (a JWT exp decode). The blocking refresh (network + + cross-process file lock) is offloaded to a thread so the event loop is + never stalled. + """ + if not self._auth._token_is_stale(): + return + await self._refresh(reason="proactive (token near expiry)", force=False) + + async def _refresh(self, *, reason: str, force: bool) -> None: + token_before = self.api_key + async with self._auth_lock: + if force: + if self.api_key != token_before: + return # another coroutine already refreshed + elif not self._auth._token_is_stale(): + return + await asyncio.to_thread(lambda: self._auth.refresh_tokens(reason, force=force)) + if self._auth.access_token != self.api_key: + self._rebuild_client() + + async def _with_auth_retry(self, fn: Any, label: str, *args: Any, **kwargs: Any) -> Any: + """Run an OpenAI-compatible call, refreshing once on a 401. + + The proactive refresh covers most expiries; a token can still be + rejected mid-flight if Hermes rotated it out from under us or the exp + claim was unparseable. One reactive refresh + retry is the safety net. + """ + await self._ensure_fresh_token() + try: + return await fn(*args, **kwargs) + except APIStatusError as e: + if getattr(e, "status_code", None) != 401: + raise + logger.warning("Nous 401 (%s) — forcing token refresh and retrying once.", label) + try: + await self._refresh(reason=f"reactive (HTTP 401 on {label})", force=True) + except NousRefreshExpiredError as refresh_err: + raise RuntimeError( + "Nous authentication failed and the refresh_token is no longer valid.\n" + "Run 'hermes portal' to re-authenticate." + ) from refresh_err + return await fn(*args, **kwargs) + + # ------------------------------------------------------------------ + # Overrides + # ------------------------------------------------------------------ + + async def verify_connection(self) -> None: + await self._ensure_fresh_token() + return await super().verify_connection() + + async def call(self, *args: Any, **kwargs: Any) -> Any: + return await self._with_auth_retry(super().call, "call", *args, **kwargs) + + async def call_with_tools(self, *args: Any, **kwargs: Any) -> Any: + return await self._with_auth_retry(super().call_with_tools, "call_with_tools", *args, **kwargs) + + async def cleanup(self) -> None: + self._auth.close() + parent_cleanup = getattr(super(), "cleanup", None) + if parent_cleanup is not None: + await parent_cleanup() diff --git a/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py b/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py index d4e0a0bff1..099fb8796f 100644 --- a/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py +++ b/hindsight-api-slim/hindsight_api/engine/providers/openai_compatible_llm.py @@ -26,6 +26,7 @@ import os import re import time +from datetime import UTC, datetime, timedelta from email.utils import parsedate_to_datetime from typing import Any from urllib.parse import parse_qs, urlparse, urlunparse @@ -34,7 +35,8 @@ from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT -from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError +from hindsight_api.engine.bank_attribution import apply_bank_attribution +from hindsight_api.engine.llm_interface import LLMInterface, OutputTooLongError, ProviderRateLimitResetError from hindsight_api.engine.response_models import LLMToolCall, LLMToolCallResult, TokenUsage from hindsight_api.metrics import get_metrics_collector from hindsight_api.worker.stage import set_stage @@ -83,6 +85,49 @@ def _strip_code_fences(content: str) -> str: return content +# Reasoning/thinking tags emitted by extended-thinking models. Some providers +# (e.g. MiniMax-M3) leak the chain-of-thought wrapped in these tags into the +# response body instead of a separate reasoning_content field. Each entry is +# (open_tag, close_tag); the open tag also matches when the close tag is missing +# (truncated output) so a dangling block is removed to end-of-string. +_REASONING_TAG_PAIRS: tuple[tuple[str, str], ...] = ( + ("", ""), + ("", ""), + ("", ""), + ("", ""), + ("|startthink|", "|endthink|"), +) + + +def _strip_reasoning_tags(text: str) -> str: + """Strip extended-thinking/reasoning blocks from an LLM response. + + Removes the full set of tag styles emitted by reasoning models: + ````, ````, ````, ```` and the + ``|startthink|...|endthink|`` markers. Both the structured (JSON) path and + the free-form path must call this — otherwise a non-structured response + (e.g. a mental-model markdown blob from MiniMax-M3) leaks the raw + ``...`` verbatim into stored memories. + + Handles two cases: + 1. Closed blocks: ``...`` removed wherever they appear. + 2. Unclosed blocks: a dangling ```` with no closing tag (model output + truncated mid-thought) is removed from the open tag to end-of-string. + + Returns the input unchanged (modulo surrounding whitespace) when no tags are + present. + """ + if not text: + return text + for open_tag, close_tag in _REASONING_TAG_PAIRS: + open_re = re.escape(open_tag) + close_re = re.escape(close_tag) + # Closed blocks first, then any remaining unclosed (truncated) block. + text = re.sub(rf"{open_re}.*?{close_re}", "", text, flags=re.DOTALL) + text = re.sub(rf"{open_re}.*", "", text, flags=re.DOTALL) + return text.strip() + + def _response_get(response: Any, key: str, default: Any = None) -> Any: if isinstance(response, dict): return response.get(key, default) @@ -234,25 +279,120 @@ def _summarize_status_error(e: APIStatusError, body_max: int = 400) -> str: return f"HTTP {e.status_code}: {body_str or ''}" -def _retry_after_seconds(e: APIStatusError, *, max_backoff: float) -> float | None: - """Parse Retry-After for provider cooldowns, capped by max_backoff.""" - response = getattr(e, "response", None) - headers = getattr(response, "headers", None) - if not headers: +_RATE_LIMIT_RESET_AT_RE = re.compile( + r"\breset at\s+" + r"(?P\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\s*(?:Z|[+-]\d{2}:?\d{2}))?)", + re.IGNORECASE, +) +_RATE_LIMIT_WINDOW_RE = re.compile( + r"\b(?:for|in)\s+(?P\d+)\s*(?Psecond|minute|hour|day)s?\b", + re.IGNORECASE, +) + + +def _status_error_body_text(e: APIStatusError) -> str: + body: Any = getattr(e, "body", None) + if body is None: + try: + body = e.response.text + except Exception: + body = None + if isinstance(body, (dict, list)): + try: + return json.dumps(body, default=str, ensure_ascii=False) + except Exception: + return str(body) + return str(body or "").strip() + + +def _parse_retry_after_header(value: str | None, now: datetime) -> datetime | None: + if not value: return None - retry_after = headers.get("retry-after") or headers.get("Retry-After") - if not retry_after: + raw = value.strip() + try: + seconds = float(raw) + except ValueError: + seconds = -1.0 + if seconds >= 0: + return now + timedelta(seconds=seconds) + + try: + parsed = parsedate_to_datetime(raw) + except (TypeError, ValueError, IndexError, OverflowError): return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=UTC) + return parsed.astimezone(UTC) + + +def _parse_reset_at_datetime(value: str) -> datetime | None: + raw = value.strip().replace(" ", "T") + if raw.endswith("Z"): + raw = f"{raw[:-1]}+00:00" + elif re.search(r"[+-]\d{4}$", raw): + raw = f"{raw[:-2]}:{raw[-2:]}" try: - seconds = float(retry_after) + parsed = datetime.fromisoformat(raw) except ValueError: - try: - seconds = parsedate_to_datetime(retry_after).timestamp() - time.time() - except (TypeError, ValueError, OverflowError): - return None - if seconds <= 0: return None - return min(seconds, max_backoff) + if parsed.tzinfo is None: + # Some providers (z.ai included) return a wall-clock reset timestamp + # without a zone. Interpret it in the host's local zone so logs, status + # pages, and the queued next_retry_at describe the same operator-facing + # clock instead of silently shifting by UTC offset. + parsed = parsed.astimezone() + return parsed.astimezone(UTC) + + +def _rate_limit_retry_at(e: APIStatusError) -> datetime | None: + now = datetime.now(UTC) + response = getattr(e, "response", None) + headers = getattr(response, "headers", None) + if headers is not None: + retry_at = _parse_retry_after_header(headers.get("retry-after") or headers.get("Retry-After"), now) + if retry_at is not None and retry_at > now: + return retry_at + + body_text = _status_error_body_text(e) + reset_match = _RATE_LIMIT_RESET_AT_RE.search(body_text) + if reset_match: + retry_at = _parse_reset_at_datetime(reset_match.group("reset_at")) + if retry_at is not None and retry_at > now: + return retry_at + + window_match = _RATE_LIMIT_WINDOW_RE.search(body_text) + if not window_match: + return None + amount = int(window_match.group("amount")) + unit = window_match.group("unit").lower() + if unit == "second": + seconds = amount + elif unit == "minute": + seconds = amount * 60 + elif unit == "hour": + seconds = amount * 3600 + else: + seconds = amount * 86400 + return now + timedelta(seconds=seconds) + + +def _raise_provider_quota_defer( + e: APIStatusError, *, provider: str, model: str, scope: str, max_backoff: float +) -> None: + if e.status_code != 429: + return + retry_at = _rate_limit_retry_at(e) + if retry_at is None: + return + if (retry_at - datetime.now(UTC)).total_seconds() <= max_backoff: + return + summary = _summarize_status_error(e) + raise ProviderRateLimitResetError( + retry_at=retry_at, + message=( + f"Provider quota exhausted ({provider}/{model}, scope={scope}); retry at {retry_at.isoformat()}: {summary}" + ), + ) from e class OpenAICompatibleLLM(LLMInterface): @@ -290,7 +430,7 @@ def __init__( base_url: Base URL for the API (uses defaults for groq/ollama/lmstudio if empty). model: Model name. reasoning_effort: Reasoning effort level for supported models ("low", "medium", "high"). - timeout: Request timeout in seconds (uses env var or 300s default). + timeout: Request timeout in seconds (uses env var or 120s default). groq_service_tier: Groq service tier ("on_demand", "flex", "auto"). extra_body: Extra body params merged into every API call. **kwargs: Additional provider-specific parameters. @@ -617,6 +757,8 @@ async def call( call_params["messages"] = _ensure_json_word_in_user_message(call_params["messages"]) call_params["response_format"] = {"type": "json_object"} + apply_bank_attribution(call_params) + last_exception = None for attempt in range(max_retries + 1): @@ -636,15 +778,10 @@ async def call( scope=scope, ) - # Strip reasoning model thinking tags + # Strip reasoning model thinking tags (closed and unclosed). # Supports: , , , , |startthink|/|endthink| original_len = len(content) - content = re.sub(r".*?", "", content, flags=re.DOTALL) - content = re.sub(r".*?", "", content, flags=re.DOTALL) - content = re.sub(r".*?", "", content, flags=re.DOTALL) - content = re.sub(r".*?", "", content, flags=re.DOTALL) - content = re.sub(r"\|startthink\|.*?\|endthink\|", "", content, flags=re.DOTALL) - content = content.strip() + content = _strip_reasoning_tags(content) if len(content) < original_len: logger.debug(f"Stripped {original_len - len(content)} chars of reasoning tokens") @@ -693,6 +830,13 @@ async def call( scope=scope, ) + # Free-form (non-structured) output also leaks reasoning tags: + # reasoning models like MiniMax-M3 wrap their chain-of-thought + # in ... in the response body. Without this strip + # a mental-model markdown blob is stored verbatim with the raw + # thinking tags. Mirrors the structured-output path above. + result = _strip_reasoning_tags(result) + # Record token usage metrics duration = time.time() - start_time usage = response.usage @@ -780,6 +924,10 @@ async def call( logger.error(f"Auth error (HTTP {e.status_code}), not retrying: {str(e)}") raise + _raise_provider_quota_defer( + e, provider=self.provider, model=self.model, scope=scope, max_backoff=max_backoff + ) + # Handle tool_use_failed error - model outputted in tool call format if e.status_code == 400 and response_format is not None: try: @@ -823,18 +971,9 @@ async def call( f"APIStatusError ({self.provider}/{self.model}, scope={scope}, " f"attempt {attempt + 1}/{max_retries + 1}): {_summarize_status_error(e)}" ) - retry_after = _retry_after_seconds(e, max_backoff=max_backoff) - if retry_after is not None: - sleep_time = retry_after - else: - backoff = min(initial_backoff * (2**attempt), max_backoff) - jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1) - sleep_time = max(0.0, backoff + jitter) - if e.status_code == 429: - logger.warning( - f"Rate limited ({self.provider}/{self.model}, scope={scope}); " - f"cooling down for {sleep_time:.2f}s before retry" - ) + backoff = min(initial_backoff * (2**attempt), max_backoff) + jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1) + sleep_time = backoff + jitter await asyncio.sleep(sleep_time) else: logger.error( @@ -842,7 +981,6 @@ async def call( f"scope={scope}): {_summarize_status_error(e)}" ) raise - except ProviderResponseError as e: last_exception = e if e.retryable and attempt < max_retries: @@ -976,6 +1114,8 @@ async def call_with_tools( if extra_body: call_params["extra_body"] = extra_body + apply_bank_attribution(call_params) + last_exception = None for attempt in range(max_retries + 1): @@ -1073,6 +1213,10 @@ async def call_with_tools( f"not retrying: {_summarize_status_error(e)}" ) raise + _raise_provider_quota_defer( + e, provider=self.provider, model=self.model, scope=scope, max_backoff=max_backoff + ) + last_exception = e if attempt < max_retries: logger.warning( @@ -1086,7 +1230,6 @@ async def call_with_tools( f"({self.provider}/{self.model}, scope={scope}): {_summarize_status_error(e)}" ) raise - except Exception: raise diff --git a/hindsight-api-slim/hindsight_api/engine/query_analyzer.py b/hindsight-api-slim/hindsight_api/engine/query_analyzer.py index 607e3d57b9..7d9889d500 100644 --- a/hindsight-api-slim/hindsight_api/engine/query_analyzer.py +++ b/hindsight-api-slim/hindsight_api/engine/query_analyzer.py @@ -6,12 +6,17 @@ """ import logging -import re from abc import ABC, abstractmethod from datetime import datetime, timedelta from pydantic import BaseModel, Field +from hindsight_api.engine.temporal_periods import ( + NO_TEMPORAL_CONSTRAINT, + extract_period, + is_embedded_cjk_dateparser_match, +) + logger = logging.getLogger(__name__) @@ -123,9 +128,12 @@ def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAn # Check for period expressions first (these need special handling) query_lower = query.lower() - period_result = self._extract_period(query_lower, reference_date) - if period_result is not None: - return QueryAnalysis(temporal_constraint=period_result) + period_result = extract_period(query_lower, reference_date) + if period_result is NO_TEMPORAL_CONSTRAINT: + return QueryAnalysis(temporal_constraint=None) + if isinstance(period_result, tuple): + start_date, end_date = period_result + return QueryAnalysis(temporal_constraint=TemporalConstraint(start_date=start_date, end_date=end_date)) # Lazy load dateparser (only imports on first call, then cached) self.load() @@ -158,7 +166,12 @@ def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAn # Filter out false positives (common words parsed as dates) false_positives = {"do", "may", "march", "will", "can", "sat", "sun", "mon", "tue", "wed", "thu", "fri"} - valid_results = [(text, date) for text, date in results if text.lower() not in false_positives or len(text) > 3] + valid_results = [ + (text, date) + for text, date in results + if (text.lower() not in false_positives or len(text) > 3) + and not is_embedded_cjk_dateparser_match(query, text) + ] if not valid_results: return QueryAnalysis(temporal_constraint=None) @@ -172,127 +185,6 @@ def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAn return QueryAnalysis(temporal_constraint=TemporalConstraint(start_date=start_date, end_date=end_date)) - def _extract_period(self, query: str, reference_date: datetime) -> TemporalConstraint | None: - """ - Extract period-based temporal expressions (week, month, year, weekend). - - These need special handling as they represent date ranges, not single dates. - Supports multiple languages. - """ - - def constraint(start: datetime, end: datetime) -> TemporalConstraint: - return TemporalConstraint( - start_date=start.replace(hour=0, minute=0, second=0, microsecond=0), - end_date=end.replace(hour=23, minute=59, second=59, microsecond=999999), - ) - - # Yesterday patterns (English, Spanish, Italian, French, German) - if re.search(r"\b(yesterday|ayer|ieri|hier|gestern)\b", query, re.IGNORECASE): - d = reference_date - timedelta(days=1) - return constraint(d, d) - - # Today patterns - if re.search(r"\b(today|hoy|oggi|aujourd\'?hui|heute)\b", query, re.IGNORECASE): - return constraint(reference_date, reference_date) - - # "a couple of days ago" / "a few days ago" patterns - # These are imprecise so we create a range - if re.search(r"\b(a\s+)?couple\s+(of\s+)?days?\s+ago\b", query, re.IGNORECASE): - # "a couple of days" = approximately 2 days, give range of 1-3 days - return constraint(reference_date - timedelta(days=3), reference_date - timedelta(days=1)) - - if re.search(r"\b(a\s+)?few\s+days?\s+ago\b", query, re.IGNORECASE): - # "a few days" = approximately 3-4 days, give range of 2-5 days - return constraint(reference_date - timedelta(days=5), reference_date - timedelta(days=2)) - - # "a couple of weeks ago" / "a few weeks ago" patterns - if re.search(r"\b(a\s+)?couple\s+(of\s+)?weeks?\s+ago\b", query, re.IGNORECASE): - # "a couple of weeks" = approximately 2 weeks, give range of 1-3 weeks - return constraint(reference_date - timedelta(weeks=3), reference_date - timedelta(weeks=1)) - - if re.search(r"\b(a\s+)?few\s+weeks?\s+ago\b", query, re.IGNORECASE): - # "a few weeks" = approximately 3-4 weeks, give range of 2-5 weeks - return constraint(reference_date - timedelta(weeks=5), reference_date - timedelta(weeks=2)) - - # "a couple of months ago" / "a few months ago" patterns - if re.search(r"\b(a\s+)?couple\s+(of\s+)?months?\s+ago\b", query, re.IGNORECASE): - # "a couple of months" = approximately 2 months, give range of 1-3 months - return constraint(reference_date - timedelta(days=90), reference_date - timedelta(days=30)) - - if re.search(r"\b(a\s+)?few\s+months?\s+ago\b", query, re.IGNORECASE): - # "a few months" = approximately 3-4 months, give range of 2-5 months - return constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60)) - - # Last week patterns (English, Spanish, Italian, French, German) - if re.search( - r"\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b", - query, - re.IGNORECASE, - ): - start = reference_date - timedelta(days=reference_date.weekday() + 7) - return constraint(start, start + timedelta(days=6)) - - # Last month patterns - if re.search( - r"\b(last\s+month|el\s+mes\s+pasado|il\s+mese\s+scorso|le\s+mois\s+dernier|letzten?\s+monat)\b", - query, - re.IGNORECASE, - ): - first = reference_date.replace(day=1) - end = first - timedelta(days=1) - start = end.replace(day=1) - return constraint(start, end) - - # Last year patterns - if re.search( - r"\b(last\s+year|el\s+a[ñn]o\s+pasado|l\'anno\s+scorso|l\'ann[ée]e\s+derni[eè]re|letztes?\s+jahr)\b", - query, - re.IGNORECASE, - ): - year = reference_date.year - 1 - return constraint(datetime(year, 1, 1), datetime(year, 12, 31)) - - # Last weekend patterns - if re.search( - r"\b(last\s+weekend|el\s+fin\s+de\s+semana\s+pasado|lo\s+scorso\s+fine\s+settimana|le\s+week-?end\s+dernier|letztes?\s+wochenende)\b", - query, - re.IGNORECASE, - ): - days_since_sat = (reference_date.weekday() + 2) % 7 - if days_since_sat == 0: - days_since_sat = 7 - sat = reference_date - timedelta(days=days_since_sat) - return constraint(sat, sat + timedelta(days=1)) - - # Month + Year patterns (e.g., "June 2024", "junio 2024", "giugno 2024") - month_patterns = { - "january|enero|gennaio|janvier|januar": 1, - "february|febrero|febbraio|f[ée]vrier|februar": 2, - "march|marzo|mars|m[äa]rz": 3, - "april|abril|aprile|avril": 4, - "may|mayo|maggio|mai": 5, - "june|junio|giugno|juin|juni": 6, - "july|julio|luglio|juillet|juli": 7, - "august|agosto|ao[uû]t": 8, - "september|septiembre|settembre|septembre": 9, - "october|octubre|ottobre|octobre|oktober": 10, - "november|noviembre|novembre": 11, - "december|diciembre|dicembre|d[ée]cembre|dezember": 12, - } - - for pattern, month_num in month_patterns.items(): - match = re.search(rf"\b({pattern})\s+(\d{{4}})\b", query, re.IGNORECASE) - if match: - year = int(match.group(2)) - start = datetime(year, month_num, 1) - if month_num == 12: - end = datetime(year, 12, 31) - else: - end = datetime(year, month_num + 1, 1) - timedelta(days=1) - return constraint(start, end) - - return None - class TransformerQueryAnalyzer(QueryAnalyzer): """ diff --git a/hindsight-api-slim/hindsight_api/engine/reflect/agent.py b/hindsight-api-slim/hindsight_api/engine/reflect/agent.py index 1edbfe9020..04ea2427b3 100644 --- a/hindsight-api-slim/hindsight_api/engine/reflect/agent.py +++ b/hindsight-api-slim/hindsight_api/engine/reflect/agent.py @@ -14,6 +14,7 @@ import time from typing import TYPE_CHECKING, Any, Awaitable, Callable +from ...config import get_config from .models import DirectiveInfo, LLMCall, ReflectAgentResult, TokenUsageSummary, ToolCall from .prompts import ( _extract_directive_rules, @@ -340,6 +341,7 @@ async def run_reflect_agent( budget: str | None = None, max_context_tokens: int = 100_000, llm_output_language: str | None = None, + cancel_check: Callable[[], None] | None = None, ) -> ReflectAgentResult: """ Execute the reflect agent loop using native tool calling. @@ -376,12 +378,16 @@ async def run_reflect_agent( # Extract directive rules for tool schema (if any) directive_rules = _extract_directive_rules(directives) if directives else None - # Get tools for this agent (with directive compliance field if directives exist) + # Get tools for this agent (with directive compliance field if directives exist). + # The expand tool only reads back raw source text (chunks/documents), so it is + # useless and excluded when document text storage is disabled. + include_expand = get_config().store_document_text tools = get_reflect_tools( directive_rules=directive_rules, include_mental_models=has_mental_models, include_observations=include_observations, include_recall=include_recall, + include_expand=include_expand, ) # Build set of enabled tool names to guard against LLM hallucinating disabled tool calls enabled_tools: frozenset[str] = frozenset(t["function"]["name"] for t in tools if t.get("type") == "function") @@ -488,6 +494,13 @@ def _log_completion(answer: str, iterations: int, forced: bool = False): # under ``auto`` tool choice. None means the full forced path still applies. stop_forcing_from_iteration: int | None = None for iteration in range(max_iterations): + # Cooperative cancellation checkpoint: abort the agent loop between + # iterations if the caller (e.g. an HTTP client) has gone away, rather + # than spending another LLM round-trip on a result nobody will read + # (issue #2122). Raises OperationCancelledError when fired. + if cancel_check is not None: + cancel_check() + is_last = iteration == max_iterations - 1 if is_last: @@ -500,7 +513,9 @@ def _log_completion(answer: str, iterations: int, forced: bool = False): messages=[ { "role": "system", - "content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language), + "content": build_final_system_prompt( + bank_profile.get("mission"), llm_output_language, directives + ), }, {"role": "user", "content": prompt}, ], @@ -560,7 +575,9 @@ def _log_completion(answer: str, iterations: int, forced: bool = False): messages=[ { "role": "system", - "content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language), + "content": build_final_system_prompt( + bank_profile.get("mission"), llm_output_language, directives + ), }, {"role": "user", "content": prompt}, ], @@ -679,7 +696,9 @@ def _log_completion(answer: str, iterations: int, forced: bool = False): messages=[ { "role": "system", - "content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language), + "content": build_final_system_prompt( + bank_profile.get("mission"), llm_output_language, directives + ), }, {"role": "user", "content": prompt}, ], @@ -803,7 +822,9 @@ def _log_completion(answer: str, iterations: int, forced: bool = False): messages=[ { "role": "system", - "content": build_final_system_prompt(bank_profile.get("mission"), llm_output_language), + "content": build_final_system_prompt( + bank_profile.get("mission"), llm_output_language, directives + ), }, {"role": "user", "content": prompt}, ], @@ -908,7 +929,9 @@ def _log_completion(answer: str, iterations: int, forced: bool = False): hallucinated_tools = [] for tc in other_tools: norm = _normalize_tool_name(tc.name) - if enabled_tools is not None and norm not in enabled_tools and norm not in ("done", "expand"): + # "done" is always available. "expand" is governed by enabled_tools + # (it is excluded when text storage is disabled), so it is not hardcoded here. + if enabled_tools is not None and norm not in enabled_tools and norm != "done": hallucinated_tools.append(tc) else: allowed_tools.append(tc) @@ -1236,8 +1259,10 @@ async def _execute_tool( # Normalize tool name for various LLM output formats tool_name = _normalize_tool_name(tool_name) - # Guard against LLMs hallucinating calls to tools that were not provided - if enabled_tools is not None and tool_name not in enabled_tools and tool_name not in ("done", "expand"): + # Guard against LLMs hallucinating calls to tools that were not provided. + # "done" is always available; "expand" is governed by enabled_tools (excluded + # when text storage is disabled), so it is not hardcoded as always-allowed here. + if enabled_tools is not None and tool_name not in enabled_tools and tool_name != "done": return {"error": f"Tool '{tool_name}' is not available. Use only the tools provided to you."} if tool_name == "search_mental_models": diff --git a/hindsight-api-slim/hindsight_api/engine/reflect/delta_ops.py b/hindsight-api-slim/hindsight_api/engine/reflect/delta_ops.py index 80e77d3d9b..4c864912bb 100644 --- a/hindsight-api-slim/hindsight_api/engine/reflect/delta_ops.py +++ b/hindsight-api-slim/hindsight_api/engine/reflect/delta_ops.py @@ -26,10 +26,13 @@ from __future__ import annotations +import json import logging from typing import Annotated, Any, Literal, Union -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, ValidationError + +from hindsight_api.engine.llm_wrapper import parse_llm_json from .structured_doc import ( Block, @@ -144,6 +147,27 @@ class RenameSectionOp(_OpBase): Field(discriminator="op"), ] +_OPERATION_ADAPTER: TypeAdapter[Operation] = TypeAdapter(Operation) + + +def _validate_operations_list(raw_ops: Any) -> tuple[list[Operation], list[dict[str, Any]]]: + """Validate each operation independently; drop invalid ops instead of failing the batch.""" + if not isinstance(raw_ops, list): + raise TypeError(f"operations must be a list, got {type(raw_ops)!r}") + valid: list[Operation] = [] + skipped: list[dict[str, Any]] = [] + for i, item in enumerate(raw_ops): + try: + valid.append(_OPERATION_ADAPTER.validate_python(item)) + except ValidationError as exc: + skipped.append({"index": i, "op": item, "error": exc.errors(include_url=False)}) + logger.warning( + "[STRUCTURED_DELTA] skipping invalid operation at index %s: %s", + i, + exc.errors(include_url=False), + ) + return valid, skipped + class DeltaOperationList(BaseModel): """Container for the operations produced by an LLM delta call.""" @@ -152,6 +176,104 @@ class DeltaOperationList(BaseModel): operations: list[Operation] = Field(default_factory=list) +class DeltaAllOpsInvalidError(ValueError): + """Raised when the model emitted operations but none survived validation. + + Distinct from an empty ``operations`` array (a legitimate no-op): here every + op was malformed, so returning zero valid ops would make the caller apply + nothing and silently drop this refresh's new facts. Raising instead lets the + caller fall back to a full rewrite, which still integrates the new facts. + """ + + +def _finalize_operations(valid: list[Operation], skipped: list[dict[str, Any]]) -> DeltaOperationList: + """Build the result, but refuse a wholesale validation failure as a silent no-op.""" + if skipped and not valid: + raise DeltaAllOpsInvalidError(f"all {len(skipped)} delta operation(s) failed validation") + return DeltaOperationList(operations=valid) + + +def _extract_balanced_json_object(text: str) -> str | None: + """Return the first top-level ``{...}`` slice, ignoring trailing junk.""" + start = text.find("{") + if start < 0: + return None + depth = 0 + in_string = False + escape = False + for i in range(start, len(text)): + ch = text[i] + if in_string: + if escape: + escape = False + elif ch == "\\": + escape = True + elif ch == '"': + in_string = False + continue + if ch == '"': + in_string = True + elif ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return text[start : i + 1] + return None + + +def parse_delta_operation_list(raw: Any) -> DeltaOperationList: + """Parse structured-delta LLM output into a validated operation list.""" + if isinstance(raw, DeltaOperationList): + return raw + if isinstance(raw, dict): + ops_raw = raw.get("operations", []) + valid, skipped = _validate_operations_list(ops_raw) + if skipped: + logger.info( + "[STRUCTURED_DELTA] parsed %s op(s), skipped %s invalid op(s) from dict payload", + len(valid), + len(skipped), + ) + return _finalize_operations(valid, skipped) + + text = (raw or "").strip() + if not text: + return DeltaOperationList() + + candidates: list[str] = [text] + extracted = _extract_balanced_json_object(text) + if extracted and extracted != text: + candidates.append(extracted) + + last_error: Exception | None = None + for candidate in candidates: + try: + payload = parse_llm_json(candidate) + except json.JSONDecodeError as exc: + last_error = exc + continue + if not isinstance(payload, dict) or "operations" not in payload: + last_error = ValueError("delta payload must be an object with an operations array") + continue + try: + valid, skipped = _validate_operations_list(payload["operations"]) + except TypeError as exc: + last_error = exc + continue + if skipped: + logger.info( + "[STRUCTURED_DELTA] parsed %s op(s), skipped %s invalid op(s)", + len(valid), + len(skipped), + ) + return _finalize_operations(valid, skipped) + + if last_error is not None: + raise last_error + return DeltaOperationList() + + # Application --------------------------------------------------------------- diff --git a/hindsight-api-slim/hindsight_api/engine/reflect/prompts.py b/hindsight-api-slim/hindsight_api/engine/reflect/prompts.py index 76298d16b9..b28b6de37a 100644 --- a/hindsight-api-slim/hindsight_api/engine/reflect/prompts.py +++ b/hindsight-api-slim/hindsight_api/engine/reflect/prompts.py @@ -604,16 +604,44 @@ def build_final_prompt( CRITICAL: This is a NON-CONVERSATIONAL system. NEVER ask follow-up questions, offer to search again, suggest alternatives, or end with anything like "Would you like me to..." or "Let me know if...". The user cannot reply. Your answer must be complete and self-contained.""" -def build_final_system_prompt(mission: str | None = None, llm_output_language: str | None = None) -> str: +# The final synthesis is a SEPARATE LLM call with its own system prompt — the +# agent/reasoning system prompt (which carries directives and the language rule) +# is NOT in scope here. So this default language rule, and the directives, must +# be repeated for the answer-writing model. Without it, weaker models drift to +# English even when the question/facts are in another language or a directive +# demands a specific one (the cause of flaky multilingual reflect tests). +_FINAL_LANGUAGE_RULE = ( + "## LANGUAGE\n" + "- Respond in the SAME language as the user's question " + "(e.g. a question in Chinese gets a Chinese answer; Japanese → Japanese).\n" + "- If a directive above specifies a response language, follow the directive — " + "it takes precedence over this default." +) + + +def build_final_system_prompt( + mission: str | None = None, + llm_output_language: str | None = None, + directives: list[dict[str, Any]] | None = None, +) -> str: """Build the final synthesis system prompt, using mission as role when set. - When ``llm_output_language`` is set, the response is forced into that - language regardless of the query/source language. + ``directives`` are re-injected here (they live in the agent/reasoning prompt, + but the final answer is a separate call) so output-constraining rules — most + visibly response language — are honoured by the model that actually writes + the answer. When ``llm_output_language`` is set it forces that language + regardless of the query/source/directive language (config override wins). """ from hindsight_api.engine.prompt_utils import escape_for_prompt, output_language_directive role_section = escape_for_prompt(mission.strip()) if mission else _DEFAULT_FINAL_ROLE - return _FINAL_SYSTEM_PROMPT_BASE.format(role_section=role_section) + output_language_directive(llm_output_language) + + parts = [build_directives_section(directives) if directives else ""] + parts.append(_FINAL_SYSTEM_PROMPT_BASE.format(role_section=role_section)) + parts.append(_FINAL_LANGUAGE_RULE) + parts.append(build_directives_reminder(directives) if directives else "") + + return "\n\n".join(p.strip() for p in parts if p.strip()) + output_language_directive(llm_output_language) # Backward-compatible constant for non-identity missions @@ -706,7 +734,65 @@ def build_final_system_prompt(mission: str | None = None, llm_output_language: s ``{"operations": [{"op": "replace_block", "section_id": "overview", "index": 0, "block": {"type": "paragraph", "text": "Updated summary."}}]}`` - Remove an obsolete block → - ``{"operations": [{"op": "remove_block", "section_id": "status", "index": 2}]}``""" + ``{"operations": [{"op": "remove_block", "section_id": "status", "index": 2}]}`` + +JSON STRING RULES (critical) +- Every ``text`` and ``items`` string must be valid JSON: escape ``"`` as ``\\"``, + backslashes as ``\\\\``, and newlines as ``\\n``. Do not use raw backticks inside + strings unless needed; prefer plain quotes for file paths. +- ``replace_block``, ``insert_block``, and ``remove_block`` MUST include ``index`` (0-based block position in that section). Use ``replace_section_blocks`` only when replacing every block in a section. + +- Do not append extra ``]`` or ``}`` after the closing ``}`` of the root object.""" + +_STRUCTURED_DELTA_DEFAULT_MAX_INPUT_TOKENS = 24_000 + + +def _truncate_cl100k(text: str, max_tokens: int) -> str: + """Truncate text to at most max_tokens using cl100k_base.""" + if max_tokens <= 0: + return "" + from .tokenization import count_cl100k_tokens + + if count_cl100k_tokens(text) <= max_tokens: + return text + enc = __import__("tiktoken").get_encoding("cl100k_base") + return enc.decode(enc.encode(text)[:max_tokens]) + + +def _fit_structured_delta_prompt_parts( + *, + source_query: str, + current_document_json: str, + candidate_markdown: str, + facts_block: str, + budget_hint: str, + task_footer: str, + max_input_tokens: int, +) -> tuple[str, str, str, bool]: + """Shrink large prompt sections to fit within max_input_tokens (cl100k estimate).""" + from .tokenization import count_cl100k_tokens + + fixed = ( + f"## Topic\n{source_query}\n\n" + f"## CURRENT DOCUMENT (apply ops to this; reference section ids as listed)\n" + f"```json\n\n```\n\n" + f"## NEW INFORMATION SYNTHESIS (context for how new facts relate to the topic)\n" + f"```markdown\n\n```\n\n" + f"## SUPPORTING FACTS (new since last refresh — integrate these)\n" + f"{budget_hint}\n\n" + f"{task_footer}" + ) + facts_header = "## SUPPORTING FACTS (new since last refresh — integrate these)\n" + facts_prefix_tokens = count_cl100k_tokens(facts_header) + reserved_facts = min(4096, max(512, max_input_tokens // 8)) + doc_budget = max(1024, (max_input_tokens - count_cl100k_tokens(fixed) - reserved_facts) * 55 // 100) + cand_budget = max(512, (max_input_tokens - count_cl100k_tokens(fixed) - reserved_facts) * 30 // 100) + facts_budget = max(256, reserved_facts - facts_prefix_tokens) + doc_json = _truncate_cl100k(current_document_json, doc_budget) + candidate = _truncate_cl100k(candidate_markdown, cand_budget) + facts_body = _truncate_cl100k(facts_block, facts_budget) + truncated = doc_json != current_document_json or candidate != candidate_markdown or facts_body != facts_block + return doc_json, candidate, facts_body, truncated def build_structured_delta_prompt( @@ -716,6 +802,7 @@ def build_structured_delta_prompt( supporting_facts: list[dict[str, Any]], source_query: str, max_output_tokens: int | None = None, + max_input_tokens: int | None = None, ) -> str: """Build the user prompt for a structured-delta mental model refresh. @@ -746,19 +833,39 @@ def build_structured_delta_prompt( "block-level ops) so the response always parses as valid JSON." ) - return ( - f"## Topic\n{source_query}\n\n" - f"## CURRENT DOCUMENT (apply ops to this; reference section ids as listed)\n" - f"```json\n{current_document_json}\n```\n\n" - f"## NEW INFORMATION SYNTHESIS (context for how new facts relate to the topic)\n" - f"```markdown\n{candidate_markdown}\n```\n\n" - f"## SUPPORTING FACTS (new since last refresh — integrate these)\n{facts_block}" - f"{budget_hint}\n\n" + task_footer = ( "## Task\n" "Output a JSON object matching the operations schema. Integrate the new " "supporting facts into CURRENT DOCUMENT. Add, update, or remove content " "as needed. Preserve unchanged sections and blocks by not mentioning them." ) + input_cap = max_input_tokens if max_input_tokens is not None else _STRUCTURED_DELTA_DEFAULT_MAX_INPUT_TOKENS + doc_json, candidate, facts_body, input_truncated = _fit_structured_delta_prompt_parts( + source_query=source_query, + current_document_json=current_document_json, + candidate_markdown=candidate_markdown, + facts_block=facts_block, + budget_hint=budget_hint, + task_footer=task_footer, + max_input_tokens=input_cap, + ) + truncation_note = "" + if input_truncated: + truncation_note = ( + "\n\n*Note: Document, synthesis, or facts were truncated to fit the model " + "context window. Prefer minimal, high-leverage operations.*" + ) + + return ( + f"## Topic\n{source_query}\n\n" + f"## CURRENT DOCUMENT (apply ops to this; reference section ids as listed)\n" + f"```json\n{doc_json}\n```\n\n" + f"## NEW INFORMATION SYNTHESIS (context for how new facts relate to the topic)\n" + f"```markdown\n{candidate}\n```\n\n" + f"## SUPPORTING FACTS (new since last refresh — integrate these)\n{facts_body}" + f"{budget_hint}{truncation_note}\n\n" + f"{task_footer}" + ) DELTA_SYSTEM_PROMPT = """You are performing a surgical delta update to an existing mental model document. diff --git a/hindsight-api-slim/hindsight_api/engine/reflect/tools_schema.py b/hindsight-api-slim/hindsight_api/engine/reflect/tools_schema.py index d9bb5a9462..ddc7ecff4b 100644 --- a/hindsight-api-slim/hindsight_api/engine/reflect/tools_schema.py +++ b/hindsight-api-slim/hindsight_api/engine/reflect/tools_schema.py @@ -232,6 +232,7 @@ def get_reflect_tools( include_mental_models: bool = True, include_observations: bool = True, include_recall: bool = True, + include_expand: bool = True, ) -> list[dict]: """ Get the list of tools for the reflect agent. @@ -247,6 +248,9 @@ def get_reflect_tools( include_mental_models: Whether to include the search_mental_models tool. include_observations: Whether to include the search_observations tool. include_recall: Whether to include the recall tool. + include_expand: Whether to include the expand tool. Disabled when raw + document/chunk text is not stored, since expand only reads back + source text and would return empty results. Returns: List of tool definitions in OpenAI format @@ -260,7 +264,8 @@ def get_reflect_tools( if include_recall: tools.append(TOOL_RECALL) - tools.append(TOOL_EXPAND) + if include_expand: + tools.append(TOOL_EXPAND) # Use directive-aware done tool if directives are present if directive_rules: diff --git a/hindsight-api-slim/hindsight_api/engine/response_models.py b/hindsight-api-slim/hindsight_api/engine/response_models.py index b4d82c1e61..ffa92c8d5c 100644 --- a/hindsight-api-slim/hindsight_api/engine/response_models.py +++ b/hindsight-api-slim/hindsight_api/engine/response_models.py @@ -105,6 +105,34 @@ def __add__(self, other: "TokenUsage") -> "TokenUsage": ) +class ExtractedFact(BaseModel): + """A single candidate fact produced by dry-run extraction (no resolution/links/persistence). + + A deliberate subset of the persisted memory-unit shape — only the fields a fresh extraction + yields. Storage/consolidation/curation fields (id, document_id, chunk_id, proof_count, state, …) + are omitted because nothing is stored. Entities are raw, unresolved names. + """ + + text: str = Field(description="The extracted fact text.") + fact_type: str = Field(description="Perspective classification: 'world' or 'experience'.") + occurred_start: str | None = Field(default=None, description="ISO timestamp the fact's event started, if dated.") + occurred_end: str | None = Field(default=None, description="ISO timestamp the fact's event ended, if dated.") + entities: list[str] = Field( + default_factory=list, description="Raw (unresolved) entity names mentioned in the fact." + ) + + +class DryRunExtractionResult(BaseModel): + """Result of dry-run fact extraction: candidate facts plus aggregated LLM token usage.""" + + facts: list[ExtractedFact] = Field( + default_factory=list, description="Candidate facts the retain step would extract." + ) + usage: TokenUsage = Field( + default_factory=TokenUsage, description="Aggregated token usage across the extraction LLM calls." + ) + + class DispositionTraits(BaseModel): """ Disposition traits for a memory bank. diff --git a/hindsight-api-slim/hindsight_api/engine/retain/bank_utils.py b/hindsight-api-slim/hindsight_api/engine/retain/bank_utils.py index 3285cf51fb..b980e80ebc 100644 --- a/hindsight-api-slim/hindsight_api/engine/retain/bank_utils.py +++ b/hindsight-api-slim/hindsight_api/engine/retain/bank_utils.py @@ -4,7 +4,6 @@ import json import logging -import re import uuid from dataclasses import dataclass from typing import TypedDict diff --git a/hindsight-api-slim/hindsight_api/engine/retain/chunk_storage.py b/hindsight-api-slim/hindsight_api/engine/retain/chunk_storage.py index 0c8eb5acf0..e1c5fab714 100644 --- a/hindsight-api-slim/hindsight_api/engine/retain/chunk_storage.py +++ b/hindsight-api-slim/hindsight_api/engine/retain/chunk_storage.py @@ -8,6 +8,7 @@ import logging from dataclasses import dataclass +from ...config import get_config from ..memory_engine import fq_table from .types import ChunkMetadata @@ -88,6 +89,11 @@ async def store_chunks_batch( if not chunks: return {} + # When document text storage is disabled, persist empty chunk_text (the + # column is NOT NULL) while still computing content_hash from the real text + # so delta-retain dedup is unaffected. + store_text = get_config().store_document_text + # Prepare chunk data for batch insert chunk_ids = [] chunk_texts = [] @@ -98,7 +104,7 @@ async def store_chunks_batch( for chunk in chunks: chunk_id = f"{bank_id}_{document_id}_{chunk.chunk_index}" chunk_ids.append(chunk_id) - chunk_texts.append(chunk.chunk_text) + chunk_texts.append(chunk.chunk_text if store_text else "") chunk_indices.append(chunk.chunk_index) content_hashes.append(compute_chunk_hash(chunk.chunk_text)) chunk_id_map[chunk.chunk_index] = chunk_id diff --git a/hindsight-api-slim/hindsight_api/engine/retain/embedding_utils.py b/hindsight-api-slim/hindsight_api/engine/retain/embedding_utils.py index 55ff2d9048..3bae37b6cd 100644 --- a/hindsight-api-slim/hindsight_api/engine/retain/embedding_utils.py +++ b/hindsight-api-slim/hindsight_api/engine/retain/embedding_utils.py @@ -3,6 +3,7 @@ """ import asyncio +import contextvars import logging from typing import Literal, Protocol @@ -89,7 +90,14 @@ async def generate_embeddings_batch( """ try: loop = asyncio.get_event_loop() - embeddings = await loop.run_in_executor(None, _encode_with_input_type, embeddings_backend, texts, input_type) + # run_in_executor runs the encode in a worker thread, which does NOT inherit + # the caller's contextvars. Capture the current context and run the encode + # inside it so context-dependent behavior (e.g. per-bank `user` attribution + # read via get_current_bank_id()) survives the thread hop. + ctx = contextvars.copy_context() + embeddings = await loop.run_in_executor( + None, lambda: ctx.run(_encode_with_input_type, embeddings_backend, texts, input_type) + ) except Exception as e: raise Exception(f"Failed to generate batch embeddings: {str(e)}") diff --git a/hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py b/hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py index 40427f3edc..74498b8af2 100644 --- a/hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py +++ b/hindsight-api-slim/hindsight_api/engine/retain/fact_extraction.py @@ -14,8 +14,9 @@ from pydantic import BaseModel, ConfigDict, Field, create_model, field_validator -from ...config import get_config +from ..llm_interface import ProviderRateLimitResetError from ..llm_wrapper import LLMConfig, OutputTooLongError, sanitize_llm_output +from ..operation_metadata import RetainExtractionErrors from ..response_models import TokenUsage from .entity_labels import ( EntityLabelsConfig, @@ -407,64 +408,93 @@ class VerbatimFactExtractionResponse(BaseModel): facts: list[VerbatimExtractedFact] = Field(description="List of metadata entries (one per chunk)") -def chunk_text(text: str, max_chars: int) -> list[str]: +# Separators for sentence-aware recursive text splitting, ordered most- to +# least-preferred. The final "" lets the splitter break mid-word as a last +# resort so a chunk can never exceed the size budget. +_RECURSIVE_TEXT_SEPARATORS = [ + "\n\n", # Paragraph breaks + "\n", # Line breaks + ". ", # Sentence endings + "! ", # Exclamations + "? ", # Questions + "; ", # Semicolons + ", ", # Commas + " ", # Words + "", # Characters (last resort) +] + + +def _split_oversized_unit(text: str, max_chars: int) -> list[str]: + """Sentence-aware split of a single unit that overflowed the budget. + + Used when one JSONL line / conversation turn is so large it can't be kept + whole within the configured structured-chunk limit. The resulting fragments + are no longer valid JSON, but the fact extractor treats every chunk as plain + text. + """ + from langchain_text_splitters import RecursiveCharacterTextSplitter + + splitter = RecursiveCharacterTextSplitter( + chunk_size=max_chars, + chunk_overlap=0, + length_function=len, + is_separator_regex=False, + separators=_RECURSIVE_TEXT_SEPARATORS, + ) + return splitter.split_text(text) + + +def chunk_text(text: str, max_chars: int, structured_chunk_size: int | None = None) -> list[str]: """ Split text into chunks, preserving conversation structure when possible. - For JSON conversation arrays (user/assistant turns), splits at turn boundaries - while preserving speaker context. For plain text, uses sentence-aware splitting. + For JSON conversation arrays (user/assistant turns) and JSONL (newline-delimited + JSON objects), splits at turn/line boundaries so no object is split across chunks. + A single turn/line that overflows ``max_chars`` is kept whole only up to + ``structured_chunk_size``. When unset, that limit defaults to ``max_chars``. + For plain text, uses sentence-aware splitting. Args: - text: Input text to chunk (plain text or JSON conversation) - max_chars: Maximum characters per chunk (default 120k ≈ 30k tokens) + text: Input text to chunk (plain text, JSON conversation, or JSONL) + max_chars: Target maximum characters per chunk + structured_chunk_size: Maximum characters for a single JSONL line or + conversation turn to keep whole. Defaults to ``max_chars``. Returns: List of text chunks, roughly under max_chars """ - from langchain_text_splitters import RecursiveCharacterTextSplitter - # If text is small enough, return as-is if len(text) <= max_chars: return [text] + structured_limit = structured_chunk_size if structured_chunk_size is not None else max_chars + # Try to parse as JSON conversation array try: parsed = json.loads(text) if isinstance(parsed, list) and all(isinstance(turn, dict) for turn in parsed): # This looks like a conversation - chunk at turn boundaries - return _chunk_conversation(parsed, max_chars) + return _chunk_conversation(parsed, max_chars, structured_limit) except (json.JSONDecodeError, ValueError): pass - # Fall back to sentence-aware text splitting - splitter = RecursiveCharacterTextSplitter( - chunk_size=max_chars, - chunk_overlap=0, - length_function=len, - is_separator_regex=False, - separators=[ - "\n\n", # Paragraph breaks - "\n", # Line breaks - ". ", # Sentence endings - "! ", # Exclamations - "? ", # Questions - "; ", # Semicolons - ", ", # Commas - " ", # Words - "", # Characters (last resort) - ], - ) + # Try to parse as JSONL (newline-delimited JSON objects, e.g. session logs) + jsonl_chunks = _chunk_jsonl(text, max_chars, structured_limit) + if jsonl_chunks is not None: + return jsonl_chunks - return splitter.split_text(text) + # Fall back to sentence-aware text splitting + return _split_oversized_unit(text, max_chars) -def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]: +def _chunk_conversation(turns: list[dict], max_chars: int, structured_limit: int) -> list[str]: """ Chunk a conversation array at turn boundaries, preserving complete turns. Args: turns: List of conversation turn dicts (with 'role' and 'content' keys) max_chars: Maximum characters per chunk + structured_limit: Maximum characters for a single turn to keep whole Returns: List of JSON-serialized chunks, each containing complete turns @@ -474,28 +504,105 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]: current_chunk = [] current_size = 2 # Account for "[]" + def _flush() -> None: + nonlocal current_chunk, current_size + if current_chunk: + chunks.append(json.dumps(current_chunk, ensure_ascii=False)) + current_chunk = [] + current_size = 2 # Reset to "[]" + for turn in turns: # Estimate size of this turn when serialized (with comma separator) turn_json = json.dumps(turn, ensure_ascii=False) - turn_size = len(turn_json) + 1 # +1 for comma + turn_unit_size = len(turn_json) + turn_size = turn_unit_size + 1 # +1 for comma + + # A turn too large to keep whole even alone: flush, then split it as + # text so no chunk runs far over budget (the extractor won't re-chunk). + if turn_unit_size > structured_limit: + _flush() + chunks.extend(_split_oversized_unit(turn_json, structured_limit)) + continue # If adding this turn would exceed limit and we have turns, save current chunk if current_size + turn_size > max_chars and current_chunk: - chunks.append(json.dumps(current_chunk, ensure_ascii=False)) - current_chunk = [] - current_size = 2 # Reset to "[]" + _flush() # Add turn to current chunk current_chunk.append(turn) current_size += turn_size # Add final chunk if non-empty - if current_chunk: - chunks.append(json.dumps(current_chunk, ensure_ascii=False)) + _flush() return chunks if chunks else [json.dumps(turns, ensure_ascii=False)] +def _chunk_jsonl(text: str, max_chars: int, structured_limit: int) -> list[str] | None: + """Chunk newline-delimited JSON (JSONL) at line boundaries. + + Detects JSONL — two or more non-empty lines, each a complete JSON object — + and packs whole lines into chunks so no line is split across chunks (multiple + short lines may share a chunk). A line that overflows ``max_chars`` is kept + whole only up to ``structured_limit``. Returns ``None`` if the input is not + JSONL, so the caller falls back to plain-text splitting. + + Args: + text: Input text to inspect/chunk. + max_chars: Maximum characters per chunk. + structured_limit: Maximum characters for a single JSONL line to + keep whole. + + Returns: + List of JSONL chunks (lines joined by newline), or ``None`` if not JSONL. + """ + lines = [line for line in text.splitlines() if line.strip()] + if len(lines) < 2: + return None + + for line in lines: + try: + obj = json.loads(line) + except (json.JSONDecodeError, ValueError): + return None + if not isinstance(obj, dict): + return None + + chunks: list[str] = [] + current_chunk: list[str] = [] + current_size = 0 + + def _flush() -> None: + nonlocal current_chunk, current_size + if current_chunk: + chunks.append("\n".join(current_chunk)) + current_chunk = [] + current_size = 0 + + for line in lines: + line_unit_size = len(line) + line_size = len(line) + 1 # +1 for the joining newline + + # A line too large to keep whole even alone: flush, then split it as + # text so no chunk runs far over budget (the extractor won't re-chunk). + if line_unit_size > structured_limit: + _flush() + chunks.extend(_split_oversized_unit(line, structured_limit)) + continue + + # If adding this line would exceed the limit and we have lines, flush. + # A line up to structured_limit is kept whole (a bounded overflow). + if current_size + line_size > max_chars and current_chunk: + _flush() + + current_chunk.append(line) + current_size += line_size + + _flush() + + return chunks + + # ============================================================================= # FACT EXTRACTION PROMPTS # ============================================================================= @@ -512,11 +619,11 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]: FACT FORMAT - BE CONCISE ══════════════════════════════════════════════════════════════════════════ -1. **what**: Core fact - concise but complete (1-2 sentences max) -2. **when**: Temporal info if mentioned. "N/A" if none. Use day name when known. -3. **where**: Location if relevant. "N/A" if none. -4. **who**: People involved with relationships. "N/A" if just general info. -5. **why**: Context/significance ONLY if important. "N/A" if obvious. +1. "what": Core fact - concise but complete (1-2 sentences max) +2. "when": Temporal info if mentioned. "N/A" if none. Use day name when known. +3. "where": Location if relevant. "N/A" if none. +4. "who": People involved with relationships. "N/A" if just general info. +5. "why": Context/significance ONLY if important. "N/A" if obvious. CONCISENESS: Capture the essence, not every word. One good sentence beats three mediocre ones. @@ -1641,7 +1748,11 @@ async def extract_facts_from_text( - chunks: List of tuples (chunk_text, fact_count) for each chunk - usage: Aggregated token usage across all LLM calls """ - chunks = chunk_text(text, max_chars=config.retain_chunk_size) + chunks = chunk_text( + text, + max_chars=config.retain_chunk_size, + structured_chunk_size=config.retain_structured_chunk_size, + ) # Log chunk count before starting LLM requests total_chars = sum(len(c) for c in chunks) @@ -1690,10 +1801,21 @@ async def extract_facts_from_text( total_usage = total_usage + chunk_usage if failed_chunks: + failed_summary = ", ".join(f"chunk {idx}: {type(err).__name__}" for idx, err in failed_chunks[:5]) + quota_errors = [err for _, err in failed_chunks if isinstance(err, ProviderRateLimitResetError)] + if quota_errors and len(quota_errors) == len(failed_chunks): + retry_at = max(err.retry_at for err in quota_errors) + raise ProviderRateLimitResetError( + retry_at=retry_at, + message=( + f"Fact extraction deferred by provider quota: {len(failed_chunks)}/{len(chunks)} chunks failed. " + f"First failures: {failed_summary}. Provider detail: {quota_errors[0]}" + ), + ) from quota_errors[0] + # Fail the entire retain — partial extraction is not acceptable. # All successfully extracted facts are discarded because the transaction # hasn't committed yet. The worker poller will retry the entire task. - failed_summary = ", ".join(f"chunk {idx}: {type(err).__name__}" for idx, err in failed_chunks[:5]) raise RuntimeError( f"Fact extraction failed: {len(failed_chunks)}/{len(chunks)} chunks failed. " f"First failures: {failed_summary}" @@ -1718,6 +1840,39 @@ async def extract_facts_from_text( SECONDS_PER_FACT = 0.01 +async def _write_batch_extraction_errors( + pool: Any, + operation_id: str | None, + schema: str | None, + errors: RetainExtractionErrors, +) -> None: + """Persist non-fatal Batch API extraction errors into operation result_metadata.""" + if not pool or not operation_id or errors.count == 0: + return + + from ..db_utils import acquire_with_retry + from ..task_backend import fq_table + + # `errors` is the complete set for this extraction run, so overwrite the + # extraction_errors_* keys rather than folding in what's already stored. On + # batch crash recovery the resumed batch reprocesses every result and + # recomputes `errors` from scratch; reading + merging the prior run's + # counters here would double-count them. The SQL `||` merge still preserves + # unrelated keys (e.g. batch_id) already on result_metadata. + table = fq_table("async_operations", schema) + async with acquire_with_retry(pool) as conn: + await conn.execute( + f""" + UPDATE {table} + SET result_metadata = COALESCE(result_metadata, '{{}}'::jsonb) || $2::jsonb, + updated_at = now() + WHERE operation_id = $1 + """, + operation_id, + json.dumps(errors.to_dict()), + ) + + async def extract_facts_from_contents_batch_api( contents: list[RetainContent], llm_config, @@ -1750,8 +1905,7 @@ async def extract_facts_from_contents_batch_api( logger.info(f"Using Batch API for fact extraction ({len(contents)} contents)") - # Check config for extraction mode and causal link extraction (used throughout) - extraction_mode = config.retain_extraction_mode + # Check config for causal link extraction (used throughout) extract_causal_links = config.retain_extract_causal_links # Check if provider supports batch API @@ -1792,7 +1946,11 @@ async def extract_facts_from_contents_batch_api( prompt, response_schema = _build_extraction_prompt_and_schema(config) for content_index, item in enumerate(contents): - chunks = chunk_text(item.content, max_chars=config.retain_chunk_size) + chunks = chunk_text( + item.content, + max_chars=config.retain_chunk_size, + structured_chunk_size=config.retain_structured_chunk_size, + ) for chunk_index_in_content, chunk in enumerate(chunks): all_chunks_info.append((chunk, content_index, chunk_index_in_content, item.event_date, item.context)) @@ -1895,6 +2053,7 @@ async def extract_facts_from_contents_batch_api( all_facts_from_llm = [] chunks_metadata = [] total_usage = TokenUsage() + extraction_errors = RetainExtractionErrors() for chunk_idx, (chunk_content, content_index, chunk_index_in_content, event_date, context) in enumerate( all_chunks_info @@ -1903,7 +2062,9 @@ async def extract_facts_from_contents_batch_api( result = results_by_id.get(custom_id) if not result: - logger.warning(f"Missing result for {custom_id}, skipping") + message = f"{custom_id}: missing batch result" + logger.warning(message) + extraction_errors.add(message) chunks_metadata.append( ChunkMetadata( chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx @@ -1913,7 +2074,9 @@ async def extract_facts_from_contents_batch_api( # Check for errors if result.get("error"): - logger.error(f"Error in {custom_id}: {result['error']}") + message = f"{custom_id}: {result['error']}" + logger.error(f"Error in {message}") + extraction_errors.add(message) chunks_metadata.append( ChunkMetadata( chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx @@ -1926,7 +2089,9 @@ async def extract_facts_from_contents_batch_api( choices = response_body.get("choices", []) if not choices: - logger.warning(f"No choices in response for {custom_id}") + message = f"{custom_id}: no choices in response" + logger.warning(message) + extraction_errors.add(message) chunks_metadata.append( ChunkMetadata( chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx @@ -1941,7 +2106,9 @@ async def extract_facts_from_contents_batch_api( try: extraction_response_json = json.loads(content_str) except json.JSONDecodeError as e: - logger.error(f"Failed to parse JSON for {custom_id}: {e}") + message = f"{custom_id}: failed to parse JSON: {e}" + logger.error(message) + extraction_errors.add(message) chunks_metadata.append( ChunkMetadata( chunk_text=chunk_content, fact_count=0, content_index=content_index, chunk_index=chunk_idx @@ -2114,7 +2281,9 @@ def get_value(field_name): fact = Fact(fact=combined_text, fact_type=fact_type, **fact_data) chunk_facts.append(fact) except Exception as e: - logger.error(f"Failed to create Fact model for fact {i}: {e}") + message = f"{custom_id}: failed to create Fact model for fact {i}: {e}" + logger.error(message) + extraction_errors.add(message) continue all_facts_from_llm.extend(chunk_facts) @@ -2179,6 +2348,8 @@ def get_value(field_name): # Step 8: Auto-tag facts from label groups with tag=True _inject_label_tags(extracted_facts, config) + await _write_batch_extraction_errors(pool, operation_id, schema, extraction_errors) + logger.info(f"Batch API extracted {len(extracted_facts)} facts from {len(all_chunks_info)} chunks") return extracted_facts, chunks_metadata, total_usage @@ -2200,7 +2371,11 @@ def _extract_facts_chunks( global_chunk_idx = 0 for content_index, content in enumerate(contents): - chunks = chunk_text(content.content, config.retain_chunk_size) + chunks = chunk_text( + content.content, + config.retain_chunk_size, + structured_chunk_size=config.retain_structured_chunk_size, + ) for chunk in chunks: chunks_metadata.append( ChunkMetadata( diff --git a/hindsight-api-slim/hindsight_api/engine/retain/fact_storage.py b/hindsight-api-slim/hindsight_api/engine/retain/fact_storage.py index ee5b480f6f..d9c93a0f5e 100644 --- a/hindsight-api-slim/hindsight_api/engine/retain/fact_storage.py +++ b/hindsight-api-slim/hindsight_api/engine/retain/fact_storage.py @@ -399,7 +399,12 @@ async def _upsert_document_row( INSERT so that re-ingesting a document (which deletes + inserts the row) keeps the original creation timestamp. ``updated_at`` is always set to ``NOW()`` on both INSERT and the ON CONFLICT UPDATE branch. + + When ``store_document_text`` is disabled, the raw source text + is dropped and ``original_text`` is stored as NULL. The ``content_hash`` is + still computed from the real content so delta-retain dedup is unaffected. """ + original_text = combined_content if get_config().store_document_text else None await conn.execute( f""" INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, retain_params, tags, created_at, updated_at) @@ -413,7 +418,7 @@ async def _upsert_document_row( """, document_id, bank_id, - combined_content, + original_text, content_hash, json.dumps(retain_params) if retain_params else None, document_tags or [], diff --git a/hindsight-api-slim/hindsight_api/engine/retain/link_utils.py b/hindsight-api-slim/hindsight_api/engine/retain/link_utils.py index aa96c8337f..ee88410ddd 100644 --- a/hindsight-api-slim/hindsight_api/engine/retain/link_utils.py +++ b/hindsight-api-slim/hindsight_api/engine/retain/link_utils.py @@ -574,12 +574,10 @@ async def compute_semantic_links_ann( # the transaction end handles both. rows: list = [] async with conn.transaction(): - # Transaction-local ANN tuning. Each supported backend exposes its own - # GUC (hnsw.ef_search on pgvector, vchordrq.probes on vchord); the - # dispatcher returns the right knob for the configured backend with a - # value tuned for top-50 semantic link creation (lower recall but much - # lower latency than the recall-side default). SET LOCAL auto-reverts - # at commit, so we don't pollute the pool for subsequent queries. + # Transaction-local ANN tuning. The dispatcher only returns GUCs that + # are safe to apply at session/transaction scope for the configured + # backend. VectorChord probe values are index-shaped, so vchordrq uses + # index storage fallback parameters instead of a blanket SET LOCAL. for guc, value in ann_search_tuning_settings(configured_vector_extension(), kind="low_latency"): await conn.execute(f"SET LOCAL {guc} = {value}") @@ -636,7 +634,7 @@ async def compute_semantic_links_ann( logger.debug(f"[ANN] fact_type={fact_type}: {len(ft_rows)} rows in {time_mod.time() - t_query:.3f}s") rows.extend(ft_rows) # Transaction commits here. _ann_seeds is dropped (ON COMMIT DROP). - # hnsw.ef_search reverts (SET LOCAL). + # Transaction-local ANN tuning reverts (SET LOCAL). for row in rows: sim = float(min(1.0, max(0.0, row["similarity"]))) @@ -802,8 +800,6 @@ async def create_causal_links_batch( try: import time as time_mod - create_start = time_mod.time() - # Build links list links = [] for fact_idx, causal_relations in enumerate(causal_relations_per_fact): diff --git a/hindsight-api-slim/hindsight_api/engine/retain/orchestrator.py b/hindsight-api-slim/hindsight_api/engine/retain/orchestrator.py index 531e3e03ad..bbb339108d 100644 --- a/hindsight-api-slim/hindsight_api/engine/retain/orchestrator.py +++ b/hindsight-api-slim/hindsight_api/engine/retain/orchestrator.py @@ -15,18 +15,166 @@ from datetime import UTC, datetime from typing import Any +from ...extensions.memory_defense import ( + DefenseAction, + DefenseDecision, + MemoryDefenseExtension, + apply_redaction, + parse_policy, +) from ...worker.stage import set_stage -from ..db.base import DatabaseBackend from ..db_utils import acquire_with_retry from ..memory_engine import count_tokens, fq_table from . import bank_utils +@dataclass +class BlockedViolation: + """One item blocked by the Memory Defense policy (surfaced in the 422 body).""" + + index: int + detector: str | None + message: str + + +class MemoryDefenseAllBlockedError(Exception): + """Raised when every item in a retain batch is blocked by the Memory Defense policy.""" + + def __init__(self, violations: list[BlockedViolation]) -> None: + self.violations = violations + super().__init__(f"all {len(violations)} items blocked by Memory Defense policy") + + def utcnow(): """Get current UTC time.""" return datetime.now(UTC) +def _redact_document_body(body: str, config: Any) -> str: + """Apply Memory Defense redaction to a document body. + + Per-item screening only scrubs the chunked content that goes through + `screen()`. When a sub-batch carries `document_body_override` (the full + original text of an oversized item — see `_split_contents_into_sub_batches`), + that override bypasses screening and would persist verbatim into + `documents.original_text`. Apply the same redactor here so the document + body is scrubbed regardless of which path produced it. + """ + try: + policy = parse_policy(getattr(config, "memory_defense", None)) + except Exception: + return body + if not policy.enabled: + return body + if not any(r.on == "sensitive_data" for r in policy.rules): + return body + return apply_redaction(body).content + + +async def _fire_memory_defense_webhook( + webhook_manager: Any, + *, + conn: Any, + schema: str | None, + bank_id: str, + operation_id: str | None, + document_id: str | None, + decision: DefenseDecision, +) -> None: + """Fire a memory_defense.triggered webhook for a non-allow decision. + + No-op when no webhook manager is wired or none is subscribed. Delivery + failures are swallowed so screening never blocks a retain. + """ + if webhook_manager is None: + return + try: + from ...webhooks import ( + MemoryDefenseEventData, + MemoryDefenseHit, + WebhookEvent, + WebhookEventType, + ) + + # Translate per-match raw dicts on the decision into MemoryDefenseHit + # entries on the wire. The decision's hits list is already fingerprinted + # by apply_redaction (the raw value never lands in hits, by contract), + # so this is purely a shape conversion. None when no per-hit data is + # available so receivers can distinguish "no preview info" from + # "scanned, nothing matched" (the latter wouldn't be a webhook delivery + # in the first place). + decision_hits = getattr(decision, "hits", None) or [] + hits: list[MemoryDefenseHit] | None = [ + MemoryDefenseHit( + detector=str(h.get("detector") or ""), + preview=str(h.get("preview") or ""), + ) + for h in decision_hits + if h.get("detector") and h.get("preview") + ] or None + + event = WebhookEvent( + event=WebhookEventType.MEMORY_DEFENSE_TRIGGERED, + bank_id=bank_id, + operation_id=operation_id or "", + status=decision.action.value, + timestamp=utcnow(), + data=MemoryDefenseEventData( + action=decision.action.value, + detector=decision.detector, + document_id=document_id, + matched_types=decision.matched_types or None, + message=decision.message or None, + hits=hits, + # Optional SIEM-enrichment fields populated by downstream + # extensions (e.g. hindsight-cloud's _CloudDefenseDecision + # subclass). Read via getattr so OSS doesn't need to know + # about extension subclasses. Combined with the manager's + # exclude_none serialization, missing values stay absent + # from the wire entirely rather than appearing as null. + severity=getattr(decision, "severity", None), + api_key_name=getattr(decision, "api_key_name", None), + memory_unit_id=getattr(decision, "memory_unit_id", None), + receipt_uri=getattr(decision, "receipt_uri", None), + ), + ) + await webhook_manager.fire_event_with_conn(event, conn, schema=schema) + except Exception: + logger.warning("memory_defense webhook delivery failed", exc_info=True) + + +def _audit_memory_defense( + audit_logger: Any, + *, + bank_id: str, + document_id: str | None, + decision: DefenseDecision, +) -> None: + """Write a fire-and-forget ``memory_defense`` audit entry for a non-allow decision. + + No-op when audit logging is disabled (the logger gates on its own config). + The action taken (redact/block) and what matched live in the entry metadata. + """ + if audit_logger is None: + return + from ..audit import AuditEntry + + entry = AuditEntry( + action="memory_defense", + transport="system", + bank_id=bank_id, + metadata={ + "action": decision.action.value, + "detector": decision.detector, + "document_id": document_id, + "matched_types": decision.matched_types, + "message": decision.message, + }, + ) + entry.ended_at = entry.started_at # point-in-time policy decision (duration 0) + audit_logger.log_fire_and_forget(entry) + + def _merge_processed_content_tokens(a: int | None, b: int | None) -> int | None: """Combine the processed-content-tokens signal across sub-results. @@ -189,6 +337,8 @@ def _build_retain_params(contents_dicts, document_tags=None, doc_contents=None): ) if first_item.get("metadata"): retain_params["metadata"] = first_item["metadata"] + if first_item.get("observation_scopes") is not None: + retain_params["observation_scopes"] = first_item["observation_scopes"] return retain_params, merged_tags @@ -456,6 +606,9 @@ async def retain_batch( document_body_override: str | None = None, chunk_index_offset: int = 0, progress_callback: "Callable[..., Awaitable[None]] | None" = None, + webhook_manager: Any = None, + memory_defense_extension: "MemoryDefenseExtension | None" = None, + audit_logger: Any = None, ) -> tuple[list[list[str]], TokenUsage, int | None]: """ Process a batch of content through the retain pipeline. @@ -546,6 +699,10 @@ async def retain_batch( db_semaphore=db_semaphore, document_body_override=document_body_override, chunk_index_offset=chunk_index_offset, + progress_callback=progress_callback, + webhook_manager=webhook_manager, + memory_defense_extension=memory_defense_extension, + audit_logger=audit_logger, ) for group_idx, orig_idx in enumerate(original_indices[doc_key]): if group_idx < len(group_ids): @@ -554,6 +711,80 @@ async def retain_batch( total_processed_tokens = _merge_processed_content_tokens(total_processed_tokens, group_processed) return result_unit_ids, total_usage, total_processed_tokens + # --- Memory Defense pre-extraction screening --- + # Delegate to the loaded extension. `config` is a resolved HindsightConfig + # object at this point (see _retain_batch_async_internal). On a non-allow + # decision we redact in place or drop the item, and fire a + # memory_defense.triggered webhook when one is configured. + _policy = parse_policy(getattr(config, "memory_defense", None)) + _blocked_violations: list[BlockedViolation] = [] + + if memory_defense_extension is not None and _policy.enabled: + async with acquire_with_retry(pool) as _defense_conn: + for _idx, _content in enumerate(contents): + # Prefer the per-item document_id over the batch-level value so + # the decision and webhook carry the document the caller + # submitted, not whichever doc_id the batch happens to share. + _item_doc_id = contents_dicts[_idx].get("document_id") or document_id + + _decision = await memory_defense_extension.screen( + policy=_policy, + bank_id=bank_id, + document_id=_item_doc_id, + content=_content.content, + tags=_content.tags, + ) + + if _decision.action is DefenseAction.ALLOW: + continue + + if _decision.action is DefenseAction.REDACT: + _redacted = _decision.redacted_content or _content.content + _content.content = _redacted + # Mirror the redaction into the raw dict so the document + # body persisted further down the pipeline also stores the + # redacted text, not the verbatim secret. + contents_dicts[_idx]["content"] = _redacted + elif _decision.action is DefenseAction.BLOCK: + _blocked_violations.append( + BlockedViolation( + index=_idx, + detector=_decision.detector, + message=_decision.message, + ) + ) + + await _fire_memory_defense_webhook( + webhook_manager, + conn=_defense_conn, + schema=schema, + bank_id=bank_id, + operation_id=operation_id, + document_id=_item_doc_id, + decision=_decision, + ) + _audit_memory_defense( + audit_logger, + bank_id=bank_id, + document_id=_item_doc_id, + decision=_decision, + ) + + if _blocked_violations: + # All items blocked → raise so the HTTP layer can return 422. + if len(_blocked_violations) == len(contents): + raise MemoryDefenseAllBlockedError(_blocked_violations) + + # Remove blocked items from the pipeline. + _skip_indices = {v.index for v in _blocked_violations} + if _skip_indices: + _surviving = [i for i in range(len(contents)) if i not in _skip_indices] + contents = [contents[i] for i in _surviving] + contents_dicts = [contents_dicts[i] for i in _surviving] + # If nothing survives, return empty results immediately. + if not contents: + return [[] for _ in contents_dicts], TokenUsage(), 0 + # Resolve effective document_id early so both delta and streaming paths # can find existing chunks from a prior attempt. On retry, a generated # document_id is recovered from operation result_metadata.document_ids[0]. @@ -700,10 +931,15 @@ async def retain_batch( # retain code paths. chunk_batch_size = getattr(config, "retain_chunk_batch_size", 100) chunk_size = getattr(config, "retain_chunk_size", 3000) + structured_chunk_size = getattr(config, "retain_structured_chunk_size", None) all_pre_chunks: list[str] = [] chunk_to_content: list[int] = [] # maps chunk index -> index into contents for content_idx, content in enumerate(contents): - content_chunks = fact_extraction.chunk_text(content.content, chunk_size) + content_chunks = fact_extraction.chunk_text( + content.content, + chunk_size, + structured_chunk_size=structured_chunk_size, + ) all_pre_chunks.extend(content_chunks) chunk_to_content.extend([content_idx] * len(content_chunks)) @@ -928,7 +1164,9 @@ async def _streaming_retain_batch( # so documents.original_text stores the complete payload, not just this # slice (issue #1838). if document_body_override is not None: - combined_content = document_body_override + # The override is the unmodified original body — apply redaction so + # secrets in oversized inputs don't bypass screening. + combined_content = _redact_document_body(document_body_override, config) else: combined_content = "\n".join([c.get("content", "") for c in contents_dicts]) # Memory: contents_dicts content strings are now captured in combined_content. @@ -1410,8 +1648,19 @@ async def _run_mini_batch_db_work() -> None: # Check if facts are already committed (recovery from previous crash). # If so, skip extraction+writes and jump straight to final ANN pass. # --------------------------------------------------------------------------- + # Only the call that starts a document at chunk 0 may take the whole-document + # skip. When an oversized single item is split into several sequential + # sub-batches that SHARE one document_id AND one operation_id (see + # _split_contents_into_sub_batches), the first sub-batch commits its chunks + # and stamps effective_doc_id into result_metadata.facts_committed_document_ids. + # Without the offset gate, every later sub-batch (chunk_index_offset > 0) would + # then see its own document already "committed" and skip extraction, dropping + # all chunks past the first slice. A non-zero offset inherently means this call + # continues a document another sub-batch already started, so it must always do + # its work — crash-safety for those chunks still comes from the per-chunk hash + # recovery (existing_chunk_hashes) below. facts_already_committed = False - if operation_id: + if operation_id and chunk_index_offset == 0: try: async with acquire_with_retry(pool) as conn: row = await conn.fetchrow( @@ -1719,6 +1968,7 @@ async def _try_delta_retain( start_time, outbox_callback, document_body_override=document_body_override, + config=config, ) # Build content items for only the changed/new chunks @@ -1736,6 +1986,7 @@ async def _try_delta_retain( start_time, outbox_callback, document_body_override=document_body_override, + config=config, ) # Freshness recheck BEFORE the (expensive) LLM extraction. @@ -1787,6 +2038,7 @@ async def _try_delta_retain( start_time, outbox_callback, document_body_override=document_body_override, + config=config, ) log_buffer.append( f"[delta] Recheck: {len(recheck.changed) + len(recheck.new) + len(recheck.removed)} chunks still differ — " @@ -1862,9 +2114,10 @@ async def _run_delta_db_work() -> None: step_start = time.time() # When this sub-batch is one slice of an oversized item # split across multiple sub-batches, store the full body - # (issue #1838) instead of just the slice. + # (issue #1838) instead of just the slice. Redact the + # override since it bypassed per-chunk screening. if document_body_override is not None: - combined_content = document_body_override + combined_content = _redact_document_body(document_body_override, config) else: combined_content = "\n".join([c.get("content", "") for c in contents_dicts]) retain_params, merged_tags = _build_retain_params(contents_dicts, document_tags) @@ -1993,6 +2246,7 @@ async def _delta_metadata_only( outbox_callback, *, document_body_override: str | None = None, + config: Any = None, ): """Handle the case where no chunks changed — just update document metadata and tags.""" async with acquire_with_retry(pool) as conn: @@ -2005,8 +2259,9 @@ async def _delta_metadata_only( ) # When this sub-batch is a slice of an oversized item, write the # full original body (issue #1838) instead of just the slice. + # Redact the override since it bypassed per-chunk screening. if document_body_override is not None: - combined_content = document_body_override + combined_content = _redact_document_body(document_body_override, config) else: combined_content = "\n".join([c.get("content", "") for c in contents_dicts]) retain_params, merged_tags = _build_retain_params(contents_dicts, document_tags) @@ -2075,9 +2330,14 @@ def _chunk_contents_for_delta(contents: list[RetainContent], config) -> dict[int """ result = {} global_chunk_idx = 0 + chunk_size = getattr(config, "retain_chunk_size", 3000) + structured_chunk_size = getattr(config, "retain_structured_chunk_size", None) for content in contents: - chunk_size = getattr(config, "retain_chunk_size", 3000) - chunks = fact_extraction.chunk_text(content.content, chunk_size) + chunks = fact_extraction.chunk_text( + content.content, + chunk_size, + structured_chunk_size=structured_chunk_size, + ) for chunk_text in chunks: result[global_chunk_idx] = chunk_text global_chunk_idx += 1 diff --git a/hindsight-api-slim/hindsight_api/engine/retain/types.py b/hindsight-api-slim/hindsight_api/engine/retain/types.py index b4c1c6a426..271e82ecc8 100644 --- a/hindsight-api-slim/hindsight_api/engine/retain/types.py +++ b/hindsight-api-slim/hindsight_api/engine/retain/types.py @@ -24,7 +24,9 @@ class RetainContentDict(TypedDict, total=False): tags: Visibility scope tags for this content item (optional) 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. + single pass with all tags; "shared" runs a single pass over one global, + untagged scope so memories consolidate together regardless of 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. @@ -38,7 +40,7 @@ class RetainContentDict(TypedDict, total=False): entities: list[dict[str, str]] # [{"text": "...", "type": "..."}] tags: list[str] # Visibility scope tags observation_scopes: ( - Literal["per_tag", "combined", "all_combinations"] | list[list[str]] + Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] ) # Observation scopes for consolidation update_mode: Literal["replace", "append"] @@ -57,7 +59,7 @@ class RetainContent: metadata: dict[str, str] = field(default_factory=dict) entities: list[dict[str, str]] = field(default_factory=list) # User-provided entities tags: list[str] = field(default_factory=list) # Visibility scope tags - observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = ( + observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = ( None # Observation scopes ) @@ -124,7 +126,7 @@ class ExtractedFact: mentioned_at: datetime | None = None metadata: dict[str, str] = field(default_factory=dict) tags: list[str] = field(default_factory=list) # Visibility scope tags - observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = ( + observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = ( None # Observation scopes ) @@ -176,7 +178,7 @@ class ProcessedFact: tags: list[str] = field(default_factory=list) # Observation scopes for consolidation - observation_scopes: Literal["per_tag", "combined", "all_combinations"] | list[list[str]] | None = None + observation_scopes: Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] | None = None @property def is_duplicate(self) -> bool: diff --git a/hindsight-api-slim/hindsight_api/engine/search/fusion.py b/hindsight-api-slim/hindsight_api/engine/search/fusion.py index bb84b200a6..4903e8ef70 100644 --- a/hindsight-api-slim/hindsight_api/engine/search/fusion.py +++ b/hindsight-api-slim/hindsight_api/engine/search/fusion.py @@ -2,8 +2,6 @@ Helper functions for hybrid search (semantic + BM25 + graph). """ -from typing import Any - from .types import MergedCandidate, RetrievalResult @@ -156,39 +154,3 @@ def interleave_fusion(result_lists: list[list[RetrievalResult]]) -> list[MergedC ) for pos, doc_id in enumerate(ordered_ids) ] - - -def normalize_scores_on_deltas(results: list[dict[str, Any]], score_keys: list[str]) -> list[dict[str, Any]]: - """ - Normalize scores based on deltas (min-max normalization within result set). - - This ensures all scores are in [0, 1] range based on the spread in THIS result set. - - Args: - results: List of result dicts - score_keys: Keys to normalize (e.g., ["recency", "frequency"]) - - Returns: - Results with normalized scores added as "{key}_normalized" - """ - for key in score_keys: - values = [r.get(key, 0.0) for r in results if key in r] - - if not values: - continue - - min_val = min(values) - max_val = max(values) - delta = max_val - min_val - - if delta > 0: - for r in results: - if key in r: - r[f"{key}_normalized"] = (r[key] - min_val) / delta - else: - # All values are the same, set to 0.5 - for r in results: - if key in r: - r[f"{key}_normalized"] = 0.5 - - return results diff --git a/hindsight-api-slim/hindsight_api/engine/search/reranking.py b/hindsight-api-slim/hindsight_api/engine/search/reranking.py index 099a5b53b5..919fa6236b 100644 --- a/hindsight-api-slim/hindsight_api/engine/search/reranking.py +++ b/hindsight-api-slim/hindsight_api/engine/search/reranking.py @@ -99,9 +99,15 @@ def apply_combined_scoring( for sr in scored_results: # Recency: linear decay over 365 days → [0.1, 1.0]; neutral 0.5 if no date. + # Use the unit's effective time (occurred_start, then mentioned_at, then + # occurred_end) — the same COALESCE order as retrieval._coalesce_date — so a + # memory that carries only a mentioned_at / occurred_end (e.g. conversation + # facts or ongoing states that intentionally lack occurred_start) still gets + # correct recency ordering instead of a flat neutral 0.5. sr.recency = 0.5 - if sr.retrieval.occurred_start: - occurred = sr.retrieval.occurred_start + effective = sr.retrieval.occurred_start or sr.retrieval.mentioned_at or sr.retrieval.occurred_end + if effective: + occurred = effective if occurred.tzinfo is None: occurred = occurred.replace(tzinfo=UTC) days_ago = (now - occurred).total_seconds() / 86400 diff --git a/hindsight-api-slim/hindsight_api/engine/search/retrieval.py b/hindsight-api-slim/hindsight_api/engine/search/retrieval.py index c93c1371a0..9cf866e639 100644 --- a/hindsight-api-slim/hindsight_api/engine/search/retrieval.py +++ b/hindsight-api-slim/hindsight_api/engine/search/retrieval.py @@ -13,7 +13,7 @@ import re from dataclasses import dataclass, field from datetime import UTC, datetime -from typing import Any, Optional +from typing import TYPE_CHECKING, Any, Optional from ...config import get_config from ..db_utils import acquire_with_retry @@ -24,6 +24,9 @@ from .tags import TagGroup, TagsMatch, build_tag_groups_where_clause, build_tags_where_clause_simple from .types import GraphRetrievalTimings, RetrievalResult +if TYPE_CHECKING: + from ..query_analyzer import QueryAnalyzer + logger = logging.getLogger(__name__) @@ -137,6 +140,7 @@ async def retrieve_semantic_bm25_combined( """ result_dict: dict[str, tuple[list[RetrievalResult], list[RetrievalResult]]] = {ft: ([], []) for ft in fact_types} + config = get_config() tokens = tokenize_query(query_text) # Over-fetch for HNSW approximation; semantic results trimmed to limit in Python. @@ -148,8 +152,6 @@ async def retrieve_semantic_bm25_combined( ) table = fq_table("memory_units") - config = get_config() - # Use the SQL dialect to build backend-specific query arms, avoiding # inline if/else branches for each database. # Use getattr for backward compat: raw asyncpg connections (used in some @@ -201,6 +203,7 @@ async def retrieve_semantic_bm25_combined( embedding_param="$1", bank_id_param="$2", fetch_limit=hnsw_fetch, + min_similarity=config.semantic_min_similarity, tags_clause=tags_clause, groups_clause=groups_clause, extra_where=created_range_clause, @@ -274,6 +277,7 @@ async def retrieve_semantic_bm25_combined( embedding_param="$1", bank_id_param="$2", fetch_limit=hnsw_fetch, + min_similarity=config.semantic_min_similarity, tags_clause=fb_tags_clause, groups_clause=fb_groups_clause, extra_where=fb_created_clause, diff --git a/hindsight-api-slim/hindsight_api/engine/search/tags.py b/hindsight-api-slim/hindsight_api/engine/search/tags.py index 5eb999ffc5..a14032fc51 100644 --- a/hindsight-api-slim/hindsight_api/engine/search/tags.py +++ b/hindsight-api-slim/hindsight_api/engine/search/tags.py @@ -2,14 +2,18 @@ Tags filtering utilities for retrieval. Provides SQL building functions for filtering memories by tags. -Supports four matching modes via TagsMatch enum: +Supports five matching modes via TagsMatch enum: - "any": OR matching, includes untagged memories (default, backward compatible) - "all": AND matching, includes untagged memories - "any_strict": OR matching, excludes untagged memories - "all_strict": AND matching, excludes untagged memories +- "exact": set-equality matching, excludes untagged memories OR matching (any/any_strict): Memory matches if ANY of its tags overlap with request tags AND matching (all/all_strict): Memory matches if ALL request tags are present in its tags +EXACT matching: Memory matches only if its tag set EQUALS the request tag set (order- + independent). Used for observation "scope" filtering, where each observation lives + under exactly one scope (its full tag set) and "scope [a]" must not match "[a, b]". """ from __future__ import annotations @@ -18,7 +22,7 @@ from pydantic import BaseModel, ConfigDict, Field -TagsMatch = Literal["any", "all", "any_strict", "all_strict"] +TagsMatch = Literal["any", "all", "any_strict", "all_strict", "exact"] def _parse_tags_match(match: TagsMatch) -> tuple[str, bool]: @@ -38,6 +42,10 @@ def _parse_tags_match(match: TagsMatch) -> tuple[str, bool]: return "&&", False elif match == "all_strict": return "@>", False + elif match == "exact": + # Set equality is handled by the callers via `@> AND <@`; the operator + # here is unused. Untagged rows never equal a non-empty scope. + return "@>", False else: # Default to "any" behavior return "&&", True @@ -78,6 +86,13 @@ def build_tags_where_clause( return "", [], param_offset column = f"{table_alias}tags" if table_alias else "tags" + + if match == "exact": + # Set equality (order-independent): superset AND subset. Untagged rows + # (empty array) never satisfy `@>` of a non-empty scope, so they're excluded. + clause = f"AND ({column} @> ${param_offset} AND {column} <@ ${param_offset})" + return clause, [tags], param_offset + 1 + operator, include_untagged = _parse_tags_match(match) if include_untagged: @@ -115,6 +130,12 @@ def build_tags_where_clause_simple( return "" column = f"{table_alias}tags" if table_alias else "tags" + + if match == "exact": + # Set equality (order-independent): superset AND subset. Untagged rows + # (empty array) never satisfy `@>` of a non-empty scope, so they're excluded. + return f"AND ({column} @> ${param_num} AND {column} <@ ${param_num})" + operator, include_untagged = _parse_tags_match(match) if include_untagged: @@ -164,7 +185,11 @@ def filter_results_by_tags( # else: skip untagged else: result_tags_set = set(result_tags) - if is_any_match: + if match == "exact": + # Set equality: tag set must match the scope exactly + if result_tags_set == tags_set: + filtered.append(result) + elif is_any_match: # Any overlap if result_tags_set & tags_set: filtered.append(result) @@ -241,6 +266,9 @@ def _build_group_clause( """ if isinstance(group, TagGroupLeaf): column = f"{table_alias}tags" if table_alias else "tags" + if group.match == "exact": + clause = f"({column} @> ${param_offset} AND {column} <@ ${param_offset})" + return clause, [group.tags], param_offset + 1 operator, include_untagged = _parse_tags_match(group.match) if include_untagged: clause = f"({column} IS NULL OR {column} = '{{}}' OR {column} {operator} ${param_offset})" @@ -349,6 +377,8 @@ def _match_group(result: object, group: TagGroup) -> bool: return include_untagged else: result_tags_set = set(result_tags) + if group.match == "exact": + return result_tags_set == tags_set if is_any_match: return bool(result_tags_set & tags_set) else: diff --git a/hindsight-api-slim/hindsight_api/engine/search/tracer.py b/hindsight-api-slim/hindsight_api/engine/search/tracer.py index 6298e34b11..4cf3947219 100644 --- a/hindsight-api-slim/hindsight_api/engine/search/tracer.py +++ b/hindsight-api-slim/hindsight_api/engine/search/tracer.py @@ -358,12 +358,15 @@ def add_rrf_merged(self, merged_results: list[tuple]): """ self.rrf_merged = [] for rank, (doc_id, data, rrf_meta) in enumerate(merged_results, start=1): + source_ranks = rrf_meta.get("source_ranks") + if source_ranks is None: + source_ranks = {key: value for key, value in rrf_meta.items() if key.endswith("_rank")} self.rrf_merged.append( RRFMergeResult( node_id=doc_id, text=data.get("text", ""), rrf_score=rrf_meta.get("rrf_score", 0.0), - source_ranks=rrf_meta.get("source_ranks", {}), + source_ranks=source_ranks, final_rrf_rank=rank, ) ) diff --git a/hindsight-api-slim/hindsight_api/engine/sql/base.py b/hindsight-api-slim/hindsight_api/engine/sql/base.py index 9867ee716a..92b1ea9430 100644 --- a/hindsight-api-slim/hindsight_api/engine/sql/base.py +++ b/hindsight-api-slim/hindsight_api/engine/sql/base.py @@ -371,6 +371,7 @@ def build_semantic_arm( embedding_param: str, bank_id_param: str, fetch_limit: int, + min_similarity: float, tags_clause: str = "", groups_clause: str = "", extra_where: str = "", @@ -387,6 +388,7 @@ def build_semantic_arm( embedding_param: Parameter placeholder for query embedding. bank_id_param: Parameter placeholder for bank_id. fetch_limit: Max rows to fetch (over-fetched for HNSW approximation). + min_similarity: Minimum cosine similarity to include. tags_clause: Optional WHERE clause fragment for tag filtering. groups_clause: Optional WHERE clause fragment for tag group filtering. extra_where: Optional additional WHERE clause fragment (e.g. time range filter). diff --git a/hindsight-api-slim/hindsight_api/engine/sql/oracle.py b/hindsight-api-slim/hindsight_api/engine/sql/oracle.py index 0ee1b03057..3f8610b072 100644 --- a/hindsight-api-slim/hindsight_api/engine/sql/oracle.py +++ b/hindsight-api-slim/hindsight_api/engine/sql/oracle.py @@ -234,6 +234,7 @@ def build_semantic_arm( embedding_param: str, bank_id_param: str, fetch_limit: int, + min_similarity: float, tags_clause: str = "", groups_clause: str = "", extra_where: str = "", @@ -249,7 +250,7 @@ def build_semantic_arm( f" WHERE bank_id = {bank_id_param}" f" AND fact_type = '{fact_type}'" f" AND embedding IS NOT NULL" - f" AND (1 - VECTOR_DISTANCE(embedding, {embedding_param}, COSINE)) >= 0.3" + f" AND (1 - VECTOR_DISTANCE(embedding, {embedding_param}, COSINE)) >= {min_similarity}" f" {tags_clause}" f" {groups_clause}" f" {extra_where}" diff --git a/hindsight-api-slim/hindsight_api/engine/sql/postgresql.py b/hindsight-api-slim/hindsight_api/engine/sql/postgresql.py index 8d7d795566..42ac27cf37 100644 --- a/hindsight-api-slim/hindsight_api/engine/sql/postgresql.py +++ b/hindsight-api-slim/hindsight_api/engine/sql/postgresql.py @@ -148,6 +148,7 @@ def build_semantic_arm( embedding_param: str, bank_id_param: str, fetch_limit: int, + min_similarity: float, tags_clause: str = "", groups_clause: str = "", extra_where: str = "", @@ -161,7 +162,7 @@ def build_semantic_arm( f" WHERE bank_id = {bank_id_param}" f" AND fact_type = '{fact_type}'" f" AND embedding IS NOT NULL" - f" AND (1 - (embedding <=> {embedding_param}::vector)) >= 0.3" + f" AND (1 - (embedding <=> {embedding_param}::vector)) >= {min_similarity}" f" {tags_clause}" f" {groups_clause}" f" {extra_where}" diff --git a/hindsight-api-slim/hindsight_api/engine/storage/gcs.py b/hindsight-api-slim/hindsight_api/engine/storage/gcs.py index 6013a19354..b98f9d07b5 100644 --- a/hindsight-api-slim/hindsight_api/engine/storage/gcs.py +++ b/hindsight-api-slim/hindsight_api/engine/storage/gcs.py @@ -2,7 +2,7 @@ import logging import os -from datetime import datetime, timedelta, timezone +from datetime import timedelta, timezone import obstore as obs from obstore.store import GCSStore diff --git a/hindsight-api-slim/hindsight_api/engine/temporal_periods.py b/hindsight-api-slim/hindsight_api/engine/temporal_periods.py new file mode 100644 index 0000000000..9123b3204c --- /dev/null +++ b/hindsight-api-slim/hindsight_api/engine/temporal_periods.py @@ -0,0 +1,155 @@ +"""Explicit period extraction helpers for DateparserQueryAnalyzer. + +This module keeps the public period-extraction API and the non-Chinese period +rules. Chinese rules live in chinese_temporal_periods.py because that rule set is +substantially larger and has different boundary behavior from whitespace-based +languages. +""" + +import calendar +import re +import unicodedata +from datetime import datetime, timedelta + +DateRange = tuple[datetime, datetime] + + +class NoTemporalConstraintSentinel: + pass + + +NO_TEMPORAL_CONSTRAINT = NoTemporalConstraintSentinel() + +__all__ = [ + "NO_TEMPORAL_CONSTRAINT", + "extract_period", + "is_embedded_cjk_dateparser_match", +] + + +def _is_cjk_character(char: str) -> bool: + return "\u4e00" <= char <= "\u9fff" + + +def is_embedded_cjk_dateparser_match(query: str, matched_text: str) -> bool: + from hindsight_api.engine.chinese_temporal_periods import ( + is_embedded_cjk_dateparser_match as chinese_is_embedded_cjk_dateparser_match, + ) + + return chinese_is_embedded_cjk_dateparser_match(query, matched_text) + + +def _constraint(start: datetime, end: datetime) -> DateRange: + return ( + start.replace(hour=0, minute=0, second=0, microsecond=0), + end.replace(hour=23, minute=59, second=59, microsecond=999999), + ) + + +def _month_end(year: int, month: int) -> datetime: + return datetime(year, month, calendar.monthrange(year, month)[1]) + + +def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRange | None: + if re.search(r"\b(yesterday|ayer|ieri|hier|gestern)\b", query, re.IGNORECASE): + d = reference_date - timedelta(days=1) + return _constraint(d, d) + + if re.search(r"\b(today|hoy|oggi|aujourd\'?hui|heute)\b", query, re.IGNORECASE): + return _constraint(reference_date, reference_date) + + if re.search(r"\b(a\s+)?couple\s+(of\s+)?days?\s+ago\b", query, re.IGNORECASE): + return _constraint(reference_date - timedelta(days=3), reference_date - timedelta(days=1)) + + if re.search(r"\b(a\s+)?few\s+days?\s+ago\b", query, re.IGNORECASE): + return _constraint(reference_date - timedelta(days=5), reference_date - timedelta(days=2)) + + if re.search(r"\b(a\s+)?couple\s+(of\s+)?weeks?\s+ago\b", query, re.IGNORECASE): + return _constraint(reference_date - timedelta(weeks=3), reference_date - timedelta(weeks=1)) + + if re.search(r"\b(a\s+)?few\s+weeks?\s+ago\b", query, re.IGNORECASE): + return _constraint(reference_date - timedelta(weeks=5), reference_date - timedelta(weeks=2)) + + if re.search(r"\b(a\s+)?couple\s+(of\s+)?months?\s+ago\b", query, re.IGNORECASE): + return _constraint(reference_date - timedelta(days=90), reference_date - timedelta(days=30)) + + if re.search(r"\b(a\s+)?few\s+months?\s+ago\b", query, re.IGNORECASE): + return _constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60)) + + if re.search( + r"\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b", + query, + re.IGNORECASE, + ): + start = reference_date - timedelta(days=reference_date.weekday() + 7) + return _constraint(start, start + timedelta(days=6)) + + if re.search( + r"\b(last\s+month|el\s+mes\s+pasado|il\s+mese\s+scorso|le\s+mois\s+dernier|letzten?\s+monat)\b", + query, + re.IGNORECASE, + ): + first = reference_date.replace(day=1) + end = first - timedelta(days=1) + start = end.replace(day=1) + return _constraint(start, end) + + if re.search( + r"\b(last\s+year|el\s+a[ñn]o\s+pasado|l\'anno\s+scorso|l\'ann[ée]e\s+derni[eè]re|letztes?\s+jahr)\b", + query, + re.IGNORECASE, + ): + year = reference_date.year - 1 + return _constraint(datetime(year, 1, 1), datetime(year, 12, 31)) + + if re.search( + r"\b(last\s+weekend|el\s+fin\s+de\s+semana\s+pasado|lo\s+scorso\s+fine\s+settimana|le\s+week-?end\s+dernier|letztes?\s+wochenende)\b", + query, + re.IGNORECASE, + ): + days_since_sat = (reference_date.weekday() + 2) % 7 + if days_since_sat == 0: + days_since_sat = 7 + sat = reference_date - timedelta(days=days_since_sat) + return _constraint(sat, sat + timedelta(days=1)) + + month_patterns = { + "january|enero|gennaio|janvier|januar": 1, + "february|febrero|febbraio|f[ée]vrier|februar": 2, + "march|marzo|mars|m[äa]rz": 3, + "april|abril|aprile|avril": 4, + "may|mayo|maggio|mai": 5, + "june|junio|giugno|juin|juni": 6, + "july|julio|luglio|juillet|juli": 7, + "august|agosto|ao[uû]t": 8, + "september|septiembre|settembre|septembre": 9, + "october|octubre|ottobre|octobre|oktober": 10, + "november|noviembre|novembre": 11, + "december|diciembre|dicembre|d[ée]cembre|dezember": 12, + } + for pattern, month_num in month_patterns.items(): + match = re.search(rf"\b({pattern})\s+(\d{{4}})\b", query, re.IGNORECASE) + if match: + year = int(match.group(2)) + start = datetime(year, month_num, 1) + return _constraint(start, _month_end(year, month_num)) + + return None + + +def extract_period(query: str, reference_date: datetime) -> DateRange | NoTemporalConstraintSentinel | None: + """Extract explicit period-based temporal expressions. + + Non-Chinese rules are kept here. Chinese rules are delegated to + chinese_temporal_periods.py and are skipped entirely for non-CJK queries. + """ + query = unicodedata.normalize("NFKC", query) + + if any(_is_cjk_character(char) for char in query): + from hindsight_api.engine.chinese_temporal_periods import extract_chinese_period + + chinese_result = extract_chinese_period(query, reference_date) + if chinese_result is not None: + return chinese_result + + return _extract_non_chinese_period(query, reference_date) diff --git a/hindsight-api-slim/hindsight_api/engine/transfer/export.py b/hindsight-api-slim/hindsight_api/engine/transfer/export.py index c30c553f58..e3835ce44f 100644 --- a/hindsight-api-slim/hindsight_api/engine/transfer/export.py +++ b/hindsight-api-slim/hindsight_api/engine/transfer/export.py @@ -80,6 +80,12 @@ "async_operations", # in-flight ops; drain on the source before migrating "graph_maintenance_queue", # transient work queue; regenerated on import "file_storage", # raw uploads; documents.original_text is already carried + # Curation archive of retired facts — local operational state, not part of + # the live knowledge the export replays. Its rows mirror memory_units (stale + # embedding) and snapshot source-bank entity ids that the import re-resolves + # to fresh ids, so carrying them would only produce dangling associations. + # Revert anything worth keeping on the source before migrating. + "invalidated_memory_units", } ) # Derived columns dropped from carried rows so the target regenerates them with diff --git a/hindsight-api-slim/hindsight_api/engine/transfer/schema.py b/hindsight-api-slim/hindsight_api/engine/transfer/schema.py index fdf3c5d526..742ae6925c 100644 --- a/hindsight-api-slim/hindsight_api/engine/transfer/schema.py +++ b/hindsight-api-slim/hindsight_api/engine/transfer/schema.py @@ -22,7 +22,7 @@ # Bump when the archive layout changes in a backward-incompatible way. SCHEMA_VERSION = 1 -ObservationScopes = Literal["per_tag", "combined", "all_combinations"] | list[list[str]] +ObservationScopes = Literal["per_tag", "combined", "all_combinations", "shared"] | list[list[str]] class TransferCausalRelation(BaseModel): diff --git a/hindsight-api-slim/hindsight_api/extensions/__init__.py b/hindsight-api-slim/hindsight_api/extensions/__init__.py index 0027a51d44..08db9f8002 100644 --- a/hindsight-api-slim/hindsight_api/extensions/__init__.py +++ b/hindsight-api-slim/hindsight_api/extensions/__init__.py @@ -16,11 +16,24 @@ """ from hindsight_api.extensions.base import Extension -from hindsight_api.extensions.builtin import ApiKeyTenantExtension, SupabaseTenantExtension +from hindsight_api.extensions.builtin import ( + ApiKeyTenantExtension, + MemoryDefenseRegexExtension, + SupabaseTenantExtension, +) from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionContext from hindsight_api.extensions.http import HttpExtension from hindsight_api.extensions.loader import load_extension from hindsight_api.extensions.mcp import MCPExtension +from hindsight_api.extensions.memory_defense import ( + DefenseAction, + DefenseDecision, + DefensePolicy, + MemoryDefenseExtension, + PolicyRule, + apply_redaction, + parse_policy, +) from hindsight_api.extensions.operation_validator import ( # Bank Management operations BankListContext, @@ -104,4 +117,13 @@ "Tenant", "TenantContext", "TenantExtension", + # Memory Defense + "DefenseAction", + "DefenseDecision", + "DefensePolicy", + "MemoryDefenseExtension", + "MemoryDefenseRegexExtension", + "PolicyRule", + "apply_redaction", + "parse_policy", ] diff --git a/hindsight-api-slim/hindsight_api/extensions/builtin/__init__.py b/hindsight-api-slim/hindsight_api/extensions/builtin/__init__.py index e12af1258e..206120393b 100644 --- a/hindsight-api-slim/hindsight_api/extensions/builtin/__init__.py +++ b/hindsight-api-slim/hindsight_api/extensions/builtin/__init__.py @@ -13,10 +13,12 @@ HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.supabase_tenant:SupabaseTenantExtension """ +from hindsight_api.extensions.builtin.memory_defense_regex import MemoryDefenseRegexExtension from hindsight_api.extensions.builtin.supabase_tenant import SupabaseTenantExtension from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension __all__ = [ "ApiKeyTenantExtension", + "MemoryDefenseRegexExtension", "SupabaseTenantExtension", ] diff --git a/hindsight-api-slim/hindsight_api/extensions/builtin/memory_defense_regex.py b/hindsight-api-slim/hindsight_api/extensions/builtin/memory_defense_regex.py new file mode 100644 index 0000000000..38386f3a6c --- /dev/null +++ b/hindsight-api-slim/hindsight_api/extensions/builtin/memory_defense_regex.py @@ -0,0 +1,56 @@ +"""Memory Defense (regex) — the default extension shipping with hindsight-api-slim. + +Scrubs known secret/PII patterns from retained content via the +``sensitive_data`` detector. Matching is pure regex (see ``apply_redaction``): +no LLM call, no external dependency. A ``sensitive_data`` rule may either +``redact`` matches in place or ``block`` the item entirely. +""" + +from __future__ import annotations + +import logging + +from hindsight_api.extensions.memory_defense import ( + DefenseAction, + DefenseDecision, + DefensePolicy, + MemoryDefenseExtension, + apply_redaction, +) + +logger = logging.getLogger(__name__) + + +class MemoryDefenseRegexExtension(MemoryDefenseExtension): + """Default Memory Defense — regex-based secret/PII redaction.""" + + async def screen( + self, + *, + policy: DefensePolicy, + bank_id: str, + document_id: str | None, + content: str, + tags: list[str], + ) -> DefenseDecision: + if not policy.enabled: + return DefenseDecision(action=DefenseAction.ALLOW) + + # The regex extension only runs the sensitive_data detector. If the + # policy doesn't include a rule for it, there's nothing to do. + rule = next((r for r in policy.rules if r.on == "sensitive_data"), None) + if rule is None or rule.action is DefenseAction.ALLOW: + return DefenseDecision(action=DefenseAction.ALLOW) + + result = apply_redaction(content) + if not result.matched_types: + return DefenseDecision(action=DefenseAction.ALLOW) + + return DefenseDecision( + action=rule.action, + detector="sensitive_data", + message=f"Sensitive data pattern matched: {', '.join(result.matched_types)}", + redacted_content=result.content if rule.action is DefenseAction.REDACT else None, + matched_types=result.matched_types, + hits=result.hits, + ) diff --git a/hindsight-api-slim/hindsight_api/extensions/context.py b/hindsight-api-slim/hindsight_api/extensions/context.py index b1b67a8685..58898cc7d6 100644 --- a/hindsight-api-slim/hindsight_api/extensions/context.py +++ b/hindsight-api-slim/hindsight_api/extensions/context.py @@ -5,6 +5,7 @@ if TYPE_CHECKING: from hindsight_api.engine.interface import MemoryEngineInterface + from hindsight_api.webhooks.manager import WebhookManager class ExtensionContext(ABC): @@ -83,6 +84,8 @@ def __init__( self, database_url: str, memory_engine: "MemoryEngineInterface | None" = None, + webhook_manager: "WebhookManager | None" = None, + current_schema: str | None = None, ): """ Initialize the context. @@ -90,9 +93,13 @@ def __init__( Args: database_url: SQLAlchemy database URL for migrations. memory_engine: Optional MemoryEngine instance for memory operations. + webhook_manager: Optional WebhookManager for firing webhooks. + current_schema: Optional current schema name for tenant context. """ self._database_url = database_url self._memory_engine = memory_engine + self.webhook_manager = webhook_manager + self.current_schema = current_schema async def run_migration(self, schema: str) -> None: """Run migrations for a specific schema.""" diff --git a/hindsight-api-slim/hindsight_api/extensions/memory_defense.py b/hindsight-api-slim/hindsight_api/extensions/memory_defense.py new file mode 100644 index 0000000000..54a240ee52 --- /dev/null +++ b/hindsight-api-slim/hindsight_api/extensions/memory_defense.py @@ -0,0 +1,271 @@ +"""Memory Defense extension contract and shared policy types. + +Lives in extensions/ (not engine/) because it defines the public contract +between the retain orchestrator and any installed Memory Defense extension — +the same shape as TenantExtension and OperationValidatorExtension. + +api-slim ships the :class:`MemoryDefenseExtension` protocol and a regex default +that scrubs known secret/PII patterns from retained content. +""" + +from __future__ import annotations + +import logging +import re +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import Enum + +from hindsight_api.extensions.base import Extension + +logger = logging.getLogger(__name__) + + +class DefenseAction(str, Enum): + ALLOW = "allow" + REDACT = "redact" + BLOCK = "block" + + +_VALID_ACTIONS = {a.value for a in DefenseAction} + +# ``policy.rules[*].on`` names a detector. The OSS extension only screens for +# ``sensitive_data``; any other name is a silent no-op here and is dispatched +# by whichever extension is loaded (e.g. hindsight-cloud screens cloud-only +# detectors). The parser therefore does NOT validate ``on`` against a fixed +# list — pinning the OSS roster to cloud's would force an OSS bump for every +# new cloud detector just to avoid 422-ing a write it never interprets. We +# only require ``on`` to be a non-empty string; entitlement and dispatch are +# the loaded extension's ``screen()`` job. + + +@dataclass(frozen=True) +class PolicyRule: + on: str + action: DefenseAction + + +@dataclass(frozen=True) +class DefensePolicy: + enabled: bool = False + rules: tuple[PolicyRule, ...] = () + + +@dataclass +class DefenseDecision: + action: DefenseAction + detector: str | None = None + message: str = "" + redacted_content: str | None = None + matched_types: list[str] = field(default_factory=list) + # Per-match fingerprinted previews. Each entry is + # ``{"detector": , "preview": }``. + # The preview is *never* the raw value — see :func:`_fingerprint_value`. + # OSS populates this from ``apply_redaction``; downstream extensions + # populate it from their own detectors. Optional: empty when the + # match path didn't capture per-hit values. + hits: list[dict] = field(default_factory=list) + + +@dataclass +class RedactionResult: + content: str + matched_types: list[str] + # Same shape as ``DefenseDecision.hits`` — one entry per matched value + # (so a single content with two GitHub tokens produces two entries). + hits: list[dict] = field(default_factory=list) + + +def _fingerprint_value(value: str) -> str: + """Return a redaction-identifiable preview of a matched value. + + The preview keeps the prefix and a short suffix so a SIEM operator can + correlate against their credential inventory (the prefix names the + provider; the suffix disambiguates specific instances) without the raw + secret crossing the wire. Length-aware so short values don't accidentally + leak material: + + - Length < 6: redact entirely (return a fixed-length mask). Catches + noise like a single ``-----BEGIN...`` marker line. + - Length 6-15: keep the first 2 + last 2 around an ellipsis. + - Length > 15: keep the first 4 + last 4 around an ellipsis. + + Examples:: + + _fingerprint_value("ghp_AAAA...AAAA" + "A" * 36) -> "ghp_...AAAA" + _fingerprint_value("AKIA" + "B" * 16) -> "AKIA...BBBB" + _fingerprint_value("123-45-6789") -> "12...89" + _fingerprint_value("abc") -> "[redacted]" + """ + n = len(value) + if n < 6: + return "[redacted]" + if n <= 15: + return f"{value[:2]}...{value[-2:]}" + return f"{value[:4]}...{value[-4:]}" + + +def parse_policy(raw: dict | None) -> DefensePolicy: + """Parse a raw bank-config dict into a frozen DefensePolicy. + + Raises ValueError for a missing/empty ``on`` or an unknown action; the + HTTP layer converts those into a 422 response. + """ + if raw is None: + return DefensePolicy() + + rules: list[PolicyRule] = [] + for item in raw.get("rules", []) or []: + on_raw = item.get("on") + if not isinstance(on_raw, str) or not on_raw: + raise ValueError(f"invalid on {on_raw!r}; must be a non-empty string") + action_raw = item.get("action") + if action_raw not in _VALID_ACTIONS: + raise ValueError(f"invalid action {action_raw!r}; must be one of {sorted(_VALID_ACTIONS)}") + rules.append(PolicyRule(on=on_raw, action=DefenseAction(action_raw))) + + return DefensePolicy( + enabled=bool(raw.get("enabled", False)), + rules=tuple(rules), + ) + + +# Secret/PII redaction patterns. +# +# Scope: high-confidence patterns with unambiguous prefixes (low false-positive +# rate). Context-dependent matches (e.g. Cohere/Mistral keys that only stand +# out near surrounding "cohere"/"mistral" tokens) are NOT covered by pure +# regex — operators who need that should layer a context-aware secret +# scanner (detect-secrets, trufflehog) on top. +# +# Order matters: more-specific patterns first so broader ones don't consume +# substrings partially. Example: `sk-ant-...` and `sk-proj-...` must run +# before the generic `sk-...` pattern. +_REDACTION_PATTERNS: list[tuple[str, str]] = [ + # --- AI / LLM providers --- + ("anthropic_key", r"\bsk-ant-[A-Za-z0-9_-]{20,}\b"), + ("openai_project_key", r"\bsk-proj-[A-Za-z0-9_-]{48,}\b"), + ("openai_admin_key", r"\bsk-admin-[A-Za-z0-9_-]{40,}\b"), + ("openai_key", r"\bsk-[A-Za-z0-9_-]{20,}\b"), + ("google_api_key", r"\bAIza[0-9A-Za-z_-]{35}\b"), + ("google_oauth_token", r"\bya29\.[0-9A-Za-z_-]{20,}\b"), + ("xai_key", r"\bxai-[A-Za-z0-9]{40,}\b"), + ("groq_key", r"\bgsk_[A-Za-z0-9]{20,}\b"), + ("huggingface_token", r"\bhf_[A-Za-z0-9]{30,}\b"), + ("replicate_token", r"\br8_[A-Za-z0-9]{30,}\b"), + ("perplexity_key", r"\bpplx-[A-Za-z0-9]{40,}\b"), + ("databricks_token", r"\bdapi[A-Za-z0-9]{32}\b"), + # --- Cloud providers --- + ("aws_access_key", r"\bAKIA[0-9A-Z]{16}\b"), + ("aws_session_token", r"\bASIA[0-9A-Z]{16}\b"), + ( + "aws_secret_key", + r"(?i)aws(.{0,20})?(secret|private)?[\s_-]?access[\s_-]?key[\s_-]?[:=][\s\"']*([A-Za-z0-9/+=]{40})", + ), + ("digitalocean_token", r"\bdop_v1_[a-f0-9]{64}\b"), + # --- Source control & CI --- + ("github_fg_pat", r"\bgithub_pat_[A-Za-z0-9_]{60,}\b"), + ("github_token", r"\bghp_[A-Za-z0-9]{36}\b"), + ("github_app_token", r"\bghs_[A-Za-z0-9]{36}\b"), + ("github_user_token", r"\bghu_[A-Za-z0-9]{36}\b"), + ("github_refresh", r"\bghr_[A-Za-z0-9]{36}\b"), + ("github_oauth", r"\bgho_[A-Za-z0-9]{36}\b"), + ("gitlab_pat", r"\bglpat-[A-Za-z0-9_-]{20,}\b"), + ("npm_token", r"\bnpm_[A-Za-z0-9]{30,}\b"), + ("pypi_token", r"\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9_-]{20,}\b"), + # --- Payment processors --- + ("stripe_secret", r"\bsk_(?:live|test)_[A-Za-z0-9]{20,}\b"), + ("stripe_restricted", r"\brk_(?:live|test)_[A-Za-z0-9]{20,}\b"), + ("square_token", r"\bsq0[a-z]{3}-[A-Za-z0-9_-]{22,}\b"), + ("braintree_token", r"\baccess_token\$production\$[a-z0-9]{16}\$[a-f0-9]{32}\b"), + # --- Communication / email --- + ("slack_token", r"\bxox[abpr]-[0-9A-Za-z-]{10,}\b"), + ("slack_webhook", r"https://hooks\.slack\.com/services/T[A-Za-z0-9_]{8,}/B[A-Za-z0-9_]{8,}/[A-Za-z0-9_]{20,}"), + ("twilio_api_key", r"\bSK[0-9a-fA-F]{32}\b"), + ("twilio_account_sid", r"\bAC[0-9a-fA-F]{32}\b"), + ("sendgrid_key", r"\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b"), + ("mailgun_key", r"\bkey-[A-Za-z0-9]{32}\b"), + ("discord_bot", r"\b[MNO][A-Za-z0-9]{23}\.[A-Za-z0-9_-]{6}\.[A-Za-z0-9_-]{27}\b"), + ("telegram_bot", r"\b[0-9]{8,10}:[A-Za-z0-9_-]{35}\b"), + # --- Commerce --- + ("shopify_token", r"\bshpat_[a-fA-F0-9]{32}\b"), + # --- Database connection strings (creds embedded in URL) --- + ("db_url_postgres", r"postgres(?:ql)?://[^\s:/@]+:[^\s/@]+@[^\s]+"), + ("db_url_mysql", r"mysql://[^\s:/@]+:[^\s/@]+@[^\s]+"), + ("db_url_mongodb", r"mongodb(?:\+srv)?://[^\s:/@]+:[^\s/@]+@[^\s]+"), + # --- Private keys & generic credentials --- + ("private_key_pem", r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY( BLOCK)?-----"), + ("jwt", r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b"), + # --- PII (US-centric defaults; can be tuned per deployment) --- + # NOTE: credit_card regex is intentionally narrowed to 13-19 digits with + # exact separators to reduce false positives on long product IDs. + ("credit_card", r"\b(?:\d{4}[ -]?){3}\d{1,4}\b"), + ("ssn_us", r"\b\d{3}-\d{2}-\d{4}\b"), +] +_COMPILED_REDACTIONS: list[tuple[str, re.Pattern]] = [ + (label, re.compile(pattern)) for label, pattern in _REDACTION_PATTERNS +] + + +def apply_redaction(content: str) -> RedactionResult: + """Scrub known secret/PII patterns from content with [REDACTED:type] markers. + + Returns the (possibly unchanged) content alongside: + - ``matched_types``: pattern labels that matched (deduplicated, in + first-occurrence order). Empty when nothing matched. + - ``hits``: per-match fingerprinted previews — one entry per matched + substring (so two GitHub tokens in the same content produce two + entries). Each entry is ``{"detector": label, "preview": fingerprint}`` + where ``preview`` is a length-aware redaction of the original value. + The raw secret never appears in ``hits``. + + The two-pass shape (find matches first, then substitute) lets us capture + raw values for fingerprinting before they're replaced by ``[REDACTED:type]`` + markers. A single-pass approach would lose the originals. + """ + matched: list[str] = [] + hits: list[dict] = [] + for label, pattern in _COMPILED_REDACTIONS: + raw_hits = pattern.findall(content) + if not raw_hits: + continue + if label not in matched: + matched.append(label) + for raw in raw_hits: + # findall returns either a string or a tuple of capture groups + # depending on the pattern. The redaction-pattern catalog uses a + # mix; coerce to the matched substring as best we can. + if isinstance(raw, tuple): + # Pick the longest non-empty group as the canonical match. + non_empty = [g for g in raw if g] + raw_str = max(non_empty, key=len) if non_empty else "" + else: + raw_str = raw + if not raw_str: + continue + hits.append({"detector": label, "preview": _fingerprint_value(raw_str)}) + content = pattern.sub(f"[REDACTED:{label}]", content) + return RedactionResult(content=content, matched_types=matched, hits=hits) + + +class MemoryDefenseExtension(Extension, ABC): + """Abstract base for Memory Defense extensions. + + Implementations decide whether to allow, redact, or block a given retain + item by inspecting its content against a per-bank policy. The orchestrator + applies the returned decision (redacts content / drops blocked items) and + fires a webhook for non-allow decisions when one is configured. + """ + + @abstractmethod + async def screen( + self, + *, + policy: DefensePolicy, + bank_id: str, + document_id: str | None, + content: str, + tags: list[str], + ) -> DefenseDecision: + """Inspect content under the given policy and return a decision.""" + ... diff --git a/hindsight-api-slim/hindsight_api/extensions/operation_validator.py b/hindsight-api-slim/hindsight_api/extensions/operation_validator.py index 3ab3f7ffc0..f284917593 100644 --- a/hindsight-api-slim/hindsight_api/extensions/operation_validator.py +++ b/hindsight-api-slim/hindsight_api/extensions/operation_validator.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from dataclasses import dataclass, field from datetime import datetime -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from hindsight_api.extensions.base import Extension @@ -97,6 +97,10 @@ class PrecheckContext: - ``bank_id``: parsed from the URL path. - ``request_context``: the authenticated :class:`RequestContext` (tenant already resolved by the tenant extension). + - ``content_length``: value of the ``Content-Length`` request header as an + int, or ``None`` when the header is absent or unparseable (e.g. chunked + transfer encoding). Lets a precheck make size-aware decisions — such as + an upper-bound cost estimate — without reading or deserialising the body. Implementations should keep precheck cheap and side-effect-free. The full per-request validators (``validate_retain`` / ``validate_recall`` @@ -107,6 +111,7 @@ class PrecheckContext: operation: str bank_id: str request_context: "RequestContext" + content_length: int | None = None @dataclass diff --git a/hindsight-api-slim/hindsight_api/extensions/tenant.py b/hindsight-api-slim/hindsight_api/extensions/tenant.py index 936f02fa74..ccb8207f0d 100644 --- a/hindsight-api-slim/hindsight_api/extensions/tenant.py +++ b/hindsight-api-slim/hindsight_api/extensions/tenant.py @@ -40,6 +40,11 @@ class Tenant: """ schema: str + # Optional tenant identifier. When provided, background maintenance (e.g. the + # consolidation reconcile sweep) can build a RequestContext carrying this id so + # tenant-level config overrides are honored. Leave as None for single-tenant + # setups or extensions that do not key config by tenant id. + tenant_id: str | None = None class TenantExtension(Extension, ABC): diff --git a/hindsight-api-slim/hindsight_api/mcp_tools.py b/hindsight-api-slim/hindsight_api/mcp_tools.py index 2a2d076ff2..0f26b53d2d 100644 --- a/hindsight-api-slim/hindsight_api/mcp_tools.py +++ b/hindsight-api-slim/hindsight_api/mcp_tools.py @@ -12,6 +12,7 @@ from typing import Any, Callable from fastmcp import FastMCP +from mcp.types import ToolAnnotations from pydantic import TypeAdapter from hindsight_api import MemoryEngine @@ -50,6 +51,8 @@ "delete_directive", "list_memories", "get_memory", + "update_memory", + "invalidate_memory", "list_documents", "get_document", "delete_document", @@ -197,6 +200,47 @@ def build_content_dict( return content_dict, None +# MCP tool annotations. Hindsight is a closed memory store (no open-world / internet +# access), so openWorldHint=False throughout. readOnlyHint lets clients group and +# auto-approve safe reads; destructiveHint flags tools that delete or clear memory. +_READ_ONLY_TOOLS = { + "recall", + "reflect", + "list_banks", + "get_bank", + "get_bank_stats", + "list_mental_models", + "get_mental_model", + "list_directives", + "list_memories", + "get_memory", + "list_documents", + "get_document", + "list_operations", + "get_operation", + "list_tags", +} +_DESTRUCTIVE_TOOLS = { + "delete_bank", + "clear_memories", + "clear_mental_model", + "delete_mental_model", + "delete_directive", + "delete_document", + "invalidate_memory", +} + + +def _tool_annotations(name: str) -> ToolAnnotations: + if name in _READ_ONLY_TOOLS: + return ToolAnnotations(readOnlyHint=True, openWorldHint=False) + if name in _DESTRUCTIVE_TOOLS: + return ToolAnnotations(readOnlyHint=False, destructiveHint=True, openWorldHint=False) + # Everything else writes but does not destructively delete/clear memory + # (retain, create_*, update_*, refresh_mental_model, cancel_operation). + return ToolAnnotations(readOnlyHint=False, destructiveHint=False, openWorldHint=False) + + def register_mcp_tools( mcp: FastMCP, memory: MemoryEngine, @@ -228,6 +272,8 @@ def register_mcp_tools( "delete_directive", "list_memories", "get_memory", + "update_memory", + "invalidate_memory", "list_documents", "get_document", "delete_document", @@ -299,6 +345,12 @@ def register_mcp_tools( if "get_memory" in tools_to_register: _register_get_memory(mcp, memory, config) + if "update_memory" in tools_to_register: + _register_update_memory(mcp, memory, config) + + if "invalidate_memory" in tools_to_register: + _register_invalidate_memory(mcp, memory, config) + # Document tools if "list_documents" in tools_to_register: _register_list_documents(mcp, memory, config) @@ -542,7 +594,7 @@ def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) if config.include_bank_id_param: - @mcp.tool(description=description) + @mcp.tool(description=description, annotations=_tool_annotations("retain")) async def retain( content: str, context: str = "general", @@ -598,7 +650,7 @@ async def retain( else: - @mcp.tool(description=description) + @mcp.tool(description=description, annotations=_tool_annotations("retain")) async def retain( content: str, context: str = "general", @@ -656,7 +708,7 @@ def _register_sync_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("sync_retain")) async def sync_retain( content: str, context: str = "general", @@ -714,7 +766,7 @@ async def sync_retain( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("sync_retain")) async def sync_retain( content: str, context: str = "general", @@ -775,7 +827,7 @@ def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) if config.include_bank_id_param: - @mcp.tool(description=description) + @mcp.tool(description=description, annotations=_tool_annotations("recall")) async def recall( query: str, max_tokens: int = 4096, @@ -847,7 +899,7 @@ async def recall( else: - @mcp.tool(description=description) + @mcp.tool(description=description, annotations=_tool_annotations("recall")) async def recall( query: str, max_tokens: int = 4096, @@ -921,7 +973,7 @@ def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("reflect")) async def reflect( query: str, context: str | None = None, @@ -931,6 +983,7 @@ async def reflect( tags: list[str] | None = None, tags_match: str = "any", include_based_on: bool = False, + include_trace: bool = False, bank_id: str | None = None, ) -> str: """ @@ -961,6 +1014,7 @@ async def reflect( tags: Optional tags to filter memories by (e.g., ['project:alpha']) tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any' include_based_on: Include source facts used for synthesis. Defaults to false because broad reflections can exceed MCP client result limits. + include_trace: Include the reflection's internal tool_trace/llm_trace. Defaults to false because the trace can be tens of KB and overflow MCP client context; enable only for debugging. bank_id: Optional bank to reflect in (defaults to session bank). Use for cross-bank operations. """ try: @@ -990,6 +1044,12 @@ async def reflect( result_data = json.loads(reflect_result.model_dump_json(indent=2)) if not include_based_on: result_data.pop("based_on", None) + if not include_trace: + # The agentic reflect loop's tool_trace/llm_trace can be tens of KB + # (full mental-model text) and silently overflow MCP client context; + # the REST API omits it by default too. Opt in via include_trace. + result_data.pop("tool_trace", None) + result_data.pop("llm_trace", None) if response_schema is not None and hasattr(reflect_result, "structured_output"): result_data["structured_output"] = reflect_result.structured_output return json.dumps(result_data, indent=2) @@ -1002,7 +1062,7 @@ async def reflect( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("reflect")) async def reflect( query: str, context: str | None = None, @@ -1012,6 +1072,7 @@ async def reflect( tags: list[str] | None = None, tags_match: str = "any", include_based_on: bool = False, + include_trace: bool = False, ) -> dict: """ Generate thoughtful analysis by synthesizing stored memories with the bank's personality. @@ -1041,6 +1102,7 @@ async def reflect( tags: Optional tags to filter memories by (e.g., ['project:alpha']) tags_match: How to match tags - 'any' (match any tag) or 'all' (match all tags). Default: 'any' include_based_on: Include source facts used for synthesis. Defaults to false because broad reflections can exceed MCP client result limits. + include_trace: Include the reflection's internal tool_trace/llm_trace. Defaults to false because the trace can be tens of KB and overflow MCP client context; enable only for debugging. """ try: target_bank = config.bank_id_resolver() @@ -1069,6 +1131,12 @@ async def reflect( result_data = reflect_result.model_dump() if not include_based_on: result_data.pop("based_on", None) + if not include_trace: + # The agentic reflect loop's tool_trace/llm_trace can be tens of KB + # (full mental-model text) and silently overflow MCP client context; + # the REST API omits it by default too. Opt in via include_trace. + result_data.pop("tool_trace", None) + result_data.pop("llm_trace", None) if response_schema is not None and hasattr(reflect_result, "structured_output"): result_data["structured_output"] = reflect_result.structured_output return result_data @@ -1083,7 +1151,7 @@ async def reflect( def _register_list_banks(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: """Register the list_banks tool.""" - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("list_banks")) async def list_banks() -> str: """ List all available memory banks. @@ -1108,7 +1176,7 @@ async def list_banks() -> str: def _register_create_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: """Register the create_bank tool.""" - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("create_bank")) async def create_bank(bank_id: str, name: str | None = None, mission: str | None = None) -> str: """ Create a new memory bank or get an existing one. @@ -1172,7 +1240,7 @@ def _register_list_mental_models(mcp: FastMCP, memory: MemoryEngine, config: MCP if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("list_mental_models")) async def list_mental_models( tags: list[str] | None = None, detail: str = "full", @@ -1211,7 +1279,7 @@ async def list_mental_models( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("list_mental_models")) async def list_mental_models( tags: list[str] | None = None, detail: str = "full", @@ -1252,7 +1320,7 @@ def _register_get_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPTo if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("get_mental_model")) async def get_mental_model( mental_model_id: str, detail: str = "full", @@ -1292,7 +1360,7 @@ async def get_mental_model( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("get_mental_model")) async def get_mental_model( mental_model_id: str, detail: str = "full", @@ -1334,7 +1402,7 @@ def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("create_mental_model")) async def create_mental_model( name: str, source_query: str, @@ -1418,7 +1486,7 @@ async def create_mental_model( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("create_mental_model")) async def create_mental_model( name: str, source_query: str, @@ -1500,7 +1568,7 @@ def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("update_mental_model")) async def update_mental_model( mental_model_id: str, name: str | None = None, @@ -1561,7 +1629,7 @@ async def update_mental_model( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("update_mental_model")) async def update_mental_model( mental_model_id: str, name: str | None = None, @@ -1624,7 +1692,7 @@ def _register_delete_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MC if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("delete_mental_model")) async def delete_mental_model( mental_model_id: str, bank_id: str | None = None, @@ -1660,7 +1728,7 @@ async def delete_mental_model( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("delete_mental_model")) async def delete_mental_model( mental_model_id: str, ) -> dict: @@ -1698,7 +1766,7 @@ def _register_refresh_mental_model(mcp: FastMCP, memory: MemoryEngine, config: M if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("refresh_mental_model")) async def refresh_mental_model( mental_model_id: str, bank_id: str | None = None, @@ -1742,7 +1810,7 @@ async def refresh_mental_model( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("refresh_mental_model")) async def refresh_mental_model( mental_model_id: str, ) -> dict: @@ -1786,7 +1854,7 @@ def _register_clear_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCP if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("clear_mental_model")) async def clear_mental_model( mental_model_id: str, bank_id: str | None = None, @@ -1832,7 +1900,7 @@ async def clear_mental_model( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("clear_mental_model")) async def clear_mental_model( mental_model_id: str, ) -> dict: @@ -1883,7 +1951,7 @@ def _register_list_directives(mcp: FastMCP, memory: MemoryEngine, config: MCPToo if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("list_directives")) async def list_directives( tags: list[str] | None = None, active_only: bool = True, @@ -1921,7 +1989,7 @@ async def list_directives( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("list_directives")) async def list_directives( tags: list[str] | None = None, active_only: bool = True, @@ -1961,7 +2029,7 @@ def _register_create_directive(mcp: FastMCP, memory: MemoryEngine, config: MCPTo if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("create_directive")) async def create_directive( name: str, content: str, @@ -2007,7 +2075,7 @@ async def create_directive( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("create_directive")) async def create_directive( name: str, content: str, @@ -2055,7 +2123,7 @@ def _register_delete_directive(mcp: FastMCP, memory: MemoryEngine, config: MCPTo if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("delete_directive")) async def delete_directive( directive_id: str, bank_id: str | None = None, @@ -2091,7 +2159,7 @@ async def delete_directive( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("delete_directive")) async def delete_directive( directive_id: str, ) -> dict: @@ -2134,7 +2202,7 @@ def _register_list_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTools if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("list_memories")) async def list_memories( type: str | None = None, q: str | None = None, @@ -2178,7 +2246,7 @@ async def list_memories( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("list_memories")) async def list_memories( type: str | None = None, q: str | None = None, @@ -2224,7 +2292,7 @@ def _register_get_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCon if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("get_memory")) async def get_memory( memory_id: str, bank_id: str | None = None, @@ -2260,7 +2328,7 @@ async def get_memory( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("get_memory")) async def get_memory( memory_id: str, ) -> dict: @@ -2293,6 +2361,206 @@ async def get_memory( return {"error": str(e)} +def _register_update_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the update_memory (edit) tool.""" + + _EDIT_DOC = """ + Edit a memory unit to correct what was extracted. + + Pass any of text / context / occurred_start / occurred_end / fact_type / + entities. For context and the dates, "" clears the field and omitting it + leaves it unchanged; entities replaces the fact's entity set ([] detaches + all). The memory is re-embedded and its derived observations, links, and + graph are recomputed automatically. + + Only raw world/experience facts can be edited; observations are derived. + To retire or restore a fact, use invalidate_memory instead. + """ + + if config.include_bank_id_param: + + @mcp.tool(description=_EDIT_DOC, annotations=_tool_annotations("update_memory")) + async def update_memory( + memory_id: str, + text: str | None = None, + context: str | None = None, + occurred_start: str | None = None, + occurred_end: str | None = None, + fact_type: str | None = None, + entities: list[str] | None = None, + bank_id: str | None = None, + ) -> str: + """ + Args: + memory_id: The ID of the memory unit to edit. + bank_id: Optional bank (defaults to session bank). Use for cross-bank operations. + """ + try: + target_bank = bank_id or config.bank_id_resolver() + if target_bank is None: + return '{"error": "No bank_id configured"}' + + result = await memory.update_memory_unit( + target_bank, + memory_id, + text=text, + context=context, + occurred_start=occurred_start, + occurred_end=occurred_end, + new_fact_type=fact_type, + entities=entities, + request_context=_get_request_context(config), + ) + if result is None: + return json.dumps({"error": f"Memory '{memory_id}' not found"}) + return json.dumps(result, indent=2, default=str) + except OperationValidationError as e: + logger.warning(f"Operation rejected: {e}") + return json.dumps({"error": str(e)}) + except ValueError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + logger.error(f"Error updating memory: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool(description=_EDIT_DOC, annotations=_tool_annotations("update_memory")) + async def update_memory( + memory_id: str, + text: str | None = None, + context: str | None = None, + occurred_start: str | None = None, + occurred_end: str | None = None, + fact_type: str | None = None, + entities: list[str] | None = None, + ) -> dict: + """ + Args: + memory_id: The ID of the memory unit to edit. + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.update_memory_unit( + target_bank, + memory_id, + text=text, + context=context, + occurred_start=occurred_start, + occurred_end=occurred_end, + new_fact_type=fact_type, + entities=entities, + request_context=_get_request_context(config), + ) + if result is None: + return {"error": f"Memory '{memory_id}' not found"} + return result + except OperationValidationError as e: + logger.warning(f"Operation rejected: {e}") + return {"error": str(e)} + except ValueError as e: + return {"error": str(e)} + except Exception as e: + logger.error(f"Error updating memory: {e}", exc_info=True) + return {"error": str(e)} + + +def _register_invalidate_memory(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: + """Register the invalidate_memory (retire / restore) tool.""" + + _INVALIDATE_DOC = """ + Soft-retire a memory unit (or restore a previously retired one). + + Invalidating moves the fact out of the active set: it's excluded from + recall, consolidation, and the knowledge graph, its links are pruned, and + its derived observations are recomputed without it — but it's kept for + audit and is fully reversible. Pass restore=True to bring it back. + + Only raw world/experience facts can be invalidated; observations are derived. + """ + + if config.include_bank_id_param: + + @mcp.tool(description=_INVALIDATE_DOC, annotations=_tool_annotations("invalidate_memory")) + async def invalidate_memory( + memory_id: str, + reason: str | None = None, + restore: bool = False, + bank_id: str | None = None, + ) -> str: + """ + Args: + memory_id: The ID of the memory unit to retire (or restore). + reason: Optional free-text reason recorded when invalidating. + restore: Set True to restore a previously invalidated fact. + bank_id: Optional bank (defaults to session bank). Use for cross-bank operations. + """ + try: + target_bank = bank_id or config.bank_id_resolver() + if target_bank is None: + return '{"error": "No bank_id configured"}' + + result = await memory.update_memory_unit( + target_bank, + memory_id, + state="valid" if restore else "invalidated", + reason=reason, + request_context=_get_request_context(config), + ) + if result is None: + return json.dumps({"error": f"Memory '{memory_id}' not found"}) + return json.dumps(result, indent=2, default=str) + except OperationValidationError as e: + logger.warning(f"Operation rejected: {e}") + return json.dumps({"error": str(e)}) + except ValueError as e: + return json.dumps({"error": str(e)}) + except Exception as e: + logger.error(f"Error invalidating memory: {e}", exc_info=True) + return f'{{"error": "{e}"}}' + + else: + + @mcp.tool(description=_INVALIDATE_DOC, annotations=_tool_annotations("invalidate_memory")) + async def invalidate_memory( + memory_id: str, + reason: str | None = None, + restore: bool = False, + ) -> dict: + """ + Args: + memory_id: The ID of the memory unit to retire (or restore). + reason: Optional free-text reason recorded when invalidating. + restore: Set True to restore a previously invalidated fact. + """ + try: + target_bank = config.bank_id_resolver() + if target_bank is None: + return {"error": "No bank_id configured"} + + result = await memory.update_memory_unit( + target_bank, + memory_id, + state="valid" if restore else "invalidated", + reason=reason, + request_context=_get_request_context(config), + ) + if result is None: + return {"error": f"Memory '{memory_id}' not found"} + return result + except OperationValidationError as e: + logger.warning(f"Operation rejected: {e}") + return {"error": str(e)} + except ValueError as e: + return {"error": str(e)} + except Exception as e: + logger.error(f"Error invalidating memory: {e}", exc_info=True) + return {"error": str(e)} + + # ========================================================================= # DOCUMENT TOOLS # ========================================================================= @@ -2303,7 +2571,7 @@ def _register_list_documents(mcp: FastMCP, memory: MemoryEngine, config: MCPTool if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("list_documents")) async def list_documents( q: str | None = None, limit: int = 100, @@ -2341,7 +2609,7 @@ async def list_documents( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("list_documents")) async def list_documents( q: str | None = None, limit: int = 100, @@ -2381,7 +2649,7 @@ def _register_get_document(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsC if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("get_document")) async def get_document( document_id: str, bank_id: str | None = None, @@ -2417,7 +2685,7 @@ async def get_document( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("get_document")) async def get_document( document_id: str, ) -> dict: @@ -2455,7 +2723,7 @@ def _register_delete_document(mcp: FastMCP, memory: MemoryEngine, config: MCPToo if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("delete_document")) async def delete_document( document_id: str, bank_id: str | None = None, @@ -2489,7 +2757,7 @@ async def delete_document( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("delete_document")) async def delete_document( document_id: str, ) -> dict: @@ -2530,7 +2798,7 @@ def _register_list_operations(mcp: FastMCP, memory: MemoryEngine, config: MCPToo if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("list_operations")) async def list_operations( status: str | None = None, limit: int = 20, @@ -2567,7 +2835,7 @@ async def list_operations( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("list_operations")) async def list_operations( status: str | None = None, limit: int = 20, @@ -2606,7 +2874,7 @@ def _register_get_operation(mcp: FastMCP, memory: MemoryEngine, config: MCPTools if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("get_operation")) async def get_operation( operation_id: str, bank_id: str | None = None, @@ -2640,7 +2908,7 @@ async def get_operation( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("get_operation")) async def get_operation( operation_id: str, ) -> dict: @@ -2676,7 +2944,7 @@ def _register_cancel_operation(mcp: FastMCP, memory: MemoryEngine, config: MCPTo if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("cancel_operation")) async def cancel_operation( operation_id: str, bank_id: str | None = None, @@ -2708,7 +2976,7 @@ async def cancel_operation( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("cancel_operation")) async def cancel_operation( operation_id: str, ) -> dict: @@ -2747,7 +3015,7 @@ def _register_list_tags(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConf if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("list_tags")) async def list_tags( q: str | None = None, limit: int = 100, @@ -2784,7 +3052,7 @@ async def list_tags( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("list_tags")) async def list_tags( q: str | None = None, limit: int = 100, @@ -2823,7 +3091,7 @@ def _register_get_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfi if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("get_bank")) async def get_bank( bank_id: str | None = None, ) -> str: @@ -2856,7 +3124,7 @@ async def get_bank( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("get_bank")) async def get_bank() -> dict: """ Get the profile of this memory bank. @@ -2886,7 +3154,7 @@ async def get_bank() -> dict: def _register_get_bank_stats(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None: """Register the get_bank_stats tool (multi-bank only).""" - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("get_bank_stats")) async def get_bank_stats( bank_id: str | None = None, ) -> str: @@ -2959,7 +3227,7 @@ def _register_update_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("update_bank")) async def update_bank( name: str | None = None, mission: str | None = None, @@ -2981,7 +3249,8 @@ async def update_bank( - retain_mission: Steers what gets extracted during retain(). - retain_extraction_mode: 'concise' (default), 'verbose', or 'custom'. - retain_custom_instructions: Custom extraction prompt (active when mode is 'custom'). - - retain_chunk_size: Maximum token size for each content chunk. + - retain_chunk_size: Target maximum characters for each content chunk. + - retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation turn to keep whole. - retain_chunk_batch_size: Number of chunks to process in parallel. - enable_observations: Toggle observation consolidation after retain(). - observations_mission: Controls observation synthesis rules. @@ -3019,7 +3288,7 @@ async def update_bank( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("update_bank")) async def update_bank( name: str | None = None, mission: str | None = None, @@ -3040,7 +3309,8 @@ async def update_bank( - retain_mission: Steers what gets extracted during retain(). - retain_extraction_mode: 'concise' (default), 'verbose', or 'custom'. - retain_custom_instructions: Custom extraction prompt (active when mode is 'custom'). - - retain_chunk_size: Maximum token size for each content chunk. + - retain_chunk_size: Target maximum characters for each content chunk. + - retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation turn to keep whole. - retain_chunk_batch_size: Number of chunks to process in parallel. - enable_observations: Toggle observation consolidation after retain(). - observations_mission: Controls observation synthesis rules. @@ -3081,7 +3351,7 @@ def _register_delete_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("delete_bank")) async def delete_bank( bank_id: str | None = None, ) -> str: @@ -3113,7 +3383,7 @@ async def delete_bank( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("delete_bank")) async def delete_bank() -> dict: """ Delete this memory bank and all its data. @@ -3144,7 +3414,7 @@ def _register_clear_memories(mcp: FastMCP, memory: MemoryEngine, config: MCPTool if config.include_bank_id_param: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("clear_memories")) async def clear_memories( type: str | None = None, bank_id: str | None = None, @@ -3179,7 +3449,7 @@ async def clear_memories( else: - @mcp.tool() + @mcp.tool(annotations=_tool_annotations("clear_memories")) async def clear_memories( type: str | None = None, ) -> dict: diff --git a/hindsight-api-slim/hindsight_api/metrics.py b/hindsight-api-slim/hindsight_api/metrics.py index 2ffce5f4b0..5f25c1b951 100644 --- a/hindsight-api-slim/hindsight_api/metrics.py +++ b/hindsight-api-slim/hindsight_api/metrics.py @@ -11,15 +11,17 @@ - Database connection pool metrics """ +import asyncio import importlib import logging import os +import re _resource_mod = importlib.import_module("resource") if importlib.util.find_spec("resource") else None import threading import time from contextlib import contextmanager -from typing import TYPE_CHECKING, Callable +from typing import TYPE_CHECKING, Callable, NamedTuple from opentelemetry import metrics from opentelemetry.exporter.prometheus import PrometheusMetricReader @@ -39,6 +41,32 @@ def _get_tenant() -> str: return get_current_schema() +def _is_client_cancellation(exc: BaseException) -> bool: + """Whether *exc* is a client-disconnect cancellation rather than a failure. + + An abandoned recall/reflect raises OperationCancelledError (issue #2122); + the HTTP layer re-raises it as ``HTTPException(499) from exc`` (see + api/http.py run_cancellable_on_disconnect). The exception itself, or any + link in its ``__cause__`` chain, being an OperationCancelledError marks it + as a cancellation. Matching on the cause chain rather than a bare status + code avoids misclassifying an unrelated 499 as a cancellation. Per the + engine contract a cancellation is "not a failure to retry or report" + (cancellation.OperationCancelledError), so it must not be counted against + ``hindsight.operation.total``. + """ + # Imported lazily to avoid import-time coupling (cf. _get_tenant above). + from hindsight_api.cancellation import OperationCancelledError + + cause: BaseException | None = exc + seen: set[int] = set() # guard against a cyclic __cause__ chain + while cause is not None and id(cause) not in seen: + if isinstance(cause, OperationCancelledError): + return True + seen.add(id(cause)) + cause = cause.__cause__ + return False + + # Custom bucket boundaries for operation duration (in seconds) # Fine granularity in 0-30s range where most operations complete DURATION_BUCKETS = (0.1, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 5.0, 7.5, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0) @@ -49,6 +77,28 @@ def _get_tenant() -> str: # HTTP request duration buckets (millisecond-level for fast endpoints) HTTP_DURATION_BUCKETS = (0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0) +# How often the backlog / queue-depth gauge caches are refreshed (seconds). +# The counts are aggregate COUNT queries, so a background task refreshes a +# cache and the observable gauges read from it — keeping the /metrics scrape +# path synchronous (the same reason the db-pool gauges read cached state). +BACKLOG_METRICS_REFRESH_SECONDS = 30 + + +class _AsyncOpKey(NamedTuple): + """Cache / label key for the async-operation queue gauge.""" + + tenant: str + operation_type: str + status: str + bank_id: str | None + + +class _BacklogKey(NamedTuple): + """Cache / label key for the consolidation backlog and failed gauges.""" + + tenant: str + bank_id: str | None + def get_token_bucket(token_count: int) -> str: """ @@ -87,6 +137,27 @@ def get_token_bucket(token_count: int) -> str: return "50k+" +# Template unbounded id segments before a path is used as the low-cardinality +# "endpoint" metric label. A raw per-bank path segment (e.g. user-123) would +# otherwise create one never-evicted OTel series per bank. +_METRIC_BANK_SEGMENT_RE = re.compile(r"(/banks/)[^/]+") +_METRIC_UUID_RE = re.compile(r"/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}") +_METRIC_NUMERIC_ID_RE = re.compile(r"/\d+(?=/|$)") + + +def normalize_http_endpoint(path: str) -> str: + """Template high-cardinality id segments in an HTTP path for safe metric labeling. + + Collapses the "/banks/" segment (any bank id, including non-numeric ones like + "user-123"), UUIDs, and numeric ids to placeholders so the "endpoint" metric label + has bounded cardinality. Analogous to get_token_bucket for token counts. + """ + path = _METRIC_BANK_SEGMENT_RE.sub(r"\g<1>{bank_id}", path) + path = _METRIC_UUID_RE.sub("/{id}", path) + path = _METRIC_NUMERIC_ID_RE.sub("/{id}", path) + return path + + logger = logging.getLogger(__name__) # Global meter instance @@ -175,6 +246,19 @@ def record_operation( """Context manager to record operation duration and status.""" raise NotImplementedError + def record_operation_result( + self, + operation: str, + bank_id: str, + success: bool, + duration: float, + source: str = "api", + budget: str | None = None, + max_tokens: int | None = None, + ): + """Record a single completed operation with an explicit success label.""" + raise NotImplementedError + def record_llm_call( self, provider: str, @@ -228,6 +312,19 @@ def record_operation( """No-op context manager.""" yield + def record_operation_result( + self, + operation: str, + bank_id: str, + success: bool, + duration: float, + source: str = "api", + budget: str | None = None, + max_tokens: int | None = None, + ): + """No-op operation result recording.""" + pass + def record_llm_call( self, provider: str, @@ -335,6 +432,13 @@ def __init__(self): # DB pool metrics holder (set via set_db_pool) self._db_pool: "asyncpg.Pool | None" = None + # Backlog / queue-depth gauge caches, refreshed by a background task + # (see _setup_backlog_metrics) so the scrape path stays synchronous. + self._async_ops_counts: dict[_AsyncOpKey, int] = {} + self._consolidation_backlog: dict[_BacklogKey, int] = {} + self._consolidation_failed: dict[_BacklogKey, int] = {} + self._backlog_task: "asyncio.Task | None" = None + @contextmanager def record_operation( self, @@ -360,6 +464,51 @@ def record_operation( max_tokens: Optional max tokens for the operation """ start_time = time.time() + success = True + cancelled = False + try: + yield + except Exception as exc: + # A client disconnect cancels the operation cooperatively (#2122), + # raised as OperationCancelledError and re-raised by the HTTP layer + # as HTTPException(499) from it. An abandoned request is neither a + # success nor a failure, so it is excluded from the metric entirely + # rather than inflating either the failure or the success rate on + # hindsight.operation.total. + if _is_client_cancellation(exc): + cancelled = True + else: + success = False + raise + finally: + if not cancelled: + self.record_operation_result( + operation, + bank_id, + success=success, + duration=time.time() - start_time, + source=source, + budget=budget, + max_tokens=max_tokens, + ) + + def record_operation_result( + self, + operation: str, + bank_id: str, + success: bool, + duration: float, + source: str = "api", + budget: str | None = None, + max_tokens: int | None = None, + ): + """Record a single completed operation (duration + count) with a success label. + + Direct (non-context-manager) recording for code paths that need explicit + success control rather than the exception-based ``record_operation`` — e.g. + the async worker, where deferrals/retries are not terminal outcomes and must + not be counted as completions. + """ attributes = { "operation": operation, "source": source, @@ -371,22 +520,13 @@ def record_operation( attributes["budget"] = budget if max_tokens: attributes["max_tokens"] = str(max_tokens) + attributes["success"] = str(success).lower() - success = True - try: - yield - except Exception: - success = False - raise - finally: - duration = time.time() - start_time - attributes["success"] = str(success).lower() - - # Record duration - self.operation_duration.record(duration, attributes) + # Record duration + self.operation_duration.record(duration, attributes) - # Record operation count - self.operation_total.add(1, attributes) + # Record operation count + self.operation_total.add(1, attributes) def record_llm_call( self, @@ -591,6 +731,10 @@ def set_db_pool(self, pool: "asyncpg.Pool"): """ self._db_pool = pool self._setup_db_pool_metrics() + from .config import get_config + + if get_config().metrics_backlog_enabled: + self._setup_backlog_metrics() def _setup_db_pool_metrics(self): """Set up observable gauges for database pool metrics.""" @@ -656,6 +800,192 @@ def get_pool_max_size(_options): unit="{connections}", ) + def _setup_backlog_metrics(self): + """Observable gauges for the async-operation queue and the + consolidation backlog. + + These mirror fields the bank-stats endpoint already computes + (``operations_by_status``, ``pending_consolidation``, + ``failed_consolidation``) but expose them as scrapable gauges, so + queue depth and backlog can be trended and alerted on instead of only + polled per-bank over HTTP. The two motivating questions both come for + free here: "is the worker keeping up?" (async-op queue) and "is the + knowledge base caught up?" (consolidation backlog) — including the + ``processing`` state, which is the only signal that surfaces a hung + operation stuck holding a worker slot. + + Counts are aggregate ``COUNT`` queries, so a background task refreshes + a cache every ``BACKLOG_METRICS_REFRESH_SECONDS`` and these callbacks + read it — keeping the scrape path synchronous, the same approach as + the db-pool gauges above. + """ + if self._backlog_task is not None: + return # already started for this collector + + def get_async_operations(_options): + for key, value in list(self._async_ops_counts.items()): + attrs = {"tenant": key.tenant, "operation_type": key.operation_type, "status": key.status} + if key.bank_id is not None: + attrs["bank_id"] = key.bank_id + yield metrics.Observation(value, attrs) + + def get_consolidation_backlog(_options): + for key, value in list(self._consolidation_backlog.items()): + attrs = {"tenant": key.tenant} + if key.bank_id is not None: + attrs["bank_id"] = key.bank_id + yield metrics.Observation(value, attrs) + + def get_consolidation_failed(_options): + for key, value in list(self._consolidation_failed.items()): + attrs = {"tenant": key.tenant} + if key.bank_id is not None: + attrs["bank_id"] = key.bank_id + yield metrics.Observation(value, attrs) + + self.meter.create_observable_gauge( + name="hindsight.async_operations", + callbacks=[get_async_operations], + description="Async operations in a non-terminal state, by operation_type and status " + "(pending=queued backlog, processing=in-flight, failed=stranded)", + unit="{operations}", + ) + self.meter.create_observable_gauge( + name="hindsight.consolidation.backlog", + callbacks=[get_consolidation_backlog], + description="Source memories (experience/world) not yet consolidated into observations", + unit="{memories}", + ) + self.meter.create_observable_gauge( + name="hindsight.consolidation.failed", + callbacks=[get_consolidation_failed], + description="Source memories whose consolidation permanently failed " + "(recoverable via the consolidation recovery endpoint)", + unit="{memories}", + ) + + # Drive the caches from a background task on the running loop. + # set_db_pool runs during async startup, so a loop is normally present; + # if not, the gauges simply stay empty rather than crashing collection. + try: + loop = asyncio.get_running_loop() + except RuntimeError: + logger.warning("No running event loop; backlog metrics disabled") + return + # Process-lifetime task: there is no collector teardown hook to cancel it + # on, so it's torn down with the event loop at process shutdown. If a + # shutdown path is ever added, cancel self._backlog_task there. + self._backlog_task = loop.create_task(self._backlog_refresh_loop()) + + async def _backlog_refresh_loop(self): + """Periodically refresh the backlog / queue-depth caches.""" + while True: + try: + await self._refresh_backlog() + except Exception: + logger.debug("Backlog metrics refresh failed", exc_info=True) + await asyncio.sleep(BACKLOG_METRICS_REFRESH_SECONDS) + + async def _refresh_backlog(self): + """Recount the async-operation queue and consolidation backlog across + every provisioned Hindsight schema. + + Per-bank labels are gated behind ``metrics_include_bank_id`` (off by + default) to keep cardinality bounded; when off, counts are aggregated + per tenant/schema. All SQL here is PostgreSQL-specific (``FILTER``, + ``information_schema``), which is consistent with this collector + already being bound to an asyncpg pool. + """ + if self._db_pool is None: + return + + async_ops: dict[_AsyncOpKey, int] = {} + backlog: dict[_BacklogKey, int] = {} + failed: dict[_BacklogKey, int] = {} + per_bank = self._include_bank_id + bank_sel = "bank_id, " if per_bank else "" + bank_grp = " GROUP BY bank_id" if per_bank else "" + + async with self._db_pool.acquire() as conn: + # memory_units is the central per-tenant table; its presence marks a + # provisioned Hindsight schema. + schema_rows = await conn.fetch( + "SELECT table_schema FROM information_schema.tables WHERE table_name = 'memory_units'" + ) + for schema_row in schema_rows: + schema = schema_row["table_schema"] + + # Worker queue depth — mirrors operations_by_status, split by + # operation_type. Terminal states (completed/cancelled) are + # excluded on purpose: a gauge of finished work grows without + # bound and says nothing about current load. + # Index: idx_async_operations_status. + ops_grp = "operation_type, status" + (", bank_id" if per_bank else "") + try: + rows = await conn.fetch( + f"SELECT operation_type, status, {bank_sel}COUNT(*) AS count " + f'FROM "{schema}".async_operations ' + "WHERE status IN ('pending', 'processing', 'failed') " + f"GROUP BY {ops_grp}" + ) + for row in rows: + bank = row["bank_id"] if per_bank else None + key = _AsyncOpKey(schema, row["operation_type"] or "unknown", row["status"], bank) + async_ops[key] = async_ops.get(key, 0) + int(row["count"]) + except Exception: + logger.debug("Async-ops queue query failed for schema %s", schema, exc_info=True) + + # Consolidation backlog + stranded counts. Two separate COUNT(*) + # queries rather than one with two FILTERs — each WHERE matches a + # partial-index predicate exactly: + # idx_memory_units_unconsolidated WHERE consolidated_at IS NULL ... + # idx_memory_units_consolidation_failed WHERE consolidation_failed_at IS NOT NULL ... + # GROUP BY bank_id still composes — bank_id is each index's lead column. + # + # The backlog count runs with seqscan disabled in a scoped + # transaction. The partial index matches its predicate, but + # `consolidated_at IS NULL` is true for a large fraction of the + # table (every observation has a null consolidated_at), so the + # planner misjudges selectivity and otherwise seq-scans the whole + # (largest) table on every refresh — verified on a 114k-row table + # via EXPLAIN: seq scan ~92 ms vs index scan ~0.1 ms. SET LOCAL + # forces the index path and resets at transaction end. The failed + # count below needs no such nudge: `consolidation_failed_at IS NOT + # NULL` is rare, so its index is chosen on cost. + try: + async with conn.transaction(): + await conn.execute("SET LOCAL enable_seqscan = off") + rows = await conn.fetch( + f"SELECT {bank_sel}COUNT(*) AS count " + f'FROM "{schema}".memory_units ' + "WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')" + f"{bank_grp}" + ) + for row in rows: + bank = row["bank_id"] if per_bank else None + key = _BacklogKey(schema, bank) + backlog[key] = backlog.get(key, 0) + int(row["count"]) + except Exception: + logger.debug("Consolidation backlog query failed for schema %s", schema, exc_info=True) + + try: + rows = await conn.fetch( + f"SELECT {bank_sel}COUNT(*) AS count " + f'FROM "{schema}".memory_units ' + "WHERE consolidation_failed_at IS NOT NULL AND fact_type IN ('experience', 'world')" + f"{bank_grp}" + ) + for row in rows: + bank = row["bank_id"] if per_bank else None + key = _BacklogKey(schema, bank) + failed[key] = failed.get(key, 0) + int(row["count"]) + except Exception: + logger.debug("Consolidation failed query failed for schema %s", schema, exc_info=True) + + self._async_ops_counts = async_ops + self._consolidation_backlog = backlog + self._consolidation_failed = failed + # Global metrics collector instance (defaults to no-op) _metrics_collector: MetricsCollectorBase = NoOpMetricsCollector() diff --git a/hindsight-api-slim/hindsight_api/migrations.py b/hindsight-api-slim/hindsight_api/migrations.py index d436059b2c..d9e6378598 100644 --- a/hindsight-api-slim/hindsight_api/migrations.py +++ b/hindsight-api-slim/hindsight_api/migrations.py @@ -25,7 +25,9 @@ from alembic import command from alembic.config import Config from alembic.script.revision import ResolutionError +from alembic.util.exc import CommandError from sqlalchemy import Connection, create_engine, text +from sqlalchemy.pool import NullPool from ._pg_search import normalize_pg_search_tokenizer, pg_search_bm25_columns from ._vector_index import ( @@ -131,7 +133,12 @@ def _run_migrations_internal(database_url: str, script_location: str, schema: st try: with _alembic_lock: command.upgrade(alembic_cfg, "heads") - except ResolutionError as e: + except (ResolutionError, CommandError) as e: + # command.upgrade() wraps ResolutionError in CommandError via + # ScriptDirectory._catch_revision_errors, so the wrapped form is what + # actually reaches us; re-raise CommandErrors with any other cause. + if isinstance(e, CommandError) and not isinstance(e.__cause__, ResolutionError): + raise # This happens during rolling deployments when a newer version of the code # has already run migrations, and this older replica doesn't have the new # migration files. The database is already at a newer revision than we know. @@ -241,7 +248,14 @@ def run_migrations( # 2. After acquiring the lock, COMMIT the transaction on the advisory-lock # connection itself before running migrations. pg_advisory_lock is # session-level, so the lock survives the COMMIT. - engine = create_engine(migration_url) + # NullPool: do not retain the connection in a pool after the migration. + # Each schema migration opens a few short-lived engines (here plus the + # ensure_* steps); with the default QueuePool those connections linger + # until GC, and running many schemas in parallel (migration_concurrency) + # multiplies that footprint and exhausts max_connections — observed as + # "FATAL: sorry, too many clients already" sweeping 20k schemas at + # concurrency 12. NullPool closes the connection on return. + engine = create_engine(migration_url, poolclass=NullPool) with engine.connect() as conn: logger.debug(f"Acquiring migration advisory lock for schema '{schema_name}' (id={lock_id})...") while True: @@ -394,7 +408,7 @@ def check_migration_status( return None, None # Get current revision from database - engine = create_engine(to_libpq_url(database_url)) + engine = create_engine(to_libpq_url(database_url), poolclass=NullPool) with engine.connect() as connection: context = MigrationContext.configure(connection) current_rev = context.get_current_revision() @@ -567,7 +581,7 @@ def ensure_embedding_dimension( """ schema_name = schema or "public" - engine = create_engine(to_libpq_url(database_url)) + engine = create_engine(to_libpq_url(database_url), poolclass=NullPool) with engine.connect() as conn: # Check if memory_units table exists (proxy for schema being initialized) table_exists = conn.execute( @@ -590,6 +604,10 @@ def ensure_embedding_dimension( _migrate_table_embedding_dimension(conn, schema_name, "memory_units", required_dimension, vector_ext) _migrate_table_embedding_dimension(conn, schema_name, "mental_models", required_dimension, vector_ext) + # NOTE: invalidated_memory_units is deliberately omitted. The curation archive has no + # embedding column at all (dropped in migration d4f6a8c2e1b3) — invalidate stores no + # embedding and revert recomputes one — so there is no archive vector to re-dimension + # and a model switch can't trip a dimension mismatch there (#2209). def ensure_vector_extension( @@ -616,7 +634,7 @@ def ensure_vector_extension( """ schema_name = schema or "public" - engine = create_engine(to_libpq_url(database_url)) + engine = create_engine(to_libpq_url(database_url), poolclass=NullPool) with engine.connect() as conn: # Detect which vector extension should be used target_ext = _detect_vector_extension(conn, vector_extension) @@ -830,7 +848,7 @@ def ensure_text_search_extension( schema_name = schema or "public" pg_search_tokenizer = normalize_pg_search_tokenizer(pg_search_tokenizer) - engine = create_engine(to_libpq_url(database_url)) + engine = create_engine(to_libpq_url(database_url), poolclass=NullPool) with engine.connect() as conn: # Tables with search_vector columns to check tables_to_check = [ @@ -1123,3 +1141,129 @@ def ensure_text_search_extension( conn.commit() logger.info(f"Successfully migrated text search to {text_search_extension}") + + +def _migrate_one_schema_pg( + database_url: str, + schema: str, + *, + migration_database_url: str | None, + embedding_dimension: int | None, + vector_extension: str, + text_search_extension: str, + pg_search_tokenizer: str | None, + ensure_extensions: bool, +) -> str: + """Run migrations + post-migration extension setup for a SINGLE PG schema. + + Module-level (not a closure) so it is picklable and can run inside a + ``ProcessPoolExecutor`` worker. The steps run strictly in order — this is + the per-tenant sequential unit; parallelism happens only *across* schemas. + Returns the schema name on success; raises on the first failing step so the + caller can attribute the failure back to this schema. + """ + run_migrations(database_url, schema=schema, migration_database_url=migration_database_url) + if embedding_dimension is not None: + ensure_embedding_dimension( + database_url, + embedding_dimension, + schema=schema, + vector_extension=vector_extension, + ) + if ensure_extensions: + ensure_vector_extension(database_url, vector_extension=vector_extension, schema=schema) + ensure_text_search_extension( + database_url, + text_search_extension=text_search_extension, + schema=schema, + pg_search_tokenizer=pg_search_tokenizer, + ) + return schema + + +def _make_migration_executor(max_workers: int): + """Build the executor that runs per-schema migrations in parallel. + + Each schema must run in its OWN process — Alembic's ``command.upgrade()`` + uses non-thread-safe module globals (serialized in-process by + ``_alembic_lock``), so a thread pool would not actually run two upgrades at + once. ``spawn`` gives every worker a clean interpreter on all platforms, + avoiding the fork-of-a-multithreaded-process deadlock hazard (the API server + holds threads/pools when migrations run on startup). + + Factored out so tests can substitute an in-process executor. + """ + import multiprocessing + from concurrent.futures import ProcessPoolExecutor + + return ProcessPoolExecutor(max_workers=max_workers, mp_context=multiprocessing.get_context("spawn")) + + +def run_migrations_for_schemas( + database_url: str, + schemas: list[str], + *, + concurrency: int = 1, + migration_database_url: str | None = None, + embedding_dimension: int | None = None, + vector_extension: str = "pgvector", + text_search_extension: str = "native", + pg_search_tokenizer: str | None = None, + ensure_extensions: bool = True, +) -> None: + """Run PostgreSQL migrations for many schemas, up to ``concurrency`` at once. + + Within a schema the work is always sequential (migrate → embedding dim → + vector ext → text-search ext). Across schemas, when ``concurrency > 1`` each + schema is migrated in its OWN process: Alembic's ``command.upgrade()`` relies + on non-thread-safe module-level globals (serialized in-process by + ``_alembic_lock``), so threads would gain nothing — separate interpreters + each get a clean Alembic context. Per-schema advisory locks + (``_get_schema_lock_id``) keep concurrent processes from colliding on the + same schema across replicas. + + ``database_url`` must already be resolved (e.g. an embedded ``pg0`` instance + started in the parent) — workers receive it verbatim and only connect. + + Failures are collected per schema and re-raised together so one bad tenant + does not hide the status of the others. + """ + if not schemas: + return + + worker_kwargs = dict( + migration_database_url=migration_database_url, + embedding_dimension=embedding_dimension, + vector_extension=vector_extension, + text_search_extension=text_search_extension, + pg_search_tokenizer=pg_search_tokenizer, + ensure_extensions=ensure_extensions, + ) + + effective = max(1, min(concurrency, len(schemas))) + if effective == 1: + # Inline, in-process — no subprocess overhead for the common single + # tenant / sequential case (and keeps embedded pg0 dev simple). + for schema in schemas: + _migrate_one_schema_pg(database_url, schema, **worker_kwargs) + return + + logger.info("Migrating %d schema(s) with concurrency=%d", len(schemas), effective) + errors: dict[str, BaseException] = {} + with _make_migration_executor(effective) as executor: + futures = { + executor.submit(_migrate_one_schema_pg, database_url, schema, **worker_kwargs): schema for schema in schemas + } + for future in futures: + schema = futures[future] + try: + future.result() + except Exception as exc: # noqa: BLE001 — aggregate per-schema, re-raise below + errors[schema] = exc + logger.error("Migration failed for schema '%s': %s", schema, exc) + + if errors: + failed = ", ".join(sorted(errors)) + raise RuntimeError( + f"Database migrations failed for {len(errors)} of {len(schemas)} schema(s): {failed}" + ) from next(iter(errors.values())) diff --git a/hindsight-api-slim/hindsight_api/models.py b/hindsight-api-slim/hindsight_api/models.py index 58f96f1b27..94b4d6af75 100644 --- a/hindsight-api-slim/hindsight_api/models.py +++ b/hindsight-api-slim/hindsight_api/models.py @@ -4,8 +4,12 @@ from dataclasses import dataclass from datetime import datetime +from typing import TYPE_CHECKING from uuid import UUID as PyUUID +if TYPE_CHECKING: + from .cancellation import CancellationToken + @dataclass class RequestContext: @@ -30,6 +34,21 @@ class RequestContext: # validators that want exponential backoff on repeated failures (e.g. # "defer for 2^retry_count minutes") without querying the DB themselves. retry_count: int = 0 + # Cooperative cancellation signal for long-running operations. The HTTP + # layer sets this to a token that fires when the client disconnects; the + # engine checks it at stage boundaries and aborts abandoned work so it stops + # consuming CPU/DB resources (issue #2122). None means "never cancelled" — + # every checkpoint is a no-op. + cancellation: "CancellationToken | None" = None + + def raise_if_cancelled(self) -> None: + """Abort the current operation if its cancellation token has fired. + + A no-op when no token is attached, so engine code can call it at every + stage boundary without caring whether the caller opted into cancellation. + """ + if self.cancellation is not None: + self.cancellation.raise_if_cancelled() from pgvector.sqlalchemy import Vector diff --git a/hindsight-api-slim/hindsight_api/webhooks/__init__.py b/hindsight-api-slim/hindsight_api/webhooks/__init__.py index 1cfcc39ff8..dc54e6464d 100644 --- a/hindsight-api-slim/hindsight_api/webhooks/__init__.py +++ b/hindsight-api-slim/hindsight_api/webhooks/__init__.py @@ -1,7 +1,15 @@ """Webhook system for Hindsight API event notifications.""" from .manager import WebhookManager -from .models import ConsolidationEventData, RetainEventData, WebhookConfig, WebhookEvent, WebhookEventType +from .models import ( + ConsolidationEventData, + MemoryDefenseEventData, + MemoryDefenseHit, + RetainEventData, + WebhookConfig, + WebhookEvent, + WebhookEventType, +) __all__ = [ "WebhookManager", @@ -9,5 +17,7 @@ "WebhookEvent", "WebhookEventType", "ConsolidationEventData", + "MemoryDefenseEventData", + "MemoryDefenseHit", "RetainEventData", ] diff --git a/hindsight-api-slim/hindsight_api/webhooks/manager.py b/hindsight-api-slim/hindsight_api/webhooks/manager.py index 544404a6f0..b86b12947b 100644 --- a/hindsight-api-slim/hindsight_api/webhooks/manager.py +++ b/hindsight-api-slim/hindsight_api/webhooks/manager.py @@ -70,7 +70,10 @@ async def fire_event(self, event: WebhookEvent, schema: str | None = None) -> No webhook_table = _fq_table("webhooks", schema) ops_table = _fq_table("async_operations", schema) now = datetime.now(timezone.utc) - payload_str = event.model_dump_json() + # Drop null fields so receivers don't see promised-but-unfilled keys. + # OSS leaves SIEM-enrichment fields (severity, api_key_name, etc.) None + # because it doesn't have the data; cloud populates them when it does. + payload_str = event.model_dump_json(exclude_none=True) try: async with self._backend.acquire() as conn: @@ -150,7 +153,10 @@ async def fire_event_with_conn(self, event: WebhookEvent, conn: Any, schema: str webhook_table = _fq_table("webhooks", schema) ops_table = _fq_table("async_operations", schema) now = datetime.now(timezone.utc) - payload_str = event.model_dump_json() + # Drop null fields so receivers don't see promised-but-unfilled keys. + # OSS leaves SIEM-enrichment fields (severity, api_key_name, etc.) None + # because it doesn't have the data; cloud populates them when it does. + payload_str = event.model_dump_json(exclude_none=True) try: rows = await self._backend.ops.get_webhooks_for_dispatch( diff --git a/hindsight-api-slim/hindsight_api/webhooks/models.py b/hindsight-api-slim/hindsight_api/webhooks/models.py index d19a585a70..ffdfc66389 100644 --- a/hindsight-api-slim/hindsight_api/webhooks/models.py +++ b/hindsight-api-slim/hindsight_api/webhooks/models.py @@ -9,6 +9,7 @@ class WebhookEventType(StrEnum): CONSOLIDATION_COMPLETED = "consolidation.completed" RETAIN_COMPLETED = "retain.completed" + MEMORY_DEFENSE_TRIGGERED = "memory_defense.triggered" class ConsolidationEventData(BaseModel): @@ -23,13 +24,52 @@ class RetainEventData(BaseModel): tags: list[str] | None = None +class MemoryDefenseHit(BaseModel): + """A single secret match inside a non-allow decision. + + ``preview`` is a fingerprinted, redaction-identifiable rendering of the + matched value (e.g. ``ghp_AAAA...BBBB``) so SIEM operators can correlate + against their credential inventory WITHOUT the raw secret crossing the + network. Implementations must never put the raw value here. + """ + + detector: str # the inner detector that matched (e.g. "GitHub Token") + preview: str # fingerprinted value, never the raw secret + + +class MemoryDefenseEventData(BaseModel): + """Payload for a memory_defense.triggered event (one item, one non-allow decision). + + The four base fields (``action``/``detector``/``document_id``/``message``) + plus ``matched_types`` are populated by every implementation including OSS's + built-in regex defense. The remaining fields are optional SIEM-enrichment + surfaces that downstream extensions (e.g. hindsight-cloud) populate when + they have richer per-decision context — severity classification, the API + key that submitted the retain, fingerprinted hit previews for SIEM + correlation, and pointers into the audit trail. OSS leaves them ``None``; + receivers should treat absence as "not provided" rather than "no match". + """ + + action: str # "redact" or "block" + detector: str | None = None # e.g. "sensitive_data" + document_id: str | None = None + matched_types: list[str] | None = None # redaction pattern labels that fired + message: str | None = None + # --- Optional SIEM enrichment (populated by extensions, not OSS) --- + severity: str | None = None # "low" / "medium" / "high" / "critical" + api_key_name: str | None = None # human-readable name of the submitting API key + hits: list[MemoryDefenseHit] | None = None # per-match fingerprints for correlation + memory_unit_id: str | None = None # drill-down pointer (when the decision was REDACT) + receipt_uri: str | None = None # storage pointer for the audit trail entry + + class WebhookEvent(BaseModel): event: WebhookEventType bank_id: str operation_id: str - status: str # "completed" or "failed" + status: str # "completed"/"failed" for retain/consolidation; the action ("redact"/"block") for memory_defense timestamp: datetime - data: ConsolidationEventData | RetainEventData + data: ConsolidationEventData | RetainEventData | MemoryDefenseEventData class WebhookHttpConfig(BaseModel): diff --git a/hindsight-api-slim/hindsight_api/worker/poller.py b/hindsight-api-slim/hindsight_api/worker/poller.py index b42dd14a2c..73d09161f0 100644 --- a/hindsight-api-slim/hindsight_api/worker/poller.py +++ b/hindsight-api-slim/hindsight_api/worker/poller.py @@ -16,13 +16,27 @@ import traceback from collections import Counter from collections.abc import Awaitable, Callable, Iterable -from dataclasses import dataclass, field +from dataclasses import dataclass from typing import TYPE_CHECKING, Any from ..engine.schema import fq_table_explicit as fq_table +from ..metrics import get_metrics_collector from .exceptions import DeferOperation, RetryTaskAt from .stage import StageHolder, bind_holder +# Map DB operation_type -> metric `operation` label, collapsing the retain +# variants onto "retain" so async worker completions land on the same +# operation="retain" series the synchronous API path emits. Unknown types +# pass through unchanged. +_RETAIN_OP_TYPES = {"retain", "batch_retain", "file_convert_retain"} + + +def _metric_operation_label(operation_type: str | None) -> str: + if operation_type in _RETAIN_OP_TYPES: + return "retain" + return operation_type or "unknown" + + if TYPE_CHECKING: from hindsight_api.engine.db.base import DatabaseBackend, DatabaseConnection from hindsight_api.extensions.tenant import TenantExtension @@ -226,38 +240,23 @@ async def _scan_active_schemas(self, schemas: list[str | None]) -> set[str | Non """ async with self._backend.acquire() as conn: if await self._optional_routines.is_installed(conn, "schemas_with_pending_work"): + # The routine IS the authority on where work exists: every schema + # it returns is claimable, and every schema it does NOT return is + # treated as having nothing to do this cycle. That is the entire + # point of installing it — one round-trip replaces N per-schema + # EXISTS probes. We deliberately do NOT re-verify the omitted + # schemas with a per-schema scan: that re-runs the exact queries + # the routine exists to avoid, on every idle poll, silently + # negating the optimisation. + # + # Because the result is trusted wholesale, the routine is only + # appropriate for multi-tenant deployments. A single-schema + # (default/public only) install should NOT create it and instead + # falls through to the per-schema path below — a single cheap + # EXISTS check that cannot starve. See + # ``hindsight_api.engine.db.optional_routines``. rows = await conn.fetch("SELECT * FROM public.schemas_with_pending_work()") - routine_active = {self._normalize_poll_schema(r[0]) for r in rows} - known_schemas = set(schemas) - active = routine_active & known_schemas - unknown = routine_active - known_schemas - if unknown: - logger.warning( - "Optional PG routine public.schemas_with_pending_work() returned schema(s) " - "not present in tenant discovery: %s", - sorted(str(s) for s in unknown), - ) - - # The optional routine returns PostgreSQL schema names, but the poller uses - # None for the default schema. Older operator-supplied implementations also - # commonly scan tenant_% only; when the default schema is in scope but absent - # from the routine result, verify via the fully-correct per-schema fallback so - # public single-tenant deployments cannot silently starve. - should_verify_with_fallback = (None in known_schemas and None not in active) or ( - bool(routine_active) and not active - ) - if not should_verify_with_fallback: - return active - - fallback_active = await self._scan_active_schemas_by_exists(conn, schemas) - missed = fallback_active - active - if missed: - logger.warning( - "Optional PG routine public.schemas_with_pending_work() missed claimable schema(s) %s; " - "using per-schema fallback for this poll", - sorted(str(s) for s in missed), - ) - return fallback_active + return {self._normalize_poll_schema(r[0]) for r in rows} return await self._scan_active_schemas_by_exists(conn, schemas) @@ -716,6 +715,24 @@ async def _execute_task_inner(self, task: ClaimedTask, holder: StageHolder | Non """ task_type = task.task_dict.get("type", "unknown") bank_id = task.task_dict.get("bank_id", "unknown") + # Operation metric (source="worker"): record on terminal outcomes only, so + # async worker throughput and latency (retain, consolidation and the other + # worker task types) are visible in Prometheus. Prefer the DB-authoritative + # operation_type. + # + # success semantics are deliberately narrow: success=false means the task + # raised out to the poller (an unexpected error, or retry-exhausted). It does + # NOT capture deterministic failures that the executor handles itself and + # returns from normally (file_convert_retain, non-retryable errors via + # memory_engine.execute_task) — those record success=true here. Treat this as + # a completion-throughput signal, not a failure-rate one: for authoritative + # failure visibility use the hindsight_async_operations{status="failed"} gauge, + # which reads each operation's final DB status. + op_label = _metric_operation_label(task.task_dict.get("operation_type") or task_type) + op_start = time.time() + metrics = get_metrics_collector() + # None = not a terminal outcome (deferred/retried) → no metric. + terminal_success: bool | None = None # Bind the stage holder in this task's own contextvar scope so engine # code running under us can update it via stage.set_stage(). If holder @@ -732,14 +749,28 @@ async def _execute_task_inner(self, task: ClaimedTask, holder: StageHolder | Non task.task_dict["_schema"] = task.schema await self._executor(task.task_dict) logger.debug(f"Task {task.operation_id} execution finished") + terminal_success = True except DeferOperation as e: + # Deferral is not a terminal outcome — do not record a completion. await self._defer_operation(task.operation_id, e.exec_date, e.reason, task.schema) except RetryTaskAt as e: + # Retry is not a terminal outcome — do not record a completion. await self._schedule_retry(task.operation_id, e.retry_at, str(e), task.schema) except Exception as e: logger.error(f"Task {task.operation_id} failed: {e}") traceback.print_exc() await self._mark_failed(task.operation_id, str(e), task.schema) + terminal_success = False + + # Record the metric outside the executor's exception scope so a metrics + # reporting failure can never be mistaken for a task failure and flip terminal state. + if terminal_success is not None: + try: + metrics.record_operation_result( + op_label, bank_id, success=terminal_success, duration=time.time() - op_start, source="worker" + ) + except Exception: + logger.warning(f"Failed to record worker operation metric for {task.operation_id}", exc_info=True) async def recover_own_tasks(self) -> int: """ @@ -824,7 +855,6 @@ async def _recover_batch_operations(self, schema: str | None) -> int: recovered = 0 for row in rows: operation_id = str(row["operation_id"]) - task_payload = row["task_payload"] result_metadata = row["result_metadata"] # Parse metadata @@ -838,12 +868,6 @@ async def _recover_batch_operations(self, schema: str | None) -> int: f"Recovering batch operation: operation_id={operation_id}, batch_id={batch_id}, provider={batch_provider}" ) - # Parse task_payload - if isinstance(task_payload, str): - task_dict = json.loads(task_payload) - else: - task_dict = task_payload - # Mark operation as ready for re-processing # Reset to pending with task_payload intact so worker picks it up again async with self._backend.acquire() as conn: diff --git a/hindsight-api-slim/pyproject.toml b/hindsight-api-slim/pyproject.toml index 6281f3f131..c6cfb77afe 100644 --- a/hindsight-api-slim/pyproject.toml +++ b/hindsight-api-slim/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "hindsight-api-slim" -version = "0.7.2" +version = "0.8.3" description = "Hindsight: Agent Memory That Works Like Human Memory" readme = "README.md" requires-python = ">=3.11" @@ -63,7 +63,7 @@ dependencies = [ "langsmith>=0.6.3", # SSRF via tracing header injection fix "protobuf>=6.33.5", # JSON recursion depth bypass fix "pillow>=12.1.1", # Out-of-bounds write in PSD image loading fix - "cryptography>=46.0.6,<47", # Incomplete DNS name constraint enforcement fix; cap <47 — 47.0.0 SIGILLs on some ARM64 Linux VMs (Docker/Podman on Apple Silicon), pyca/cryptography#14733 + "cryptography>=48.0.1", # GHSA-537c-gmf6-5ccf: bundled-OpenSSL OOB read fix needs >=48.0.1. Prior <47 cap (47.0.0 SIGILL on ARM64 Docker/Podman, pyca/cryptography#14733) lifted — 47/48/49 verified importing + RSA sign/verify cleanly on linux/arm64 (Docker on Apple Silicon) and native arm64 macOS; upstream issue closed unconfirmed. "filelock>=3.20.1", # TOCTOU race condition fix "authlib>=1.6.9", # Account takeover/JWS header injection vulnerability fix "pyjwt>=2.12.0", # Accepts unknown crit header extensions fix @@ -81,6 +81,10 @@ local-ml = [ # Local ML models for embeddings/reranking "sentence-transformers>=3.3.0", "transformers>=4.53.0", # Security fixes for ReDoS vulnerabilities + # transformers (incl. latest 5.x) hard-requires tokenizers<=0.23.0 via a + # runtime check; without this cap an in-place upgrade can pull tokenizers + # 0.23.1 and break local embeddings/reranker startup. See issue #2055. + "tokenizers>=0.22.0,<=0.23.0", "torch>=2.6.0", # CVE fix for remote code execution "einops>=0.8.2", "flashrank>=0.2.0", @@ -100,6 +104,7 @@ local-onnx = [ # In-process ONNX Runtime embeddings without an Ollama/TEI sidecar "onnxruntime>=1.17.0", "transformers>=4.53.0", + "tokenizers>=0.22.0,<=0.23.0", # See issue #2055 (transformers caps tokenizers<=0.23.0) "huggingface-hub>=0.20.0", "numpy>=1.26.0", ] @@ -182,23 +187,24 @@ dev = [ [tool.ruff] line-length = 120 target-version = "py311" -exclude = [ - "tests/", - "**/tests/", -] [tool.ruff.lint] +# Tests are formatted (via `ruff format`) but excluded from lint rules, which +# are too noisy for test code (unused imports/vars, import ordering). +exclude = [ + "tests/**", + "**/tests/**", +] select = [ "E", # pycodestyle errors "W", # pycodestyle warnings "F", # Pyflakes "I", # isort + "B021", # flake8-bugbear: f-string used as docstring (leaves __doc__ None) ] ignore = [ "E501", # line too long (handled by formatter) "E402", # module import not at top of file - "F401", # unused import (too noisy during development) - "F841", # unused variable (too noisy during development) "F811", # redefined while unused "F821", # undefined name (forward references in type hints) ] diff --git a/hindsight-api-slim/tests/conftest.py b/hindsight-api-slim/tests/conftest.py index 3014c810cb..6a7ea2ac60 100644 --- a/hindsight-api-slim/tests/conftest.py +++ b/hindsight-api-slim/tests/conftest.py @@ -1,6 +1,7 @@ """ Pytest configuration and shared fixtures. """ + import asyncio import os from pathlib import Path @@ -15,11 +16,42 @@ from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer from hindsight_api.engine.task_backend import SyncTaskBackend from hindsight_api.pg0 import EmbeddedPostgres +from hindsight_api.tracing import unregister_span_recorder + + +async def _teardown_memory_engine(mem: MemoryEngine) -> None: + """Tear down a test MemoryEngine, guaranteeing its span recorder is unregistered. + + LLM-trace recorders live in a process-global registry; ``MemoryEngine.close()`` is + the only thing that removes the engine's recorder from it. If close() is skipped + (pool already closing) or raises before that step, the recorder leaks and a later + test's LLM calls get recorded into the shared DB — the flaky + test_llm_trace::test_disabled_writes_no_rows (#2229). Unregister unconditionally; + it's a no-op when close() already did it. + """ + try: + if mem._pool and not mem._pool._closing: + await mem.close() + except Exception: + pass + finally: + unregister_span_recorder(mem._llm_recorder) + # Default pg0 instance configuration for tests DEFAULT_PG0_INSTANCE_NAME = "hindsight-test" DEFAULT_PG0_PORT = int(os.environ.get("HINDSIGHT_TEST_PG_PORT", "5556")) +# Keep the background MaintenanceLoop from auto-starting during tests. In +# production it sweeps retention and re-schedules consolidation, but its timers +# would race shared-pg0 test data (e.g. delete llm_requests/audit_log rows a test +# just inserted). Disabling the reconcile interval and llm-trace retention — with +# audit retention already off by default — leaves no job enabled, so the loop +# never starts. Tests that exercise it call MaintenanceLoop methods +# (_run_reconcile / _purge_expired) directly. +os.environ.setdefault("HINDSIGHT_API_CONSOLIDATION_RECONCILE_INTERVAL_SECONDS", "0") +os.environ.setdefault("HINDSIGHT_API_LLM_TRACE_RETENTION_DAYS", "-1") + # Load environment variables from .env at the start of test session def pytest_configure(config): @@ -66,6 +98,7 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id): if db_url and not _parse_pg0_url(db_url)[0]: # Plain postgresql:// URL - use it directly but still run migrations from hindsight_api.migrations import run_migrations + run_migrations(db_url) return db_url @@ -117,6 +150,7 @@ def pg0_db_url(db_url, tmp_path_factory, worker_id): # Run migrations - uses PostgreSQL advisory lock internally, # so safe to call from multiple workers (only one will actually run migrations) from hindsight_api.migrations import run_migrations + run_migrations(url) # Clean up stale test data from previous sessions. Per-bank vector indexes @@ -147,8 +181,7 @@ async def _do_cleanup(): conn = await asyncpg.connect(db_url) try: idx_rows = await conn.fetch( - "SELECT indexname FROM pg_indexes " - "WHERE schemaname = 'public' AND indexname LIKE 'idx_mu_emb_%'" + "SELECT indexname FROM pg_indexes WHERE schemaname = 'public' AND indexname LIKE 'idx_mu_emb_%'" ) if idx_rows: for row in idx_rows: @@ -156,10 +189,20 @@ async def _do_cleanup(): # Truncate test data in dependency order for table in [ - "entity_cooccurrences", "unit_entities", "memory_links", - "entities", "memory_units", "chunks", "documents", - "mental_models", "directives", "async_operations", - "audit_log", "webhooks", "file_storage", "banks", + "entity_cooccurrences", + "unit_entities", + "memory_links", + "entities", + "memory_units", + "chunks", + "documents", + "mental_models", + "directives", + "async_operations", + "audit_log", + "webhooks", + "file_storage", + "banks", ]: try: await conn.execute(f"TRUNCATE {table} CASCADE") @@ -242,8 +285,7 @@ def oracle_db_url(_oracle_admin_dsn): # Create test user (idempotent — skip if already exists) try: cursor.execute( - f'CREATE USER {test_user} IDENTIFIED BY "{test_pass}" ' - f"DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS" + f'CREATE USER {test_user} IDENTIFIED BY "{test_pass}" DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS' ) except oracledb.DatabaseError as e: if hasattr(e.args[0], "code") and e.args[0].code == 1920: @@ -321,10 +363,7 @@ async def oracle_memory(oracle_db_url, embeddings, cross_encoder, query_analyzer ) await mem.initialize() yield mem - try: - await mem.close() - except Exception: - pass + await _teardown_memory_engine(mem) finally: # Restore original env var and clear config cache if old_backend is None: @@ -410,13 +449,12 @@ def cross_encoder(tmp_path_factory, worker_id): return ce + @pytest.fixture(scope="session") def query_analyzer(): return DateparserQueryAnalyzer() - - @pytest_asyncio.fixture(scope="function") async def memory(pg0_db_url, embeddings, cross_encoder, query_analyzer): """ @@ -443,11 +481,7 @@ async def memory(pg0_db_url, embeddings, cross_encoder, query_analyzer): ) await mem.initialize() yield mem - try: - if mem._pool and not mem._pool._closing: - await mem.close() - except Exception: - pass + await _teardown_memory_engine(mem) @pytest_asyncio.fixture(scope="function") @@ -476,11 +510,7 @@ async def memory_real_llm(pg0_db_url, embeddings, cross_encoder, query_analyzer) ) await mem.initialize() yield mem - try: - if mem._pool and not mem._pool._closing: - await mem.close() - except Exception: - pass + await _teardown_memory_engine(mem) @pytest_asyncio.fixture(scope="function") @@ -507,8 +537,22 @@ async def memory_no_llm_verify(pg0_db_url, embeddings, cross_encoder, query_anal ) await mem.initialize() yield mem - try: - if mem._pool and not mem._pool._closing: - await mem.close() - except Exception: - pass + await _teardown_memory_engine(mem) + + +@pytest_asyncio.fixture +async def api_client(memory): + """General-purpose HTTP test client over the `memory` fixture's app. + + Use for any integration test that exercises the FastAPI surface without + needing audit-logging side effects. See `audit_api_client` for the + audit-enabled variant. + """ + import httpx + + from hindsight_api.api import create_app + + app = create_app(memory, initialize_memory=False) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + yield client diff --git a/hindsight-api-slim/tests/e2e_oracle_smoke.py b/hindsight-api-slim/tests/e2e_oracle_smoke.py index 2330c0988e..64d551e292 100644 --- a/hindsight-api-slim/tests/e2e_oracle_smoke.py +++ b/hindsight-api-slim/tests/e2e_oracle_smoke.py @@ -23,6 +23,7 @@ # Helpers # --------------------------------------------------------------------------- + def _log(step: int, total: int, msg: str) -> None: print(f" [{step}/{total}] {msg}") @@ -64,8 +65,7 @@ def _bootstrap_test_user(admin_dsn: dict[str, str]) -> str: # Create user (skip if already exists - ORA-01920) try: cursor.execute( - f'CREATE USER {test_user} IDENTIFIED BY "{test_pass}" ' - f"DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS" + f'CREATE USER {test_user} IDENTIFIED BY "{test_pass}" DEFAULT TABLESPACE USERS QUOTA UNLIMITED ON USERS' ) except oracledb.DatabaseError as e: if hasattr(e.args[0], "code") and e.args[0].code == 1920: @@ -100,6 +100,7 @@ def _bootstrap_test_user(admin_dsn: dict[str, str]) -> str: # Main # --------------------------------------------------------------------------- + async def _run() -> None: total_steps = 8 @@ -290,6 +291,7 @@ def main() -> int: except Exception as exc: print(f"\nFAILED: {exc}", file=sys.stderr) import traceback + traceback.print_exc() return 1 diff --git a/hindsight-api-slim/tests/llm_judge.py b/hindsight-api-slim/tests/llm_judge.py index 35afbefd28..d7eaca0b1c 100644 --- a/hindsight-api-slim/tests/llm_judge.py +++ b/hindsight-api-slim/tests/llm_judge.py @@ -12,6 +12,7 @@ ) """ +import asyncio import json import logging import os @@ -36,6 +37,19 @@ ) _JUDGE_BASE_URL = os.getenv("HINDSIGHT_TEST_JUDGE_BASE_URL", "") +# Flakiness hardening. A single temperature-0 judge call still occasionally flips +# its verdict on borderline phrasing — the dominant source of hs_llm_core +# flakiness. When the primary verdict is "not met", we ask for a few independent +# second opinions (at a higher temperature so the samples genuinely differ) and +# uphold the failure only if the majority agrees. Verdicts that pass on the first +# call are returned immediately, so passing tests are unaffected in cost or +# behaviour, and genuine failures (where every judge agrees) still fail. +_JUDGE_CONFIRMATIONS = int(os.getenv("HINDSIGHT_TEST_JUDGE_CONFIRMATIONS", "2")) +_JUDGE_CONFIRM_TEMPERATURE = float(os.getenv("HINDSIGHT_TEST_JUDGE_CONFIRM_TEMPERATURE", "0.5")) +# Retry transient judge-call errors (rate limits, 5xx) so judge infrastructure +# hiccups never fail the test under evaluation. +_JUDGE_CALL_ATTEMPTS = int(os.getenv("HINDSIGHT_TEST_JUDGE_CALL_ATTEMPTS", "3")) + class JudgeVerdict(BaseModel): meets_criteria: bool @@ -58,6 +72,58 @@ def _get_judge(): return _judge_instance +async def _judge_once( + response: str, + criteria: str, + context: str | None, + temperature: float, +) -> JudgeVerdict: + """Run a single judge verdict, retrying transient call errors.""" + judge = _get_judge() + context_block = f"\n\nContext provided to the system:\n{context}" if context else "" + messages = [ + { + "role": "system", + "content": ( + "You are a test evaluation judge. Given a response and evaluation criteria, " + "determine whether the response meets the criteria. " + 'Respond with JSON: {"meets_criteria": true/false, "reasoning": "brief explanation"}' + ), + }, + { + "role": "user", + "content": ( + f"## Response to evaluate\n{response}\n" + f"{context_block}\n" + f"## Criteria\n{criteria}\n\n" + "Does the response meet the criteria?" + ), + }, + ] + + last_error: Exception | None = None + for attempt in range(max(1, _JUDGE_CALL_ATTEMPTS)): + try: + result = await judge.call( + messages=messages, + response_format=JudgeVerdict, + max_completion_tokens=256, + temperature=temperature, + scope="test_judge", + ) + if isinstance(result, JudgeVerdict): + return result + if isinstance(result, dict): + return JudgeVerdict(**result) + return JudgeVerdict(**json.loads(str(result))) + except Exception as e: # transient provider error — retry before giving up + last_error = e + logger.warning(f"Judge call failed (attempt {attempt + 1}/{_JUDGE_CALL_ATTEMPTS}): {e}") + await asyncio.sleep(1.0 * (attempt + 1)) + + raise RuntimeError(f"Judge call failed after {_JUDGE_CALL_ATTEMPTS} attempts: {last_error}") from last_error + + async def evaluate( response: str, criteria: str, @@ -65,6 +131,12 @@ async def evaluate( ) -> JudgeVerdict: """Ask the judge LLM whether a response meets the given criteria. + The primary verdict is deterministic (temperature 0). If it says the criteria + are NOT met, we collect a few independent higher-temperature second opinions + and overrule the failure only when the majority disagrees — smoothing out the + single-call noise that makes these tests flaky. See the module-level + ``_JUDGE_CONFIRMATIONS`` notes. + Args: response: The LLM-generated text to evaluate. criteria: Plain-English description of what the response should contain/satisfy. @@ -73,43 +145,33 @@ async def evaluate( Returns: JudgeVerdict with meets_criteria bool and reasoning string. """ - judge = _get_judge() - - context_block = f"\n\nContext provided to the system:\n{context}" if context else "" - - result = await judge.call( - messages=[ - { - "role": "system", - "content": ( - "You are a test evaluation judge. Given a response and evaluation criteria, " - "determine whether the response meets the criteria. " - "Respond with JSON: {\"meets_criteria\": true/false, \"reasoning\": \"brief explanation\"}" - ), - }, - { - "role": "user", - "content": ( - f"## Response to evaluate\n{response}\n" - f"{context_block}\n" - f"## Criteria\n{criteria}\n\n" - "Does the response meet the criteria?" - ), - }, - ], - response_format=JudgeVerdict, - max_completion_tokens=256, - temperature=0.0, - scope="test_judge", + primary = await _judge_once(response, criteria, context, temperature=0.0) + if primary.meets_criteria or _JUDGE_CONFIRMATIONS <= 0: + return primary + + # Primary says "not met": get independent second opinions before trusting it. + confirmations = await asyncio.gather( + *( + _judge_once(response, criteria, context, temperature=_JUDGE_CONFIRM_TEMPERATURE) + for _ in range(_JUDGE_CONFIRMATIONS) + ), + return_exceptions=True, + ) + verdicts = [primary] + [c for c in confirmations if isinstance(c, JudgeVerdict)] + met = sum(1 for v in verdicts if v.meets_criteria) + not_met = len(verdicts) - met + + if met > not_met: + agreeing = next(v for v in verdicts if v.meets_criteria) + logger.info(f"Judge: primary 'not met' overruled by majority ({met}/{len(verdicts)} met). Criteria: {criteria}") + return JudgeVerdict( + meets_criteria=True, + reasoning=f"Majority of {len(verdicts)} judges met criteria (primary verdict overruled as noise). {agreeing.reasoning}", + ) + return JudgeVerdict( + meets_criteria=False, + reasoning=f"{not_met}/{len(verdicts)} judges agree criteria not met. {primary.reasoning}", ) - - if isinstance(result, JudgeVerdict): - return result - - # Fallback: parse raw dict/string - if isinstance(result, dict): - return JudgeVerdict(**result) - return JudgeVerdict(**json.loads(str(result))) async def assert_meets_criteria( @@ -124,7 +186,7 @@ async def assert_meets_criteria( """ verdict = await evaluate(response=response, criteria=criteria, context=context) if not verdict.meets_criteria: - fail_msg = msg or f"LLM judge: criteria not met" + fail_msg = msg or "LLM judge: criteria not met" raise AssertionError( f"{fail_msg}\n" f" Criteria: {criteria}\n" diff --git a/hindsight-api-slim/tests/test_agents_api.py b/hindsight-api-slim/tests/test_agents_api.py index c81c94140c..32a3cd7faa 100644 --- a/hindsight-api-slim/tests/test_agents_api.py +++ b/hindsight-api-slim/tests/test_agents_api.py @@ -1,6 +1,7 @@ """ Tests for agent management API (profile, disposition). """ + import pytest import uuid from hindsight_api import MemoryEngine, RequestContext @@ -17,9 +18,7 @@ class TestAgentProfile: """Tests for agent profile management.""" @pytest.mark.asyncio - async def test_get_bank_profile_no_auto_create_returns_none( - self, memory: MemoryEngine, request_context - ): + async def test_get_bank_profile_no_auto_create_returns_none(self, memory: MemoryEngine, request_context): """When create_if_missing=False is passed, a missing bank returns None rather than being silently auto-created. This is what read-only endpoints (HTTP GET, polling, etc.) must use to avoid creating banks @@ -27,28 +26,20 @@ async def test_get_bank_profile_no_auto_create_returns_none( bank_id = unique_agent_id("test_no_auto_create") # First call with create_if_missing=False on a non-existent bank - result = await memory.get_bank_profile( - bank_id, request_context=request_context, create_if_missing=False - ) + result = await memory.get_bank_profile(bank_id, request_context=request_context, create_if_missing=False) assert result is None, "Expected None for missing bank with create_if_missing=False" # Verify the bank was NOT created as a side effect - result_again = await memory.get_bank_profile( - bank_id, request_context=request_context, create_if_missing=False - ) + result_again = await memory.get_bank_profile(bank_id, request_context=request_context, create_if_missing=False) assert result_again is None, "Bank must not exist after read-only call" # And explicit auto-create still works - created = await memory.get_bank_profile( - bank_id, request_context=request_context, create_if_missing=True - ) + created = await memory.get_bank_profile(bank_id, request_context=request_context, create_if_missing=True) assert created is not None assert created["disposition"]["skepticism"] == 3 # Now read-only call sees it - seen = await memory.get_bank_profile( - bank_id, request_context=request_context, create_if_missing=False - ) + seen = await memory.get_bank_profile(bank_id, request_context=request_context, create_if_missing=False) assert seen is not None assert seen["disposition"]["skepticism"] == 3 @@ -122,11 +113,7 @@ async def test_put_agent_create(self, memory: MemoryEngine, request_context): bank_id = unique_agent_id("test_put_create") request = CreateBankRequest( - disposition=DispositionTraits( - skepticism=4, - literalism=5, - empathy=2 - ), + disposition=DispositionTraits(skepticism=4, literalism=5, empathy=2), ) profile = await memory.get_bank_profile(bank_id, request_context=request_context) @@ -155,7 +142,7 @@ async def test_think_uses_disposition(self, memory: MemoryEngine, request_contex disposition = { "skepticism": 5, # Very skeptical "literalism": 4, # High literalism - "empathy": 2, # Low empathy + "empathy": 2, # Low empathy } await memory.update_bank_disposition(bank_id, disposition, request_context=request_context) @@ -163,7 +150,7 @@ async def test_think_uses_disposition(self, memory: MemoryEngine, request_contex bank_id=bank_id, contents=[ {"content": "Traditional painting techniques have been used for centuries"}, - {"content": "Modern digital art is changing the art world"} + {"content": "Modern digital art is changing the art world"}, ], request_context=request_context, ) diff --git a/hindsight-api-slim/tests/test_async_batch_retain.py b/hindsight-api-slim/tests/test_async_batch_retain.py index d16ce5ff91..17264cabc9 100644 --- a/hindsight-api-slim/tests/test_async_batch_retain.py +++ b/hindsight-api-slim/tests/test_async_batch_retain.py @@ -103,6 +103,11 @@ async def test_small_async_batch_no_splitting(memory, request_context): assert status["result_metadata"]["num_sub_batches"] == 1 # Single sub-batch assert len(status["child_operations"]) == 1 assert status["child_operations"][0]["status"] == "completed" + child_meta = await _child_metadata(memory, bank_id, operation_id, request_context) + assert child_meta["unit_ids_count"] > 0 + assert child_meta["extraction_errors_count"] == 0 + assert status["result_metadata"]["unit_ids_count"] == child_meta["unit_ids_count"] + assert status["result_metadata"]["extraction_errors_count"] == 0 @pytest.mark.asyncio @@ -166,6 +171,19 @@ async def test_large_async_batch_auto_splits(memory, request_context): # Parent status should be aggregated as "completed" assert parent_status["status"] == "completed" + child_unit_counts = [] + for child in child_ops: + child_status = await memory.get_operation_status( + bank_id=bank_id, + operation_id=child["operation_id"], + request_context=request_context, + ) + child_meta = child_status["result_metadata"] + assert child_meta["unit_ids_count"] > 0 + assert child_meta["extraction_errors_count"] == 0 + child_unit_counts.append(child_meta["unit_ids_count"]) + assert parent_status["result_metadata"]["unit_ids_count"] == sum(child_unit_counts) + assert parent_status["result_metadata"]["extraction_errors_count"] == 0 @pytest.mark.asyncio @@ -461,6 +479,42 @@ async def _child_metadata(memory, bank_id: str, parent_operation_id: str, reques return child["result_metadata"] +@pytest.mark.asyncio +async def test_retain_outcome_metadata_records_zero_counts(memory, request_context, monkeypatch): + """Completed retain operations expose explicit zero outcome counters.""" + from hindsight_api.engine.response_models import TokenUsage + from hindsight_api.engine.retain import fact_extraction + + async def empty_extract_facts_from_contents( + *args: object, **kwargs: object + ) -> tuple[list[object], list[object], TokenUsage]: + return [], [], TokenUsage() + + monkeypatch.setattr(fact_extraction, "extract_facts_from_contents", empty_extract_facts_from_contents) + + bank_id = "test_retain_outcome_zero_counts" + result = await memory.submit_async_retain( + bank_id=bank_id, + contents=[{"content": "No extracted facts for this item."}], + request_context=request_context, + ) + await asyncio.sleep(0.2) + + parent = await memory.get_operation_status( + bank_id=bank_id, + operation_id=result["operation_id"], + request_context=request_context, + ) + child_meta = await _child_metadata(memory, bank_id, result["operation_id"], request_context) + + assert child_meta["unit_ids_count"] == 0 + assert child_meta["extraction_errors_count"] == 0 + assert "extraction_errors_sample" not in child_meta + assert parent["result_metadata"]["unit_ids_count"] == 0 + assert parent["result_metadata"]["extraction_errors_count"] == 0 + assert "extraction_errors_sample" not in parent["result_metadata"] + + @pytest.mark.asyncio async def test_retain_records_user_provided_document_ids(memory, request_context): """User-supplied document_ids land in child op result_metadata.document_ids.""" diff --git a/hindsight-api-slim/tests/test_backlog_metrics.py b/hindsight-api-slim/tests/test_backlog_metrics.py new file mode 100644 index 0000000000..6110d838dc --- /dev/null +++ b/hindsight-api-slim/tests/test_backlog_metrics.py @@ -0,0 +1,244 @@ +""" +Tests for the async-operation queue and consolidation backlog gauges +(``_setup_backlog_metrics`` / ``_refresh_backlog`` in metrics.py). + +These gauges expose, as scrapable time-series, the same counts the bank-stats +endpoint already returns per bank (``operations_by_status``, +``pending_consolidation``, ``failed_consolidation``): + +- ``hindsight_async_operations{operation_type,status}`` — worker queue depth + (pending=backlog, processing=in-flight, failed=stranded) +- ``hindsight_consolidation_backlog`` — source memories not yet consolidated +- ``hindsight_consolidation_failed`` — source memories permanently failed +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from hindsight_api.metrics import MetricsCollector, _AsyncOpKey, _BacklogKey + + +class _FakeTxn: + async def __aenter__(self): + return None + + async def __aexit__(self, *exc): + return False + + +class _FakeConn: + """asyncpg-like connection whose fetch() is dispatched by SQL substring.""" + + def __init__(self, fetch_fn): + self._fetch_fn = fetch_fn + self.executed = [] + + async def fetch(self, sql, *args): + return self._fetch_fn(sql, *args) + + async def execute(self, sql, *args): + self.executed.append(sql) + + def transaction(self): + return _FakeTxn() + + +class _FakeAcquire: + def __init__(self, conn): + self._conn = conn + + async def __aenter__(self): + return self._conn + + async def __aexit__(self, *exc): + return False + + +class _FakePool: + def __init__(self, fetch_fn): + self._conn = _FakeConn(fetch_fn) + + def acquire(self): + return _FakeAcquire(self._conn) + + +def _collector(include_bank_id=False): + mock_config = MagicMock() + mock_config.metrics_include_bank_id = include_bank_id + with ( + patch("hindsight_api.metrics.get_meter", return_value=MagicMock()), + patch("hindsight_api.config.get_config", return_value=mock_config), + ): + return MetricsCollector() + + +def _set_db_pool_with_backlog_enabled(collector, pool): + """Call set_db_pool with the backlog flag forced on (it's off by default).""" + mock_config = MagicMock() + mock_config.metrics_backlog_enabled = True + with patch("hindsight_api.config.get_config", return_value=mock_config): + collector.set_db_pool(pool) + + +def _rows_for(sql): + """Canned results, keyed off distinctive substrings of each query.""" + if "information_schema.tables" in sql: + return [{"table_schema": "public"}] + if "async_operations" in sql: + return [ + {"operation_type": "retain", "status": "pending", "count": 5}, + {"operation_type": "consolidation", "status": "pending", "count": 12}, + {"operation_type": "consolidation", "status": "processing", "count": 1}, + {"operation_type": "consolidation", "status": "failed", "count": 2}, + ] + if "memory_units" in sql and "consolidated_at IS NULL" in sql: + return [{"count": 42}] + if "memory_units" in sql and "consolidation_failed_at IS NOT NULL" in sql: + return [{"count": 3}] + return [] + + +@pytest.mark.asyncio +async def test_refresh_backlog_aggregates_queue_and_consolidation(): + collector = _collector(include_bank_id=False) + collector._db_pool = _FakePool(lambda sql, *a: _rows_for(sql)) + + await collector._refresh_backlog() + + # Worker queue depth keyed by (schema, operation_type, status, bank=None) + assert collector._async_ops_counts[("public", "retain", "pending", None)] == 5 + assert collector._async_ops_counts[("public", "consolidation", "pending", None)] == 12 + assert collector._async_ops_counts[("public", "consolidation", "processing", None)] == 1 + assert collector._async_ops_counts[("public", "consolidation", "failed", None)] == 2 + # Consolidation backlog (source memories), keyed by (schema, bank=None) + assert collector._consolidation_backlog[("public", None)] == 42 + assert collector._consolidation_failed[("public", None)] == 3 + + +@pytest.mark.asyncio +async def test_refresh_backlog_uses_index_matched_predicates_not_filter_scan(): + """Backlog/failed must be two separate COUNT(*) queries whose WHERE matches + a partial-index predicate exactly (no FILTER over a full-table scan), and + the queue query must exclude terminal statuses.""" + captured = [] + collector = _collector() + collector._db_pool = _FakePool(lambda sql, *a: (captured.append(sql), _rows_for(sql))[1]) + await collector._refresh_backlog() + + mem_queries = [s for s in captured if "memory_units" in s and "COUNT(*)" in s] + assert len(mem_queries) == 2 # split, not a single two-FILTER aggregate + assert all("FILTER" not in s for s in mem_queries) + assert any("consolidated_at IS NULL AND fact_type IN ('experience', 'world')" in s for s in mem_queries) + assert any("consolidation_failed_at IS NOT NULL AND fact_type IN ('experience', 'world')" in s for s in mem_queries) + + ops_sql = next(s for s in captured if "async_operations" in s and "GROUP BY" in s) + assert "status IN ('pending', 'processing', 'failed')" in ops_sql + assert "completed" not in ops_sql and "cancelled" not in ops_sql + + +@pytest.mark.asyncio +async def test_backlog_count_runs_with_seqscan_disabled(): + """`consolidated_at IS NULL` is true for a large fraction of the table, so + the planner misjudges selectivity and won't use the partial index without a + nudge — the backlog count must issue SET LOCAL enable_seqscan=off.""" + collector = _collector() + pool = _FakePool(lambda sql, *a: _rows_for(sql)) + collector._db_pool = pool + await collector._refresh_backlog() + + assert any("enable_seqscan" in s.lower() and "off" in s.lower() for s in pool._conn.executed) + # the result is still correct under the nudge + assert collector._consolidation_backlog[("public", None)] == 42 + + +@pytest.mark.asyncio +async def test_refresh_backlog_per_bank_labels_and_group_by_when_enabled(): + """With metrics_include_bank_id on, bank_id enters the cache key and the + SQL switches to GROUP BY bank_id.""" + captured = [] + + def fetch(sql, *a): + captured.append(sql) + if "information_schema.tables" in sql: + return [{"table_schema": "public"}] + if "async_operations" in sql: + return [{"operation_type": "retain", "status": "pending", "bank_id": "bankA", "count": 4}] + if "memory_units" in sql and "consolidated_at IS NULL" in sql: + return [{"bank_id": "bankA", "count": 11}] + if "memory_units" in sql and "consolidation_failed_at IS NOT NULL" in sql: + return [{"bank_id": "bankA", "count": 2}] + return [] + + collector = _collector(include_bank_id=True) + collector._db_pool = _FakePool(fetch) + await collector._refresh_backlog() + + assert collector._async_ops_counts[("public", "retain", "pending", "bankA")] == 4 + assert collector._consolidation_backlog[("public", "bankA")] == 11 + assert collector._consolidation_failed[("public", "bankA")] == 2 + # bank_id must be grouped in every per-bank count query + assert all("GROUP BY bank_id" in s for s in captured if "memory_units" in s and "COUNT(*)" in s) + + +def test_gauges_register_and_emit_cached_values_without_bank_id(): + collector = _collector(include_bank_id=False) + # Sync call: no running loop, so gauges register but no background task spawns. + _set_db_pool_with_backlog_enabled(collector, MagicMock()) + + gauges = { + c.kwargs["name"]: c.kwargs["callbacks"][0] + for c in collector.meter.create_observable_gauge.call_args_list + if "callbacks" in c.kwargs + } + assert "hindsight.async_operations" in gauges + assert "hindsight.consolidation.backlog" in gauges + assert "hindsight.consolidation.failed" in gauges + + collector._async_ops_counts = { + _AsyncOpKey("public", "retain", "pending", None): 7, + _AsyncOpKey("public", "consolidation", "processing", None): 1, + } + collector._consolidation_backlog = {_BacklogKey("public", None): 9} + + obs = list(gauges["hindsight.async_operations"](None)) + by_label = {(o.attributes["operation_type"], o.attributes["status"]): o.value for o in obs} + assert by_label[("retain", "pending")] == 7 + assert by_label[("consolidation", "processing")] == 1 + assert all("bank_id" not in o.attributes for o in obs) # cardinality guard + + backlog_obs = list(gauges["hindsight.consolidation.backlog"](None)) + assert backlog_obs[0].value == 9 + assert backlog_obs[0].attributes["tenant"] == "public" + + +def test_gauge_emits_bank_id_attribute_when_present(): + collector = _collector(include_bank_id=True) + _set_db_pool_with_backlog_enabled(collector, MagicMock()) + gauges = { + c.kwargs["name"]: c.kwargs["callbacks"][0] + for c in collector.meter.create_observable_gauge.call_args_list + if "callbacks" in c.kwargs + } + collector._consolidation_backlog = {_BacklogKey("public", "bankA"): 4} + obs = list(gauges["hindsight.consolidation.backlog"](None)) + assert obs[0].value == 4 + assert obs[0].attributes["bank_id"] == "bankA" + + +def test_backlog_gauges_not_registered_when_flag_disabled(): + """Backlog metrics are off by default: set_db_pool must not register the + gauges unless metrics_backlog_enabled is set.""" + collector = _collector() + mock_config = MagicMock() + mock_config.metrics_backlog_enabled = False + with patch("hindsight_api.config.get_config", return_value=mock_config): + collector.set_db_pool(MagicMock()) + + names = [ + c.kwargs.get("name") for c in collector.meter.create_observable_gauge.call_args_list if "callbacks" in c.kwargs + ] + assert "hindsight.async_operations" not in names + assert "hindsight.consolidation.backlog" not in names + assert "hindsight.consolidation.failed" not in names + assert collector._backlog_task is None diff --git a/hindsight-api-slim/tests/test_bank_attribution.py b/hindsight-api-slim/tests/test_bank_attribution.py new file mode 100644 index 0000000000..4a955f64ac --- /dev/null +++ b/hindsight-api-slim/tests/test_bank_attribution.py @@ -0,0 +1,331 @@ +""" +Tests for per-bank provider cost attribution. + +Covers the opt-in `HINDSIGHT_API_LLM_SEND_BANK_AS_USER` plumbing that tags +outbound OpenAI-compatible LLM and embedding calls with `user=`, the +`_current_bank_id` engine ContextVar that carries the bank across the async call +chain, and its propagation into the embedding executor thread. + +All deterministic — no network, stdlib/pytest only. +""" + +import os +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import pytest +from pydantic import BaseModel + +from hindsight_api.engine.bank_attribution import apply_bank_attribution +from hindsight_api.engine.embeddings import OpenAIEmbeddings +from hindsight_api.engine.memory_engine import ( + _bind_bank_id, + _current_bank_id, + get_current_bank_id, +) +from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM +from hindsight_api.engine.retain.embedding_utils import generate_embeddings_batch + + +@pytest.fixture(autouse=True) +def restore_send_bank_env(): + """Save/restore the attribution env var and clear the cached config.""" + from hindsight_api.config import clear_config_cache + + original = os.environ.get("HINDSIGHT_API_LLM_SEND_BANK_AS_USER") + clear_config_cache() + yield + if original is None: + os.environ.pop("HINDSIGHT_API_LLM_SEND_BANK_AS_USER", None) + else: + os.environ["HINDSIGHT_API_LLM_SEND_BANK_AS_USER"] = original + clear_config_cache() + + +def _set_flag(enabled: bool) -> None: + from hindsight_api.config import clear_config_cache + + os.environ["HINDSIGHT_API_LLM_SEND_BANK_AS_USER"] = "true" if enabled else "false" + clear_config_cache() + + +# ── ContextVar lifecycle ────────────────────────────────────────────────────── + + +class TestBankContextVar: + def test_default_is_none(self): + assert get_current_bank_id() is None + + def test_set_and_reset(self): + token = _current_bank_id.set("user-42") + try: + assert get_current_bank_id() == "user-42" + finally: + _current_bank_id.reset(token) + assert get_current_bank_id() is None + + def test_reset_runs_even_on_exception(self): + """A finally-based reset must unwind the binding even when the body raises.""" + token = _current_bank_id.set("user-boom") + try: + with pytest.raises(ValueError): + try: + assert get_current_bank_id() == "user-boom" + raise ValueError("boom") + finally: + _current_bank_id.reset(token) + finally: + pass + assert get_current_bank_id() is None + + +class TestBindBankIdDecorator: + """The engine binds the bank via @_bind_bank_id on recall/retain/batch/task methods.""" + + async def test_binds_named_arg_positional_and_keyword(self): + @_bind_bank_id() + async def op(bank_id: str, query: str) -> str | None: + return get_current_bank_id() + + assert await op("user-pos", "q") == "user-pos" + assert await op(bank_id="user-kw", query="q") == "user-kw" + assert get_current_bank_id() is None + + async def test_extracts_dict_key(self): + @_bind_bank_id("task_dict", key="bank_id") + async def op(task_dict: dict) -> str | None: + return get_current_bank_id() + + assert await op({"bank_id": "user-task", "type": "consolidation"}) == "user-task" + assert await op({"type": "consolidation"}) is None + assert get_current_bank_id() is None + + async def test_resets_on_exception(self): + @_bind_bank_id() + async def op(bank_id: str) -> None: + assert get_current_bank_id() == "user-boom" + raise ValueError("boom") + + with pytest.raises(ValueError): + await op("user-boom") + assert get_current_bank_id() is None + + async def test_non_string_value_binds_none(self): + @_bind_bank_id() + async def op(bank_id: object) -> str | None: + return get_current_bank_id() + + assert await op(12345) is None + + +# ── LLM provider: user injection ────────────────────────────────────────────── + + +class _SimpleJson(BaseModel): + ok: bool + + +def _llm() -> OpenAICompatibleLLM: + return OpenAICompatibleLLM( + provider="openai", + api_key="test-key", + base_url="https://example.test/v1", + model="gpt-4o-mini", + ) + + +def _chat_response(content: str = '{"ok": true}'): + choice = SimpleNamespace( + finish_reason="stop", + message=SimpleNamespace(content=content, tool_calls=None, refusal=None), + ) + return SimpleNamespace(choices=[choice], usage=None, error=None) + + +async def _call(llm: OpenAICompatibleLLM, create: AsyncMock): + llm._client.chat.completions.create = create + with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"): + return await llm.call( + messages=[{"role": "user", "content": "ping"}], + max_retries=0, + ) + + +async def test_user_injected_when_flag_on_and_bank_set(): + _set_flag(True) + llm = _llm() + create = AsyncMock(return_value=_chat_response()) + token = _current_bank_id.set("user-7") + try: + await _call(llm, create) + finally: + _current_bank_id.reset(token) + assert create.call_args.kwargs["user"] == "user-7" + + +async def test_user_not_injected_when_flag_off(): + _set_flag(False) + llm = _llm() + create = AsyncMock(return_value=_chat_response()) + token = _current_bank_id.set("user-7") + try: + await _call(llm, create) + finally: + _current_bank_id.reset(token) + assert "user" not in create.call_args.kwargs + + +async def test_user_not_injected_when_bank_unset(): + _set_flag(True) + llm = _llm() + create = AsyncMock(return_value=_chat_response()) + # No bank bound in context. + assert get_current_bank_id() is None + await _call(llm, create) + assert "user" not in create.call_args.kwargs + + +async def test_caller_set_user_is_not_overridden(): + """The helper never clobbers a `user` the caller already placed in call_params.""" + _set_flag(True) + # Simulate a caller-provided user via the centralized helper directly. + params = {"user": "explicit-user"} + token = _current_bank_id.set("user-7") + try: + apply_bank_attribution(params) + finally: + _current_bank_id.reset(token) + assert params["user"] == "explicit-user" + + +async def test_user_injected_in_tool_calling_path(): + """call_with_tools() builds its own call_params; attribution must reach it too.""" + _set_flag(True) + llm = _llm() + tool_response = SimpleNamespace( + choices=[ + SimpleNamespace( + finish_reason="stop", + message=SimpleNamespace(content="done", tool_calls=None, refusal=None, reasoning_content=None), + ) + ], + usage=None, + error=None, + ) + create = AsyncMock(return_value=tool_response) + llm._client.chat.completions.create = create + token = _current_bank_id.set("user-tools") + try: + with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector"): + await llm.call_with_tools( + messages=[{"role": "user", "content": "ping"}], + tools=[{"type": "function", "function": {"name": "noop", "parameters": {}}}], + max_retries=0, + ) + finally: + _current_bank_id.reset(token) + assert create.call_args.kwargs["user"] == "user-tools" + + +# ── Embeddings: user injection ───────────────────────────────────────────────── + + +def _openai_embeddings() -> OpenAIEmbeddings: + emb = OpenAIEmbeddings(api_key="sk-test", model="text-embedding-3-small", batch_size=100) + emb._dimension = 1536 + return emb + + +def _fake_embed_client(captured: list[dict]): + def fake_create(**kwargs): + captured.append(kwargs) + n = len(kwargs["input"]) + return SimpleNamespace(data=[SimpleNamespace(index=i, embedding=[0.0] * 1536) for i in range(n)]) + + return SimpleNamespace(embeddings=SimpleNamespace(create=fake_create)) + + +def test_embeddings_user_injected_when_flag_on_and_bank_set(): + _set_flag(True) + emb = _openai_embeddings() + captured: list[dict] = [] + emb._client = _fake_embed_client(captured) + token = _current_bank_id.set("user-emb") + try: + emb.encode(["hello"]) + finally: + _current_bank_id.reset(token) + assert captured[0]["user"] == "user-emb" + + +def test_embeddings_user_not_injected_when_flag_off(): + _set_flag(False) + emb = _openai_embeddings() + captured: list[dict] = [] + emb._client = _fake_embed_client(captured) + token = _current_bank_id.set("user-emb") + try: + emb.encode(["hello"]) + finally: + _current_bank_id.reset(token) + assert "user" not in captured[0] + + +def test_embeddings_user_not_injected_when_bank_unset(): + _set_flag(True) + emb = _openai_embeddings() + captured: list[dict] = [] + emb._client = _fake_embed_client(captured) + assert get_current_bank_id() is None + emb.encode(["hello"]) + assert "user" not in captured[0] + + +# ── Executor context propagation ────────────────────────────────────────────── + + +class _BankCapturingBackend: + """Embeddings backend whose encode records the bank id visible at call time. + + The real `generate_embeddings_batch` offloads encode to a thread via + run_in_executor; this verifies the bank ContextVar survives that thread hop. + """ + + dimension = 1 + + def __init__(self) -> None: + self.seen_bank_id: str | None = "UNSET" + + def encode_documents(self, texts: list[str]) -> list[list[float]]: + self.seen_bank_id = get_current_bank_id() + return [[0.0] for _ in texts] + + def encode_query(self, texts: list[str]) -> list[list[float]]: + return self.encode_documents(texts) + + +async def test_executor_propagates_bank_contextvar_into_worker_thread(): + backend = _BankCapturingBackend() + token = _current_bank_id.set("user-thread") + try: + vectors = await generate_embeddings_batch(backend, ["a", "b"], input_type="document") + finally: + _current_bank_id.reset(token) + assert backend.seen_bank_id == "user-thread" + assert len(vectors) == 2 + + +async def test_executor_length_validation_preserved(): + """The 1:1 alignment guard must still fire after the context-aware offload.""" + + class _ShortBackend: + dimension = 1 + + def encode_documents(self, texts: list[str]) -> list[list[float]]: + return [[0.0]] # one vector for two inputs + + def encode_query(self, texts: list[str]) -> list[list[float]]: + return self.encode_documents(texts) + + with pytest.raises(Exception, match="expected exact 1:1 alignment"): + await generate_embeddings_batch(_ShortBackend(), ["a", "b"], input_type="document") diff --git a/hindsight-api-slim/tests/test_bank_attribution_config.py b/hindsight-api-slim/tests/test_bank_attribution_config.py new file mode 100644 index 0000000000..f2f78abac4 --- /dev/null +++ b/hindsight-api-slim/tests/test_bank_attribution_config.py @@ -0,0 +1,132 @@ +""" +Config wiring for per-bank attribution and the configurable OpenRouter rerank URL. + +- HINDSIGHT_API_LLM_SEND_BANK_AS_USER (default off, opt-in bool) +- HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL (default = previously hardcoded URL) + +Deterministic, no network. +""" + +import os +from dataclasses import fields +from unittest.mock import patch + +from hindsight_api.config import DEFAULT_RERANKER_OPENROUTER_BASE_URL, HindsightConfig +from hindsight_api.engine.cross_encoder import create_cross_encoder_from_env + + +def _restore_env(saved: dict[str, str | None]) -> None: + from hindsight_api.config import clear_config_cache + + for key, value in saved.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value + clear_config_cache() + + +def _make_full_config(**overrides): + """Build a complete HindsightConfig from type-based defaults plus overrides. + + Mirrors the helper in test_reranker_timeouts.py so we can exercise the + factory without touching real env/config. + """ + defaults: dict = {} + 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 + else: + defaults[f.name] = None + defaults.update(overrides) + return HindsightConfig(**defaults) + + +class TestSendBankAsUserConfig: + def test_default_is_false(self): + from hindsight_api.config import clear_config_cache + + saved = {"HINDSIGHT_API_LLM_SEND_BANK_AS_USER": os.environ.get("HINDSIGHT_API_LLM_SEND_BANK_AS_USER")} + os.environ.pop("HINDSIGHT_API_LLM_SEND_BANK_AS_USER", None) + clear_config_cache() + try: + assert HindsightConfig.from_env().llm_send_bank_as_user is False + finally: + _restore_env(saved) + + def test_true_enables(self): + from hindsight_api.config import clear_config_cache + + saved = {"HINDSIGHT_API_LLM_SEND_BANK_AS_USER": os.environ.get("HINDSIGHT_API_LLM_SEND_BANK_AS_USER")} + os.environ["HINDSIGHT_API_LLM_SEND_BANK_AS_USER"] = "true" + clear_config_cache() + try: + assert HindsightConfig.from_env().llm_send_bank_as_user is True + finally: + _restore_env(saved) + + def test_one_enables(self): + from hindsight_api.config import clear_config_cache + + saved = {"HINDSIGHT_API_LLM_SEND_BANK_AS_USER": os.environ.get("HINDSIGHT_API_LLM_SEND_BANK_AS_USER")} + os.environ["HINDSIGHT_API_LLM_SEND_BANK_AS_USER"] = "1" + clear_config_cache() + try: + assert HindsightConfig.from_env().llm_send_bank_as_user is True + finally: + _restore_env(saved) + + +class TestRerankerOpenRouterBaseUrlConfig: + def test_default_matches_previously_hardcoded_url(self): + from hindsight_api.config import clear_config_cache + + saved = { + "HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL": os.environ.get("HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL") + } + os.environ.pop("HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL", None) + clear_config_cache() + try: + config = HindsightConfig.from_env() + assert config.reranker_openrouter_base_url == DEFAULT_RERANKER_OPENROUTER_BASE_URL + assert config.reranker_openrouter_base_url == "https://openrouter.ai/api/v1/rerank" + finally: + _restore_env(saved) + + def test_env_override_is_read(self): + from hindsight_api.config import clear_config_cache + + saved = { + "HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL": os.environ.get("HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL") + } + os.environ["HINDSIGHT_API_RERANKER_OPENROUTER_BASE_URL"] = "https://gateway.internal/v1/rerank" + clear_config_cache() + try: + assert HindsightConfig.from_env().reranker_openrouter_base_url == "https://gateway.internal/v1/rerank" + finally: + _restore_env(saved) + + def test_factory_threads_configured_base_url_into_cross_encoder(self): + """create_cross_encoder_from_env() honors the configured OpenRouter rerank URL.""" + config = _make_full_config( + reranker_provider="openrouter", + reranker_openrouter_api_key="k", + reranker_openrouter_model="cohere/rerank-v3.5", + reranker_openrouter_base_url="https://gateway.internal/v1/rerank", + reranker_openrouter_timeout=60.0, + ) + with patch("hindsight_api.config.get_config", return_value=config): + encoder = create_cross_encoder_from_env() + assert encoder.base_url == "https://gateway.internal/v1/rerank" diff --git a/hindsight-api-slim/tests/test_bank_health.py b/hindsight-api-slim/tests/test_bank_health.py new file mode 100644 index 0000000000..2307033a4a --- /dev/null +++ b/hindsight-api-slim/tests/test_bank_health.py @@ -0,0 +1,154 @@ +"""Tests for the per-bank LLM connectivity probe (POST /health/llm). + +Deterministic: the probe runs against the MockLLM provider (whose verify_connection +succeeds offline). No judge. +""" + +import asyncio + +import httpx +import pytest +import pytest_asyncio + +import hindsight_api.engine.memory_engine as memory_engine +from hindsight_api.api import create_app +from hindsight_api.config import clear_config_cache + + +@pytest_asyncio.fixture +async def api_client(memory): + app = create_app(memory, initialize_memory=False) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +@pytest.fixture(autouse=True) +def _enable_bank_llm_health(monkeypatch): + """The probe is off by default, so enable it for these tests. The 'disabled' test + overrides this within its own body.""" + monkeypatch.setenv("HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH", "true") + clear_config_cache() + yield + clear_config_cache() + + +# --------------------------------------------------------------------------- # +# POST /health/llm (connectivity probe) +# --------------------------------------------------------------------------- # + + +def _statuses(body: dict) -> dict[str, str]: + """Map operation -> status from a probe response.""" + return {op["operation"]: op["status"] for op in body["operations"]} + + +@pytest.mark.asyncio +async def test_bank_llm_connected_with_mock(api_client): + response = await api_client.post("/v1/default/banks/llm-ok/health/llm") + assert response.status_code == 200 + body = response.json() + # All three operations share the mock config and should report connected. + statuses = _statuses(body) + assert statuses == {"retain": "connected", "consolidation": "connected", "reflect": "connected"} + assert all(op["ok"] for op in body["operations"]) + assert all(op["latency_ms"] is not None for op in body["operations"]) + # Status only — no LLM identity must leak. + assert all(set(op) == {"operation", "ok", "status", "latency_ms"} for op in body["operations"]) + assert "mock" not in response.text + + +@pytest.mark.asyncio +async def test_bank_llm_probes_shared_config_once(api_client, memory, monkeypatch): + """retain/consolidation/reflect share one config in the mock fixture, so the probe + must run exactly once and fan the result out to all three.""" + calls = 0 + + async def counting_verify(): + nonlocal calls + calls += 1 + + for cfg in (memory._retain_llm_config, memory._consolidation_llm_config, memory._reflect_llm_config): + monkeypatch.setattr(cfg, "verify_connection", counting_verify) + + body = (await api_client.post("/v1/default/banks/llm-dedup/health/llm")).json() + assert len(body["operations"]) == 3 + assert calls == 1 + + +@pytest.mark.asyncio +async def test_bank_llm_not_configured(api_client, memory, monkeypatch): + for cfg in (memory._retain_llm_config, memory._consolidation_llm_config, memory._reflect_llm_config): + monkeypatch.setattr(cfg, "provider", "none") + body = (await api_client.post("/v1/default/banks/llm-none/health/llm")).json() + assert all(op["status"] == "not_configured" and op["ok"] is False for op in body["operations"]) + # latency_ms is null when not configured; responses omit null fields, so use .get(). + assert all(op.get("latency_ms") is None for op in body["operations"]) + + +@pytest.mark.asyncio +async def test_bank_llm_unreachable_does_not_leak_error(api_client, memory, monkeypatch): + async def boom(): + raise RuntimeError("Connection refused to model gpt-4 at https://secret.internal/v1") + + for cfg in (memory._retain_llm_config, memory._consolidation_llm_config, memory._reflect_llm_config): + monkeypatch.setattr(cfg, "verify_connection", boom) + response = await api_client.post("/v1/default/banks/llm-bad/health/llm") + body = response.json() + assert all(op["status"] == "unreachable" and op["ok"] is False for op in body["operations"]) + # The raw provider error (which embeds endpoint/model) must NOT be returned. + assert "secret.internal" not in response.text + + +@pytest.mark.asyncio +async def test_bank_llm_auth_failed(api_client, memory, monkeypatch): + """A wrong API key (the most common failure) gets its own status, without leaking + the raw provider error.""" + + async def bad_key(): + raise RuntimeError("Error code: 401 - {'error': {'message': 'Incorrect API key provided: sk-secret'}}") + + for cfg in (memory._retain_llm_config, memory._consolidation_llm_config, memory._reflect_llm_config): + monkeypatch.setattr(cfg, "verify_connection", bad_key) + response = await api_client.post("/v1/default/banks/llm-badkey/health/llm") + body = response.json() + assert all(op["status"] == "auth_failed" and op["ok"] is False for op in body["operations"]) + assert "sk-secret" not in response.text + + +def test_is_auth_error_classifier(): + assert memory_engine._is_auth_error(RuntimeError("Error code: 401 Unauthorized")) is True + assert memory_engine._is_auth_error(RuntimeError("Incorrect API key provided")) is True + assert memory_engine._is_auth_error(RuntimeError("permission denied")) is True + assert memory_engine._is_auth_error(RuntimeError("Connection refused")) is False + assert memory_engine._is_auth_error(TimeoutError("slow")) is False + + class _StatusErr(Exception): + status_code = 401 + + assert memory_engine._is_auth_error(_StatusErr("nope")) is True + + +@pytest.mark.asyncio +async def test_bank_llm_timeout(api_client, memory, monkeypatch): + monkeypatch.setattr(memory_engine, "_LLM_PROBE_TIMEOUT_SECONDS", 0.05) + + async def slow(): + await asyncio.sleep(0.5) + + for cfg in (memory._retain_llm_config, memory._consolidation_llm_config, memory._reflect_llm_config): + monkeypatch.setattr(cfg, "verify_connection", slow) + body = (await api_client.post("/v1/default/banks/llm-slow/health/llm")).json() + assert all(op["status"] == "timeout" and op["ok"] is False for op in body["operations"]) + + +@pytest.mark.asyncio +async def test_bank_llm_health_disabled_returns_404(api_client, monkeypatch): + monkeypatch.setenv("HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH", "false") + clear_config_cache() + try: + response = await api_client.post("/v1/default/banks/llm-off/health/llm") + assert response.status_code == 404 + finally: + monkeypatch.delenv("HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH", raising=False) + clear_config_cache() diff --git a/hindsight-api-slim/tests/test_bank_template_configurable_fields.py b/hindsight-api-slim/tests/test_bank_template_configurable_fields.py index aee525938f..675e04f232 100644 --- a/hindsight-api-slim/tests/test_bank_template_configurable_fields.py +++ b/hindsight-api-slim/tests/test_bank_template_configurable_fields.py @@ -30,6 +30,7 @@ # Each tuple is (field_name, applied_value). Values chosen to differ # visibly from defaults so round-trip bugs surface. NEW_FIELDS: list[tuple[str, object]] = [ + ("retain_structured_chunk_size", 6000), ("retain_default_strategy", "strategy-a"), ("retain_strategies", {"strategy-a": {"mode": "concise", "max_tokens": 512}}), ("retain_chunk_batch_size", 7), diff --git a/hindsight-api-slim/tests/test_bank_templates.py b/hindsight-api-slim/tests/test_bank_templates.py index 7bf31056ca..7037c62a83 100644 --- a/hindsight-api-slim/tests/test_bank_templates.py +++ b/hindsight-api-slim/tests/test_bank_templates.py @@ -495,9 +495,10 @@ async def test_export_empty_bank(self, api_client, bank_id): assert resp.status_code == 200 data = resp.json() assert data["version"] == "1" - assert data["bank"] is None - assert data["mental_models"] is None - assert data["directives"] is None + # An empty bank has no overrides; these null fields are omitted from the response. + assert data.get("bank") is None + assert data.get("mental_models") is None + assert data.get("directives") is None @pytest.mark.asyncio async def test_export_after_import(self, api_client, bank_id): @@ -643,9 +644,7 @@ def _patched_default_template(self, monkeypatch, 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 - ): + 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={}) @@ -730,9 +729,7 @@ async def test_default_template_unset_is_noop(self, api_client, bank_id): assert config_resp.json()["overrides"] == {} @pytest.mark.asyncio - async def test_default_template_malformed_is_swallowed( - self, api_client, bank_id, monkeypatch - ): + 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 diff --git a/hindsight-api-slim/tests/test_base_path.py b/hindsight-api-slim/tests/test_base_path.py index 132071caa3..04200816e7 100644 --- a/hindsight-api-slim/tests/test_base_path.py +++ b/hindsight-api-slim/tests/test_base_path.py @@ -4,6 +4,7 @@ Tests that the API works correctly when deployed with a base path (e.g., /hindsight) for reverse proxy deployments. """ + import os import pytest import pytest_asyncio @@ -27,10 +28,7 @@ async def api_client_with_base_path(memory): # Use base_url with base path transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient( - transport=transport, - base_url=f"http://test{base_path}" - ) as client: + async with httpx.AsyncClient(transport=transport, base_url=f"http://test{base_path}") as client: yield client # Cleanup: unset base path @@ -122,10 +120,10 @@ async def test_base_path_full_workflow(api_client_with_base_path): "items": [ { "content": "The API supports base path deployment for reverse proxy use cases.", - "context": "testing base path feature" + "context": "testing base path feature", } ] - } + }, ) assert response.status_code == 200 result = response.json() @@ -133,10 +131,7 @@ async def test_base_path_full_workflow(api_client_with_base_path): # 3. Recall the memory response = await api_client_with_base_path.post( - f"/v1/default/banks/{bank_id}/memories/recall", - json={ - "query": "base path support" - } + f"/v1/default/banks/{bank_id}/memories/recall", json={"query": "base path support"} ) assert response.status_code == 200 recall_result = response.json() diff --git a/hindsight-api-slim/tests/test_batch_api.py b/hindsight-api-slim/tests/test_batch_api.py index d6f6322660..7770b5bba6 100644 --- a/hindsight-api-slim/tests/test_batch_api.py +++ b/hindsight-api-slim/tests/test_batch_api.py @@ -7,21 +7,24 @@ - Hard error when provider doesn't support the batch API (no silent fallback) - Worker recovery on restart """ -import pytest + import asyncio -import logging import json +import logging import uuid from datetime import datetime, timezone from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + from hindsight_api import RequestContext +from hindsight_api.config import HindsightConfig +from hindsight_api.engine.llm_wrapper import create_llm_provider from hindsight_api.engine.retain.fact_extraction import ( - extract_facts_from_contents_batch_api, - extract_facts_from_contents, RetainContent, + extract_facts_from_contents, + extract_facts_from_contents_batch_api, ) -from hindsight_api.config import HindsightConfig -from hindsight_api.engine.llm_wrapper import create_llm_provider from hindsight_api.worker.poller import WorkerPoller logger = logging.getLogger(__name__) @@ -103,19 +106,21 @@ async def test_batch_api_normal_flow(mock_llm_config, test_contents, hindsight_c "choices": [ { "message": { - "content": json.dumps({ - "facts": [ - { - "what": "Alice is a senior software engineer at TechCorp", - "when": "present", - "where": "TechCorp", - "who": "Alice", - "why": "Professional background information", - "fact_type": "world", - "fact_kind": "conversation", - } - ] - }) + "content": json.dumps( + { + "facts": [ + { + "what": "Alice is a senior software engineer at TechCorp", + "when": "present", + "where": "TechCorp", + "who": "Alice", + "why": "Professional background information", + "fact_type": "world", + "fact_kind": "conversation", + } + ] + } + ) } } ], @@ -130,19 +135,21 @@ async def test_batch_api_normal_flow(mock_llm_config, test_contents, hindsight_c "choices": [ { "message": { - "content": json.dumps({ - "facts": [ - { - "what": "Bob joined the team last month as a junior developer", - "when": "last month", - "where": "team", - "who": "Bob", - "why": "New team member information", - "fact_type": "world", - "fact_kind": "conversation", - } - ] - }) + "content": json.dumps( + { + "facts": [ + { + "what": "Bob joined the team last month as a junior developer", + "when": "last month", + "where": "team", + "who": "Bob", + "why": "New team member information", + "fact_type": "world", + "fact_kind": "conversation", + } + ] + } + ) } } ], @@ -211,6 +218,7 @@ async def test_batch_api_crash_recovery(mock_llm_config, test_contents, hindsigh schema = request_context.tenant_id from hindsight_api.engine.task_backend import fq_table + table = fq_table("async_operations", schema) # Create operation with batch_id already stored @@ -221,11 +229,13 @@ async def test_batch_api_crash_recovery(mock_llm_config, test_contents, hindsigh """, operation_id, bank_id, - json.dumps({ - "batch_id": batch_id, - "batch_provider": "openai", - "chunk_count": 2, - }), + json.dumps( + { + "batch_id": batch_id, + "batch_provider": "openai", + "chunk_count": 2, + } + ), ) # Mock batch API responses for resume scenario @@ -248,19 +258,21 @@ async def test_batch_api_crash_recovery(mock_llm_config, test_contents, hindsigh "choices": [ { "message": { - "content": json.dumps({ - "facts": [ - { - "what": "Alice is a senior software engineer", - "when": "present", - "where": "TechCorp", - "who": "Alice", - "why": "Background", - "fact_type": "world", - "fact_kind": "conversation", - } - ] - }) + "content": json.dumps( + { + "facts": [ + { + "what": "Alice is a senior software engineer", + "when": "present", + "where": "TechCorp", + "who": "Alice", + "why": "Background", + "fact_type": "world", + "fact_kind": "conversation", + } + ] + } + ) } } ], @@ -275,19 +287,21 @@ async def test_batch_api_crash_recovery(mock_llm_config, test_contents, hindsigh "choices": [ { "message": { - "content": json.dumps({ - "facts": [ - { - "what": "Bob is a junior developer", - "when": "last month", - "where": "team", - "who": "Bob", - "why": "New member", - "fact_type": "world", - "fact_kind": "conversation", - } - ] - }) + "content": json.dumps( + { + "facts": [ + { + "what": "Bob is a junior developer", + "when": "last month", + "where": "team", + "who": "Bob", + "why": "New member", + "fact_type": "world", + "fact_kind": "conversation", + } + ] + } + ) } } ], @@ -331,6 +345,105 @@ async def test_batch_api_crash_recovery(mock_llm_config, test_contents, hindsigh pass +@pytest.mark.asyncio +async def test_batch_api_records_non_fatal_extraction_errors( + mock_llm_config, test_contents, hindsight_config, memory, request_context +): + """Batch API skipped chunks are surfaced in operation result_metadata.""" + bank_id = f"test_batch_errors_{datetime.now(timezone.utc).timestamp()}" + operation_id = str(uuid.uuid4()) + + try: + await memory.get_bank_profile(bank_id, request_context=request_context) + pool = memory._pool + schema = request_context.tenant_id + + from hindsight_api.engine.task_backend import fq_table + + table = fq_table("async_operations", schema) + await pool.execute( + f""" + INSERT INTO {table} (operation_id, operation_type, bank_id, status, result_metadata) + VALUES ($1, 'retain', $2, 'processing', $3::jsonb) + """, + operation_id, + bank_id, + json.dumps({}), + ) + + batch_id = "batch_partial_errors" + mock_llm_config._provider_impl.supports_batch_api = AsyncMock(return_value=True) + mock_llm_config._provider_impl.submit_batch = AsyncMock(return_value={"batch_id": batch_id}) + mock_llm_config._provider_impl.get_batch_status = AsyncMock( + return_value={ + "status": "completed", + "request_counts": {"total": 2, "completed": 2, "failed": 0}, + } + ) + mock_llm_config._provider_impl.retrieve_batch_results = AsyncMock( + return_value=[ + { + "custom_id": "chunk_0", + "response": { + "body": { + "choices": [ + { + "message": { + "content": json.dumps( + { + "facts": [ + { + "what": "Alice is a senior software engineer", + "when": "present", + "where": "TechCorp", + "who": "Alice", + "why": "Background", + "fact_type": "world", + "fact_kind": "conversation", + } + ] + } + ) + } + } + ], + "usage": {"prompt_tokens": 100, "completion_tokens": 50, "total_tokens": 150}, + } + }, + } + ] + ) + + facts, chunks, usage = await extract_facts_from_contents_batch_api( + contents=test_contents, + llm_config=mock_llm_config, + agent_name="test_agent", + config=hindsight_config, + pool=pool, + operation_id=operation_id, + schema=schema, + ) + + assert len(facts) == 1 + assert len(chunks) == 2 + assert chunks[1].fact_count == 0 + assert usage.total_tokens == 150 + + row = await pool.fetchrow(f"SELECT result_metadata FROM {table} WHERE operation_id = $1", operation_id) + metadata = ( + json.loads(row["result_metadata"]) if isinstance(row["result_metadata"], str) else row["result_metadata"] + ) + assert metadata["batch_id"] == batch_id + assert metadata["extraction_errors_count"] == 1 + assert metadata["extraction_errors_sample"] == ["chunk_1: missing batch result"] + + finally: + try: + await memory.delete_bank(bank_id, request_context=request_context) + except Exception: + pass + + @pytest.mark.asyncio async def test_batch_api_raises_for_unsupported_provider(mock_llm_config, test_contents, hindsight_config): """Batch extraction must surface a hard error (not silently fall back) when @@ -372,6 +485,7 @@ async def test_worker_batch_recovery(memory, request_context): schema = request_context.tenant_id from hindsight_api.engine.task_backend import fq_table + table = fq_table("async_operations", schema) # Create orphaned batch operation (simulates worker crash during polling) @@ -389,16 +503,19 @@ async def test_worker_batch_recovery(memory, request_context): """, operation_id, bank_id, - json.dumps({ - "batch_id": batch_id, - "batch_provider": "openai", - "chunk_count": 1, - }), + json.dumps( + { + "batch_id": batch_id, + "batch_provider": "openai", + "chunk_count": 1, + } + ), json.dumps(task_payload), ) # Create WorkerPoller from hindsight_api.extensions.builtin.tenant import DefaultTenantExtension + tenant_extension = DefaultTenantExtension(config={"schema": schema} if schema else {}) poller = WorkerPoller( @@ -462,13 +579,7 @@ async def test_batch_api_via_extract_facts_from_contents( "custom_id": "chunk_0", "response": { "body": { - "choices": [ - { - "message": { - "content": json.dumps({"facts": []}) - } - } - ], + "choices": [{"message": {"content": json.dumps({"facts": []})}}], "usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}, } }, diff --git a/hindsight-api-slim/tests/test_batch_api_integration.py b/hindsight-api-slim/tests/test_batch_api_integration.py index 52a77f1789..72830fda1f 100644 --- a/hindsight-api-slim/tests/test_batch_api_integration.py +++ b/hindsight-api-slim/tests/test_batch_api_integration.py @@ -10,6 +10,7 @@ To skip in CI: Add @pytest.mark.skip at the test level """ + import pytest import os import asyncio @@ -115,7 +116,9 @@ def integration_config(): return config -@pytest.mark.skip(reason="Real API test - takes minutes and costs money. Run manually with: pytest tests/test_batch_api_integration.py::test_real_openai_batch_api -v -s") +@pytest.mark.skip( + reason="Real API test - takes minutes and costs money. Run manually with: pytest tests/test_batch_api_integration.py::test_real_openai_batch_api -v -s" +) @pytest.mark.integration # Mark as integration test @pytest.mark.slow # Mark as slow test @pytest.mark.asyncio @@ -174,17 +177,21 @@ async def test_real_openai_batch_api(real_llm_config, test_contents_real, integr logger.info("\n" + "=" * 80) logger.info("✅ BATCH COMPLETED SUCCESSFULLY") logger.info("=" * 80) - logger.info(f"Total duration: {total_duration:.1f} seconds ({total_duration/60:.1f} minutes)") + logger.info(f"Total duration: {total_duration:.1f} seconds ({total_duration / 60:.1f} minutes)") logger.info(f"Facts extracted: {len(facts)}") logger.info(f"Chunks processed: {len(chunks)}") - logger.info(f"Token usage: {usage.input_tokens} input + {usage.output_tokens} output = {usage.total_tokens} total") - logger.info(f"Estimated cost: ${(usage.input_tokens * 0.00015 / 1000 + usage.output_tokens * 0.0006 / 1000):.4f}") + logger.info( + f"Token usage: {usage.input_tokens} input + {usage.output_tokens} output = {usage.total_tokens} total" + ) + logger.info( + f"Estimated cost: ${(usage.input_tokens * 0.00015 / 1000 + usage.output_tokens * 0.0006 / 1000):.4f}" + ) logger.info("=" * 80) # Log sample facts logger.info("\n📋 Sample extracted facts:") for i, fact in enumerate(facts[:5]): # Show first 5 facts - logger.info(f"\nFact {i+1}:") + logger.info(f"\nFact {i + 1}:") logger.info(f" Type: {fact.fact_type}") logger.info(f" Text: {fact.fact_text[:100]}...") logger.info(f" Entities: {fact.entities}") @@ -212,11 +219,13 @@ async def test_real_openai_batch_api(real_llm_config, test_contents_real, integr f.write(f"Contents: {len(test_contents_real)} items\n") f.write(f"Poll Interval: {integration_config.retain_batch_poll_interval_seconds}s\n\n") f.write(f"Results:\n") - f.write(f" Total Duration: {total_duration:.1f}s ({total_duration/60:.1f} min)\n") + f.write(f" Total Duration: {total_duration:.1f}s ({total_duration / 60:.1f} min)\n") f.write(f" Facts Extracted: {len(facts)}\n") f.write(f" Chunks Processed: {len(chunks)}\n") f.write(f" Token Usage: {usage.total_tokens} ({usage.input_tokens} in + {usage.output_tokens} out)\n") - f.write(f" Estimated Cost: ${(usage.input_tokens * 0.00015 / 1000 + usage.output_tokens * 0.0006 / 1000):.4f}\n") + f.write( + f" Estimated Cost: ${(usage.input_tokens * 0.00015 / 1000 + usage.output_tokens * 0.0006 / 1000):.4f}\n" + ) logger.info(f"\n📄 Timing report written to: {report_path}") diff --git a/hindsight-api-slim/tests/test_batch_api_validation.py b/hindsight-api-slim/tests/test_batch_api_validation.py index ed7c3e2bf3..a8dedf1d7b 100644 --- a/hindsight-api-slim/tests/test_batch_api_validation.py +++ b/hindsight-api-slim/tests/test_batch_api_validation.py @@ -23,7 +23,8 @@ async def test_startup_rejects_batch_enabled_with_non_batch_provider(): mock_provider.supports_batch_api = AsyncMock(return_value=False) mock_llm_config = MagicMock() - mock_llm_config.provider = "gemini" + # anthropic has no batch API in the engine — a genuine non-batch provider. + mock_llm_config.provider = "anthropic" mock_llm_config._provider_impl = mock_provider mock_llm_config.verify_connection = AsyncMock() @@ -40,7 +41,7 @@ async def test_startup_rejects_batch_enabled_with_non_batch_provider(): f"Configuration error: HINDSIGHT_API_RETAIN_BATCH_ENABLED=true " f"but the retain LLM provider '{mock_llm_config.provider}' " f"does not support the batch API. Either switch to a provider " - f"that supports batch operations (e.g. 'openai', 'groq') or " + f"that supports batch operations (e.g. 'openai', 'groq', 'gemini') or " f"set HINDSIGHT_API_RETAIN_BATCH_ENABLED=false." ) @@ -97,7 +98,7 @@ async def test_runtime_raises_if_batch_unsupported(): with pytest.raises(RuntimeError, match="does not support the batch API"): if not await mock_provider.supports_batch_api(): raise RuntimeError( - "retain_batch_enabled=True but provider 'gemini' does not " + "retain_batch_enabled=True but provider 'anthropic' does not " "support the batch API. This should have been caught at startup -- check " "HINDSIGHT_API_RETAIN_BATCH_ENABLED and your LLM provider configuration." ) diff --git a/hindsight-api-slim/tests/test_batch_chunking.py b/hindsight-api-slim/tests/test_batch_chunking.py index f0f94d2827..c3aa012682 100644 --- a/hindsight-api-slim/tests/test_batch_chunking.py +++ b/hindsight-api-slim/tests/test_batch_chunking.py @@ -174,9 +174,7 @@ def test_async_children_packs_small_items_by_budget(): num_items = max(4, (tokens_per_batch // max(item_tokens, 1)) * 3) contents = [{"content": item_text, "document_id": f"doc-{i}"} for i in range(num_items)] total = sum(count_tokens(c["content"]) for c in contents) - assert total > tokens_per_batch, ( - f"Test setup error: {total} tokens does not exceed budget {tokens_per_batch}" - ) + assert total > tokens_per_batch, f"Test setup error: {total} tokens does not exceed budget {tokens_per_batch}" children = _split_contents_into_async_children(contents, tokens_per_batch) diff --git a/hindsight-api-slim/tests/test_bedrock_service_tier.py b/hindsight-api-slim/tests/test_bedrock_service_tier.py new file mode 100644 index 0000000000..25cd18247d --- /dev/null +++ b/hindsight-api-slim/tests/test_bedrock_service_tier.py @@ -0,0 +1,61 @@ +"""Plumbing tests for the Bedrock service tier (HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER). + +These assert the *wiring* — that a configured tier actually reaches the LiteLLM +call kwargs — rather than just that the env var parses into config (covered by +test_config_validation.py). The config value is threaded +config -> LLMProvider -> create_llm_provider -> LiteLLMLLM, and only LiteLLMLLM +injects ``service_tier`` for ``bedrock/`` models, so the checks live here. +""" + +from hindsight_api.engine.llm_wrapper import LLMConfig +from hindsight_api.engine.providers.litellm_llm import LiteLLMLLM + +_MESSAGES = [{"role": "user", "content": "hi"}] + + +def _make_litellm(model: str, tier: str | None) -> LiteLLMLLM: + return LiteLLMLLM(provider="bedrock", api_key="", base_url="", model=model, bedrock_service_tier=tier) + + +def test_bedrock_model_injects_service_tier(): + """A configured tier is injected as ``service_tier`` for bedrock/ models.""" + llm = _make_litellm("bedrock/us.amazon.nova-2-lite-v1:0", "flex") + kwargs = llm._build_common_kwargs(messages=_MESSAGES) + assert kwargs["service_tier"] == "flex" + + +def test_bedrock_model_without_tier_omits_service_tier(): + """No tier configured -> no ``service_tier`` key (Bedrock default tier).""" + llm = _make_litellm("bedrock/us.amazon.nova-2-lite-v1:0", None) + kwargs = llm._build_common_kwargs(messages=_MESSAGES) + assert "service_tier" not in kwargs + + +def test_non_bedrock_model_never_gets_service_tier(): + """The bedrock/ prefix guard keeps the kwarg off non-Bedrock LiteLLM models.""" + llm = LiteLLMLLM( + provider="litellm", + api_key="k", + base_url="", + model="fireworks_ai/accounts/fireworks/models/llama-v3p1-70b-instruct", + bedrock_service_tier="flex", + ) + kwargs = llm._build_common_kwargs(messages=_MESSAGES) + assert "service_tier" not in kwargs + + +def test_llm_config_threads_tier_to_provider_impl(): + """End-to-end: LLMConfig -> create_llm_provider -> LiteLLMLLM carries the tier. + + This is the bridge the env var depends on; if MemoryEngine ever stops + passing ``bedrock_service_tier`` through, the value silently defaults to + None and the flag becomes inert. + """ + llm = LLMConfig( + provider="bedrock", + api_key="", + base_url="", + model="us.amazon.nova-2-lite-v1:0", + bedrock_service_tier="flex", + ) + assert llm._provider_impl.bedrock_service_tier == "flex" diff --git a/hindsight-api-slim/tests/test_causal_relations.py b/hindsight-api-slim/tests/test_causal_relations.py index 1983255f6f..838651c6c8 100644 --- a/hindsight-api-slim/tests/test_causal_relations.py +++ b/hindsight-api-slim/tests/test_causal_relations.py @@ -104,8 +104,7 @@ async def test_first_fact_has_no_causal_relations(self): for rel in facts[0].causal_relations: # This should never happen due to validation assert False, ( - f"First fact should not have causal relations, " - f"but found: target_index={rel.target_fact_index}" + f"First fact should not have causal relations, but found: target_index={rel.target_fact_index}" ) @pytest.mark.asyncio @@ -139,11 +138,13 @@ async def test_causal_chain_extraction(self): for i, fact in enumerate(facts): if fact.causal_relations: for rel in fact.causal_relations: - all_relations.append({ - "from_fact": i, - "to_fact": rel.target_fact_index, - "type": rel.relation_type, - }) + all_relations.append( + { + "from_fact": i, + "to_fact": rel.target_fact_index, + "type": rel.relation_type, + } + ) # If causal relations were extracted, verify they form a valid chain if all_relations: @@ -226,6 +227,5 @@ async def test_relation_types_are_backward_looking(self): if fact.causal_relations: for rel in fact.causal_relations: assert rel.relation_type in valid_types, ( - f"Invalid relation_type '{rel.relation_type}'. " - f"Must be one of: {valid_types}" + f"Invalid relation_type '{rel.relation_type}'. Must be one of: {valid_types}" ) diff --git a/hindsight-api-slim/tests/test_causal_relationships.py b/hindsight-api-slim/tests/test_causal_relationships.py index 3994d09039..33253f780d 100644 --- a/hindsight-api-slim/tests/test_causal_relationships.py +++ b/hindsight-api-slim/tests/test_causal_relationships.py @@ -40,7 +40,11 @@ async def test_causal_chain_extraction(self): llm_config = LLMConfig.from_env() facts, _, _ = await extract_facts_from_text( - text=text, event_date=datetime(2024, 3, 15), context=context, llm_config=llm_config, agent_name="TestUser", + text=text, + event_date=datetime(2024, 3, 15), + context=context, + llm_config=llm_config, + agent_name="TestUser", config=_get_raw_config(), ) @@ -109,7 +113,11 @@ async def test_complex_causal_web(self): llm_config = LLMConfig.from_env() facts, _, _ = await extract_facts_from_text( - text=text, event_date=datetime(2024, 6, 1), context=context, llm_config=llm_config, agent_name="TestUser", + text=text, + event_date=datetime(2024, 6, 1), + context=context, + llm_config=llm_config, + agent_name="TestUser", config=_get_raw_config(), ) @@ -140,7 +148,11 @@ async def test_no_self_referencing_causal_relations(self): llm_config = LLMConfig.from_env() facts, _, _ = await extract_facts_from_text( - text=text, event_date=datetime(2024, 1, 1), context=context, llm_config=llm_config, agent_name="TestUser", + text=text, + event_date=datetime(2024, 1, 1), + context=context, + llm_config=llm_config, + agent_name="TestUser", config=_get_raw_config(), ) @@ -168,7 +180,11 @@ async def test_bidirectional_causal_relationships(self): llm_config = LLMConfig.from_env() facts, _, _ = await extract_facts_from_text( - text=text, event_date=datetime(2024, 2, 15), context=context, llm_config=llm_config, agent_name="TestUser", + text=text, + event_date=datetime(2024, 2, 15), + context=context, + llm_config=llm_config, + agent_name="TestUser", config=_get_raw_config(), ) @@ -180,4 +196,3 @@ async def test_bidirectional_causal_relationships(self): f"Invalid target_fact_index {rel.target_fact_index} in fact {i}. " f"Must reference previous facts only (valid range: 0 to {i - 1})" ) - diff --git a/hindsight-api-slim/tests/test_chunk_storage_upsert.py b/hindsight-api-slim/tests/test_chunk_storage_upsert.py index a9f486c12f..68a09b86d5 100644 --- a/hindsight-api-slim/tests/test_chunk_storage_upsert.py +++ b/hindsight-api-slim/tests/test_chunk_storage_upsert.py @@ -130,8 +130,7 @@ async def test_store_chunks_batch_second_call_with_identical_payload(memory): await _seed_bank_and_document(conn, bank_id, document_id) chunks = [ - ChunkMetadata(chunk_text=f"chunk-{i}", fact_count=1, content_index=0, chunk_index=i) - for i in range(5) + ChunkMetadata(chunk_text=f"chunk-{i}", fact_count=1, content_index=0, chunk_index=i) for i in range(5) ] await chunk_storage.store_chunks_batch(conn, bank_id, document_id, chunks, ops=ops) diff --git a/hindsight-api-slim/tests/test_chunking.py b/hindsight-api-slim/tests/test_chunking.py index 929beb7b22..8770f5b947 100644 --- a/hindsight-api-slim/tests/test_chunking.py +++ b/hindsight-api-slim/tests/test_chunking.py @@ -1,17 +1,41 @@ """ Test chunking functionality for large documents. + +These assert the EXACT chunk output for small, controlled inputs (so a change +in splitting behavior is caught precisely), plus a few property/scale tests for +large inputs where spelling out every chunk would be unwieldy. """ + +import json + import pytest + from hindsight_api.engine.retain.fact_extraction import chunk_text +# --------------------------------------------------------------------------- +# Plain text +# --------------------------------------------------------------------------- + def test_chunk_text_small(): - """Test that small text is not chunked.""" + """Text within the budget is returned unchanged, as a single chunk.""" text = "This is a short text. It should not be chunked." - chunks = chunk_text(text, max_chars=1000) + assert chunk_text(text, max_chars=1000) == [text] - assert len(chunks) == 1, "Small text should not be chunked" - assert chunks[0] == text + +def test_chunk_text_exact_split(): + """Plain text splits at sentence boundaries — exact chunks.""" + text = "Alpha sentence one. Beta sentence two. Gamma sentence three. Delta sentence four." + + chunks = chunk_text(text, max_chars=40) + + assert chunks == [ + "Alpha sentence one. Beta sentence two", + ". Gamma sentence three", + ". Delta sentence four.", + ] + # Sentence-boundary splitting here is lossless: concatenation rebuilds the input. + assert "".join(chunks) == text def test_chunk_text_large(): @@ -54,3 +78,247 @@ def test_chunk_text_64k(): combined_length = sum(len(chunk) for chunk in chunks) assert combined_length >= len(text) * 0.95, "Lost too much content during chunking" + +# --------------------------------------------------------------------------- +# JSONL (newline-delimited JSON objects) +# --------------------------------------------------------------------------- + + +def test_chunk_jsonl_small(): + """JSONL that fits in one chunk is returned unchanged.""" + lines = [json.dumps({"role": "user", "content": f"message {i}"}) for i in range(3)] + text = "\n".join(lines) + + assert chunk_text(text, max_chars=10000) == [text] + + +def test_chunk_jsonl_packs_multiple_short_lines(): + """Short JSONL lines are packed together — exact chunk boundaries.""" + lines = [json.dumps({"i": i}) for i in range(6)] # each '{"i": N}' is 8 chars + text = "\n".join(lines) + + chunks = chunk_text(text, max_chars=40) + + # Four lines (8 chars + newline = 9 each -> 36) fit; the fifth would hit 45 > 40. + assert chunks == [ + '{"i": 0}\n{"i": 1}\n{"i": 2}\n{"i": 3}', + '{"i": 4}\n{"i": 5}', + ] + + +def test_chunk_jsonl_one_line_per_chunk(): + """When two lines don't fit together, each lands in its own chunk.""" + lines = [json.dumps({"k": "a" * 10}) for _ in range(3)] # 19 chars each + text = "\n".join(lines) + + # Budget 25: one line (20 w/ newline) fits, two (40) don't. + assert chunk_text(text, max_chars=25) == lines + + +def test_chunk_jsonl_splits_at_line_boundaries(): + """Large JSONL is chunked at line boundaries without splitting any line.""" + lines = [json.dumps({"role": "user", "content": f"message {i} " + "x" * 80}) for i in range(10)] + text = "\n".join(lines) + + chunks = chunk_text(text, max_chars=300) + + assert len(chunks) > 1, "Large JSONL should be chunked" + + # Every line across all chunks must remain a complete, parseable JSON object. + seen = [] + for chunk in chunks: + for line in chunk.split("\n"): + seen.append(json.loads(line)) # raises if a line was split mid-object + assert seen == [json.loads(line) for line in lines], "Lines must be preserved in order" + + +def test_chunk_jsonl_default_structured_unit_limit_matches_budget(): + """A JSONL line over the budget is split when no larger structured-chunk cap is set.""" + big = json.dumps({"c": "y" * 20}) # 29 chars; budget 25 -> split + small = json.dumps({"c": "ok"}) + text = "\n".join([big, small]) + + chunks = chunk_text(text, max_chars=25) + + assert chunks == [ + '{"c":', + '"yyyyyyyyyyyyyyyyyyyy"}', + small, + ] + + +def test_chunk_jsonl_custom_structured_unit_limit_keeps_overflow_whole(): + """A JSONL line over the budget is kept whole when the explicit cap allows it.""" + big = json.dumps({"c": "y" * 20}) # 29 chars + small = json.dumps({"c": "ok"}) + text = "\n".join([big, small]) + + chunks = chunk_text(text, max_chars=25, structured_chunk_size=len(big)) + + assert chunks == [big, small] + + +def test_chunk_structured_unit_limit_above_chunk_size_preserves_small_overflows(): + """Structured units between max_chars and the structured cap remain intact.""" + jsonl_line = json.dumps({"c": "y" * 20}) # 29 chars; over budget 25, within cap 29 + conversation = json.dumps([{"c": "y" * 20}]) + + jsonl_chunks = chunk_text( + "\n".join([jsonl_line, json.dumps({"c": "ok"})]), + max_chars=25, + structured_chunk_size=29, + ) + conversation_chunks = chunk_text(conversation, max_chars=25, structured_chunk_size=29) + + assert jsonl_chunks[0] == jsonl_line + assert conversation_chunks == [conversation] + + +def test_chunk_jsonl_structured_unit_limit_can_be_below_chunk_size(): + """An oversized JSONL line is split by the structured cap, not the larger chunk budget.""" + huge = json.dumps({"c": "y" * 40}) # 49 chars; over cap 20 but under budget 55 + small = json.dumps({"c": "ok"}) + text = "\n".join([huge, small]) + + chunks = chunk_text(text, max_chars=55, structured_chunk_size=20) + + assert chunks == [ + '{"c":', + '"yyyyyyyyyyyyyyyyyy', + "yyyyyyyyyyyyyyyyyyyy", + 'yy"}', + small, + ] + for chunk in chunks: + assert len(chunk) <= 20 + + +def test_chunk_jsonl_huge_line_is_split(): + """A JSONL line past the structured-chunk cap is split as text — exact fragments.""" + huge = json.dumps({"c": "y" * 40}) # 49 chars; budget/cap 20 -> must split + small = json.dumps({"c": "ok"}) + text = "\n".join([huge, small]) + + chunks = chunk_text(text, max_chars=20) + + # The huge line is split into text fragments; the small line survives intact. + assert chunks == [ + '{"c":', + '"yyyyyyyyyyyyyyyyyy', + "yyyyyyyyyyyyyyyyyyyy", + 'yy"}', + '{"c": "ok"}', + ] + # No fragment exceeds the configured split budget. + for chunk in chunks: + assert len(chunk) <= 20 + + +# --------------------------------------------------------------------------- +# JSON conversation array +# --------------------------------------------------------------------------- + + +def test_chunk_conversation_packs_turns(): + """A conversation array packs whole turns per chunk — exact JSON-array chunks.""" + turns = [ + {"r": "u", "c": "hi"}, + {"r": "a", "c": "yo"}, + {"r": "u", "c": "bye"}, + {"r": "a", "c": "ok"}, + ] + text = json.dumps(turns) + + chunks = chunk_text(text, max_chars=50) + + assert chunks == [ + '[{"r": "u", "c": "hi"}, {"r": "a", "c": "yo"}]', + '[{"r": "u", "c": "bye"}, {"r": "a", "c": "ok"}]', + ] + # Each chunk is itself a valid JSON array of complete turns. + assert [json.loads(c) for c in chunks] == [turns[:2], turns[2:]] + + +def test_chunk_conversation_splits_at_turn_boundaries(): + """A large conversation array chunks at turn boundaries, keeping turns whole.""" + turns = [{"role": "user", "content": f"message {i} " + "x" * 80} for i in range(10)] + text = json.dumps(turns) + + chunks = chunk_text(text, max_chars=300) + + assert len(chunks) > 1 + seen = [] + for chunk in chunks: + parsed = json.loads(chunk) + assert isinstance(parsed, list) + seen.extend(parsed) + assert seen == turns + + +def test_chunk_conversation_custom_structured_unit_limit_keeps_overflow_whole(): + """A conversation turn over the budget is kept whole when the explicit cap allows it.""" + turns = [{"c": "y" * 20}, {"c": "ok"}] + text = json.dumps(turns) + turn_size = len(json.dumps(turns[0])) + + chunks = chunk_text(text, max_chars=25, structured_chunk_size=turn_size) + + assert chunks == [ + '[{"c": "yyyyyyyyyyyyyyyyyyyy"}]', + '[{"c": "ok"}]', + ] + + +def test_chunk_conversation_structured_unit_limit_can_be_below_chunk_size(): + """An oversized conversation turn is split by the structured cap, not the larger chunk budget.""" + turns = [{"c": "y" * 40}, {"c": "ok"}] + text = json.dumps(turns) + + chunks = chunk_text(text, max_chars=55, structured_chunk_size=20) + + assert chunks == [ + '{"c":', + '"yyyyyyyyyyyyyyyyyy', + "yyyyyyyyyyyyyyyyyyyy", + 'yy"}', + '[{"c": "ok"}]', + ] + for chunk in chunks: + assert len(chunk) <= 20 + + +def test_chunk_conversation_huge_turn_is_split(): + """A single turn past the structured-chunk cap is split as text — exact fragments.""" + turns = [{"c": "y" * 40}, {"c": "ok"}] + text = json.dumps(turns) + + chunks = chunk_text(text, max_chars=20) + + # The huge turn is split into text fragments; the small turn stays a JSON array. + assert chunks == [ + '{"c":', + '"yyyyyyyyyyyyyyyyyy', + "yyyyyyyyyyyyyyyyyyyy", + 'yy"}', + '[{"c": "ok"}]', + ] + for chunk in chunks: + assert len(chunk) <= 20 + + +# --------------------------------------------------------------------------- +# Detection guard +# --------------------------------------------------------------------------- + + +def test_plain_text_lines_not_treated_as_jsonl(): + """Plain (non-JSON) lines fall back to text splitting, not JSONL chunking.""" + text = "\n".join(["Line one here.", "Line two here.", "Line three now."]) + + chunks = chunk_text(text, max_chars=20) + + # Each line fits the budget, so text splitting emits one line per chunk. + assert chunks == ["Line one here.", "Line two here.", "Line three now."] + # Sanity: these are not JSON objects (so the JSONL path correctly declined). + with pytest.raises(json.JSONDecodeError): + json.loads(chunks[0]) diff --git a/hindsight-api-slim/tests/test_codex_home_env.py b/hindsight-api-slim/tests/test_codex_home_env.py new file mode 100644 index 0000000000..3d29087214 --- /dev/null +++ b/hindsight-api-slim/tests/test_codex_home_env.py @@ -0,0 +1,109 @@ +"""Tests for ``CODEX_HOME`` resolution of the Codex ``auth.json`` location. + +Codex stores its OAuth credentials under a configurable home directory. The +canonical ``@openai/codex`` CLI honors the ``CODEX_HOME`` environment variable +and falls back to ``~/.codex``. Hindsight's Codex auth/LLM/embeddings paths +must resolve the same way so that a user who relocates ``CODEX_HOME`` is still +authenticated. +""" + +import json +from pathlib import Path + +from hindsight_api.engine.providers.codex_auth import ( + CodexAuthManager, + default_codex_auth_file, +) +from hindsight_api.engine.providers.codex_llm import CodexLLM + + +def _write_auth(auth_dir: Path, access_token: str = "at-test") -> Path: + """Write a minimal chatgpt-mode auth.json under ``auth_dir``.""" + auth_dir.mkdir(parents=True, exist_ok=True) + auth_file = auth_dir / "auth.json" + auth_file.write_text( + json.dumps( + { + "auth_mode": "chatgpt", + "tokens": { + "access_token": access_token, + "refresh_token": "rt-test", + "account_id": "acct-test", + }, + } + ) + ) + return auth_file + + +# --------------------------------------------------------------------------- +# default_codex_auth_file() +# --------------------------------------------------------------------------- + + +def test_default_auth_file_falls_back_to_home_codex_when_unset(tmp_path, monkeypatch): + monkeypatch.delenv("CODEX_HOME", raising=False) + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + assert default_codex_auth_file() == tmp_path / ".codex" / "auth.json" + + +def test_default_auth_file_honors_codex_home_when_set(tmp_path, monkeypatch): + codex_home = tmp_path / "custom-codex" + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + assert default_codex_auth_file() == codex_home / "auth.json" + + +def test_default_auth_file_empty_codex_home_falls_back(tmp_path, monkeypatch): + """An empty ``CODEX_HOME`` is treated as unset (matches shell semantics).""" + monkeypatch.setenv("CODEX_HOME", "") + monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path)) + + assert default_codex_auth_file() == tmp_path / ".codex" / "auth.json" + + +def test_default_auth_file_resolved_lazily(tmp_path, monkeypatch): + """The env var is read on each call, not cached at import time.""" + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "a")) + assert default_codex_auth_file() == tmp_path / "a" / "auth.json" + + monkeypatch.setenv("CODEX_HOME", str(tmp_path / "b")) + assert default_codex_auth_file() == tmp_path / "b" / "auth.json" + + +# --------------------------------------------------------------------------- +# CodexAuthManager.from_file() — honors CODEX_HOME by default +# --------------------------------------------------------------------------- + + +def test_auth_manager_from_file_uses_codex_home(tmp_path, monkeypatch): + codex_home = tmp_path / "custom-codex" + _write_auth(codex_home, access_token="at-from-codex-home") + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + mgr = CodexAuthManager.from_file() + + assert mgr.access_token == "at-from-codex-home" + assert mgr._auth_file == codex_home / "auth.json" + + +# --------------------------------------------------------------------------- +# CodexLLM — loads credentials from CODEX_HOME +# --------------------------------------------------------------------------- + + +def test_codex_llm_loads_from_codex_home(tmp_path, monkeypatch): + codex_home = tmp_path / "custom-codex" + _write_auth(codex_home, access_token="at-llm") + monkeypatch.setenv("CODEX_HOME", str(codex_home)) + + llm = CodexLLM( + provider="codex", + api_key="ignored", + base_url="", + model="gpt-5-codex", + ) + + assert llm.access_token == "at-llm" + assert llm._auth_file == codex_home / "auth.json" diff --git a/hindsight-api-slim/tests/test_cohere_cross_encoder.py b/hindsight-api-slim/tests/test_cohere_cross_encoder.py index 12da8f933e..f14c691cd3 100644 --- a/hindsight-api-slim/tests/test_cohere_cross_encoder.py +++ b/hindsight-api-slim/tests/test_cohere_cross_encoder.py @@ -344,4 +344,7 @@ async def test_create_cohere_with_azure_base_url_from_env(self): assert isinstance(encoder, CohereCrossEncoder) assert encoder.api_key == "test_key" assert encoder.model == "cohere-rerank-v3-english" - assert encoder.base_url == "https://my-endpoint.inference.ai.azure.com/models/cohere-rerank-v3-english/invoke" + assert ( + encoder.base_url + == "https://my-endpoint.inference.ai.azure.com/models/cohere-rerank-v3-english/invoke" + ) diff --git a/hindsight-api-slim/tests/test_combined_scoring.py b/hindsight-api-slim/tests/test_combined_scoring.py index 2951091987..b43a6ef599 100644 --- a/hindsight-api-slim/tests/test_combined_scoring.py +++ b/hindsight-api-slim/tests/test_combined_scoring.py @@ -21,12 +21,16 @@ def _make_result( ce_norm: float, occurred_start: datetime | None = None, temporal_proximity: float | None = None, + mentioned_at: datetime | None = None, + occurred_end: datetime | None = None, ) -> ScoredResult: retrieval = RetrievalResult( id="test", text="test", fact_type="world", occurred_start=occurred_start, + occurred_end=occurred_end, + mentioned_at=mentioned_at, temporal_proximity=temporal_proximity, ) @@ -141,13 +145,36 @@ def test_model_calibration_independence(self): apply_combined_scoring([l_relevant, l_recent], now=NOW) assert l_relevant.weight > l_recent.weight, "Low-CE model: relevance should still win" - def test_no_occurred_start_defaults_recency_neutral(self): - """Missing occurred_start → recency=0.5 → no boost/penalty.""" - sr = _make_result(ce_norm=0.5, occurred_start=None) + def test_no_effective_time_defaults_recency_neutral(self): + """No effective time at all (occurred_start/mentioned_at/occurred_end) → recency=0.5.""" + sr = _make_result(ce_norm=0.5) apply_combined_scoring([sr], now=NOW) assert sr.recency == 0.5 assert abs(sr.weight - 0.5) < 1e-9 + def test_mentioned_at_drives_recency_when_no_occurred_start(self): + """A memory with only mentioned_at must derive recency from it, not stay neutral.""" + sr = _make_result(ce_norm=0.5, mentioned_at=NOW) + apply_combined_scoring([sr], now=NOW) + assert sr.recency == 1.0 + assert sr.weight > 0.5 + + def test_occurred_end_is_last_recency_fallback(self): + """occurred_end feeds recency when neither occurred_start nor mentioned_at is set.""" + old = NOW - timedelta(days=400) + sr = _make_result(ce_norm=0.5, occurred_end=old) + apply_combined_scoring([sr], now=NOW) + assert sr.recency == 0.1 + assert sr.weight < 0.5 + + def test_occurred_start_takes_precedence_over_mentioned_at(self): + """occurred_start wins over mentioned_at (matches _coalesce_date COALESCE order).""" + recent = NOW - timedelta(days=10) + old = NOW - timedelta(days=400) + sr = _make_result(ce_norm=0.5, occurred_start=recent, mentioned_at=old) + apply_combined_scoring([sr], now=NOW) + assert sr.recency > 0.9 + def test_timezone_naive_occurred_start_handled(self): """Naive datetimes in occurred_start should not raise.""" naive_date = datetime(2024, 1, 1) # no tzinfo diff --git a/hindsight-api-slim/tests/test_config_validation.py b/hindsight-api-slim/tests/test_config_validation.py index eea6cf8e3e..8b4fd3713a 100644 --- a/hindsight-api-slim/tests/test_config_validation.py +++ b/hindsight-api-slim/tests/test_config_validation.py @@ -18,10 +18,15 @@ def setup_test_env(): # Save original environment values env_vars_to_save = [ "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS", + "HINDSIGHT_API_CONSOLIDATION_MAX_COMPLETION_TOKENS", "HINDSIGHT_API_RETAIN_CHUNK_SIZE", + "HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE", "HINDSIGHT_API_LLM_PROVIDER", "HINDSIGHT_API_LLM_MODEL", "HINDSIGHT_API_LLM_REASONING_EFFORT", + "HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER", + "HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", + "HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY", "HINDSIGHT_API_DATABASE_URL", "HINDSIGHT_API_MIGRATION_DATABASE_URL", ] @@ -101,6 +106,96 @@ def test_valid_retain_config_succeeds(): config = HindsightConfig.from_env() assert config.retain_max_completion_tokens == 64000 assert config.retain_chunk_size == 3000 + assert config.retain_structured_chunk_size is None + + +def test_retain_structured_chunk_size_reads_from_env(): + """Structured JSONL/conversation units can have an explicit character cap.""" + from hindsight_api.config import HindsightConfig + + os.environ["HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"] = "64000" + os.environ["HINDSIGHT_API_RETAIN_CHUNK_SIZE"] = "3000" + os.environ["HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE"] = "9000" + os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock" + + config = HindsightConfig.from_env() + assert config.retain_structured_chunk_size == 9000 + + +def test_retain_structured_chunk_size_can_be_less_than_chunk_size(): + """Structured-chunk cap can be smaller than the retain chunk target.""" + from hindsight_api.config import HindsightConfig + + os.environ["HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"] = "64000" + os.environ["HINDSIGHT_API_RETAIN_CHUNK_SIZE"] = "3000" + os.environ["HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE"] = "2000" + os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock" + + config = HindsightConfig.from_env() + assert config.retain_chunk_size == 3000 + assert config.retain_structured_chunk_size == 2000 + + +def test_retain_strategy_structured_chunk_size_validation(): + """Retain strategies allow structured-chunk caps below chunk size.""" + from hindsight_api.config import HindsightConfig + from hindsight_api.config_resolver import apply_strategy + + os.environ["HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"] = "64000" + os.environ["HINDSIGHT_API_RETAIN_CHUNK_SIZE"] = "3000" + os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock" + + config = HindsightConfig.from_env() + config.retain_strategies = { + "jsonl": { + "retain_structured_chunk_size": 2000, + } + } + + resolved = apply_strategy(config, "jsonl") + assert resolved.retain_structured_chunk_size == 2000 + + +def test_semantic_min_similarity_reads_from_env(): + """Semantic retrieval min similarity can be configured at the server level.""" + from hindsight_api.config import HindsightConfig + + os.environ["HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY"] = "0.58" + + config = HindsightConfig.from_env() + assert config.semantic_min_similarity == 0.58 + + +def test_semantic_min_similarity_must_be_between_zero_and_one(): + """Invalid semantic min similarity fails fast during configuration loading.""" + from hindsight_api.config import HindsightConfig + + os.environ["HINDSIGHT_API_SEMANTIC_MIN_SIMILARITY"] = "1.5" + + with pytest.raises(ValueError, match="semantic_min_similarity"): + HindsightConfig.from_env() + + +def test_consolidation_max_completion_tokens_defaults_to_unset(): + """By default consolidation sends no explicit output budget (backwards compatible).""" + from hindsight_api.config import HindsightConfig + + os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock" + os.environ.pop("HINDSIGHT_API_CONSOLIDATION_MAX_COMPLETION_TOKENS", None) + + config = HindsightConfig.from_env() + assert config.consolidation_max_completion_tokens is None + + +def test_consolidation_max_completion_tokens_env_override(): + """HINDSIGHT_API_CONSOLIDATION_MAX_COMPLETION_TOKENS controls consolidation LLM output budget.""" + from hindsight_api.config import HindsightConfig + + os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock" + os.environ["HINDSIGHT_API_CONSOLIDATION_MAX_COMPLETION_TOKENS"] = "8192" + + config = HindsightConfig.from_env() + assert config.consolidation_max_completion_tokens == 8192 def test_log_config_masks_database_urls(caplog): @@ -358,6 +453,53 @@ def test_llm_output_language_empty_string_is_unset(monkeypatch): assert config.llm_output_language is None +def test_markitdown_ocr_defaults_disabled(monkeypatch): + from hindsight_api.config import HindsightConfig + + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock") + + config = HindsightConfig.from_env() + assert config.file_parser_markitdown_ocr_enabled is False + + +def test_markitdown_ocr_does_not_fall_back_to_main_llm_config(monkeypatch): + from hindsight_api.config import DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT, HindsightConfig + + monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED", "true") + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "anthropic") + monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "main-key") + monkeypatch.setenv("HINDSIGHT_API_LLM_BASE_URL", "https://main.example/v1") + monkeypatch.setenv("HINDSIGHT_API_LLM_MODEL", "main-vision-model") + + config = HindsightConfig.from_env() + assert config.file_parser_markitdown_ocr_enabled is True + assert config.file_parser_markitdown_ocr_api_key is None + assert config.file_parser_markitdown_ocr_base_url is None + assert config.file_parser_markitdown_ocr_model is None + assert config.file_parser_markitdown_ocr_prompt == DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT + + +def test_markitdown_ocr_uses_explicit_config(monkeypatch): + from hindsight_api.config import HindsightConfig + + monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_ENABLED", "true") + monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_API_KEY", "parser-key") + monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_BASE_URL", "https://parser.example/v1") + monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_MODEL", "parser-vision-model") + monkeypatch.setenv("HINDSIGHT_API_FILE_PARSER_MARKITDOWN_OCR_PROMPT", "Extract this document exactly.") + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock") + monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "main-key") + monkeypatch.setenv("HINDSIGHT_API_LLM_BASE_URL", "https://main.example/v1") + monkeypatch.setenv("HINDSIGHT_API_LLM_MODEL", "main-vision-model") + + config = HindsightConfig.from_env() + assert config.file_parser_markitdown_ocr_enabled is True + assert config.file_parser_markitdown_ocr_api_key == "parser-key" + assert config.file_parser_markitdown_ocr_base_url == "https://parser.example/v1" + assert config.file_parser_markitdown_ocr_model == "parser-vision-model" + assert config.file_parser_markitdown_ocr_prompt == "Extract this document exactly." + + def test_llm_reasoning_effort_defaults_to_low(monkeypatch): from hindsight_api.config import HindsightConfig @@ -421,3 +563,146 @@ def test_recall_max_candidates_per_source_loaded_from_env(monkeypatch): config = HindsightConfig.from_env() assert config.recall_max_candidates_per_source == 150 + + +# --------------------------------------------------------------------------- +# Bedrock service tier (HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER) +# --------------------------------------------------------------------------- + + +def test_bedrock_service_tier_defaults_to_none(monkeypatch): + """Bedrock service tier defaults to None (standard tier) when unset.""" + from hindsight_api.config import HindsightConfig + + monkeypatch.delenv("HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER", raising=False) + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock") + + config = HindsightConfig.from_env() + assert config.llm_bedrock_service_tier is None + + +def test_bedrock_service_tier_flex(monkeypatch): + """Flex tier (50% cost savings) is accepted.""" + from hindsight_api.config import HindsightConfig + + monkeypatch.setenv("HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER", "flex") + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock") + + config = HindsightConfig.from_env() + assert config.llm_bedrock_service_tier == "flex" + + +def test_bedrock_service_tier_priority(monkeypatch): + """Priority tier (guaranteed throughput) is accepted.""" + from hindsight_api.config import HindsightConfig + + monkeypatch.setenv("HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER", "priority") + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock") + + config = HindsightConfig.from_env() + assert config.llm_bedrock_service_tier == "priority" + + +def test_bedrock_service_tier_reserved(monkeypatch): + """Reserved tier (provisioned capacity) is accepted.""" + from hindsight_api.config import HindsightConfig + + monkeypatch.setenv("HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER", "reserved") + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock") + + config = HindsightConfig.from_env() + assert config.llm_bedrock_service_tier == "reserved" + + +def test_bedrock_service_tier_rejects_invalid_value(monkeypatch): + """ "standard" is not a valid Bedrock service tier and must be rejected.""" + from hindsight_api.config import HindsightConfig + + monkeypatch.setenv("HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER", "standard") + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock") + + with pytest.raises(ValueError) as exc_info: + HindsightConfig.from_env() + + error_message = str(exc_info.value) + assert "HINDSIGHT_API_LLM_BEDROCK_SERVICE_TIER" in error_message + assert "standard" in error_message + assert "'standard' is not a valid Bedrock service tier" in error_message + + +# --------------------------------------------------------------------------- +# Gemini service tier (HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER) +# --------------------------------------------------------------------------- + + +def test_gemini_service_tier_defaults_to_none(monkeypatch): + """Gemini service tier defaults to None (standard tier) when unset.""" + from hindsight_api.config import HindsightConfig + + monkeypatch.delenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", raising=False) + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock") + + config = HindsightConfig.from_env() + assert config.llm_gemini_service_tier is None + + +def test_gemini_service_tier_flex(monkeypatch): + """Flex tier is accepted for Gemini.""" + from hindsight_api.config import HindsightConfig + + monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "flex") + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "gemini") + monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "fake-key") + + config = HindsightConfig.from_env() + assert config.llm_gemini_service_tier == "flex" + + +def test_gemini_service_tier_accepts_mixed_case_provider(monkeypatch): + """Gemini tier parsing follows provider's case-insensitive handling.""" + from hindsight_api.config import HindsightConfig + + monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "flex") + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "Gemini") + monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "fake-key") + + config = HindsightConfig.from_env() + assert config.llm_gemini_service_tier == "flex" + + +def test_gemini_service_tier_rejects_invalid_value(monkeypatch): + """Unknown Gemini service tiers are rejected early.""" + from hindsight_api.config import HindsightConfig + + monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "standard") + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "gemini") + monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "fake-key") + + with pytest.raises(ValueError) as exc_info: + HindsightConfig.from_env() + + error_message = str(exc_info.value) + assert "HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER" in error_message + assert "standard" in error_message + + +def test_gemini_service_tier_ignored_for_non_gemini_provider(monkeypatch): + """Invalid Gemini-only tiers do not break unrelated providers.""" + from hindsight_api.config import HindsightConfig + + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock") + monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "standard") + + config = HindsightConfig.from_env() + assert config.llm_gemini_service_tier is None + + +def test_gemini_service_tier_empty_env_is_unset(monkeypatch): + """Empty env values are treated as unset for templated deployments.""" + from hindsight_api.config import HindsightConfig + + monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "") + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock") + + config = HindsightConfig.from_env() + assert config.llm_gemini_service_tier is None diff --git a/hindsight-api-slim/tests/test_consolidation.py b/hindsight-api-slim/tests/test_consolidation.py index 52a758db64..cd8e6fe2d2 100644 --- a/hindsight-api-slim/tests/test_consolidation.py +++ b/hindsight-api-slim/tests/test_consolidation.py @@ -3114,6 +3114,59 @@ async def test_max_observations_per_scope_limits_creates(memory: MemoryEngine, r await memory.delete_bank(bank_id, request_context=request_context) +@pytest.mark.asyncio +async def test_max_observations_per_scope_zero_forbids_all_creates(memory: MemoryEngine, request_context): + """limit=0 means "no new observations": consolidation must create none. + + Regression for the ``> 0`` call-site guards that excluded 0, leaving + ``remaining_observation_slots=None`` (unconstrained) so a limit of 0 behaved + like unlimited — the inverse of the documented ``0 = no new observations``. + """ + bank_id = f"test-max-obs-zero-{uuid.uuid4().hex[:8]}" + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + raw = _get_raw_config() + fake_config = type(raw)( + **{ + **{f: getattr(raw, f) for f in raw.__dataclass_fields__}, + "max_observations_per_scope": 0, + } + ) + + try: + original_global_config = memory._config_resolver._global_config + memory._config_resolver._global_config = fake_config + wrapper, mock_llm = _make_mock_llm_one_obs_per_fact() + original_llm = memory._consolidation_llm_config + memory._consolidation_llm_config = wrapper + + try: + # Insert tagged memories; the mock LLM will try to create 1 obs per + # fact, but limit=0 must block every create. + async with memory._pool.acquire() as conn: + await _insert_memories_with_tags( + conn, + bank_id, + ["Alice loves hiking.", "Bob swims daily.", "Charlie does yoga."], + tags=["scope:test"], + ) + + for _ in range(3): + await run_consolidation_job(memory_engine=memory, bank_id=bank_id, request_context=request_context) + + async with memory._pool.acquire() as conn: + count = await _count_observations_for_scope(conn, bank_id, ["scope:test"]) + assert count == 0, f"Expected 0 observations (limit=0), got {count}" + + consolidation_calls = [c for c in mock_llm.get_mock_calls() if c["scope"] == "consolidation"] + assert len(consolidation_calls) >= 1, "LLM should have been called at least once" + finally: + memory._config_resolver._global_config = original_global_config + memory._consolidation_llm_config = original_llm + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio async def test_max_observations_per_scope_allows_updates_at_capacity(memory: MemoryEngine, request_context): """At capacity, the LLM can still update existing observations.""" diff --git a/hindsight-api-slim/tests/test_consolidation_failure_recovery.py b/hindsight-api-slim/tests/test_consolidation_failure_recovery.py index c737ddd4f8..0ffe9d2f0c 100644 --- a/hindsight-api-slim/tests/test_consolidation_failure_recovery.py +++ b/hindsight-api-slim/tests/test_consolidation_failure_recovery.py @@ -371,9 +371,7 @@ async def test_recover_resets_failed_memories(self, memory_no_llm_verify: Memory mem_id, ) - result = await memory_no_llm_verify.retry_failed_consolidation( - bank_id, request_context=request_context - ) + result = await memory_no_llm_verify.retry_failed_consolidation(bank_id, request_context=request_context) assert result["retried_count"] == 2 @@ -394,9 +392,7 @@ async def test_recover_returns_zero_when_none_failed(self, memory_no_llm_verify: bank_id = f"test-recover-zero-{uuid.uuid4().hex[:8]}" await memory_no_llm_verify.get_bank_profile(bank_id=bank_id, request_context=request_context) - result = await memory_no_llm_verify.retry_failed_consolidation( - bank_id, request_context=request_context - ) + result = await memory_no_llm_verify.retry_failed_consolidation(bank_id, request_context=request_context) assert result["retried_count"] == 0 @@ -410,14 +406,10 @@ async def test_recover_then_consolidate_succeeds(self, memory_no_llm_verify: Mem async with memory_no_llm_verify._pool.acquire() as conn: (mem_id,) = await _insert_memories(conn, bank_id, ["Grace is an expert rock climber."]) - await conn.execute( - "UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1", mem_id - ) + await conn.execute("UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1", mem_id) # Recover - recover_result = await memory_no_llm_verify.retry_failed_consolidation( - bank_id, request_context=request_context - ) + recover_result = await memory_no_llm_verify.retry_failed_consolidation(bank_id, request_context=request_context) assert recover_result["retried_count"] == 1 # Now consolidate with a healthy LLM @@ -460,9 +452,7 @@ async def test_recover_endpoint_via_http(self, memory_no_llm_verify: MemoryEngin ["Henry is a professional chef.", "Henry trained at Le Cordon Bleu."], ) for mem_id in ids: - await conn.execute( - "UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1", mem_id - ) + await conn.execute("UPDATE memory_units SET consolidation_failed_at = NOW() WHERE id = $1", mem_id) app = create_app(memory_no_llm_verify, initialize_memory=False) transport = httpx.ASGITransport(app=app) diff --git a/hindsight-api-slim/tests/test_consolidation_reschedule_after_round.py b/hindsight-api-slim/tests/test_consolidation_reschedule_after_round.py index 812bf4211e..dd1ae0cd47 100644 --- a/hindsight-api-slim/tests/test_consolidation_reschedule_after_round.py +++ b/hindsight-api-slim/tests/test_consolidation_reschedule_after_round.py @@ -80,9 +80,7 @@ async def _pending_consolidation_ops(memory, bank_id: str) -> list[str]: @pytest.mark.asyncio -async def test_round_limited_consolidation_leaves_followup_pending_op( - memory: MemoryEngine, request_context -): +async def test_round_limited_consolidation_leaves_followup_pending_op(memory: MemoryEngine, request_context): """A round-limited consolidation must leave a new ``pending`` consolidation op in ``async_operations`` for the same bank so the worker poller can drain the backlog without external intervention.""" @@ -147,9 +145,7 @@ async def test_round_limited_consolidation_leaves_followup_pending_op( op_id, ) assert row is not None - assert row["status"] == "completed", ( - f"first consolidation op should be marked completed, got {row['status']}" - ) + assert row["status"] == "completed", f"first consolidation op should be marked completed, got {row['status']}" # 4. Backlog must remain (round limit kept one round under the total) unconsolidated_after = await _count_unconsolidated(memory, bank_id) @@ -169,8 +165,6 @@ async def test_round_limited_consolidation_leaves_followup_pending_op( f"backlog. Found {len(pending_ops)} pending ops; backlog still has " f"{unconsolidated_after} unconsolidated memory_units." ) - assert pending_ops[0] != str(op_id), ( - "The pending op must be a NEW row, not the original op we just executed." - ) + assert pending_ops[0] != str(op_id), "The pending op must be a NEW row, not the original op we just executed." await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api-slim/tests/test_consolidation_retry_budget.py b/hindsight-api-slim/tests/test_consolidation_retry_budget.py index 389a2394f3..3c1e943dc2 100644 --- a/hindsight-api-slim/tests/test_consolidation_retry_budget.py +++ b/hindsight-api-slim/tests/test_consolidation_retry_budget.py @@ -1,9 +1,9 @@ """Tests for consolidation retry budget configurability (issue #1042).""" -import pytest - from unittest.mock import AsyncMock, MagicMock +import pytest + from hindsight_api.engine.consolidation.consolidator import _consolidate_batch_with_llm @@ -24,6 +24,7 @@ def mock_config(): config.observations_mission = None config.consolidation_max_attempts = 3 config.consolidation_llm_max_retries = None + config.consolidation_max_completion_tokens = None return config @@ -68,6 +69,32 @@ async def test_max_retries_threaded_to_call(self, mock_llm_config, mock_config): ) assert mock_llm_config.call.call_args.kwargs.get("max_retries") == 3 + @pytest.mark.asyncio + async def test_max_completion_tokens_threaded_to_call(self, mock_llm_config, mock_config): + """consolidation_max_completion_tokens is passed to llm_config.call().""" + mock_config.consolidation_max_completion_tokens = 8192 + await _consolidate_batch_with_llm( + llm_config=mock_llm_config, + memories=[{"id": "m1", "text": "test"}], + union_observations=[], + union_source_facts={}, + config=mock_config, + ) + assert mock_llm_config.call.call_args.kwargs.get("max_completion_tokens") == 8192 + + @pytest.mark.asyncio + async def test_max_completion_tokens_not_passed_when_none(self, mock_llm_config, mock_config): + """When consolidation_max_completion_tokens is None, max_completion_tokens is omitted (no regression).""" + mock_config.consolidation_max_completion_tokens = None + await _consolidate_batch_with_llm( + llm_config=mock_llm_config, + memories=[{"id": "m1", "text": "test"}], + union_observations=[], + union_source_facts={}, + config=mock_config, + ) + assert "max_completion_tokens" not in mock_llm_config.call.call_args.kwargs + @pytest.mark.asyncio async def test_max_retries_not_passed_when_none(self, mock_llm_config, mock_config): """When consolidation_llm_max_retries is None, max_retries is not passed.""" diff --git a/hindsight-api-slim/tests/test_consolidation_retry_dedup_by_bank.py b/hindsight-api-slim/tests/test_consolidation_retry_dedup_by_bank.py index 5b85bcdca4..36d11ddf48 100644 --- a/hindsight-api-slim/tests/test_consolidation_retry_dedup_by_bank.py +++ b/hindsight-api-slim/tests/test_consolidation_retry_dedup_by_bank.py @@ -230,8 +230,7 @@ async def test_backoff_matches_schedule_by_retry_count(memory, retry_count): delta = (excinfo.value.retry_at - before).total_seconds() assert expected_backoff <= delta <= expected_backoff + 10, ( - f"retry_count={retry_count}: expected backoff ~{expected_backoff}s, " - f"got delta={delta:.2f}s" + f"retry_count={retry_count}: expected backoff ~{expected_backoff}s, got delta={delta:.2f}s" ) await _cleanup(pool, bank_id, op_id) @@ -266,8 +265,6 @@ async def test_retry_is_indefinite(memory): delta = (excinfo.value.retry_at - before).total_seconds() cap = _CONSOLIDATION_RETRY_BACKOFF_MAX_SECONDS - assert cap <= delta <= cap + 10, ( - f"At retry_count=100 expected backoff at cap (~{cap}s), got {delta:.2f}s" - ) + assert cap <= delta <= cap + 10, f"At retry_count=100 expected backoff at cap (~{cap}s), got {delta:.2f}s" await _cleanup(pool, bank_id, op_id) diff --git a/hindsight-api-slim/tests/test_consolidation_round_limit.py b/hindsight-api-slim/tests/test_consolidation_round_limit.py index 617e79cbc9..b9190f08cc 100644 --- a/hindsight-api-slim/tests/test_consolidation_round_limit.py +++ b/hindsight-api-slim/tests/test_consolidation_round_limit.py @@ -77,9 +77,7 @@ async def test_round_limit_caps_processed_memories(memory: MemoryEngine, request assert result["memories_processed"] <= round_limit # Must have re-queued consolidation for remaining work - mock_requeue.assert_called_once_with( - bank_id=bank_id, request_context=request_context, observation_scopes=None - ) + mock_requeue.assert_called_once_with(bank_id=bank_id, request_context=request_context, observation_scopes=None) # Mental model refresh should be skipped on intermediate round assert result.get("mental_models_refreshed", 0) == 0 diff --git a/hindsight-api-slim/tests/test_consolidation_scope_parallelism.py b/hindsight-api-slim/tests/test_consolidation_scope_parallelism.py index c24cad4a68..92847d4947 100644 --- a/hindsight-api-slim/tests/test_consolidation_scope_parallelism.py +++ b/hindsight-api-slim/tests/test_consolidation_scope_parallelism.py @@ -113,10 +113,7 @@ def callback(messages, scope): # example UUIDs in its OUTPUT samples — read user only. prompt = "\n".join(m.get("content", "") for m in messages if m.get("role") == "user") fact_ids = re.findall(r"\[([0-9a-f-]{36})\]", prompt) - creates = [ - _CreateAction(text=f"Observation about fact {fid[:8]}", source_fact_ids=[fid]) - for fid in fact_ids - ] + creates = [_CreateAction(text=f"Observation about fact {fid[:8]}", source_fact_ids=[fid]) for fid in fact_ids] return _ConsolidationBatchResponse(creates=creates) mock_llm.set_response_callback(callback) @@ -170,11 +167,49 @@ async def test_combined_mode_parallel_writes_to_memory_tag_set(memory: MemoryEng assert result["status"] == "completed" tag_sets = _ag_sorted(await _fetch_observation_tag_sets(memory, bank_id)) - assert tag_sets == _ag_sorted([ - frozenset({"user:alice"}), - frozenset({"user:bob"}), - frozenset({"user:carol"}), - ]) + assert tag_sets == _ag_sorted( + [ + frozenset({"user:alice"}), + frozenset({"user:bob"}), + frozenset({"user:carol"}), + ] + ) + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +@pytest.mark.asyncio +async def test_shared_mode_parallel_writes_only_untagged_scope(memory: MemoryEngine, request_context): + """shared → every memory writes to the single untagged scope, ignoring its + own tags. Three memories with disjoint tags therefore all consolidate into + the same global scope (the per-session-tag dedup use case) instead of one + isolated observation per tag.""" + bank_id = f"test-shared-{uuid.uuid4().hex[:8]}" + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + try: + async with memory._pool.acquire() as conn: + await _insert_memory(conn, bank_id, "Alice likes tea", ["session:s1"], "shared") + await _insert_memory(conn, bank_id, "Bob bikes daily", ["session:s2"], "shared") + await _insert_memory(conn, bank_id, "Carol reads books", ["session:s3"], "shared") + + wrapper, _ = _mock_llm_one_obs_per_fact() + original_llm = memory._consolidation_llm_config + memory._consolidation_llm_config = wrapper + try: + with ( + _override_config(memory, consolidation_llm_parallelism=3, consolidation_llm_batch_size=1), + patch.object(memory, "submit_async_consolidation"), + ): + result = await run_consolidation_job( + memory_engine=memory, bank_id=bank_id, request_context=request_context + ) + finally: + memory._consolidation_llm_config = original_llm + + assert result["status"] == "completed" + tag_sets = await _fetch_observation_tag_sets(memory, bank_id) + # Every observation lands at the untagged scope — none carries a session tag. + assert tag_sets and all(t == frozenset() for t in tag_sets), tag_sets finally: await memory.delete_bank(bank_id, request_context=request_context) @@ -373,8 +408,7 @@ async def tracked_find(*, memory_engine, bank_id, query, request_context, tags=N # The whole point: lock invariant per scope. for scope, peak in max_concurrent.items(): assert peak <= 1, ( - f"scope {set(scope) or ''} had {peak} concurrent in-flight recalls; " - "lock invariant violated" + f"scope {set(scope) or ''} had {peak} concurrent in-flight recalls; lock invariant violated" ) # Sanity: we DID see recalls for the shared scope, so the test wasn't trivial. assert frozenset({"a"}) in max_concurrent @@ -421,9 +455,7 @@ async def test_per_batch_log_line_attributes_only_own_work(memory: MemoryEngine, patch.object(memory, "submit_async_consolidation"), caplog.at_level(logging.INFO, logger="hindsight_api.engine.consolidation.consolidator"), ): - await run_consolidation_job( - memory_engine=memory, bank_id=bank_id, request_context=request_context - ) + await run_consolidation_job(memory_engine=memory, bank_id=bank_id, request_context=request_context) finally: memory._consolidation_llm_config = original_llm @@ -452,9 +484,7 @@ async def test_per_batch_log_line_attributes_only_own_work(memory: MemoryEngine, assert processed_values == sorted(processed_values), ( f"processed counter must be monotonic, got {processed_values}" ) - assert max(processed_values) == 3, ( - f"final cumulative processed should be 3, got {max(processed_values)}" - ) + assert max(processed_values) == 3, f"final cumulative processed should be 3, got {max(processed_values)}" assert set(processed_values) == {1, 2, 3}, ( f"each batch should bump the counter by exactly 1, got {processed_values}" ) @@ -466,9 +496,7 @@ async def test_per_batch_log_line_attributes_only_own_work(memory: MemoryEngine, assert m_llm_time, f"expected llm=Xs timing, got: {line}" # Sanity: a single mock-LLM call is fast — under a second easily. # If snapshot leaked, this would catch concurrent batches' LLM time too. - assert float(m_llm_time.group(1)) < 5.0, ( - f"llm timing implausibly large for a single mock-LLM call: {line}" - ) + assert float(m_llm_time.group(1)) < 5.0, f"llm timing implausibly large for a single mock-LLM call: {line}" finally: await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api-slim/tests/test_consolidation_silent_requeue_failure.py b/hindsight-api-slim/tests/test_consolidation_silent_requeue_failure.py index 0a0b8b149a..e173d93cf7 100644 --- a/hindsight-api-slim/tests/test_consolidation_silent_requeue_failure.py +++ b/hindsight-api-slim/tests/test_consolidation_silent_requeue_failure.py @@ -71,9 +71,7 @@ async def _count_unconsolidated(memory, bank_id: str) -> int: @pytest.mark.asyncio -async def test_requeue_failure_propagates_to_worker_retry( - memory: MemoryEngine, request_context -): +async def test_requeue_failure_propagates_to_worker_retry(memory: MemoryEngine, request_context): """When the in-task ``submit_async_consolidation`` call raises, the op must NOT be silently completed. The consolidator's work for this round is durably committed (memories marked consolidated_at in their own @@ -183,8 +181,6 @@ async def failing_submit(*args, **kwargs): f"unconsolidated_remaining={unconsolidated_after}" ) - assert call_count["n"] == 1, ( - f"only one in-task submit_async_consolidation call expected, got {call_count['n']}" - ) + assert call_count["n"] == 1, f"only one in-task submit_async_consolidation call expected, got {call_count['n']}" await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api-slim/tests/test_consolidation_submit_atomic_dedup.py b/hindsight-api-slim/tests/test_consolidation_submit_atomic_dedup.py index 9cf6e8745f..e08c7149f0 100644 --- a/hindsight-api-slim/tests/test_consolidation_submit_atomic_dedup.py +++ b/hindsight-api-slim/tests/test_consolidation_submit_atomic_dedup.py @@ -63,10 +63,7 @@ async def test_concurrent_submits_leave_one_pending(memory, request_context, no_ await _ensure_bank(pool, bank_id) try: results = await asyncio.gather( - *( - memory.submit_async_consolidation(bank_id=bank_id, request_context=request_context) - for _ in range(5) - ) + *(memory.submit_async_consolidation(bank_id=bank_id, request_context=request_context) for _ in range(5)) ) assert await _count_pending(pool, bank_id) == 1 op_ids = {r["operation_id"] for r in results} diff --git a/hindsight-api-slim/tests/test_consolidation_write_scopes.py b/hindsight-api-slim/tests/test_consolidation_write_scopes.py index f47c8d68ca..4fc6b6ef8c 100644 --- a/hindsight-api-slim/tests/test_consolidation_write_scopes.py +++ b/hindsight-api-slim/tests/test_consolidation_write_scopes.py @@ -92,6 +92,18 @@ def test_empty_tags_collapses_to_untagged_scope(self): assert _resolve_write_scopes(memory) == [frozenset()] +class TestResolveWriteScopesShared: + def test_collapses_to_single_untagged_scope_regardless_of_tags(self): + # "shared" ignores the memory's own tags and writes to one global scope, + # so every memory deduplicates against the same observation. + memory = {"tags": ["alice", "session"], "observation_scopes": _as_json_string("shared")} + assert _resolve_write_scopes(memory) == [frozenset()] + + def test_empty_tags_also_untagged_scope(self): + memory = {"tags": [], "observation_scopes": _as_json_string("shared")} + assert _resolve_write_scopes(memory) == [frozenset()] + + class TestResolveWriteScopesExplicitList: def test_uses_declared_scopes_verbatim(self): memory = { @@ -166,6 +178,12 @@ def test_explicit_list_passthrough(self): memory = {"tags": ["a", "b"], "observation_scopes": json.dumps(spec)} assert _resolve_obs_tags_list(memory) == spec + def test_shared_returns_single_empty_scope(self): + # One pass over the empty (untagged) scope; the memory's own tags are + # ignored so cross-tag memories consolidate into one observation. + memory = {"tags": ["a", "b"], "observation_scopes": json.dumps("shared")} + assert _resolve_obs_tags_list(memory) == [[]] + # --------------------------------------------------------------------------- # Agreement between obs_tags_list (dispatch) and write_scopes (locks) @@ -185,6 +203,7 @@ class TestDispatchLockAgreement: {"tags": ["a", "b", "c"], "observation_scopes": json.dumps("combined")}, {"tags": ["a", "b"], "observation_scopes": json.dumps("per_tag")}, {"tags": ["a", "b", "c"], "observation_scopes": json.dumps("all_combinations")}, + {"tags": ["a", "b"], "observation_scopes": json.dumps("shared")}, {"tags": ["a", "b"], "observation_scopes": json.dumps([["a"], ["b"], ["a", "b"]])}, {"tags": ["a"], "observation_scopes": json.dumps([["a"], ["x"]])}, # Pre-parsed Python shape (defensive — covers callers that hand the diff --git a/hindsight-api-slim/tests/test_curation_http.py b/hindsight-api-slim/tests/test_curation_http.py new file mode 100644 index 0000000000..429d9ee76b --- /dev/null +++ b/hindsight-api-slim/tests/test_curation_http.py @@ -0,0 +1,103 @@ +"""HTTP integration tests for memory curation endpoints. + +Exercises the FastAPI PATCH /memories/{id} route end-to-end over an ASGI +transport, covering the happy path, validation, and not-found mapping. The +deeper cascade behaviour is covered at the engine level in +test_memory_curation.py. +""" + +import uuid + +import httpx +import pytest +import pytest_asyncio + +from hindsight_api import RequestContext +from hindsight_api.api import create_app +from hindsight_api.engine.memory_engine import MemoryEngine +from hindsight_api.engine.retain import embedding_processing + + +@pytest_asyncio.fixture +async def api_client(memory): + app = create_app(memory, initialize_memory=False) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +async def _insert_fact(memory: MemoryEngine, bank_id: str, text: str) -> str: + """Insert one world fact with a real embedding; returns its id.""" + await memory.get_bank_profile(bank_id=bank_id, request_context=RequestContext()) + emb = await embedding_processing.generate_embeddings_batch(memory.embeddings, [text]) + mem_id = uuid.uuid4() + pool = await memory._get_pool() + async with pool.acquire() as conn: + await conn.execute( + """ + INSERT INTO memory_units (id, bank_id, text, fact_type, embedding, event_date, created_at, updated_at, consolidated_at) + VALUES ($1, $2, $3, 'world', $4::vector, NOW(), NOW(), NOW(), NOW()) + """, + mem_id, + bank_id, + text, + str(emb[0]), + ) + return str(mem_id) + + +@pytest.mark.asyncio +async def test_patch_invalidate_and_revert_over_http(api_client, memory): + bank_id = f"curation-http-{uuid.uuid4().hex[:8]}" + mem_id = await _insert_fact(memory, bank_id, "srv-04 runs PostgreSQL 14.") + + # Invalidate via PATCH + resp = await api_client.patch( + f"/v1/default/banks/{bank_id}/memories/{mem_id}", + json={"state": "invalidated", "reason": "decommissioned"}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["state"] == "invalidated" + assert body["invalidation_reason"] == "decommissioned" + + # GET reflects the new state + resp = await api_client.get(f"/v1/default/banks/{bank_id}/memories/{mem_id}") + assert resp.status_code == 200 + assert resp.json()["state"] == "invalidated" + + # Revert via PATCH + resp = await api_client.patch( + f"/v1/default/banks/{bank_id}/memories/{mem_id}", + json={"state": "valid"}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["state"] == "valid" + assert resp.json()["invalidation_reason"] is None + + await memory.delete_bank(bank_id, request_context=RequestContext()) + + +@pytest.mark.asyncio +async def test_patch_not_found_returns_404(api_client, memory): + bank_id = f"curation-http-404-{uuid.uuid4().hex[:8]}" + await memory.get_bank_profile(bank_id=bank_id, request_context=RequestContext()) + resp = await api_client.patch( + f"/v1/default/banks/{bank_id}/memories/{uuid.uuid4()}", + json={"state": "invalidated"}, + ) + assert resp.status_code == 404 + await memory.delete_bank(bank_id, request_context=RequestContext()) + + +@pytest.mark.asyncio +async def test_patch_empty_body_is_rejected(api_client, memory): + bank_id = f"curation-http-422-{uuid.uuid4().hex[:8]}" + mem_id = await _insert_fact(memory, bank_id, "A fact.") + # Neither text nor state → request model validation rejects it. + resp = await api_client.patch( + f"/v1/default/banks/{bank_id}/memories/{mem_id}", + json={}, + ) + assert resp.status_code == 422 + await memory.delete_bank(bank_id, request_context=RequestContext()) diff --git a/hindsight-api-slim/tests/test_custom_embedding_dimension.py b/hindsight-api-slim/tests/test_custom_embedding_dimension.py index 9fd7837bc9..6ac6a54854 100644 --- a/hindsight-api-slim/tests/test_custom_embedding_dimension.py +++ b/hindsight-api-slim/tests/test_custom_embedding_dimension.py @@ -287,7 +287,9 @@ def test_dimension_change_blocked_with_data(self, dimension_test_schema): # Try to change dimension - should raise RuntimeError. # Retry on transient OID errors from concurrent xdist schema drops. _assert_raises_runtime_error_with_retry( - db_url, 768, schema, + db_url, + 768, + schema, expected_messages=["Cannot change embedding dimension", "1 rows with embeddings"], ) @@ -332,7 +334,9 @@ def test_mental_models_dimension_change_blocked_with_data(self, dimension_test_s # Try to change dimension - should raise RuntimeError. # Retry on transient OID errors from concurrent xdist schema drops. _assert_raises_runtime_error_with_retry( - db_url, 768, schema, + db_url, + 768, + schema, expected_messages=["Cannot change embedding dimension", "mental_models"], ) diff --git a/hindsight-api-slim/tests/test_db_abstraction.py b/hindsight-api-slim/tests/test_db_abstraction.py index 0bfa358b0c..9d49c63fac 100644 --- a/hindsight-api-slim/tests/test_db_abstraction.py +++ b/hindsight-api-slim/tests/test_db_abstraction.py @@ -193,18 +193,28 @@ def test_array_agg(self, d): def test_build_semantic_arm(self, d): arm = d.build_semantic_arm( - table="schema.memory_units", cols="id, text", fact_type="world", - embedding_param="$1", bank_id_param="$2", fetch_limit=100, + table="schema.memory_units", + cols="id, text", + fact_type="world", + embedding_param="$1", + bank_id_param="$2", + fetch_limit=100, + min_similarity=0.58, ) assert "1 - (embedding <=> $1::vector)" in arm + assert ">= 0.58" in arm assert "fact_type = 'world'" in arm assert "LIMIT 100" in arm assert "'semantic' AS source" in arm def test_build_bm25_arm_native(self, d): arm = d.build_bm25_arm( - table="schema.memory_units", cols="id, text", fact_type="world", - bank_id_param="$2", limit_param="$3", text_param="$4", + table="schema.memory_units", + cols="id, text", + fact_type="world", + bank_id_param="$2", + limit_param="$3", + text_param="$4", ) assert "ts_rank_cd" in arm assert "to_tsquery" in arm @@ -215,8 +225,12 @@ def test_build_bm25_arm_native(self, d): def test_build_bm25_arm_native_uses_configured_language(self, d): arm = d.build_bm25_arm( - table="schema.memory_units", cols="id, text", fact_type="world", - bank_id_param="$2", limit_param="$3", text_param="$4", + table="schema.memory_units", + cols="id, text", + fact_type="world", + bank_id_param="$2", + limit_param="$3", + text_param="$4", bm25_language="french", ) # Both the score and the WHERE filter must use the configured dictionary @@ -225,8 +239,12 @@ def test_build_bm25_arm_native_uses_configured_language(self, d): def test_build_bm25_arm_vchord(self, d): arm = d.build_bm25_arm( - table="t", cols="id", fact_type="world", - bank_id_param="$2", limit_param="$3", text_param="$4", + table="t", + cols="id", + fact_type="world", + bank_id_param="$2", + limit_param="$3", + text_param="$4", text_search_extension="vchord", ) assert "to_bm25query" in arm @@ -239,16 +257,26 @@ def test_build_bm25_arm_vchord_gates_zero_score_by_default(self, d): rows with a genuine query-term match, mirroring native tsvector's `@@`. """ arm = d.build_bm25_arm( - table="t", cols="id", fact_type="world", - bank_id_param="$2", limit_param="$3", text_param="$4", + table="t", + cols="id", + fact_type="world", + bank_id_param="$2", + limit_param="$3", + text_param="$4", text_search_extension="vchord", ) - assert "-(search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize($4, 'llmlingua2'))) > 0" in arm + assert ( + "-(search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize($4, 'llmlingua2'))) > 0" in arm + ) def test_build_bm25_arm_vchord_honors_custom_min_score(self, d): arm = d.build_bm25_arm( - table="t", cols="id", fact_type="world", - bank_id_param="$2", limit_param="$3", text_param="$4", + table="t", + cols="id", + fact_type="world", + bank_id_param="$2", + limit_param="$3", + text_param="$4", text_search_extension="vchord", bm25_min_score=2.5, ) @@ -256,8 +284,12 @@ def test_build_bm25_arm_vchord_honors_custom_min_score(self, d): def test_build_bm25_arm_pgroonga(self, d): arm = d.build_bm25_arm( - table="schema.memory_units", cols="id, text", fact_type="world", - bank_id_param="$2", limit_param="$3", text_param="$4", + table="schema.memory_units", + cols="id, text", + fact_type="world", + bank_id_param="$2", + limit_param="$3", + text_param="$4", text_search_extension="pgroonga", ) # pgroonga uses the &@~ operator + pgroonga_score for ranking. Escape @@ -271,8 +303,12 @@ def test_build_bm25_arm_pgroonga(self, d): def test_build_bm25_arm_pgroonga_ignores_bm25_language(self, d): """pgroonga's tokenizer is fixed at index creation; bm25_language must not leak in.""" arm = d.build_bm25_arm( - table="t", cols="id", fact_type="world", - bank_id_param="$2", limit_param="$3", text_param="$4", + table="t", + cols="id", + fact_type="world", + bank_id_param="$2", + limit_param="$3", + text_param="$4", text_search_extension="pgroonga", bm25_language="french", ) @@ -280,8 +316,12 @@ def test_build_bm25_arm_pgroonga_ignores_bm25_language(self, d): def test_build_bm25_arm_pg_search(self, d): arm = d.build_bm25_arm( - table="schema.memory_units", cols="id, text", fact_type="world", - bank_id_param="$2", limit_param="$3", text_param="$4", + table="schema.memory_units", + cols="id, text", + fact_type="world", + bank_id_param="$2", + limit_param="$3", + text_param="$4", text_search_extension="pg_search", ) assert "paradedb.score(id)" in arm @@ -359,18 +399,28 @@ def test_current_timestamp(self, d): def test_build_semantic_arm(self, d): arm = d.build_semantic_arm( - table="memory_units", cols="id, text", fact_type="world", - embedding_param=":1", bank_id_param=":2", fetch_limit=100, + table="memory_units", + cols="id, text", + fact_type="world", + embedding_param=":1", + bank_id_param=":2", + fetch_limit=100, + min_similarity=0.58, ) assert "VECTOR_DISTANCE" in arm + assert ">= 0.58" in arm assert "fact_type = 'world'" in arm assert "FETCH FIRST 100 ROWS ONLY" in arm assert "'semantic' AS source" in arm def test_build_bm25_arm(self, d): arm = d.build_bm25_arm( - table="memory_units", cols="id, text", fact_type="world", - bank_id_param=":2", limit_param=":3", text_param=":4", + table="memory_units", + cols="id, text", + fact_type="world", + bank_id_param=":2", + limit_param=":3", + text_param=":4", arm_index=0, ) assert "CONTAINS" in arm @@ -381,12 +431,22 @@ def test_build_bm25_arm(self, d): def test_build_bm25_arm_unique_labels(self, d): """Each arm_index produces a unique SCORE label to avoid conflicts in UNION ALL.""" arm0 = d.build_bm25_arm( - table="t", cols="id", fact_type="world", - bank_id_param=":2", limit_param=":3", text_param=":4", arm_index=0, + table="t", + cols="id", + fact_type="world", + bank_id_param=":2", + limit_param=":3", + text_param=":4", + arm_index=0, ) arm1 = d.build_bm25_arm( - table="t", cols="id", fact_type="experience", - bank_id_param=":2", limit_param=":3", text_param=":4", arm_index=1, + table="t", + cols="id", + fact_type="experience", + bank_id_param=":2", + limit_param=":3", + text_param=":4", + arm_index=1, ) assert "SCORE(10)" in arm0 assert "SCORE(11)" in arm1 @@ -472,9 +532,7 @@ def test_jsonb_boolean_rewrite(self): """Verify JSONB ->> boolean comparison is rewritten to JSON_VALUE.""" from hindsight_api.engine.db.oracle import _rewrite_pg_to_oracle - query, _, _ = _rewrite_pg_to_oracle( - "WHERE (trigger->>'refresh_after_consolidation')::boolean = true" - ) + query, _, _ = _rewrite_pg_to_oracle("WHERE (trigger->>'refresh_after_consolidation')::boolean = true") assert "JSON_VALUE" in query assert "'true'" in query assert "->>" not in query @@ -491,9 +549,7 @@ def test_jsonb_arrow_text_quoted(self): """Verify ->> works with quoted column names.""" from hindsight_api.engine.db.oracle import _rewrite_pg_to_oracle - query, _, _ = _rewrite_pg_to_oracle( - "ORDER BY (result_metadata->>'sub_batch_index')::int" - ) + query, _, _ = _rewrite_pg_to_oracle("ORDER BY (result_metadata->>'sub_batch_index')::int") assert "JSON_VALUE" in query assert "->>" not in query @@ -679,9 +735,7 @@ async def test_sql_column_count_matches_values(self, ops, mock_conn): @pytest.mark.asyncio async def test_tags_json_decoded_to_list(self, ops, mock_conn): """Tags JSON strings must be decoded to Python lists, not passed as strings.""" - await ops.insert_facts_batch( - conn=mock_conn, **{**self._make_batch(1), "tags_list": ['["tag1", "tag2"]']} - ) + await ops.insert_facts_batch(conn=mock_conn, **{**self._make_batch(1), "tags_list": ['["tag1", "tag2"]']}) _, rows_data = mock_conn.executemany.call_args.args assert rows_data[0][13] == ["tag1", "tag2"] assert isinstance(rows_data[0][13], list) @@ -689,9 +743,7 @@ async def test_tags_json_decoded_to_list(self, ops, mock_conn): @pytest.mark.asyncio async def test_empty_tags_becomes_empty_list(self, ops, mock_conn): """Empty/falsy tags string must become [], not crash or pass empty string.""" - await ops.insert_facts_batch( - conn=mock_conn, **{**self._make_batch(1), "tags_list": [""]} - ) + await ops.insert_facts_batch(conn=mock_conn, **{**self._make_batch(1), "tags_list": [""]}) _, rows_data = mock_conn.executemany.call_args.args assert rows_data[0][13] == [] diff --git a/hindsight-api-slim/tests/test_db_url.py b/hindsight-api-slim/tests/test_db_url.py index 8ff9dcd588..5e062fb4cb 100644 --- a/hindsight-api-slim/tests/test_db_url.py +++ b/hindsight-api-slim/tests/test_db_url.py @@ -36,16 +36,10 @@ def test_unchanged(self, url: str) -> None: class TestSchemeNormalization: def test_asyncpg_scheme_stripped(self) -> None: - assert ( - to_libpq_url("postgresql+asyncpg://user:pass@host:5432/db") - == "postgresql://user:pass@host:5432/db" - ) + assert to_libpq_url("postgresql+asyncpg://user:pass@host:5432/db") == "postgresql://user:pass@host:5432/db" def test_postgres_asyncpg_scheme_normalized(self) -> None: - assert ( - to_libpq_url("postgres+asyncpg://user:pass@host/db") - == "postgresql://user:pass@host/db" - ) + assert to_libpq_url("postgres+asyncpg://user:pass@host/db") == "postgresql://user:pass@host/db" def test_bare_postgres_scheme_normalized_to_postgresql(self) -> None: assert to_libpq_url("postgres://user:pass@host/db") == "postgresql://user:pass@host/db" @@ -68,9 +62,7 @@ def test_ssl_rename_on_libpq_url(self) -> None: assert to_libpq_url("postgresql://h/d?ssl=require") == "postgresql://h/d?sslmode=require" def test_ssl_param_preserved_among_other_params(self) -> None: - result = to_libpq_url( - "postgresql+asyncpg://h/d?ssl=require&application_name=hindsight&connect_timeout=10" - ) + result = to_libpq_url("postgresql+asyncpg://h/d?ssl=require&application_name=hindsight&connect_timeout=10") assert result.startswith("postgresql://h/d?") # Query order should be preserved; ssl renamed, others untouched. assert "sslmode=require" in result @@ -80,10 +72,7 @@ def test_ssl_param_preserved_among_other_params(self) -> None: def test_sslmode_not_double_renamed(self) -> None: """An already-correct sslmode= param must not be altered.""" - assert ( - to_libpq_url("postgresql+asyncpg://h/d?sslmode=require") - == "postgresql://h/d?sslmode=require" - ) + assert to_libpq_url("postgresql+asyncpg://h/d?sslmode=require") == "postgresql://h/d?sslmode=require" class TestProductionConfigs: @@ -132,10 +121,7 @@ def test_password_literal_asyncpg_in_password(self) -> None: assert result == "postgresql://user:my%2Basyncpgpass@host/db" def test_url_without_query_string(self) -> None: - assert ( - to_libpq_url("postgresql+asyncpg://user:pass@host/db") - == "postgresql://user:pass@host/db" - ) + assert to_libpq_url("postgresql+asyncpg://user:pass@host/db") == "postgresql://user:pass@host/db" def test_url_with_port_and_path_only(self) -> None: assert to_libpq_url("postgresql+asyncpg://host:5432/db") == "postgresql://host:5432/db" diff --git a/hindsight-api-slim/tests/test_delta_editorial_fusion.py b/hindsight-api-slim/tests/test_delta_editorial_fusion.py index c2cd84033f..8f61c12e74 100644 --- a/hindsight-api-slim/tests/test_delta_editorial_fusion.py +++ b/hindsight-api-slim/tests/test_delta_editorial_fusion.py @@ -126,22 +126,30 @@ async def test_delta_fuses_seo_and_brand_voice( # Phase 1: Ingest SEO best practices await memory.retain_async( - bank_id=bank_id, content=SEO_BEST_PRACTICES, - document_id="seo-best-practices", request_context=request_context, + bank_id=bank_id, + content=SEO_BEST_PRACTICES, + document_id="seo-best-practices", + request_context=request_context, ) mm_after_seo = await memory.refresh_mental_model( - bank_id=bank_id, mental_model_id=mm_id, request_context=request_context, + bank_id=bank_id, + mental_model_id=mm_id, + request_context=request_context, ) seo_content = mm_after_seo["content"] assert len(seo_content) > 100, f"First refresh produced too little content: {len(seo_content)} chars" # Phase 2: Ingest brand voice -> delta refresh await memory.retain_async( - bank_id=bank_id, content=BRAND_VOICE, - document_id="brand-voice", request_context=request_context, + bank_id=bank_id, + content=BRAND_VOICE, + document_id="brand-voice", + request_context=request_context, ) mm_after_brand = await memory.refresh_mental_model( - bank_id=bank_id, mental_model_id=mm_id, request_context=request_context, + bank_id=bank_id, + mental_model_id=mm_id, + request_context=request_context, ) fused = mm_after_brand["content"] rr = mm_after_brand.get("reflect_response") or {} @@ -156,8 +164,7 @@ async def test_delta_fuses_seo_and_brand_voice( "vocabulary rules": ["jargon", "leverage", "empower", "forbidden"], }.items(): assert any(s in fused_lower for s in signals), ( - f"Brand voice concept '{concept}' missing (looked for {signals}).\n" - f"Fused content:\n{fused[:500]}" + f"Brand voice concept '{concept}' missing (looked for {signals}).\nFused content:\n{fused[:500]}" ) # SEO concepts still present (not wiped by delta) @@ -167,8 +174,7 @@ async def test_delta_fuses_seo_and_brand_voice( "seo": ["meta", "e-e-a-t", "seo", "search"], }.items(): assert any(s in fused_lower for s in signals), ( - f"SEO concept '{concept}' missing (looked for {signals}).\n" - f"Fused content:\n{fused[:500]}" + f"SEO concept '{concept}' missing (looked for {signals}).\nFused content:\n{fused[:500]}" ) # Brand voice overrides generic tone @@ -177,15 +183,9 @@ async def test_delta_fuses_seo_and_brand_voice( ) # No duplicate paragraphs - lines = [ - ln.strip() for ln in fused.split("\n") - if ln.strip() and not ln.strip().startswith("#") - ] + lines = [ln.strip() for ln in fused.split("\n") if ln.strip() and not ln.strip().startswith("#")] dupes = {line: cnt for line, cnt in Counter(lines).items() if cnt > 1} - assert not dupes, ( - "Duplicate paragraphs:\n" + - "\n".join(f" [{c}x] {t[:80]}" for t, c in dupes.items()) - ) + assert not dupes, "Duplicate paragraphs:\n" + "\n".join(f" [{c}x] {t[:80]}" for t, c in dupes.items()) # based_on accumulates from both docs obs_count = len(rr.get("based_on", {}).get("observation", [])) diff --git a/hindsight-api-slim/tests/test_delta_operation_parse.py b/hindsight-api-slim/tests/test_delta_operation_parse.py new file mode 100644 index 0000000000..1dffc7a509 --- /dev/null +++ b/hindsight-api-slim/tests/test_delta_operation_parse.py @@ -0,0 +1,110 @@ +"""Tests for structured-delta LLM JSON parsing.""" + +from __future__ import annotations + +import pytest + +from hindsight_api.engine.reflect.delta_ops import ( + AppendBlockOp, + DeltaAllOpsInvalidError, + DeltaOperationList, + parse_delta_operation_list, +) +from hindsight_api.engine.reflect.structured_doc import BulletListBlock + + +def test_parse_delta_operation_list_trailing_brackets(): + """glm-style output with extra ]} after the root object.""" + raw = ( + '{"operations":[{"op":"append_block","section_id":"members",' + '"block":{"type":"bullet_list","items":["knip ignore react-dom"]}}]}]}' + ) + op_list = parse_delta_operation_list(raw) + assert len(op_list.operations) == 1 + assert isinstance(op_list.operations[0], AppendBlockOp) + + +def test_parse_delta_operation_list_backticks_in_path(): + raw = ( + '{"operations":[{"op":"append_block","section_id":"conventions",' + '"block":{"type":"bullet_list","items":["hindsight-control-plane/knip.json"]}}]}' + ) + op_list = parse_delta_operation_list(raw) + assert len(op_list.operations) == 1 + op = op_list.operations[0] + assert op.section_id == "conventions" + assert op.block.items == ["hindsight-control-plane/knip.json"] + + +def test_parse_delta_operation_list_prose_prefix(): + raw = ( + 'Here is the update:\n{"operations": [{"op": "append_block", ' + '"section_id": "x", "block": {"type": "paragraph", "text": "ok"}}]}' + "\nDone." + ) + op_list = parse_delta_operation_list(raw) + assert len(op_list.operations) == 1 + + +def test_parse_delta_operation_list_skips_invalid_op_keeps_valid(): + """One bad replace_block (missing index) must not discard the whole batch.""" + raw = ( + '{"operations": [' + '{"op": "append_block", "section_id": "s", ' + '"block": {"type": "paragraph", "text": "ok"}}, ' + '{"op": "replace_block", "section_id": "s", ' + '"block": {"type": "paragraph", "text": "missing index"}}, ' + '{"op": "append_block", "section_id": "s", ' + '"block": {"type": "paragraph", "text": "also ok"}}' + "]}" + ) + op_list = parse_delta_operation_list(raw) + assert len(op_list.operations) == 2 + assert all(isinstance(o, AppendBlockOp) for o in op_list.operations) + + +def test_parse_delta_operation_list_empty(): + assert parse_delta_operation_list("").operations == [] + + +def test_parse_delta_operation_list_empty_operations_is_noop(): + """A genuine empty operations array is a valid no-op, not an error.""" + assert parse_delta_operation_list('{"operations": []}').operations == [] + assert parse_delta_operation_list({"operations": []}).operations == [] + + +def test_parse_delta_operation_list_all_invalid_raises(): + """If the model emits ops but every one is malformed, raise so the caller + falls back to a full rewrite instead of applying zero ops — which would + silently drop this refresh's new facts.""" + raw = ( + '{"operations": [' + '{"op": "replace_block", "section_id": "s", ' + '"block": {"type": "paragraph", "text": "missing index a"}}, ' + '{"op": "replace_block", "section_id": "s", ' + '"block": {"type": "paragraph", "text": "missing index b"}}' + "]}" + ) + with pytest.raises(DeltaAllOpsInvalidError): + parse_delta_operation_list(raw) + # Same payload shape as a dict must behave identically. + with pytest.raises(DeltaAllOpsInvalidError): + parse_delta_operation_list( + { + "operations": [ + {"op": "replace_block", "section_id": "s", "block": {"type": "paragraph", "text": "no index"}}, + ] + } + ) + + +def test_parse_delta_operation_list_pydantic_instance(): + original = DeltaOperationList( + operations=[ + AppendBlockOp( + section_id="s", + block=BulletListBlock(items=["a"]), + ) + ] + ) + assert parse_delta_operation_list(original) is original diff --git a/hindsight-api-slim/tests/test_delta_retain.py b/hindsight-api-slim/tests/test_delta_retain.py index 8878fb5607..2e626e9892 100644 --- a/hindsight-api-slim/tests/test_delta_retain.py +++ b/hindsight-api-slim/tests/test_delta_retain.py @@ -146,7 +146,10 @@ async def test_delta_retain_appended_content(memory, request_context): # Second version — original content + new content appended # This should preserve facts from the first chunk and add new ones - v2_content = v1_content + "\n\nBob joined Google as a product manager in 2024. He previously worked at Meta on AR/VR products." + v2_content = ( + v1_content + + "\n\nBob joined Google as a product manager in 2024. He previously worked at Meta on AR/VR products." + ) v2_units = await memory.retain_async( bank_id=bank_id, @@ -387,9 +390,7 @@ async def test_delta_retain_links_preserved_for_unchanged_chunks(memory, request document_id, ) - assert v2_link_count == v1_link_count, ( - f"Links should be preserved: v1={v1_link_count}, v2={v2_link_count}" - ) + assert v2_link_count == v1_link_count, f"Links should be preserved: v1={v1_link_count}, v2={v2_link_count}" finally: await memory.delete_bank(bank_id, request_context=request_context) @@ -456,11 +457,13 @@ async def test_delta_retain_tags_propagated_to_existing_units(memory, request_co # v1 with tag "team-a" await memory.retain_batch_async( bank_id=bank_id, - contents=[{ - "content": content, - "document_id": document_id, - "tags": ["team-a"], - }], + contents=[ + { + "content": content, + "document_id": document_id, + "tags": ["team-a"], + } + ], request_context=request_context, ) @@ -476,11 +479,13 @@ async def test_delta_retain_tags_propagated_to_existing_units(memory, request_co # v2 with same content but different tags await memory.retain_batch_async( bank_id=bank_id, - contents=[{ - "content": content, - "document_id": document_id, - "tags": ["team-b", "important"], - }], + contents=[ + { + "content": content, + "document_id": document_id, + "tags": ["team-b", "important"], + } + ], request_context=request_context, ) @@ -692,7 +697,9 @@ async def test_delta_retain_empty_to_content(memory, request_context): doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context) assert doc_v2 is not None - assert doc_v2["memory_unit_count"] > 0 or len(v2_units) > 0, "Should have facts after updating with real content" + assert doc_v2["memory_unit_count"] > 0 or len(v2_units) > 0, ( + "Should have facts after updating with real content" + ) finally: await memory.delete_bank(bank_id, request_context=request_context) @@ -775,11 +782,13 @@ async def test_delta_retain_with_user_entities(memory, request_context): # v1 with user entities await memory.retain_batch_async( bank_id=bank_id, - contents=[{ - "content": content, - "document_id": document_id, - "entities": [{"text": "Project Alpha", "type": "PROJECT"}], - }], + contents=[ + { + "content": content, + "document_id": document_id, + "entities": [{"text": "Project Alpha", "type": "PROJECT"}], + } + ], request_context=request_context, ) @@ -797,14 +806,16 @@ async def test_delta_retain_with_user_entities(memory, request_context): v2_content = content + "\n\nThe timeline is on track for Q2 delivery." await memory.retain_batch_async( bank_id=bank_id, - contents=[{ - "content": v2_content, - "document_id": document_id, - "entities": [ - {"text": "Project Alpha", "type": "PROJECT"}, - {"text": "Q2 Deadline", "type": "MILESTONE"}, - ], - }], + contents=[ + { + "content": v2_content, + "document_id": document_id, + "entities": [ + {"text": "Project Alpha", "type": "PROJECT"}, + {"text": "Q2 Deadline", "type": "MILESTONE"}, + ], + } + ], request_context=request_context, ) @@ -867,9 +878,7 @@ async def test_delta_retain_recall_with_chunks(memory, request_context): facts_with_chunks = [r for r in result.results if r.chunk_id] if facts_with_chunks and result.chunks: for fact in facts_with_chunks: - assert fact.chunk_id in result.chunks, ( - f"Chunk {fact.chunk_id} should be in returned chunks" - ) + assert fact.chunk_id in result.chunks, f"Chunk {fact.chunk_id} should be in returned chunks" finally: await memory.delete_bank(bank_id, request_context=request_context) @@ -1024,8 +1033,7 @@ async def test_processed_content_tokens_appended_reports_delta(memory, request_c return assert second > 0, "Partial-delta retain should report a positive token count" assert second < submitted_tokens, ( - "Partial-delta retain should report fewer processed tokens " - "than the full submitted payload" + "Partial-delta retain should report fewer processed tokens than the full submitted payload" ) finally: memory._operation_validator = None diff --git a/hindsight-api-slim/tests/test_delta_retain_duplicates.py b/hindsight-api-slim/tests/test_delta_retain_duplicates.py index 039d789e92..7a99f6f9c6 100644 --- a/hindsight-api-slim/tests/test_delta_retain_duplicates.py +++ b/hindsight-api-slim/tests/test_delta_retain_duplicates.py @@ -143,9 +143,7 @@ async def test_delta_detects_unchanged_after_first_retain(memory, request_contex bank_id, document_id, ) - assert v2_count == v1_count, ( - f"Memory unit count changed on same-content upsert: {v1_count} -> {v2_count}" - ) + assert v2_count == v1_count, f"Memory unit count changed on same-content upsert: {v1_count} -> {v2_count}" # Third retain — verify stability v3_units = await memory.retain_async( @@ -163,9 +161,7 @@ async def test_delta_detects_unchanged_after_first_retain(memory, request_contex bank_id, document_id, ) - assert v3_count == v1_count, ( - f"Memory unit count changed on third upsert: {v1_count} -> {v3_count}" - ) + assert v3_count == v1_count, f"Memory unit count changed on third upsert: {v1_count} -> {v3_count}" finally: await memory.delete_bank(bank_id, request_context=request_context) @@ -371,9 +367,7 @@ async def _retain_version(version_content: str) -> None: # splitter may cut mid-text, so later chunks might not start with the prefix. winning_person = f"Person_{winning_version}" wrong_version_units = [ - (r["text"], r["chunk_id"], r["unit_id"]) - for r in units - if winning_person not in r["text"] + (r["text"], r["chunk_id"], r["unit_id"]) for r in units if winning_person not in r["text"] ] assert not wrong_version_units, ( f"Found {len(wrong_version_units)} memory units NOT from winning version " @@ -397,8 +391,7 @@ async def _retain_version(version_content: str) -> None: ) logger.info( - f"Concurrent test passed: version {winning_version} won with " - f"{len(unit_texts)} memory units, no duplicates" + f"Concurrent test passed: version {winning_version} won with {len(unit_texts)} memory units, no duplicates" ) finally: diff --git a/hindsight-api-slim/tests/test_document_chunks_and_reprocess.py b/hindsight-api-slim/tests/test_document_chunks_and_reprocess.py index 9c2cc46125..cf4bfe667f 100644 --- a/hindsight-api-slim/tests/test_document_chunks_and_reprocess.py +++ b/hindsight-api-slim/tests/test_document_chunks_and_reprocess.py @@ -1,6 +1,7 @@ """ Tests for document chunks API, reprocess, nodes_by_fact_type, and graph document/chunk filtering. """ + from datetime import datetime, timezone import httpx @@ -223,9 +224,7 @@ async def test_graph_chunk_id_filter(api_client, bank_id): await _retain(api_client, bank_id, "doc-chunk-test", "Alice works at Google. " * 20) # First get chunks to find a valid chunk_id - response = await api_client.get( - f"/v1/default/banks/{bank_id}/documents/doc-chunk-test/chunks" - ) + response = await api_client.get(f"/v1/default/banks/{bank_id}/documents/doc-chunk-test/chunks") assert response.status_code == 200 chunks_data = response.json() if chunks_data["total"] == 0: @@ -251,11 +250,14 @@ async def test_graph_chunk_id_filter(api_client, bank_id): @pytest.mark.asyncio async def test_http_list_document_chunks(api_client, bank_id): """HTTP GET .../documents/{id}/chunks returns chunks.""" - await _retain(api_client, bank_id, "doc-http-chunks", "Alice works at Google on AI research. Bob works at Meta on VR systems. " * 20) - - response = await api_client.get( - f"/v1/default/banks/{bank_id}/documents/doc-http-chunks/chunks" + await _retain( + api_client, + bank_id, + "doc-http-chunks", + "Alice works at Google on AI research. Bob works at Meta on VR systems. " * 20, ) + + response = await api_client.get(f"/v1/default/banks/{bank_id}/documents/doc-http-chunks/chunks") assert response.status_code == 200 data = response.json() assert "items" in data @@ -266,9 +268,7 @@ async def test_http_list_document_chunks(api_client, bank_id): @pytest.mark.asyncio async def test_http_list_document_chunks_not_found(api_client, bank_id): """HTTP GET .../documents/{id}/chunks returns 404 for non-existent document.""" - response = await api_client.get( - f"/v1/default/banks/{bank_id}/documents/nonexistent/chunks" - ) + response = await api_client.get(f"/v1/default/banks/{bank_id}/documents/nonexistent/chunks") assert response.status_code == 404 @@ -277,9 +277,7 @@ async def test_http_reprocess_document(api_client, bank_id): """HTTP POST .../documents/{id}/reprocess returns success with operation_id.""" await _retain(api_client, bank_id, "doc-http-reprocess", "Alice works at Google.") - response = await api_client.post( - f"/v1/default/banks/{bank_id}/documents/doc-http-reprocess/reprocess" - ) + response = await api_client.post(f"/v1/default/banks/{bank_id}/documents/doc-http-reprocess/reprocess") assert response.status_code == 200 data = response.json() assert data["success"] is True @@ -289,9 +287,7 @@ async def test_http_reprocess_document(api_client, bank_id): @pytest.mark.asyncio async def test_http_reprocess_document_not_found(api_client, bank_id): """HTTP POST .../documents/{id}/reprocess returns 404 for non-existent document.""" - response = await api_client.post( - f"/v1/default/banks/{bank_id}/documents/nonexistent/reprocess" - ) + response = await api_client.post(f"/v1/default/banks/{bank_id}/documents/nonexistent/reprocess") assert response.status_code == 404 @@ -300,9 +296,7 @@ async def test_http_get_document_includes_nodes_by_fact_type(api_client, bank_id """HTTP GET .../documents/{id} includes nodes_by_fact_type.""" await _retain(api_client, bank_id, "doc-http-comp", "Alice works at Google on AI research.") - response = await api_client.get( - f"/v1/default/banks/{bank_id}/documents/doc-http-comp" - ) + response = await api_client.get(f"/v1/default/banks/{bank_id}/documents/doc-http-comp") assert response.status_code == 200 data = response.json() assert "nodes_by_fact_type" in data diff --git a/hindsight-api-slim/tests/test_document_tracking.py b/hindsight-api-slim/tests/test_document_tracking.py index 047749c787..bff9d7b658 100644 --- a/hindsight-api-slim/tests/test_document_tracking.py +++ b/hindsight-api-slim/tests/test_document_tracking.py @@ -1,6 +1,7 @@ """ Tests for document tracking and upsert functionality. """ + import logging from datetime import datetime, timezone from unittest.mock import patch @@ -259,6 +260,58 @@ async def test_document_without_metadata(memory, request_context): await memory.delete_bank(bank_id, request_context=request_context) +@pytest.mark.asyncio +async def test_document_observation_scopes_from_retain_params(memory, request_context): + """observation_scopes passed at retain time is captured into retain_params and surfaced by get_document.""" + bank_id = f"test_doc_obs_scopes_{datetime.now(timezone.utc).timestamp()}" + + try: + document_id = "doc-with-scopes" + await memory.retain_batch_async( + bank_id=bank_id, + contents=[ + { + "content": "Alice and Bob are friends.", + "tags": ["alice", "bob"], + "observation_scopes": "all_combinations", + } + ], + document_id=document_id, + request_context=request_context, + ) + + doc = await memory.get_document(document_id, bank_id, request_context=request_context) + assert doc is not None + # Surfaced as a top-level field and persisted in retain_params. + assert doc["observation_scopes"] == "all_combinations" + assert doc["retain_params"]["observation_scopes"] == "all_combinations" + + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +@pytest.mark.asyncio +async def test_document_observation_scopes_none_when_unset(memory, request_context): + """get_document returns observation_scopes None when none was configured at retain time.""" + bank_id = f"test_doc_no_scopes_{datetime.now(timezone.utc).timestamp()}" + + try: + document_id = "doc-no-scopes" + await memory.retain_async( + bank_id=bank_id, + content="Bob works at Microsoft.", + document_id=document_id, + request_context=request_context, + ) + + doc = await memory.get_document(document_id, bank_id, request_context=request_context) + assert doc is not None + assert doc["observation_scopes"] is None + + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio @pytest.mark.hs_llm_core async def test_document_persisted_with_zero_facts(memory_real_llm, request_context): @@ -411,9 +464,7 @@ async def test_document_persisted_with_zero_facts_async_submit(memory_real_llm, elapsed += wait_interval # Check if document exists - doc = await memory.get_document( - "doc-async-zero-facts", bank_id, request_context=request_context - ) + doc = await memory.get_document("doc-async-zero-facts", bank_id, request_context=request_context) if doc is not None: break diff --git a/hindsight-api-slim/tests/test_document_transfer.py b/hindsight-api-slim/tests/test_document_transfer.py index e1273a46d2..abda8a1909 100644 --- a/hindsight-api-slim/tests/test_document_transfer.py +++ b/hindsight-api-slim/tests/test_document_transfer.py @@ -86,9 +86,7 @@ async def _import(memory, bank_id, archive, request_context, on_conflict="skip") inline and is already completed when submit returns. """ submission = await memory.import_documents_async(bank_id, archive, request_context, on_conflict) - status = await memory.get_operation_status( - bank_id, submission["operation_id"], request_context=request_context - ) + status = await memory.get_operation_status(bank_id, submission["operation_id"], request_context=request_context) assert status["status"] == "completed", status return status["result_metadata"] @@ -340,12 +338,8 @@ async def test_bank_roundtrip_carries_mental_model_history(memory, request_conte mental_model_id="mm-1", request_context=request_context, ) - await memory.update_mental_model( - bank, mental_model_id="mm-1", content="v2", request_context=request_context - ) - await memory.update_mental_model( - bank, mental_model_id="mm-1", content="v3", request_context=request_context - ) + await memory.update_mental_model(bank, mental_model_id="mm-1", content="v2", request_context=request_context) + await memory.update_mental_model(bank, mental_model_id="mm-1", content="v3", request_context=request_context) # Two refreshes → two snapshots (previous content v1 then v2), newest-first. before = await memory.get_mental_model_history(bank, "mm-1", request_context=request_context) assert [h["previous_content"] for h in before] == ["v2", "v1"] @@ -475,13 +469,11 @@ async def _bank_snapshot(memory, bank_id): backend = await memory._get_backend() async with acquire_with_retry(backend) as conn: docs = await conn.fetch( - f"SELECT id, COALESCE(length(original_text), 0) AS len FROM {fq_table('documents')} " - f"WHERE bank_id = $1", + f"SELECT id, COALESCE(length(original_text), 0) AS len FROM {fq_table('documents')} WHERE bank_id = $1", bank_id, ) chunks = await conn.fetch( - f"SELECT document_id, chunk_index, length(chunk_text) AS len FROM {fq_table('chunks')} " - f"WHERE bank_id = $1", + f"SELECT document_id, chunk_index, length(chunk_text) AS len FROM {fq_table('chunks')} WHERE bank_id = $1", bank_id, ) ftypes = await conn.fetch( @@ -591,9 +583,7 @@ async def test_export_import_observations(memory, request_context): backend = await memory._get_backend() async with acquire_with_retry(backend) as conn: async with conn.transaction(): - await _create_observation_directly( - conn, memory, src, source_ids, "Alice and Bob are colleagues." - ) + await _create_observation_directly(conn, memory, src, source_ids, "Alice and Bob are colleagues.") # Export WITHOUT observations -> none in the archive (the bank may also # contain auto-consolidation observations; the flag is what gates them). @@ -762,9 +752,7 @@ async def test_include_observations_requires_whole_bank_export(memory, request_c await _retain(memory, src, "Alice works at Google.", request_context, "doc-1") # Subset export (document_ids set) + observations must be rejected. with pytest.raises(ValueError, match="whole bank"): - await memory.export_documents_async( - src, request_context, ["doc-1"], include_observations=True - ) + await memory.export_documents_async(src, request_context, ["doc-1"], include_observations=True) # Whole-bank export with observations is fine; subset without observations is fine. await memory.export_documents_async(src, request_context, include_observations=True) await memory.export_documents_async(src, request_context, ["doc-1"]) diff --git a/hindsight-api-slim/tests/test_embeddings_openai_batch_size.py b/hindsight-api-slim/tests/test_embeddings_openai_batch_size.py index 5c520dab32..a3df2ee692 100644 --- a/hindsight-api-slim/tests/test_embeddings_openai_batch_size.py +++ b/hindsight-api-slim/tests/test_embeddings_openai_batch_size.py @@ -124,6 +124,10 @@ def test_openai_codex_provider_uses_codex_oauth_token_and_configured_batch_size( ) monkeypatch.setenv("HOME", str(tmp_path)) + # Codex auth resolves via CODEX_HOME first (falling back to ~/.codex), so a + # CODEX_HOME leaking in from the runner's environment would point auth.json + # away from the tmp_path fixture. Pin resolution to the patched HOME. + monkeypatch.delenv("CODEX_HOME", raising=False) os.environ["HINDSIGHT_API_LLM_PROVIDER"] = "mock" os.environ["HINDSIGHT_API_EMBEDDINGS_PROVIDER"] = "openai-codex" os.environ["HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"] = "text-embedding-3-small" diff --git a/hindsight-api-slim/tests/test_entity_labels.py b/hindsight-api-slim/tests/test_entity_labels.py index 35ea9b6f0a..ec6dde4c45 100644 --- a/hindsight-api-slim/tests/test_entity_labels.py +++ b/hindsight-api-slim/tests/test_entity_labels.py @@ -80,11 +80,7 @@ def test_parse_entity_labels_dict_format(): def test_parse_entity_labels_dict_format_defaults(): """Dict format parses attributes correctly.""" - raw = { - "attributes": [ - {"key": "topic", "values": [{"value": "math", "description": "Mathematics"}]} - ] - } + raw = {"attributes": [{"key": "topic", "values": [{"value": "math", "description": "Mathematics"}]}]} result = parse_entity_labels(raw) assert result is not None assert len(result.attributes) == 1 @@ -178,9 +174,7 @@ def test_build_labels_model_free_values_optional(): """type='text', optional=True → str | None field.""" from hindsight_api.engine.retain.entity_labels import build_labels_model - labels_cfg = EntityLabelsConfig( - attributes=[LabelGroup(key="topic", type="text", optional=True, values=[])] - ) + labels_cfg = EntityLabelsConfig(attributes=[LabelGroup(key="topic", type="text", optional=True, values=[])]) Model = build_labels_model(labels_cfg) assert Model is not None schema = Model.model_json_schema() @@ -194,9 +188,7 @@ def test_build_labels_model_free_values_always_optional(): """type='text' with optional=False is still treated as str | None — always optional.""" from hindsight_api.engine.retain.entity_labels import build_labels_model - labels_cfg = EntityLabelsConfig( - attributes=[LabelGroup(key="topic", type="text", optional=False, values=[])] - ) + labels_cfg = EntityLabelsConfig(attributes=[LabelGroup(key="topic", type="text", optional=False, values=[])]) Model = build_labels_model(labels_cfg) assert Model is not None schema = Model.model_json_schema() @@ -210,9 +202,7 @@ def test_build_labels_model_free_values_multi_still_optional(): """type='text' is always str | None — multi-values only applies to enum types.""" from hindsight_api.engine.retain.entity_labels import build_labels_model - labels_cfg = EntityLabelsConfig( - attributes=[LabelGroup(key="tags", type="text", values=[])] - ) + labels_cfg = EntityLabelsConfig(attributes=[LabelGroup(key="tags", type="text", values=[])]) Model = build_labels_model(labels_cfg) assert Model is not None schema = Model.model_json_schema() @@ -226,9 +216,7 @@ def test_build_labels_model_free_values_no_values_still_creates_field(): """type='text' group with no values still creates a field (description holds examples).""" from hindsight_api.engine.retain.entity_labels import build_labels_model - labels_cfg = EntityLabelsConfig( - attributes=[LabelGroup(key="mood", type="text", values=[])] - ) + labels_cfg = EntityLabelsConfig(attributes=[LabelGroup(key="mood", type="text", values=[])]) Model = build_labels_model(labels_cfg) assert Model is not None assert "mood" in Model.model_json_schema()["properties"] @@ -549,9 +537,7 @@ def test_label_entity_post_processing_invalid_value_ignored(): from hindsight_api.engine.retain.entity_labels import build_labels_lookup, parse_entity_labels from hindsight_api.engine.retain.fact_extraction import Entity - labels_cfg = parse_entity_labels( - [{"key": "pedagogy", "values": [{"value": "scaffolding", "description": ""}]}] - ) + labels_cfg = parse_entity_labels([{"key": "pedagogy", "values": [{"value": "scaffolding", "description": ""}]}]) labels_lookup = build_labels_lookup(labels_cfg) labels_data = {"pedagogy": "unknown_value"} @@ -665,9 +651,7 @@ def test_free_values_label_is_single_value(): """type='text' groups are always single-value (str | None).""" from hindsight_api.engine.retain.entity_labels import build_labels_model, parse_entity_labels - labels_cfg = parse_entity_labels( - [{"key": "topic", "type": "text", "values": []}] - ) + labels_cfg = parse_entity_labels([{"key": "topic", "type": "text", "values": []}]) Model = build_labels_model(labels_cfg) assert Model is not None schema = Model.model_json_schema() @@ -681,9 +665,7 @@ def test_free_values_label_not_in_lookup(): """type='text' group values do NOT appear in the lookup set (no fixed vocabulary).""" from hindsight_api.engine.retain.entity_labels import build_labels_lookup, parse_entity_labels - labels_cfg = parse_entity_labels( - [{"key": "topic", "type": "text", "values": [{"value": "algebra"}]}] - ) + labels_cfg = parse_entity_labels([{"key": "topic", "type": "text", "values": [{"value": "algebra"}]}]) lookup = build_labels_lookup(labels_cfg) assert "topic:algebra" not in lookup # example hints not added to lookup assert len(lookup) == 0 @@ -725,9 +707,7 @@ def test_optional_label_string_none_produces_no_entity(): # LLM returned the string "None" instead of JSON null — must not be stored entity_texts = _run_label_post_processing(labels_cfg, {"engagement": "None"}) - assert entity_texts == set(), ( - f"String 'None' must not produce engagement:None entity, got: {entity_texts}" - ) + assert entity_texts == set(), f"String 'None' must not produce engagement:None entity, got: {entity_texts}" def test_optional_label_null_does_not_affect_other_labels(): @@ -744,9 +724,7 @@ def test_optional_label_null_does_not_affect_other_labels(): # engagement is null, but topic is set entity_texts = _run_label_post_processing(labels_cfg, {"engagement": None, "topic": "math"}) assert "topic:math" in entity_texts, f"Expected topic:math entity, got: {entity_texts}" - assert not any("engagement" in t for t in entity_texts), ( - f"engagement should not appear, got: {entity_texts}" - ) + assert not any("engagement" in t for t in entity_texts), f"engagement should not appear, got: {entity_texts}" def test_free_form_entities_false_clears_entities(): @@ -982,9 +960,7 @@ async def test_retain_extracts_single_value_label(memory_real_llm, request_conte ) entity_names = {r["canonical_name"].lower() for r in rows} - assert "engagement:active" in entity_names, ( - f"Expected 'engagement:active' label entity. Got: {entity_names}" - ) + assert "engagement:active" in entity_names, f"Expected 'engagement:active' label entity. Got: {entity_names}" # In labels-only mode, free-form entities like 'Maria' should be absent assert not any("maria" in n for n in entity_names), ( f"Free-form entity 'Maria' should not appear in labels-only mode. Got: {entity_names}" @@ -1054,9 +1030,7 @@ async def test_retain_extracts_multi_value_label(memory_real_llm, request_contex entity_names = {r["canonical_name"].lower() for r in rows} # At least one pedagogy label should be assigned pedagogy_labels = {n for n in entity_names if n.startswith("pedagogy:")} - assert len(pedagogy_labels) > 0, ( - f"Expected at least one pedagogy:* label entity. Got: {entity_names}" - ) + assert len(pedagogy_labels) > 0, f"Expected at least one pedagogy:* label entity. Got: {entity_names}" finally: await memory.delete_bank(bank_id, request_context=request_context) @@ -1118,9 +1092,7 @@ async def test_retain_extracts_free_values_label(memory_real_llm, request_contex entity_names = {r["canonical_name"].lower() for r in rows} # A topic:* entity must exist — value is free-form so we only check the prefix topic_entities = {n for n in entity_names if n.startswith("topic:")} - assert len(topic_entities) > 0, ( - f"Expected at least one topic:* free-value entity. Got: {entity_names}" - ) + assert len(topic_entities) > 0, f"Expected at least one topic:* free-value entity. Got: {entity_names}" # The value must not be the literal string "none" or "null" assert not any(n in ("topic:none", "topic:null", "topic:n/a") for n in topic_entities), ( f"topic entity should not be a null sentinel. Got: {topic_entities}" @@ -1188,18 +1160,14 @@ async def test_retain_extracts_map_type_entities(memory_real_llm, request_contex entity_names = {r["canonical_name"].lower() for r in rows} # Should have person:name:* entity name_entities = {n for n in entity_names if n.startswith("person:name:")} - assert len(name_entities) > 0, ( - f"Expected at least one person:name:* entity. Got: {entity_names}" - ) + assert len(name_entities) > 0, f"Expected at least one person:name:* entity. Got: {entity_names}" # Name should contain "alice" somewhere assert any("alice" in n for n in name_entities), ( f"Expected person:name entity containing 'alice'. Got: {name_entities}" ) # Should have person:organization:* entity mentioning google org_entities = {n for n in entity_names if n.startswith("person:organization:")} - assert len(org_entities) > 0, ( - f"Expected at least one person:organization:* entity. Got: {entity_names}" - ) + assert len(org_entities) > 0, f"Expected at least one person:organization:* entity. Got: {entity_names}" assert any("google" in n for n in org_entities), ( f"Expected person:organization entity containing 'google'. Got: {org_entities}" ) @@ -2036,9 +2004,7 @@ async def test_retain_multivalue_tag_entities_all_stored(memory_real_llm, reques # The core assertion from GH-1558: tags and entities should match # Tags show both but entities only show a subset → BUG - assert len(use_tags) >= 2, ( - f"Expected at least 2 use:* tags. Got: {use_tags}" - ) + assert len(use_tags) >= 2, f"Expected at least 2 use:* tags. Got: {use_tags}" assert len(use_entities) >= 2, ( f"GH-1558 BUG: Expected at least 2 use:* entities in unit_entities, " f"but only got {len(use_entities)}: {use_entities}. " @@ -2097,8 +2063,7 @@ async def test_retain_multivalue_tag_entities_second_retain(memory_real_llm, req await memory_real_llm.retain_async( bank_id=bank_id, content=( - "## Authentication Flow (use-001)\n\n" - "The authentication flow use-001 handles user login via OAuth2." + "## Authentication Flow (use-001)\n\nThe authentication flow use-001 handles user login via OAuth2." ), request_context=request_context, ) @@ -2145,9 +2110,7 @@ async def test_retain_multivalue_tag_entities_second_retain(memory_real_llm, req use_entities = {n for n in entity_names if n.startswith("use:")} use_tags = {t for t in all_tags if t.startswith("use:")} - assert len(use_tags) >= 2, ( - f"Expected at least 2 use:* tags on second retain. Got: {use_tags}" - ) + assert len(use_tags) >= 2, f"Expected at least 2 use:* tags on second retain. Got: {use_tags}" assert len(use_entities) >= 2, ( f"GH-1558 BUG: On second retain, expected at least 2 use:* entities " f"but only got {len(use_entities)}: {use_entities}. " @@ -2155,9 +2118,7 @@ async def test_retain_multivalue_tag_entities_second_retain(memory_real_llm, req f"Entity resolution may be merging similar names." ) missing = use_tags - use_entities - assert len(missing) == 0, ( - f"GH-1558 BUG: Tags present but entities missing after second retain: {missing}" - ) + assert len(missing) == 0, f"GH-1558 BUG: Tags present but entities missing after second retain: {missing}" finally: await memory_real_llm.delete_bank(bank_id, request_context=request_context) @@ -2243,9 +2204,7 @@ async def test_entity_resolution_does_not_merge_distinct_label_values(memory, re ) # We should get 2 DISTINCT entity IDs, not the same ID twice - assert len(resolved_entity_ids) == 2, ( - f"Expected 2 resolved entity IDs, got {len(resolved_entity_ids)}" - ) + assert len(resolved_entity_ids) == 2, f"Expected 2 resolved entity IDs, got {len(resolved_entity_ids)}" unique_ids = set(resolved_entity_ids) assert len(unique_ids) == 2, ( f"GH-1558 BUG: Entity resolution merged 'use:use-001' and 'use:use-002' " @@ -2254,3 +2213,210 @@ async def test_entity_resolution_does_not_merge_distinct_label_values(memory, re ) finally: await memory.delete_bank(bank_id, request_context=request_context) + + +# ─── User report: paired id/name map-entity extraction from [[...]] tags ─────── +# +# Forum report (related to GH-1558): a user wants consistent extraction of a +# structured `application` entity with BOTH an `id` and a `name` field for every +# tagged element in their documents. They mark up source text with their own +# `[[Matched Text (name, id)]]` notation, e.g. +# [[SystemA (SystemA, SYS001)]] [[System-A (SystemA, SYS001)]] +# and configure an entity label group like: +# application (tag) +# - id (multi-values): SYS001, SYS002, SYS003, ... +# - name (multi-values): SystemA, SystemB, SystemC, ... +# +# Symptom: extraction is inconsistent. For a given tagged element they often get +# only PART of the pair (e.g. application:name:SystemA but no application:id:SYS001), +# and sometimes the element is missed entirely. It is noticeably worse when more +# than one tagged element appears in the same chunk. +# +# These tests reproduce that scenario. The deterministic tests pin the mechanics +# (map post-processing emits the full pair when the LLM returns both fields, and +# faithfully drops half when it doesn't — there is no backfill, so the pairing +# must come from the model). The hs_llm_core test exercises the real model +# end-to-end and asserts that EVERY tagged element yields a COMPLETE {name, id} +# pair — the assertion that surfaces the reported flakiness. + + +# Known applications: canonical name → canonical id (the configured vocabulary). +_KNOWN_APPLICATIONS = { + "SystemA": "SYS001", + "SystemB": "SYS002", + "SystemC": "SYS003", +} + + +def _build_application_label_config() -> dict: + """The user's reported entity_labels config: application map with id + name.""" + return { + "entity_labels": [ + { + "key": "application", + "type": "map", + "tag": True, + "description": "A known software system referenced in the text", + "fields": { + "name": { + "type": "multi-values", + "description": "The human-readable application name", + "values": [{"value": n} for n in _KNOWN_APPLICATIONS], + }, + "id": { + "type": "multi-values", + "description": "The application identifier code", + "values": [{"value": i} for i in _KNOWN_APPLICATIONS.values()], + }, + }, + } + ], + "entities_allow_free_form": False, + "retain_extraction_mode": "verbose", + } + + +def test_map_entity_emits_complete_id_name_pair(): + """ + Deterministic mechanics: when the LLM returns a map entity object with BOTH + fields populated, post-processing emits the full pair of label entities. + + This isolates the post-processing step from LLM non-determinism — it proves + the pipeline is capable of producing the complete pair, so any missing half + seen end-to-end comes from the model's structured output, not from a bug here. + """ + from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities + + cfg = parse_entity_labels(_build_application_label_config()["entity_labels"]) + assert cfg is not None + group = cfg.attributes[0] + + validated: list[Entity] = [] + existing_lower: set[str] = set() + # Simulated LLM output for one tagged element: [[SystemA (SystemA, SYS001)]] + _extract_map_entities( + entity_obj={"name": ["SystemA"], "id": ["SYS001"]}, + fields=group.fields, + prefix="application:", + validated_entities=validated, + existing_texts_lower=existing_lower, + ) + + texts = {e.text for e in validated} + assert texts == {"application:name:SystemA", "application:id:SYS001"}, ( + f"Expected the complete id/name pair, got: {texts}" + ) + + +def test_map_entity_partial_object_drops_half_the_pair(): + """ + Deterministic: documents the failure shape the user sees. If the LLM returns + only one field of the map object, post-processing faithfully emits only that + half — there is no inference of the missing member. This shows the pairing + must be guaranteed upstream (by the model), and post-processing won't backfill. + """ + from hindsight_api.engine.retain.fact_extraction import Entity, _extract_map_entities + + cfg = parse_entity_labels(_build_application_label_config()["entity_labels"]) + group = cfg.attributes[0] + + validated: list[Entity] = [] + # LLM returned the name but omitted the id — the reported "part only" case. + _extract_map_entities( + entity_obj={"name": ["SystemA"]}, + fields=group.fields, + prefix="application:", + validated_entities=validated, + existing_texts_lower=set(), + ) + + texts = {e.text for e in validated} + assert texts == {"application:name:SystemA"}, texts + assert "application:id:SYS001" not in texts + + +@pytest.mark.asyncio +@pytest.mark.hs_llm_core +async def test_retain_application_tags_extract_complete_pairs(memory_real_llm, request_context): + """ + User report reproducer (integration): retain a document whose source text is + marked up with `[[Matched Text (name, id)]]` tags referencing several known + applications, and assert that EVERY tagged element yields a COMPLETE + {application:name:*, application:id:*} pair. + + The reported symptom is that some elements come back with only the name OR + only the id (and occasionally neither), especially with several tags in one + chunk. This test fails when any expected pair is incomplete, surfacing that + inconsistency. + """ + from hindsight_api.engine.memory_engine import fq_table + + bank_id = f"test-app-pairs-{uuid.uuid4().hex[:8]}" + # Three tagged elements in ONE chunk, with surface forms that differ from the + # canonical values (hyphenation, casing) so the model has to map each tag back + # onto the configured vocabulary — the "more than one item in the chunk" + # condition from the report. + elements = ["SystemA", "SystemB", "SystemC"] + expected_pairs = { + name: ( + f"application:name:{name.lower()}", + f"application:id:{_KNOWN_APPLICATIONS[name].lower()}", + ) + for name in elements + } + try: + await memory_real_llm.get_bank_profile(bank_id=bank_id, request_context=request_context) + await memory_real_llm._config_resolver.update_bank_config( + bank_id=bank_id, + updates=_build_application_label_config(), + context=request_context, + ) + + # Multiple tagged elements in a single document, mirroring the user's + # `[[Matched Text (name, id)]]` notation and varied surface forms. + unit_ids = await memory_real_llm.retain_async( + bank_id=bank_id, + content=( + "## Integration Architecture\n\n" + "The order pipeline routes events from [[SystemA (SystemA, SYS001)]] " + "into [[System-B (SystemB, SYS002)]] for enrichment. " + "Reconciliation is handled downstream by [[system c (SystemC, SYS003)]]. " + "Note that [[System-A (SystemA, SYS001)]] also emits audit records " + "consumed by [[SystemC (SystemC, SYS003)]]." + ), + request_context=request_context, + ) + + assert len(unit_ids) > 0, "Should have extracted at least one fact" + + async with memory_real_llm._pool.acquire() as conn: + entity_rows = await conn.fetch( + f""" + SELECT e.canonical_name + FROM {fq_table("unit_entities")} ue + JOIN {fq_table("entities")} e ON e.id = ue.entity_id + WHERE ue.unit_id = ANY($1::uuid[]) + """, + [u for u in unit_ids], + ) + entity_names = {r["canonical_name"].lower() for r in entity_rows} + app_entities = {n for n in entity_names if n.startswith("application:")} + + # Build a per-element completeness report so a failure is diagnostic. + report: list[str] = [] + incomplete: list[str] = [] + for element, (name_ent, id_ent) in expected_pairs.items(): + has_name = name_ent in app_entities + has_id = id_ent in app_entities + if not (has_name and has_id): + incomplete.append(element) + report.append(f" {element}: name={'OK' if has_name else 'MISSING'} id={'OK' if has_id else 'MISSING'}") + + assert not incomplete, ( + "User report reproduced: not every tagged element produced a complete " + f"id/name pair. Incomplete: {incomplete}\n" + "Per-element extraction:\n" + "\n".join(report) + "\n" + f"All application:* entities: {sorted(app_entities)}" + ) + finally: + await memory_real_llm.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api-slim/tests/test_entity_resolver.py b/hindsight-api-slim/tests/test_entity_resolver.py index ad7f551352..4729dbf0ff 100644 --- a/hindsight-api-slim/tests/test_entity_resolver.py +++ b/hindsight-api-slim/tests/test_entity_resolver.py @@ -305,9 +305,7 @@ async def test_oracle_candidate_lookup_batches_entity_texts(self): conn = AsyncMock() conn.backend_type = "oracle" conn.fetch = AsyncMock(return_value=[]) - entities_data = [ - {"text": f"Entity {idx}", "nearby_entities": [], "event_date": None} for idx in range(5) - ] + entities_data = [{"text": f"Entity {idx}", "nearby_entities": [], "event_date": None} for idx in range(5)] with patch.object(resolver, "_resolve_from_candidates", new_callable=AsyncMock, return_value=[]): await resolver._resolve_entities_batch_oracle_fuzzy( diff --git a/hindsight-api-slim/tests/test_extensions.py b/hindsight-api-slim/tests/test_extensions.py index 1fd91b0bb2..d2222267e8 100644 --- a/hindsight-api-slim/tests/test_extensions.py +++ b/hindsight-api-slim/tests/test_extensions.py @@ -101,25 +101,19 @@ def __init__(self, config: dict): async def validate_retain(self, ctx: RetainContext) -> ValidationResult: self.retain_counts[ctx.bank_id] += 1 if self.retain_counts[ctx.bank_id] > self.max_attempts: - return ValidationResult.reject( - f"Retain limit exceeded for bank {ctx.bank_id}" - ) + return ValidationResult.reject(f"Retain limit exceeded for bank {ctx.bank_id}") return ValidationResult.accept() async def validate_recall(self, ctx: RecallContext) -> ValidationResult: self.recall_counts[ctx.bank_id] += 1 if self.recall_counts[ctx.bank_id] > self.max_attempts: - return ValidationResult.reject( - f"Recall limit exceeded for bank {ctx.bank_id}" - ) + return ValidationResult.reject(f"Recall limit exceeded for bank {ctx.bank_id}") return ValidationResult.accept() async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult: self.reflect_counts[ctx.bank_id] += 1 if self.reflect_counts[ctx.bank_id] > self.max_attempts: - return ValidationResult.reject( - f"Reflect limit exceeded for bank {ctx.bank_id}" - ) + return ValidationResult.reject(f"Reflect limit exceeded for bank {ctx.bank_id}") return ValidationResult.accept() @@ -579,9 +573,7 @@ class TestMemoryEngineTenantAuth: """Tests for tenant authentication in MemoryEngine.""" @pytest.mark.asyncio - async def test_retain_requires_tenant_request_when_extension_configured( - self, memory_with_tenant - ): + async def test_retain_requires_tenant_request_when_extension_configured(self, memory_with_tenant): """Retain fails without RequestContext when tenant extension is configured.""" memory = memory_with_tenant @@ -621,9 +613,7 @@ async def test_retain_fails_with_invalid_api_key(self, memory_with_tenant): assert "Invalid API key" in str(exc_info.value) @pytest.mark.asyncio - async def test_recall_requires_tenant_request_when_extension_configured( - self, memory_with_tenant - ): + async def test_recall_requires_tenant_request_when_extension_configured(self, memory_with_tenant): """Recall fails without RequestContext when tenant extension is configured.""" memory = memory_with_tenant @@ -861,8 +851,7 @@ class RecordingPrecheckValidator(OperationValidatorExtension): instantiable; the tests here only exercise precheck. """ - def __init__(self, *, reject: bool = False, status_code: int = 402, - reason: str = "rejected by precheck") -> None: + def __init__(self, *, reject: bool = False, status_code: int = 402, reason: str = "rejected by precheck") -> None: super().__init__(config={}) self.reject = reject self.status_code = status_code @@ -922,7 +911,7 @@ class TestPrecheckHttpWiring: def _build_app(validator): """Mirror the precheck wiring from ``hindsight_api.api.http`` in a standalone FastAPI app.""" - from fastapi import Depends, FastAPI, HTTPException + from fastapi import Depends, FastAPI, HTTPException, Request from pydantic import BaseModel, model_validator from hindsight_api.extensions import PrecheckContext @@ -963,12 +952,23 @@ async def _request_context() -> RequestContext: def _precheck_for(operation: str): async def _dep( bank_id: str, + request: Request, request_context: RequestContext = Depends(_request_context), ) -> None: + cl_header = request.headers.get("content-length") + content_length: int | None = None + if cl_header is not None: + try: + parsed = int(cl_header) + except ValueError: + parsed = -1 + if parsed >= 0: + content_length = parsed ctx = PrecheckContext( operation=operation, bank_id=bank_id, request_context=request_context, + content_length=content_length, ) result = await validator.precheck(ctx) if not result.allowed: @@ -1027,9 +1027,7 @@ def test_precheck_accept_lets_request_through_to_body_parse(self): assert body_parses == ["retain"] def test_precheck_rejection_returns_status_and_reason(self): - validator = RecordingPrecheckValidator( - reject=True, status_code=402, reason="Insufficient credits" - ) + validator = RecordingPrecheckValidator(reject=True, status_code=402, reason="Insufficient credits") app, _ = self._build_app(validator) client = TestClient(app) @@ -1045,9 +1043,7 @@ def test_precheck_rejection_skips_body_parse(self): deserialises the body. We send an oversized body and verify the body-parse counter never incremented. """ - validator = RecordingPrecheckValidator( - reject=True, status_code=402, reason="rejected by precheck" - ) + validator = RecordingPrecheckValidator(reject=True, status_code=402, reason="rejected by precheck") app, body_parses = self._build_app(validator) client = TestClient(app) @@ -1063,9 +1059,7 @@ def test_precheck_rejection_skips_body_parse(self): ) def test_precheck_rejection_skips_body_parse_for_recall(self): - validator = RecordingPrecheckValidator( - reject=True, status_code=402, reason="rejected" - ) + validator = RecordingPrecheckValidator(reject=True, status_code=402, reason="rejected") app, body_parses = self._build_app(validator) client = TestClient(app) @@ -1078,9 +1072,7 @@ def test_precheck_rejection_skips_body_parse_for_recall(self): assert body_parses == [] def test_precheck_rejection_skips_body_parse_for_reflect(self): - validator = RecordingPrecheckValidator( - reject=True, status_code=402, reason="rejected" - ) + validator = RecordingPrecheckValidator(reject=True, status_code=402, reason="rejected") app, body_parses = self._build_app(validator) client = TestClient(app) @@ -1100,3 +1092,86 @@ def test_precheck_does_not_run_on_get(self): resp = client.get("/v1/default/banks/precheck-bank/memories/list") assert resp.status_code == 200 assert len(validator.precheck_calls) == 0 + + def test_precheck_context_carries_content_length(self): + """Content-Length header is exposed to the precheck so a validator + can make size-aware decisions (e.g. upper-bound cost estimate) + before the body is deserialised.""" + validator = RecordingPrecheckValidator(reject=False) + app, _ = self._build_app(validator) + client = TestClient(app) + + # Body must contain at least 500 'x' bytes; check the surfaced + # Content-Length is within a tight band around that floor (allows + # for JSON envelope + httpx's serialisation choices without + # depending on exact byte counts). + payload = {"items": [{"content": "x" * 500}]} + resp = client.post( + "/v1/default/banks/precheck-bank/memories", + json=payload, + ) + assert resp.status_code == 200 + assert len(validator.precheck_calls) == 1 + ctx = validator.precheck_calls[0] + assert ctx.content_length is not None + assert 500 <= ctx.content_length <= 600 + + def test_precheck_context_content_length_zero_is_not_none(self): + """An empty POST body has Content-Length: 0. That should surface + as the int 0, not None — None means 'unknown', 0 means 'known to + be empty'.""" + validator = RecordingPrecheckValidator(reject=False) + app, _ = self._build_app(validator) + client = TestClient(app) + + # Empty body fails Pydantic parse (422), but precheck runs first + # and records the Content-Length. + client.post( + "/v1/default/banks/precheck-bank/memories", + content=b"", + headers={"content-type": "application/json"}, + ) + assert len(validator.precheck_calls) >= 1 + ctx = validator.precheck_calls[-1] + assert ctx.content_length == 0 + + @pytest.mark.asyncio + async def test_precheck_context_content_length_none_when_header_missing(self): + """When the Content-Length header isn't set (e.g. chunked transfer + encoding) the validator sees None, not a crash and not a default 0.""" + from starlette.requests import Request as _StarletteRequest + + from hindsight_api.extensions import PrecheckContext + from hindsight_api.models import RequestContext + + validator = RecordingPrecheckValidator(reject=False) + + # Replicate the wiring's parse step inline so the test exercises + # the same code-path semantics introduced in + # ``hindsight_api.api.http._precheck_dep``. + scope = { + "type": "http", + "method": "POST", + "path": "/v1/default/banks/bank-x/memories", + "headers": [], # no content-length + "query_string": b"", + } + req = _StarletteRequest(scope) + cl_header = req.headers.get("content-length") + content_length: int | None = None + if cl_header is not None: + try: + parsed = int(cl_header) + except ValueError: + parsed = -1 + if parsed >= 0: + content_length = parsed + + ctx = PrecheckContext( + operation="retain", + bank_id="bank-x", + request_context=RequestContext(), + content_length=content_length, + ) + await validator.precheck(ctx) + assert validator.precheck_calls[-1].content_length is None diff --git a/hindsight-api-slim/tests/test_extract_dry_run_http.py b/hindsight-api-slim/tests/test_extract_dry_run_http.py new file mode 100644 index 0000000000..e29349e74f --- /dev/null +++ b/hindsight-api-slim/tests/test_extract_dry_run_http.py @@ -0,0 +1,200 @@ +"""HTTP + engine tests for dry-run fact extraction. + +POST /memories/dry-run-extract runs extraction ONLY (no resolution/links/embeddings/persistence) and +returns candidate facts (a subset of the memory-unit shape) plus LLM token usage. Uses the +deterministic mock-LLM `memory` fixture, so extraction yields canned facts without a real provider. +""" + +import os +import uuid +from unittest.mock import patch + +import httpx +import pytest +import pytest_asyncio + +from hindsight_api import RequestContext +from hindsight_api.api import create_app +from hindsight_api.config import clear_config_cache +from hindsight_api.extensions import ( + OperationValidatorExtension, + PrecheckContext, + ValidationResult, +) + +# Dry-run facts are a subset of the memory-unit shape — only fields a fresh extraction produces +# (no storage/consolidation/curation fields, since nothing is persisted). +FACT_KEYS = { + "text", + "fact_type", + "occurred_start", + "occurred_end", + "entities", +} + + +@pytest_asyncio.fixture +async def api_client(memory): + app = create_app(memory, initialize_memory=False) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +@pytest.mark.asyncio +async def test_dry_run_extracts_without_persisting(api_client, memory): + bank_id = f"dryrun-{uuid.uuid4().hex[:8]}" + await memory.get_bank_profile(bank_id=bank_id, request_context=RequestContext()) + + before = await memory.list_memory_units(bank_id=bank_id, request_context=RequestContext()) + + resp = await api_client.post( + f"/v1/default/banks/{bank_id}/memories/dry-run-extract", + json={ + "content": "Alice moved to Berlin in 2021 and works as a nurse.", + "retain_mission": "Capture where people live and their jobs.", + "retain_chunk_size": 4000, + }, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert isinstance(body["facts"], list) and body["facts"], "expected candidate facts" + + for fact in body["facts"]: + # A subset of the memory-unit shape — no persistence/curation fields leak in. Null fields + # are omitted from responses API-wide (#2204), so the optional date fields may be absent; + # assert no UNEXPECTED key appears and the always-present ones are there. + assert set(fact) <= FACT_KEYS, f"unexpected keys: {set(fact) - FACT_KEYS}" + assert {"text", "fact_type", "entities"} <= set(fact) + assert fact["fact_type"] in ("world", "experience") + assert isinstance(fact["entities"], list) # raw extraction → array, not a joined string + + # Token usage is reported alongside the facts. + assert set(body["usage"]) >= {"input_tokens", "output_tokens", "total_tokens"} + + # No persistence: the bank's stored memory count is unchanged. + after = await memory.list_memory_units(bank_id=bank_id, request_context=RequestContext()) + assert after["total"] == before["total"] + + +@pytest.mark.asyncio +async def test_dry_run_rejects_empty_content(api_client, memory): + """Empty/whitespace-only content is rejected by request validation (422) before the + billable LLM extraction call runs — matching retain (RetainItem.content) and recall + (RecallRequest.query), which already reject empty input.""" + bank_id = f"dryrun-{uuid.uuid4().hex[:8]}" + await memory.get_bank_profile(bank_id=bank_id, request_context=RequestContext()) + + before = await memory.list_memory_units(bank_id=bank_id, request_context=RequestContext()) + for content in ("", " ", "\n\t "): + resp = await api_client.post( + f"/v1/default/banks/{bank_id}/memories/dry-run-extract", + json={"content": content}, + ) + assert resp.status_code == 422, resp.text + + # Rejected before extraction: nothing was persisted. + after = await memory.list_memory_units(bank_id=bank_id, request_context=RequestContext()) + assert after["total"] == before["total"] + + +@pytest.mark.asyncio +async def test_dry_run_disabled_returns_404(api_client, memory): + """With HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=false the endpoint is removed (returns 404).""" + bank_id = f"dryrun-{uuid.uuid4().hex[:8]}" + await memory.get_bank_profile(bank_id=bank_id, request_context=RequestContext()) + + try: + with patch.dict(os.environ, {"HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT": "false"}): + clear_config_cache() # force get_config() to re-read the patched env + resp = await api_client.post( + f"/v1/default/banks/{bank_id}/memories/dry-run-extract", + json={"content": "Alice moved to Berlin in 2021."}, + ) + assert resp.status_code == 404, resp.text + assert "disabled" in resp.json()["detail"].lower() + finally: + clear_config_cache() # env restored on with-exit; reset so later tests see the default + + +@pytest.mark.asyncio +async def test_dry_run_rejects_unknown_override(memory): + bank_id = f"dryrun-{uuid.uuid4().hex[:8]}" + await memory.get_bank_profile(bank_id=bank_id, request_context=RequestContext()) + with pytest.raises(ValueError, match="Unsupported extraction override"): + await memory.extract_dry_run( + bank_id, + "some content", + overrides={"embeddings_provider": "evil"}, + request_context=RequestContext(), + ) + + +class _DryRunRejectingValidator(OperationValidatorExtension): + """Operation validator that rejects the dry-run-extract precheck. + + Models an extension that gates LLM-billable routes (revoked key / exhausted + balance / rate-limited tenant). It rejects only the ``dry_run_extract`` + operation so the test asserts the dry-run route is actually wired to the + precheck, not that the validator rejects everything. + """ + + async def precheck(self, ctx: PrecheckContext) -> ValidationResult: + if ctx.operation == "dry_run_extract": + return ValidationResult.reject("dry-run extraction not allowed", status_code=402) + return ValidationResult.accept() + + async def validate_retain(self, ctx) -> ValidationResult: + return ValidationResult.accept() + + async def validate_recall(self, ctx) -> ValidationResult: + return ValidationResult.accept() + + async def validate_reflect(self, ctx) -> ValidationResult: + return ValidationResult.accept() + + +@pytest.mark.asyncio +async def test_dry_run_honors_operation_precheck(api_client, memory): + """dry-run-extract makes a real LLM call, so it must run the same billing/quota/rate-limit + precheck the other LLM-billable POST routes (retain/recall/reflect/mental_model_*/files_retain) + already wire. A validator that rejects the operation must short-circuit the request before any + extraction runs — without the precheck dependency the route would proceed to a 200.""" + bank_id = f"dryrun-{uuid.uuid4().hex[:8]}" + await memory.get_bank_profile(bank_id=bank_id, request_context=RequestContext()) + + previous = getattr(memory, "_operation_validator", None) + memory._operation_validator = _DryRunRejectingValidator({}) + try: + resp = await api_client.post( + f"/v1/default/banks/{bank_id}/memories/dry-run-extract", + json={"content": "Alice moved to Berlin in 2021."}, + ) + assert resp.status_code == 402, resp.text + assert "not allowed" in resp.json()["detail"].lower() + finally: + memory._operation_validator = previous + + +@pytest.mark.asyncio +async def test_dry_run_disabled_returns_404_even_with_validator(api_client, memory): + """A disabled dry-run route must 404 before the billing/quota precheck runs, even with a + configured validator — the feature-flag gate is declared as a dependency before the precheck, + so it preserves the original "disabled → 404" contract instead of leaking a 402/401/429.""" + bank_id = f"dryrun-{uuid.uuid4().hex[:8]}" + await memory.get_bank_profile(bank_id=bank_id, request_context=RequestContext()) + + previous = getattr(memory, "_operation_validator", None) + memory._operation_validator = _DryRunRejectingValidator({}) + try: + with patch.dict(os.environ, {"HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT": "false"}): + clear_config_cache() # force get_config() to re-read the patched env + resp = await api_client.post( + f"/v1/default/banks/{bank_id}/memories/dry-run-extract", + json={"content": "Alice moved to Berlin in 2021."}, + ) + assert resp.status_code == 404, resp.text + assert "disabled" in resp.json()["detail"].lower() + finally: + clear_config_cache() # env restored on with-exit; reset so later tests see the default + memory._operation_validator = previous diff --git a/hindsight-api-slim/tests/test_fact_extraction_analysis.py b/hindsight-api-slim/tests/test_fact_extraction_analysis.py index 9f65588999..78d6a7be5a 100644 --- a/hindsight-api-slim/tests/test_fact_extraction_analysis.py +++ b/hindsight-api-slim/tests/test_fact_extraction_analysis.py @@ -1,6 +1,7 @@ """ Test to analyze fact extraction token usage and identify optimization opportunities. """ + import asyncio import logging import time @@ -63,9 +64,9 @@ async def test_fact_extraction_basic_analysis(llm_config): duration = time.time() - start_time - logger.info(f"\n{'='*60}") + logger.info(f"\n{'=' * 60}") logger.info(f"EXTRACTION RESULTS") - logger.info(f"{'='*60}") + logger.info(f"{'=' * 60}") logger.info(f"Duration: {duration:.2f}s") logger.info(f"Chunks: {len(chunks)}") logger.info(f"Facts extracted: {len(facts)}") @@ -86,13 +87,13 @@ async def test_fact_extraction_basic_analysis(llm_config): # Show sample facts logger.info(f"\nSample facts (first 10):") for i, fact in enumerate(facts[:10]): - logger.info(f"\n [{i+1}] {fact.fact_type}: {fact.fact[:150]}...") + logger.info(f"\n [{i + 1}] {fact.fact_type}: {fact.fact[:150]}...") # Show facts containing key terms key_terms = ["kubernetes", "k8s", "CKA", "certification", "Alice"] - logger.info(f"\n{'='*60}") + logger.info(f"\n{'=' * 60}") logger.info(f"FACTS CONTAINING KEY TERMS") - logger.info(f"{'='*60}") + logger.info(f"{'=' * 60}") for term in key_terms: matching = [f for f in facts if term.lower() in f.fact.lower()] diff --git a/hindsight-api-slim/tests/test_fact_extraction_metadata.py b/hindsight-api-slim/tests/test_fact_extraction_metadata.py index 9c0be897f0..051a316ef7 100644 --- a/hindsight-api-slim/tests/test_fact_extraction_metadata.py +++ b/hindsight-api-slim/tests/test_fact_extraction_metadata.py @@ -1,6 +1,7 @@ """ Unit tests for metadata inclusion in fact extraction LLM prompt. """ + from datetime import datetime from hindsight_api.engine.retain.fact_extraction import _build_user_message diff --git a/hindsight-api-slim/tests/test_fact_extraction_output_ratio.py b/hindsight-api-slim/tests/test_fact_extraction_output_ratio.py index 6f2a301671..5daeee2b3c 100644 --- a/hindsight-api-slim/tests/test_fact_extraction_output_ratio.py +++ b/hindsight-api-slim/tests/test_fact_extraction_output_ratio.py @@ -109,8 +109,7 @@ async def test_output_ratio_conversation(self): # Output should not be more than 5x the input assert ratio < 5.0, ( - f"Output/input ratio {ratio:.2f} is too high! " - f"Input: {input_length} chars, Output: {output_length} chars" + f"Output/input ratio {ratio:.2f} is too high! Input: {input_length} chars, Output: {output_length} chars" ) @pytest.mark.asyncio @@ -168,16 +167,12 @@ async def test_output_ratio_longer_text(self): # Output should not be more than 4x the input for longer texts # (ratio should decrease as input grows) assert ratio < 4.0, ( - f"Output/input ratio {ratio:.2f} is too high! " - f"Input: {input_length} chars, Output: {output_length} chars" + f"Output/input ratio {ratio:.2f} is too high! Input: {input_length} chars, Output: {output_length} chars" ) # Also check that individual facts aren't excessively long max_fact_length = max(len(f.fact) for f in facts) if facts else 0 - assert max_fact_length < 1000, ( - f"Individual fact too long: {max_fact_length} chars. " - f"Facts should be concise." - ) + assert max_fact_length < 1000, f"Individual fact too long: {max_fact_length} chars. Facts should be concise." @pytest.mark.asyncio async def test_token_ratio_with_locomo_conversation(self): @@ -190,11 +185,7 @@ async def test_token_ratio_with_locomo_conversation(self): import os # Load locomo conversation - fixture_path = os.path.join( - os.path.dirname(__file__), - "fixtures", - "locomo_conversation_sample.json" - ) + fixture_path = os.path.join(os.path.dirname(__file__), "fixtures", "locomo_conversation_sample.json") with open(fixture_path, "r") as f: data = json.load(f) @@ -246,8 +237,7 @@ async def test_token_ratio_with_locomo_conversation(self): max_expected_facts = num_turns * 2 # At most 2 facts per conversation turn assert len(facts) <= max_expected_facts, ( - f"Too many facts: {len(facts)} for {num_turns} conversation turns. " - f"Expected at most {max_expected_facts}." + f"Too many facts: {len(facts)} for {num_turns} conversation turns. Expected at most {max_expected_facts}." ) @pytest.mark.asyncio @@ -279,7 +269,7 @@ async def test_number_of_facts_reasonable(self): ) # Count approximate number of statements (sentences) - num_statements = len([s for s in text.split('.') if s.strip()]) + num_statements = len([s for s in text.split(".") if s.strip()]) print(f"\nNumber of facts test:") print(f" Input statements: ~{num_statements}") diff --git a/hindsight-api-slim/tests/test_fact_ordering.py b/hindsight-api-slim/tests/test_fact_ordering.py index c0c757327b..46ce14a522 100644 --- a/hindsight-api-slim/tests/test_fact_ordering.py +++ b/hindsight-api-slim/tests/test_fact_ordering.py @@ -5,6 +5,7 @@ their relative order is preserved via time offsets, allowing retrieval to distinguish between things said earlier vs later. """ + import pytest from datetime import datetime, timezone from hindsight_api import MemoryEngine, RequestContext @@ -20,11 +21,9 @@ async def test_fact_ordering_within_conversation(memory, request_context): await memory.get_bank_profile(bank_id, request_context=request_context) # Update disposition to match Marcus - await memory.update_bank_disposition(bank_id, { - "skepticism": 3, - "literalism": 3, - "empathy": 3 - }, request_context=request_context) + await memory.update_bank_disposition( + bank_id, {"skepticism": 3, "literalism": 3, "empathy": 3}, request_context=request_context + ) # A conversation where Marcus changes his position conversation = """ @@ -51,7 +50,7 @@ async def test_fact_ordering_within_conversation(memory, request_context): results = await memory.recall_async( bank_id=bank_id, query="Marcus prediction Rams", - fact_type=['experience', 'world'], + fact_type=["experience", "world"], budget=Budget.LOW, max_tokens=8192, request_context=request_context, @@ -59,37 +58,41 @@ async def test_fact_ordering_within_conversation(memory, request_context): print(f"\n=== Retrieved {len(results.results)} facts ===") for i, result in enumerate(results.results): - print(f"{i+1}. [{result.mentioned_at}] {result.text[:100]}") + print(f"{i + 1}. [{result.mentioned_at}] {result.text[:100]}") # Get all facts (Marcus's predictions/statements) agent_facts = results.results print(f"\n=== Agent facts (Marcus's statements) ===") for i, fact in enumerate(agent_facts): - print(f"{i+1}. [{fact.mentioned_at}] {fact.text}") + print(f"{i + 1}. [{fact.mentioned_at}] {fact.text}") # Check that agent facts have different timestamps if len(agent_facts) >= 2: # Parse timestamps - timestamps = [datetime.fromisoformat(f.mentioned_at.replace('Z', '+00:00')) for f in agent_facts] + timestamps = [datetime.fromisoformat(f.mentioned_at.replace("Z", "+00:00")) for f in agent_facts] # Verify timestamps are different (have time offsets) unique_timestamps = set(timestamps) - assert len(unique_timestamps) == len(timestamps), \ + assert len(unique_timestamps) == len(timestamps), ( f"Expected unique timestamps for each fact, but got duplicates: {timestamps}" + ) # Sort facts by timestamp for ordering check # Note: recall returns by relevance, not time order - sorted_facts = sorted(agent_facts, key=lambda f: datetime.fromisoformat(f.mentioned_at.replace('Z', '+00:00'))) - sorted_timestamps = [datetime.fromisoformat(f.mentioned_at.replace('Z', '+00:00')) for f in sorted_facts] + sorted_facts = sorted(agent_facts, key=lambda f: datetime.fromisoformat(f.mentioned_at.replace("Z", "+00:00"))) + sorted_timestamps = [datetime.fromisoformat(f.mentioned_at.replace("Z", "+00:00")) for f in sorted_facts] # Verify sorted timestamps are in ascending order for i in range(len(sorted_timestamps) - 1): - assert sorted_timestamps[i] < sorted_timestamps[i + 1], \ - f"Facts should have sequential timestamps. Fact {i} ({sorted_timestamps[i]}) >= Fact {i+1} ({sorted_timestamps[i+1]})" + assert sorted_timestamps[i] < sorted_timestamps[i + 1], ( + f"Facts should have sequential timestamps. Fact {i} ({sorted_timestamps[i]}) >= Fact {i + 1} ({sorted_timestamps[i + 1]})" + ) # Verify facts have distinct timestamps (ordering is preserved) - time_diffs = [(sorted_timestamps[i+1] - sorted_timestamps[i]).total_seconds() for i in range(len(sorted_timestamps) - 1)] + time_diffs = [ + (sorted_timestamps[i + 1] - sorted_timestamps[i]).total_seconds() for i in range(len(sorted_timestamps) - 1) + ] print(f"\n=== Time differences between facts: {time_diffs} seconds ===") # Each fact should have a positive time difference (uniqueness already checked above) @@ -108,7 +111,7 @@ async def test_fact_ordering_within_conversation(memory, request_context): all_text = " ".join(agent_texts) # Look for evidence of the predictions being captured (may be merged or separate) - has_prediction_info = '27' in all_text or 'rams' in all_text or 'prediction' in all_text + has_prediction_info = "27" in all_text or "rams" in all_text or "prediction" in all_text assert has_prediction_info, "Facts should contain information about Marcus's predictions" print(f"\n✅ Facts capture prediction information") @@ -121,7 +124,6 @@ async def test_fact_ordering_within_conversation(memory, request_context): @pytest.mark.asyncio async def test_multiple_documents_ordering(memory, request_context): - bank_id = "test_multi_doc_agent" await memory.get_bank_profile(bank_id, request_context=request_context) # Auto-creates with defaults @@ -149,7 +151,7 @@ async def test_multiple_documents_ordering(memory, request_context): bank_id=bank_id, contents=[ {"content": conv1, "context": "project discussion 1", "event_date": time1}, - {"content": conv2, "context": "project discussion 2", "event_date": time2} + {"content": conv2, "context": "project discussion 2", "event_date": time2}, ], request_context=request_context, ) @@ -168,18 +170,21 @@ async def test_multiple_documents_ordering(memory, request_context): agent_facts = results.results for i, fact in enumerate(agent_facts): - print(f"{i+1}. [{fact.mentioned_at}] {fact.text[:80]}") + print(f"{i + 1}. [{fact.mentioned_at}] {fact.text[:80]}") # Each conversation's facts should have different timestamps. # Filter out observations — they inherit their source fact's timestamp, # which can collapse the unique set. Also skip facts without timestamps. - source_facts = [f for f in agent_facts if f.mentioned_at is not None and getattr(f, "fact_type", "") != "observation"] + source_facts = [ + f for f in agent_facts if f.mentioned_at is not None and getattr(f, "fact_type", "") != "observation" + ] if len(source_facts) >= 2: - timestamps = [datetime.fromisoformat(f.mentioned_at.replace('Z', '+00:00')) for f in source_facts] + timestamps = [datetime.fromisoformat(f.mentioned_at.replace("Z", "+00:00")) for f in source_facts] unique_timestamps = set(timestamps) - assert len(unique_timestamps) >= 2, \ + assert len(unique_timestamps) >= 2, ( f"Expected multiple unique timestamps across conversations, got: {len(unique_timestamps)}" + ) print(f"\n✅ Facts from {len(source_facts)} statements have {len(unique_timestamps)} unique timestamps") diff --git a/hindsight-api-slim/tests/test_file_retain.py b/hindsight-api-slim/tests/test_file_retain.py index 23fc48410f..a8ec12cd47 100644 --- a/hindsight-api-slim/tests/test_file_retain.py +++ b/hindsight-api-slim/tests/test_file_retain.py @@ -216,7 +216,9 @@ async def test_file_retain_validation_errors(memory_no_llm_verify): async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client: # Create bank - bank_response = await client.put("/v1/default/banks/test-validation-bank", json={"name": "Test Validation Bank"}) + bank_response = await client.put( + "/v1/default/banks/test-validation-bank", json={"name": "Test Validation Bank"} + ) assert bank_response.status_code in (200, 201) # Test: metadata count mismatch @@ -344,6 +346,149 @@ async def test_markitdown_converter(): assert "test document" in result.lower() or "multiple lines" in result.lower() +def test_markitdown_converter_does_not_enable_ocr_by_default(monkeypatch): + """Markitdown should keep its local/default behavior unless OCR is explicitly enabled.""" + import markitdown + + from hindsight_api.engine.parsers import MarkitdownParser + + calls = [] + + class FakeMarkItDown: + def __init__(self, **kwargs): + calls.append(kwargs) + + monkeypatch.setattr(markitdown, "MarkItDown", FakeMarkItDown) + + MarkitdownParser() + + assert calls == [{}] + + +@pytest.mark.asyncio +async def test_markitdown_image_without_ocr_has_actionable_error(monkeypatch): + """Image uploads should explain that MarkItDown OCR is disabled instead of surfacing a low-level error.""" + import markitdown + + from hindsight_api.engine.parsers import MarkitdownParser + + class FakeMarkItDown: + def __init__(self, **kwargs): + pass + + def convert(self, path): + raise AssertionError("MarkItDown should not be called when image OCR is disabled") + + monkeypatch.setattr(markitdown, "MarkItDown", FakeMarkItDown) + + parser = MarkitdownParser() + with pytest.raises(RuntimeError, match="Image OCR is not enabled for the markitdown parser"): + await parser.convert(b"\x89PNG\r\n\x1a\n", "screenshot.png") + + +def test_markitdown_converter_can_enable_ocr(monkeypatch): + """When enabled, Markitdown receives an OpenAI-compatible client, model, and OCR prompt.""" + import markitdown + import openai + + from hindsight_api.config import DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT + from hindsight_api.engine.parsers import MarkitdownParser + + markitdown_calls = [] + openai_calls = [] + + class FakeMarkItDown: + def __init__(self, **kwargs): + markitdown_calls.append(kwargs) + + class FakeOpenAI: + def __init__(self, **kwargs): + openai_calls.append(kwargs) + + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setattr(markitdown, "MarkItDown", FakeMarkItDown) + monkeypatch.setattr(openai, "OpenAI", FakeOpenAI) + + MarkitdownParser( + ocr_enabled=True, + ocr_api_key="parser-key", + ocr_base_url="https://vision.example/v1", + ocr_model="vision-model", + ) + + assert openai_calls == [ + { + "api_key": "parser-key", + "base_url": "https://vision.example/v1", + } + ] + assert markitdown_calls[0]["llm_client"].__class__ is FakeOpenAI + assert markitdown_calls[0]["llm_model"] == "vision-model" + assert markitdown_calls[0]["llm_prompt"] == DEFAULT_FILE_PARSER_MARKITDOWN_OCR_PROMPT + + +def test_markitdown_converter_requires_model_when_ocr_enabled(monkeypatch): + """OCR should fail fast when enabled without a model.""" + import markitdown + + from hindsight_api.engine.parsers import MarkitdownParser + + class FakeMarkItDown: + def __init__(self, **kwargs): + pass + + monkeypatch.setattr(markitdown, "MarkItDown", FakeMarkItDown) + + with pytest.raises(ValueError, match="no model"): + MarkitdownParser(ocr_enabled=True, ocr_api_key="parser-key") + + +def test_markitdown_converter_requires_base_url_when_ocr_enabled(monkeypatch): + """OCR should fail fast when enabled without a dedicated OpenAI-compatible endpoint.""" + import markitdown + + from hindsight_api.engine.parsers import MarkitdownParser + + class FakeMarkItDown: + def __init__(self, **kwargs): + pass + + monkeypatch.setattr(markitdown, "MarkItDown", FakeMarkItDown) + + with pytest.raises(ValueError, match="no base URL"): + MarkitdownParser(ocr_enabled=True, ocr_api_key="parser-key", ocr_model="vision-model") + + +def test_markitdown_converter_reports_missing_openai_when_ocr_enabled(monkeypatch): + """Missing OpenAI SDK should not be reported as missing MarkItDown.""" + import builtins + import markitdown + + from hindsight_api.engine.parsers import MarkitdownParser + + real_import = builtins.__import__ + + class FakeMarkItDown: + def __init__(self, **kwargs): + pass + + def fake_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "openai": + raise ImportError("no openai") + return real_import(name, globals, locals, fromlist, level) + + monkeypatch.setattr(markitdown, "MarkItDown", FakeMarkItDown) + monkeypatch.setattr(builtins, "__import__", fake_import) + + with pytest.raises(RuntimeError, match="openai package is required"): + MarkitdownParser( + ocr_enabled=True, + ocr_api_key="parser-key", + ocr_base_url="https://vision.example/v1", + ocr_model="vision-model", + ) + + @pytest.mark.asyncio async def test_converter_registry(): """Test file parser registry.""" diff --git a/hindsight-api-slim/tests/test_file_storage_s3.py b/hindsight-api-slim/tests/test_file_storage_s3.py index b94cadeadf..495fcd446b 100644 --- a/hindsight-api-slim/tests/test_file_storage_s3.py +++ b/hindsight-api-slim/tests/test_file_storage_s3.py @@ -12,6 +12,8 @@ import tempfile import time import uuid +from collections.abc import Iterator +from contextlib import contextmanager import httpx import pytest @@ -21,6 +23,7 @@ try: from testcontainers.core.container import DockerContainer + from testcontainers.core.docker_client import DockerClient as _DockerClient _has_testcontainers = True except ImportError: @@ -38,6 +41,8 @@ TEST_BUCKET = "hindsight-test" ACCESS_KEY = "test_access_key" SECRET_KEY = "test_secret_key" +_PORT_MAPPING_RETRY_TIMEOUT_SECONDS = 10.0 +_PORT_MAPPING_RETRY_INTERVAL_SECONDS = 0.1 # SeaweedFS S3 IAM config granting full access to our test credentials _S3_CONFIG = { @@ -64,6 +69,33 @@ def _docker_available() -> bool: return False +if _has_testcontainers: + + @contextmanager + def _retry_testcontainers_port_mapping() -> Iterator[None]: + original_port = _DockerClient.port + + def port_with_retry(self: _DockerClient, container_id: str, port: int) -> str: + deadline = time.monotonic() + _PORT_MAPPING_RETRY_TIMEOUT_SECONDS + while True: + try: + return original_port(self, container_id, port) + except ConnectionError: + # Docker Desktop can report a container as running before its + # published port appears in NetworkSettings.Ports. This affects + # both Ryuk's 8080 lookup inside testcontainers and the + # SeaweedFS S3 port lookup below. + if time.monotonic() >= deadline: + raise + time.sleep(_PORT_MAPPING_RETRY_INTERVAL_SECONDS) + + _DockerClient.port = port_with_retry + try: + yield + finally: + _DockerClient.port = original_port + + def _wait_for_seaweedfs(endpoint: str, timeout: int = 30) -> None: """Poll SeaweedFS S3 endpoint until ready.""" deadline = time.time() + timeout @@ -98,16 +130,14 @@ def seaweedfs_container(): DockerContainer(image="chrislusf/seaweedfs:latest") .with_exposed_ports(SEAWEEDFS_S3_PORT) .with_volume_mapping(s3_config_file.name, "/etc/seaweedfs/s3.json", "ro") - .with_command( - f"server -s3 -s3.port={SEAWEEDFS_S3_PORT} -s3.config=/etc/seaweedfs/s3.json -ip.bind=0.0.0.0" - ) + .with_command(f"server -s3 -s3.port={SEAWEEDFS_S3_PORT} -s3.config=/etc/seaweedfs/s3.json -ip.bind=0.0.0.0") ) - container.start() - try: - host = container.get_container_host_ip() - port = container.get_exposed_port(SEAWEEDFS_S3_PORT) + with _retry_testcontainers_port_mapping(): + container.start() + host = container.get_container_host_ip() + port = container.get_exposed_port(SEAWEEDFS_S3_PORT) endpoint = f"http://{host}:{port}" _wait_for_seaweedfs(endpoint, timeout=240) diff --git a/hindsight-api-slim/tests/test_fireworks_batch.py b/hindsight-api-slim/tests/test_fireworks_batch.py index 8e819273c3..38c74885dd 100644 --- a/hindsight-api-slim/tests/test_fireworks_batch.py +++ b/hindsight-api-slim/tests/test_fireworks_batch.py @@ -309,9 +309,7 @@ def handler(request: httpx.Request) -> httpx.Response: llm = _make_fireworks(http_client=client) with pytest.raises(httpx.HTTPStatusError, match="invalid field 'userUploaded'"): - await llm.submit_batch( - [{"custom_id": "c0", "method": "POST", "url": "/v1/chat/completions", "body": {}}] - ) + await llm.submit_batch([{"custom_id": "c0", "method": "POST", "url": "/v1/chat/completions", "body": {}}]) await client.aclose() diff --git a/hindsight-api-slim/tests/test_gemini_batch.py b/hindsight-api-slim/tests/test_gemini_batch.py new file mode 100644 index 0000000000..6fc20ec972 --- /dev/null +++ b/hindsight-api-slim/tests/test_gemini_batch.py @@ -0,0 +1,309 @@ +"""Tests for the Gemini Batch API provider path (``GeminiLLM`` batch overrides). + +Google's Gemini Batch API gives a flat 50% input+output discount with a 24h SLA +(https://ai.google.dev/gemini-api/docs/batch-api). ``GeminiLLM`` extends +``LLMInterface`` directly (not the OpenAI-compatible base), so it overrides the +four batch members and translates Gemini's file-upload -> ``batches.create`` -> +``batches.get`` -> download flow back onto the OpenAI-batch interface contract +that the retain orchestrator + ``fact_extraction`` consumer depend on. + +The interface contract that MUST be preserved (see fact_extraction.py):: + result["response"]["body"]["choices"][0]["message"]["content"] + +The pure translation/normalization helpers are unit-tested directly; the async +submit/status/retrieve flow is tested against a fake genai client. A live key is +only needed for the (separate) end-to-end path. +""" + +import json +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +pytest.importorskip("google.genai") + +from hindsight_api.engine.providers.gemini_llm import GeminiLLM + + +def _make_gemini(model: str = "gemini-2.5-flash") -> GeminiLLM: + # Patch the genai client constructor so no real credentials/network are used; + # the batch tests swap in a fake aio client below. + with patch("hindsight_api.engine.providers.gemini_llm.genai.Client", MagicMock()): + return GeminiLLM(provider="gemini", api_key="test-key", base_url="", model=model) + + +def _openai_request(custom_id: str, *, strict: bool = True) -> dict: + return { + "custom_id": custom_id, + "method": "POST", + "url": "/v1/chat/completions", + "body": { + "model": "gemini-2.5-flash", + "messages": [ + {"role": "system", "content": "Extract facts."}, + {"role": "user", "content": "Paris is the capital of France."}, + ], + "temperature": 0.1, + "max_completion_tokens": 2048, + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "facts", + "schema": {"type": "object", "properties": {"facts": {"type": "array"}}}, + "strict": strict, + }, + }, + }, + } + + +# -------------------------------------------------------------------------- +# Structural: capability flag (gemini yes, vertexai no) +# -------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_gemini_supports_batch_api_is_true(): + llm = _make_gemini() + assert await llm.supports_batch_api() is True + + +@pytest.mark.asyncio +async def test_vertexai_does_not_support_batch_api(): + # Vertex AI's batch path is GCS/BigQuery-backed (no file upload), so it stays + # unsupported — the startup validation then raises a clear error. + llm = _make_gemini() + llm.provider = "vertexai" + assert await llm.supports_batch_api() is False + + +# -------------------------------------------------------------------------- +# Pure translation: OpenAI body -> Gemini GenerateContentRequest +# -------------------------------------------------------------------------- + + +def test_translate_requests_builds_keyed_jsonl(): + jsonl = GeminiLLM._translate_requests([_openai_request("chunk_0"), _openai_request("chunk_1")]) + lines = jsonl.split("\n") + assert len(lines) == 2 + first = json.loads(lines[0]) + assert first["key"] == "chunk_0" + assert set(first["request"].keys()) == {"contents", "systemInstruction", "generationConfig"} + + +def test_body_translation_maps_roles_and_generation_config(): + req = GeminiLLM._openai_body_to_gemini_request(_openai_request("c")["body"]) + + # system -> systemInstruction; user -> contents(role=user) + assert req["contents"] == [{"role": "user", "parts": [{"text": "Paris is the capital of France."}]}] + assert req["systemInstruction"]["parts"][0]["text"].startswith("Extract facts.") + + gc = req["generationConfig"] + assert gc["temperature"] == 0.1 + assert gc["maxOutputTokens"] == 2048 + assert gc["responseMimeType"] == "application/json" + # strict=True -> grammar-enforced via responseJsonSchema + assert gc["responseJsonSchema"] == {"type": "object", "properties": {"facts": {"type": "array"}}} + # schema is also appended as a textual hint (mirrors the sync call path) + assert "valid JSON matching this schema" in req["systemInstruction"]["parts"][0]["text"] + + +def test_body_translation_omits_response_json_schema_when_not_strict(): + req = GeminiLLM._openai_body_to_gemini_request(_openai_request("c", strict=False)["body"]) + gc = req["generationConfig"] + # Non-strict still forces JSON output, but does not grammar-enforce the schema + assert gc["responseMimeType"] == "application/json" + assert "responseJsonSchema" not in gc + + +def test_assistant_role_maps_to_model(): + body = {"messages": [{"role": "assistant", "content": "prior turn"}]} + req = GeminiLLM._openai_body_to_gemini_request(body) + assert req["contents"] == [{"role": "model", "parts": [{"text": "prior turn"}]}] + assert "systemInstruction" not in req + + +# -------------------------------------------------------------------------- +# Pure normalization: Gemini output line -> OpenAI-batch-output shape +# -------------------------------------------------------------------------- + + +def test_normalize_output_line_success_preserves_contract(): + line = { + "key": "chunk_0", + "response": {"candidates": [{"content": {"parts": [{"text": '{"facts": []}'}]}}]}, + } + out = GeminiLLM._normalize_output_line(line) + assert out["custom_id"] == "chunk_0" + assert out["error"] is None + # The exact path fact_extraction.py reads: + assert out["response"]["body"]["choices"][0]["message"]["content"] == '{"facts": []}' + + +def test_normalize_output_line_concatenates_multiple_text_parts(): + line = { + "key": "chunk_0", + "response": {"candidates": [{"content": {"parts": [{"text": "a"}, {"text": "b"}]}}]}, + } + out = GeminiLLM._normalize_output_line(line) + assert out["response"]["body"]["choices"][0]["message"]["content"] == "ab" + + +def test_normalize_output_line_includes_translated_usage(): + line = { + "key": "chunk_0", + "response": { + "candidates": [{"content": {"parts": [{"text": "{}"}]}}], + "usageMetadata": {"promptTokenCount": 120, "candidatesTokenCount": 30, "totalTokenCount": 150}, + }, + } + out = GeminiLLM._normalize_output_line(line) + # OpenAI-shaped usage block the batch consumer accumulates (was missing -> usage=0). + assert out["response"]["body"]["usage"] == { + "prompt_tokens": 120, + "completion_tokens": 30, + "total_tokens": 150, + } + + +def test_normalize_output_line_omits_usage_when_absent(): + line = {"key": "chunk_0", "response": {"candidates": [{"content": {"parts": [{"text": "{}"}]}}]}} + out = GeminiLLM._normalize_output_line(line) + assert "usage" not in out["response"]["body"] + + +def test_normalize_output_line_error_surfaces_per_key(): + line = {"key": "chunk_1", "error": {"code": 400, "message": "bad request"}} + out = GeminiLLM._normalize_output_line(line) + assert out["custom_id"] == "chunk_1" + assert out["response"] is None + assert out["error"] == {"code": 400, "message": "bad request"} + + +def test_extract_text_from_empty_response_is_empty_string(): + assert GeminiLLM._extract_text_from_response({}) == "" + assert GeminiLLM._extract_text_from_response({"candidates": []}) == "" + + +# -------------------------------------------------------------------------- +# Pure status mapping +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "state,expected", + [ + ("JOB_STATE_SUCCEEDED", "completed"), + ("JOB_STATE_PARTIALLY_SUCCEEDED", "completed"), + ("JOB_STATE_FAILED", "failed"), + ("JOB_STATE_CANCELLED", "cancelled"), + ("JOB_STATE_CANCELLING", "cancelled"), + ("JOB_STATE_EXPIRED", "expired"), + ("JOB_STATE_RUNNING", "in_progress"), + ("JOB_STATE_PENDING", "in_progress"), + ("JOB_STATE_QUEUED", "in_progress"), + ("JOB_STATE_UNSPECIFIED", "in_progress"), + ], +) +def test_normalize_state(state, expected): + assert GeminiLLM._normalize_state(state) == expected + + +def test_normalize_state_handles_enum_and_qualified_string(): + assert GeminiLLM._normalize_state(SimpleNamespace(name="JOB_STATE_SUCCEEDED")) == "completed" + assert GeminiLLM._normalize_state("JobState.JOB_STATE_RUNNING") == "in_progress" + assert GeminiLLM._normalize_state(None) == "in_progress" + + +# -------------------------------------------------------------------------- +# Async flow: submit -> status -> retrieve against a fake genai client +# -------------------------------------------------------------------------- + + +def _fake_client(*, get_batch, download_text: bytes | None = None) -> MagicMock: + """Build a fake ``client.aio`` namespace covering the batch flow.""" + aio = MagicMock() + aio.files.upload = AsyncMock(return_value=SimpleNamespace(name="files/uploaded-123")) + aio.batches.create = AsyncMock(return_value=SimpleNamespace(name="batches/abc", state="JOB_STATE_PENDING")) + aio.batches.get = AsyncMock(return_value=get_batch) + if download_text is not None: + aio.files.download = AsyncMock(return_value=download_text) + client = MagicMock() + client.aio = aio + return client + + +@pytest.mark.asyncio +async def test_submit_batch_uploads_and_creates_job(): + llm = _make_gemini() + llm._client = _fake_client(get_batch=None) + + meta = await llm.submit_batch([_openai_request("chunk_0"), _openai_request("chunk_1")]) + + assert meta["batch_id"] == "batches/abc" + assert meta["status"] == "in_progress" # JOB_STATE_PENDING + assert meta["request_count"] == 2 + + # Uploaded JSONL had one line per request, and the job used the model + file. + upload_kwargs = llm._client.aio.files.upload.call_args.kwargs + uploaded_bytes = upload_kwargs["file"].getvalue().decode("utf-8") + assert len(uploaded_bytes.strip().split("\n")) == 2 + assert upload_kwargs["config"].mime_type == "jsonl" + + create_kwargs = llm._client.aio.batches.create.call_args.kwargs + assert create_kwargs["model"] == "gemini-2.5-flash" + assert create_kwargs["src"] == "files/uploaded-123" + + +@pytest.mark.asyncio +async def test_get_batch_status_maps_counts_and_output_file(): + batch = SimpleNamespace( + name="batches/abc", + state="JOB_STATE_SUCCEEDED", + completion_stats=SimpleNamespace(successful_count=3, failed_count=1, incomplete_count=0), + dest=SimpleNamespace(file_name="files/output-999"), + error=None, + ) + llm = _make_gemini() + llm._client = _fake_client(get_batch=batch) + + status = await llm.get_batch_status("batches/abc") + assert status["status"] == "completed" + assert status["request_counts"] == {"total": 4, "completed": 3, "failed": 1} + assert status["output_file_id"] == "files/output-999" + + +@pytest.mark.asyncio +async def test_retrieve_batch_results_downloads_and_normalizes(): + output_lines = [ + {"key": "chunk_0", "response": {"candidates": [{"content": {"parts": [{"text": '{"facts": []}'}]}}]}}, + {"key": "chunk_1", "error": {"code": 500, "message": "boom"}}, + ] + download_text = ("\n".join(json.dumps(line) for line in output_lines)).encode("utf-8") + batch = SimpleNamespace( + name="batches/abc", + state="JOB_STATE_SUCCEEDED", + completion_stats=None, + dest=SimpleNamespace(file_name="files/output-999"), + error=None, + ) + llm = _make_gemini() + llm._client = _fake_client(get_batch=batch, download_text=download_text) + + results = await llm.retrieve_batch_results("batches/abc") + by_id = {r["custom_id"]: r for r in results} + assert by_id["chunk_0"]["response"]["body"]["choices"][0]["message"]["content"] == '{"facts": []}' + assert by_id["chunk_1"]["error"] == {"code": 500, "message": "boom"} + + llm._client.aio.files.download.assert_awaited_once() + assert llm._client.aio.files.download.call_args.kwargs["file"] == "files/output-999" + + +@pytest.mark.asyncio +async def test_retrieve_batch_results_raises_when_not_completed(): + batch = SimpleNamespace(name="batches/abc", state="JOB_STATE_RUNNING", completion_stats=None, dest=None, error=None) + llm = _make_gemini() + llm._client = _fake_client(get_batch=batch) + with pytest.raises(ValueError, match="not completed"): + await llm.retrieve_batch_results("batches/abc") diff --git a/hindsight-api-slim/tests/test_gemini_batch_integration.py b/hindsight-api-slim/tests/test_gemini_batch_integration.py new file mode 100644 index 0000000000..867459b2e9 --- /dev/null +++ b/hindsight-api-slim/tests/test_gemini_batch_integration.py @@ -0,0 +1,133 @@ +"""Live integration test for the Gemini Batch API provider. + +This makes REAL calls to the Gemini Batch API and runs the full retain fact +extraction pipeline end-to-end (translate -> upload JSONL -> batches.create -> +poll -> download -> normalize -> parse facts). It is the only test that +validates the one assumption the unit tests (which use a fake genai client) +cannot: that Gemini's real batch output JSONL shape matches what +``_normalize_output_line`` produces and what ``fact_extraction`` consumes. If +the shape is wrong, this returns zero facts. + +This is an explicit opt-in test. It is gated on a dedicated flag rather than +just "a Gemini API key exists" because CI always has a Gemini key (Gemini is the +LLM-as-judge / core-LLM provider) — keying off the API key alone would let this +slow batch job run in the standard CI shard and blow the 300s pytest timeout. To +run it: + + export HINDSIGHT_API_GEMINI_BATCH_LIVE_TEST=1 + export GEMINI_API_KEY=... # or HINDSIGHT_API_GEMINI_API_KEY + # optional: override the model + export HINDSIGHT_API_GEMINI_TEST_MODEL=gemini-2.5-flash + uv run pytest tests/test_gemini_batch_integration.py -v -s + +It is slow (typically minutes, but Gemini's batch queue can take far longer; the +SLA is up to 24h) and costs money, so it never runs in CI. No database is +required — it calls the extraction function directly with ``pool=None``. +""" + +import logging +import os +from dataclasses import dataclass +from datetime import datetime, timezone + +import pytest +from dotenv import load_dotenv + +from hindsight_api.config import HindsightConfig, clear_config_cache +from hindsight_api.engine.llm_wrapper import LLMProvider +from hindsight_api.engine.retain.fact_extraction import ( + RetainContent, + extract_facts_from_contents_batch_api, +) + +logger = logging.getLogger(__name__) + +load_dotenv() + +_DEFAULT_TEST_MODEL = "gemini-2.5-flash" + + +@dataclass +class GeminiTestEnv: + api_key: str + model: str + + +@pytest.fixture +def gemini_env() -> GeminiTestEnv: + # Opt-in flag, NOT just key presence: CI always has GEMINI_API_KEY (judge / + # core-LLM provider), so gating on the key alone runs this slow batch job in + # the standard CI shard and times out. Require an explicit flag CI never sets. + if os.getenv("HINDSIGHT_API_GEMINI_BATCH_LIVE_TEST", "").lower() not in ("1", "true", "yes"): + pytest.skip("Set HINDSIGHT_API_GEMINI_BATCH_LIVE_TEST=1 (and GEMINI_API_KEY) to run the live Gemini batch test") + + api_key = os.getenv("GEMINI_API_KEY") or os.getenv("HINDSIGHT_API_GEMINI_API_KEY") + if not api_key: + pytest.skip("Set GEMINI_API_KEY (or HINDSIGHT_API_GEMINI_API_KEY) to run the live Gemini batch test") + + clear_config_cache() + + return GeminiTestEnv( + api_key=api_key, + model=os.getenv("HINDSIGHT_API_GEMINI_TEST_MODEL", _DEFAULT_TEST_MODEL), + ) + + +@pytest.mark.integration +@pytest.mark.slow +@pytest.mark.asyncio +async def test_real_gemini_batch_end_to_end(gemini_env): + config = HindsightConfig.from_env() + config.retain_batch_enabled = True + config.retain_batch_poll_interval_seconds = 30 + config.retain_chunk_size = 4000 + config.retain_extraction_mode = "concise" + config.retain_extract_causal_links = False + + llm_config = LLMProvider( + provider="gemini", + api_key=gemini_env.api_key, + base_url="", + model=gemini_env.model, + reasoning_effort="low", + ) + assert await llm_config._provider_impl.supports_batch_api() is True + + contents = [ + RetainContent( + content=( + "Alice is a senior software engineer at TechCorp. She specializes in " + "distributed systems and graduated from MIT in 2015." + ), + event_date=datetime(2024, 1, 15, tzinfo=timezone.utc), + context="team member profile", + ) + ] + + logger.info("Submitting a real Gemini batch (this can take several minutes)...") + facts, chunks, usage = await extract_facts_from_contents_batch_api( + contents=contents, + llm_config=llm_config, + agent_name="test_agent", + config=config, + pool=None, + operation_id=None, + schema=None, + ) + + # The end-to-end proof: if the real output shape doesn't match the normalizer, + # the consumer extracts nothing and this is empty. + assert len(facts) > 0, ( + "Gemini batch returned no facts. The live output JSONL shape likely " + "differs from what _normalize_output_line produces — inspect a raw output " + "line and adjust the normalizer." + ) + assert any("Alice" in fact.fact_text for fact in facts) + # Token usage must be threaded from Gemini's usageMetadata into the batch + # result body — otherwise the consumer reports zero (the bug this guards). + assert usage.total_tokens > 0, "Gemini batch reported zero token usage — usageMetadata translation is broken" + assert usage.input_tokens > 0 and usage.output_tokens > 0 + logger.info( + f"Extracted {len(facts)} facts; usage in={usage.input_tokens} out={usage.output_tokens} " + f"total={usage.total_tokens} tokens" + ) diff --git a/hindsight-api-slim/tests/test_gemini_cache.py b/hindsight-api-slim/tests/test_gemini_cache.py index ec0c0c61e0..2a070c17c0 100644 --- a/hindsight-api-slim/tests/test_gemini_cache.py +++ b/hindsight-api-slim/tests/test_gemini_cache.py @@ -33,9 +33,7 @@ def _make_client(create_side_effect=None): elif callable(create_side_effect): create_mock.side_effect = create_side_effect else: - create_mock.return_value = SimpleNamespace( - name="cachedContents/test-cache-name-001" - ) + create_mock.return_value = SimpleNamespace(name="cachedContents/test-cache-name-001") client = MagicMock() client.aio = MagicMock() @@ -127,18 +125,12 @@ async def test_first_call_creates_subsequent_reuses(): @pytest.mark.asyncio async def test_different_prefixes_create_separately(): client, create_mock = _make_client( - create_side_effect=lambda *a, **kw: SimpleNamespace( - name=f"cachedContents/created-{create_mock.call_count}" - ) + create_side_effect=lambda *a, **kw: SimpleNamespace(name=f"cachedContents/created-{create_mock.call_count}") ) mgr = GeminiCacheManager(client) - name_a = await mgr.get_or_create( - model="m", system_instruction="A", response_schema=None - ) - name_b = await mgr.get_or_create( - model="m", system_instruction="B", response_schema=None - ) + name_a = await mgr.get_or_create(model="m", system_instruction="A", response_schema=None) + name_b = await mgr.get_or_create(model="m", system_instruction="B", response_schema=None) assert name_a != name_b assert create_mock.call_count == 2 @@ -155,9 +147,7 @@ async def test_minimum_token_count_error_returns_none(): client, _ = _make_client(create_side_effect=err) mgr = GeminiCacheManager(client) - result = await mgr.get_or_create( - model="m", system_instruction="tiny", response_schema=None - ) + result = await mgr.get_or_create(model="m", system_instruction="tiny", response_schema=None) assert result is None @@ -169,9 +159,7 @@ async def test_other_sdk_errors_also_return_none(): client, _ = _make_client(create_side_effect=err) mgr = GeminiCacheManager(client) - result = await mgr.get_or_create( - model="m", system_instruction="ok-sized prefix", response_schema=None - ) + result = await mgr.get_or_create(model="m", system_instruction="ok-sized prefix", response_schema=None) assert result is None @@ -194,12 +182,8 @@ async def maybe_fail(*args, **kwargs): mgr = GeminiCacheManager(client) - first = await mgr.get_or_create( - model="m", system_instruction="prefix", response_schema=None - ) - second = await mgr.get_or_create( - model="m", system_instruction="prefix", response_schema=None - ) + first = await mgr.get_or_create(model="m", system_instruction="prefix", response_schema=None) + second = await mgr.get_or_create(model="m", system_instruction="prefix", response_schema=None) assert first is None assert second == "cachedContents/recovered" @@ -214,9 +198,7 @@ async def test_refreshes_after_ttl_margin(monkeypatch): """An entry created at t=0 with ttl=10 and margin=2 should be treated as stale at t>=8 and trigger a recreate.""" client, create_mock = _make_client( - create_side_effect=lambda *a, **kw: SimpleNamespace( - name=f"cachedContents/v{create_mock.call_count}" - ) + create_side_effect=lambda *a, **kw: SimpleNamespace(name=f"cachedContents/v{create_mock.call_count}") ) mgr = GeminiCacheManager(client, ttl_seconds=10, refresh_margin_seconds=2) @@ -226,24 +208,18 @@ async def test_refreshes_after_ttl_margin(monkeypatch): lambda: fake_now["t"], ) - first = await mgr.get_or_create( - model="m", system_instruction="p", response_schema=None - ) + first = await mgr.get_or_create(model="m", system_instruction="p", response_schema=None) assert first == "cachedContents/v1" # Advance to just before the refresh boundary — should reuse. fake_now["t"] = 1000.0 + 7.0 - again = await mgr.get_or_create( - model="m", system_instruction="p", response_schema=None - ) + again = await mgr.get_or_create(model="m", system_instruction="p", response_schema=None) assert again == "cachedContents/v1" assert create_mock.call_count == 1 # Advance past the refresh boundary — should recreate. fake_now["t"] = 1000.0 + 9.0 - refreshed = await mgr.get_or_create( - model="m", system_instruction="p", response_schema=None - ) + refreshed = await mgr.get_or_create(model="m", system_instruction="p", response_schema=None) assert refreshed == "cachedContents/v2" assert create_mock.call_count == 2 @@ -295,9 +271,7 @@ async def test_gemini_llm_uses_cache_when_enabled(monkeypatch): # Replace the SDK-shaped client with a fake whose caches.create returns # a predictable name. The lazy import inside get_or_create_cached_prefix # picks up the patched module-level GeminiCacheManager naturally. - fake_create = AsyncMock( - return_value=SimpleNamespace(name="cachedContents/from-llm-test") - ) + fake_create = AsyncMock(return_value=SimpleNamespace(name="cachedContents/from-llm-test")) llm._client = MagicMock() llm._client.aio = MagicMock() llm._client.aio.caches = MagicMock() @@ -333,7 +307,9 @@ async def test_call_falls_back_to_uncached_when_cache_400s(): from hindsight_api.engine.providers.gemini_cache import GeminiCacheManager, _CacheEntry from hindsight_api.engine.providers.gemini_llm import GeminiLLM - llm = GeminiLLM(provider="gemini", api_key="not-real-key", base_url="", model="gemini-test", prompt_cache_enabled=True) + llm = GeminiLLM( + provider="gemini", api_key="not-real-key", base_url="", model="gemini-test", prompt_cache_enabled=True + ) # Seed a cache manager entry that maps to the (now invalid) cache name. mgr = GeminiCacheManager(client=MagicMock()) @@ -413,9 +389,7 @@ def test_fingerprint_changes_with_tools(): """Two prefixes that differ ONLY in tools must hash differently — otherwise a loop that adds a tool would silently reuse a stale cache that doesn't know about it.""" - tools_a = [ - {"type": "function", "function": {"name": "search", "description": "search", "parameters": {}}} - ] + tools_a = [{"type": "function", "function": {"name": "search", "description": "search", "parameters": {}}}] tools_b = [ {"type": "function", "function": {"name": "search", "description": "search", "parameters": {}}}, {"type": "function", "function": {"name": "fetch", "description": "fetch", "parameters": {}}}, @@ -455,7 +429,10 @@ async def fake_create(*, model, config): mgr = GeminiCacheManager(client) tools = [ - {"type": "function", "function": {"name": "search", "description": "do a search", "parameters": {"type": "object"}}} + { + "type": "function", + "function": {"name": "search", "description": "do a search", "parameters": {"type": "object"}}, + } ] name = await mgr.get_or_create( model="gemini-3.1-flash-lite", diff --git a/hindsight-api-slim/tests/test_gemini_embeddings.py b/hindsight-api-slim/tests/test_gemini_embeddings.py index 3264221e6f..d11f20912d 100644 --- a/hindsight-api-slim/tests/test_gemini_embeddings.py +++ b/hindsight-api-slim/tests/test_gemini_embeddings.py @@ -10,6 +10,7 @@ 6. Factory function (create from env, validation errors) """ +import os from typing import Any from unittest.mock import MagicMock, patch @@ -20,7 +21,11 @@ ENV_EMBEDDINGS_PROVIDER, HindsightConfig, ) -from hindsight_api.engine.embeddings import GeminiEmbeddings, create_embeddings_from_env +from hindsight_api.engine.embeddings import ( + GeminiEmbeddings, + _gemini_model_aggregates_inputs, + create_embeddings_from_env, +) def _make_mock_embedding(values: list[float]) -> MagicMock: @@ -239,6 +244,40 @@ def test_encode_passes_config(self): emb.encode(["hello"]) assert mock_client.models.embed_content.call_args.kwargs["config"] is emb._embed_config + def test_encode_aggregating_model_embeds_one_per_call(self): + """Gemini Embedding 2+ aggregates multi-input requests, so each text must + be embedded in its own call to keep 1:1 input→vector alignment.""" + emb = GeminiEmbeddings(model="gemini-embedding-2-preview", api_key="test-key", batch_size=100) + mock_client = MagicMock() + mock_client.models.embed_content = MagicMock( + side_effect=[ + _make_mock_embed_result([[0.1]]), + _make_mock_embed_result([[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]] + # One call per input despite batch_size=100. + assert mock_client.models.embed_content.call_count == 3 + for call in mock_client.models.embed_content.call_args_list: + assert len(call.kwargs["contents"]) == 1 + + def test_encode_raises_on_misaligned_vector_count(self): + """A backend that aggregates inputs (returns fewer vectors than texts) + must raise rather than silently misalign vectors with inputs.""" + emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key", batch_size=100) + mock_client = MagicMock() + # 3 inputs in one batch but only 1 vector returned (aggregation). + mock_client.models.embed_content = MagicMock(return_value=_make_mock_embed_result([[0.1]])) + emb._client = mock_client + emb._dimension = 1 + + with pytest.raises(RuntimeError, match="expected exact 1:1 alignment"): + emb.encode(["a", "b", "c"]) + def test_encode_empty_list(self): emb = GeminiEmbeddings(model="gemini-embedding-001", api_key="test-key") emb._client = MagicMock() @@ -273,6 +312,20 @@ def test_custom_region(self): emb = GeminiEmbeddings(model="m", vertexai_project_id="proj", vertexai_region="europe-west1") assert emb.vertexai_region == "europe-west1" + @pytest.mark.parametrize( + "model,expected", + [ + ("gemini-embedding-001", False), + ("gemini-embedding-2-preview", True), + ("gemini-embedding-2", True), + ("models/gemini-embedding-2-preview", True), + ("google/gemini-embedding-2", True), + ("text-embedding-004", False), + ], + ) + def test_aggregating_model_detection(self, model, expected): + assert _gemini_model_aggregates_inputs(model) is expected + class TestGeminiEmbeddingsFactory: """Tests for create_embeddings_from_env() with 'google' provider.""" @@ -361,3 +414,66 @@ def test_create_with_custom_dimensionality(self): with patch("hindsight_api.config.get_config", return_value=config): emb = create_embeddings_from_env() assert emb.output_dimensionality == 256 + + +@pytest.mark.asyncio +@pytest.mark.skipif( + not os.getenv("HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"), + reason="Vertex AI integration tests require HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID", +) +async def test_gemini_embedding_2_vertexai_one_vector_per_input(): + """Real Vertex AI check that the gemini-embedding-2 family stays 1:1. + + These multimodal models aggregate a multi-input request into a single + embedding, so encode() must embed one input per call. Before the fix this + returned a single aggregated vector for the whole batch (the bug in #1139). + Runs in the CI jobs that provide GCP credentials; skips locally otherwise. + """ + project_id = os.getenv("HINDSIGHT_API_EMBEDDINGS_VERTEXAI_PROJECT_ID") or os.getenv( + "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID" + ) + region = ( + os.getenv("HINDSIGHT_API_EMBEDDINGS_VERTEXAI_REGION") + or os.getenv("HINDSIGHT_API_LLM_VERTEXAI_REGION") + or "us-central1" + ) + service_account_key = os.getenv("HINDSIGHT_API_EMBEDDINGS_VERTEXAI_SERVICE_ACCOUNT_KEY") or os.getenv( + "HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY" + ) + # Overridable so the model can be bumped (e.g. to GA) without a code change. + model = os.getenv("HINDSIGHT_API_EMBEDDINGS_GEMINI_MODEL", "gemini-embedding-2-preview") + + emb = GeminiEmbeddings( + model=model, + vertexai_project_id=project_id, + vertexai_region=region, + vertexai_service_account_key=service_account_key, + output_dimensionality=768, + ) + + texts = [ + "The sky is blue.", + "I visited Paris in 2023.", + "Python is a programming language.", + ] + + from google.genai.errors import APIError + + try: + await emb.initialize() + vectors = emb.encode(texts) + except APIError as e: + # gemini-embedding-2 is a preview/allowlisted model not enabled in every + # Vertex project (e.g. CI returns 400 FAILED_PRECONDITION). Skip rather + # than fail when the project lacks access — a real aggregation regression + # surfaces below as a wrong vector count (RuntimeError/AssertionError), + # never as an APIError, so this skip cannot mask the behavior under test. + pytest.skip(f"gemini-embedding-2 not available in this Vertex project: {e}") + + # The fix: one vector per input, not a single aggregated vector. + assert len(vectors) == len(texts) + assert all(len(v) == emb.dimension for v in vectors) + # Distinct inputs must produce distinct vectors (proves no aggregation). + assert vectors[0] != vectors[1] + assert vectors[1] != vectors[2] + assert vectors[0] != vectors[2] diff --git a/hindsight-api-slim/tests/test_gemini_implicit_cache_ratio.py b/hindsight-api-slim/tests/test_gemini_implicit_cache_ratio.py index 6c00a0ec0d..9de2c6ccf1 100644 --- a/hindsight-api-slim/tests/test_gemini_implicit_cache_ratio.py +++ b/hindsight-api-slim/tests/test_gemini_implicit_cache_ratio.py @@ -34,9 +34,7 @@ from hindsight_api.engine.llm_trace import LLMRequestEntry from hindsight_api.engine.llm_wrapper import LLMConfig -_GEMINI_API_KEY = ( - os.getenv("HINDSIGHT_GEMINI_API_KEY") or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") -) +_GEMINI_API_KEY = os.getenv("HINDSIGHT_GEMINI_API_KEY") or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") _RUN = os.getenv("HINDSIGHT_RUN_GEMINI_EVALS") == "1" and bool(_GEMINI_API_KEY) pytestmark = pytest.mark.skipif( diff --git a/hindsight-api-slim/tests/test_gemini_safety_settings.py b/hindsight-api-slim/tests/test_gemini_safety_settings.py index 5d9c73f323..21bc0e8a2b 100644 --- a/hindsight-api-slim/tests/test_gemini_safety_settings.py +++ b/hindsight-api-slim/tests/test_gemini_safety_settings.py @@ -133,13 +133,17 @@ async def test_call_applies_safety_settings(): assert hasattr(config_arg, "safety_settings"), "Config should have safety_settings" assert config_arg.safety_settings is not None - categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings] + categories = [ + s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings + ] assert "HARM_CATEGORY_HARASSMENT" in categories assert "HARM_CATEGORY_HATE_SPEECH" in categories assert "HARM_CATEGORY_SEXUALLY_EXPLICIT" in categories assert "HARM_CATEGORY_DANGEROUS_CONTENT" in categories - thresholds = [s.threshold.value if hasattr(s.threshold, "value") else str(s.threshold) for s in config_arg.safety_settings] + thresholds = [ + s.threshold.value if hasattr(s.threshold, "value") else str(s.threshold) for s in config_arg.safety_settings + ] assert all(t == "BLOCK_NONE" for t in thresholds) @@ -212,7 +216,9 @@ async def test_call_with_tools_applies_safety_settings(): assert config_arg is not None assert config_arg.safety_settings is not None - categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings] + categories = [ + s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings + ] assert "HARM_CATEGORY_HARASSMENT" in categories @@ -266,7 +272,9 @@ async def test_with_config_overrides_instance_settings(): config_arg = provider._provider_impl._client.aio.models.generate_content.call_args.kwargs.get("config") assert config_arg is not None - categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings] + categories = [ + s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings + ] # Should use override_settings (HATE_SPEECH), not instance_settings (HARASSMENT) assert "HARM_CATEGORY_HATE_SPEECH" in categories assert "HARM_CATEGORY_HARASSMENT" not in categories @@ -285,7 +293,9 @@ async def test_with_config_none_falls_back_to_instance(): config_arg = provider._provider_impl._client.aio.models.generate_content.call_args.kwargs.get("config") assert config_arg is not None - categories = [s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings] + categories = [ + s.category.value if hasattr(s.category, "value") else str(s.category) for s in config_arg.safety_settings + ] assert "HARM_CATEGORY_HARASSMENT" in categories diff --git a/hindsight-api-slim/tests/test_gemini_service_tier.py b/hindsight-api-slim/tests/test_gemini_service_tier.py new file mode 100644 index 0000000000..2d8331a58e --- /dev/null +++ b/hindsight-api-slim/tests/test_gemini_service_tier.py @@ -0,0 +1,103 @@ +"""Plumbing tests for the Gemini service tier flag.""" + +from unittest.mock import MagicMock, patch + +import pytest + +from hindsight_api.engine.llm_wrapper import LLMConfig + + +def test_llm_config_threads_gemini_service_tier_to_provider_impl(): + """End-to-end: LLMConfig -> create_llm_provider -> GeminiLLM carries the tier.""" + pytest.importorskip("google.genai") + with patch("google.genai.Client", return_value=MagicMock()): + llm = LLMConfig( + provider="gemini", + api_key="fake-key", + base_url="", + model="gemini-2.5-flash", + gemini_service_tier="flex", + ) + + assert llm._provider_impl._service_tier == "flex" + + +def test_llm_provider_from_env_validates_gemini_service_tier(monkeypatch): + """Direct env construction rejects the same invalid tiers as HindsightConfig.""" + from hindsight_api.config import clear_config_cache + from hindsight_api.engine.llm_wrapper import LLMProvider + + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "gemini") + monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "fake-key") + monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "standard") + clear_config_cache() + + with pytest.raises(ValueError, match="HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER"): + LLMProvider.from_env() + + clear_config_cache() + + +def test_llm_provider_from_env_ignores_gemini_tier_for_non_gemini(monkeypatch): + """Invalid Gemini-only tier env values do not break other providers.""" + from hindsight_api.config import clear_config_cache + from hindsight_api.engine.llm_wrapper import LLMProvider + + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock") + monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "standard") + clear_config_cache() + + provider = LLMProvider.from_env() + + assert provider.gemini_service_tier is None + clear_config_cache() + + +def test_llm_provider_from_env_keeps_lightweight_loader(monkeypatch): + """Reading the Gemini tier must not construct the full application config.""" + from hindsight_api.config import clear_config_cache + from hindsight_api.engine.llm_wrapper import LLMProvider + + monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "gemini") + monkeypatch.setenv("HINDSIGHT_API_LLM_API_KEY", "fake-key") + monkeypatch.setenv("HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER", "flex") + monkeypatch.setenv("HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS", "1000") + monkeypatch.setenv("HINDSIGHT_API_RETAIN_CHUNK_SIZE", "2000") + clear_config_cache() + + with patch("google.genai.Client", return_value=MagicMock()): + provider = LLMProvider.from_env() + + assert provider.gemini_service_tier == "flex" + clear_config_cache() + + +def test_llm_provider_constructor_validates_gemini_service_tier(): + """Direct Gemini construction rejects invalid tiers before API calls.""" + from hindsight_api.engine.llm_wrapper import LLMProvider + + with pytest.raises(ValueError, match="HINDSIGHT_API_LLM_GEMINI_SERVICE_TIER"): + LLMProvider( + provider="gemini", + api_key="fake-key", + base_url="", + model="gemini-2.5-flash", + gemini_service_tier="standard", + ) + + +def test_vertexai_ignores_gemini_service_tier(): + """The Gemini-only tier flag is not forwarded to Vertex AI providers.""" + from hindsight_api.engine.llm_wrapper import create_llm_provider + + with patch("hindsight_api.engine.providers.GeminiLLM") as mock_gemini: + create_llm_provider( + provider="vertexai", + api_key="", + base_url="", + model="gemini-2.5-flash", + reasoning_effort="low", + gemini_service_tier="flex", + ) + + assert mock_gemini.call_args.kwargs["gemini_service_tier"] is None diff --git a/hindsight-api-slim/tests/test_google_cross_encoder.py b/hindsight-api-slim/tests/test_google_cross_encoder.py index 26d92eeb79..3f8d67c522 100644 --- a/hindsight-api-slim/tests/test_google_cross_encoder.py +++ b/hindsight-api-slim/tests/test_google_cross_encoder.py @@ -97,19 +97,23 @@ async def test_initialization_idempotent(self): 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)]), - ]) + 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"), - ]) + 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 @@ -119,21 +123,25 @@ async def test_predict_single_query(self): 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)]), - ]) + 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"), - ]) + 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 @@ -161,10 +169,12 @@ async def test_predict_not_initialized(self): 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)]), - ]) + 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")): @@ -181,9 +191,11 @@ 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)]), - ]) + 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")): diff --git a/hindsight-api-slim/tests/test_graph_filtering.py b/hindsight-api-slim/tests/test_graph_filtering.py index ccbb835e6a..f30bae21b6 100644 --- a/hindsight-api-slim/tests/test_graph_filtering.py +++ b/hindsight-api-slim/tests/test_graph_filtering.py @@ -269,3 +269,82 @@ async def test_graph_q_filter_empty_results(api_client, test_bank_id): assert response.status_code == 200 data = response.json() assert data["table_rows"] == [] + + +async def _seed_scoped_observations(memory, bank_id, request_context): + """Seed observations under scopes [a], [b], [a,b] (x2) and the global scope.""" + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + rows = [ + (uuid.uuid4(), "obs scope a", ["a"]), + (uuid.uuid4(), "obs scope b", ["b"]), + (uuid.uuid4(), "obs scope ab one", ["a", "b"]), + (uuid.uuid4(), "obs scope ab two", ["b", "a"]), # same scope as above, different order + (uuid.uuid4(), "obs global", []), + ] + async with memory._pool.acquire() as conn: + for obs_id, text, tags in rows: + await conn.execute( + """ + INSERT INTO memory_units (id, bank_id, text, fact_type, tags, proof_count) + VALUES ($1, $2, $3, 'observation', $4::text[], 1) + """, + obs_id, + bank_id, + text, + tags, + ) + return rows + + +@pytest.mark.asyncio +async def test_observation_scopes_enumeration(memory, api_client, test_bank_id, request_context): + """The scopes endpoint enumerates distinct tag sets (order-normalized) with counts.""" + await _seed_scoped_observations(memory, test_bank_id, request_context) + + response = await api_client.get(f"/v1/default/banks/{test_bank_id}/observations/scopes") + assert response.status_code == 200 + scopes = response.json()["scopes"] + + # [a,b] and [b,a] collapse into one scope with count 2; global scope is []. + as_map = {tuple(s["tags"]): s["count"] for s in scopes} + assert as_map == {("a",): 1, ("b",): 1, ("a", "b"): 2, (): 1} + # Most populous scope is first. + assert scopes[0]["tags"] == ["a", "b"] + + +@pytest.mark.asyncio +async def test_graph_exact_scope_filter(memory, api_client, test_bank_id, request_context): + """tags_match=exact filters observations to exactly one scope, not supersets.""" + await _seed_scoped_observations(memory, test_bank_id, request_context) + + # Exact scope [a] returns only the [a] observation, NOT the [a,b] ones. + response = await api_client.get( + f"/v1/default/banks/{test_bank_id}/graph", + params={"type": "observation", "tags": ["a"], "tags_match": "exact"}, + ) + assert response.status_code == 200 + texts = {row["text"] for row in response.json()["table_rows"]} + assert texts == {"obs scope a"} + + # Exact scope [a,b] returns both [a,b] observations regardless of stored order. + response = await api_client.get( + f"/v1/default/banks/{test_bank_id}/graph", + params={"type": "observation", "tags": ["a", "b"], "tags_match": "exact"}, + ) + assert response.status_code == 200 + texts = {row["text"] for row in response.json()["table_rows"]} + assert texts == {"obs scope ab one", "obs scope ab two"} + + +@pytest.mark.asyncio +async def test_graph_exact_global_scope_filter(memory, api_client, test_bank_id, request_context): + """tags_match=exact with no tags is the global scope: untagged observations only.""" + await _seed_scoped_observations(memory, test_bank_id, request_context) + + response = await api_client.get( + f"/v1/default/banks/{test_bank_id}/graph", + params={"type": "observation", "tags_match": "exact"}, + ) + assert response.status_code == 200 + texts = {row["text"] for row in response.json()["table_rows"]} + assert texts == {"obs global"} diff --git a/hindsight-api-slim/tests/test_graph_maintenance.py b/hindsight-api-slim/tests/test_graph_maintenance.py index 62fbb2c99e..9bcf70830a 100644 --- a/hindsight-api-slim/tests/test_graph_maintenance.py +++ b/hindsight-api-slim/tests/test_graph_maintenance.py @@ -167,9 +167,7 @@ async def test_enqueues_units_with_outgoing_link_to_deleted( assert await _queue_unit_ids(conn, bank_id) == [str(survivor)] @pytest.mark.asyncio - async def test_excludes_deleted_units_themselves( - self, memory: MemoryEngine, request_context: RequestContext - ): + async def test_excludes_deleted_units_themselves(self, memory: MemoryEngine, request_context: RequestContext): """A unit being deleted that linked TO another deleted unit must not enqueue itself.""" bank_id = f"test-gm-self-{uuid.uuid4().hex[:8]}" await _ensure_bank(memory, bank_id, request_context) @@ -270,9 +268,7 @@ async def test_delete_document_enqueues_cross_doc_victims( class TestRelinkPass: @pytest.mark.asyncio - async def test_drains_empty_queue_cleanly( - self, memory: MemoryEngine, request_context: RequestContext - ): + async def test_drains_empty_queue_cleanly(self, memory: MemoryEngine, request_context: RequestContext): bank_id = f"test-gm-empty-{uuid.uuid4().hex[:8]}" await _ensure_bank(memory, bank_id, request_context) @@ -285,9 +281,7 @@ async def test_drains_empty_queue_cleanly( } @pytest.mark.asyncio - async def test_skips_missing_unit_silently( - self, memory: MemoryEngine, request_context: RequestContext - ): + async def test_skips_missing_unit_silently(self, memory: MemoryEngine, request_context: RequestContext): """Unit deleted between enqueue and drain: worker dequeues and no-ops.""" bank_id = f"test-gm-miss-{uuid.uuid4().hex[:8]}" await _ensure_bank(memory, bank_id, request_context) @@ -309,9 +303,7 @@ async def test_skips_missing_unit_silently( assert await _queue_unit_ids(conn, bank_id) == [] @pytest.mark.asyncio - async def test_tops_up_temporal_when_under_cap( - self, memory: MemoryEngine, request_context: RequestContext - ): + async def test_tops_up_temporal_when_under_cap(self, memory: MemoryEngine, request_context: RequestContext): """A victim under the temporal cap gets new outgoing links to neighbours that were never linked at retain time.""" bank_id = f"test-gm-topup-{uuid.uuid4().hex[:8]}" @@ -365,9 +357,7 @@ async def test_tops_up_temporal_when_under_cap( assert await _queue_unit_ids(conn, bank_id) == [] @pytest.mark.asyncio - async def test_no_topup_when_victim_at_cap( - self, memory: MemoryEngine, request_context: RequestContext - ): + async def test_no_topup_when_victim_at_cap(self, memory: MemoryEngine, request_context: RequestContext): """If the victim already has cap links, probing is skipped.""" bank_id = f"test-gm-atcap-{uuid.uuid4().hex[:8]}" await _ensure_bank(memory, bank_id, request_context) @@ -414,9 +404,7 @@ async def test_no_topup_when_victim_at_cap( class TestOrphanEntityPrune: @pytest.mark.asyncio - async def test_prunes_entities_with_no_unit_references( - self, memory: MemoryEngine, request_context: RequestContext - ): + async def test_prunes_entities_with_no_unit_references(self, memory: MemoryEngine, request_context: RequestContext): """An entity with zero unit_entities rows is an orphan and should be deleted by the sweep.""" bank_id = f"test-gm-orphan-{uuid.uuid4().hex[:8]}" @@ -435,9 +423,7 @@ async def test_prunes_entities_with_no_unit_references( assert result["orphan_entities_pruned"] == 2 async with pool.acquire() as conn: - survivors = await conn.fetch( - "SELECT id FROM entities WHERE bank_id = $1 ORDER BY id", bank_id - ) + survivors = await conn.fetch("SELECT id FROM entities WHERE bank_id = $1 ORDER BY id", bank_id) survivor_ids = {str(r["id"]) for r in survivors} assert survivor_ids == {str(referenced)} # Confirm orphans are gone. @@ -445,9 +431,7 @@ async def test_prunes_entities_with_no_unit_references( assert orphan not in survivor_ids @pytest.mark.asyncio - async def test_does_not_touch_other_banks( - self, memory: MemoryEngine, request_context: RequestContext - ): + async def test_does_not_touch_other_banks(self, memory: MemoryEngine, request_context: RequestContext): """The sweep is scoped by bank — orphan entities in OTHER banks must not be touched.""" bank_a = f"test-gm-scopea-{uuid.uuid4().hex[:8]}" @@ -478,9 +462,7 @@ async def test_does_not_touch_other_banks( class TestStaleCooccurrencePrune: @pytest.mark.asyncio - async def test_prunes_cooccurrence_with_no_shared_unit( - self, memory: MemoryEngine, request_context: RequestContext - ): + async def test_prunes_cooccurrence_with_no_shared_unit(self, memory: MemoryEngine, request_context: RequestContext): """Both entities still exist but no unit references both of them — the cooccurrence row is stale and should be pruned.""" bank_id = f"test-gm-cocc-{uuid.uuid4().hex[:8]}" @@ -515,9 +497,7 @@ async def test_prunes_cooccurrence_with_no_shared_unit( assert remaining == 0 @pytest.mark.asyncio - async def test_keeps_cooccurrence_with_shared_unit( - self, memory: MemoryEngine, request_context: RequestContext - ): + async def test_keeps_cooccurrence_with_shared_unit(self, memory: MemoryEngine, request_context: RequestContext): """If at least one unit still references both entities, the cooccurrence row stays.""" bank_id = f"test-gm-keep-{uuid.uuid4().hex[:8]}" diff --git a/hindsight-api-slim/tests/test_hierarchical_config.py b/hindsight-api-slim/tests/test_hierarchical_config.py index f99490d8f0..6018fdc244 100644 --- a/hindsight-api-slim/tests/test_hierarchical_config.py +++ b/hindsight-api-slim/tests/test_hierarchical_config.py @@ -6,11 +6,9 @@ """ import json -import os import pytest -from hindsight_api import MemoryEngine from hindsight_api.config import HindsightConfig, normalize_config_dict, normalize_config_key from hindsight_api.config_resolver import ConfigResolver from hindsight_api.extensions.tenant import TenantExtension @@ -113,6 +111,7 @@ async def test_hierarchical_fields_categorization(): assert "retain_mission" in configurable assert "retain_custom_instructions" in configurable assert "retain_chunk_size" in configurable + assert "retain_structured_chunk_size" in configurable assert "enable_observations" in configurable assert "consolidation_llm_batch_size" in configurable assert "consolidation_source_facts_max_tokens" in configurable @@ -131,6 +130,7 @@ async def test_hierarchical_fields_categorization(): assert "retain_default_strategy" in configurable assert "retain_strategies" in configurable assert "max_observations_per_scope" in configurable + assert "observation_scope_limits" in configurable assert "reflect_source_facts_max_tokens" in configurable assert "llm_gemini_safety_settings" in configurable assert "mcp_enabled_tools" in configurable @@ -139,7 +139,7 @@ async def test_hierarchical_fields_categorization(): assert "consolidation_llm_parallelism" in configurable # Verify count is correct - assert len(configurable) == 37 + assert len(configurable) == 40 # Verify credential fields (NEVER exposed) assert "llm_api_key" in credentials @@ -250,6 +250,230 @@ async def test_bank_config_null_consolidation_overrides_use_server_defaults(): assert field_name not in bank_overrides +@pytest.mark.asyncio +async def test_retain_chunking_null_overrides_use_server_defaults(): + """JSON null retain chunking overrides should behave like Server Default.""" + bank_id = "test-null-retain-chunking-config-bank" + resolver = ConfigResolver(backend=FakeBankConfigBackend()) + + await resolver.update_bank_config( + bank_id, + { + "retain_chunk_size": 5000, + "retain_structured_chunk_size": 7000, + }, + ) + config = await resolver.resolve_full_config(bank_id) + assert config.retain_chunk_size == 5000 + assert config.retain_structured_chunk_size == 7000 + + await resolver.update_bank_config( + bank_id, + { + "retain_chunk_size": None, + "retain_structured_chunk_size": None, + }, + ) + + resolved_config = await resolver.resolve_full_config(bank_id) + global_config = resolver._global_config + assert resolved_config.retain_chunk_size == global_config.retain_chunk_size + assert resolved_config.retain_structured_chunk_size == global_config.retain_structured_chunk_size + + +@pytest.mark.asyncio +async def test_retain_chunking_validation_uses_null_cleared_chunk_size(): + """Chunking validation should apply JSON null tombstones before checking final values.""" + bank_id = "test-null-retain-chunking-validation-bank" + resolver = ConfigResolver(backend=FakeBankConfigBackend()) + + await resolver.update_bank_config( + bank_id, + { + "retain_chunk_size": 5000, + "retain_structured_chunk_size": 7000, + }, + ) + + await resolver.update_bank_config( + bank_id, + { + "retain_chunk_size": None, + "retain_structured_chunk_size": 4000, + }, + ) + + resolved_config = await resolver.resolve_full_config(bank_id) + assert resolved_config.retain_chunk_size == resolver._global_config.retain_chunk_size + assert resolved_config.retain_structured_chunk_size == 4000 + + +@pytest.mark.asyncio +async def test_existing_retain_strategy_structured_chunking_survives_chunk_size_changes(): + """Top-level chunk size updates can exceed existing structured chunk caps.""" + from hindsight_api.config_resolver import apply_strategy + + bank_id = "test-existing-retain-strategy-chunking-bank" + resolver = ConfigResolver(backend=FakeBankConfigBackend()) + + await resolver.update_bank_config( + bank_id, + { + "retain_strategies": { + "jsonl": { + "retain_structured_chunk_size": 4000, + }, + }, + }, + ) + + await resolver.update_bank_config(bank_id, {"retain_chunk_size": 5000}) + + config = await resolver.resolve_full_config(bank_id) + strategy_config = apply_strategy(config, "jsonl") + assert strategy_config.retain_chunk_size == 5000 + assert strategy_config.retain_structured_chunk_size == 4000 + + +@pytest.mark.asyncio +async def test_retain_strategy_chunking_null_matches_apply_strategy_semantics(): + """Strategy null values are direct overrides, not bank-config tombstones.""" + from hindsight_api.config_resolver import apply_strategy + + bank_id = "test-retain-strategy-null-chunking-bank" + resolver = ConfigResolver(backend=FakeBankConfigBackend()) + + await resolver.update_bank_config( + bank_id, + { + "retain_structured_chunk_size": 5000, + "retain_strategies": { + "large-turns": { + "retain_chunk_size": 8000, + "retain_structured_chunk_size": None, + }, + }, + }, + ) + + resolved_config = await resolver.resolve_full_config(bank_id) + strategy_config = apply_strategy(resolved_config, "large-turns") + assert strategy_config.retain_chunk_size == 8000 + assert strategy_config.retain_structured_chunk_size is None + + +@pytest.mark.parametrize( + "updates", + [ + {"retain_chunk_size": "5000"}, + {"retain_chunk_size": 5000.5}, + {"retain_chunk_size": True}, + {"retain_structured_chunk_size": "5000"}, + {"retain_structured_chunk_size": 5000.5}, + {"retain_structured_chunk_size": False}, + ], +) +@pytest.mark.asyncio +async def test_retain_chunking_raw_patch_values_must_be_integers(updates): + """Raw config PATCH values should fail as 400-style ValueError, not TypeError.""" + resolver = ConfigResolver(backend=FakeBankConfigBackend()) + + with pytest.raises(ValueError) as exc_info: + await resolver.update_bank_config("test-retain-chunking-malformed-patch-bank", updates) + + error_message = str(exc_info.value) + assert "must be an integer" in error_message + assert "HINDSIGHT_API_" not in error_message + + +@pytest.mark.asyncio +async def test_retain_strategy_chunk_size_null_rejected_with_value_error(): + """Strategy retain_chunk_size cannot be null because apply_strategy would use it directly.""" + resolver = ConfigResolver(backend=FakeBankConfigBackend()) + + with pytest.raises(ValueError) as exc_info: + await resolver.update_bank_config( + "test-retain-strategy-null-chunk-size-bank", + { + "retain_strategies": { + "bad": { + "retain_chunk_size": None, + }, + }, + }, + ) + + error_message = str(exc_info.value) + assert "Invalid retain strategy 'bad'" in error_message + assert "retain_chunk_size must be an integer" in error_message + + +@pytest.mark.asyncio +async def test_retain_strategy_non_object_rejected_with_value_error(): + """Strategy entries must be objects so apply_strategy cannot fail later.""" + resolver = ConfigResolver(backend=FakeBankConfigBackend()) + + with pytest.raises(ValueError) as exc_info: + await resolver.update_bank_config( + "test-retain-strategy-non-object-bank", + { + "retain_strategies": { + "bad": "not-a-dict", + }, + }, + ) + + assert "Invalid retain strategy 'bad': must be an object" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_retain_strategy_chunk_size_must_remain_below_max_completion_tokens(): + """Strategy chunk-size overrides must preserve the existing retain output-token invariant.""" + resolver = ConfigResolver(backend=FakeBankConfigBackend()) + + with pytest.raises(ValueError) as exc_info: + await resolver.update_bank_config( + "test-retain-strategy-max-completion-bank", + { + "retain_strategies": { + "bad": { + "retain_chunk_size": 64000, + }, + }, + }, + ) + + error_message = str(exc_info.value) + assert "Invalid retain strategy 'bad'" in error_message + assert "retain_max_completion_tokens" in error_message + assert "must be greater than retain_chunk_size" in error_message + + +@pytest.mark.asyncio +async def test_retain_strategy_structured_chunk_size_can_be_below_same_update_chunk_size(): + """Strategy structured chunk size can be lower than the top-level chunk size.""" + from hindsight_api.config_resolver import apply_strategy + + resolver = ConfigResolver(backend=FakeBankConfigBackend()) + + await resolver.update_bank_config( + "test-retain-strategy-chunking-bank", + { + "retain_chunk_size": 5000, + "retain_strategies": { + "jsonl": { + "retain_structured_chunk_size": 4000, + }, + }, + }, + ) + + config = await resolver.resolve_full_config("test-retain-strategy-chunking-bank") + strategy_config = apply_strategy(config, "jsonl") + assert strategy_config.retain_chunk_size == 5000 + assert strategy_config.retain_structured_chunk_size == 4000 + + @pytest.mark.asyncio async def test_config_validation_rejects_static_fields(memory, request_context): """Test that attempting to override static fields raises ValueError.""" @@ -501,7 +725,6 @@ async def test_config_get_bank_config_no_static_or_credential_fields_leak(memory # Get field categorizations configurable_fields = HindsightConfig.get_configurable_fields() credential_fields = HindsightConfig.get_credential_fields() - static_fields = HindsightConfig.get_static_fields() # SECURITY: Verify ONLY configurable fields are returned (NO static, NO credentials) for key in config.keys(): @@ -534,7 +757,12 @@ async def test_config_get_bank_config_no_static_or_credential_fields_leak(memory ) # Verify we have the expected configurable fields (small set) - expected_configurable = ["retain_chunk_size", "retain_extraction_mode", "enable_observations"] + expected_configurable = [ + "retain_chunk_size", + "retain_structured_chunk_size", + "retain_extraction_mode", + "enable_observations", + ] for field in expected_configurable: assert field in config, f"Expected configurable field '{field}' missing from config" diff --git a/hindsight-api-slim/tests/test_hnsw_indexes.py b/hindsight-api-slim/tests/test_hnsw_indexes.py index 8a94b03e4e..19266ace4c 100644 --- a/hindsight-api-slim/tests/test_hnsw_indexes.py +++ b/hindsight-api-slim/tests/test_hnsw_indexes.py @@ -7,6 +7,7 @@ - Per-bank vector indexes dropped on bank deletion - retrieve_semantic_bm25_combined groups results correctly by fact_type and source """ + import uuid from datetime import datetime, timezone @@ -152,10 +153,7 @@ async def test_retrieve_semantic_bm25_grouped_by_fact_type(memory, request_conte try: await memory.retain_async( bank_id=bank_id, - content=( - "Alice is a software engineer at TechCorp. " - "She visited Paris in 2023 for a conference." - ), + content=("Alice is a software engineer at TechCorp. She visited Paris in 2023 for a conference."), context="background", event_date=datetime(2023, 6, 1, tzinfo=timezone.utc), request_context=request_context, diff --git a/hindsight-api-slim/tests/test_http_api_integration.py b/hindsight-api-slim/tests/test_http_api_integration.py index bb33e1d2bb..ca31fbf933 100644 --- a/hindsight-api-slim/tests/test_http_api_integration.py +++ b/hindsight-api-slim/tests/test_http_api_integration.py @@ -141,7 +141,8 @@ async def test_full_api_workflow(api_client, test_bank_id): reflect_result = response.json() assert "text" in reflect_result assert len(reflect_result["text"]) > 0 - assert "based_on" in reflect_result + # based_on is only populated when facts are requested; it's null (and thus omitted) here. + assert reflect_result.get("based_on") is None # Verify the reflect endpoint returned a non-trivial response assert len(reflect_result["text"]) > 5, "Reflect should return a substantive response" diff --git a/hindsight-api-slim/tests/test_integrity_violation_not_retried.py b/hindsight-api-slim/tests/test_integrity_violation_not_retried.py index 0922fa54d4..d0c31712a7 100644 --- a/hindsight-api-slim/tests/test_integrity_violation_not_retried.py +++ b/hindsight-api-slim/tests/test_integrity_violation_not_retried.py @@ -87,9 +87,7 @@ async def test_unique_violation_marks_failed_without_retry(memory): try: await memory.execute_task(task_dict) except RetryTaskAt as exc: - pytest.fail( - f"IntegrityConstraintViolationError must not be retried, but execute_task raised {exc!r}" - ) + pytest.fail(f"IntegrityConstraintViolationError must not be retried, but execute_task raised {exc!r}") # The operation must be marked 'failed' (not left pending / retrying). row = await pool.fetchrow( @@ -97,9 +95,7 @@ async def test_unique_violation_marks_failed_without_retry(memory): operation_id, ) assert row is not None, "Operation row disappeared" - assert row["status"] == "failed", ( - f"Expected status='failed' after integrity violation, got {row['status']!r}" - ) + assert row["status"] == "failed", f"Expected status='failed' after integrity violation, got {row['status']!r}" assert row["error_message"] is not None assert "pk_chunks" in row["error_message"] @@ -123,7 +119,7 @@ async def test_foreign_key_violation_also_not_retried(memory): await _create_pending_operation(pool, bank_id, operation_id) fk_violation = asyncpg.exceptions.ForeignKeyViolationError( - "insert or update on table \"memory_units\" violates foreign key constraint \"fk_bank\"" + 'insert or update on table "memory_units" violates foreign key constraint "fk_bank"' ) task_dict = { @@ -137,9 +133,7 @@ async def test_foreign_key_violation_also_not_retried(memory): try: await memory.execute_task(task_dict) except RetryTaskAt as exc: - pytest.fail( - f"ForeignKeyViolationError must not be retried, but execute_task raised {exc!r}" - ) + pytest.fail(f"ForeignKeyViolationError must not be retried, but execute_task raised {exc!r}") row = await pool.fetchrow( "SELECT status FROM async_operations WHERE operation_id = $1", diff --git a/hindsight-api-slim/tests/test_iris_parser.py b/hindsight-api-slim/tests/test_iris_parser.py index be48eda39e..2b63ea056b 100644 --- a/hindsight-api-slim/tests/test_iris_parser.py +++ b/hindsight-api-slim/tests/test_iris_parser.py @@ -68,5 +68,3 @@ async def test_iris_parser_converts_pdf(iris_parser: IrisParser): async def test_iris_parser_name(iris_parser: IrisParser): """IrisParser.name() should return 'iris'.""" assert iris_parser.name() == "iris" - - diff --git a/hindsight-api-slim/tests/test_large_document_replacement.py b/hindsight-api-slim/tests/test_large_document_replacement.py index c0baada1dd..08150f923f 100644 --- a/hindsight-api-slim/tests/test_large_document_replacement.py +++ b/hindsight-api-slim/tests/test_large_document_replacement.py @@ -48,9 +48,7 @@ def _make_replacement_body() -> str: than one sub-batch. """ lines = [ - f"[role: user] turn {i}: alpha bravo charlie delta echo " - f"foxtrot golf hotel india juliet" - for i in range(20) + f"[role: user] turn {i}: alpha bravo charlie delta echo foxtrot golf hotel india juliet" for i in range(20) ] return "\n".join(lines) @@ -79,9 +77,7 @@ async def test_large_same_id_replacement_preserves_full_body(memory, request_con request_context=request_context, ) - doc_initial = await memory.get_document( - document_id, bank_id, request_context=request_context - ) + doc_initial = await memory.get_document(document_id, bank_id, request_context=request_context) assert doc_initial is not None assert doc_initial["original_text"] == initial_body @@ -95,9 +91,7 @@ async def test_large_same_id_replacement_preserves_full_body(memory, request_con request_context=request_context, ) - doc_replaced = await memory.get_document( - document_id, bank_id, request_context=request_context - ) + doc_replaced = await memory.get_document(document_id, bank_id, request_context=request_context) assert doc_replaced is not None stored = doc_replaced["original_text"] @@ -105,10 +99,7 @@ async def test_large_same_id_replacement_preserves_full_body(memory, request_con f"stored body length {len(stored)} != submitted length " f"{len(replacement_body)} — partial replacement persisted" ) - assert stored == replacement_body, ( - "stored original_text does not exactly match the submitted " - "replacement body" - ) + assert stored == replacement_body, "stored original_text does not exactly match the submitted replacement body" finally: await memory.delete_bank(bank_id, request_context=request_context) @@ -143,9 +134,7 @@ async def test_repeated_large_same_id_replacement_is_idempotent(memory, request_ request_context=request_context, ) - doc = await memory.get_document( - document_id, bank_id, request_context=request_context - ) + doc = await memory.get_document(document_id, bank_id, request_context=request_context) assert doc is not None, f"attempt {attempt}: document missing after retain" assert doc["original_text"] == replacement_body, ( f"attempt {attempt}: stored body diverged from submitted body " diff --git a/hindsight-api-slim/tests/test_link_expansion_retrieval.py b/hindsight-api-slim/tests/test_link_expansion_retrieval.py index 5da4b11fd5..403754f0cd 100644 --- a/hindsight-api-slim/tests/test_link_expansion_retrieval.py +++ b/hindsight-api-slim/tests/test_link_expansion_retrieval.py @@ -133,7 +133,9 @@ async def test_link_expansion_observation_graph_retrieval(memory_real_llm, reque assert obs_result is not None and obs_result.results is not None, "Should have observations after consolidation" # We should have observations from consolidation - assert len(obs_result.results) >= 1, f"Should have at least 1 observation about Python, got {len(obs_result.results)}" + assert len(obs_result.results) >= 1, ( + f"Should have at least 1 observation about Python, got {len(obs_result.results)}" + ) # Now test graph retrieval specifically # Query for Alice - should find Bob via shared "Python" entity @@ -175,9 +177,7 @@ async def test_link_expansion_observation_graph_retrieval(memory_real_llm, reque assert world_result.trace is not None, "Should have trace data for world facts" world_retrieval_results = world_result.trace.get("retrieval_results", []) - world_graph_results = [ - r for r in world_retrieval_results if r.get("method_name") == "graph" - ] + world_graph_results = [r for r in world_retrieval_results if r.get("method_name") == "graph"] if world_graph_results: world_graph_result = [r for r in world_graph_results if r.get("fact_type") == "world"][0] @@ -192,7 +192,9 @@ async def test_link_expansion_observation_graph_retrieval(memory_real_llm, reque print(" Found Bob's world fact via shared 'Python' entity!") print("\n✓ Link expansion observation test passed!") - print(" Entity traversal path verified (observations -> sources -> entities -> connected sources -> observations)") + print( + " Entity traversal path verified (observations -> sources -> entities -> connected sources -> observations)" + ) finally: await memory.delete_bank(bank_id, request_context=request_context) @@ -259,15 +261,11 @@ async def test_link_expansion_world_fact_graph_retrieval(memory, request_context # Verify graph retrieval ran (it may or may not find new results depending # on whether semantic search already found everything) retrieval_results = result.trace.get("retrieval_results", []) - graph_results = [ - r for r in retrieval_results if r.get("method_name") == "graph" - ] + graph_results = [r for r in retrieval_results if r.get("method_name") == "graph"] assert len(graph_results) > 0, "Should have graph retrieval results in trace" # The important thing is that recall works and returns relevant results - assert result.results is not None and len(result.results) > 0, ( - "Should return results for 'Alice' query" - ) + assert result.results is not None and len(result.results) > 0, "Should return results for 'Alice' query" # Alice's result should be at or near the top result_texts = [r.text for r in result.results] diff --git a/hindsight-api-slim/tests/test_link_utils.py b/hindsight-api-slim/tests/test_link_utils.py index b5f9d6511e..3668087af8 100644 --- a/hindsight-api-slim/tests/test_link_utils.py +++ b/hindsight-api-slim/tests/test_link_utils.py @@ -1,4 +1,5 @@ """Tests for link_utils datetime handling, temporal link computation, and semantic link splitting.""" + import numpy as np import pytest from datetime import datetime, timezone, timedelta @@ -374,6 +375,7 @@ def test_top_k_limits_per_unit(self): links = compute_semantic_links_within_batch(unit_ids, embs, top_k=3, threshold=0.5) # Each unit should have at most 3 outgoing links from collections import Counter + from_counts = Counter(lnk[0] for lnk in links) for count in from_counts.values(): assert count <= 3 @@ -509,16 +511,13 @@ async def test_no_manual_drop_or_truncate(self, mock_conn): ) @pytest.mark.asyncio - @pytest.mark.parametrize( - ("ext", "guc"), - [("pgvector", "hnsw.ef_search"), ("vchord", "vchordrq.probes")], - ) - async def test_uses_set_local_for_ann_tuning(self, mock_conn, monkeypatch, ext, guc): + async def test_uses_set_local_for_pgvector_ann_tuning(self, mock_conn, monkeypatch): """The per-backend ANN tuning GUC must be set with SET LOCAL so the change is scoped to the transaction. Without SET LOCAL, the setting would leak onto the pooled backend and affect subsequent recall queries that land on the same backend.""" - monkeypatch.setenv("HINDSIGHT_API_VECTOR_EXTENSION", ext) + monkeypatch.setenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector") + guc = "hnsw.ef_search" emb = [0.1] * 384 await compute_semantic_links_ann( conn=mock_conn, @@ -530,10 +529,29 @@ async def test_uses_set_local_for_ann_tuning(self, mock_conn, monkeypatch, ext, executed_sql = [call.args[0] for call in mock_conn.execute.call_args_list] tuning_statements = [s for s in executed_sql if guc in s] - assert tuning_statements, f"{guc} must be tuned for retain ANN under ext={ext}" + assert tuning_statements, f"{guc} must be tuned for retain ANN under pgvector" for stmt in tuning_statements: - assert stmt.strip().startswith("SET LOCAL"), ( - f"{guc} must use SET LOCAL, got: {stmt}" - ) + assert stmt.strip().startswith("SET LOCAL"), f"{guc} must use SET LOCAL, got: {stmt}" # And there must not be a RESET — SET LOCAL handles it at commit. assert not any(f"RESET {guc}" in s for s in executed_sql) + + @pytest.mark.asyncio + async def test_vchord_ann_does_not_set_fixed_probe_count(self, mock_conn, monkeypatch): + """VectorChord probe counts must come from index/default config. + + VectorChord requires vchordrq.probes to match the index's + build.internal.lists shape. Hindsight must not apply one fixed session + GUC across listless and partitioned vchordrq indexes. + """ + monkeypatch.setenv("HINDSIGHT_API_VECTOR_EXTENSION", "vchord") + emb = [0.1] * 384 + await compute_semantic_links_ann( + conn=mock_conn, + bank_id="bank-1", + unit_ids=["u1"], + embeddings=[emb], + fact_types=["world"], + ) + + executed_sql = [call.args[0] for call in mock_conn.execute.call_args_list] + assert not any("vchordrq.probes" in s for s in executed_sql) diff --git a/hindsight-api-slim/tests/test_list_banks_config_overlay.py b/hindsight-api-slim/tests/test_list_banks_config_overlay.py new file mode 100644 index 0000000000..2cebcf0bc5 --- /dev/null +++ b/hindsight-api-slim/tests/test_list_banks_config_overlay.py @@ -0,0 +1,71 @@ +"""Regression test: list_banks must apply the same disposition + mission +config overlay that get_bank_profile applies. + +Bug (reproduced live against 0.8.1): for a bank whose disposition and +mission were evolved/overridden via bank *config* (the banks.config JSONB: +reflect_mission, disposition_skepticism/literalism/empathy), the single-bank +get path returns the real values while the list path returns the stale legacy +DB-column defaults ({skepticism:3, literalism:3, empathy:3} and ""). + +Root cause: MemoryEngine.get_bank_profile overlays the resolved bank config +on top of the legacy banks.disposition/banks.mission columns, but +MemoryEngine.list_banks returned bank_utils.list_banks rows straight from +those columns with no overlay. The two endpoints disagreed for the same bank. + +This test sets disposition + mission through the config path (so the legacy +columns keep their defaults) and asserts list_banks agrees with +get_bank_profile for that bank. + +Runs via: uv run pytest tests/test_list_banks_config_overlay.py -v +""" + +from __future__ import annotations + +import pytest + +from hindsight_api.models import RequestContext + + +@pytest.mark.asyncio +async def test_list_banks_overlays_config_disposition_and_mission(memory): + bank_id = "list_banks_config_overlay_bank" + request_context = RequestContext(api_key=None, api_key_id=None, tenant_id=None, internal=False) + + # Values that differ from the 3/3/3 defaults on every trait, and a + # clearly non-empty mission, so a stale-default regression is unmissable. + overrides = { + "reflect_mission": "I am the shared long-term memory for this regression test.", + "disposition_skepticism": 4, + "disposition_literalism": 5, + "disposition_empathy": 2, + } + + try: + # Create the bank. Its legacy banks.disposition/banks.mission columns + # keep their defaults (3/3/3 and "") — the real values live in config. + await memory.get_bank_profile(bank_id, request_context=request_context) + + # Set disposition + mission via the *config* path (banks.config JSONB), + # exactly the path that triggered the live bug. + await memory._config_resolver.update_bank_config(bank_id, overrides, request_context) + + # Source of truth: the single-bank get path already overlays config. + profile = await memory.get_bank_profile(bank_id, request_context=request_context) + assert profile["mission"] == overrides["reflect_mission"] + assert profile["disposition"] == {"skepticism": 4, "literalism": 5, "empathy": 2} + + # The list path must agree with the get path for this bank. + banks = await memory.list_banks(request_context=request_context) + entry = next((b for b in banks if b["bank_id"] == bank_id), None) + assert entry is not None, f"bank {bank_id!r} not present in list_banks output" + + assert entry["mission"] == profile["mission"], ( + "list_banks returned a different mission than get_bank_profile: " + f"list={entry['mission']!r} get={profile['mission']!r}" + ) + assert entry["disposition"] == profile["disposition"], ( + "list_banks returned a different disposition than get_bank_profile: " + f"list={entry['disposition']!r} get={profile['disposition']!r}" + ) + finally: + await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api-slim/tests/test_list_documents.py b/hindsight-api-slim/tests/test_list_documents.py index acd4a9a4bc..03ecc92bab 100644 --- a/hindsight-api-slim/tests/test_list_documents.py +++ b/hindsight-api-slim/tests/test_list_documents.py @@ -1,6 +1,7 @@ """ Tests for list_documents pagination and tags filtering. """ + from datetime import datetime, timezone import pytest @@ -28,25 +29,19 @@ async def test_list_documents_offset_pagination(memory, request_context): await _retain_doc(memory, bank_id, f"doc-{i:02d}", [], request_context) # All documents, ordered by created_at DESC → doc-03, doc-02, doc-01, doc-00 - all_docs = await memory.list_documents( - bank_id=bank_id, limit=10, offset=0, request_context=request_context - ) + all_docs = await memory.list_documents(bank_id=bank_id, limit=10, offset=0, request_context=request_context) assert all_docs["total"] == 4 assert len(all_docs["items"]) == 4 all_ids = [d["id"] for d in all_docs["items"]] # offset=2 should skip the first two and return the remaining two - page2 = await memory.list_documents( - bank_id=bank_id, limit=10, offset=2, request_context=request_context - ) + page2 = await memory.list_documents(bank_id=bank_id, limit=10, offset=2, request_context=request_context) assert page2["total"] == 4 # total is always the full count assert len(page2["items"]) == 2 assert [d["id"] for d in page2["items"]] == all_ids[2:] # offset beyond total returns empty items but correct total - beyond = await memory.list_documents( - bank_id=bank_id, limit=10, offset=10, request_context=request_context - ) + beyond = await memory.list_documents(bank_id=bank_id, limit=10, offset=10, request_context=request_context) assert beyond["total"] == 4 assert beyond["items"] == [] diff --git a/hindsight-api-slim/tests/test_litellm_sdk_cross_encoder.py b/hindsight-api-slim/tests/test_litellm_sdk_cross_encoder.py index 3a2ae64a5f..68c5c75533 100644 --- a/hindsight-api-slim/tests/test_litellm_sdk_cross_encoder.py +++ b/hindsight-api-slim/tests/test_litellm_sdk_cross_encoder.py @@ -104,6 +104,35 @@ async def test_predict_single_query(self): assert len(call_args.kwargs["documents"]) == 3 assert call_args.kwargs["api_key"] == "test_key" + def test_constructor_without_api_key(self): + """api_key is optional (e.g. AWS Bedrock reranker with ambient IAM creds).""" + encoder = LiteLLMSDKCrossEncoder(model="bedrock/cohere.rerank-v3-5:0") + assert encoder.api_key is None + + @pytest.mark.asyncio + async def test_predict_omits_api_key_for_ambient_credentials(self): + """When no api_key is set, it must not be injected into the rerank call. + + litellm maps an explicit ``api_key`` to ``aws_access_key_id`` for Bedrock, + which overrides ambient IAM/task-role credentials; omitting it lets litellm + resolve credentials from the environment (regression test for IAM auth). + """ + encoder = LiteLLMSDKCrossEncoder(model="bedrock/cohere.rerank-v3-5:0") + + mock_response = MagicMock() + mock_response.results = [{"index": 0, "relevance_score": 0.9}] + + mock_litellm = MagicMock() + mock_litellm.arerank = AsyncMock(return_value=mock_response) + + with patch.dict("sys.modules", {"litellm": mock_litellm}): + await encoder.initialize() + await encoder.predict([("query", "document")]) + + mock_litellm.arerank.assert_called_once() + call_kwargs = mock_litellm.arerank.call_args.kwargs + assert "api_key" not in call_kwargs + @pytest.mark.asyncio async def test_predict_multiple_queries(self): """Test prediction with multiple different queries (grouped efficiently).""" @@ -278,11 +307,11 @@ async def test_create_litellm_sdk_from_env(self): assert encoder.model == "deepinfra/Qwen3-reranker-8B" @pytest.mark.asyncio - async def test_create_litellm_sdk_missing_api_key(self): - """Test that factory raises error when API key is missing.""" + async def test_create_litellm_sdk_without_api_key(self): + """Test that litellm-sdk works without an API key (e.g. AWS Bedrock with IAM).""" env_vars = { "HINDSIGHT_API_RERANKER_PROVIDER": "litellm-sdk", - "HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL": "deepinfra/Qwen3-reranker-8B", + "HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL": "bedrock/cohere.rerank-v3-5:0", } with patch.dict(os.environ, env_vars, clear=False): @@ -295,8 +324,11 @@ async def test_create_litellm_sdk_missing_api_key(self): config = HindsightConfig.from_env() with patch("hindsight_api.config.get_config", return_value=config): - with pytest.raises(ValueError, match="HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY is required"): - create_cross_encoder_from_env() + encoder = create_cross_encoder_from_env() + + assert isinstance(encoder, LiteLLMSDKCrossEncoder) + assert encoder.api_key is None + assert encoder.model == "bedrock/cohere.rerank-v3-5:0" @pytest.mark.asyncio async def test_create_litellm_sdk_with_custom_api_base(self): diff --git a/hindsight-api-slim/tests/test_litellm_sdk_embeddings.py b/hindsight-api-slim/tests/test_litellm_sdk_embeddings.py index c92f1c6d51..16c9752454 100644 --- a/hindsight-api-slim/tests/test_litellm_sdk_embeddings.py +++ b/hindsight-api-slim/tests/test_litellm_sdk_embeddings.py @@ -57,7 +57,10 @@ async def embeddings(self, mock_litellm): async def test_initialization_success(self, mock_litellm): """Test successful initialization.""" - with patch("builtins.__import__", side_effect=lambda name, *args: mock_litellm if name == "litellm" else __import__(name, *args)): + 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", @@ -84,7 +87,10 @@ async def test_initialization_success(self, mock_litellm): async def test_initialization_without_api_key(self, mock_litellm): """Test initialization without api_key (e.g. AWS Bedrock with IAM auth).""" - with patch("builtins.__import__", side_effect=lambda name, *args: mock_litellm if name == "litellm" else __import__(name, *args)): + with patch( + "builtins.__import__", + side_effect=lambda name, *args: mock_litellm if name == "litellm" else __import__(name, *args), + ): emb = LiteLLMSDKEmbeddings( model="bedrock/amazon.titan-embed-text-v2:0", batch_size=100, @@ -119,6 +125,7 @@ async def test_encode_without_api_key(self, mock_litellm): async def test_initialization_missing_package(self): """Test initialization fails gracefully when litellm is not installed.""" + def mock_import(name, *args): if name == "litellm": raise ImportError("No module named 'litellm'") @@ -208,9 +215,7 @@ async def test_encode_batching(self, embeddings, mock_litellm): # Mock responses for each batch def mock_embedding_side_effect(model, input, **kwargs): mock_response = MagicMock() - mock_response.data = [ - {"embedding": [float(i)] * 768, "index": i} for i in range(len(input)) - ] + mock_response.data = [{"embedding": [float(i)] * 768, "index": i} for i in range(len(input))] return mock_response mock_litellm.embedding.side_effect = mock_embedding_side_effect @@ -279,7 +284,10 @@ async def test_dimension_before_initialization(self, mock_litellm): async def test_custom_api_base(self, mock_litellm): """Test custom API base URL is passed to embedding calls.""" - with patch("builtins.__import__", side_effect=lambda name, *args: mock_litellm if name == "litellm" else __import__(name, *args)): + 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", diff --git a/hindsight-api-slim/tests/test_litellm_timeout.py b/hindsight-api-slim/tests/test_litellm_timeout.py new file mode 100644 index 0000000000..67f53d04d8 --- /dev/null +++ b/hindsight-api-slim/tests/test_litellm_timeout.py @@ -0,0 +1,82 @@ +""" +Regression test for the hard timeout on the LiteLLM provider. + +A completion that never returns — a connection held open with no token +progress, or one straggler inside a concurrent ``asyncio.gather`` fan-out — +must not block forever. ``call`` / ``call_with_tools`` wrap the request in +``asyncio.wait_for`` so it is cancelled after ``timeout`` seconds and surfaced +as a retryable ``TimeoutError`` instead of pinning a worker slot and a +concurrency permit indefinitely. +""" + +import asyncio +import time + +import pytest + +from hindsight_api.config import DEFAULT_LLM_TIMEOUT, ENV_LLM_TIMEOUT +from hindsight_api.engine.providers.litellm_llm import LiteLLMLLM + + +def _make_provider(timeout: float | None) -> LiteLLMLLM: + return LiteLLMLLM( + provider="litellm", + api_key="unused", + base_url="http://localhost:0/v1", + model="litellm_proxy/test-model", + timeout=timeout, + ) + + +async def test_call_cancels_hung_completion(monkeypatch): + """A hung ``_acompletion`` is cancelled per attempt and raises TimeoutError.""" + provider = _make_provider(timeout=0.1) + calls = 0 + + async def _hang(**kwargs): + nonlocal calls + calls += 1 + await asyncio.Event().wait() # never resolves + + monkeypatch.setattr(provider, "_acompletion", _hang) + + started = time.monotonic() + with pytest.raises((TimeoutError, asyncio.TimeoutError)): + await provider.call( + messages=[{"role": "user", "content": "hi"}], + max_retries=1, + initial_backoff=0.01, + max_backoff=0.01, + ) + elapsed = time.monotonic() - started + + # max_retries=1 -> attempts 0 and 1, each bounded by the timeout. + assert calls == 2 + # Bounded by ~2 * timeout + backoff — nowhere near hanging forever. + assert elapsed < 2.0 + + +async def test_call_with_tools_cancels_hung_completion(monkeypatch): + provider = _make_provider(timeout=0.1) + + async def _hang(**kwargs): + await asyncio.Event().wait() + + monkeypatch.setattr(provider, "_acompletion", _hang) + + with pytest.raises((TimeoutError, asyncio.TimeoutError)): + await provider.call_with_tools( + messages=[{"role": "user", "content": "hi"}], + tools=[], + max_retries=0, + initial_backoff=0.01, + max_backoff=0.01, + ) + + +async def test_unset_timeout_falls_back_to_default(monkeypatch): + """``None`` must resolve to a finite default — never ``None``, which would + make ``asyncio.wait_for`` wait forever and reintroduce the hang.""" + monkeypatch.delenv(ENV_LLM_TIMEOUT, raising=False) + provider = _make_provider(timeout=None) + assert provider.timeout == DEFAULT_LLM_TIMEOUT diff --git a/hindsight-api-slim/tests/test_liveness_backpressure.py b/hindsight-api-slim/tests/test_liveness_backpressure.py index ac1eb9f818..96484f2d2f 100644 --- a/hindsight-api-slim/tests/test_liveness_backpressure.py +++ b/hindsight-api-slim/tests/test_liveness_backpressure.py @@ -3,7 +3,6 @@ import pytest from hindsight_api.engine.memory_engine import MemoryEngine -from hindsight_api.engine.providers.openai_compatible_llm import _retry_after_seconds from hindsight_api.engine.retain.fact_extraction import MAX_RETAIN_CONTEXT_CHARS, _build_user_message @@ -20,16 +19,6 @@ def acquire(self): return _SlowAcquire() -class _Response: - def __init__(self, headers): - self.headers = headers - - -class _StatusError: - def __init__(self, headers): - self.response = _Response(headers) - - @pytest.mark.asyncio async def test_health_check_degrades_quickly_when_db_acquire_is_stalled(monkeypatch): memory = object.__new__(MemoryEngine) @@ -62,9 +51,3 @@ def test_retain_fact_prompt_truncates_large_context_before_llm_call(): assert len(message) < MAX_RETAIN_CONTEXT_CHARS + 1_000 assert "truncated" in message - - -def test_retry_after_seconds_honors_provider_cooldown_without_exceeding_cap(): - error = _StatusError({"retry-after": "30"}) - - assert _retry_after_seconds(error, max_backoff=10) == 10 diff --git a/hindsight-api-slim/tests/test_llm_extra_body.py b/hindsight-api-slim/tests/test_llm_extra_body.py index 39496ff656..d2b1427679 100644 --- a/hindsight-api-slim/tests/test_llm_extra_body.py +++ b/hindsight-api-slim/tests/test_llm_extra_body.py @@ -120,7 +120,7 @@ async def test_anthropic_no_extra_body_omits_key(): # ─── Gemini ─────────────────────────────────────────────────────────────────── -def _make_gemini_provider(extra_body=None): +def _make_gemini_provider(extra_body=None, gemini_service_tier=None): pytest.importorskip("google.genai") with patch("google.genai.Client") as mock_client_cls: mock_client_cls.return_value = MagicMock() @@ -132,6 +132,7 @@ def _make_gemini_provider(extra_body=None): base_url="", model="gemini-2.5-flash", extra_body=extra_body, + gemini_service_tier=gemini_service_tier, ) provider._client = MagicMock() return provider @@ -176,6 +177,34 @@ async def test_gemini_explicit_temperature_overrides_extra_body(): assert config_arg.temperature == 0.9 +@pytest.mark.asyncio +async def test_gemini_service_tier_applies_to_http_options_extra_body(): + """The native Gemini service tier flag reaches GenerateContentConfig.""" + provider = _make_gemini_provider(gemini_service_tier="flex") + provider._client.aio.models.generate_content = AsyncMock(return_value=_fake_gemini_response()) + + await provider.call(messages=[{"role": "user", "content": "hi"}], scope="test") + + config_arg = provider._client.aio.models.generate_content.call_args.kwargs.get("config") + assert config_arg.http_options.extra_body["service_tier"] == "flex" + + +@pytest.mark.asyncio +async def test_gemini_extra_body_service_tier_takes_precedence(): + """The explicit extra_body escape hatch wins over the native flag.""" + provider = _make_gemini_provider( + extra_body={"http_options": {"extra_body": {"service_tier": "standard"}}}, + gemini_service_tier="flex", + ) + provider._client.aio.models.generate_content = AsyncMock(return_value=_fake_gemini_response()) + + await provider.call(messages=[{"role": "user", "content": "hi"}], scope="test") + + config_arg = provider._client.aio.models.generate_content.call_args.kwargs.get("config") + assert config_arg.http_options.extra_body["service_tier"] == "standard" + assert provider._extra_body["http_options"]["extra_body"]["service_tier"] == "standard" + + # ─── LiteLLM ────────────────────────────────────────────────────────────────── @@ -230,9 +259,7 @@ async def test_litellm_explicit_param_wins_over_extra_body(): provider._acompletion = AsyncMock(return_value=_fake_litellm_response()) with patch("hindsight_api.engine.providers.litellm_llm.get_metrics_collector"): - await provider.call( - messages=[{"role": "user", "content": "hi"}], temperature=0.9, scope="test", max_retries=0 - ) + await provider.call(messages=[{"role": "user", "content": "hi"}], temperature=0.9, scope="test", max_retries=0) assert provider._acompletion.call_args.kwargs.get("temperature") == 0.9 diff --git a/hindsight-api-slim/tests/test_llm_per_op_concurrency.py b/hindsight-api-slim/tests/test_llm_per_op_concurrency.py index 25f59e49f6..188cf33999 100644 --- a/hindsight-api-slim/tests/test_llm_per_op_concurrency.py +++ b/hindsight-api-slim/tests/test_llm_per_op_concurrency.py @@ -5,6 +5,7 @@ is set. They patch the module-level semaphore registry so they can run without needing to re-import the module with custom env vars. """ + import asyncio from contextlib import AsyncExitStack from unittest.mock import patch @@ -79,9 +80,7 @@ def test_unbucketed_scope_only_global(self): "consolidation": consolidation_sem, }, ): - assert _semaphores_for_scope("mental_model_delta_ops") == [ - llm_wrapper._global_llm_semaphore - ] + assert _semaphores_for_scope("mental_model_delta_ops") == [llm_wrapper._global_llm_semaphore] assert _semaphores_for_scope("memory_think") == [llm_wrapper._global_llm_semaphore] assert _semaphores_for_scope("verification") == [llm_wrapper._global_llm_semaphore] diff --git a/hindsight-api-slim/tests/test_llm_provider.py b/hindsight-api-slim/tests/test_llm_provider.py index 7319f56b79..331a1f8d3d 100644 --- a/hindsight-api-slim/tests/test_llm_provider.py +++ b/hindsight-api-slim/tests/test_llm_provider.py @@ -26,6 +26,7 @@ _PROVIDER = os.environ.get("HINDSIGHT_API_LLM_PROVIDER", "") _MODEL = os.environ.get("HINDSIGHT_API_LLM_MODEL", "") + def _get_api_key() -> str: """Get API key from HINDSIGHT_API_LLM_API_KEY (CI) or provider-specific env var.""" key = os.environ.get("HINDSIGHT_API_LLM_API_KEY", "") diff --git a/hindsight-api-slim/tests/test_llm_token_metrics.py b/hindsight-api-slim/tests/test_llm_token_metrics.py index 54986bd4ec..ef5aa89d23 100644 --- a/hindsight-api-slim/tests/test_llm_token_metrics.py +++ b/hindsight-api-slim/tests/test_llm_token_metrics.py @@ -1,6 +1,7 @@ """ Test that LLM calls record token metrics via the metrics collector. """ + import os from unittest.mock import MagicMock, patch import pytest @@ -31,7 +32,9 @@ async def test_llm_metrics_recorded_for_groq(): mock_collector = MagicMock(spec=MetricsCollector) # Patch the provider module where get_metrics_collector is actually called - with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector", return_value=mock_collector): + with patch( + "hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector", return_value=mock_collector + ): llm = LLMProvider( provider="groq", api_key=api_key, @@ -43,7 +46,7 @@ async def test_llm_metrics_recorded_for_groq(): response = await llm.call( messages=[ {"role": "system", "content": "You are a helpful assistant. Always respond."}, - {"role": "user", "content": "What is 2+2? Reply with just the number."} + {"role": "user", "content": "What is 2+2? Reply with just the number."}, ], max_completion_tokens=50, scope="test_metrics", @@ -92,7 +95,9 @@ class SimpleResponse(BaseModel): mock_collector = MagicMock(spec=MetricsCollector) # Patch the provider module where get_metrics_collector is actually called - with patch("hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector", return_value=mock_collector): + with patch( + "hindsight_api.engine.providers.openai_compatible_llm.get_metrics_collector", return_value=mock_collector + ): llm = LLMProvider( provider="groq", api_key=api_key, @@ -180,7 +185,7 @@ async def test_return_usage_returns_tuple(): result, usage = await llm.call( messages=[ {"role": "system", "content": "You are a helpful assistant."}, - {"role": "user", "content": "What is 2+2? Reply with just the number."} + {"role": "user", "content": "What is 2+2? Reply with just the number."}, ], max_completion_tokens=50, return_usage=True, diff --git a/hindsight-api-slim/tests/test_llm_tools.py b/hindsight-api-slim/tests/test_llm_tools.py index 2468ef229c..04e4f3af2e 100644 --- a/hindsight-api-slim/tests/test_llm_tools.py +++ b/hindsight-api-slim/tests/test_llm_tools.py @@ -51,9 +51,11 @@ async def test_call_with_tools_returns_tool_calls(self): llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock") # Set mock response to return tool calls - llm.set_mock_response([ - {"name": "get_weather", "arguments": {"location": "Paris", "unit": "celsius"}}, - ]) + llm.set_mock_response( + [ + {"name": "get_weather", "arguments": {"location": "Paris", "unit": "celsius"}}, + ] + ) result = await llm.call_with_tools( messages=[{"role": "user", "content": "What's the weather in Paris?"}], @@ -105,10 +107,12 @@ async def test_call_with_tools_multiple_tool_calls(self): """Test handling multiple tool calls in one response.""" llm = LLMProvider(provider="mock", api_key="", base_url="", model="mock") - llm.set_mock_response([ - {"name": "get_weather", "arguments": {"location": "Paris"}}, - {"name": "search", "arguments": {"query": "weather forecast"}}, - ]) + llm.set_mock_response( + [ + {"name": "get_weather", "arguments": {"location": "Paris"}}, + {"name": "search", "arguments": {"query": "weather forecast"}}, + ] + ) result = await llm.call_with_tools( messages=[{"role": "user", "content": "Weather in Paris and search for forecasts"}], diff --git a/hindsight-api-slim/tests/test_llm_trace.py b/hindsight-api-slim/tests/test_llm_trace.py index ca7481eecd..e7388c2dd9 100644 --- a/hindsight-api-slim/tests/test_llm_trace.py +++ b/hindsight-api-slim/tests/test_llm_trace.py @@ -222,6 +222,41 @@ class _Cfg: assert current_trace_context() is None # unwound after the call +@pytest.mark.asyncio +async def test_engine_teardown_unregisters_recorder_even_when_close_skipped(): + """Regression for #2229. + + Span recorders live in a process-global registry, and providers fan every call + out to ALL registered recorders. The engine fixtures must remove their recorder + on teardown even when ``close()`` is skipped (pool already closing/absent) or + raises before the unregister step — otherwise a leaked, still-enabled recorder + from an earlier test records a later test's LLM calls into the shared DB, which + is what made ``test_disabled_writes_no_rows`` flaky. The teardown helper must + leave the registry exactly as it found it. + """ + from hindsight_api import tracing + + from tests.conftest import _teardown_memory_engine + + sentinel = object() + tracing.register_span_recorder(sentinel) + try: + assert sentinel in tracing.get_span_recorder()._recorders + + # _pool=None makes the helper's gated close() a no-op, exercising the exact + # leak path; the finally must still unregister the recorder. + class _FakeEngine: + _pool = None + _llm_recorder = sentinel + + await _teardown_memory_engine(_FakeEngine()) + assert sentinel not in tracing.get_span_recorder()._recorders + finally: + # Belt-and-suspenders: don't leave the sentinel in the global registry if an + # assertion above fails (idempotent — the helper normally already removed it). + tracing.unregister_span_recorder(sentinel) + + # ── HTTP read API (integration) ─────────────────────────────────────────────── @@ -307,9 +342,7 @@ async def test_retain_creates_trace_rows_with_tokens(trace_api_client, bank_id): # Filtering by a trace_id returns only that operation run's calls. a_trace = entry["trace_id"] - resp = await trace_api_client.get( - f"/v1/default/banks/{bank_id}/llm-requests", params={"trace_id": a_trace} - ) + resp = await trace_api_client.get(f"/v1/default/banks/{bank_id}/llm-requests", params={"trace_id": a_trace}) assert resp.status_code == 200 filtered = resp.json() assert filtered["total"] >= 1 @@ -402,9 +435,7 @@ async def test_memory_ids_mapped_to_retain_and_consolidation(trace_api_client, b # the retain that produced it (memory_ids) and any consolidation that consumed # it as a source (source_memory_ids). by_mem = ( - await trace_api_client.get( - f"/v1/default/banks/{bank_id}/llm-requests", params={"memory_id": created[0]} - ) + await trace_api_client.get(f"/v1/default/banks/{bank_id}/llm-requests", params={"memory_id": created[0]}) ).json() assert by_mem["total"] >= 1 for it in by_mem["items"]: @@ -433,9 +464,7 @@ async def test_filter_by_status_and_operation(trace_api_client, bank_id): assert item["status"] == "success" assert item["operation"] == "retain" - response = await trace_api_client.get( - f"/v1/default/banks/{bank_id}/llm-requests", params={"status": "error"} - ) + response = await trace_api_client.get(f"/v1/default/banks/{bank_id}/llm-requests", params={"status": "error"}) assert response.json()["total"] == 0 @@ -448,9 +477,7 @@ async def test_stats_endpoint_includes_tokens(trace_api_client, bank_id): ) await asyncio.sleep(1.0) - response = await trace_api_client.get( - f"/v1/default/banks/{bank_id}/llm-requests/stats", params={"period": "1d"} - ) + response = await trace_api_client.get(f"/v1/default/banks/{bank_id}/llm-requests/stats", params={"period": "1d"}) assert response.status_code == 200 data = response.json() assert data["trunc"] == "day" diff --git a/hindsight-api-slim/tests/test_load_large_batch.py b/hindsight-api-slim/tests/test_load_large_batch.py index d1e75d5fc5..26d0956278 100644 --- a/hindsight-api-slim/tests/test_load_large_batch.py +++ b/hindsight-api-slim/tests/test_load_large_batch.py @@ -21,7 +21,7 @@ from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer from hindsight_api.engine.task_backend import SyncTaskBackend from hindsight_api.engine.retain.fact_extraction import FactExtractionResponse, ExtractedFact -from hindsight_api.engine.llm_wrapper import TokenUsage +from hindsight_api.engine.response_models import TokenUsage logger = logging.getLogger(__name__) @@ -72,21 +72,23 @@ def create_mock_facts_from_content(content: str, ratio: float = 1.5, max_facts: If content has N sentences, return approximately N * ratio facts (capped at max_facts). """ # Estimate sentences by splitting on periods - sentences = [s.strip() for s in content.split('.') if s.strip()] + sentences = [s.strip() for s in content.split(".") if s.strip()] num_facts = min(max(1, int(len(sentences) * ratio)), max_facts) facts = [] for i in range(num_facts): - facts.append({ - "what": f"Mock fact {i}: Something happened based on the content", - "when": "2024-06-15", - "where": "San Francisco", - "who": "John, Sarah", - "why": "Business reasons", - "fact_type": "world", - "entities": [{"text": "John", "type": "PERSON"}], - "causal_relations": [], - }) + facts.append( + { + "what": f"Mock fact {i}: Something happened based on the content", + "when": "2024-06-15", + "where": "San Francisco", + "who": "John, Sarah", + "why": "Business reasons", + "fact_type": "world", + "entities": [{"text": "John", "type": "PERSON"}], + "causal_relations": [], + } + ) return facts @@ -122,6 +124,7 @@ async def memory_with_mock_llm(self, pg0_db_url, embeddings, cross_encoder, quer @pytest.fixture def disable_observations(self): from hindsight_api.config import _get_raw_config + config = _get_raw_config() original = config.enable_observations config.enable_observations = False @@ -147,11 +150,13 @@ async def test_large_batch_500k_chars_20_items(self, memory_with_mock_llm, reque contents = [] for i in range(num_items): content_text = generate_content(chars_per_item) - contents.append({ - "content": content_text, - "context": f"Test content item {i + 1} of {num_items}", - "event_date": datetime.now(UTC), - }) + contents.append( + { + "content": content_text, + "context": f"Test content item {i + 1} of {num_items}", + "event_date": datetime.now(UTC), + } + ) actual_total_chars = sum(len(c["content"]) for c in contents) logger.info(f"Created {num_items} content items with {actual_total_chars:,} total chars") @@ -191,7 +196,7 @@ async def mock_llm_call(*args, **kwargs): return response_dict # Patch LLMProvider.call at the class level - with patch('hindsight_api.engine.llm_wrapper.LLMProvider.call', new=mock_llm_call): + with patch("hindsight_api.engine.llm_wrapper.LLMProvider.call", new=mock_llm_call): start_time = time.time() try: @@ -247,11 +252,13 @@ async def test_batch_chunking_behavior(self, memory_with_mock_llm, request_conte contents = [] for i in range(num_items): - contents.append({ - "content": generate_content(chars_per_item), - "context": f"Chunk test item {i + 1}", - "event_date": datetime.now(UTC), - }) + contents.append( + { + "content": generate_content(chars_per_item), + "context": f"Chunk test item {i + 1}", + "event_date": datetime.now(UTC), + } + ) actual_total_chars = sum(len(c["content"]) for c in contents) logger.info(f"Created {num_items} items with {actual_total_chars:,} chars (should trigger chunking)") @@ -275,7 +282,7 @@ async def mock_llm_call(*args, **kwargs): return response_dict, TokenUsage(input_tokens=100, output_tokens=50) return response_dict - with patch('hindsight_api.engine.llm_wrapper.LLMProvider.call', new=mock_llm_call): + with patch("hindsight_api.engine.llm_wrapper.LLMProvider.call", new=mock_llm_call): start_time = time.time() result = await memory.retain_batch_async( @@ -305,9 +312,18 @@ async def test_db_connection_pool_under_load(self, memory_with_mock_llm, request async def mock_llm_call(*args, **kwargs): # Small delay to simulate real LLM latency await asyncio.sleep(0.01) - mock_facts = [{"what": "Test fact", "when": "now", "where": "here", - "who": "someone", "why": "testing", "fact_type": "world", - "entities": [], "causal_relations": []}] + mock_facts = [ + { + "what": "Test fact", + "when": "now", + "where": "here", + "who": "someone", + "why": "testing", + "fact_type": "world", + "entities": [], + "causal_relations": [], + } + ] response_dict = {"facts": mock_facts} return_usage = kwargs.get("return_usage", False) @@ -315,16 +331,18 @@ async def mock_llm_call(*args, **kwargs): return response_dict, TokenUsage(input_tokens=10, output_tokens=10) return response_dict - with patch('hindsight_api.engine.llm_wrapper.LLMProvider.call', new=mock_llm_call): + with patch("hindsight_api.engine.llm_wrapper.LLMProvider.call", new=mock_llm_call): # Run 10 concurrent retain operations tasks = [] for i in range(10): bank_id = f"pool-test-{uuid.uuid4().hex[:8]}" - contents = [{ - "content": f"Test content for concurrent operation {i}. " * 50, - "context": f"Pool test {i}", - "event_date": datetime.now(UTC), - }] + contents = [ + { + "content": f"Test content for concurrent operation {i}. " * 50, + "context": f"Pool test {i}", + "event_date": datetime.now(UTC), + } + ] tasks.append( memory.retain_batch_async(bank_id=bank_id, contents=contents, request_context=request_context) ) diff --git a/hindsight-api-slim/tests/test_main_module.py b/hindsight-api-slim/tests/test_main_module.py index b59c98ac8a..0f6b42bb06 100644 --- a/hindsight-api-slim/tests/test_main_module.py +++ b/hindsight-api-slim/tests/test_main_module.py @@ -43,14 +43,15 @@ def tracking_load_extension(name, base_class): loaded_extensions[name] = result return result - with patch("hindsight_api.main.MemoryEngine") as mock_engine, \ - patch("hindsight_api.main.create_app") as mock_create_app, \ - patch("hindsight_api.main._get_raw_config") as mock_get_config, \ - patch("hindsight_api.main.load_extension", side_effect=tracking_load_extension), \ - patch("hindsight_api.main.DefaultExtensionContext"), \ - patch("hindsight_api.main.print_banner"), \ - patch("uvicorn.run"): # Don't actually start uvicorn - + with ( + patch("hindsight_api.main.MemoryEngine") as mock_engine, + patch("hindsight_api.main.create_app") as mock_create_app, + patch("hindsight_api.main._get_raw_config") as mock_get_config, + patch("hindsight_api.main.load_extension", side_effect=tracking_load_extension), + patch("hindsight_api.main.DefaultExtensionContext"), + patch("hindsight_api.main.print_banner"), + patch("uvicorn.run"), + ): # Don't actually start uvicorn mock_config = MagicMock() mock_config.host = "0.0.0.0" mock_config.port = 8888 @@ -63,17 +64,21 @@ def tracking_load_extension(name, base_class): mock_create_app.return_value = MagicMock() # Mock sys.argv to simulate CLI invocation - with patch.object(sys, 'argv', ['hindsight-api']): + with patch.object(sys, "argv", ["hindsight-api"]): from hindsight_api.main import main + main() # Verify TENANT extension was loaded - assert "TENANT" in loaded_extensions, \ + assert "TENANT" in loaded_extensions, ( "main.py did not call load_extension('TENANT', ...) - extensions not loaded!" - assert loaded_extensions["TENANT"] is not None, \ + ) + assert loaded_extensions["TENANT"] is not None, ( "load_extension('TENANT', ...) returned None despite env var being set" - assert isinstance(loaded_extensions["TENANT"], MockTenantExtension), \ + ) + assert isinstance(loaded_extensions["TENANT"], MockTenantExtension), ( f"Expected MockTenantExtension, got {type(loaded_extensions['TENANT'])}" + ) def test_main_loads_operation_validator_when_configured(self, monkeypatch): """ @@ -94,14 +99,15 @@ def tracking_load_extension(name, base_class): loaded_extensions[name] = result return result - with patch("hindsight_api.main.MemoryEngine") as mock_engine, \ - patch("hindsight_api.main.create_app") as mock_create_app, \ - patch("hindsight_api.main._get_raw_config") as mock_get_config, \ - patch("hindsight_api.main.load_extension", side_effect=tracking_load_extension), \ - patch("hindsight_api.main.DefaultExtensionContext"), \ - patch("hindsight_api.main.print_banner"), \ - patch("uvicorn.run"): - + with ( + patch("hindsight_api.main.MemoryEngine") as mock_engine, + patch("hindsight_api.main.create_app") as mock_create_app, + patch("hindsight_api.main._get_raw_config") as mock_get_config, + patch("hindsight_api.main.load_extension", side_effect=tracking_load_extension), + patch("hindsight_api.main.DefaultExtensionContext"), + patch("hindsight_api.main.print_banner"), + patch("uvicorn.run"), + ): mock_config = MagicMock() mock_config.host = "0.0.0.0" mock_config.port = 8888 @@ -113,12 +119,14 @@ def tracking_load_extension(name, base_class): mock_engine.return_value = MagicMock() mock_create_app.return_value = MagicMock() - with patch.object(sys, 'argv', ['hindsight-api']): + with patch.object(sys, "argv", ["hindsight-api"]): from hindsight_api.main import main + main() - assert "OPERATION_VALIDATOR" in loaded_extensions, \ + assert "OPERATION_VALIDATOR" in loaded_extensions, ( "main.py did not call load_extension('OPERATION_VALIDATOR', ...)" + ) assert loaded_extensions["OPERATION_VALIDATOR"] is not None assert isinstance(loaded_extensions["OPERATION_VALIDATOR"], MockOperationValidator) @@ -141,13 +149,14 @@ def capture_memory_engine(*args, **kwargs): memory_engine_calls.append({"args": args, "kwargs": kwargs}) return MagicMock() - with patch("hindsight_api.main.MemoryEngine", side_effect=capture_memory_engine), \ - patch("hindsight_api.main.create_app") as mock_create_app, \ - patch("hindsight_api.main._get_raw_config") as mock_get_config, \ - patch("hindsight_api.main.DefaultExtensionContext"), \ - patch("hindsight_api.main.print_banner"), \ - patch("uvicorn.run"): - + with ( + patch("hindsight_api.main.MemoryEngine", side_effect=capture_memory_engine), + patch("hindsight_api.main.create_app") as mock_create_app, + patch("hindsight_api.main._get_raw_config") as mock_get_config, + patch("hindsight_api.main.DefaultExtensionContext"), + patch("hindsight_api.main.print_banner"), + patch("uvicorn.run"), + ): mock_config = MagicMock() mock_config.host = "0.0.0.0" mock_config.port = 8888 @@ -158,8 +167,9 @@ def capture_memory_engine(*args, **kwargs): mock_get_config.return_value = mock_config mock_create_app.return_value = MagicMock() - with patch.object(sys, 'argv', ['hindsight-api']): + with patch.object(sys, "argv", ["hindsight-api"]): from hindsight_api.main import main + main() # Verify MemoryEngine was called @@ -168,10 +178,10 @@ def capture_memory_engine(*args, **kwargs): call_kwargs = memory_engine_calls[0]["kwargs"] # THE CRITICAL ASSERTION: tenant_extension must be passed and not None - assert "tenant_extension" in call_kwargs, \ - "MemoryEngine was not called with tenant_extension parameter!" - assert call_kwargs["tenant_extension"] is not None, \ + assert "tenant_extension" in call_kwargs, "MemoryEngine was not called with tenant_extension parameter!" + assert call_kwargs["tenant_extension"] is not None, ( "tenant_extension was None - main.py did not pass loaded extension to MemoryEngine!" + ) def test_main_sets_extension_context_on_tenant_extension(self, monkeypatch): """ @@ -198,13 +208,14 @@ def capture_context(*args, **kwargs): context_created.append(ctx) return ctx - with patch("hindsight_api.main.MemoryEngine", side_effect=capture_memory_engine), \ - patch("hindsight_api.main.create_app") as mock_create_app, \ - patch("hindsight_api.main._get_raw_config") as mock_get_config, \ - patch("hindsight_api.main.DefaultExtensionContext", side_effect=capture_context), \ - patch("hindsight_api.main.print_banner"), \ - patch("uvicorn.run"): - + with ( + patch("hindsight_api.main.MemoryEngine", side_effect=capture_memory_engine), + patch("hindsight_api.main.create_app") as mock_create_app, + patch("hindsight_api.main._get_raw_config") as mock_get_config, + patch("hindsight_api.main.DefaultExtensionContext", side_effect=capture_context), + patch("hindsight_api.main.print_banner"), + patch("uvicorn.run"), + ): mock_config = MagicMock() mock_config.host = "0.0.0.0" mock_config.port = 8888 @@ -215,15 +226,15 @@ def capture_context(*args, **kwargs): mock_get_config.return_value = mock_config mock_create_app.return_value = MagicMock() - with patch.object(sys, 'argv', ['hindsight-api']): + with patch.object(sys, "argv", ["hindsight-api"]): from hindsight_api.main import main + main() # Verify context was created and set assert len(context_created) == 1, "DefaultExtensionContext should be created" assert captured_tenant_ext[0] is not None, "Tenant extension should be captured" - assert captured_tenant_ext[0]._context_set, \ - "set_context was not called on tenant extension" + assert captured_tenant_ext[0]._context_set, "set_context was not called on tenant extension" def test_main_works_without_extensions(self, monkeypatch): """ @@ -240,12 +251,13 @@ def capture_memory_engine(*args, **kwargs): memory_engine_calls.append({"args": args, "kwargs": kwargs}) return MagicMock() - with patch("hindsight_api.main.MemoryEngine", side_effect=capture_memory_engine), \ - patch("hindsight_api.main.create_app") as mock_create_app, \ - patch("hindsight_api.main._get_raw_config") as mock_get_config, \ - patch("hindsight_api.main.print_banner"), \ - patch("uvicorn.run"): - + with ( + patch("hindsight_api.main.MemoryEngine", side_effect=capture_memory_engine), + patch("hindsight_api.main.create_app") as mock_create_app, + patch("hindsight_api.main._get_raw_config") as mock_get_config, + patch("hindsight_api.main.print_banner"), + patch("uvicorn.run"), + ): mock_config = MagicMock() mock_config.host = "0.0.0.0" mock_config.port = 8888 @@ -256,8 +268,9 @@ def capture_memory_engine(*args, **kwargs): mock_get_config.return_value = mock_config mock_create_app.return_value = MagicMock() - with patch.object(sys, 'argv', ['hindsight-api']): + with patch.object(sys, "argv", ["hindsight-api"]): from hindsight_api.main import main + main() # Should work without extensions @@ -285,12 +298,13 @@ def capture_uvicorn_run(**kwargs): mock_app = MagicMock() - with patch("hindsight_api.main.MemoryEngine") as mock_engine, \ - patch("hindsight_api.main.create_app", return_value=mock_app), \ - patch("hindsight_api.main._get_raw_config") as mock_get_config, \ - patch("hindsight_api.main.print_banner"), \ - patch("uvicorn.run", side_effect=capture_uvicorn_run): - + with ( + patch("hindsight_api.main.MemoryEngine") as mock_engine, + patch("hindsight_api.main.create_app", return_value=mock_app), + patch("hindsight_api.main._get_raw_config") as mock_get_config, + patch("hindsight_api.main.print_banner"), + patch("uvicorn.run", side_effect=capture_uvicorn_run), + ): mock_config = MagicMock() mock_config.host = "0.0.0.0" mock_config.port = 8888 @@ -301,14 +315,14 @@ def capture_uvicorn_run(**kwargs): mock_get_config.return_value = mock_config mock_engine.return_value = MagicMock() - with patch.object(sys, 'argv', ['hindsight-api', '--workers', '1']): + with patch.object(sys, "argv", ["hindsight-api", "--workers", "1"]): from hindsight_api.main import main + main() assert len(uvicorn_calls) == 1 # With workers=1, should pass app object, not import string - assert uvicorn_calls[0]["app"] is mock_app, \ - "main.py should pass app object (not import string) when workers=1" + assert uvicorn_calls[0]["app"] is mock_app, "main.py should pass app object (not import string) when workers=1" def test_main_uses_import_string_for_multiple_workers(self, monkeypatch): """ @@ -325,12 +339,13 @@ def test_main_uses_import_string_for_multiple_workers(self, monkeypatch): def capture_uvicorn_run(**kwargs): uvicorn_calls.append(kwargs) - with patch("hindsight_api.main.MemoryEngine") as mock_engine, \ - patch("hindsight_api.main.create_app") as mock_create_app, \ - patch("hindsight_api.main._get_raw_config") as mock_get_config, \ - patch("hindsight_api.main.print_banner"), \ - patch("uvicorn.run", side_effect=capture_uvicorn_run): - + with ( + patch("hindsight_api.main.MemoryEngine") as mock_engine, + patch("hindsight_api.main.create_app") as mock_create_app, + patch("hindsight_api.main._get_raw_config") as mock_get_config, + patch("hindsight_api.main.print_banner"), + patch("uvicorn.run", side_effect=capture_uvicorn_run), + ): mock_config = MagicMock() mock_config.host = "0.0.0.0" mock_config.port = 8888 @@ -342,14 +357,16 @@ def capture_uvicorn_run(**kwargs): mock_engine.return_value = MagicMock() mock_create_app.return_value = MagicMock() - with patch.object(sys, 'argv', ['hindsight-api', '--workers', '2']): + with patch.object(sys, "argv", ["hindsight-api", "--workers", "2"]): from hindsight_api.main import main + main() assert len(uvicorn_calls) == 1 # With workers > 1, should use import string - assert uvicorn_calls[0]["app"] == "hindsight_api.server:app", \ + assert uvicorn_calls[0]["app"] == "hindsight_api.server:app", ( "main.py should use import string when workers > 1" + ) assert uvicorn_calls[0]["workers"] == 2 def test_main_sets_keepalive_timeout(self, monkeypatch): @@ -366,12 +383,13 @@ def test_main_sets_keepalive_timeout(self, monkeypatch): def capture_uvicorn_run(**kwargs): uvicorn_calls.append(kwargs) - with patch("hindsight_api.main.MemoryEngine") as mock_engine, \ - patch("hindsight_api.main.create_app") as mock_create_app, \ - patch("hindsight_api.main._get_raw_config") as mock_get_config, \ - patch("hindsight_api.main.print_banner"), \ - patch("uvicorn.run", side_effect=capture_uvicorn_run): - + with ( + patch("hindsight_api.main.MemoryEngine") as mock_engine, + patch("hindsight_api.main.create_app") as mock_create_app, + patch("hindsight_api.main._get_raw_config") as mock_get_config, + patch("hindsight_api.main.print_banner"), + patch("uvicorn.run", side_effect=capture_uvicorn_run), + ): mock_config = MagicMock() mock_config.host = "0.0.0.0" mock_config.port = 8888 @@ -383,15 +401,16 @@ def capture_uvicorn_run(**kwargs): mock_engine.return_value = MagicMock() mock_create_app.return_value = MagicMock() - with patch.object(sys, 'argv', ['hindsight-api']): + with patch.object(sys, "argv", ["hindsight-api"]): from hindsight_api.main import main + main() assert len(uvicorn_calls) == 1 - assert "timeout_keep_alive" in uvicorn_calls[0], \ - "uvicorn config must set timeout_keep_alive" - assert uvicorn_calls[0]["timeout_keep_alive"] > 15, \ + assert "timeout_keep_alive" in uvicorn_calls[0], "uvicorn config must set timeout_keep_alive" + assert uvicorn_calls[0]["timeout_keep_alive"] > 15, ( "timeout_keep_alive must exceed aiohttp's 15s client default" + ) # Mock extensions for testing diff --git a/hindsight-api-slim/tests/test_maintenance_loop.py b/hindsight-api-slim/tests/test_maintenance_loop.py new file mode 100644 index 0000000000..e270d586e9 --- /dev/null +++ b/hindsight-api-slim/tests/test_maintenance_loop.py @@ -0,0 +1,111 @@ +"""Tests for the MaintenanceLoop: due-timer logic, consolidation reconcile +gating, and cross-schema retention purge.""" + +import time +import uuid + +import pytest + +from hindsight_api.engine.maintenance import MaintenanceLoop +from hindsight_api.engine.memory_engine import MemoryEngine + + +def test_start_is_noop_on_oracle(monkeypatch): + """The loop is PostgreSQL-only (PG-only tables + routines); it must not start on Oracle.""" + import hindsight_api.engine.maintenance as maintenance_mod + + monkeypatch.setattr(maintenance_mod, "_is_oracle", lambda: True) + loop = MaintenanceLoop(engine=None) + loop.start() + assert loop._task is None + + +def test_is_due_runs_at_start_then_waits_interval(): + """A job is due on first check (run-at-start), then not until its interval elapses.""" + loop = MaintenanceLoop(engine=None) # _is_due needs no engine + + assert loop._is_due("job", 3600) is True # never run -> due + assert loop._is_due("job", 3600) is False # just ran -> not due + + # Simulate the interval having elapsed. + loop._last_run["job"] = time.monotonic() - 4000 + assert loop._is_due("job", 3600) is True + + +async def _make_bank(memory: MemoryEngine, request_context, suffix: str, config_json: str | None = None) -> str: + bank_id = f"recon-{suffix}-{uuid.uuid4().hex[:8]}" + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + if config_json is not None: + async with memory._pool.acquire() as conn: + await conn.execute("UPDATE banks SET config = $2::jsonb WHERE bank_id = $1", bank_id, config_json) + return bank_id + + +async def _insert_fact(conn, bank_id: str) -> None: + await conn.execute( + "INSERT INTO memory_units (id, bank_id, text, fact_type, created_at) VALUES ($1, $2, 'a fact', 'experience', now())", + uuid.uuid4(), + bank_id, + ) + + +@pytest.mark.asyncio +async def test_reconcile_submits_eligible_skips_disabled_and_in_flight( + memory: MemoryEngine, request_context, monkeypatch +): + """Reconcile enqueues consolidation for eligible banks and skips banks that + disabled auto-consolidation or already have an in-flight consolidation.""" + eligible = await _make_bank( + memory, request_context, "eligible", '{"enable_observations": true, "enable_auto_consolidation": true}' + ) + disabled = await _make_bank(memory, request_context, "disabled", '{"enable_auto_consolidation": false}') + in_flight = await _make_bank(memory, request_context, "inflight") + + async with memory._pool.acquire() as conn: + await _insert_fact(conn, eligible) + await _insert_fact(conn, disabled) + await _insert_fact(conn, in_flight) + await conn.execute( + """ + INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload) + VALUES ($1, $2, 'consolidation', 'processing', '{}'::jsonb) + """, + uuid.uuid4(), + in_flight, + ) + + submitted: list[str] = [] + + async def _record(*, bank_id, request_context, observation_scopes=None): + submitted.append(bank_id) + return {"operation_id": str(uuid.uuid4())} + + monkeypatch.setattr(memory, "submit_async_consolidation", _record) + + await MaintenanceLoop(memory)._run_reconcile() + + # Shared pg0 may contain other eligible banks, so assert on membership. + assert eligible in submitted + assert disabled not in submitted + assert in_flight not in submitted + + +@pytest.mark.asyncio +async def test_purge_expired_deletes_old_rows_across_schema(memory: MemoryEngine): + """_purge_expired deletes rows older than the cutoff and keeps recent ones.""" + tag = f"maint-purge-{uuid.uuid4().hex[:8]}" + async with memory._pool.acquire() as conn: + await conn.execute( + "INSERT INTO audit_log (action, transport, started_at) VALUES ($1, 'system', now() - INTERVAL '10 days')", + tag, + ) + await conn.execute( + "INSERT INTO audit_log (action, transport, started_at) VALUES ($1, 'system', now())", + tag, + ) + + await MaintenanceLoop(memory)._purge_expired("audit_log", "started_at", 7) + + async with memory._pool.acquire() as conn: + remaining = await conn.fetchval("SELECT COUNT(*) FROM audit_log WHERE action = $1", tag) + assert remaining == 1 # only the recent row survives diff --git a/hindsight-api-slim/tests/test_maintenance_multitenant.py b/hindsight-api-slim/tests/test_maintenance_multitenant.py new file mode 100644 index 0000000000..84d3ff76af --- /dev/null +++ b/hindsight-api-slim/tests/test_maintenance_multitenant.py @@ -0,0 +1,174 @@ +"""Multi-tenant maintenance-loop test. + +Provisions 100 tenant schemas and verifies that each of the loop's three jobs — +audit-log retention, llm-request retention, and consolidation reconcile — +affects only the tenants that should be affected, leaving the rest untouched. + +Schemas are provisioned cheaply by cloning just the five tables the loop touches +(`CREATE TABLE ... LIKE public. INCLUDING DEFAULTS`); the server-side routines +discover them by table presence, exactly as they would real tenant schemas. +""" + +import uuid + +import pytest +import pytest_asyncio + +from hindsight_api.engine.maintenance import MaintenanceLoop +from hindsight_api.engine.memory_engine import MemoryEngine, _current_schema +from hindsight_api.extensions.builtin.tenant import DefaultTenantExtension +from hindsight_api.extensions.tenant import Tenant + +N_TENANTS = 100 +_CLONED_TABLES = ("banks", "memory_units", "async_operations", "audit_log", "llm_requests") + + +class _StaticTenantExtension(DefaultTenantExtension): + """Lists a fixed set of tenants (each with a tenant_id) for the reconcile sweep.""" + + def __init__(self, tenants: list[Tenant]) -> None: + super().__init__(config={}) + self._tenants = list(tenants) + + async def list_tenants(self) -> list[Tenant]: + return list(self._tenants) + + +@pytest_asyncio.fixture +async def hundred_tenant_schemas(memory: MemoryEngine): + """Create N_TENANTS isolated schemas cloning the loop's tables; drop them after.""" + prefix = f"mt{uuid.uuid4().hex[:8]}" + schemas = [f"{prefix}_{i:03d}" for i in range(N_TENANTS)] + async with memory._pool.acquire() as conn: + for s in schemas: + await conn.execute(f'CREATE SCHEMA "{s}"') + for table in _CLONED_TABLES: + await conn.execute(f'CREATE TABLE "{s}".{table} (LIKE public.{table} INCLUDING DEFAULTS)') + try: + yield prefix, schemas + finally: + async with memory._pool.acquire() as conn: + for s in schemas: + await conn.execute(f'DROP SCHEMA IF EXISTS "{s}" CASCADE') + + +async def _expired_schemas(memory: MemoryEngine, table: str, ts_col: str, days: int) -> set[str]: + async with memory._pool.acquire() as conn: + rows = await conn.fetch("SELECT * FROM public.schemas_with_expired_rows($1, $2, $3)", table, ts_col, days) + return {r[0] for r in rows} + + +async def _banks_needing(memory: MemoryEngine) -> set[tuple[str, str]]: + async with memory._pool.acquire() as conn: + rows = await conn.fetch("SELECT schema_name, bank_id FROM public.banks_needing_consolidation()") + return {(r["schema_name"], r["bank_id"]) for r in rows} + + +@pytest.mark.asyncio +async def test_maintenance_loop_targets_only_affected_tenants( + memory: MemoryEngine, hundred_tenant_schemas, monkeypatch +): + prefix, schemas = hundred_tenant_schemas + schema_set = set(schemas) + loop = MaintenanceLoop(memory) + + # Per-tenant categories (deterministic by index): + # consolidation (i % 4): 0=eligible, 1=auto-consolidation disabled, 2=in-flight op, 3=already consolidated + # audit_log retention: i % 2 == 0 has a 10-day-old row (the rest only recent) + # llm_requests retention: i % 3 == 0 has a 3-day-old row (the rest only recent) + eligible: set[tuple[str, str]] = set() + not_eligible: set[tuple[str, str]] = set() + audit_with_old: set[str] = set() + llm_with_old: set[str] = set() + + async with memory._pool.acquire() as conn: + for i, s in enumerate(schemas): + bank = f"{prefix}-bank-{i}" + cat = i % 4 + if cat == 1: + cfg = '{"enable_auto_consolidation": false}' + else: + cfg = '{"enable_observations": true, "enable_auto_consolidation": true}' + await conn.execute(f'INSERT INTO "{s}".banks (bank_id, config) VALUES ($1, $2::jsonb)', bank, cfg) + await conn.execute( + f'INSERT INTO "{s}".memory_units (id, bank_id, text, fact_type, created_at, consolidated_at) ' + f"VALUES ($1, $2, 'f', 'experience', now(), CASE WHEN $3 THEN now() ELSE NULL END)", + uuid.uuid4(), + bank, + cat == 3, # already consolidated + ) + if cat == 2: # in-flight consolidation op + await conn.execute( + f'INSERT INTO "{s}".async_operations (operation_id, bank_id, operation_type, status, task_payload) ' + f"VALUES ($1, $2, 'consolidation', 'pending', '{{}}'::jsonb)", + uuid.uuid4(), + bank, + ) + (eligible if cat == 0 else not_eligible).add((s, bank)) + + await conn.execute( + f"INSERT INTO \"{s}\".audit_log (action, transport, started_at) VALUES ('NEW', 'system', now())" + ) + if i % 2 == 0: + await conn.execute( + f'INSERT INTO "{s}".audit_log (action, transport, started_at) ' + f"VALUES ('OLD', 'system', now() - INTERVAL '10 days')" + ) + audit_with_old.add(s) + + await conn.execute(f"INSERT INTO \"{s}\".llm_requests (status, started_at) VALUES ('success', now())") + if i % 3 == 0: + await conn.execute( + f'INSERT INTO "{s}".llm_requests (status, started_at) ' + f"VALUES ('success', now() - INTERVAL '3 days')" + ) + llm_with_old.add(s) + + # ── 1. audit_log retention ──────────────────────────────────────────────── + # Discovery targets exactly the tenants holding an expired row. + assert (await _expired_schemas(memory, "audit_log", "started_at", 7)) & schema_set == audit_with_old + await loop._purge_expired("audit_log", "started_at", 7) + async with memory._pool.acquire() as conn: + for s in schemas: + old = await conn.fetchval(f"SELECT count(*) FROM \"{s}\".audit_log WHERE action = 'OLD'") + new = await conn.fetchval(f"SELECT count(*) FROM \"{s}\".audit_log WHERE action = 'NEW'") + assert old == 0, f"{s}: expired audit row not purged" + assert new == 1, f"{s}: recent audit row wrongly deleted" + + # ── 2. llm_requests retention ───────────────────────────────────────────── + assert (await _expired_schemas(memory, "llm_requests", "started_at", 1)) & schema_set == llm_with_old + await loop._purge_expired("llm_requests", "started_at", 1) + async with memory._pool.acquire() as conn: + for s in schemas: + total = await conn.fetchval(f'SELECT count(*) FROM "{s}".llm_requests') + recent = await conn.fetchval( + f"SELECT count(*) FROM \"{s}\".llm_requests WHERE started_at > now() - INTERVAL '1 day'" + ) + assert total == 1, f"{s}: expected only the recent llm_requests row to remain" + assert recent == 1, f"{s}: recent llm_requests row wrongly deleted" + + # ── 3. consolidation reconcile ──────────────────────────────────────────── + # Discovery returns exactly the eligible banks among ours (not disabled/in-flight/consolidated). + discovered_banks = await _banks_needing(memory) + assert {(s, b) for (s, b) in discovered_banks if s in schema_set} == eligible + assert discovered_banks.isdisjoint(not_eligible) + + monkeypatch.setattr( + memory, + "_tenant_extension", + _StaticTenantExtension([Tenant(schema=s, tenant_id=f"tid-{i}") for i, s in enumerate(schemas)]), + ) + submitted: list[tuple[str | None, str]] = [] + + async def _record(*, bank_id, request_context, observation_scopes=None): + # Capture the schema the op is being enqueued into (set on the contextvar by the loop). + submitted.append((_current_schema.get(), bank_id)) + return {"operation_id": str(uuid.uuid4())} + + monkeypatch.setattr(memory, "submit_async_consolidation", _record) + + await loop._run_reconcile() + + ours_submitted = {(s, b) for (s, b) in submitted if s in schema_set} + # Exactly the eligible tenants were reconciled — into their own schema — and nobody else. + assert ours_submitted == eligible diff --git a/hindsight-api-slim/tests/test_maintenance_routines.py b/hindsight-api-slim/tests/test_maintenance_routines.py new file mode 100644 index 0000000000..dbd0bc9977 --- /dev/null +++ b/hindsight-api-slim/tests/test_maintenance_routines.py @@ -0,0 +1,157 @@ +"""Tests for the server-side maintenance discovery routines. + +``public.banks_needing_consolidation()`` and +``public.schemas_with_expired_rows(table, ts_col, days)`` are installed by the +maintenance-routines migration and loop over every schema holding the relevant +table in a single round-trip. These tests drive them directly against pg0. +""" + +import importlib.util +import uuid +from pathlib import Path + +import pytest + +from hindsight_api.engine.memory_engine import MemoryEngine + + +def _load_repair_migration(): + """Import the repair migration by path (filename starts with a digit, so it + is not importable as a normal module name).""" + path = ( + Path(__file__).resolve().parent.parent + / "hindsight_api/alembic/versions/b2d4f6a8c1e3_repair_maintenance_routines_public.py" + ) + spec = importlib.util.spec_from_file_location("_repair_maintenance_routines", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.mark.parametrize( + ("target_schema", "expected"), + [ + (None, True), # base-schema run (no target_schema) + ("", True), # falsy schema behaves like the base run + ("public", True), # the case #2056 regressed: explicit public must install + ("tenant_xyz", False), # per-tenant run skips to avoid concurrent CREATE + ], +) +def test_repair_gate_installs_on_public_and_base_runs(target_schema, expected): + """Regression for #2056: the maintenance routines live in ``public`` and must + be (re)created on both the base run and the explicit ``target_schema=public`` + run — the runtime always migrates an explicit ``public`` schema, so gating on + ``not target_schema`` alone silently skipped function creation.""" + migration = _load_repair_migration() + assert migration._should_install_public_routines(target_schema) is expected + + +async def _make_bank(memory: MemoryEngine, request_context, suffix: str) -> str: + bank_id = f"maint-{suffix}-{uuid.uuid4().hex[:8]}" + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + return bank_id + + +async def _insert_fact( + conn, bank_id: str, *, fact_type: str = "experience", consolidated: bool = False, failed: bool = False +) -> None: + await conn.execute( + """ + INSERT INTO memory_units (id, bank_id, text, fact_type, created_at, consolidated_at, consolidation_failed_at) + VALUES ($1, $2, 'a fact', $3, now(), + CASE WHEN $4 THEN now() ELSE NULL END, + CASE WHEN $5 THEN now() ELSE NULL END) + """, + uuid.uuid4(), + bank_id, + fact_type, + consolidated, + failed, + ) + + +@pytest.mark.asyncio +async def test_banks_needing_consolidation_filters(memory: MemoryEngine, request_context): + """Returns only banks with eligible-but-unscheduled facts, auto-consolidation + not bank-disabled, and no in-flight consolidation op.""" + eligible = await _make_bank(memory, request_context, "eligible") + eligible_world = await _make_bank(memory, request_context, "world") + all_consolidated = await _make_bank(memory, request_context, "done") + all_failed = await _make_bank(memory, request_context, "failed") + in_flight = await _make_bank(memory, request_context, "inflight") + bank_disabled = await _make_bank(memory, request_context, "disabled") + + async with memory._pool.acquire() as conn: + await _insert_fact(conn, eligible) + await _insert_fact(conn, eligible_world, fact_type="world") + await _insert_fact(conn, all_consolidated, consolidated=True) + await _insert_fact(conn, all_failed, failed=True) + + await _insert_fact(conn, in_flight) + await conn.execute( + """ + INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload) + VALUES ($1, $2, 'consolidation', 'pending', '{}'::jsonb) + """, + uuid.uuid4(), + in_flight, + ) + + await _insert_fact(conn, bank_disabled) + await conn.execute( + "UPDATE banks SET config = '{\"enable_auto_consolidation\": false}'::jsonb WHERE bank_id = $1", + bank_disabled, + ) + + rows = await conn.fetch("SELECT schema_name, bank_id FROM public.banks_needing_consolidation()") + + returned = {r["bank_id"] for r in rows} + assert eligible in returned + assert eligible_world in returned + assert all_consolidated not in returned + assert all_failed not in returned + assert in_flight not in returned + assert bank_disabled not in returned + + +@pytest.mark.asyncio +async def test_banks_needing_consolidation_includes_in_flight_after_completion(memory: MemoryEngine, request_context): + """A bank whose only consolidation op is already completed is still eligible + (only pending/processing ops suppress re-scheduling).""" + bank = await _make_bank(memory, request_context, "completed-op") + async with memory._pool.acquire() as conn: + await _insert_fact(conn, bank) + await conn.execute( + """ + INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload) + VALUES ($1, $2, 'consolidation', 'completed', '{}'::jsonb) + """, + uuid.uuid4(), + bank, + ) + rows = await conn.fetch("SELECT bank_id FROM public.banks_needing_consolidation()") + assert bank in {r["bank_id"] for r in rows} + + +@pytest.mark.asyncio +async def test_schemas_with_expired_rows(memory: MemoryEngine): + """Returns schemas holding a row older than p_days; respects the p_days<=0 guard.""" + async with memory._pool.acquire() as conn: + await conn.execute( + "INSERT INTO audit_log (action, transport, started_at) VALUES ('t', 'system', now() - INTERVAL '10 days')" + ) + + # 7-day cutoff: the 10-day-old row makes 'public' expired. + expired_7 = await conn.fetch("SELECT * FROM public.schemas_with_expired_rows('audit_log', 'started_at', 7)") + assert "public" in {r[0] for r in expired_7} + + # 100-year cutoff: nothing is that old. + expired_century = await conn.fetch( + "SELECT * FROM public.schemas_with_expired_rows('audit_log', 'started_at', 36500)" + ) + assert "public" not in {r[0] for r in expired_century} + + # Disabled retention (days <= 0): always empty. + disabled = await conn.fetch("SELECT * FROM public.schemas_with_expired_rows('audit_log', 'started_at', 0)") + assert len(disabled) == 0 diff --git a/hindsight-api-slim/tests/test_mcp_routing.py b/hindsight-api-slim/tests/test_mcp_routing.py index 70c30cffd6..8345fdf1e3 100644 --- a/hindsight-api-slim/tests/test_mcp_routing.py +++ b/hindsight-api-slim/tests/test_mcp_routing.py @@ -423,6 +423,28 @@ def test_global_mcp_enabled_tools_intersects_with_single_bank_mode(mock_memory): assert "list_banks" not in tools # single-bank mode excludes it regardless +def test_mcp_instructions_append_to_retain_and_recall_descriptions(mock_memory): + """HINDSIGHT_API_MCP_INSTRUCTIONS customizes retain/recall tool descriptions.""" + from unittest.mock import MagicMock, patch + + from hindsight_api.api.mcp import create_mcp_server + + custom_instructions = "Also store every action you take." + mock_cfg = MagicMock() + mock_cfg.mcp_enabled_tools = ["retain", "recall", "reflect"] + mock_cfg.mcp_instructions = custom_instructions + + with patch("hindsight_api.api.mcp._get_raw_config", return_value=mock_cfg): + mcp_server = create_mcp_server(mock_memory, multi_bank=True) + + tools = _tools(mcp_server) + expected_suffix = f"Additional instructions: {custom_instructions}" + + assert expected_suffix in tools["retain"].description + assert expected_suffix in tools["recall"].description + assert expected_suffix not in tools["reflect"].description + + @pytest.mark.asyncio async def test_routing_logic_from_url_path(): """Test that routing correctly selects server based on URL structure. @@ -430,14 +452,6 @@ async def test_routing_logic_from_url_path(): Simulates the path parsing logic from MCPMiddleware.__call__ after the prefix has been stripped. Any first path segment is treated as a bank_id. """ - from hindsight_api.api.mcp import MCPMiddleware - - # Mock memory - mock_memory = MagicMock() - - # Create middleware - middleware = MCPMiddleware(None, mock_memory) - # Simulate different URL patterns and verify routing # Path is what remains after stripping the /mcp prefix test_cases = [ diff --git a/hindsight-api-slim/tests/test_mcp_string_coercion.py b/hindsight-api-slim/tests/test_mcp_string_coercion.py index 1316fffd02..947194613f 100644 --- a/hindsight-api-slim/tests/test_mcp_string_coercion.py +++ b/hindsight-api-slim/tests/test_mcp_string_coercion.py @@ -35,16 +35,12 @@ def test_direct_array_type(self): 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"}]} - ) + 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"}]} - ) + arrays, objects = self._run({"oneOf": [{"type": "array", "items": {"type": "string"}}, {"type": "null"}]}) assert "p" in arrays # --- object types --- @@ -136,9 +132,7 @@ def test_coerce_empty_dict_string(self): assert result["metadata"] == {} def test_native_dict_passthrough(self): - result = _coerce_string_json( - {"metadata": {"key": "value"}}, array_params=set(), object_params={"metadata"} - ) + result = _coerce_string_json({"metadata": {"key": "value"}}, array_params=set(), object_params={"metadata"}) assert result["metadata"] == {"key": "value"} # --- non-coercible values left untouched --- @@ -153,16 +147,12 @@ def test_invalid_json_string_passthrough(self): 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() - ) + 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"} - ) + 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): @@ -175,15 +165,11 @@ def test_string_param_not_touched(self): 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() - ) + 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() - ) + result = _coerce_string_json({"verbose": True}, array_params=set(), object_params=set()) assert result["verbose"] is True def test_missing_param_no_error(self): @@ -277,13 +263,15 @@ 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"}, - }) + 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 @@ -296,11 +284,13 @@ async def test_strips_extra_args_and_coerces(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": '["x"]', - "explanation": "LLM added this", - }) + await tool.run( + { + "query": "hi", + "tags": '["x"]', + "explanation": "LLM added this", + } + ) assert captured["tags"] == ["x"] assert "explanation" not in captured diff --git a/hindsight-api-slim/tests/test_mcp_tool_filtering.py b/hindsight-api-slim/tests/test_mcp_tool_filtering.py index 4587a74352..625e10c9e9 100644 --- a/hindsight-api-slim/tests/test_mcp_tool_filtering.py +++ b/hindsight-api-slim/tests/test_mcp_tool_filtering.py @@ -78,10 +78,13 @@ async def test_filter_mcp_tools_returns_empty_set(): class DenyAllValidator(OperationValidatorExtension): async def validate_retain(self, ctx): return ValidationResult.accept() + async def validate_recall(self, ctx): return ValidationResult.accept() + async def validate_reflect(self, ctx): return ValidationResult.accept() + async def filter_mcp_tools(self, bank_id, request_context, tools): return frozenset() @@ -189,10 +192,13 @@ async def test_validator_cannot_add_tools_beyond_bank_config(): class PermissiveValidator(OperationValidatorExtension): async def validate_retain(self, ctx): return ValidationResult.accept() + async def validate_recall(self, ctx): return ValidationResult.accept() + async def validate_reflect(self, ctx): return ValidationResult.accept() + async def filter_mcp_tools(self, bank_id, request_context, tools): return tools | {"retain", "delete_bank"} @@ -241,15 +247,19 @@ async def filter_mcp_tools(self, bank_id, request_context, tools): async def test_validator_exception_fails_open(caplog): """If filter_mcp_tools raises, all tools remain visible and warning is logged.""" import logging + caplog.set_level(logging.WARNING) class BrokenValidator(OperationValidatorExtension): async def validate_retain(self, ctx): return ValidationResult.accept() + async def validate_recall(self, ctx): return ValidationResult.accept() + async def validate_reflect(self, ctx): return ValidationResult.accept() + async def filter_mcp_tools(self, bank_id, request_context, tools): raise RuntimeError("Policy backend unreachable") diff --git a/hindsight-api-slim/tests/test_mcp_tools.py b/hindsight-api-slim/tests/test_mcp_tools.py index 05023606c3..fa55d0d0ab 100644 --- a/hindsight-api-slim/tests/test_mcp_tools.py +++ b/hindsight-api-slim/tests/test_mcp_tools.py @@ -174,6 +174,7 @@ async def _get_mental_model(**kwargs): # Memory browsing methods memory.list_memory_units = AsyncMock(return_value={"items": [{"id": "mem-1", "content": "Test"}], "total": 1}) memory.get_memory_unit = AsyncMock(return_value={"id": "mem-1", "content": "Test memory"}) + memory.update_memory_unit = AsyncMock(return_value={"id": "mem-1", "state": "valid"}) # Document methods memory.list_documents = AsyncMock(return_value={"items": [{"id": "doc-1", "name": "Test Doc"}], "total": 1}) @@ -361,7 +362,117 @@ def test_mental_model_tools_in_default_set(self): assert "clear_memories" in tools assert "sync_retain" in tools assert "clear_mental_model" in tools - assert len(tools) == 30 + assert "update_memory" in tools + assert "invalidate_memory" in tools + assert len(tools) == 32 + + def test_all_tools_have_nonempty_descriptions(self): + """Every registered tool must expose a non-empty description. + + Amazon Bedrock's Converse API rejects any toolSpec whose description is + an empty string, so a tool with no description breaks every Bedrock + request that includes it. This regressed once because update_memory and + invalidate_memory used an f-string as their "docstring" + (f\"\"\"{_DOC}...\"\"\"), which is an expression rather than a string + literal — so __doc__ was None and FastMCP emitted an empty description. + """ + from fastmcp import FastMCP + + memory = MagicMock() + # Mock all engine methods that tools reference + memory.retain_batch_async = AsyncMock() + memory.submit_async_retain = AsyncMock(return_value={"operation_id": "op"}) + memory.recall_async = AsyncMock(return_value=MagicMock(results=[])) + memory.reflect_async = AsyncMock() + memory.list_banks = AsyncMock(return_value=[]) + memory.get_bank_profile = AsyncMock(return_value={}) + memory.update_bank = AsyncMock() + memory.list_mental_models = AsyncMock(return_value=[]) + memory.get_mental_model = AsyncMock() + memory.create_mental_model = AsyncMock() + memory.submit_async_refresh_mental_model = AsyncMock() + memory.update_mental_model = AsyncMock() + memory.delete_mental_model = AsyncMock() + memory.list_directives = AsyncMock(return_value=[]) + memory.create_directive = AsyncMock() + memory.delete_directive = AsyncMock() + memory.list_memory_units = AsyncMock(return_value={}) + memory.get_memory_unit = AsyncMock() + memory.list_documents = AsyncMock(return_value={}) + memory.get_document = AsyncMock() + memory.delete_document = AsyncMock() + memory.list_operations = AsyncMock(return_value={}) + memory.get_operation_status = AsyncMock() + memory.cancel_operation = AsyncMock() + memory.list_tags = AsyncMock(return_value={}) + memory.get_bank_stats = AsyncMock(return_value={}) + memory.delete_bank = AsyncMock(return_value={}) + + # Cover both registration paths: multi-bank (include_bank_id_param=True) + # and single-bank (False), since each registers a distinct function. + for include_bank_id_param in (True, False): + mcp = FastMCP("test") + config = MCPToolsConfig( + bank_id_resolver=lambda: "bank", + include_bank_id_param=include_bank_id_param, + tools=None, # Default - all tools + ) + register_mcp_tools(mcp, memory, config) + tools = _tools(mcp) + missing = [name for name, tool in tools.items() if not (getattr(tool, "description", None) or "").strip()] + assert not missing, ( + f"tools with empty descriptions (include_bank_id_param={include_bank_id_param}): {missing}" + ) + + def test_no_mcp_tool_definition_can_lack_a_description(self): + """Statically reject any @mcp.tool that would register without a description. + + Complements test_all_tools_have_nonempty_descriptions: that test exercises + the *default* tool set at runtime, this one parses the source so it also + covers tools gated behind feature flags / non-default configs, and points + at the offending line directly. A tool must carry either a ``description=`` + kwarg on the decorator or a real string-literal docstring. An f-string + ``docstring`` (f\"\"\"{_DOC}...\"\"\") is an expression, not a literal, so + __doc__ stays None and FastMCP emits an empty description — which Bedrock's + Converse API rejects, breaking every request that advertises the tool. + """ + import ast + import pathlib + + from hindsight_api import mcp_tools + + source = pathlib.Path(mcp_tools.__file__).read_text() + tree = ast.parse(source) + + def is_tool_decorator(dec: ast.expr) -> bool: + target = dec.func if isinstance(dec, ast.Call) else dec + return isinstance(target, ast.Attribute) and target.attr == "tool" + + def has_valid_docstring(fn: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + first = fn.body[0] if fn.body else None + if not isinstance(first, ast.Expr): + return False + value = first.value + # ast.JoinedStr == f-string: __doc__ becomes None, not a docstring. + return isinstance(value, ast.Constant) and isinstance(value.value, str) and bool(value.value.strip()) + + offenders: list[str] = [] + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + tool_decorators = [d for d in node.decorator_list if is_tool_decorator(d)] + if not tool_decorators: + continue + dec = tool_decorators[0] + has_description_kwarg = isinstance(dec, ast.Call) and any(k.arg == "description" for k in dec.keywords) + if has_description_kwarg or has_valid_docstring(node): + continue + offenders.append(f"{node.name} (line {node.lineno})") + + assert not offenders, ( + "@mcp.tool definitions missing a description (need a description= kwarg or a " + f"plain-literal docstring, not an f-string): {offenders}" + ) @pytest.fixture @@ -1245,6 +1356,31 @@ async def test_get_memory_invalid_uuid_single_bank(self, mock_memory): result = await _tools(mcp)["get_memory"].fn(memory_id="bad") assert "not a valid UUID" in result["error"] + async def test_update_memory_edits_fields(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"update_memory"}, include_bank_id=True) + await _tools(mcp)["update_memory"].fn( + memory_id="mem-1", text="corrected", fact_type="experience", entities=["Alice"] + ) + call_kwargs = mock_memory.update_memory_unit.call_args.kwargs + assert call_kwargs["text"] == "corrected" + assert call_kwargs["new_fact_type"] == "experience" + assert call_kwargs["entities"] == ["Alice"] + # update_memory does not change state — that's invalidate_memory's job. + assert "state" not in call_kwargs + + async def test_invalidate_memory(self, mock_memory): + mock_memory.update_memory_unit.return_value = {"id": "mem-1", "state": "invalidated"} + mcp = _make_mcp_server(mock_memory, {"invalidate_memory"}, include_bank_id=True) + await _tools(mcp)["invalidate_memory"].fn(memory_id="mem-1", reason="stale") + call_kwargs = mock_memory.update_memory_unit.call_args.kwargs + assert call_kwargs["state"] == "invalidated" + assert call_kwargs["reason"] == "stale" + + async def test_invalidate_memory_restore(self, mock_memory): + mcp = _make_mcp_server(mock_memory, {"invalidate_memory"}, include_bank_id=True) + await _tools(mcp)["invalidate_memory"].fn(memory_id="mem-1", restore=True) + assert mock_memory.update_memory_unit.call_args.kwargs["state"] == "valid" + 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() @@ -1544,6 +1680,7 @@ async def test_update_bank_multiple_config_fields(self, mock_memory): "retain_extraction_mode": "custom", "retain_custom_instructions": "Extract only action items", "retain_chunk_size": 2000, + "retain_structured_chunk_size": 5000, } ) config_call = mock_memory._config_resolver.update_bank_config.call_args @@ -1557,6 +1694,7 @@ async def test_update_bank_multiple_config_fields(self, mock_memory): assert updates["retain_extraction_mode"] == "custom" assert updates["retain_custom_instructions"] == "Extract only action items" assert updates["retain_chunk_size"] == 2000 + assert updates["retain_structured_chunk_size"] == 5000 async def test_update_bank_name_and_config_together(self, mock_memory): """name goes to engine, config_updates goes to config resolver.""" @@ -1773,3 +1911,101 @@ async def test_filter_skipped_when_no_bank_id(self, mock_memory_with_resolver): # Filter bypassed — config resolver was never consulted, all tools visible assert "recall" in visible mock_memory_with_resolver._config_resolver.get_bank_config.assert_not_called() + + +@pytest.mark.asyncio +class TestToolAnnotations: + """Every MCP tool must carry read-only / destructive hints (openWorldHint=False).""" + + async def test_read_only_tool(self, mock_memory): + ann = _tools(_make_mcp_server(mock_memory, {"recall"}))["recall"].annotations + assert ann is not None + assert ann.readOnlyHint is True + assert ann.openWorldHint is False + + async def test_reflect_is_read_only(self, mock_memory): + # reflect synthesizes an answer and persists nothing (memory_engine.reflect_async), + # so it carries readOnlyHint=True like recall. + ann = _tools(_make_mcp_server(mock_memory, {"reflect"}))["reflect"].annotations + assert ann is not None + assert ann.readOnlyHint is True + assert ann.openWorldHint is False + + async def test_destructive_tool(self, mock_memory): + ann = _tools(_make_mcp_server(mock_memory, {"delete_bank"}))["delete_bank"].annotations + assert ann is not None + assert ann.readOnlyHint is False + assert ann.destructiveHint is True + + async def test_write_tool_is_not_destructive(self, mock_memory): + ann = _tools(_make_mcp_server(mock_memory, {"retain"}))["retain"].annotations + assert ann is not None + assert ann.readOnlyHint is False + assert ann.destructiveHint is False + + async def test_annotations_apply_in_single_bank_mode(self, mock_memory): + ann = _tools(_make_mcp_server(mock_memory, {"recall"}, include_bank_id=False))["recall"].annotations + assert ann is not None + assert ann.readOnlyHint is True + + +def _reflect_mcp_with_trace(include_bank_id_param: bool): + """An MCP server whose reflect returns a result carrying tool_trace/llm_trace.""" + from fastmcp import FastMCP + + # Mirrors ReflectResult: the agentic loop's trace fields are large and present. + reflect_payload = { + "text": "answer", + "based_on": {"world": []}, + "tool_trace": [{"tool": "recall", "output": "x" * 1000}], + "llm_trace": [{"model": "test", "output": "y" * 1000}], + } + memory = MagicMock() + memory.reflect_async = AsyncMock( + return_value=MagicMock( + model_dump_json=lambda indent=None: json.dumps(reflect_payload), + model_dump=lambda: dict(reflect_payload), + structured_output=None, + ) + ) + mcp = FastMCP("test") + config = MCPToolsConfig( + bank_id_resolver=lambda: "test-bank", + include_bank_id_param=include_bank_id_param, + tools={"reflect"}, + ) + register_mcp_tools(mcp, memory, config) + return mcp + + +def _reflect_result_data(result) -> dict: + """The multi-bank reflect returns a JSON string; single-bank returns a dict.""" + return json.loads(result) if isinstance(result, str) else result + + +@pytest.mark.asyncio +class TestReflectTraceOmission: + """reflect must not leak the agentic tool_trace/llm_trace into MCP responses by default.""" + + @pytest.mark.parametrize("multi_bank", [True, False]) + async def test_trace_omitted_by_default(self, multi_bank): + mcp = _reflect_mcp_with_trace(multi_bank) + data = _reflect_result_data(await _tools(mcp)["reflect"].fn(query="q")) + assert data["text"] == "answer" + assert "tool_trace" not in data + assert "llm_trace" not in data + + @pytest.mark.parametrize("multi_bank", [True, False]) + async def test_trace_included_when_requested(self, multi_bank): + mcp = _reflect_mcp_with_trace(multi_bank) + data = _reflect_result_data(await _tools(mcp)["reflect"].fn(query="q", include_trace=True)) + assert "tool_trace" in data + assert "llm_trace" in data + + @pytest.mark.parametrize("multi_bank", [True, False]) + async def test_based_on_flag_is_independent_of_trace(self, multi_bank): + # include_based_on keeps based_on but must not pull the trace back in. + mcp = _reflect_mcp_with_trace(multi_bank) + data = _reflect_result_data(await _tools(mcp)["reflect"].fn(query="q", include_based_on=True)) + assert "based_on" in data + assert "tool_trace" not in data diff --git a/hindsight-api-slim/tests/test_memory_curation.py b/hindsight-api-slim/tests/test_memory_curation.py new file mode 100644 index 0000000000..605cf54da6 --- /dev/null +++ b/hindsight-api-slim/tests/test_memory_curation.py @@ -0,0 +1,546 @@ +"""Tests for memory curation: edit / invalidate / revert. + +Invalidation MOVES a fact out of ``memory_units`` into the +``invalidated_memory_units`` archive, so the recall hot-path never sees it. +These tests cover the move semantics, lossless revert (incl. entity +associations), edit, the guards, listing, and recall exclusion. +""" + +import uuid +from unittest.mock import AsyncMock, patch + +import pytest + +from hindsight_api import RequestContext +from hindsight_api.engine.memory_engine import MemoryEngine +from hindsight_api.engine.retain import embedding_processing + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +async def _insert_memory( + conn, memory: MemoryEngine, bank_id: str, text: str, fact_type: str = "experience" +) -> uuid.UUID: + """Insert a live memory unit with a real embedding, bypassing the LLM pipeline.""" + mem_id = uuid.uuid4() + emb = await embedding_processing.generate_embeddings_batch(memory.embeddings, [text]) + await conn.execute( + """ + INSERT INTO memory_units (id, bank_id, text, fact_type, embedding, event_date, created_at, updated_at, consolidated_at) + VALUES ($1, $2, $3, $4, $5::vector, NOW(), NOW(), NOW(), NOW()) + """, + mem_id, + bank_id, + text, + fact_type, + str(emb[0]), + ) + return mem_id + + +async def _insert_observation(conn, bank_id: str, text: str, source_memory_ids: list[uuid.UUID]) -> uuid.UUID: + obs_id = uuid.uuid4() + await conn.execute( + """ + INSERT INTO memory_units ( + id, bank_id, text, fact_type, event_date, source_memory_ids, proof_count, created_at, updated_at + ) VALUES ($1, $2, $3, 'observation', NOW(), $4, $5, NOW(), NOW()) + """, + obs_id, + bank_id, + text, + source_memory_ids, + len(source_memory_ids), + ) + return obs_id + + +async def _insert_link(conn, bank_id: str, from_id: uuid.UUID, to_id: uuid.UUID) -> None: + await conn.execute( + """ + INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, bank_id) + VALUES ($1, $2, 'temporal', 0.5, $3) + """, + from_id, + to_id, + bank_id, + ) + + +async def _insert_entity(conn, bank_id: str, name: str) -> uuid.UUID: + eid = uuid.uuid4() + await conn.execute( + "INSERT INTO entities (id, bank_id, canonical_name) VALUES ($1, $2, $3)", + eid, + bank_id, + name, + ) + return eid + + +async def _link_entity(conn, unit_id: uuid.UUID, entity_id: uuid.UUID) -> None: + await conn.execute( + "INSERT INTO unit_entities (unit_id, entity_id) VALUES ($1, $2)", + unit_id, + entity_id, + ) + + +async def _in_live(conn, mem_id: uuid.UUID) -> bool: + return bool(await conn.fetchval("SELECT 1 FROM memory_units WHERE id = $1", mem_id)) + + +async def _archive_row(conn, mem_id: uuid.UUID) -> dict | None: + # No `embedding` column: the archive is cold storage and the schema drops it (#2209). + row = await conn.fetchrow( + "SELECT text, invalidation_reason, invalidated_at, entity_ids FROM invalidated_memory_units WHERE id = $1", + mem_id, + ) + return dict(row) if row else None + + +async def _archive_has_embedding_column(conn) -> bool: + return bool( + await conn.fetchval( + "SELECT 1 FROM information_schema.columns " + "WHERE table_name = 'invalidated_memory_units' AND column_name = 'embedding'" + ) + ) + + +async def _link_count(conn, mem_id: uuid.UUID) -> int: + return await conn.fetchval( + "SELECT COUNT(*) FROM memory_links WHERE from_unit_id = $1 OR to_unit_id = $1", + mem_id, + ) + + +async def _entity_ids_for(conn, unit_id: uuid.UUID) -> list[uuid.UUID]: + rows = await conn.fetch("SELECT entity_id FROM unit_entities WHERE unit_id = $1", unit_id) + return [r["entity_id"] for r in rows] + + +async def _obs_ids(conn, bank_id: str) -> list[str]: + rows = await conn.fetch( + "SELECT id FROM memory_units WHERE bank_id = $1 AND fact_type = 'observation'", + bank_id, + ) + return [str(r["id"]) for r in rows] + + +async def _consolidated_at(conn, mem_id: uuid.UUID): + return await conn.fetchval("SELECT consolidated_at FROM memory_units WHERE id = $1", mem_id) + + +async def _ensure_bank(memory: MemoryEngine, bank_id: str, request_context: RequestContext) -> None: + await memory.get_bank_profile(bank_id=bank_id, request_context=request_context) + + +# --------------------------------------------------------------------------- +# Invalidate / revert (table move) +# --------------------------------------------------------------------------- + + +class TestInvalidate: + @pytest.mark.asyncio + async def test_invalidate_moves_to_archive_and_prunes(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-inv-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "The deploy server srv-04 runs PostgreSQL 14.") + m2 = await _insert_memory(conn, memory, bank_id, "srv-04 is in the eu-west datacenter.") + obs_id = await _insert_observation(conn, bank_id, "srv-04 runs PG14 in eu-west.", [m1, m2]) + await _insert_link(conn, bank_id, m1, m2) + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + result = await memory.update_memory_unit( + bank_id, str(m1), state="invalidated", reason="decommissioned", request_context=request_context + ) + + assert result is not None + assert result["state"] == "invalidated" + assert result["invalidation_reason"] == "decommissioned" + assert result["invalidated_at"] is not None + + async with pool.acquire() as conn: + assert not await _in_live(conn, m1), "invalidated row must leave memory_units" + arch = await _archive_row(conn, m1) + assert arch is not None, "row must be in the archive" + assert arch["invalidation_reason"] == "decommissioned" + assert not await _archive_has_embedding_column(conn), ( + "archive is cold storage; the schema drops the embedding column (#2209)" + ) + assert await _link_count(conn, m1) == 0, "links cascade-pruned on move" + assert str(obs_id) not in await _obs_ids(conn, bank_id), "derived observation removed" + assert await _consolidated_at(conn, m2) is None, "surviving source reset for re-consolidation" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_revert_moves_back_and_restores_entities(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-rev-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "Alice prefers tea over coffee.") + e1 = await _insert_entity(conn, bank_id, "Alice") + await _link_entity(conn, m1, e1) + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + await memory.update_memory_unit(bank_id, str(m1), state="invalidated", request_context=request_context) + async with pool.acquire() as conn: + assert not await _in_live(conn, m1) + arch = await _archive_row(conn, m1) + assert arch is not None and e1 in (arch["entity_ids"] or []), "entity ids snapshotted on invalidate" + assert await _entity_ids_for(conn, m1) == [], "unit_entities cascade-pruned on move" + + result = await memory.update_memory_unit(bank_id, str(m1), state="valid", request_context=request_context) + + assert result["state"] == "valid" + assert result["invalidation_reason"] is None + async with pool.acquire() as conn: + assert await _in_live(conn, m1), "reverted row back in memory_units" + assert await _archive_row(conn, m1) is None, "archive row removed on revert" + assert await _consolidated_at(conn, m1) is None, "reverted memory re-consolidates" + assert e1 in await _entity_ids_for(conn, m1), "entity associations restored on revert" + reverted_emb = await conn.fetchval("SELECT embedding FROM memory_units WHERE id = $1", m1) + assert reverted_emb is not None, "embedding recomputed on revert (archive keeps none)" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_invalidate_idempotent_updates_reason(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-idem-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "Bob works at Google.") + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + await memory.update_memory_unit( + bank_id, str(m1), state="invalidated", reason="first", request_context=request_context + ) + result = await memory.update_memory_unit( + bank_id, str(m1), state="invalidated", reason="second", request_context=request_context + ) + + assert result["state"] == "invalidated" + assert result["invalidation_reason"] == "second" + async with pool.acquire() as conn: + assert not await _in_live(conn, m1) + assert (await _archive_row(conn, m1))["invalidation_reason"] == "second" + await memory.delete_bank(bank_id, request_context=request_context) + + +# --------------------------------------------------------------------------- +# Edit +# --------------------------------------------------------------------------- + + +class TestEdit: + @pytest.mark.asyncio + async def test_edit_changes_text_and_rederives(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-edit-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "The assistant visited Paris in 2023.") + obs_id = await _insert_observation(conn, bank_id, "The assistant went to Paris.", [m1]) + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + result = await memory.update_memory_unit( + bank_id, + str(m1), + text="The user visited Paris in 2023.", + reason="wrong subject", + request_context=request_context, + ) + + assert result["text"] == "The user visited Paris in 2023." + assert result["state"] == "valid" + async with pool.acquire() as conn: + assert await _in_live(conn, m1), "edited row stays live" + row = dict(await conn.fetchrow("SELECT text, consolidated_at FROM memory_units WHERE id = $1", m1)) + assert row["text"] == "The user visited Paris in 2023." + assert row["consolidated_at"] is None, "edited memory re-consolidates" + assert str(obs_id) not in await _obs_ids(conn, bank_id), "stale observation re-derived" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_edit_fields_dates_facttype_context(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-editfields-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "A world fact.", fact_type="world") + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + result = await memory.update_memory_unit( + bank_id, + str(m1), + context="from a chat", + occurred_start="2023-06-01", + new_fact_type="experience", + request_context=request_context, + ) + + assert result["type"] == "experience" + assert result["context"] == "from a chat" + assert result["occurred_start"] is not None and result["occurred_start"].startswith("2023-06-01") + assert result["edited_at"] is not None, "edit records edited_at (user-modified marker)" + async with pool.acquire() as conn: + row = dict( + await conn.fetchrow( + "SELECT fact_type, context, occurred_start, event_date FROM memory_units WHERE id = $1", m1 + ) + ) + assert row["fact_type"] == "experience" + assert row["context"] == "from a chat" + assert row["occurred_start"].date().isoformat() == "2023-06-01" + assert row["event_date"].date().isoformat() == "2023-06-01", "event_date tracks occurred_start" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_edit_replaces_entities(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-editent-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "Alice met Bob in Paris.") + # Pre-link a wrong entity the LLM extracted. + wrong = await _insert_entity(conn, bank_id, "Carol") + await _link_entity(conn, m1, wrong) + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + # Correct the entity set: drop Carol, attach Alice + Bob. + result = await memory.update_memory_unit( + bank_id, + str(m1), + entities=["Alice", "Bob"], + request_context=request_context, + ) + + assert result is not None + assert set(result["entities"]) == {"Alice", "Bob"} + assert result["edited_at"] is not None, "entity edit records the user-modified marker" + async with pool.acquire() as conn: + names = await conn.fetch( + "SELECT e.canonical_name FROM unit_entities ue " + "JOIN entities e ON e.id = ue.entity_id WHERE ue.unit_id = $1", + m1, + ) + assert {r["canonical_name"] for r in names} == {"Alice", "Bob"}, "unit_entities rebuilt" + assert wrong not in await _entity_ids_for(conn, m1), "wrong entity detached" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_edit_empty_entities_detaches_all(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-editent0-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "A fact with a spurious entity.") + e = await _insert_entity(conn, bank_id, "Spurious") + await _link_entity(conn, m1, e) + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + result = await memory.update_memory_unit(bank_id, str(m1), entities=[], request_context=request_context) + + assert result["entities"] == [] + async with pool.acquire() as conn: + assert await _entity_ids_for(conn, m1) == [], "empty list detaches all entities" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_cannot_edit_invalidated_memory(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-editinv-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "Stale fact.") + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + await memory.update_memory_unit(bank_id, str(m1), state="invalidated", request_context=request_context) + with pytest.raises(ValueError, match="revert"): + await memory.update_memory_unit(bank_id, str(m1), text="corrected", request_context=request_context) + + await memory.delete_bank(bank_id, request_context=request_context) + + +# --------------------------------------------------------------------------- +# Guards / listing / recall +# --------------------------------------------------------------------------- + + +class TestGuardsAndListing: + @pytest.mark.asyncio + async def test_cannot_curate_observation(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-obs-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + m1 = await _insert_memory(conn, memory, bank_id, "source fact") + obs_id = await _insert_observation(conn, bank_id, "a synthesized observation", [m1]) + + with pytest.raises(ValueError, match="observation"): + await memory.update_memory_unit(bank_id, str(obs_id), state="invalidated", request_context=request_context) + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_not_found_returns_none(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-404-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + result = await memory.update_memory_unit( + bank_id, str(uuid.uuid4()), state="invalidated", request_context=request_context + ) + assert result is None + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_list_filters_by_state(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-list-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + pool = await memory._get_pool() + async with pool.acquire() as conn: + keep = await _insert_memory(conn, memory, bank_id, "Valid fact one.") + m2 = await _insert_memory(conn, memory, bank_id, "Fact to retire.") + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + await memory.update_memory_unit( + bank_id, str(m2), state="invalidated", reason="dup", request_context=request_context + ) + + # Default lists live facts only. + live = (await memory.list_memory_units(bank_id, request_context=request_context))["items"] + live_ids = {i["id"] for i in live} + assert str(keep) in live_ids and str(m2) not in live_ids + assert all(i["state"] == "valid" for i in live) + + # state=invalidated reads the archive. + invalid = (await memory.list_memory_units(bank_id, state="invalidated", request_context=request_context))[ + "items" + ] + assert len(invalid) == 1 + assert invalid[0]["id"] == str(m2) + assert invalid[0]["state"] == "invalidated" + assert invalid[0]["invalidation_reason"] == "dup" + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_list_filters_by_document(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-doc-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + doc_id = f"doc-{uuid.uuid4().hex[:8]}" + + pool = await memory._get_pool() + async with pool.acquire() as conn: + await conn.execute("INSERT INTO documents (id, bank_id) VALUES ($1, $2)", doc_id, bank_id) + m_doc = await _insert_memory(conn, memory, bank_id, "Fact from the document.") + await _insert_memory(conn, memory, bank_id, "Fact from elsewhere.") + await conn.execute("UPDATE memory_units SET document_id = $1 WHERE id = $2", doc_id, m_doc) + + # Live listing scoped to the document returns only its fact. + live = (await memory.list_memory_units(bank_id, document_id=doc_id, request_context=request_context))["items"] + assert {i["id"] for i in live} == {str(m_doc)} + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + await memory.update_memory_unit(bank_id, str(m_doc), state="invalidated", request_context=request_context) + + # Invalidated archive is filterable by document too (carries document_id). + scoped = ( + await memory.list_memory_units( + bank_id, state="invalidated", document_id=doc_id, request_context=request_context + ) + )["items"] + assert {i["id"] for i in scoped} == {str(m_doc)} + other = ( + await memory.list_memory_units( + bank_id, state="invalidated", document_id="nope", request_context=request_context + ) + )["items"] + assert other == [] + + await memory.delete_bank(bank_id, request_context=request_context) + + @pytest.mark.asyncio + async def test_recall_excludes_invalidated(self, memory: MemoryEngine, request_context: RequestContext): + bank_id = f"test-curation-recall-{uuid.uuid4().hex[:8]}" + await _ensure_bank(memory, bank_id, request_context) + + unit_ids = await memory.retain_async( + bank_id, + "The Anaconda XR7 telescope has a 9000mm focal length.", + request_context=request_context, + ) + assert unit_ids, "retain should produce at least one memory unit" + + def _hit(res) -> bool: + return any("anaconda" in f.text.lower() or "telescope" in f.text.lower() for f in res.results) + + before = await memory.recall_async( + bank_id, "Anaconda XR7 telescope focal length", request_context=request_context + ) + assert _hit(before), "fact should be recalled before invalidation" + + with ( + patch.object(memory, "submit_async_consolidation", new=AsyncMock()), + patch.object(memory, "submit_async_graph_maintenance", new=AsyncMock()), + ): + for uid in unit_ids: + await memory.update_memory_unit(bank_id, uid, state="invalidated", request_context=request_context) + + after = await memory.recall_async( + bank_id, "Anaconda XR7 telescope focal length", request_context=request_context + ) + assert not _hit(after), "invalidated fact must be excluded from recall" + + await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api-slim/tests/test_memory_defense.py b/hindsight-api-slim/tests/test_memory_defense.py new file mode 100644 index 0000000000..f5e8b4cc41 --- /dev/null +++ b/hindsight-api-slim/tests/test_memory_defense.py @@ -0,0 +1,762 @@ +"""Memory Defense — the OSS regex extension end to end. + +Sections: + * policy parsing (unit) + * regex screening (unit) + * extension loading (unit) + * extension-context wiring (unit) + * bank config validation (DB) + * retain: allow / redact / block / webhook (DB) + * document-body scrubbing (DB) +""" + +import json + +import pytest + +from hindsight_api.extensions.builtin.memory_defense_regex import MemoryDefenseRegexExtension +from hindsight_api.extensions.loader import ExtensionLoadError, load_extension +from hindsight_api.extensions.memory_defense import ( + DefenseAction, + MemoryDefenseExtension, + _fingerprint_value, + apply_redaction, + parse_policy, +) + +# --------------------------------------------------------------------------- +# Policy parsing (unit) +# --------------------------------------------------------------------------- + + +def test_parse_minimal_policy() -> None: + policy = parse_policy({"enabled": True}) + assert policy.enabled is True + assert policy.rules == () + + +def test_parse_policy_with_rule() -> None: + policy = parse_policy({"enabled": True, "rules": [{"on": "sensitive_data", "action": "redact"}]}) + assert {r.on for r in policy.rules} == {"sensitive_data"} + assert policy.rules[0].action is DefenseAction.REDACT + + +def test_parse_policy_accepts_block_action() -> None: + policy = parse_policy({"enabled": True, "rules": [{"on": "sensitive_data", "action": "block"}]}) + assert policy.rules[0].action is DefenseAction.BLOCK + + +def test_parse_policy_rejects_invalid_action() -> None: + # Use a valid ``on`` so the parser progresses to action validation. + with pytest.raises(ValueError, match="action"): + parse_policy({"enabled": True, "rules": [{"on": "sensitive_data", "action": "lol"}]}) + + +@pytest.mark.parametrize("on", [None, "", 123]) +def test_parse_policy_rejects_empty_or_non_string_on(on: object) -> None: + with pytest.raises(ValueError, match="invalid on"): + parse_policy({"enabled": True, "rules": [{"on": on, "action": "block"}]}) + + +@pytest.mark.parametrize( + "detector", + [ + "sensitive_data", + "prompt_injection", + "size_anomaly", + "protected_keys", + "detect_secrets", + "base64_decode", + "llm_screen", + # An unknown future name passes too: the parser doesn't gate ``on`` + # against a fixed roster. + "some_future_cloud_detector", + ], +) +def test_parse_policy_accepts_any_detector_name(detector: str) -> None: + """The parser accepts any non-empty detector name so cloud-shape policies + pass through the OSS PATCH layer unchanged. The OSS regex extension only + actually screens ``sensitive_data``; the rest are silent no-ops here and + are dispatched by downstream extensions (e.g. hindsight-cloud).""" + policy = parse_policy({"enabled": True, "rules": [{"on": detector, "action": "block"}]}) + assert len(policy.rules) == 1 + assert policy.rules[0].on == detector + assert policy.rules[0].action is DefenseAction.BLOCK + + +def test_disabled_policy_is_inert() -> None: + policy = parse_policy({"enabled": False, "rules": [{"on": "sensitive_data", "action": "redact"}]}) + assert policy.enabled is False + + +def test_defense_action_string_round_trip() -> None: + assert DefenseAction("redact") is DefenseAction.REDACT + assert DefenseAction.BLOCK.value == "block" + + +# --------------------------------------------------------------------------- +# Fingerprinting (unit) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "value,expected", + [ + # Length > 15 → first-4 + ellipsis + last-4. + ("ghp_" + "A" * 36, "ghp_...AAAA"), + ("AKIA" + "B" * 16, "AKIA...BBBB"), + ("sk-ant-" + "Z" * 40, "sk-a...ZZZZ"), + # Length 6–15 → first-2 + ellipsis + last-2. + ("123-45-6789", "12...89"), + ("xoxb-12345", "xo...45"), + # Length < 6 → fully masked; we don't preview anything. + ("abcde", "[redacted]"), + ("", "[redacted]"), + ], +) +def test_fingerprint_value_shape(value: str, expected: str) -> None: + """_fingerprint_value never returns the raw value and uses length-aware + bracketing so short matches don't leak material.""" + out = _fingerprint_value(value) + assert out == expected + if value: + assert value not in out, f"raw value leaked into fingerprint: {out!r}" + + +def test_apply_redaction_hits_carry_fingerprinted_previews() -> None: + """apply_redaction returns per-match fingerprinted previews — one entry + per matched substring — with the raw secret nowhere present in the hits.""" + s1 = "ghp_" + "A" * 36 + s2 = "AKIA" + "B" * 16 + s3 = "123-45-6789" + content = f"rotate {s1}, drop {s2}, also ssn {s3}" + + result = apply_redaction(content) + + # Same-shape labels still flow to matched_types (deduplicated). + assert set(result.matched_types) >= {"github_token", "aws_access_key", "ssn_us"} + + # One hit per matched substring; raw secret never appears. + by_detector = {h["detector"]: h["preview"] for h in result.hits} + assert by_detector["github_token"] == "ghp_...AAAA" + assert by_detector["aws_access_key"] == "AKIA...BBBB" + assert by_detector["ssn_us"] == "12...89" + for h in result.hits: + assert s1 not in h["preview"] + assert s2 not in h["preview"] + assert s3 not in h["preview"] + + +def test_apply_redaction_multiple_hits_per_pattern() -> None: + """Two matches of the same pattern produce two hits — receivers can count + occurrences, not just types.""" + a = "ghp_" + "A" * 36 + b = "ghp_" + "B" * 36 + content = f"old {a} new {b}" + result = apply_redaction(content) + + gh_hits = [h for h in result.hits if h["detector"] == "github_token"] + assert len(gh_hits) == 2 + previews = {h["preview"] for h in gh_hits} + assert previews == {"ghp_...AAAA", "ghp_...BBBB"} + + +# --------------------------------------------------------------------------- +# Regex screening (unit) +# --------------------------------------------------------------------------- + + +@pytest.fixture +def regex_defense() -> MemoryDefenseRegexExtension: + return MemoryDefenseRegexExtension({}) + + +@pytest.fixture +def redact_policy() -> dict: + return {"enabled": True, "rules": [{"on": "sensitive_data", "action": "redact"}]} + + +@pytest.mark.asyncio +async def test_screen_allows_innocuous_content(regex_defense, redact_policy) -> None: + decision = await regex_defense.screen( + policy=parse_policy(redact_policy), + bank_id="b1", + document_id="d1", + content="The Q3 roadmap meeting is on Friday.", + tags=["session:abc"], + ) + assert decision.action is DefenseAction.ALLOW + + +@pytest.mark.asyncio +async def test_screen_redacts_secret(regex_defense, redact_policy) -> None: + secret = "ghp_" + "A" * 36 + decision = await regex_defense.screen( + policy=parse_policy(redact_policy), + bank_id="b1", + document_id="d1", + content=f"rotate this token: {secret}", + tags=[], + ) + assert decision.action is DefenseAction.REDACT + assert decision.redacted_content is not None + assert secret not in decision.redacted_content + assert "[REDACTED:github_token]" in decision.redacted_content + assert "github_token" in decision.matched_types + # The decision carries a per-match fingerprinted preview — never the raw + # value — so SIEM receivers can correlate without the secret crossing + # the wire. + assert decision.hits, "OSS should populate at least one hit" + hit = decision.hits[0] + assert hit["detector"] == "github_token" + assert hit["preview"] == "ghp_...AAAA" + assert secret not in hit["preview"] + + +@pytest.mark.asyncio +async def test_screen_blocks_secret(regex_defense) -> None: + """A sensitive_data rule with action=block returns BLOCK (no redacted content).""" + policy = parse_policy({"enabled": True, "rules": [{"on": "sensitive_data", "action": "block"}]}) + secret = "AKIA" + "A" * 16 + decision = await regex_defense.screen( + policy=policy, + bank_id="b1", + document_id="d1", + content=f"key={secret}", + tags=[], + ) + assert decision.action is DefenseAction.BLOCK + assert decision.redacted_content is None + assert "aws_access_key" in decision.matched_types + + +@pytest.mark.asyncio +async def test_screen_allows_when_no_sensitive_data_rule(regex_defense) -> None: + policy = parse_policy({"enabled": True, "rules": []}) + decision = await regex_defense.screen( + policy=policy, bank_id="b1", document_id="d1", content="ghp_" + "Z" * 36, tags=[] + ) + assert decision.action is DefenseAction.ALLOW + + +@pytest.mark.asyncio +async def test_screen_disabled_policy_is_inert(regex_defense) -> None: + policy = parse_policy({"enabled": False, "rules": [{"on": "sensitive_data", "action": "redact"}]}) + decision = await regex_defense.screen( + policy=policy, bank_id="b1", document_id="d1", content="ghp_" + "Z" * 36, tags=[] + ) + assert decision.action is DefenseAction.ALLOW + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + [ + "ghp_" + "A" * 36, + "sk-ant-" + "B" * 40, + "sk-" + "C" * 30, + "AKIA" + "D" * 16, + ], +) +async def test_screen_redacts_known_patterns(payload: str, regex_defense, redact_policy) -> None: + d = await regex_defense.screen( + policy=parse_policy(redact_policy), + bank_id="b", + document_id="d", + content=f"my key is {payload}", + tags=[], + ) + assert d.action is DefenseAction.REDACT, f"expected redact for {payload!r}, got {d.action}" + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "payload", + [ + "The roadmap meeting is on Friday", + "Product launch planning notes", + "Reminder about Tuesday", + ], +) +async def test_screen_allows_benign_payloads(payload: str, regex_defense, redact_policy) -> None: + d = await regex_defense.screen( + policy=parse_policy(redact_policy), bank_id="b", document_id="d", content=payload, tags=[] + ) + assert d.action is DefenseAction.ALLOW + + +# --------------------------------------------------------------------------- +# Extension loading (unit) +# --------------------------------------------------------------------------- + + +def test_regex_is_default_when_no_env(monkeypatch) -> None: + monkeypatch.delenv("HINDSIGHT_API_MEMORY_DEFENSE_EXTENSION", raising=False) + ext = load_extension("MEMORY_DEFENSE", MemoryDefenseExtension) or MemoryDefenseRegexExtension({}) + assert isinstance(ext, MemoryDefenseRegexExtension) + + +def test_custom_extension_loaded_from_env(monkeypatch) -> None: + monkeypatch.setenv( + "HINDSIGHT_API_MEMORY_DEFENSE_EXTENSION", + "hindsight_api.extensions.builtin.memory_defense_regex:MemoryDefenseRegexExtension", + ) + ext = load_extension("MEMORY_DEFENSE", MemoryDefenseExtension) + assert isinstance(ext, MemoryDefenseRegexExtension) + + +def test_malformed_extension_path_raises(monkeypatch) -> None: + monkeypatch.setenv("HINDSIGHT_API_MEMORY_DEFENSE_EXTENSION", "no_colon_here") + with pytest.raises(ExtensionLoadError): + load_extension("MEMORY_DEFENSE", MemoryDefenseExtension) + + +def test_non_subclass_extension_raises(monkeypatch) -> None: + monkeypatch.setenv("HINDSIGHT_API_MEMORY_DEFENSE_EXTENSION", "builtins:dict") + with pytest.raises(ExtensionLoadError): + load_extension("MEMORY_DEFENSE", MemoryDefenseExtension) + + +# --------------------------------------------------------------------------- +# Extension-context wiring (unit) +# --------------------------------------------------------------------------- + + +def _make_minimal_engine(): + """Construct a MemoryEngine with minimal env config (no network/GPU). + + Uses the "none" LLM provider and a mocked embeddings model so __init__ + runs without external calls; the pool is never started (no DB access). + """ + import os + from unittest.mock import MagicMock, patch + + mock_embeddings = MagicMock() + mock_embeddings.dimension = 384 + + with patch.dict( + os.environ, + { + "HINDSIGHT_API_LLM_PROVIDER": "none", + "HINDSIGHT_API_LLM_MODEL": "none", + "HINDSIGHT_API_LLM_API_KEY": "test-key", + }, + clear=False, + ): + from hindsight_api.config import clear_config_cache + from hindsight_api.engine.memory_engine import MemoryEngine + + clear_config_cache() + return MemoryEngine(db_url="postgresql://localhost/hindsight_test", embeddings=mock_embeddings) + + +def test_engine_memory_defense_shares_ext_ctx() -> None: + """The defense extension's context is the engine's _ext_ctx, and webhook_manager + starts None (it is wired in initialize()).""" + engine = _make_minimal_engine() + assert engine._memory_defense._context is engine._ext_ctx + assert engine._ext_ctx.webhook_manager is None + + +def test_engine_ext_ctx_current_schema_propagation() -> None: + """Writing _ext_ctx.current_schema is visible through _memory_defense.context.""" + engine = _make_minimal_engine() + engine._ext_ctx.current_schema = "tenant_x" + assert engine._memory_defense.context.current_schema == "tenant_x" + + +# --------------------------------------------------------------------------- +# Bank config validation (DB) +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_patch_accepts_and_persists_policy(api_client) -> None: + await api_client.put("/v1/default/banks/md-cfg-1", json={}) + r = await api_client.patch( + "/v1/default/banks/md-cfg-1/config", + json={ + "updates": {"memory_defense": {"enabled": True, "rules": [{"on": "sensitive_data", "action": "redact"}]}} + }, + ) + assert r.status_code == 200, r.text + + r2 = await api_client.get("/v1/default/banks/md-cfg-1/config") + assert r2.json()["config"]["memory_defense"]["enabled"] is True + + +@pytest.mark.asyncio +async def test_patch_rejects_invalid_action(api_client) -> None: + await api_client.put("/v1/default/banks/md-cfg-2", json={}) + # Valid ``on`` so the parser reaches action validation. + r = await api_client.patch( + "/v1/default/banks/md-cfg-2/config", + json={ + "updates": { + "memory_defense": {"enabled": True, "rules": [{"on": "sensitive_data", "action": "delete_everything"}]} + } + }, + ) + assert r.status_code == 422, r.text + assert "action" in str(r.json()["detail"]).lower() + + +@pytest.mark.asyncio +async def test_patch_accepts_cloud_only_detector(api_client) -> None: + # A cloud-only detector the OSS extension doesn't implement still persists + # through the PATCH layer (it's a silent no-op here, dispatched downstream). + await api_client.put("/v1/default/banks/md-cfg-3", json={}) + r = await api_client.patch( + "/v1/default/banks/md-cfg-3/config", + json={ + "updates": {"memory_defense": {"enabled": True, "rules": [{"on": "prompt_injection", "action": "block"}]}} + }, + ) + assert r.status_code == 200, r.text + r2 = await api_client.get("/v1/default/banks/md-cfg-3/config") + assert r2.json()["config"]["memory_defense"]["rules"][0]["on"] == "prompt_injection" + + +@pytest.mark.asyncio +async def test_patch_rejects_empty_detector(api_client) -> None: + await api_client.put("/v1/default/banks/md-cfg-4", json={}) + r = await api_client.patch( + "/v1/default/banks/md-cfg-4/config", + json={"updates": {"memory_defense": {"enabled": True, "rules": [{"on": "", "action": "redact"}]}}}, + ) + assert r.status_code == 422, r.text + assert "on" in str(r.json()["detail"]).lower() + + +# --------------------------------------------------------------------------- +# Retain: allow / redact / block / webhook (DB) +# --------------------------------------------------------------------------- + +_REDACT_POLICY = {"memory_defense": {"enabled": True, "rules": [{"on": "sensitive_data", "action": "redact"}]}} + + +async def _set_policy(api_client, bank: str, updates: dict) -> None: + r = await api_client.patch(f"/v1/default/banks/{bank}/config", json={"updates": updates}) + assert r.status_code == 200, r.text + + +@pytest.mark.asyncio +async def test_retain_allows_clean_content(api_client) -> None: + await api_client.put("/v1/default/banks/md-retain-1", json={}) + await _set_policy(api_client, "md-retain-1", _REDACT_POLICY) + r = await api_client.post( + "/v1/default/banks/md-retain-1/memories", + json={"items": [{"content": "the meeting is friday"}]}, + ) + assert r.status_code == 200, r.text + + +@pytest.mark.asyncio +async def test_retain_stores_redacted_text(api_client, memory) -> None: + await api_client.put("/v1/default/banks/md-retain-2", json={}) + await _set_policy(api_client, "md-retain-2", _REDACT_POLICY) + secret = "ghp_" + "A" * 36 + r = await api_client.post( + "/v1/default/banks/md-retain-2/memories", + json={"items": [{"content": f"my token is {secret}"}]}, + ) + assert r.status_code == 200, r.text + async with memory._pool.acquire() as conn: + texts = [row["text"] for row in await conn.fetch("SELECT text FROM memory_units WHERE bank_id = 'md-retain-2'")] + assert all(secret not in t for t in texts), texts + + +@pytest.mark.asyncio +async def test_retain_blocks_secret_item(api_client) -> None: + await api_client.put("/v1/default/banks/md-retain-3", json={}) + await _set_policy( + api_client, + "md-retain-3", + {"memory_defense": {"enabled": True, "rules": [{"on": "sensitive_data", "action": "block"}]}}, + ) + # A single item that contains a secret is fully blocked → 422. + secret = "sk-ant-" + "B" * 40 + r = await api_client.post( + "/v1/default/banks/md-retain-3/memories", + json={"items": [{"content": f"key={secret}"}]}, + ) + assert r.status_code == 422, r.text + # Content with no sensitive_data hit still passes (nothing to block). + r2 = await api_client.post( + "/v1/default/banks/md-retain-3/memories", + json={"items": [{"content": "the roadmap meeting is on friday"}]}, + ) + assert r2.status_code == 200, r2.text + + +async def _memory_defense_webhook_events(memory, bank: str) -> list[dict]: + """Return the fully-parsed WebhookEvent bodies of the memory_defense.triggered + deliveries queued for ``bank``. The webhook_delivery task_payload nests the + serialized event under ``payload`` (a JSON string).""" + async with memory._pool.acquire() as conn: + # Order most-recent-first so callers using ``events[0]`` always see + # the latest queued delivery — otherwise pollution from earlier test + # runs against the same bank surfaces stale payloads. + rows = await conn.fetch( + "SELECT task_payload FROM async_operations " + "WHERE operation_type = 'webhook_delivery' AND bank_id = $1 " + "ORDER BY created_at DESC", + bank, + ) + events: list[dict] = [] + for row in rows: + task = row["task_payload"] + if isinstance(task, str): + task = json.loads(task) + if task.get("event_type") != "memory_defense.triggered": + continue + events.append(json.loads(task["payload"])) + return events + + +@pytest.mark.asyncio +async def test_retain_fires_webhook_on_redact(api_client, memory) -> None: + """A redact decision queues a memory_defense.triggered delivery whose payload + reports the action, detector, and matched pattern labels.""" + bank = "md-retain-wh" + await api_client.put(f"/v1/default/banks/{bank}", json={}) + wr = await api_client.post( + f"/v1/default/banks/{bank}/webhooks", + json={"url": "https://example.com/hook", "event_types": ["memory_defense.triggered"]}, + ) + assert wr.status_code in {200, 201}, wr.text + await _set_policy(api_client, bank, _REDACT_POLICY) + + secret = "ghp_" + "A" * 36 + rr = await api_client.post( + f"/v1/default/banks/{bank}/memories", + json={"items": [{"content": f"rotate {secret}"}]}, + ) + assert rr.status_code == 200, rr.text + + events = await _memory_defense_webhook_events(memory, bank) + assert len(events) >= 1, events + ev = events[0] + assert ev["event"] == "memory_defense.triggered" + assert ev["status"] == "redact" + data = ev["data"] + assert data["action"] == "redact" + assert data["detector"] == "sensitive_data" + assert "github_token" in data["matched_types"] + assert data["message"] + # The webhook payload carries a per-match fingerprinted preview — the raw + # secret never crosses the wire, but a SIEM can still correlate against + # its credential inventory using the leading provider prefix + trailing + # discriminator (e.g. `ghp_...AAAA`). Populated by OSS as of #2157. + hits = data.get("hits") or [] + assert any(h.get("detector") == "github_token" and h.get("preview") == "ghp_...AAAA" for h in hits), hits + for h in hits: + assert secret not in (h.get("preview") or ""), "raw secret leaked into preview" + + +@pytest.mark.asyncio +async def test_retain_fires_webhook_on_block(api_client, memory) -> None: + """A block decision also fires the webhook (before the 422 is raised), with + action=block in the payload.""" + bank = "md-retain-wh-block" + await api_client.put(f"/v1/default/banks/{bank}", json={}) + wr = await api_client.post( + f"/v1/default/banks/{bank}/webhooks", + json={"url": "https://example.com/hook", "event_types": ["memory_defense.triggered"]}, + ) + assert wr.status_code in {200, 201}, wr.text + await _set_policy( + api_client, + bank, + {"memory_defense": {"enabled": True, "rules": [{"on": "sensitive_data", "action": "block"}]}}, + ) + + secret = "AKIA" + "A" * 16 + rr = await api_client.post( + f"/v1/default/banks/{bank}/memories", + json={"items": [{"content": f"key {secret}"}]}, + ) + assert rr.status_code == 422, rr.text # all items blocked + + events = await _memory_defense_webhook_events(memory, bank) + assert any(ev["data"]["action"] == "block" for ev in events), events + blocked = next(ev for ev in events if ev["data"]["action"] == "block") + assert blocked["status"] == "block" + assert blocked["data"]["detector"] == "sensitive_data" + assert "aws_access_key" in blocked["data"]["matched_types"] + + +@pytest.mark.asyncio +async def test_retain_writes_audit_log(api_client, memory) -> None: + """A non-allow decision writes a 'memory_defense' audit entry recording the + action taken and what matched (when audit logging is enabled).""" + import asyncio + + # Audit logging is a static, server-level switch that defaults off; enable it + # on the test engine's logger for this case only. + memory._audit_logger._enabled = True + try: + bank = "md-audit" + await api_client.put(f"/v1/default/banks/{bank}", json={}) + await _set_policy(api_client, bank, _REDACT_POLICY) + + secret = "ghp_" + "A" * 36 + rr = await api_client.post( + f"/v1/default/banks/{bank}/memories", + json={"items": [{"content": f"rotate {secret}", "document_id": "doc-audit"}]}, + ) + assert rr.status_code == 200, rr.text + + # Audit writes are fire-and-forget — poll briefly for the row. + row = None + for _ in range(20): + async with memory._pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT action, transport, metadata FROM audit_log " + "WHERE bank_id = $1 AND action = 'memory_defense' ORDER BY started_at DESC LIMIT 1", + bank, + ) + if row is not None: + break + await asyncio.sleep(0.1) + assert row is not None, "no memory_defense audit entry written" + meta = row["metadata"] + if isinstance(meta, str): + meta = json.loads(meta) + assert meta["action"] == "redact" + assert meta["detector"] == "sensitive_data" + assert "github_token" in meta["matched_types"] + assert meta["document_id"] == "doc-audit" + finally: + memory._audit_logger._enabled = False + + +# --------------------------------------------------------------------------- +# Document-body scrubbing (DB) +# --------------------------------------------------------------------------- +# +# Regression coverage for the "ghp_AAA... persists in raw documents" leak: +# per-chunk screen() mutates the chunk content, but the document body is built +# either from the raw dict or from document_body_override (the FULL original +# body for oversized inputs). Both paths must be scrubbed. + +# Mix of secret patterns covered by the redactor (keys, tokens, PII, DB URLs). +_SECRETS = { + "ssn": "123-45-6789", + "github_pat": "ghp_" + "A" * 36, + "github_app": "ghs_" + "B" * 36, + "anthropic": "sk-ant-" + "C" * 40, + "xai": "xai-" + "D" * 40, + "groq": "gsk_" + "E" * 30, + "huggingface": "hf_" + "F" * 35, + "stripe_live": "sk_live_" + "G" * 30, + "twilio_sid": "AC" + "0" * 32, + "sendgrid": "SG." + "H" * 22 + "." + "I" * 43, + "aws_access": "AKIA" + "J" * 16, + "postgres_url": "postgres://user:p4ssw0rd@db.example.com:5432/app", +} + + +@pytest.mark.asyncio +async def test_scrubs_secrets_from_document_body(api_client) -> None: + bank = "md-doc-body-1" + await api_client.put(f"/v1/default/banks/{bank}", json={}) + await _set_policy(api_client, bank, _REDACT_POLICY) + + doc_id = "leak-test-doc-1" + body = "Audit log:\n" + "\n".join(f"- {label} = {value}" for label, value in _SECRETS.items()) + r = await api_client.post( + f"/v1/default/banks/{bank}/memories", + json={"items": [{"content": body, "document_id": doc_id}]}, + ) + assert r.status_code == 200, r.text + + # 1) Memory units must not contain ANY secret value verbatim. + r2 = await api_client.get(f"/v1/default/banks/{bank}/memories/list", params={"limit": 200}) + for label, value in _SECRETS.items(): + for unit in r2.json()["items"]: + assert value not in unit["text"], f"memory_unit leaked {label}={value!r}: {unit['text']!r}" + + # 2) Document body must not contain ANY secret value verbatim. + r3 = await api_client.get(f"/v1/default/banks/{bank}/documents/{doc_id}") + assert r3.status_code == 200, r3.text + original_text = r3.json()["original_text"] + for label, value in _SECRETS.items(): + assert value not in original_text, f"document.original_text leaked {label}={value!r}" + + +@pytest.mark.asyncio +async def test_scrubs_ssn_from_short_message(api_client) -> None: + bank = "md-doc-body-ssn" + await api_client.put(f"/v1/default/banks/{bank}", json={}) + await _set_policy(api_client, bank, _REDACT_POLICY) + + doc_id = "ssn-1" + ssn = "123-45-6789" + body = f"The user pasted their ssn us for debugging: {ssn} — please scrub and rotate." + r = await api_client.post( + f"/v1/default/banks/{bank}/memories", + json={"items": [{"content": body, "document_id": doc_id}]}, + ) + assert r.status_code == 200, r.text + + r3 = await api_client.get(f"/v1/default/banks/{bank}/documents/{doc_id}") + assert r3.status_code == 200, r3.text + original_text = r3.json()["original_text"] + assert ssn not in original_text, f"document.original_text leaked SSN: {original_text!r}" + assert "[REDACTED:ssn_us]" in original_text, original_text + + +@pytest.mark.asyncio +async def test_scrubs_secrets_in_multi_doc_batch(api_client) -> None: + """Multiple items with distinct document_ids in a single POST trigger the + multi-doc grouping recursion in retain_batch(); screening must run for each.""" + bank = "md-multi-doc-batch" + await api_client.put(f"/v1/default/banks/{bank}", json={}) + await _set_policy(api_client, bank, _REDACT_POLICY) + + secrets = [ + ("anthropic", "sk-ant-" + "A" * 40), + ("xai", "xai-" + "G" * 80), + ("databricks", "dapi" + "L" * 32), + ("ssn", "123-45-6789"), + ] + items = [ + {"content": f"User pasted {label}: {value} — scrub it.", "document_id": f"multi-doc-{label}"} + for label, value in secrets + ] + r = await api_client.post(f"/v1/default/banks/{bank}/memories", json={"items": items}) + assert r.status_code == 200, r.text + + for label, value in secrets: + r2 = await api_client.get(f"/v1/default/banks/{bank}/documents/multi-doc-{label}") + assert r2.status_code == 200, r2.text + assert value not in r2.json()["original_text"], f"{label} leaked in multi-doc batch" + + +@pytest.mark.asyncio +async def test_scrubs_secrets_from_oversized_chunked_input(api_client) -> None: + """A single content item over retain_batch_tokens is chunked and carries the + FULL original body in document_body_override, which bypasses per-chunk + screen() — the orchestrator must scrub it before persisting.""" + bank = "md-doc-body-oversized" + await api_client.put(f"/v1/default/banks/{bank}", json={}) + await _set_policy(api_client, bank, _REDACT_POLICY) + + secret = "ghp_" + "Z" * 36 + ssn = "987-65-4321" + padding = ("The quick brown fox jumps over the lazy dog. " * 50 + "\n") * 5 # ~12KB + body = f"Audit:\n{padding}\nCredential: {secret}\nUser SSN: {ssn}\n{padding}{padding}{padding}" # >45KB + + doc_id = "oversized-leak-1" + r = await api_client.post( + f"/v1/default/banks/{bank}/memories", + json={"items": [{"content": body, "document_id": doc_id}]}, + ) + assert r.status_code == 200, r.text + + r3 = await api_client.get(f"/v1/default/banks/{bank}/documents/{doc_id}") + assert r3.status_code == 200, r3.text + original_text = r3.json()["original_text"] + assert secret not in original_text, "oversized document.original_text leaked github token" + assert ssn not in original_text, "oversized document.original_text leaked SSN" diff --git a/hindsight-api-slim/tests/test_mental_model_delta.py b/hindsight-api-slim/tests/test_mental_model_delta.py index a2be35034e..dcda778a17 100644 --- a/hindsight-api-slim/tests/test_mental_model_delta.py +++ b/hindsight-api-slim/tests/test_mental_model_delta.py @@ -250,9 +250,7 @@ async def test_delta_mode_source_query_change_falls_back_to_full( # First refresh: establishes last_refreshed_source_query. patch_reflect(memory, text="# Team\n\nFirst pass.") patch_llm_call(memory, returns="unused-first") - await memory.refresh_mental_model( - bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context - ) + await memory.refresh_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context) # Now change the source_query — a genuine topic shift. await memory.update_mental_model( @@ -289,15 +287,7 @@ async def test_delta_mode_applies_ops_when_query_stable( bank_id = f"test-delta-apply-{uuid.uuid4().hex[:8]}" await memory.get_bank_profile(bank_id, request_context=request_context) - existing = ( - "# Team\n" - "\n" - "Alice is the lead.\n" - "\n" - "## Members\n" - "\n" - "- Alice — lead\n" - ) + existing = "# Team\n\nAlice is the lead.\n\n## Members\n\n- Alice — lead\n" mm = await memory.create_mental_model( bank_id=bank_id, name="Team Info", @@ -311,9 +301,7 @@ async def test_delta_mode_applies_ops_when_query_stable( # render of the parsed existing content. This also seeds the tracking column. patch_reflect(memory, text="ignored — full mode candidate") patch_llm_call(memory, returns=[]) # zero ops - await memory.refresh_mental_model( - bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context - ) + await memory.refresh_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context) # Second refresh: a new fact arrives; LLM returns one append_block op. candidate = "# Team\n\nAlice is the lead. Bob joined as junior engineer." @@ -354,7 +342,7 @@ async def test_delta_mode_applies_ops_when_query_stable( assert "obs-bob" in user_msg assert "Bob joined" in user_msg # The structured JSON of the current doc must include the section id "members". - assert '"id": "members"' in user_msg + assert '"members"' in user_msg # New content includes the new bullet. assert "Bob — junior engineer" in refreshed["content"] @@ -369,6 +357,96 @@ async def test_delta_mode_applies_ops_when_query_stable( await memory.delete_bank(bank_id, request_context=request_context) + async def test_delta_prompt_sends_only_new_facts_not_accumulated_history( + self, + memory: MemoryEngine, + request_context: RequestContext, + patch_reflect, + patch_llm_call, + ): + """Regression: the delta prompt carries only THIS refresh's facts. + + ``based_on`` accumulates across refreshes for grounding/audit, but the + structured-delta LLM call must receive only the facts produced by the + current reflect. Re-sending every historical fact each refresh grows the + prompt without bound and trips provider input limits (e.g. Z.ai 1261). + The accumulated set is still persisted in ``reflect_response.based_on``. + """ + bank_id = f"test-delta-newfacts-{uuid.uuid4().hex[:8]}" + await memory.get_bank_profile(bank_id, request_context=request_context) + + existing = "# Team\n\nAlice is the lead.\n\n## Members\n\n- Alice — lead\n" + mm = await memory.create_mental_model( + bank_id=bank_id, + name="Team Info", + source_query="Tell me about the team", + content=existing, + trigger={"mode": "delta"}, + request_context=request_context, + ) + + # First refresh seeds prior based_on with an OLD fact (zero ops applied). + patch_reflect( + memory, + text="ignored — delta keeps existing", + facts=[ + { + "id": "obs-old-alice", + "text": "Alice has been the team lead since 2019", + "type": "observation", + "context": None, + } + ], + ) + patch_llm_call(memory, returns=[]) + first = await memory.refresh_mental_model( + bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context + ) + first_based_on = (first.get("reflect_response") or {}).get("based_on") or {} + assert "obs-old-alice" in {f.get("id") for f in first_based_on.get("observation", [])} + + # Second refresh brings only a NEW fact. + patch_reflect( + memory, + text="# Team\n\nAlice is the lead. Bob joined.", + facts=[ + { + "id": "obs-new-bob", + "text": "Bob joined the team as junior engineer", + "type": "observation", + "context": None, + } + ], + ) + ops = [ + { + "op": "append_block", + "section_id": "members", + "block": {"type": "bullet_list", "items": ["Bob — junior engineer"]}, + } + ] + llm_calls = patch_llm_call(memory, returns=ops) + + refreshed = await memory.refresh_mental_model( + bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context + ) + + assert len(llm_calls) == 1 + user_msg = llm_calls[0]["messages"][1]["content"] + # The NEW fact is sent to the delta call... + assert "obs-new-bob" in user_msg + assert "Bob joined the team" in user_msg + # ...but the accumulated OLD fact must NOT be re-sent (the regression). + assert "obs-old-alice" not in user_msg + assert "Alice has been the team lead since 2019" not in user_msg + + # based_on still ACCUMULATES both facts for grounding/audit. + based_on = (refreshed.get("reflect_response") or {}).get("based_on") or {} + obs_ids = {f.get("id") for f in based_on.get("observation", [])} + assert obs_ids == {"obs-new-bob", "obs-old-alice"} + + await memory.delete_bank(bank_id, request_context=request_context) + async def test_delta_zero_ops_keeps_existing_content_byte_identical( self, memory: MemoryEngine, @@ -386,15 +464,7 @@ async def test_delta_zero_ops_keeps_existing_content_byte_identical( bank_id = f"test-delta-noop-{uuid.uuid4().hex[:8]}" await memory.get_bank_profile(bank_id, request_context=request_context) - existing = ( - "# Team\n" - "\n" - "Alice is the lead.\n" - "\n" - "## Members\n" - "\n" - "- Alice\n" - ) + existing = "# Team\n\nAlice is the lead.\n\n## Members\n\n- Alice\n" mm = await memory.create_mental_model( bank_id=bank_id, name="Team Info", @@ -461,9 +531,7 @@ async def ok_call(*, messages, **kwargs): return DeltaOperationList() monkeypatch.setattr(memory._reflect_llm_config, "call", ok_call) - await memory.refresh_mental_model( - bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context - ) + await memory.refresh_mental_model(bank_id=bank_id, mental_model_id=mm["id"], request_context=request_context) # Now the second refresh: LLM raises. Refresh must not crash; it should # store the candidate markdown. @@ -512,15 +580,7 @@ async def test_empty_reflect_answer_preserves_existing_content( bank_id = f"test-empty-reflect-{uuid.uuid4().hex[:8]}" await memory.get_bank_profile(bank_id, request_context=request_context) - existing = ( - "# Team\n" - "\n" - "Alice is the lead.\n" - "\n" - "## Members\n" - "\n" - "- Alice\n" - ) + existing = "# Team\n\nAlice is the lead.\n\n## Members\n\n- Alice\n" mm = await memory.create_mental_model( bank_id=bank_id, name="Team Info", @@ -576,15 +636,9 @@ async def boom(*, messages, **kwargs): # Real-Gemini evaluation tests # --------------------------------------------------------------------------- -_GEMINI_API_KEY = ( - os.getenv("HINDSIGHT_GEMINI_API_KEY") - or os.getenv("GEMINI_API_KEY") - or os.getenv("GOOGLE_API_KEY") -) +_GEMINI_API_KEY = os.getenv("HINDSIGHT_GEMINI_API_KEY") or os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY") _OPENAI_API_KEY = os.getenv("OPENAI_API_KEY") -_RUN_LLM_EVAL = os.getenv("HINDSIGHT_RUN_GEMINI_EVALS") == "1" and ( - bool(_GEMINI_API_KEY) or bool(_OPENAI_API_KEY) -) +_RUN_LLM_EVAL = os.getenv("HINDSIGHT_RUN_GEMINI_EVALS") == "1" and (bool(_GEMINI_API_KEY) or bool(_OPENAI_API_KEY)) pytestmark_gemini = pytest.mark.skipif( diff --git a/hindsight-api-slim/tests/test_mental_models.py b/hindsight-api-slim/tests/test_mental_models.py index 7ebff4534f..32f334292c 100644 --- a/hindsight-api-slim/tests/test_mental_models.py +++ b/hindsight-api-slim/tests/test_mental_models.py @@ -1285,6 +1285,7 @@ def memory(self, memory_real_llm): """Override to use real LLM for this class.""" return memory_real_llm + @pytest.mark.flaky(reruns=2, reruns_delay=2) async def test_refresh_with_tags_only_accesses_same_tagged_models(self, memory: MemoryEngine, request_context): """Test that refreshing a mental model with tags can only access other models with the same tags. diff --git a/hindsight-api-slim/tests/test_metrics.py b/hindsight-api-slim/tests/test_metrics.py index 82bd96b83b..cb8d8fe3b9 100644 --- a/hindsight-api-slim/tests/test_metrics.py +++ b/hindsight-api-slim/tests/test_metrics.py @@ -1,4 +1,5 @@ """Tests for metrics instrumentation.""" + import pytest from unittest.mock import MagicMock, patch @@ -10,6 +11,7 @@ get_token_bucket, create_metrics_collector, initialize_metrics, + normalize_http_endpoint, ) @@ -79,8 +81,10 @@ def collector(self, mock_meter): """Create a MetricsCollector with a 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): + 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): @@ -114,6 +118,54 @@ def test_record_operation_records_failure_on_exception(self, collector): attributes = call_args[0][1] assert attributes["success"] == "false" + def test_record_operation_cancellation_excluded_from_metric(self, collector): + """A client-disconnect cancellation is neither a success nor a failure. + + Recall/reflect run the engine call inside record_operation; when the + client disconnects the engine raises OperationCancelledError (issue + #2122). That abandoned request must not be recorded on + hindsight.operation.total at all -- inflating neither the failure nor + the success rate -- even though the exception still propagates. + """ + from hindsight_api.cancellation import OperationCancelledError + + with pytest.raises(OperationCancelledError): + with collector.record_operation("recall", bank_id="test_bank", source="api"): + raise OperationCancelledError("client disconnected") + + collector.operation_total.add.assert_not_called() + collector.operation_duration.record.assert_not_called() + + def test_record_operation_http_499_from_cancellation_excluded(self, collector): + """run_cancellable_on_disconnect re-raises the cancellation as + ``HTTPException(499) from exc``; the cause chain marks it as a + cancellation, so it is excluded from the metric too.""" + from fastapi import HTTPException + + from hindsight_api.cancellation import OperationCancelledError + + with pytest.raises(HTTPException): + with collector.record_operation("reflect", bank_id="test_bank", source="api"): + try: + raise OperationCancelledError("client disconnected") + except OperationCancelledError as cancel: + raise HTTPException(status_code=499, detail="client disconnected") from cancel + + collector.operation_total.add.assert_not_called() + collector.operation_duration.record.assert_not_called() + + def test_record_operation_unrelated_499_still_recorded_as_failure(self, collector): + """A 499 that is NOT caused by a cancellation (no OperationCancelledError + in the cause chain) is a real failure and must still be recorded.""" + from fastapi import HTTPException + + with pytest.raises(HTTPException): + with collector.record_operation("recall", bank_id="test_bank", source="api"): + raise HTTPException(status_code=499, detail="unrelated downstream error") + + attributes = collector.operation_duration.record.call_args[0][1] + assert attributes["success"] == "false" + def test_record_operation_with_budget(self, collector): """Test that budget is included in attributes when provided.""" with collector.record_operation("recall", bank_id="test_bank", source="api", budget="mid"): @@ -170,12 +222,25 @@ def test_nested_contexts_track_separately(self, collector): assert reflect_attrs["operation"] == "reflect" assert reflect_attrs["source"] == "api" + def test_record_operation_result_records_with_explicit_success(self, collector): + """Direct recording path used by the worker (source=worker, explicit success).""" + collector.record_operation_result("retain", bank_id="test_bank", success=False, duration=1.5, source="worker") + + duration, attributes = collector.operation_duration.record.call_args[0] + assert duration == 1.5 + assert attributes["operation"] == "retain" + assert attributes["source"] == "worker" + assert attributes["success"] == "false" + collector.operation_total.add.assert_called_once_with(1, attributes) + 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): + 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() @@ -193,6 +258,7 @@ def test_returns_noop_by_default(self): """Test that get_metrics_collector returns NoOpMetricsCollector by default.""" # Reset global state import hindsight_api.metrics as metrics_module + original_collector = metrics_module._metrics_collector try: @@ -208,6 +274,7 @@ class TestMetricsCollectorBase: def test_is_abstract(self): """Test that MetricsCollectorBase methods are abstract.""" + # Create a class that inherits but doesn't implement class IncompleteCollector(MetricsCollectorBase): pass @@ -269,6 +336,24 @@ def test_bucket_50k_plus(self): assert get_token_bucket(1000000) == "50k+" +class TestNormalizeHttpEndpoint: + """Tests for normalize_http_endpoint (low-cardinality HTTP metric labels).""" + + def test_templates_high_cardinality_segments(self): + """Bank ids (incl. non-numeric), UUIDs, and numeric ids collapse to placeholders.""" + cases = [ + ("/v1/default/banks/user-1680/memories/recall", "/v1/default/banks/{bank_id}/memories/recall"), + ("/v1/default/banks/tenant-acme/memories", "/v1/default/banks/{bank_id}/memories"), + ("/v1/default/banks/user-1680", "/v1/default/banks/{bank_id}"), + ("/v1/default/banks/3f8c1e2a-1111-2222-3333-444455556666/config", "/v1/default/banks/{bank_id}/config"), + ("/v1/default/banks/42/config", "/v1/default/banks/{bank_id}/config"), + ("/v1/default/banks", "/v1/default/banks"), + ("/health", "/health"), + ] + for raw, expected in cases: + assert normalize_http_endpoint(raw) == expected, raw + + class TestLLMMetrics: """Tests for LLM-specific metrics recording.""" @@ -291,8 +376,10 @@ def collector(self, mock_meter): """Create a MetricsCollector with a 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): + 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): diff --git a/hindsight-api-slim/tests/test_migration_backsweep.py b/hindsight-api-slim/tests/test_migration_backsweep.py index fb511c1557..d1d563fea6 100644 --- a/hindsight-api-slim/tests/test_migration_backsweep.py +++ b/hindsight-api-slim/tests/test_migration_backsweep.py @@ -11,7 +11,7 @@ import pytest from alembic import command from alembic.config import Config -from sqlalchemy import create_engine, text, inspect +from sqlalchemy import create_engine, text # --------------------------------------------------------------------------- # Helpers @@ -33,25 +33,35 @@ def _upgrade(db_url: str, revision: str) -> None: command.upgrade(_alembic_cfg(db_url), revision) -def _downgrade(db_url: str, revision: str) -> None: - command.downgrade(_alembic_cfg(db_url), revision) +def _reset_public_schema(db_url: str) -> None: + engine = create_engine(db_url, isolation_level="AUTOCOMMIT") + try: + with engine.connect() as conn: + # This test rewinds/replays migration history against a persistent + # pg0 instance. Rebuild only its dedicated public schema so a + # previous run cannot leave alembic_version ahead of the real DDL. + conn.execute(text("DROP SCHEMA IF EXISTS public CASCADE")) + conn.execute(text("CREATE SCHEMA public")) + finally: + engine.dispose() # --------------------------------------------------------------------------- # Fixture: fresh database at the revision just before the backsweep # --------------------------------------------------------------------------- + @pytest.fixture(scope="module") -def pre_backsweep_db_url(): +def pre_backsweep_db_url() -> str: """ Spin up a dedicated pg0 instance and ensure schema is at the revision just before the backsweep so each test can seed orphan data and then apply the backsweep itself. - Because pg0 data directories persist across test runs, the DB may - already be at head. We upgrade to head first (to ensure all tables - exist), then stamp the revision back to pre-backsweep so Alembic - treats the backsweep as not-yet-applied. + Because pg0 data directories persist across test runs, the DB may already + have schema from a previous test run. Reset this test's dedicated schema + first, then migrate to the real pre-backsweep revision instead of stamping + a head schema backward. """ from hindsight_api.pg0 import EmbeddedPostgres @@ -62,10 +72,8 @@ def pre_backsweep_db_url(): finally: loop.close() - # Ensure all tables exist (upgrade to head), then stamp back to - # pre-backsweep so the backsweep migration will actually run. - _upgrade(url, "heads") - command.stamp(_alembic_cfg(url), "f6g7h8i9j0k1") + _reset_public_schema(url) + _upgrade(url, "f6g7h8i9j0k1") return url @@ -73,7 +81,8 @@ def pre_backsweep_db_url(): # The test # --------------------------------------------------------------------------- -def test_backsweep_removes_orphans_and_preserves_legit_rows(pre_backsweep_db_url): + +def test_backsweep_removes_orphans_and_preserves_legit_rows(pre_backsweep_db_url: str) -> None: """ Seed four kinds of rows then apply the backsweep migration and verify: @@ -100,27 +109,32 @@ def test_backsweep_removes_orphans_and_preserves_legit_rows(pre_backsweep_db_url ghost_bank = f"bank_{uuid.uuid4().hex[:8]}" # never inserted into banks # UUIDs for memory units - id_pass1_world = uuid.uuid4() # A: world unit, ghost bank - id_pass1_obs = uuid.uuid4() # A: observation, ghost bank - id_pass2_obs = uuid.uuid4() # B: observation, all sources gone - id_keep_obs = uuid.uuid4() # C: observation with one live source - id_keep_world = uuid.uuid4() # D: world unit, alive bank - id_live_source = uuid.uuid4() # live source for C + id_pass1_world = uuid.uuid4() # A: world unit, ghost bank + id_pass1_obs = uuid.uuid4() # A: observation, ghost bank + id_pass2_obs = uuid.uuid4() # B: observation, all sources gone + id_keep_obs = uuid.uuid4() # C: observation with one live source + id_keep_world = uuid.uuid4() # D: world unit, alive bank + id_live_source = uuid.uuid4() # live source for C with engine.connect() as conn: # --- banks --- conn.execute(text("INSERT INTO banks (bank_id) VALUES (:b)"), {"b": alive_bank}) # --- seed memory_units --- - def insert_mu(uid, bank, fact_type, sources=None): + def insert_mu( + uid: uuid.UUID, + bank: str, + fact_type: str, + sources: list[uuid.UUID] | None = None, + ) -> None: src_arr = "{" + ",".join(str(s) for s in (sources or [])) + "}" conn.execute( text( """ INSERT INTO memory_units - (id, bank_id, text, fact_type, source_memory_ids) + (id, bank_id, text, event_date, fact_type, source_memory_ids) VALUES - (:id, :bank, :text, :ft, CAST(:src AS uuid[])) + (:id, :bank, :text, now(), :ft, CAST(:src AS uuid[])) """ ), {"id": uid, "bank": bank, "text": "test", "ft": fact_type, "src": src_arr}, @@ -147,10 +161,9 @@ def insert_mu(uid, bank, fact_type, sources=None): # --- verify --- with engine.connect() as conn: - def exists(uid): - return conn.execute( - text("SELECT 1 FROM memory_units WHERE id = :id"), {"id": uid} - ).fetchone() is not None + + def exists(uid: uuid.UUID) -> bool: + return conn.execute(text("SELECT 1 FROM memory_units WHERE id = :id"), {"id": uid}).fetchone() is not None # Must be gone assert not exists(id_pass1_world), "Pass 1: world unit with ghost bank should be deleted" diff --git a/hindsight-api-slim/tests/test_migration_history_long_bank_id.py b/hindsight-api-slim/tests/test_migration_history_long_bank_id.py new file mode 100644 index 0000000000..a004c31f42 --- /dev/null +++ b/hindsight-api-slim/tests/test_migration_history_long_bank_id.py @@ -0,0 +1,114 @@ +"""Regression for issue #2106: the split-history migration must not truncate +``bank_id`` on PostgreSQL. + +``a7b8c9d0e1f2`` originally declared ``observation_history.bank_id`` as +``VARCHAR(64)`` while its backfill source ``memory_units.bank_id`` is ``TEXT``. +A bank_id longer than 64 chars aborted the backfill with +``StringDataRightTruncation``, rolled back the migration, and bricked startup. + +This test seeds a 78-char bank_id (the shape reported in the issue) at the +revision just before the migration, then runs the migration up to head and +asserts the row is backfilled intact and both history tables expose a ``TEXT`` +``bank_id``. Uses a dedicated pg0 instance (mirrors test_migration_backsweep) +so we control exactly which migrations have run. +""" + +import asyncio +import uuid +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, text + +_SCRIPT_LOCATION = str(Path(__file__).parent.parent / "hindsight_api" / "alembic") + +# Revision immediately before the split-history migration; at this point +# memory_units.history still exists and the history tables do not. +_PRE_SPLIT_REVISION = "d3e4f5a6b7c8" +_SPLIT_REVISION = "a7b8c9d0e1f2" + + +def _alembic_cfg(db_url: str) -> Config: + cfg = Config() + cfg.set_main_option("script_location", _SCRIPT_LOCATION) + cfg.set_main_option("sqlalchemy.url", db_url) + cfg.set_main_option("prepend_sys_path", ".") + cfg.set_main_option("path_separator", "os") + return cfg + + +@pytest.fixture(scope="module") +def pre_split_db_url(): + """pg0 instance with schema at the revision just before the split-history + migration so the migration's backfill actually runs against seeded data.""" + from hindsight_api.pg0 import EmbeddedPostgres + + pg0 = EmbeddedPostgres(name="hindsight-long-bankid-test", port=5567) + loop = asyncio.new_event_loop() + try: + url = loop.run_until_complete(pg0.ensure_running()) + finally: + loop.close() + + # pg0 data dirs persist across runs, so the DB may already be past the + # split. Bring everything to head, then downgrade to before the split so + # memory_units.history is present and the history tables are gone. + command.upgrade(_alembic_cfg(url), "heads") + command.downgrade(_alembic_cfg(url), _PRE_SPLIT_REVISION) + return url + + +def _col_type(conn, table: str) -> str: + return conn.execute( + text("SELECT data_type FROM information_schema.columns WHERE table_name = :t AND column_name = 'bank_id'"), + {"t": table}, + ).scalar() + + +def test_split_history_backfills_long_bank_id(pre_split_db_url): + db_url = pre_split_db_url + # bank_id matching the shape reported in the issue: + # ::ou_<32hex>::ou_<32hex> — well over the old 64-char cap. A unique + # suffix keeps the test idempotent against pg0 data dirs that persist across + # runs (otherwise a re-run collides on the banks PK). + long_bank = f"tenantA::ou_{uuid.uuid4().hex}::ou_{uuid.uuid4().hex}" + assert len(long_bank) > 64 + + obs_id = uuid.uuid4() + engine = create_engine(db_url) + with engine.connect() as conn: + conn.execute(text("INSERT INTO banks (bank_id) VALUES (:b)"), {"b": long_bank}) + conn.execute( + text( + """ + INSERT INTO memory_units (id, bank_id, text, fact_type, history) + VALUES (:id, :b, 'obs text', 'observation', + '[{"changed_at":"2026-01-01T00:00:00Z","previous_text":"a"}]'::jsonb) + """ + ), + {"id": obs_id, "b": long_bank}, + ) + conn.commit() + + # The migration that used to abort with StringDataRightTruncation. + command.upgrade(_alembic_cfg(db_url), _SPLIT_REVISION) + + with engine.connect() as conn: + row = conn.execute( + text("SELECT bank_id FROM observation_history WHERE observation_id = :id"), + {"id": obs_id}, + ).fetchone() + assert row is not None, "history entry for the long-bank_id observation was not backfilled" + assert row[0] == long_bank, "bank_id was truncated during backfill" + assert _col_type(conn, "observation_history") == "text" + assert _col_type(conn, "mental_model_history") == "text" + + # Forward-repair migration to head is a clean no-op on already-TEXT columns. + command.upgrade(_alembic_cfg(db_url), "heads") + with engine.connect() as conn: + assert _col_type(conn, "observation_history") == "text" + assert _col_type(conn, "mental_model_history") == "text" + + engine.dispose() diff --git a/hindsight-api-slim/tests/test_migration_remaining_bank_id_text.py b/hindsight-api-slim/tests/test_migration_remaining_bank_id_text.py new file mode 100644 index 0000000000..5d916bf596 --- /dev/null +++ b/hindsight-api-slim/tests/test_migration_remaining_bank_id_text.py @@ -0,0 +1,123 @@ +"""Regression for the issue #2106 follow-up: the live ``bank_id`` columns must +not truncate on PostgreSQL. + +``c3e5a7b9d1f4`` widened the *history* tables to ``TEXT``, but ``directives`` and +``mental_models`` kept their original ``VARCHAR(64)`` ``bank_id`` while +``banks.bank_id`` is ``TEXT``. A bank_id longer than 64 chars (the 78-char shape +reported in #2106) can create the bank but then 500s with +``StringDataRightTruncation`` on the next write to those tables. + +This test migrates a dedicated pg0 instance to head, asserts both columns are +``TEXT``, then writes a >64-char bank_id through every widened table. Uses a +dedicated pg0 instance (mirrors test_migration_history_long_bank_id) so the +migrated schema is well defined. + +``mental_model_versions`` is deliberately excluded: it is dropped on the upgrade +path (``o0j1k2l3m4n5``) and does not exist at head, so the migration must not +touch it. +""" + +import asyncio +import uuid +from pathlib import Path + +import pytest +from alembic import command +from alembic.config import Config +from sqlalchemy import create_engine, text + +# Both tests in this module share one module-scoped pg0 on a fixed port (5568). +# CI runs with `--dist loadgroup`, which, absent an xdist_group, may scatter the +# two tests across workers that then each instantiate the module fixture and race +# to provision the SAME instance — surfacing as flaky "Instance already running", +# a pg_type UniqueViolation (concurrent CREATE EXTENSION), or "server closed the +# connection". Pinning the module to a single worker serialises that provisioning. +pytestmark = pytest.mark.xdist_group("migration-remaining-bankid-pg0") + +_SCRIPT_LOCATION = str(Path(__file__).parent.parent / "hindsight_api" / "alembic") + +_WIDEN_TABLES = ("directives", "mental_models") + + +def _alembic_cfg(db_url: str) -> Config: + cfg = Config() + cfg.set_main_option("script_location", _SCRIPT_LOCATION) + cfg.set_main_option("sqlalchemy.url", db_url) + cfg.set_main_option("prepend_sys_path", ".") + cfg.set_main_option("path_separator", "os") + return cfg + + +def _col_type(conn, table: str) -> str: + return conn.execute( + text("SELECT data_type FROM information_schema.columns WHERE table_name = :t AND column_name = 'bank_id'"), + {"t": table}, + ).scalar() + + +@pytest.fixture(scope="module") +def head_db_url(): + """pg0 instance migrated to head (includes the widen migration).""" + from hindsight_api.pg0 import EmbeddedPostgres + + pg0 = EmbeddedPostgres(name="hindsight-remaining-bankid-test", port=5568) + loop = asyncio.new_event_loop() + try: + url = loop.run_until_complete(pg0.ensure_running()) + finally: + loop.close() + + command.upgrade(_alembic_cfg(url), "heads") + return url + + +def test_remaining_bank_id_columns_are_text(head_db_url): + engine = create_engine(head_db_url) + try: + with engine.connect() as conn: + for table in _WIDEN_TABLES: + assert _col_type(conn, table) == "text", f"{table}.bank_id must be TEXT to match banks.bank_id" + finally: + engine.dispose() + + +def test_long_bank_id_round_trips_through_widened_tables(head_db_url): + # bank_id matching the shape reported in the issue, well over the old 64-char + # cap. Unique suffixes keep the test idempotent against pg0 data dirs that + # persist across runs (otherwise a re-run collides on the banks PK). + long_bank = f"tenantA::ou_{uuid.uuid4().hex}::ou_{uuid.uuid4().hex}" + assert len(long_bank) > 64 + mm_id = f"mm-{uuid.uuid4().hex}" # explicit id keeps re-runs from colliding + + engine = create_engine(head_db_url) + try: + with engine.connect() as conn: + conn.execute(text("INSERT INTO banks (bank_id) VALUES (:b)"), {"b": long_bank}) + conn.execute( + text("INSERT INTO directives (bank_id, name, content) VALUES (:b, :n, :c)"), + {"b": long_bank, "n": "long-bank directive", "c": "rule body"}, + ) + conn.execute( + text( + "INSERT INTO mental_models " + "(id, bank_id, subtype, name, source_query, content) " + "VALUES (:mid, :b, 'pinned', :n, :q, :c)" + ), + { + "mid": mm_id, + "b": long_bank, + "n": "long-bank model", + "q": "what does the user prefer", + "c": "model body", + }, + ) + conn.commit() + + for table in _WIDEN_TABLES: + got = conn.execute( + text(f"SELECT bank_id FROM {table} WHERE bank_id = :b LIMIT 1"), + {"b": long_bank}, + ).scalar() + assert got == long_bank, f"bank_id was truncated on the widened {table} table" + finally: + engine.dispose() diff --git a/hindsight-api-slim/tests/test_migrations_newer_revision.py b/hindsight-api-slim/tests/test_migrations_newer_revision.py new file mode 100644 index 0000000000..568f6c6800 --- /dev/null +++ b/hindsight-api-slim/tests/test_migrations_newer_revision.py @@ -0,0 +1,51 @@ +"""Newer-bank rolling-deployment handling in _run_migrations_internal. + +command.upgrade() does not raise ResolutionError directly: alembic's +ScriptDirectory._catch_revision_errors wraps it in CommandError. The +rolling-deployment skip must therefore handle the wrapped form too +(github issue #2114). +""" + +import logging + +import pytest +from alembic.script.revision import ResolutionError +from alembic.util.exc import CommandError + +from hindsight_api import migrations + +DB_URL = "postgresql://user:pass@localhost/db" + + +def _raise_wrapped_resolution_error(_cfg, _revision): + # Mirrors alembic's _catch_revision_errors wrapping. + try: + raise ResolutionError("No such revision or branch 'c1d2e3f4a5b6'", "c1d2e3f4a5b6") + except ResolutionError as err: + raise CommandError("Can't locate revision identified by 'c1d2e3f4a5b6'") from err + + +def test_wrapped_resolution_error_skips_migrations(monkeypatch, caplog): + monkeypatch.setattr(migrations.command, "upgrade", _raise_wrapped_resolution_error) + with caplog.at_level(logging.WARNING): + migrations._run_migrations_internal(DB_URL, "/tmp/alembic") + assert "newer migration revision" in caplog.text + + +def test_bare_resolution_error_still_skips_migrations(monkeypatch, caplog): + def raise_resolution_error(_cfg, _revision): + raise ResolutionError("No such revision or branch 'c1d2e3f4a5b6'", "c1d2e3f4a5b6") + + monkeypatch.setattr(migrations.command, "upgrade", raise_resolution_error) + with caplog.at_level(logging.WARNING): + migrations._run_migrations_internal(DB_URL, "/tmp/alembic") + assert "newer migration revision" in caplog.text + + +def test_unrelated_command_error_propagates(monkeypatch): + def raise_command_error(_cfg, _revision): + raise CommandError("Path doesn't exist: '/tmp/alembic'") + + monkeypatch.setattr(migrations.command, "upgrade", raise_command_error) + with pytest.raises(CommandError): + migrations._run_migrations_internal(DB_URL, "/tmp/alembic") diff --git a/hindsight-api-slim/tests/test_migrations_parallel_schemas.py b/hindsight-api-slim/tests/test_migrations_parallel_schemas.py new file mode 100644 index 0000000000..a7f20bd2b1 --- /dev/null +++ b/hindsight-api-slim/tests/test_migrations_parallel_schemas.py @@ -0,0 +1,145 @@ +"""Orchestration tests for run_migrations_for_schemas (per-tenant parallelism). + +These cover the fan-out logic deterministically without a real database by +stubbing the per-step migration functions. The real cross-process path is +exercised by the standard migration/integration suites that run against pg0. +""" + +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +import pytest + +from hindsight_api import migrations + + +@pytest.fixture +def record_steps(monkeypatch): + """Replace the real migration steps with recorders; return the call log.""" + calls: list[tuple[str, str]] = [] + lock = threading.Lock() + + def make(step): + def _step(database_url, *args, schema=None, **kwargs): + with lock: + calls.append((step, schema)) + + return _step + + monkeypatch.setattr(migrations, "run_migrations", make("run_migrations")) + monkeypatch.setattr(migrations, "ensure_embedding_dimension", make("embedding_dimension")) + monkeypatch.setattr(migrations, "ensure_vector_extension", make("vector_extension")) + monkeypatch.setattr(migrations, "ensure_text_search_extension", make("text_search_extension")) + return calls + + +def test_empty_schema_list_is_noop(record_steps): + migrations.run_migrations_for_schemas("postgresql://x/db", []) + assert record_steps == [] + + +def test_sequential_runs_all_steps_in_order_per_schema(record_steps): + migrations.run_migrations_for_schemas( + "postgresql://x/db", + ["a", "b"], + concurrency=1, + embedding_dimension=768, + ) + # Each schema: migrate -> embedding dim -> vector ext -> text-search ext. + assert record_steps == [ + ("run_migrations", "a"), + ("embedding_dimension", "a"), + ("vector_extension", "a"), + ("text_search_extension", "a"), + ("run_migrations", "b"), + ("embedding_dimension", "b"), + ("vector_extension", "b"), + ("text_search_extension", "b"), + ] + + +def test_skips_embedding_dim_when_none_and_extensions_when_disabled(record_steps): + migrations.run_migrations_for_schemas( + "postgresql://x/db", + ["a"], + concurrency=1, + embedding_dimension=None, + ensure_extensions=False, + ) + assert record_steps == [("run_migrations", "a")] + + +def test_parallel_fans_out_across_schemas(monkeypatch): + """concurrency>1 runs distinct schemas at the same time (not serialized).""" + max_active = 0 + active = 0 + lock = threading.Lock() + + def slow_migrate(database_url, *args, schema=None, **kwargs): + nonlocal max_active, active + with lock: + active += 1 + max_active = max(max_active, active) + time.sleep(0.05) + with lock: + active -= 1 + + monkeypatch.setattr(migrations, "run_migrations", slow_migrate) + monkeypatch.setattr(migrations, "ensure_embedding_dimension", lambda *a, **k: None) + monkeypatch.setattr(migrations, "ensure_vector_extension", lambda *a, **k: None) + monkeypatch.setattr(migrations, "ensure_text_search_extension", lambda *a, **k: None) + # Run the parallel branch in-process so the monkeypatched steps are visible. + monkeypatch.setattr( + migrations, + "_make_migration_executor", + lambda max_workers: ThreadPoolExecutor(max_workers=max_workers), + ) + + migrations.run_migrations_for_schemas( + "postgresql://x/db", + ["a", "b", "c", "d"], + concurrency=3, + ) + assert max_active == 3 + + +def test_parallel_aggregates_per_schema_failures(monkeypatch): + """One failing schema does not hide the others, and all are still attempted.""" + attempted: list[str] = [] + lock = threading.Lock() + + def migrate(database_url, *args, schema=None, **kwargs): + with lock: + attempted.append(schema) + if schema in ("b", "d"): + raise RuntimeError(f"boom {schema}") + + monkeypatch.setattr(migrations, "run_migrations", migrate) + monkeypatch.setattr(migrations, "ensure_embedding_dimension", lambda *a, **k: None) + monkeypatch.setattr(migrations, "ensure_vector_extension", lambda *a, **k: None) + monkeypatch.setattr(migrations, "ensure_text_search_extension", lambda *a, **k: None) + monkeypatch.setattr( + migrations, + "_make_migration_executor", + lambda max_workers: ThreadPoolExecutor(max_workers=max_workers), + ) + + with pytest.raises(RuntimeError) as exc_info: + migrations.run_migrations_for_schemas( + "postgresql://x/db", + ["a", "b", "c", "d"], + concurrency=2, + ) + + assert set(attempted) == {"a", "b", "c", "d"} + message = str(exc_info.value) + assert "b" in message and "d" in message + assert "2 of 4" in message + + +def test_worker_is_picklable(): + """ProcessPoolExecutor requires the worker to be importable/picklable.""" + import pickle + + pickle.loads(pickle.dumps(migrations._migrate_one_schema_pg)) diff --git a/hindsight-api-slim/tests/test_model_load_default_dtype.py b/hindsight-api-slim/tests/test_model_load_default_dtype.py new file mode 100644 index 0000000000..605af9032b --- /dev/null +++ b/hindsight-api-slim/tests/test_model_load_default_dtype.py @@ -0,0 +1,90 @@ +""" +Startup must leave torch's global default dtype at float32 regardless of how the +concurrent local model loads interleave. + +Covers issue #2162: transformers' dtype context manager (entered by +SentenceTransformer / CrossEncoder / from_pretrained) does a NON-thread-safe +save/restore of the *process-global* default dtype. When an fp16 embedding model +and an fp32 reranker/query-analyzer load in parallel at startup, an unlucky +interleave leaves the global default stuck at float16 — every later encode() then +emits NaN vectors that pgvector rejects ("NaN not allowed in vector") on MPS, or +raises "c10::Half != float" on CPU, non-deterministically across restarts. + +MemoryEngine.initialize() loads the models in parallel (for speed) and then, once +the gather has joined every load thread, normalizes the global default dtype back +to float32 — the inference state a healthy boot already converges to. This test +simulates the poisoning by having a model load flip the default to float16, then +asserts initialize() leaves it at float32. +""" + +import pytest + +from hindsight_api import MemoryEngine +from hindsight_api.engine.task_backend import SyncTaskBackend + + +class _StopInit(Exception): + """Sentinel to abort initialize() right after the model-load gather.""" + + +class _PoisoningEmbeddings: + """Local embedding stub that mimics an fp16 load poisoning the global dtype.""" + + provider_name = "local" + + async def initialize(self) -> None: + import torch + + # Reproduce the symptom of transformers' racy dtype restore: the global + # default is left at float16 after the (parallel) load. + torch.set_default_dtype(torch.float16) + + +class _NoopCrossEncoder: + provider_name = "local" + + async def initialize(self) -> None: + return None + + +class _NoopQueryAnalyzer: + def load(self) -> None: + return None + + +@pytest.mark.asyncio +async def test_global_default_dtype_restored_to_float32_after_init(): + """A load that leaves the torch default at float16 is normalized back to float32.""" + import torch + + original = torch.get_default_dtype() + try: + engine = MemoryEngine( + # Non-pg0 URL so start_pg0() is a no-op and __init__ never connects. + db_url="postgresql://u:p@localhost:5999/db", + memory_llm_provider="none", + memory_llm_api_key=None, + memory_llm_model="none", + embeddings=_PoisoningEmbeddings(), + cross_encoder=_NoopCrossEncoder(), + query_analyzer=_NoopQueryAnalyzer(), + run_migrations=False, + skip_llm_verification=True, + lazy_reranker=False, # load the cross-encoder eagerly, in the gather + task_backend=SyncTaskBackend(), + ) + + # Abort right after the post-gather dtype restore, before any real DB work. + async def _stop(*args, **kwargs): + raise _StopInit + + engine._backend.initialize = _stop # type: ignore[method-assign] + + with pytest.raises(_StopInit): + await engine.initialize() + + # The embedding load poisoned the default to float16; initialize() must + # have normalized it back so later encode() can't emit NaN vectors. + assert torch.get_default_dtype() == torch.float32 + finally: + torch.set_default_dtype(original) diff --git a/hindsight-api-slim/tests/test_multilingual.py b/hindsight-api-slim/tests/test_multilingual.py index 675ea1ae84..14ee21fbbe 100644 --- a/hindsight-api-slim/tests/test_multilingual.py +++ b/hindsight-api-slim/tests/test_multilingual.py @@ -85,10 +85,7 @@ async def test_retain_chinese_content(memory_real_llm, request_context): for fact in result.results: logger.info(f"Fact: {fact.text[:100]}...") # Check for common Chinese characters or the name - if any( - char in fact.text - for char in ["张", "伟", "腾讯", "软件", "工程师", "分布式", "系统", "代码"] - ): + if any(char in fact.text for char in ["张", "伟", "腾讯", "软件", "工程师", "分布式", "系统", "代码"]): chinese_facts_found += 1 logger.info(f"Found {chinese_facts_found} facts with Chinese content") @@ -185,7 +182,7 @@ async def test_reflect_chinese_content(memory_real_llm, request_context): expected_names = set() for fact in result.based_on.get("world", []): # Extract Chinese entity names from the fact - for entity in (fact.entities or []): + for entity in fact.entities or []: # Check if entity contains Chinese characters if any("\u4e00" <= char <= "\u9fff" for char in entity): expected_names.add(entity) @@ -277,8 +274,7 @@ async def test_retain_japanese_content(memory_real_llm, request_context): japanese_facts_found += 1 assert japanese_facts_found > 0, ( - f"Expected facts to contain Japanese characters. " - f"Facts: {[f.text for f in result.results]}" + f"Expected facts to contain Japanese characters. Facts: {[f.text for f in result.results]}" ) logger.info("Japanese retain test passed - facts preserved in Japanese") @@ -350,8 +346,7 @@ async def test_english_content_stays_english(memory_real_llm, request_context): # Count Japanese characters (hiragana, katakana) japanese_chars = sum( - 1 for char in fact.text - if ("\u3040" <= char <= "\u309f") or ("\u30a0" <= char <= "\u30ff") + 1 for char in fact.text if ("\u3040" <= char <= "\u309f") or ("\u30a0" <= char <= "\u30ff") ) # Count Chinese/CJK characters (excluding those also used in Japanese) @@ -426,8 +421,7 @@ async def test_italian_content_stays_italian(memory_real_llm, request_context): # Count CJK characters cjk_chars = sum(1 for char in fact.text if "\u4e00" <= char <= "\u9fff") japanese_chars = sum( - 1 for char in fact.text - if ("\u3040" <= char <= "\u309f") or ("\u30a0" <= char <= "\u30ff") + 1 for char in fact.text if ("\u3040" <= char <= "\u309f") or ("\u30a0" <= char <= "\u30ff") ) total_chars = len(fact.text) @@ -503,13 +497,9 @@ async def test_mixed_language_entities(memory_real_llm, request_context): # Should contain Chinese name and/or English company names has_chinese_name = "王芳" in all_text - has_english_company = any( - company in all_text for company in ["Google", "Microsoft", "Amazon", "YouTube"] - ) + has_english_company = any(company in all_text for company in ["Google", "Microsoft", "Amazon", "YouTube"]) - assert has_chinese_name or has_english_company, ( - f"Expected mixed language entities. Facts: {all_text}" - ) + assert has_chinese_name or has_english_company, f"Expected mixed language entities. Facts: {all_text}" logger.info("Mixed language entity test passed") diff --git a/hindsight-api-slim/tests/test_nous_auth_refresh.py b/hindsight-api-slim/tests/test_nous_auth_refresh.py new file mode 100644 index 0000000000..42b80b6380 --- /dev/null +++ b/hindsight-api-slim/tests/test_nous_auth_refresh.py @@ -0,0 +1,234 @@ +"""Tests for the native Nous Portal OAuth provider. + +The Nous provider mirrors the Codex provider: it reads OAuth state from the +Hermes auth store (``~/.hermes/auth.json``, ``providers.nous``) and refreshes +the inference JWT itself, with no dependency on the ``hermes_cli`` package. + +These tests pin that behaviour against a fake auth store on disk and a stubbed +refresh endpoint — no network, no Hermes install required: + +- ``from_file`` loads access/refresh tokens (and raises a clear "logged out" + error when ``providers.nous`` is absent / has no access_token). +- A loaded static-shaped store still surfaces the access_token as the bearer. +- Proactive refresh fires when the JWT ``exp`` claim is near/past expiry. +- The refresh request matches Hermes' shape: POST {portal}/api/oauth/token with + an ``x-nous-refresh-token`` header and a ``grant_type=refresh_token`` body. +- The rotated refresh_token is persisted atomically back into + ``providers.nous`` (mode 0600) without clobbering sibling fields. +- Terminal refresh errors raise ``NousRefreshExpiredError`` and do not loop. +- Single-use safety: refresh re-reads the latest refresh_token from disk under + the lock before exchanging. +- The provider registers in ``create_llm_provider`` and needs no api_key. +""" + +from __future__ import annotations + +import base64 +import json +import os +import stat +import time +from pathlib import Path + +import httpx +import pytest + +from hindsight_api.engine.providers.nous_auth import ( + _NOUS_TOKEN_REFRESH_SKEW_SECONDS, + NousAuthManager, + NousNotLoggedInError, + NousRefreshExpiredError, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _jwt_with_exp(exp_unixtime: int) -> str: + """Build an unsigned JWT whose payload carries the given ``exp`` claim.""" + + def b64(obj: dict) -> str: + raw = json.dumps(obj).encode("utf-8") + return base64.urlsafe_b64encode(raw).decode("utf-8").rstrip("=") + + return f"{b64({'alg': 'none'})}.{b64({'exp': exp_unixtime})}.sig" + + +def _write_store(path: Path, state: dict | None, *, extra: dict | None = None) -> None: + store: dict = {"version": 1, "providers": {}, "credential_pool": {"openai-codex": {"keep": "me"}}} + if extra: + store.update(extra) + if state is not None: + store["providers"]["nous"] = state + path.write_text(json.dumps(store, indent=2)) + + +def _fresh_state(**overrides) -> dict: + state = { + "access_token": _jwt_with_exp(int(time.time()) + 3600), + "refresh_token": "rt-original", + "portal_base_url": "https://portal.nousresearch.com", + "inference_base_url": "https://inference-api.nousresearch.com/v1", + "client_id": "hermes-cli", + } + state.update(overrides) + return state + + +# --------------------------------------------------------------------------- +# from_file +# --------------------------------------------------------------------------- + + +def test_from_file_loads_nous_oauth_state(tmp_path: Path) -> None: + auth = tmp_path / "auth.json" + _write_store(auth, _fresh_state()) + + mgr = NousAuthManager.from_file(auth) + + assert mgr.refresh_token == "rt-original" + assert mgr.base_url == "https://inference-api.nousresearch.com/v1" + assert mgr.ensure_fresh_token() == mgr.access_token # fresh JWT → no refresh + + +def test_from_file_missing_file_raises_not_logged_in(tmp_path: Path) -> None: + with pytest.raises(NousNotLoggedInError, match="hermes portal"): + NousAuthManager.from_file(tmp_path / "nope.json") + + +def test_from_file_without_nous_provider_raises_not_logged_in(tmp_path: Path) -> None: + auth = tmp_path / "auth.json" + _write_store(auth, None) # has other providers/pool, but no providers.nous + with pytest.raises(NousNotLoggedInError, match="not logged into Nous Portal"): + NousAuthManager.from_file(auth) + + +def test_from_file_without_access_token_raises(tmp_path: Path) -> None: + auth = tmp_path / "auth.json" + _write_store(auth, {"refresh_token": "rt"}) + with pytest.raises(NousNotLoggedInError, match="no access_token"): + NousAuthManager.from_file(auth) + + +# --------------------------------------------------------------------------- +# Proactive refresh + request shape + persistence +# --------------------------------------------------------------------------- + + +def test_stale_token_triggers_refresh_with_hermes_request_shape(tmp_path: Path, monkeypatch) -> None: + auth = tmp_path / "auth.json" + near_exp = int(time.time()) + (_NOUS_TOKEN_REFRESH_SKEW_SECONDS - 5) # within skew → stale + _write_store(auth, _fresh_state(access_token=_jwt_with_exp(near_exp))) + mgr = NousAuthManager.from_file(auth) + + new_access = _jwt_with_exp(int(time.time()) + 3600) + captured: dict = {} + + def fake_post(url, *, headers=None, data=None, timeout=None): + captured["url"] = url + captured["headers"] = headers + captured["data"] = data + return httpx.Response( + 200, + json={"access_token": new_access, "refresh_token": "rt-rotated", "expires_in": 3600}, + request=httpx.Request("POST", url), + ) + + monkeypatch.setattr(mgr._http_client, "post", fake_post) + + token = mgr.ensure_fresh_token() + + assert token == new_access + assert captured["url"] == "https://portal.nousresearch.com/api/oauth/token" + assert captured["headers"]["x-nous-refresh-token"] == "rt-original" + assert captured["data"] == {"grant_type": "refresh_token", "client_id": "hermes-cli"} + + # Rotated tokens persisted back into providers.nous, pool preserved. + on_disk = json.loads(auth.read_text()) + assert on_disk["providers"]["nous"]["access_token"] == new_access + assert on_disk["providers"]["nous"]["refresh_token"] == "rt-rotated" + assert on_disk["providers"]["nous"]["agent_key"] == new_access # bearer == access_token + assert on_disk["credential_pool"]["openai-codex"] == {"keep": "me"} # not clobbered + assert stat.S_IMODE(auth.stat().st_mode) == 0o600 + + +def test_refresh_rereads_latest_refresh_token_from_disk(tmp_path: Path, monkeypatch) -> None: + """Single-use safety: a token Hermes rotated on disk is used, not the stale + in-memory one the manager loaded at startup.""" + auth = tmp_path / "auth.json" + near_exp = int(time.time()) + 10 + _write_store(auth, _fresh_state(access_token=_jwt_with_exp(near_exp))) + mgr = NousAuthManager.from_file(auth) + assert mgr.refresh_token == "rt-original" + + # Simulate a concurrent Hermes refresh that rotated the RT on disk. + rotated = _fresh_state(access_token=_jwt_with_exp(near_exp), refresh_token="rt-from-hermes") + _write_store(auth, rotated) + + sent_rt: dict = {} + + def fake_post(url, *, headers=None, data=None, timeout=None): + sent_rt["value"] = headers["x-nous-refresh-token"] + return httpx.Response( + 200, + json={"access_token": _jwt_with_exp(int(time.time()) + 3600), "expires_in": 3600}, + request=httpx.Request("POST", url), + ) + + monkeypatch.setattr(mgr._http_client, "post", fake_post) + mgr.refresh_tokens(force=True) + + assert sent_rt["value"] == "rt-from-hermes" # disk value, not the stale in-memory "rt-original" + + +# --------------------------------------------------------------------------- +# Terminal errors +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("status,body", [(400, {"error": "invalid_grant"}), (401, {"error": "refresh_token_reused"})]) +def test_terminal_refresh_error_raises_and_does_not_loop(tmp_path: Path, monkeypatch, status, body) -> None: + auth = tmp_path / "auth.json" + _write_store(auth, _fresh_state(access_token=_jwt_with_exp(int(time.time()) - 10))) + mgr = NousAuthManager.from_file(auth) + + calls = {"n": 0} + + def fake_post(url, *, headers=None, data=None, timeout=None): + calls["n"] += 1 + return httpx.Response(status, json=body, request=httpx.Request("POST", url)) + + monkeypatch.setattr(mgr._http_client, "post", fake_post) + + with pytest.raises(NousRefreshExpiredError, match="hermes portal"): + mgr.refresh_tokens(force=True) + assert calls["n"] == 1 # one attempt, no retry loop + + +def test_missing_refresh_token_raises_runtime_error(tmp_path: Path) -> None: + auth = tmp_path / "auth.json" + state = _fresh_state(access_token=_jwt_with_exp(int(time.time()) - 10)) + del state["refresh_token"] + _write_store(auth, state) + mgr = NousAuthManager.from_file(auth) + + with pytest.raises(RuntimeError, match="no refresh_token"): + mgr.refresh_tokens(force=True) + + +# --------------------------------------------------------------------------- +# JWT exp decoding +# --------------------------------------------------------------------------- + + +def test_unparseable_exp_is_not_treated_as_stale(tmp_path: Path) -> None: + auth = tmp_path / "auth.json" + _write_store(auth, _fresh_state(access_token="not-a-jwt")) + mgr = NousAuthManager.from_file(auth) + # exp can't be determined → prefer reactive 401 recovery over aggressive refresh. + assert mgr._token_is_stale() is False + + +def test_load_refresh_token_from_file_missing_returns_none(tmp_path: Path) -> None: + assert NousAuthManager.load_refresh_token_from_file(tmp_path / "absent.json") is None diff --git a/hindsight-api-slim/tests/test_nous_provider.py b/hindsight-api-slim/tests/test_nous_provider.py new file mode 100644 index 0000000000..db1d1c443a --- /dev/null +++ b/hindsight-api-slim/tests/test_nous_provider.py @@ -0,0 +1,125 @@ +"""Tests for Nous provider wiring into the LLM factory and the NousLLM subclass. + +These exercise the integration surface without a Hermes install: the auth +manager is stubbed so we can assert base-url handling, the no-api-key contract, +provider validation, and the proactive/reactive token-refresh plumbing on +``NousLLM`` (rebuild client on rotation; one reactive refresh on a 401). +""" + +from __future__ import annotations + +from unittest.mock import patch + +import httpx +import pytest +from openai import APIStatusError + +from hindsight_api.engine.llm_wrapper import requires_api_key +from hindsight_api.engine.providers.nous_auth import NousAuthManager +from hindsight_api.engine.providers.nous_llm import NousLLM + + +class _FakeAuth: + """Stand-in for NousAuthManager with a controllable token + refresh.""" + + def __init__(self, token: str = "tok-1") -> None: + self.access_token = token + self.base_url = "https://inference-api.nousresearch.com/v1" + self.stale = False + self.refresh_calls: list[tuple[str, bool]] = [] + self.next_token = token + + def _token_is_stale(self) -> bool: + return self.stale + + def refresh_tokens(self, reason: str = "", *, force: bool = False) -> None: + self.refresh_calls.append((reason, force)) + self.access_token = self.next_token + self.stale = False + + def close(self) -> None: # pragma: no cover - trivial + pass + + +def _make(base_url: str = "", auth: _FakeAuth | None = None) -> NousLLM: + fake = auth or _FakeAuth() + with patch.object(NousAuthManager, "from_file", return_value=fake): + return NousLLM(provider="nous", api_key="ignored", base_url=base_url, model="deepseek/deepseek-v4-flash") + + +# --------------------------------------------------------------------------- +# Factory contract +# --------------------------------------------------------------------------- + + +def test_nous_does_not_require_api_key() -> None: + assert requires_api_key("nous") is False + + +def test_nous_default_base_url_when_empty() -> None: + assert _make("").base_url == "https://inference-api.nousresearch.com/v1" + + +def test_nous_respects_explicit_base_url() -> None: + llm = _make("https://staging-inference.example.com/v1") + assert llm.base_url == "https://staging-inference.example.com/v1" + + +def test_nous_uses_loaded_token_as_api_key() -> None: + llm = _make(auth=_FakeAuth(token="tok-loaded")) + assert llm.api_key == "tok-loaded" + + +# --------------------------------------------------------------------------- +# Token refresh plumbing +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_ensure_fresh_token_rebuilds_client_on_rotation() -> None: + auth = _FakeAuth(token="tok-1") + llm = _make(auth=auth) + original_client = llm._client + + auth.stale = True + auth.next_token = "tok-2" + await llm._ensure_fresh_token() + + assert auth.refresh_calls == [("proactive (token near expiry)", False)] + assert llm.api_key == "tok-2" + assert llm._client is not original_client # client rebuilt with the new token + + +@pytest.mark.asyncio +async def test_fresh_token_skips_refresh() -> None: + auth = _FakeAuth(token="tok-1") + llm = _make(auth=auth) + await llm._ensure_fresh_token() + assert auth.refresh_calls == [] + + +@pytest.mark.asyncio +async def test_call_refreshes_once_on_401_then_retries() -> None: + auth = _FakeAuth(token="tok-1") + llm = _make(auth=auth) + auth.next_token = "tok-2" + + calls = {"n": 0} + + async def fake_super_call(*args, **kwargs): + calls["n"] += 1 + if calls["n"] == 1: + raise APIStatusError( + "unauthorized", + response=httpx.Response(401, request=httpx.Request("POST", "http://x")), + body=None, + ) + return "ok" + + with patch.object(NousLLM.__bases__[0], "call", side_effect=fake_super_call, autospec=False): + result = await llm.call(messages=[{"role": "user", "content": "hi"}]) + + assert result == "ok" + assert calls["n"] == 2 # original + one retry + assert auth.refresh_calls[-1][1] is True # forced refresh on the 401 path + assert llm.api_key == "tok-2" diff --git a/hindsight-api-slim/tests/test_observation_history.py b/hindsight-api-slim/tests/test_observation_history.py index 693324b856..3f624f5ca1 100644 --- a/hindsight-api-slim/tests/test_observation_history.py +++ b/hindsight-api-slim/tests/test_observation_history.py @@ -66,13 +66,9 @@ async def test_append_read_and_cap( await memory.delete_bank(bank_id, request_context=request_context) - async def test_returns_none_for_missing_observation( - self, memory: MemoryEngine, request_context: Any - ) -> None: + async def test_returns_none_for_missing_observation(self, memory: MemoryEngine, request_context: Any) -> None: bank_id = f"test-obs-hist-{uuid.uuid4().hex[:8]}" await memory.get_bank_profile(bank_id, request_context=request_context) - result = await memory.get_observation_history( - bank_id, str(uuid.uuid4()), request_context=request_context - ) + result = await memory.get_observation_history(bank_id, str(uuid.uuid4()), request_context=request_context) assert result is None await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api-slim/tests/test_observation_invalidation.py b/hindsight-api-slim/tests/test_observation_invalidation.py index 68a8ac25a7..f3f4c6b9fe 100644 --- a/hindsight-api-slim/tests/test_observation_invalidation.py +++ b/hindsight-api-slim/tests/test_observation_invalidation.py @@ -267,6 +267,7 @@ async def test_deleting_document_removes_observations(self, memory: MemoryEngine # Tests: document upsert via retain pipeline (regression for orphan observations) # --------------------------------------------------------------------------- + class TestDocumentUpsertObservationCleanup: """Regression: re-ingesting a document via the retain pipeline must clean up observations derived from the outgoing memory_units, the same way the @@ -360,9 +361,7 @@ async def test_upsert_document_removes_observations_from_outgoing_memories( # be reset for re-consolidation since one of its observations was # invalidated by the upsert. consolidated_at = await _get_consolidated_at(conn, standalone_mem) - assert consolidated_at is None, ( - "Surviving co-source memory should be reset for re-consolidation" - ) + assert consolidated_at is None, "Surviving co-source memory should be reset for re-consolidation" # The two doc-scoped memories are gone via FK cascade. doc_mem_count = await conn.fetchval( diff --git a/hindsight-api-slim/tests/test_observation_scope_limit_resolution.py b/hindsight-api-slim/tests/test_observation_scope_limit_resolution.py new file mode 100644 index 0000000000..c95656a7cd --- /dev/null +++ b/hindsight-api-slim/tests/test_observation_scope_limit_resolution.py @@ -0,0 +1,156 @@ +"""Unit tests for per-scope observation-limit resolution. + +These cover the three pure helpers behind the ``observation_scope_limits`` +config field, which lets a bank cap observations differently per consolidation +scope (e.g. one tag's scope unlimited, while scopes that also carry a wildcard +tag are capped): + +- ``_scope_matches_globs`` — exact-cover match between a glob pattern and a + concrete tag set (the crux: ``{a}`` and ``{run_1, a}`` must resolve to + *different* rules even though both contain ``a``). +- ``_parse_scope_limit_rules`` — defensive parsing of the raw JSON config. +- ``_effective_scope_limit`` — first-match-wins resolution with fallback to the + bank-wide ``max_observations_per_scope``. + +All deterministic — direct asserts, no LLM. +""" + +from types import SimpleNamespace + +import pytest + +from hindsight_api.engine.consolidation.consolidator import ( + _effective_scope_limit, + _parse_scope_limit_rules, + _scope_matches_globs, + _ScopeLimitRule, +) + + +def _config(scope_limits, default=50): + """A minimal stand-in for the resolved HindsightConfig fields we read.""" + return SimpleNamespace( + observation_scope_limits=scope_limits, + max_observations_per_scope=default, + ) + + +# --------------------------------------------------------------------------- +# _scope_matches_globs — exact cover (every tag covered, every glob used) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "globs,tags,expected", + [ + # Literal single-tag scope: matches only the exact set. + (("shared",), ["shared"], True), + (("shared",), ["run_1", "shared"], False), # run_1 uncovered + (("shared",), [], False), # untagged never matches + # Wildcard + literal combined scope. + (("run_*", "shared"), ["run_1", "shared"], True), + (("run_*", "shared"), ["shared"], False), # run_* glob is vacuous + (("run_*", "shared"), ["run_1"], False), # shared glob is vacuous + (("run_*", "shared"), ["run_1", "shared", "extra"], False), # extra uncovered + # One glob may cover several tags (still exact cover). + (("run_*", "shared"), ["run_1", "run_2", "shared"], True), + # Catch-all single glob matches any tagged scope but not the untagged one. + (("*",), ["anything"], True), + (("*",), ["a", "b"], True), + (("*",), [], False), + # Matching is case-sensitive. + (("SHARED",), ["shared"], False), + ], +) +def test_scope_matches_globs_exact_cover(globs, tags, expected): + assert _scope_matches_globs(globs, tags) is expected + + +def test_scope_matches_globs_is_order_independent(): + # Tags are a set; pattern order must not change the verdict. + assert _scope_matches_globs(("run_*", "shared"), ["shared", "run_9"]) is True + assert _scope_matches_globs(("shared", "run_*"), ["run_9", "shared"]) is True + + +# --------------------------------------------------------------------------- +# _parse_scope_limit_rules — defensive, order-preserving +# --------------------------------------------------------------------------- + + +def test_parse_rules_happy_path_preserves_order(): + raw = [ + {"scope": ["shared"], "limit": -1}, + {"scope": ["run_*", "shared"], "limit": 1}, + ] + rules = _parse_scope_limit_rules(raw) + assert rules == [ + _ScopeLimitRule(globs=("shared",), limit=-1), + _ScopeLimitRule(globs=("run_*", "shared"), limit=1), + ] + + +@pytest.mark.parametrize("raw", [None, "not-a-list", 42, {}, {"scope": ["shared"], "limit": 1}]) +def test_parse_rules_non_list_yields_empty(raw): + assert _parse_scope_limit_rules(raw) == [] + + +@pytest.mark.parametrize( + "entry", + [ + "string-entry", # not a dict + {"limit": 1}, # missing scope + {"scope": ["a"]}, # missing limit + {"scope": [], "limit": 1}, # empty scope + {"scope": "a", "limit": 1}, # scope not a list + {"scope": ["a", 7], "limit": 1}, # non-str glob + {"scope": ["a", ""], "limit": 1}, # empty glob string + {"scope": ["a"], "limit": "1"}, # limit not an int + {"scope": ["a"], "limit": True}, # bool masquerading as int + ], +) +def test_parse_rules_skips_malformed_entries(entry): + # Malformed entries are dropped; a following valid entry still parses. + raw = [entry, {"scope": ["ok"], "limit": 3}] + assert _parse_scope_limit_rules(raw) == [_ScopeLimitRule(globs=("ok",), limit=3)] + + +# --------------------------------------------------------------------------- +# _effective_scope_limit — first match wins, else bank default +# --------------------------------------------------------------------------- + + +def test_effective_limit_literal_vs_wildcard_scope(): + """Literal scope unlimited, wildcard+literal scope capped, everything else default.""" + config = _config( + [ + {"scope": ["shared"], "limit": -1}, + {"scope": ["run_*", "shared"], "limit": 1}, + ], + default=50, + ) + assert _effective_scope_limit(config, ["shared"]) == -1 + assert _effective_scope_limit(config, ["run_42", "shared"]) == 1 + assert _effective_scope_limit(config, ["some_other_tag"]) == 50 # fallback + assert _effective_scope_limit(config, []) == 50 # untagged → fallback (no rule matches) + + +def test_effective_limit_first_match_wins(): + # A broad catch-all placed first shadows a more specific later rule. + config = _config( + [ + {"scope": ["*"], "limit": 5}, + {"scope": ["shared"], "limit": -1}, + ], + default=50, + ) + assert _effective_scope_limit(config, ["shared"]) == 5 + + +def test_effective_limit_falls_back_when_no_rules(): + assert _effective_scope_limit(_config(None, default=7), ["shared"]) == 7 + assert _effective_scope_limit(_config([], default=7), ["shared"]) == 7 + + +def test_effective_limit_none_config_is_unlimited(): + # Mirrors the old `config is None` branch at the call site. + assert _effective_scope_limit(None, ["shared"]) == -1 diff --git a/hindsight-api-slim/tests/test_observations.py b/hindsight-api-slim/tests/test_observations.py index 73f99227eb..7749b717a4 100644 --- a/hindsight-api-slim/tests/test_observations.py +++ b/hindsight-api-slim/tests/test_observations.py @@ -5,6 +5,7 @@ not as separate memory_units. The observations list in EntityState is populated from the summary for backwards compatibility. """ + import pytest from hindsight_api.engine.memory_engine import Budget from hindsight_api import RequestContext @@ -64,7 +65,7 @@ async def test_entity_extraction_on_retain(memory, request_context): WHERE bank_id = $1 AND LOWER(canonical_name) LIKE '%john%' LIMIT 1 """, - bank_id + bank_id, ) # Check the fact count for this entity @@ -73,7 +74,7 @@ async def test_entity_extraction_on_retain(memory, request_context): """ SELECT COUNT(*) FROM unit_entities WHERE entity_id = $1 """, - entity_row['id'] + entity_row["id"], ) print(f"\n=== Entity Facts ===") print(f"Entity: {entity_row['canonical_name']} has {fact_count} linked facts") @@ -175,7 +176,7 @@ async def test_observation_fact_type_in_database(memory, request_context, disabl FROM memory_units WHERE bank_id = $1 AND fact_type = 'observation' """, - bank_id + bank_id, ) print(f"\n=== Observation Records in memory_units ===") @@ -243,7 +244,7 @@ async def test_entity_mention_counts(memory, request_context): WHERE e.bank_id = $1 ORDER BY e.mention_count DESC """, - bank_id + bank_id, ) print(f"\n=== Entity Mention Counts Test ===") @@ -253,8 +254,8 @@ async def test_entity_mention_counts(memory, request_context): low_mention_entity = None for entity in entities: - name = entity['canonical_name'].lower() - mention_count = entity['mention_count'] + name = entity["canonical_name"].lower() + mention_count = entity["mention_count"] print(f" {entity['canonical_name']}: mentions={mention_count}") @@ -268,8 +269,9 @@ async def test_entity_mention_counts(memory, request_context): assert low_mention_entity is not None, "Trivex entity should exist" # Nexora (10 mentions) must rank higher than Trivex (1 mention) - assert high_mention_entity['mention_count'] > low_mention_entity['mention_count'], \ + assert high_mention_entity["mention_count"] > low_mention_entity["mention_count"], ( f"Nexora ({high_mention_entity['mention_count']} mentions) should have more than Trivex ({low_mention_entity['mention_count']} mentions)" + ) finally: # Cleanup @@ -297,7 +299,7 @@ async def test_entity_mention_ranking(memory, request_context): for i in range(6): await memory.retain_async( bank_id=bank_id, - content=f"Alice is mentioned here in fact {i+1}.", + content=f"Alice is mentioned here in fact {i + 1}.", context="test", event_date=datetime(2024, 1, 1 + i, tzinfo=timezone.utc), request_context=request_context, @@ -310,7 +312,7 @@ async def test_entity_mention_ranking(memory, request_context): for mention in range(10): await memory.retain_async( bank_id=bank_id, - content=f"{entity_name} is a very important entity, mention {mention+1}.", + content=f"{entity_name} is a very important entity, mention {mention + 1}.", context="test", event_date=datetime(2024, 2, 1 + mention, tzinfo=timezone.utc), request_context=request_context, @@ -329,7 +331,7 @@ async def test_entity_mention_ranking(memory, request_context): WHERE bank_id = $1 ORDER BY mention_count DESC """, - bank_id + bank_id, ) print(f"\nAll entities by mention count:") @@ -337,15 +339,15 @@ async def test_entity_mention_ranking(memory, request_context): print(f" {e['canonical_name']}: mentions={e['mention_count']}") # Verify high-mention entities rank higher than Alice (6 mentions) - alice = next((e for e in all_entities if 'alice' in e['canonical_name'].lower()), None) - high_mention = [e for e in all_entities if e['canonical_name'].lower() in ('bruno', 'carlos', 'diana')] + alice = next((e for e in all_entities if "alice" in e["canonical_name"].lower()), None) + high_mention = [e for e in all_entities if e["canonical_name"].lower() in ("bruno", "carlos", "diana")] assert alice is not None, "Alice entity should exist" assert len(high_mention) > 0, "High-mention entities should exist" # Entities with 10 mentions each should rank higher than Alice (6 mentions) for entity in high_mention: - assert entity['mention_count'] > alice['mention_count'], ( + assert entity["mention_count"] > alice["mention_count"], ( f"{entity['canonical_name']} ({entity['mention_count']} mentions) " f"should rank higher than Alice ({alice['mention_count']} mentions)" ) @@ -407,7 +409,7 @@ async def test_user_entity_extraction(memory_real_llm, request_context): AND LOWER(e.canonical_name) LIKE '%user%' LIMIT 1 """, - bank_id + bank_id, ) # Get all entities with their fact counts @@ -421,7 +423,7 @@ async def test_user_entity_extraction(memory_real_llm, request_context): WHERE e.bank_id = $1 ORDER BY fact_count DESC """, - bank_id + bank_id, ) print(f"\n=== Entities by Mention Count ===") diff --git a/hindsight-api-slim/tests/test_onnx_embeddings.py b/hindsight-api-slim/tests/test_onnx_embeddings.py index 7e0c47b066..3771d3e76b 100644 --- a/hindsight-api-slim/tests/test_onnx_embeddings.py +++ b/hindsight-api-slim/tests/test_onnx_embeddings.py @@ -139,7 +139,9 @@ async def test_onnx_embeddings_dimension_mismatch_raises_value_error(): tokenizer_name_or_path="/models/e5", dimensions=3, ) - fake_transformers = SimpleNamespace(AutoTokenizer=SimpleNamespace(from_pretrained=MagicMock(return_value=FakeTokenizer()))) + fake_transformers = SimpleNamespace( + AutoTokenizer=SimpleNamespace(from_pretrained=MagicMock(return_value=FakeTokenizer())) + ) fake_onnxruntime = SimpleNamespace(InferenceSession=MagicMock(return_value=FakeOnnxSession())) with patch.dict(sys.modules, {"transformers": fake_transformers, "onnxruntime": fake_onnxruntime}): @@ -153,7 +155,9 @@ async def test_onnx_embeddings_downloads_external_data_sidecar_when_needed(): download = MagicMock(return_value="/hf/bge-m3") session = MagicMock(return_value=FakeOnnxSession()) fake_hf = SimpleNamespace(snapshot_download=download) - fake_transformers = SimpleNamespace(AutoTokenizer=SimpleNamespace(from_pretrained=MagicMock(return_value=FakeTokenizer()))) + fake_transformers = SimpleNamespace( + AutoTokenizer=SimpleNamespace(from_pretrained=MagicMock(return_value=FakeTokenizer())) + ) fake_onnxruntime = SimpleNamespace(InferenceSession=session) with patch.dict( diff --git a/hindsight-api-slim/tests/test_op_cancellation.py b/hindsight-api-slim/tests/test_op_cancellation.py index 2bdc8587d4..0914b76fcb 100644 --- a/hindsight-api-slim/tests/test_op_cancellation.py +++ b/hindsight-api-slim/tests/test_op_cancellation.py @@ -190,9 +190,7 @@ async def test_mark_failed_does_not_raise_when_row_missing(self, memory: MemoryE await memory._mark_operation_failed(missing_id, "some error", "traceback here") # no exception @pytest.mark.asyncio - async def test_mark_completed_and_fire_webhook_does_not_raise_when_row_missing( - self, memory: MemoryEngine - ): + async def test_mark_completed_and_fire_webhook_does_not_raise_when_row_missing(self, memory: MemoryEngine): missing_id = str(uuid.uuid4()) await memory._mark_operation_completed_and_fire_webhook( operation_id=missing_id, @@ -265,9 +263,7 @@ async def _fake_check(operation_id: str) -> bool: class TestRetainCheckpoint: @pytest.mark.asyncio - async def test_retain_stops_between_sub_batches_when_cancelled( - self, memory: MemoryEngine, request_context - ): + async def test_retain_stops_between_sub_batches_when_cancelled(self, memory: MemoryEngine, request_context): """retain_batch_async returns partial results if _check_op_alive is False between sub-batches.""" from hindsight_api.config import _get_raw_config @@ -290,9 +286,7 @@ async def _fake_check(operation_id: str) -> bool: # Cancel after the first sub-batch completes return check_calls <= 1 - contents = [ - {"content": f"Memory item {i} about something interesting."} for i in range(4) - ] + contents = [{"content": f"Memory item {i} about something interesting."} for i in range(4)] with patch.object(memory, "_check_op_alive", side_effect=_fake_check): result = await memory.retain_batch_async( diff --git a/hindsight-api-slim/tests/test_operation_progress.py b/hindsight-api-slim/tests/test_operation_progress.py index 947e380be7..7c3b5ed4f4 100644 --- a/hindsight-api-slim/tests/test_operation_progress.py +++ b/hindsight-api-slim/tests/test_operation_progress.py @@ -141,7 +141,11 @@ async def test_progress_surfaced_via_get_and_list(api_client, memory: MemoryEngi @pytest.mark.asyncio async def test_progress_absent_returns_null(api_client, memory: MemoryEngine): - """Operations that never reached a checkpoint expose progress=null (shape unchanged).""" + """Operations that never reached a checkpoint expose no progress value. + + The field is omitted from the JSON when null (responses drop null fields), so + ``.get("progress")`` is None whether the key is absent or explicitly null. + """ bank_id = f"op_progress_none_{uuid.uuid4().hex[:8]}" pool = memory._pool await _ensure_bank(pool, bank_id) @@ -149,11 +153,11 @@ async def test_progress_absent_returns_null(api_client, memory: MemoryEngine): get_resp = await api_client.get(f"/v1/default/banks/{bank_id}/operations/{op_id}") assert get_resp.status_code == 200 - assert get_resp.json()["progress"] is None + assert get_resp.json().get("progress") is None list_resp = await api_client.get(f"/v1/default/banks/{bank_id}/operations") op = next(o for o in list_resp.json()["operations"] if o["id"] == op_id) - assert op["progress"] is None + assert op.get("progress") is None @pytest.mark.asyncio diff --git a/hindsight-api-slim/tests/test_operation_status.py b/hindsight-api-slim/tests/test_operation_status.py index a35b12f079..7ef7108743 100644 --- a/hindsight-api-slim/tests/test_operation_status.py +++ b/hindsight-api-slim/tests/test_operation_status.py @@ -7,6 +7,7 @@ - Cancel used to delete the operation row; now it sets status to 'cancelled'. - Retry now accepts both 'failed' and 'cancelled' operations. """ + import uuid from datetime import datetime @@ -124,9 +125,7 @@ async def test_get_operation_returns_processing_status(api_client, memory, test_ processing_id = await _insert_operation(pool, test_bank_id, "processing") - response = await api_client.get( - f"/v1/default/banks/{test_bank_id}/operations/{processing_id}" - ) + response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations/{processing_id}") assert response.status_code == 200 data = response.json() assert data["status"] == "processing" @@ -154,9 +153,7 @@ async def test_all_statuses_returned_correctly(api_client, memory, test_bank_id) # Verify get endpoint for each for status, op_id in ids.items(): - response = await api_client.get( - f"/v1/default/banks/{test_bank_id}/operations/{op_id}" - ) + response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations/{op_id}") assert response.status_code == 200 assert response.json()["status"] == status, f"Get: expected {status} for {op_id}" @@ -170,16 +167,12 @@ async def test_cancel_sets_cancelled_status(api_client, memory, test_bank_id): op_id = await _insert_operation(pool, test_bank_id, "pending") # Cancel the operation - response = await api_client.delete( - f"/v1/default/banks/{test_bank_id}/operations/{op_id}" - ) + response = await api_client.delete(f"/v1/default/banks/{test_bank_id}/operations/{op_id}") assert response.status_code == 200 assert response.json()["success"] is True # Verify the operation still exists with 'cancelled' status - response = await api_client.get( - f"/v1/default/banks/{test_bank_id}/operations/{op_id}" - ) + response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations/{op_id}") assert response.status_code == 200 assert response.json()["status"] == "cancelled" @@ -203,16 +196,12 @@ async def test_retry_cancelled_operation(api_client, memory, test_bank_id): op_id = await _insert_operation(pool, test_bank_id, "cancelled") # Retry the cancelled operation - response = await api_client.post( - f"/v1/default/banks/{test_bank_id}/operations/{op_id}/retry" - ) + response = await api_client.post(f"/v1/default/banks/{test_bank_id}/operations/{op_id}/retry") assert response.status_code == 200 assert response.json()["success"] is True # Verify the operation is now pending - response = await api_client.get( - f"/v1/default/banks/{test_bank_id}/operations/{op_id}" - ) + response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations/{op_id}") assert response.status_code == 200 assert response.json()["status"] == "pending" @@ -225,9 +214,7 @@ async def test_retry_rejects_non_retriable_statuses(api_client, memory, test_ban for status in ("pending", "processing", "completed"): op_id = await _insert_operation(pool, test_bank_id, status) - response = await api_client.post( - f"/v1/default/banks/{test_bank_id}/operations/{op_id}/retry" - ) + response = await api_client.post(f"/v1/default/banks/{test_bank_id}/operations/{op_id}/retry") assert response.status_code == 409, f"Expected 409 for {status}, got {response.status_code}" @@ -239,7 +226,5 @@ async def test_cancel_rejects_non_pending_operations(api_client, memory, test_ba for status in ("processing", "completed", "failed"): op_id = await _insert_operation(pool, test_bank_id, status) - response = await api_client.delete( - f"/v1/default/banks/{test_bank_id}/operations/{op_id}" - ) + response = await api_client.delete(f"/v1/default/banks/{test_bank_id}/operations/{op_id}") assert response.status_code == 409, f"Expected 409 for {status}, got {response.status_code}" diff --git a/hindsight-api-slim/tests/test_oracle_http_integration.py b/hindsight-api-slim/tests/test_oracle_http_integration.py index cbb3741881..23fcfc3df7 100644 --- a/hindsight-api-slim/tests/test_oracle_http_integration.py +++ b/hindsight-api-slim/tests/test_oracle_http_integration.py @@ -27,6 +27,7 @@ # Fixtures # --------------------------------------------------------------------------- + def _bank_id(prefix: str = "http") -> str: return f"test-{prefix}-{uuid.uuid4().hex[:8]}" @@ -188,11 +189,7 @@ async def test_http_memory_crud(self, api_client: httpx.AsyncClient): # Retain resp = await api_client.post( f"/v1/default/banks/{bank_id}/memories", - json={ - "items": [ - {"content": "Memory CRUD via HTTP on Oracle.", "context": "test"} - ] - }, + json={"items": [{"content": "Memory CRUD via HTTP on Oracle.", "context": "test"}]}, ) assert resp.status_code == 200 @@ -268,9 +265,7 @@ async def test_http_directives(self, api_client: httpx.AsyncClient): # Delete if directive_id: - resp = await api_client.delete( - f"/v1/default/banks/{bank_id}/directives/{directive_id}" - ) + resp = await api_client.delete(f"/v1/default/banks/{bank_id}/directives/{directive_id}") assert resp.status_code == 200 finally: await _safe_http_cleanup(api_client, bank_id) @@ -306,11 +301,7 @@ async def test_http_operations(self, api_client: httpx.AsyncClient): try: await api_client.post( f"/v1/default/banks/{bank_id}/memories", - json={ - "items": [ - {"content": "Operations tracking test.", "context": "test"} - ] - }, + json={"items": [{"content": "Operations tracking test.", "context": "test"}]}, ) resp = await api_client.get(f"/v1/default/banks/{bank_id}/operations") assert resp.status_code == 200 @@ -445,9 +436,7 @@ async def test_full_lifecycle(self, api_client: httpx.AsyncClient): # --- 5. Verify the operation completed (not stuck as 'pending') --- if operation_id: - resp = await api_client.get( - f"/v1/default/banks/{bank_id}/operations/{operation_id}" - ) + resp = await api_client.get(f"/v1/default/banks/{bank_id}/operations/{operation_id}") if resp.status_code == 200: op = resp.json() # SyncTaskBackend should have completed the refresh inline @@ -456,9 +445,7 @@ async def test_full_lifecycle(self, api_client: httpx.AsyncClient): ) # --- 6. Verify the mental model has real content (not placeholder) --- - resp = await api_client.get( - f"/v1/default/banks/{bank_id}/mental-models/{mental_model_id}" - ) + resp = await api_client.get(f"/v1/default/banks/{bank_id}/mental-models/{mental_model_id}") assert resp.status_code == 200, f"Get mental model failed: {resp.text}" mm = resp.json() content = mm.get("content", "") diff --git a/hindsight-api-slim/tests/test_oracle_integration.py b/hindsight-api-slim/tests/test_oracle_integration.py index f144508d30..841d10d9fa 100644 --- a/hindsight-api-slim/tests/test_oracle_integration.py +++ b/hindsight-api-slim/tests/test_oracle_integration.py @@ -28,6 +28,7 @@ # Helpers # --------------------------------------------------------------------------- + def _bank_id(prefix: str = "oracle") -> str: return f"test-{prefix}-{uuid.uuid4().hex[:8]}" @@ -136,21 +137,25 @@ async def test_recall_with_filters(self, oracle_memory: MemoryEngine, request_co try: await oracle_memory.retain_batch_async( bank_id=bank_id, - contents=[{ - "content": "Dan is an expert in distributed systems.", - "context": "engineering", - "event_date": datetime(2024, 5, 1, tzinfo=timezone.utc), - }], + contents=[ + { + "content": "Dan is an expert in distributed systems.", + "context": "engineering", + "event_date": datetime(2024, 5, 1, tzinfo=timezone.utc), + } + ], document_tags=["backend"], request_context=request_context, ) await oracle_memory.retain_batch_async( bank_id=bank_id, - contents=[{ - "content": "Eve designs beautiful user interfaces.", - "context": "design", - "event_date": datetime(2024, 5, 2, tzinfo=timezone.utc), - }], + contents=[ + { + "content": "Eve designs beautiful user interfaces.", + "context": "design", + "event_date": datetime(2024, 5, 2, tzinfo=timezone.utc), + } + ], document_tags=["frontend"], request_context=request_context, ) @@ -236,9 +241,7 @@ async def test_delete_memory(self, oracle_memory: MemoryEngine, request_context: memory_id = unit_ids[0] # Delete the memory - await oracle_memory.delete_memory_unit( - str(memory_id), request_context=request_context - ) + await oracle_memory.delete_memory_unit(str(memory_id), request_context=request_context) # Verify deletion mem = await oracle_memory.get_memory_unit( @@ -306,9 +309,7 @@ async def test_list_memories(self, oracle_memory: MemoryEngine, request_context: event_date=datetime(2024, 6, i + 1, tzinfo=timezone.utc), request_context=request_context, ) - memories = await oracle_memory.list_memory_units( - bank_id=bank_id, request_context=request_context - ) + memories = await oracle_memory.list_memory_units(bank_id=bank_id, request_context=request_context) # Each retain may extract multiple facts assert len(memories) >= 3 finally: @@ -361,7 +362,7 @@ async def test_retain_chunking(self, oracle_memory: MemoryEngine, request_contex "Leonardo da Vinci painted the Mona Lisa between 1503 and 1519, and it now hangs in the Louvre Museum in Paris.", "The International Space Station orbits Earth at an altitude of approximately 250 miles at a speed of 17,500 mph.", ] - long_content = " ".join(f"Section {i+1}: {fact} " * 3 for i, fact in enumerate(facts)) + long_content = " ".join(f"Section {i + 1}: {fact} " * 3 for i, fact in enumerate(facts)) unit_ids = await oracle_memory.retain_async( bank_id=bank_id, content=long_content, @@ -514,11 +515,13 @@ async def test_retain_with_tags(self, oracle_memory: MemoryEngine, request_conte try: await oracle_memory.retain_batch_async( bank_id=bank_id, - contents=[{ - "content": "Tagged memory about machine learning models.", - "context": "ml", - "event_date": datetime(2024, 6, 1, tzinfo=timezone.utc), - }], + contents=[ + { + "content": "Tagged memory about machine learning models.", + "context": "ml", + "event_date": datetime(2024, 6, 1, tzinfo=timezone.utc), + } + ], document_tags=["ml", "models"], request_context=request_context, ) @@ -654,7 +657,7 @@ async def test_fusion_ranking(self, oracle_memory: MemoryEngine, request_context for i in range(5): await oracle_memory.retain_async( bank_id=bank_id, - content=f"Fact {i}: Machine learning model {i} achieved {90+i}% accuracy.", + content=f"Fact {i}: Machine learning model {i} achieved {90 + i}% accuracy.", context="ml", event_date=datetime(2024, 1, i + 1, tzinfo=timezone.utc), request_context=request_context, @@ -818,9 +821,7 @@ async def test_mental_model_crud(self, oracle_memory: MemoryEngine, request_cont assert model["id"] is not None # List - models = await oracle_memory.list_mental_models( - bank_id=bank_id, request_context=request_context - ) + models = await oracle_memory.list_mental_models(bank_id=bank_id, request_context=request_context) assert len(models) > 0 # Get @@ -899,16 +900,12 @@ async def test_consolidation(self, oracle_memory: MemoryEngine, request_context: ) # Verify facts were stored (consolidation is async and may run inline # via SyncTaskBackend, but the key assertion is that all 5 retains persisted) - memories = await oracle_memory.list_memory_units( - bank_id=bank_id, request_context=request_context - ) + memories = await oracle_memory.list_memory_units(bank_id=bank_id, request_context=request_context) items = memories.get("items", memories) if isinstance(memories, dict) else memories assert len(items) >= 5, f"Expected at least 5 stored memories, got {len(items)}" # Verify facts contain expected content texts = [item.get("text", "") for item in items] - assert any("dark mode" in t for t in texts), ( - f"Expected 'dark mode' in stored facts, got: {texts[:3]}" - ) + assert any("dark mode" in t for t in texts), f"Expected 'dark mode' in stored facts, got: {texts[:3]}" finally: await _safe_cleanup(oracle_memory, bank_id, request_context) @@ -923,9 +920,7 @@ async def test_operations_tracking(self, oracle_memory: MemoryEngine, request_co event_date=datetime(2024, 6, 1, tzinfo=timezone.utc), request_context=request_context, ) - ops = await oracle_memory.list_operations( - bank_id=bank_id, request_context=request_context - ) + ops = await oracle_memory.list_operations(bank_id=bank_id, request_context=request_context) # Retain creates async operations (consolidation at minimum) assert ops is not None items = ops.get("items", ops) if isinstance(ops, dict) else ops @@ -948,9 +943,7 @@ async def test_directives_crud(self, oracle_memory: MemoryEngine, request_contex ) assert directive is not None - directives = await oracle_memory.list_directives( - bank_id=bank_id, request_context=request_context - ) + directives = await oracle_memory.list_directives(bank_id=bank_id, request_context=request_context) assert len(directives) > 0 await oracle_memory.delete_directive( @@ -967,17 +960,17 @@ async def test_list_tags(self, oracle_memory: MemoryEngine, request_context: Req try: await oracle_memory.retain_batch_async( bank_id=bank_id, - contents=[{ - "content": "Tag listing test.", - "context": "test", - "event_date": datetime(2024, 6, 1, tzinfo=timezone.utc), - }], + contents=[ + { + "content": "Tag listing test.", + "context": "test", + "event_date": datetime(2024, 6, 1, tzinfo=timezone.utc), + } + ], document_tags=["alpha", "beta"], request_context=request_context, ) - tags = await oracle_memory.list_tags( - bank_id=bank_id, request_context=request_context - ) + tags = await oracle_memory.list_tags(bank_id=bank_id, request_context=request_context) assert len(tags) > 0 finally: await _safe_cleanup(oracle_memory, bank_id, request_context) @@ -994,9 +987,7 @@ async def test_task_queue(self, oracle_memory: MemoryEngine, request_context: Re event_date=datetime(2024, 6, 1, tzinfo=timezone.utc), request_context=request_context, ) - ops = await oracle_memory.list_operations( - bank_id=bank_id, request_context=request_context - ) + ops = await oracle_memory.list_operations(bank_id=bank_id, request_context=request_context) assert ops is not None items = ops.get("items", ops) if isinstance(ops, dict) else ops assert len(items) > 0, "Retain should enqueue at least one task" @@ -1098,9 +1089,7 @@ async def test_oracle_json_operations(self, oracle_memory: MemoryEngine, request mission="Test JSON CLOB storage", request_context=request_context, ) - profile = await oracle_memory.get_bank_profile( - bank_id=bank_id, request_context=request_context - ) + profile = await oracle_memory.get_bank_profile(bank_id=bank_id, request_context=request_context) assert profile is not None assert profile["name"] == "JSON Test Bank" assert profile["mission"] == "Test JSON CLOB storage" @@ -1167,9 +1156,7 @@ async def test_empty_context(self, oracle_memory: MemoryEngine, request_context: event_date=datetime(2024, 1, 1, tzinfo=timezone.utc), request_context=request_context, ) - memories = await oracle_memory.list_memory_units( - bank_id=bank_id, request_context=request_context - ) + memories = await oracle_memory.list_memory_units(bank_id=bank_id, request_context=request_context) items = memories.get("items", memories) if isinstance(memories, dict) else memories assert len(items) >= 1 finally: @@ -1197,19 +1184,13 @@ async def test_large_content_chunking(self, oracle_memory: MemoryEngine, request event_date=datetime(2024, 1, 1, tzinfo=timezone.utc), request_context=request_context, ) - memories = await oracle_memory.list_memory_units( - bank_id=bank_id, request_context=request_context - ) + memories = await oracle_memory.list_memory_units(bank_id=bank_id, request_context=request_context) items = memories.get("items", memories) if isinstance(memories, dict) else memories # Large content (~10KB, 50 paragraphs) should produce multiple memory units # from LLM fact extraction. At minimum we expect several facts. - assert len(items) >= 3, ( - f"Expected large content to produce at least 3 memory units, got {len(items)}" - ) + assert len(items) >= 3, f"Expected large content to produce at least 3 memory units, got {len(items)}" # Verify operations completed without errors (catches background datetime issues etc.) - ops = await oracle_memory.list_operations( - bank_id=bank_id, request_context=request_context - ) + ops = await oracle_memory.list_operations(bank_id=bank_id, request_context=request_context) if ops: op_list = ops.get("items", ops) if isinstance(ops, dict) else ops failed = [o for o in op_list if isinstance(o, dict) and o.get("status") == "failed"] @@ -1272,19 +1253,13 @@ async def test_concurrent_retains(self, oracle_memory: MemoryEngine, request_con # not a code bug. Allow up to 1 deadlock failure. deadlocks = [r for r in results if isinstance(r, Exception) and "ORA-00060" in str(r)] other_failures = [r for r in results if isinstance(r, Exception) and "ORA-00060" not in str(r)] - assert len(other_failures) == 0, ( - f"Non-deadlock failures: {[str(e)[:100] for e in other_failures]}" - ) + assert len(other_failures) == 0, f"Non-deadlock failures: {[str(e)[:100] for e in other_failures]}" successes = len(results) - len(deadlocks) assert successes >= 2, f"Expected at least 2 successful retains, got {successes}" - memories = await oracle_memory.list_memory_units( - bank_id=bank_id, request_context=request_context - ) + memories = await oracle_memory.list_memory_units(bank_id=bank_id, request_context=request_context) items = memories.get("items", memories) if isinstance(memories, dict) else memories - assert len(items) >= successes, ( - f"Expected at least {successes} memories, got {len(items)}" - ) + assert len(items) >= successes, f"Expected at least {successes} memories, got {len(items)}" finally: await _safe_cleanup(oracle_memory, bank_id, request_context) @@ -1311,9 +1286,7 @@ async def test_recall_empty_bank(self, oracle_memory: MemoryEngine, request_cont async def test_delete_nonexistent_bank(self, oracle_memory: MemoryEngine, request_context: RequestContext): """Verify deleting a non-existent bank doesn't raise.""" # Should not raise an exception - await oracle_memory.delete_bank( - f"nonexistent-{uuid.uuid4().hex[:8]}", request_context=request_context - ) + await oracle_memory.delete_bank(f"nonexistent-{uuid.uuid4().hex[:8]}", request_context=request_context) @pytest.mark.asyncio async def test_retain_and_delete_cycle(self, oracle_memory: MemoryEngine, request_context: RequestContext): @@ -1369,19 +1342,13 @@ async def test_multiple_documents_same_bank(self, oracle_memory: MemoryEngine, r request_context=request_context, ) - docs = await oracle_memory.list_documents( - bank_id=bank_id, request_context=request_context - ) + docs = await oracle_memory.list_documents(bank_id=bank_id, request_context=request_context) items = docs.get("items", docs.get("documents", [])) assert len(items) >= 3 # Delete one document, verify others remain - await oracle_memory.delete_document( - bank_id=bank_id, document_id="doc-1", request_context=request_context - ) - docs_after = await oracle_memory.list_documents( - bank_id=bank_id, request_context=request_context - ) + await oracle_memory.delete_document(bank_id=bank_id, document_id="doc-1", request_context=request_context) + docs_after = await oracle_memory.list_documents(bank_id=bank_id, request_context=request_context) items_after = docs_after.get("items", docs_after.get("documents", [])) assert len(items_after) >= 2 finally: diff --git a/hindsight-api-slim/tests/test_prompt_brace_escape.py b/hindsight-api-slim/tests/test_prompt_brace_escape.py index 6dd6016f8b..c461e9e5db 100644 --- a/hindsight-api-slim/tests/test_prompt_brace_escape.py +++ b/hindsight-api-slim/tests/test_prompt_brace_escape.py @@ -63,9 +63,7 @@ def test_capacity_note_with_braces_renders(self): ) note = "Use shape {limit, used}" - prompt = build_batch_consolidation_prompt( - observations_mission="m", observation_capacity_note=note - ) + prompt = build_batch_consolidation_prompt(observations_mission="m", observation_capacity_note=note) rendered = prompt.format(facts_text="", observations_text="") assert "{limit, used}" in rendered diff --git a/hindsight-api-slim/tests/test_prompt_length_retries.py b/hindsight-api-slim/tests/test_prompt_length_retries.py new file mode 100644 index 0000000000..18fcdf06ba --- /dev/null +++ b/hindsight-api-slim/tests/test_prompt_length_retries.py @@ -0,0 +1,46 @@ +"""Prompt-length 400s follow the normal APIStatusError retry path.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from openai import APIStatusError + +from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM + + +def _llm() -> OpenAICompatibleLLM: + return OpenAICompatibleLLM( + provider="zai", + model="glm-5-turbo", + api_key="test", + base_url="https://example.com/v1", + ) + + +def _length_error() -> APIStatusError: + response = MagicMock() + response.status_code = 400 + response.text = '{"code": "1261", "message": "Prompt exceeds max length"}' + return APIStatusError( + "bad", + response=response, + body={"code": "1261", "message": "Prompt exceeds max length"}, + ) + + +@pytest.mark.asyncio +async def test_prompt_length_400_is_retried(): + llm = _llm() + create = AsyncMock(side_effect=_length_error()) + llm._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create))) + + with patch("hindsight_api.engine.providers.openai_compatible_llm.asyncio.sleep", new_callable=AsyncMock): + with pytest.raises(APIStatusError): + await llm.call( + messages=[{"role": "user", "content": "x"}], + scope="mental_model_delta_ops", + max_retries=2, + ) + + assert create.await_count == 3 diff --git a/hindsight-api-slim/tests/test_provider_default_models.py b/hindsight-api-slim/tests/test_provider_default_models.py index 270553bdbd..91cb41cf66 100644 --- a/hindsight-api-slim/tests/test_provider_default_models.py +++ b/hindsight-api-slim/tests/test_provider_default_models.py @@ -24,7 +24,9 @@ def test_provider_default_models(): config = HindsightConfig.from_env() assert config.llm_provider == provider, f"Provider mismatch for {provider}" - assert config.llm_model == expected_model, f"Expected {expected_model} for {provider}, got {config.llm_model}" + assert config.llm_model == expected_model, ( + f"Expected {expected_model} for {provider}, got {config.llm_model}" + ) finally: # Restore original env vars @@ -96,9 +98,9 @@ def test_per_operation_provider_default_model(): assert config.llm_model == "gpt-4o-mini", f"Expected gpt-4o-mini, got {config.llm_model}" # Retain should use Anthropic default - assert ( - config.retain_llm_model == "claude-haiku-4-5" - ), f"Expected claude-haiku-4-5, got {config.retain_llm_model}" + assert config.retain_llm_model == "claude-haiku-4-5", ( + f"Expected claude-haiku-4-5, got {config.retain_llm_model}" + ) finally: clear_config_cache() diff --git a/hindsight-api-slim/tests/test_provider_quota_reset_defer.py b/hindsight-api-slim/tests/test_provider_quota_reset_defer.py new file mode 100644 index 0000000000..703c7adfbb --- /dev/null +++ b/hindsight-api-slim/tests/test_provider_quota_reset_defer.py @@ -0,0 +1,110 @@ +"""Provider quota reset windows defer worker retries instead of failing retains.""" + +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from openai import APIStatusError + +from hindsight_api.engine.llm_interface import ProviderRateLimitResetError +from hindsight_api.engine.providers.openai_compatible_llm import OpenAICompatibleLLM + + +def _llm() -> OpenAICompatibleLLM: + return OpenAICompatibleLLM( + provider="zai", + model="glm-5-turbo", + api_key="test", + base_url="https://example.com/v1", + ) + + +def _usage_limit_error(reset_at: str) -> APIStatusError: + body = { + "code": "1308", + "message": f"Usage limit reached for 5 hour. Your limit will reset at {reset_at}", + } + response = MagicMock() + response.status_code = 429 + response.text = '{"code": "1308", "message": "usage limit"}' + response.headers = {} + return APIStatusError("rate limited", response=response, body=body) + + +def _short_retry_after_error() -> APIStatusError: + response = MagicMock() + response.status_code = 429 + response.text = '{"code": "rate_limit", "message": "retry shortly"}' + response.headers = {"retry-after": "1"} + return APIStatusError("rate limited", response=response, body={"message": "retry shortly"}) + + +@pytest.mark.asyncio +async def test_usage_limit_429_with_reset_defers_without_inner_retry() -> None: + llm = _llm() + reset_at = (datetime.now(UTC) + timedelta(hours=5)).replace(microsecond=0) + create = AsyncMock(side_effect=_usage_limit_error(reset_at.isoformat().replace("+00:00", "Z"))) + llm._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create))) + + with patch( + "hindsight_api.engine.providers.openai_compatible_llm.asyncio.sleep", + new_callable=AsyncMock, + ) as sleep: + with pytest.raises(ProviderRateLimitResetError) as exc_info: + await llm.call( + messages=[{"role": "user", "content": "x"}], + scope="retain_extract_facts", + max_retries=2, + ) + + assert create.await_count == 1 + sleep.assert_not_awaited() + assert abs((exc_info.value.retry_at - reset_at).total_seconds()) < 1 + assert "Provider quota exhausted" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_short_retry_after_429_uses_normal_retry_loop() -> None: + llm = _llm() + create = AsyncMock(side_effect=_short_retry_after_error()) + llm._client = SimpleNamespace(chat=SimpleNamespace(completions=SimpleNamespace(create=create))) + + with patch( + "hindsight_api.engine.providers.openai_compatible_llm.asyncio.sleep", + new_callable=AsyncMock, + ) as sleep: + with pytest.raises(APIStatusError): + await llm.call( + messages=[{"role": "user", "content": "x"}], + scope="retain_extract_facts", + max_retries=2, + max_backoff=60, + ) + + assert create.await_count == 3 + assert sleep.await_count == 2 + + +@pytest.mark.asyncio +async def test_extract_facts_from_text_preserves_provider_quota_reset(monkeypatch) -> None: + from hindsight_api.engine.retain import fact_extraction + + retry_at = (datetime.now(UTC) + timedelta(hours=2)).replace(microsecond=0) + + async def quota_limited_chunk(**_: object) -> None: + raise ProviderRateLimitResetError(retry_at=retry_at, message="quota resets later") + + monkeypatch.setattr(fact_extraction, "_extract_facts_with_auto_split", quota_limited_chunk) + + with pytest.raises(ProviderRateLimitResetError) as exc_info: + await fact_extraction.extract_facts_from_text( + text="Alice moved to Berlin.", + event_date=None, + llm_config=object(), + agent_name="TestAgent", + config=SimpleNamespace(retain_chunk_size=1000, retain_structured_chunk_size=None), + ) + + assert exc_info.value.retry_at == retry_at + assert "Fact extraction deferred by provider quota" in str(exc_info.value) diff --git a/hindsight-api-slim/tests/test_quality_integration.py b/hindsight-api-slim/tests/test_quality_integration.py index 5c8ef6c758..725c656b6b 100644 --- a/hindsight-api-slim/tests/test_quality_integration.py +++ b/hindsight-api-slim/tests/test_quality_integration.py @@ -151,7 +151,9 @@ def memory(self, memory_real_llm): return memory_real_llm @pytest.mark.asyncio - @pytest.mark.flaky(reruns=2, reruns_delay=2) + # Disposition is a subtle, judge-evaluated signal; 2 reruns still flaked in CI, + # so give this borderline comparison a little more margin. + @pytest.mark.flaky(reruns=3, reruns_delay=2) async def test_high_skepticism_response_is_more_hedged_than_low(self, memory: MemoryEngine, request_context): """Skepticism=5 should produce a measurably more hedged response than skepticism=1. diff --git a/hindsight-api-slim/tests/test_query_analyzer.py b/hindsight-api-slim/tests/test_query_analyzer.py index bd2c4f8d72..a1259a539e 100644 --- a/hindsight-api-slim/tests/test_query_analyzer.py +++ b/hindsight-api-slim/tests/test_query_analyzer.py @@ -1,9 +1,10 @@ """ Test query analyzer for temporal extraction. """ -import pytest + from datetime import datetime -from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer, QueryAnalysis + +import pytest def test_query_analyzer_june_2024(query_analyzer): @@ -266,6 +267,452 @@ def test_query_analyzer_few_days_ago(query_analyzer): assert analysis.temporal_constraint.end_date.day == 13 +@pytest.mark.parametrize( + ("query", "start", "end"), + [ + ("今天做了什么", datetime(2025, 1, 15), datetime(2025, 1, 15)), + ("本日记录", datetime(2025, 1, 15), datetime(2025, 1, 15)), + ("今天清晨的记录", datetime(2025, 1, 15), datetime(2025, 1, 15)), + ("今天能做什么", datetime(2025, 1, 15), datetime(2025, 1, 15)), + ("今天下雨了吗", datetime(2025, 1, 15), datetime(2025, 1, 15)), + ("昨天做了什么", datetime(2025, 1, 14), datetime(2025, 1, 14)), + ("昨天还说过什么", datetime(2025, 1, 14), datetime(2025, 1, 14)), + ("昨天紀錄", datetime(2025, 1, 14), datetime(2025, 1, 14)), + ("昨天傍晚发生了什么", datetime(2025, 1, 14), datetime(2025, 1, 14)), + ("昨天說了什麼", datetime(2025, 1, 14), datetime(2025, 1, 14)), + ("昨天帮我做了什么", datetime(2025, 1, 14), datetime(2025, 1, 14)), + ("这周有哪些会议", datetime(2025, 1, 13), datetime(2025, 1, 19)), + ("這週有哪些會議", datetime(2025, 1, 13), datetime(2025, 1, 19)), + ("這週以內的記錄", datetime(2025, 1, 13), datetime(2025, 1, 19)), + ("本周有哪些会议", datetime(2025, 1, 13), datetime(2025, 1, 19)), + ("这个月的费用", datetime(2025, 1, 1), datetime(2025, 1, 31)), + ("這個月的費用", datetime(2025, 1, 1), datetime(2025, 1, 31)), + ("這個月期間的費用", datetime(2025, 1, 1), datetime(2025, 1, 31)), + ("这一个月的费用", datetime(2025, 1, 1), datetime(2025, 1, 31)), + ("本月的费用", datetime(2025, 1, 1), datetime(2025, 1, 31)), + ("本月中了奖", datetime(2025, 1, 1), datetime(2025, 1, 31)), + ("本月经费", datetime(2025, 1, 1), datetime(2025, 1, 31)), + ("本月資料", datetime(2025, 1, 1), datetime(2025, 1, 31)), + ("本月報告", datetime(2025, 1, 1), datetime(2025, 1, 31)), + ("本月工资", datetime(2025, 1, 1), datetime(2025, 1, 31)), + ("本月收入多少", datetime(2025, 1, 1), datetime(2025, 1, 31)), + ("月初的事", datetime(2025, 1, 1), datetime(2025, 1, 10)), + ("上旬的记录", datetime(2025, 1, 1), datetime(2025, 1, 10)), + ("中旬的记录", datetime(2025, 1, 11), datetime(2025, 1, 20)), + ("月底的安排", datetime(2025, 1, 21), datetime(2025, 1, 31)), + ("月尾的安排", datetime(2025, 1, 21), datetime(2025, 1, 31)), + ("今年讨论过什么", datetime(2025, 1, 1), datetime(2025, 12, 31)), + ("今年初的计划", datetime(2025, 1, 1), datetime(2025, 1, 31)), + ("当日安排", datetime(2025, 1, 15), datetime(2025, 1, 15)), + ("当天安排", datetime(2025, 1, 15), datetime(2025, 1, 15)), + ("當天記錄", datetime(2025, 1, 15), datetime(2025, 1, 15)), + ("当年计划", datetime(2025, 1, 1), datetime(2025, 12, 31)), + ("年初的计划", datetime(2025, 1, 1), datetime(2025, 1, 31)), + ("年底的计划", datetime(2025, 12, 1), datetime(2025, 12, 31)), + ("年尾计划", datetime(2025, 12, 1), datetime(2025, 12, 31)), + ("下周有哪些会议", datetime(2025, 1, 20), datetime(2025, 1, 26)), + ("下周拜访客户", datetime(2025, 1, 20), datetime(2025, 1, 26)), + ("下周再安排", datetime(2025, 1, 20), datetime(2025, 1, 26)), + ("下一个星期有哪些会议", datetime(2025, 1, 20), datetime(2025, 1, 26)), + ("上周一的会议", datetime(2025, 1, 6), datetime(2025, 1, 6)), + ("上周星期一的会议", datetime(2025, 1, 6), datetime(2025, 1, 6)), + ("上星期天去了哪里", datetime(2025, 1, 12), datetime(2025, 1, 12)), + ("这周五聊了什么", datetime(2025, 1, 17), datetime(2025, 1, 17)), + ("下周三的安排", datetime(2025, 1, 22), datetime(2025, 1, 22)), + ("下周星期三的安排", datetime(2025, 1, 22), datetime(2025, 1, 22)), + ("周一的会议", datetime(2025, 1, 13), datetime(2025, 1, 13)), + ("星期天去哪", datetime(2025, 1, 19), datetime(2025, 1, 19)), + ("礼拜五安排", datetime(2025, 1, 17), datetime(2025, 1, 17)), + ("下周末去哪", datetime(2025, 1, 25), datetime(2025, 1, 26)), + ("下一个周末去哪", datetime(2025, 1, 25), datetime(2025, 1, 26)), + ("下下周有哪些会议", datetime(2025, 1, 27), datetime(2025, 2, 2)), + ("下下周末去哪", datetime(2025, 2, 1), datetime(2025, 2, 2)), + ("大下周有哪些会议", datetime(2025, 1, 27), datetime(2025, 2, 2)), + ("下个月的费用", datetime(2025, 2, 1), datetime(2025, 2, 28)), + ("下下个月的费用", datetime(2025, 3, 1), datetime(2025, 3, 31)), + ("大下个月的费用", datetime(2025, 3, 1), datetime(2025, 3, 31)), + ("下一个月的费用", datetime(2025, 2, 1), datetime(2025, 2, 28)), + ("明年讨论什么", datetime(2026, 1, 1), datetime(2026, 12, 31)), + ("下一个年度计划", datetime(2026, 1, 1), datetime(2026, 12, 31)), + ("后年讨论什么", datetime(2027, 1, 1), datetime(2027, 12, 31)), + ("大后年计划", datetime(2028, 1, 1), datetime(2028, 12, 31)), + ("周末去哪", datetime(2025, 1, 18), datetime(2025, 1, 19)), + ("这周末去哪", datetime(2025, 1, 18), datetime(2025, 1, 19)), + ("本周末去哪", datetime(2025, 1, 18), datetime(2025, 1, 19)), + ("上周有哪些会议", datetime(2025, 1, 6), datetime(2025, 1, 12)), + ("上周代码改动", datetime(2025, 1, 6), datetime(2025, 1, 12)), + ("上周又改了什么", datetime(2025, 1, 6), datetime(2025, 1, 12)), + ("上周部署了什么", datetime(2025, 1, 6), datetime(2025, 1, 12)), + ("上周转账记录", datetime(2025, 1, 6), datetime(2025, 1, 12)), + ("上週開會說了什麼", datetime(2025, 1, 6), datetime(2025, 1, 12)), + ("上週紀錄", datetime(2025, 1, 6), datetime(2025, 1, 12)), + ("當週記錄", datetime(2025, 1, 13), datetime(2025, 1, 19)), + ("上一个星期有哪些会议", datetime(2025, 1, 6), datetime(2025, 1, 12)), + ("前一周有哪些会议", datetime(2025, 1, 6), datetime(2025, 1, 12)), + ("前一个星期有哪些会议", datetime(2025, 1, 6), datetime(2025, 1, 12)), + ("上上周有哪些会议", datetime(2024, 12, 30), datetime(2025, 1, 5)), + ("上上个星期有哪些会议", datetime(2024, 12, 30), datetime(2025, 1, 5)), + ("大上周有哪些会议", datetime(2024, 12, 30), datetime(2025, 1, 5)), + ("上週有哪些會議", datetime(2025, 1, 6), datetime(2025, 1, 12)), + ("上个月的费用", datetime(2024, 12, 1), datetime(2024, 12, 31)), + ("上个月3号的事", datetime(2024, 12, 3), datetime(2024, 12, 3)), + ("本月5日的记录", datetime(2025, 1, 5), datetime(2025, 1, 5)), + ("下个月10号安排", datetime(2025, 2, 10), datetime(2025, 2, 10)), + ("當月計劃", datetime(2025, 1, 1), datetime(2025, 1, 31)), + ("上月底的事", datetime(2024, 12, 21), datetime(2024, 12, 31)), + ("这个月初的事", datetime(2025, 1, 1), datetime(2025, 1, 10)), + ("上一个月的费用", datetime(2024, 12, 1), datetime(2024, 12, 31)), + ("前一个月的费用", datetime(2024, 12, 1), datetime(2024, 12, 31)), + ("上上个月的费用", datetime(2024, 11, 1), datetime(2024, 11, 30)), + ("上個月的費用", datetime(2024, 12, 1), datetime(2024, 12, 31)), + ("去年讨论过什么", datetime(2024, 1, 1), datetime(2024, 12, 31)), + ("今年曾经做过什么", datetime(2025, 1, 1), datetime(2025, 12, 31)), + ("去年申请了什么", datetime(2024, 1, 1), datetime(2024, 12, 31)), + ("去年總結", datetime(2024, 1, 1), datetime(2024, 12, 31)), + ("去年报销记录", datetime(2024, 1, 1), datetime(2024, 12, 31)), + ("去年底的事", datetime(2024, 12, 1), datetime(2024, 12, 31)), + ("去年年末的事", datetime(2024, 12, 1), datetime(2024, 12, 31)), + ("本年度计划", datetime(2025, 1, 1), datetime(2025, 12, 31)), + ("上一年度计划", datetime(2024, 1, 1), datetime(2024, 12, 31)), + ("前一年讨论过什么", datetime(2024, 1, 1), datetime(2024, 12, 31)), + ("前年讨论过什么", datetime(2023, 1, 1), datetime(2023, 12, 31)), + ("大前年讨论过什么", datetime(2022, 1, 1), datetime(2022, 12, 31)), + ("本季度计划", datetime(2025, 1, 1), datetime(2025, 3, 31)), + ("今年第一季计划", datetime(2025, 1, 1), datetime(2025, 3, 31)), + ("这一个季度计划", datetime(2025, 1, 1), datetime(2025, 3, 31)), + ("上季度计划", datetime(2024, 10, 1), datetime(2024, 12, 31)), + ("上一季计划", datetime(2024, 10, 1), datetime(2024, 12, 31)), + ("上一个季度计划", datetime(2024, 10, 1), datetime(2024, 12, 31)), + ("上一季度计划", datetime(2024, 10, 1), datetime(2024, 12, 31)), + ("上上季度计划", datetime(2024, 7, 1), datetime(2024, 9, 30)), + ("下季度计划", datetime(2025, 4, 1), datetime(2025, 6, 30)), + ("下一季计划", datetime(2025, 4, 1), datetime(2025, 6, 30)), + ("下一个季度计划", datetime(2025, 4, 1), datetime(2025, 6, 30)), + ("下下季度计划", datetime(2025, 7, 1), datetime(2025, 9, 30)), + ("第一季度计划", datetime(2025, 1, 1), datetime(2025, 3, 31)), + ("去年第四季度计划", datetime(2024, 10, 1), datetime(2024, 12, 31)), + ("上一年度第二季度计划", datetime(2024, 4, 1), datetime(2024, 6, 30)), + ("下一年度第三季度计划", datetime(2026, 7, 1), datetime(2026, 9, 30)), + ("2024年第二季度计划", datetime(2024, 4, 1), datetime(2024, 6, 30)), + ("2024年第二季计划", datetime(2024, 4, 1), datetime(2024, 6, 30)), + ("二零二四年第三季度计划", datetime(2024, 7, 1), datetime(2024, 9, 30)), + ("2024年上半年计划", datetime(2024, 1, 1), datetime(2024, 6, 30)), + ("2024年下半年计划", datetime(2024, 7, 1), datetime(2024, 12, 31)), + ("二零二四年上半年计划", datetime(2024, 1, 1), datetime(2024, 6, 30)), + ("去年上半年计划", datetime(2024, 1, 1), datetime(2024, 6, 30)), + ("去年下半年计划", datetime(2024, 7, 1), datetime(2024, 12, 31)), + ("今年下半年计划", datetime(2025, 7, 1), datetime(2025, 12, 31)), + ("明年上半年计划", datetime(2026, 1, 1), datetime(2026, 6, 30)), + ("上半年计划", datetime(2025, 1, 1), datetime(2025, 6, 30)), + ("下半年计划", datetime(2025, 7, 1), datetime(2025, 12, 31)), + ("今年六月中旬的活动", datetime(2025, 6, 11), datetime(2025, 6, 20)), + ("2024年6月下旬的活动", datetime(2024, 6, 21), datetime(2024, 6, 30)), + ("2024年6月底的活动", datetime(2024, 6, 21), datetime(2024, 6, 30)), + ("六月初的活动", datetime(2024, 6, 1), datetime(2024, 6, 10)), + ("6月底的活动", datetime(2024, 6, 21), datetime(2024, 6, 30)), + ("前年六月的活动", datetime(2023, 6, 1), datetime(2023, 6, 30)), + ("去年六月的活动", datetime(2024, 6, 1), datetime(2024, 6, 30)), + ("今年六月的活动", datetime(2025, 6, 1), datetime(2025, 6, 30)), + ("明年六月的活动", datetime(2026, 6, 1), datetime(2026, 6, 30)), + ("后年六月的活动", datetime(2027, 6, 1), datetime(2027, 6, 30)), + ("上周末去了哪里", datetime(2025, 1, 11), datetime(2025, 1, 12)), + ("上一个周末去了哪里", datetime(2025, 1, 11), datetime(2025, 1, 12)), + ("上上周末去了哪里", datetime(2025, 1, 4), datetime(2025, 1, 5)), + ("上星期末去了哪里", datetime(2025, 1, 11), datetime(2025, 1, 12)), + ("上礼拜末去了哪里", datetime(2025, 1, 11), datetime(2025, 1, 12)), + ("上週末去了哪裡", datetime(2025, 1, 11), datetime(2025, 1, 12)), + ("上年讨论过什么", datetime(2024, 1, 1), datetime(2024, 12, 31)), + ("2024年6月的活动", datetime(2024, 6, 1), datetime(2024, 6, 30)), + ("2024年6月份的活动", datetime(2024, 6, 1), datetime(2024, 6, 30)), + ("2024年6月的活动", datetime(2024, 6, 1), datetime(2024, 6, 30)), + ("2024年06月的活动", datetime(2024, 6, 1), datetime(2024, 6, 30)), + ("2024年六月的活动", datetime(2024, 6, 1), datetime(2024, 6, 30)), + ("六月中了奖", datetime(2024, 6, 1), datetime(2024, 6, 30)), + ("二零二四年六月的活动", datetime(2024, 6, 1), datetime(2024, 6, 30)), + ("二O二四年六月的活动", datetime(2024, 6, 1), datetime(2024, 6, 30)), + ("二○二四年六月的活动", datetime(2024, 6, 1), datetime(2024, 6, 30)), + ("二〇二四年十一月的活动", datetime(2024, 11, 1), datetime(2024, 11, 30)), + ("2024年六月五日的活动", datetime(2024, 6, 5), datetime(2024, 6, 5)), + ("2024年6月廿一日的活动", datetime(2024, 6, 21), datetime(2024, 6, 21)), + ("二零二四年十二月卅一日的活动", datetime(2024, 12, 31), datetime(2024, 12, 31)), + ("今年6月5日的活动", datetime(2025, 6, 5), datetime(2025, 6, 5)), + ("六月五日的活动", datetime(2024, 6, 5), datetime(2024, 6, 5)), + ("2024年6月5號的活动", datetime(2024, 6, 5), datetime(2024, 6, 5)), + ("去年今天做了什么", datetime(2024, 1, 15), datetime(2024, 1, 15)), + ("明年今日安排", datetime(2026, 1, 15), datetime(2026, 1, 15)), + ("去年本日做了什么", datetime(2024, 1, 15), datetime(2024, 1, 15)), + ("明年今晚安排", datetime(2026, 1, 15), datetime(2026, 1, 15)), + ("去年昨晚吃了什么", datetime(2024, 1, 14), datetime(2024, 1, 14)), + ], +) +def test_query_analyzer_chinese_periods(query_analyzer, query, start, end): + """Test deterministic Chinese period extraction.""" + reference_date = datetime(2025, 1, 15, 12, 0, 0) + + analysis = query_analyzer.analyze(query, reference_date) + + assert analysis.temporal_constraint is not None + assert analysis.temporal_constraint.start_date.date() == start.date() + assert analysis.temporal_constraint.end_date.date() == end.date() + + +@pytest.mark.parametrize( + ("query", "expected"), + [ + ("两天前提到的菜是什么", datetime(2025, 1, 13)), + ("明天要做什么", datetime(2025, 1, 16)), + ("明天才开会", datetime(2025, 1, 16)), + ("明天開會提醒我", datetime(2025, 1, 16)), + ("明天半夜提醒我", datetime(2025, 1, 16)), + ("明日要做什么", datetime(2025, 1, 16)), + ("后天要做什么", datetime(2025, 1, 17)), + ("大后天要做什么", datetime(2025, 1, 18)), + ("大後天要做什么", datetime(2025, 1, 18)), + ("大大后天要做什么", datetime(2025, 1, 19)), + ("前天提到的菜是什么", datetime(2025, 1, 13)), + ("大前天提到的菜是什么", datetime(2025, 1, 12)), + ("大大前天提到的菜是什么", datetime(2025, 1, 11)), + ("三天前提到的菜是什么", datetime(2025, 1, 12)), + ("三日前的记录", datetime(2025, 1, 12)), + ("十天前提到的菜是什么", datetime(2025, 1, 5)), + ("十二天前提到的菜是什么", datetime(2025, 1, 3)), + ("一百天前提到的菜是什么", datetime(2024, 10, 7)), + ("两周前讨论了这个", datetime(2025, 1, 1)), + ("一周前讨论了这个", datetime(2025, 1, 8)), + ("一个星期前讨论了这个", datetime(2025, 1, 8)), + ("兩週前討論了這個", datetime(2025, 1, 1)), + ("两个月前的计划", datetime(2024, 11, 15)), + ("俩月前的计划", datetime(2024, 11, 15)), + ("倆月前的計畫", datetime(2024, 11, 15)), + ("一个月前的计划", datetime(2024, 12, 15)), + ("三个月前的计划", datetime(2024, 10, 15)), + ("二十二个月前的计划", datetime(2023, 3, 15)), + ("两年前的计划", datetime(2023, 1, 15)), + ("三天后提醒我", datetime(2025, 1, 18)), + ("三天之后提醒我", datetime(2025, 1, 18)), + ("一个月以后提醒我", datetime(2025, 2, 15)), + ("两年后提醒我", datetime(2027, 1, 15)), + ("半个月后提醒我", datetime(2025, 1, 30)), + ("一年半后提醒我", datetime(2026, 7, 15)), + ("两年半以后提醒我", datetime(2027, 7, 15)), + ("昨晚吃了什么", datetime(2025, 1, 14)), + ("今晚安排", datetime(2025, 1, 15)), + ("明早安排", datetime(2025, 1, 16)), + ("半个月前的计划", datetime(2024, 12, 31)), + ("一个半月前的计划", datetime(2024, 11, 30)), + ("半年前的计划", datetime(2024, 7, 15)), + ("一年半前的计划", datetime(2023, 7, 15)), + ("两年半前的计划", datetime(2022, 7, 15)), + ("兩個月前的計畫", datetime(2024, 11, 15)), + ], +) +def test_query_analyzer_chinese_exact_relative_periods(query_analyzer, query, expected): + """Test Chinese exact relative time expressions are not treated as fuzzy couple ranges.""" + reference_date = datetime(2025, 1, 15, 12, 0, 0) + + analysis = query_analyzer.analyze(query, reference_date) + + assert analysis.temporal_constraint is not None + assert analysis.temporal_constraint.start_date.date() == expected.date() + assert analysis.temporal_constraint.end_date.date() == expected.date() + + +@pytest.mark.parametrize( + ("query", "start", "end"), + [ + ("前两天提到的菜是什么", datetime(2025, 1, 12), datetime(2025, 1, 14)), + ("三两天前提到的菜是什么", datetime(2025, 1, 12), datetime(2025, 1, 14)), + ("几天前我做了什么", datetime(2025, 1, 10), datetime(2025, 1, 13)), + ("前幾天我做了什麼", datetime(2025, 1, 10), datetime(2025, 1, 13)), + ("一两周前讨论了这个", datetime(2024, 12, 25), datetime(2025, 1, 8)), + ("两三周前讨论了这个", datetime(2024, 12, 25), datetime(2025, 1, 8)), + ("几周前讨论了这个", datetime(2024, 12, 11), datetime(2025, 1, 1)), + ("几个星期前讨论了这个", datetime(2024, 12, 11), datetime(2025, 1, 1)), + ("几个礼拜前讨论了这个", datetime(2024, 12, 11), datetime(2025, 1, 1)), + ("一两个月前的计划", datetime(2024, 10, 17), datetime(2024, 12, 16)), + ("两三个月前的计划", datetime(2024, 10, 17), datetime(2024, 12, 16)), + ("几个月前的计划", datetime(2024, 8, 18), datetime(2024, 11, 16)), + ("一两年前的计划", datetime(2022, 1, 15), datetime(2024, 1, 15)), + ("三四天前的记录", datetime(2025, 1, 11), datetime(2025, 1, 12)), + ("四五周前讨论了什么", datetime(2024, 12, 11), datetime(2024, 12, 18)), + ("數天前的記錄", datetime(2025, 1, 10), datetime(2025, 1, 13)), + ], +) +def test_query_analyzer_chinese_fuzzy_periods(query_analyzer, query, start, end): + """Test Chinese fuzzy relative period extraction mirrors English ranges.""" + reference_date = datetime(2025, 1, 15, 12, 0, 0) + + analysis = query_analyzer.analyze(query, reference_date) + + assert analysis.temporal_constraint is not None + assert analysis.temporal_constraint.start_date.date() == start.date() + assert analysis.temporal_constraint.end_date.date() == end.date() + + +@pytest.mark.parametrize( + ("query", "start", "end"), + [ + ("过去一周的记录", datetime(2025, 1, 8), datetime(2025, 1, 15)), + ("過去一週的記錄", datetime(2025, 1, 8), datetime(2025, 1, 15)), + ("過去一週以內的記錄", datetime(2025, 1, 8), datetime(2025, 1, 15)), + ("今年以来的记录", datetime(2025, 1, 1), datetime(2025, 1, 15)), + ("本周以来的记录", datetime(2025, 1, 13), datetime(2025, 1, 15)), + ("去年至今的记录", datetime(2024, 1, 1), datetime(2025, 1, 15)), + ("2024年以来的记录", datetime(2024, 1, 1), datetime(2025, 1, 15)), + ("2024年6月5日以来的记录", datetime(2024, 6, 5), datetime(2025, 1, 15)), + ("本周一以来的进展", datetime(2025, 1, 13), datetime(2025, 1, 15)), + ("昨晚以来的记录", datetime(2025, 1, 14), datetime(2025, 1, 15)), + ("上周末以来的记录", datetime(2025, 1, 11), datetime(2025, 1, 15)), + ("去年今天以来的记录", datetime(2024, 1, 15), datetime(2025, 1, 15)), + ("本季度以来的记录", datetime(2025, 1, 1), datetime(2025, 1, 15)), + ("去年第四季度以来的记录", datetime(2024, 10, 1), datetime(2025, 1, 15)), + ("去年底以来的记录", datetime(2024, 12, 1), datetime(2025, 1, 15)), + ("2024年6月下旬以来的活动", datetime(2024, 6, 21), datetime(2025, 1, 15)), + ("三天前以来的记录", datetime(2025, 1, 12), datetime(2025, 1, 15)), + ("三天前到現在的记录", datetime(2025, 1, 12), datetime(2025, 1, 15)), + ("三天前开始的记录", datetime(2025, 1, 12), datetime(2025, 1, 15)), + ("三天前開始的記錄", datetime(2025, 1, 12), datetime(2025, 1, 15)), + ("2024年6月開始的記錄", datetime(2024, 6, 1), datetime(2025, 1, 15)), + ("近三日的记录", datetime(2025, 1, 12), datetime(2025, 1, 15)), + ("去年迄今的记录", datetime(2024, 1, 1), datetime(2025, 1, 15)), + ("这两天的记录", datetime(2025, 1, 13), datetime(2025, 1, 15)), + ("这几天的记录", datetime(2025, 1, 10), datetime(2025, 1, 15)), + ("最近几天的记录", datetime(2025, 1, 10), datetime(2025, 1, 15)), + ("最近半个月记录", datetime(2024, 12, 31), datetime(2025, 1, 15)), + ("最近几个月的记录", datetime(2024, 8, 15), datetime(2025, 1, 15)), + ("最近两三天的记录", datetime(2025, 1, 12), datetime(2025, 1, 15)), + ("过去一两个月的记录", datetime(2024, 10, 15), datetime(2025, 1, 15)), + ("这两三天的记录", datetime(2025, 1, 12), datetime(2025, 1, 15)), + ("过去几个星期的记录", datetime(2024, 12, 11), datetime(2025, 1, 15)), + ("近半年记录", datetime(2024, 7, 15), datetime(2025, 1, 15)), + ("三天内的记录", datetime(2025, 1, 12), datetime(2025, 1, 15)), + ("一週內的記錄", datetime(2025, 1, 8), datetime(2025, 1, 15)), + ("半个月内的记录", datetime(2024, 12, 31), datetime(2025, 1, 15)), + ("过去24小时记录", datetime(2025, 1, 14), datetime(2025, 1, 15)), + ("過去24小時的記錄", datetime(2025, 1, 14), datetime(2025, 1, 15)), + ("過去24鐘頭的記錄", datetime(2025, 1, 14), datetime(2025, 1, 15)), + ("未来24小时计划", datetime(2025, 1, 15), datetime(2025, 1, 16)), + ("前三天的记录", datetime(2025, 1, 12), datetime(2025, 1, 15)), + ("前5天的记录", datetime(2025, 1, 10), datetime(2025, 1, 15)), + ("前两周的记录", datetime(2025, 1, 1), datetime(2025, 1, 15)), + ("前两个月的记录", datetime(2024, 11, 15), datetime(2025, 1, 15)), + ("最近一个月的记录", datetime(2024, 12, 15), datetime(2025, 1, 15)), + ("近三个月的记录", datetime(2024, 10, 15), datetime(2025, 1, 15)), + ("过去一年做了什么", datetime(2024, 1, 15), datetime(2025, 1, 15)), + ("未来一周的计划", datetime(2025, 1, 15), datetime(2025, 1, 22)), + ("未來三天計劃", datetime(2025, 1, 15), datetime(2025, 1, 18)), + ("未来几天计划", datetime(2025, 1, 15), datetime(2025, 1, 20)), + ("未来几个月计划", datetime(2025, 1, 15), datetime(2025, 6, 15)), + ("未来两三天计划", datetime(2025, 1, 15), datetime(2025, 1, 18)), + ("未来半年计划", datetime(2025, 1, 15), datetime(2025, 7, 15)), + ("接下来一个月的计划", datetime(2025, 1, 15), datetime(2025, 2, 15)), + ("接下来一两周计划", datetime(2025, 1, 15), datetime(2025, 2, 5)), + ("接下来几周的计划", datetime(2025, 1, 15), datetime(2025, 2, 19)), + ("未来一年做什么", datetime(2025, 1, 15), datetime(2026, 1, 15)), + ("两三天后提醒我", datetime(2025, 1, 17), datetime(2025, 1, 18)), + ("三四天后提醒我", datetime(2025, 1, 18), datetime(2025, 1, 19)), + ("几天后提醒我", datetime(2025, 1, 17), datetime(2025, 1, 20)), + ("明后天安排", datetime(2025, 1, 16), datetime(2025, 1, 17)), + ("明后两天安排", datetime(2025, 1, 16), datetime(2025, 1, 17)), + ("今明两天的记录", datetime(2025, 1, 15), datetime(2025, 1, 16)), + ("昨今两天的记录", datetime(2025, 1, 14), datetime(2025, 1, 15)), + ("本周六和周日的安排", datetime(2025, 1, 18), datetime(2025, 1, 19)), + ("上周六、周日做了什么", datetime(2025, 1, 11), datetime(2025, 1, 12)), + ("周六日安排", datetime(2025, 1, 18), datetime(2025, 1, 19)), + ("昨天到今天的记录", datetime(2025, 1, 14), datetime(2025, 1, 15)), + ("本周一到周三的会议", datetime(2025, 1, 13), datetime(2025, 1, 15)), + ("上周一到周三的会议", datetime(2025, 1, 6), datetime(2025, 1, 8)), + ("上周五到周日的会议", datetime(2025, 1, 10), datetime(2025, 1, 12)), + ("下周五到周一的安排", datetime(2025, 1, 24), datetime(2025, 1, 27)), + ("上周五到这周一的会议", datetime(2025, 1, 10), datetime(2025, 1, 13)), + ("上周周五到本周周一的会议", datetime(2025, 1, 10), datetime(2025, 1, 13)), + ("本周五到下周一的会议", datetime(2025, 1, 17), datetime(2025, 1, 20)), + ("周五到周一的安排", datetime(2025, 1, 17), datetime(2025, 1, 20)), + ("2024年6月至8月的活动", datetime(2024, 6, 1), datetime(2024, 8, 31)), + ("2024年6月5日到6月8日的活动", datetime(2024, 6, 5), datetime(2024, 6, 8)), + ("2024年6月5日至8日的活动", datetime(2024, 6, 5), datetime(2024, 6, 8)), + ("2024年6月5至8日的活动", datetime(2024, 6, 5), datetime(2024, 6, 8)), + ("2024年6月5-8日的活动", datetime(2024, 6, 5), datetime(2024, 6, 8)), + ("6月5日到6月8日的活动", datetime(2024, 6, 5), datetime(2024, 6, 8)), + ("6月5到8号的活动", datetime(2024, 6, 5), datetime(2024, 6, 8)), + ("去年到今年的记录", datetime(2024, 1, 1), datetime(2025, 12, 31)), + ], +) +def test_query_analyzer_chinese_rolling_windows(query_analyzer, query, start, end): + """Test Chinese rolling-window temporal expressions.""" + reference_date = datetime(2025, 1, 15, 12, 0, 0) + + analysis = query_analyzer.analyze(query, reference_date) + + assert analysis.temporal_constraint is not None + assert analysis.temporal_constraint.start_date.date() == start.date() + assert analysis.temporal_constraint.end_date.date() == end.date() + + +@pytest.mark.parametrize("query", ["三两天前提到的菜是什么"]) +def test_query_analyzer_chinese_exact_relative_boundaries(query_analyzer, query): + """Test malformed Chinese numerals are not truncated into exact relative rules.""" + reference_date = datetime(2025, 1, 15, 12, 0, 0) + + analysis = query_analyzer.analyze(query, reference_date) + + assert analysis.temporal_constraint is not None + assert analysis.temporal_constraint.start_date.date() == datetime(2025, 1, 12).date() + assert analysis.temporal_constraint.end_date.date() == datetime(2025, 1, 14).date() + + +@pytest.mark.parametrize( + "query", + [ + "上周杰伦的歌", + "下周星驰电影", + "今年糕点", + "上个月亮很圆", + "下个月亮很圆", + "本月饼很好吃", + "周末端项目", + "明日方舟攻略", + "明日之后攻略", + "今日头条新闻", + "庆余年第一季剧情", + "后天免疫因素", + "会议之后三天发生了什么", + "每周末做什么", + "每个周末做什么", + "每年上半年计划", + "大大大后天要做什么", + "大大大前天提到的菜是什么", + "2024年6月前的记录", + "2024年6月份前的记录", + "2024年6月5日之前的记录", + "2024年前的记录", + "2026年后的计划", + "今年之前的记录", + "上周之前的记录", + "这个月以后的计划", + "明天起的计划", + "下周起的计划", + "三天后开始的计划", + "三天以前的记录", + "三天之前的记录", + "三年以前的记录", + "每周一开会", + "每周星期一开会", + "每个周一开会", + "每周一到周三开会", + "每个星期一至星期五开会", + "隔周一到周五排班", + ], +) +def test_query_analyzer_chinese_compound_word_false_positives(query_analyzer, query): + """Test Chinese compound-word prefixes do not fall through to dateparser false positives.""" + reference_date = datetime(2025, 1, 15, 12, 0, 0) + + analysis = query_analyzer.analyze(query, reference_date) + + assert analysis.temporal_constraint is None + + def test_query_analyzer_couple_weeks_ago(query_analyzer): """Test extraction of 'a couple of weeks ago' colloquial expression.""" reference_date = datetime(2025, 1, 15, 12, 0, 0) @@ -312,8 +759,4 @@ def boom(*args, **kwargs): 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" - ) - - + assert any("dateparser" in rec.message for rec in caplog.records), "Should log a warning when dateparser fails" diff --git a/hindsight-api-slim/tests/test_recall_cancellation.py b/hindsight-api-slim/tests/test_recall_cancellation.py new file mode 100644 index 0000000000..50e0b204a0 --- /dev/null +++ b/hindsight-api-slim/tests/test_recall_cancellation.py @@ -0,0 +1,285 @@ +"""Tests for cooperative recall/reflect cancellation on client disconnect (#2122). + +Layers covered: +- the ``CancellationToken`` primitive, +- ``RequestContext`` integration (the carrier the engine checks at boundaries), +- ``run_cancellable_on_disconnect`` (reads the scope token, maps cancel -> 499), +- ``ClientDisconnectCancellationMiddleware``, including the critical regression + test that it still fires **behind a BaseHTTPMiddleware** — the exact condition + under which ``Request.is_disconnected()`` silently never fires and the original + #2127 implementation did nothing. +""" + +import asyncio + +import pytest +from fastapi import FastAPI, HTTPException, Request + +from hindsight_api.api.disconnect import ( + SCOPE_CANCELLATION_TOKEN, + ClientDisconnectCancellationMiddleware, + _should_monitor, + get_scope_cancellation_token, +) +from hindsight_api.api.http import _CLIENT_CLOSED_REQUEST_STATUS_CODE, run_cancellable_on_disconnect +from hindsight_api.cancellation import CancellationToken, OperationCancelledError +from hindsight_api.models import RequestContext + +_TEST_TIMEOUT_SECONDS = 3.0 + + +# --- CancellationToken primitive ------------------------------------------------ + + +def test_token_starts_uncancelled(): + token = CancellationToken() + assert token.cancelled is False + token.raise_if_cancelled() # no-op + + +def test_token_raises_after_cancel(): + token = CancellationToken() + token.cancel("client disconnected") + assert token.cancelled is True + assert token.reason == "client disconnected" + with pytest.raises(OperationCancelledError) as exc: + token.raise_if_cancelled() + assert exc.value.reason == "client disconnected" + + +def test_token_cancel_is_idempotent_first_reason_wins(): + token = CancellationToken() + token.cancel("first") + token.cancel("second") + assert token.reason == "first" + + +async def test_token_wait_unblocks_on_cancel(): + token = CancellationToken() + + async def cancel_soon(): + await asyncio.sleep(0) + token.cancel("done") + + asyncio.create_task(cancel_soon()) + await asyncio.wait_for(token.wait(), timeout=_TEST_TIMEOUT_SECONDS) + assert token.cancelled is True + + +# --- RequestContext integration ------------------------------------------------- + + +def test_request_context_check_is_noop_without_token(): + ctx = RequestContext() + assert ctx.cancellation is None + ctx.raise_if_cancelled() # must not raise + + +def test_request_context_raises_when_token_fired(): + token = CancellationToken() + token.cancel("client disconnected") + ctx = RequestContext(cancellation=token) + with pytest.raises(OperationCancelledError): + ctx.raise_if_cancelled() + + +# --- _should_monitor path gating ------------------------------------------------ + + +def test_should_monitor_only_recall_and_reflect(): + assert _should_monitor("/v1/default/banks/b/memories/recall") is True + assert _should_monitor("/v1/default/banks/b/reflect") is True + assert _should_monitor("/v1/default/banks/b/memories") is False + assert _should_monitor("/health") is False + assert _should_monitor("/v1/default/banks/b/memories/recall/extra") is False + + +# --- run_cancellable_on_disconnect ---------------------------------------------- + + +class _ScopeRequest: + """Minimal Request stand-in exposing a .scope dict.""" + + def __init__(self, scope: dict) -> None: + self.scope = scope + + +async def test_run_cancellable_returns_result_when_no_token(): + ctx = RequestContext() + req = _ScopeRequest({}) # middleware didn't attach a token + + async def work() -> str: + ctx.raise_if_cancelled() + return "ok" + + result = await run_cancellable_on_disconnect(req, ctx, work(), operation="recall", bank_id="b1") + assert result == "ok" + + +async def test_run_cancellable_wires_scope_token_and_maps_to_499(): + token = CancellationToken() + ctx = RequestContext() + req = _ScopeRequest({SCOPE_CANCELLATION_TOKEN: token}) + + async def work() -> str: + # token fires mid-flight; engine checkpoint raises + for _ in range(1000): + ctx.raise_if_cancelled() + await asyncio.sleep(0.005) + return "done" + + async def fire_soon(): + await asyncio.sleep(0.02) + token.cancel("client disconnected") + + asyncio.create_task(fire_soon()) + with pytest.raises(HTTPException) as exc: + await asyncio.wait_for( + run_cancellable_on_disconnect(req, ctx, work(), operation="reflect", bank_id="b1"), + timeout=_TEST_TIMEOUT_SECONDS, + ) + assert exc.value.status_code == _CLIENT_CLOSED_REQUEST_STATUS_CODE + assert exc.value.detail == "client disconnected" + # the engine's carrier was wired to the scope token + assert ctx.cancellation is token + + +# --- Middleware ASGI integration (the regression that matters) ------------------ + + +def _build_app(*, with_base_http_middleware: bool) -> tuple[FastAPI, asyncio.Event]: + """Reflect-like app guarded by the disconnect middleware. + + with_base_http_middleware reproduces the production setup where a + @app.middleware("http") (BaseHTTPMiddleware) sits between uvicorn and the + route and breaks Request.is_disconnected(). + """ + app = FastAPI() + cancelled = asyncio.Event() + + @app.post("/v1/default/banks/{bank_id}/reflect") + async def reflect(bank_id: str, http_request: Request): + ctx = RequestContext() + + async def work(): + try: + while True: + ctx.raise_if_cancelled() + await asyncio.sleep(0.005) + except OperationCancelledError: + cancelled.set() + raise + + return await run_cancellable_on_disconnect(http_request, ctx, work(), operation="reflect", bank_id=bank_id) + + if with_base_http_middleware: + + @app.middleware("http") + async def noop(request, call_next): + return await call_next(request) + + # Installed last -> outermost -> owns the raw receive (as in create_app). + app.add_middleware(ClientDisconnectCancellationMiddleware) + return app, cancelled + + +async def _drive_disconnect(app: FastAPI) -> list: + """Send a request, then http.disconnect once the handler is running.""" + started = asyncio.Event() + body_sent = False + + async def receive(): + nonlocal body_sent + if not body_sent: + body_sent = True + started.set() + return {"type": "http.request", "body": b"{}", "more_body": False} + await started.wait() + await asyncio.sleep(0.05) + return {"type": "http.disconnect"} + + messages = [] + + async def send(message): + messages.append(message) + + scope = { + "type": "http", + "method": "POST", + "path": "/v1/default/banks/b1/reflect", + "raw_path": b"/v1/default/banks/b1/reflect", + "query_string": b"", + "headers": [(b"content-type", b"application/json")], + } + await asyncio.wait_for(app(scope, receive, send), timeout=_TEST_TIMEOUT_SECONDS) + return messages + + +async def test_middleware_cancels_behind_base_http_middleware(): + """THE regression test: disconnect cancellation must fire even with a + BaseHTTPMiddleware in the stack (where is_disconnected() is broken).""" + app, cancelled = _build_app(with_base_http_middleware=True) + messages = await _drive_disconnect(app) + assert cancelled.is_set(), "work was not cancelled behind BaseHTTPMiddleware" + start = next(m for m in messages if m["type"] == "http.response.start") + assert start["status"] == _CLIENT_CLOSED_REQUEST_STATUS_CODE + + +async def test_middleware_cancels_without_base_http_middleware(): + app, cancelled = _build_app(with_base_http_middleware=False) + messages = await _drive_disconnect(app) + assert cancelled.is_set() + start = next(m for m in messages if m["type"] == "http.response.start") + assert start["status"] == _CLIENT_CLOSED_REQUEST_STATUS_CODE + + +async def test_middleware_passes_through_unmonitored_paths(): + """Non-recall/reflect paths get no token and no receive proxying.""" + seen_scope = {} + + async def app(scope, receive, send): + seen_scope.update(scope) + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"ok"}) + + mw = ClientDisconnectCancellationMiddleware(app) + + async def receive(): + return {"type": "http.request", "body": b"", "more_body": False} + + sent = [] + + async def send(m): + sent.append(m) + + await mw({"type": "http", "path": "/health"}, receive, send) + assert SCOPE_CANCELLATION_TOKEN not in seen_scope + assert any(m["type"] == "http.response.start" for m in sent) + + +async def test_middleware_completes_normally_when_no_disconnect(): + app, cancelled = _build_app(with_base_http_middleware=True) + # never disconnects; the route would loop forever, so give it a token and + # trip it via a normal completion path instead: hit an unmonitored no-op. + token_seen = {} + + async def inner(scope, receive, send): + token_seen["t"] = get_scope_cancellation_token(scope) + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"{}"}) + + mw = ClientDisconnectCancellationMiddleware(inner) + + async def receive(): + return {"type": "http.request", "body": b"{}", "more_body": False} + + sent = [] + + async def send(m): + sent.append(m) + + scope = {"type": "http", "path": "/v1/default/banks/b1/reflect"} + await asyncio.wait_for(mw(scope, receive, send), timeout=_TEST_TIMEOUT_SECONDS) + # monitored path => token attached, request completed 200 + assert isinstance(token_seen["t"], CancellationToken) + assert any(m.get("status") == 200 for m in sent if m["type"] == "http.response.start") diff --git a/hindsight-api-slim/tests/test_recall_chunks_independence.py b/hindsight-api-slim/tests/test_recall_chunks_independence.py index a3d5738b40..c4f9c5da5d 100644 --- a/hindsight-api-slim/tests/test_recall_chunks_independence.py +++ b/hindsight-api-slim/tests/test_recall_chunks_independence.py @@ -26,16 +26,18 @@ async def test_recall_chunks_independent_of_max_tokens(memory, request_context): bank_id = "test-chunks-independence" try: - # Retain some test content with substantial size to generate chunks - test_content = """ + test_content = ( + """ The quantum computing research team at MIT has made significant breakthroughs. Dr. Sarah Chen leads the team and focuses on quantum error correction. The team published three papers in Nature Physics this year. Their work on topological qubits shows promise for scalable quantum computers. Collaborators include IBM Research and Google Quantum AI. The research is funded by a $5M NSF grant running through 2026. - """ * 10 # Repeat to ensure we get multiple chunks + """ + * 10 + ) # Repeat to ensure we get multiple chunks await memory.retain_async( bank_id=bank_id, @@ -98,7 +100,6 @@ async def test_recall_chunks_batching_with_varying_sizes(memory, request_context bank_id = "test-chunks-batching" try: - # Retain multiple documents with different content sizes # Document 1: Short content (small chunks) await memory.retain_async( @@ -109,12 +110,15 @@ async def test_recall_chunks_batching_with_varying_sizes(memory, request_context ) # Document 2: Medium content - content_bob = """ + content_bob = ( + """ Bob works as a data scientist at a tech startup in San Francisco. He has expertise in natural language processing and computer vision. Bob completed his PhD at Stanford University in 2020. He leads a team of five engineers working on AI-powered recommendation systems. - """ * 5 + """ + * 5 + ) await memory.retain_async( bank_id=bank_id, content=content_bob, @@ -123,14 +127,17 @@ async def test_recall_chunks_batching_with_varying_sizes(memory, request_context ) # Document 3: Long content (large chunks) - content_charlie = """ + content_charlie = ( + """ Charlie is the CTO of a growing AI company focused on healthcare applications. He has over 15 years of experience in software architecture and distributed systems. Charlie's team builds machine learning models for medical image analysis and diagnosis. The company recently raised $50 million in Series B funding. They have partnerships with major hospitals in the United States and Europe. Charlie holds several patents in medical imaging and deep learning. - """ * 20 + """ + * 20 + ) await memory.retain_async( bank_id=bank_id, content=content_charlie, @@ -178,7 +185,6 @@ async def test_recall_chunks_ordering_by_relevance(memory, request_context): bank_id = "test-chunks-ordering" try: - # Retain content with different relevance to query await memory.retain_async( bank_id=bank_id, @@ -223,8 +229,9 @@ async def test_recall_chunks_ordering_by_relevance(memory, request_context): all_chunk_text = " ".join(chunk.chunk_text for chunk in result.chunks.values()) # At least some chunks should mention Python (higher relevance) # This is a soft check since exact ordering depends on scoring - assert "Python" in all_chunk_text or "python" in all_chunk_text.lower(), \ + assert "Python" in all_chunk_text or "python" in all_chunk_text.lower(), ( "Chunks should include content about Python (relevant to query)" + ) finally: # Cleanup @@ -243,13 +250,16 @@ async def test_recall_chunks_for_observations(memory, request_context): try: # Retain content that will generate observations via consolidation - test_content = """ + test_content = ( + """ Alice is a senior software engineer at a large technology company. She specializes in distributed systems and has 10 years of experience. Alice leads a team of 8 engineers working on cloud infrastructure. She holds a PhD in computer science from Stanford University. Alice has published several papers on fault-tolerant distributed systems. - """ * 8 + """ + * 8 + ) await memory.retain_async( bank_id=bank_id, @@ -296,7 +306,6 @@ async def test_recall_chunks_without_include_flag(memory, request_context): bank_id = "test-chunks-no-include" try: - # Retain content test_content = """ Sarah is a product manager at a fintech company in New York. @@ -321,8 +330,7 @@ async def test_recall_chunks_without_include_flag(memory, request_context): # Should have facts but no chunks assert len(result.results) > 0, "Should return facts" - assert result.chunks is None or len(result.chunks) == 0, \ - "Should NOT return chunks when include_chunks=False" + assert result.chunks is None or len(result.chunks) == 0, "Should NOT return chunks when include_chunks=False" finally: # Cleanup diff --git a/hindsight-api-slim/tests/test_recall_config.py b/hindsight-api-slim/tests/test_recall_config.py index bcdac2c58d..f47f6a1726 100644 --- a/hindsight-api-slim/tests/test_recall_config.py +++ b/hindsight-api-slim/tests/test_recall_config.py @@ -54,9 +54,7 @@ async def test_include_chunks_false_propagates(self, mock_request_context): async def test_max_chunk_tokens_propagates(self, mock_request_context): engine = _make_mock_engine() - await tool_recall( - engine, "bank-1", "q", mock_request_context, max_chunk_tokens=2500, max_tokens=512 - ) + await tool_recall(engine, "bank-1", "q", mock_request_context, max_chunk_tokens=2500, max_tokens=512) kwargs = engine.recall_async.call_args.kwargs assert kwargs["max_chunk_tokens"] == 2500 diff --git a/hindsight-api-slim/tests/test_recall_error_propagation.py b/hindsight-api-slim/tests/test_recall_error_propagation.py index 9084f9375d..c9e49985e1 100644 --- a/hindsight-api-slim/tests/test_recall_error_propagation.py +++ b/hindsight-api-slim/tests/test_recall_error_propagation.py @@ -45,9 +45,7 @@ async def test_recall_async_error_preserves_original(memory_no_llm_verify: Memor # the symptom in #1384 was an empty trailer like "Failed to search memories: ". message = str(excinfo.value) assert "Failed to search memories" in message - assert "_SilentError" in message, ( - f"wrapper message dropped the original exception class: {message!r}" - ) + assert "_SilentError" in message, f"wrapper message dropped the original exception class: {message!r}" # `from e` chain must be preserved so worker logs / debuggers can walk # back to the real cause. diff --git a/hindsight-api-slim/tests/test_reflect_agent.py b/hindsight-api-slim/tests/test_reflect_agent.py index b11d2de666..5eb86ddb67 100644 --- a/hindsight-api-slim/tests/test_reflect_agent.py +++ b/hindsight-api-slim/tests/test_reflect_agent.py @@ -32,14 +32,14 @@ class TestCleanAnswerText: def test_clean_text_with_done_call(self): """Text ending with done() call should have it stripped.""" - text = '''The team's OKRs focus on performance.done({"answer":"The team's OKRs","memory_ids":[]})''' + text = """The team's OKRs focus on performance.done({"answer":"The team's OKRs","memory_ids":[]})""" cleaned = _clean_answer_text(text) assert cleaned == "The team's OKRs focus on performance." assert "done(" not in cleaned def test_clean_text_with_done_call_and_whitespace(self): """done() call with whitespace should be stripped.""" - text = '''Answer text here. done( {"answer": "short", "memory_ids": []} )''' + text = """Answer text here. done( {"answer": "short", "memory_ids": []} )""" cleaned = _clean_answer_text(text) assert cleaned == "Answer text here." @@ -61,10 +61,10 @@ def test_clean_empty_text(self): def test_clean_text_multiline_done(self): """done() call spanning multiple lines should be stripped.""" - text = '''Summary of findings.done({ + text = """Summary of findings.done({ "answer": "Summary", "memory_ids": ["id1", "id2"] - })''' + })""" cleaned = _clean_answer_text(text) assert cleaned == "Summary of findings." @@ -74,22 +74,22 @@ class TestCleanDoneAnswer: def test_clean_answer_with_leaked_json_code_block(self): """Answer with leaked JSON code block at the end should be cleaned.""" - text = '''The user's favorite color is blue. + text = """The user's favorite color is blue. ```json {"observation_ids": ["obs-1", "obs-2"]} -```''' +```""" cleaned = _clean_done_answer(text) assert cleaned == "The user's favorite color is blue." assert "observation_ids" not in cleaned def test_clean_answer_with_memory_ids_code_block(self): """Answer with leaked memory_ids JSON code block should be cleaned.""" - text = '''Here is the answer. + text = """Here is the answer. ```json {"memory_ids": ["mem-1"]} -```''' +```""" cleaned = _clean_done_answer(text) assert cleaned == "Here is the answer." @@ -101,13 +101,13 @@ def test_clean_answer_with_raw_json_object(self): def test_clean_answer_with_trailing_ids_pattern(self): """Answer with 'observation_ids: [...]' pattern at the end should be cleaned.""" - text = "This is the answer.\n\nobservation_ids: [\"obs-1\", \"obs-2\"]" + text = 'This is the answer.\n\nobservation_ids: ["obs-1", "obs-2"]' cleaned = _clean_done_answer(text) assert cleaned == "This is the answer." def test_clean_answer_with_memory_ids_equals(self): """Answer with 'memory_ids = [...]' pattern at the end should be cleaned.""" - text = "Answer text here.\nmemory_ids = [\"mem-1\"]" + text = 'Answer text here.\nmemory_ids = ["mem-1"]' cleaned = _clean_done_answer(text) assert cleaned == "Answer text here." @@ -129,13 +129,13 @@ def test_clean_answer_with_observation_word_in_content(self): def test_clean_answer_multiline_with_markdown(self): """Answer with markdown and leaked JSON at end should clean only the leak.""" - text = '''Summary: + text = """Summary: - Point 1 - Point 2 ```json {"mental_model_ids": ["mm-1"]} -```''' +```""" cleaned = _clean_done_answer(text) assert "Point 1" in cleaned assert "Point 2" in cleaned @@ -244,7 +244,10 @@ def mock_llm(self): llm.call_with_tools = AsyncMock() # Also mock call() for final iteration fallback - returns (response, usage) tuple llm.call = AsyncMock( - return_value=("Fallback answer from final iteration", TokenUsage(input_tokens=100, output_tokens=50, total_tokens=150)) + return_value=( + "Fallback answer from final iteration", + TokenUsage(input_tokens=100, output_tokens=50, total_tokens=150), + ) ) return llm @@ -284,9 +287,7 @@ async def test_fresh_mental_model_releases_forced_retrieval(self, mock_llm, mock self._mm_call(), LLMToolCallResult( tool_calls=[ - LLMToolCall( - id="2", name="done", arguments={"answer": "Be concise.", "mental_model_ids": ["mm-1"]} - ) + LLMToolCall(id="2", name="done", arguments={"answer": "Be concise.", "mental_model_ids": ["mm-1"]}) ], finish_reason="tool_calls", ), @@ -326,7 +327,9 @@ async def test_short_circuited_agent_may_still_retrieve_under_auto(self, mock_ll self._mm_call(), LLMToolCallResult( tool_calls=[ - LLMToolCall(id="2", name="recall", arguments={"reason": "verify", "query": "launch completion proof"}) + LLMToolCall( + id="2", name="recall", arguments={"reason": "verify", "query": "launch completion proof"} + ) ], finish_reason="tool_calls", ), @@ -472,9 +475,7 @@ async def test_no_mental_models_keeps_forced_retrieval(self, mock_llm, mock_func finish_reason="tool_calls", ), LLMToolCallResult( - tool_calls=[ - LLMToolCall(id="4", name="done", arguments={"answer": "Done.", "memory_ids": ["mem-1"]}) - ], + tool_calls=[LLMToolCall(id="4", name="done", arguments={"answer": "Done.", "memory_ids": ["mem-1"]})], finish_reason="tool_calls", ), ] @@ -679,10 +680,7 @@ async def test_short_circuit_answer_is_capped_by_max_tokens(self, mock_llm, mock """ # Build a long response that's well over the cap in cl100k_base tokens. long_answer = " ".join( - [ - "This is a detailed paragraph about the team, their roles, and their recurring meetings." - ] - * 80 + ["This is a detailed paragraph about the team, their roles, and their recurring meetings."] * 80 ) # The short-circuit path: tool_calls empty, content populated. mock_llm.call_with_tools.return_value = LLMToolCallResult( @@ -715,8 +713,7 @@ async def test_short_circuit_answer_is_capped_by_max_tokens(self, mock_llm, mock ) rewrite_kwargs = mock_llm.call.await_args.kwargs assert rewrite_kwargs.get("max_completion_tokens") == cap, ( - f"rewrite call should use max_completion_tokens={cap}, " - f"got {rewrite_kwargs.get('max_completion_tokens')}" + f"rewrite call should use max_completion_tokens={cap}, got {rewrite_kwargs.get('max_completion_tokens')}" ) # The final answer is the rewritten text, not the oversized original. @@ -824,7 +821,14 @@ def test_count_messages_tokens_with_tool_result(self): "role": "tool", "tool_call_id": "x", "name": "recall", - "content": '{"memories": [' + ', '.join([f'{{"id": "m{i}", "content": "A long memory fact about some topic that goes on and on."}}' for i in range(50)]) + ']}', + "content": '{"memories": [' + + ", ".join( + [ + f'{{"id": "m{i}", "content": "A long memory fact about some topic that goes on and on."}}' + for i in range(50) + ] + ) + + "]}", }, ] small = _count_messages_tokens(small_messages) @@ -833,7 +837,11 @@ def test_count_messages_tokens_with_tool_result(self): def test_is_context_overflow_error_openai(self): assert _is_context_overflow_error(Exception("context_length_exceeded: too many tokens")) - assert _is_context_overflow_error(Exception("This model's maximum context length is 128000 tokens. However, your messages resulted in 142164 tokens.")) + assert _is_context_overflow_error( + Exception( + "This model's maximum context length is 128000 tokens. However, your messages resulted in 142164 tokens." + ) + ) def test_is_context_overflow_error_anthropic(self): assert _is_context_overflow_error(Exception("prompt_too_long")) @@ -860,17 +868,17 @@ def mock_llm(self): llm = MagicMock() llm.call_with_tools = AsyncMock() llm.call = AsyncMock( - return_value=("Synthesized answer from gathered evidence.", TokenUsage(input_tokens=50, output_tokens=20, total_tokens=70)) + return_value=( + "Synthesized answer from gathered evidence.", + TokenUsage(input_tokens=50, output_tokens=20, total_tokens=70), + ) ) return llm @pytest.fixture def mock_functions_with_large_output(self): """Mock functions that return a large enough payload to exceed a tiny token budget.""" - large_memories = [ - {"id": f"mem-{i}", "content": f"Memory fact number {i}: " + "A" * 200} - for i in range(20) - ] + large_memories = [{"id": f"mem-{i}", "content": f"Memory fact number {i}: " + "A" * 200} for i in range(20)] return { "search_mental_models_fn": AsyncMock(return_value={"mental_models": []}), "search_observations_fn": AsyncMock(return_value={"observations": []}), @@ -910,9 +918,7 @@ async def test_proactive_guard_fires_when_budget_exceeded(self, mock_llm, mock_f async def test_context_overflow_error_skips_retry(self, mock_llm, mock_functions_with_large_output): """A context_length_exceeded error from the LLM should NOT be retried — it should immediately fall back to final synthesis.""" - mock_llm.call_with_tools.side_effect = Exception( - "context_length_exceeded: messages resulted in 150000 tokens." - ) + mock_llm.call_with_tools.side_effect = Exception("context_length_exceeded: messages resulted in 150000 tokens.") result = await run_reflect_agent( llm_config=mock_llm, @@ -969,8 +975,7 @@ async def test_directive_not_echoed_on_empty_bank(self, memory, request_context) # The directive content must NOT leak into the answer. assert directive_text not in result.text, ( - f"Directive content leaked into the answer verbatim. " - f"Got: {result.text!r}" + f"Directive content leaked into the answer verbatim. Got: {result.text!r}" ) finally: await memory.delete_bank(bank_id, request_context=request_context) @@ -1023,6 +1028,7 @@ async def test_reflect_completes_with_tiny_context_budget(self, memory, request_ class _TinyContextProxy: """Forwards all attribute access to the real config proxy except reflect_max_context_tokens which is forced to 1.""" + _real = _real_get_config() def __getattr__(self, name: str): diff --git a/hindsight-api-slim/tests/test_reflect_empty_based_on.py b/hindsight-api-slim/tests/test_reflect_empty_based_on.py index a7339f006c..3fd8ab86bf 100644 --- a/hindsight-api-slim/tests/test_reflect_empty_based_on.py +++ b/hindsight-api-slim/tests/test_reflect_empty_based_on.py @@ -34,8 +34,8 @@ async def test_reflect_with_no_memories_empty_bank(api_client): "budget": "low", "include": { "facts": {} # Request facts but bank is empty - } - } + }, + }, ) assert response.status_code == 200 @@ -43,10 +43,11 @@ async def test_reflect_with_no_memories_empty_bank(api_client): # DEBUG: Print what the API actually returned import json - print("\n" + "="*80) + + print("\n" + "=" * 80) print("API Response:") print(json.dumps(data, indent=2)) - print("="*80 + "\n") + print("=" * 80 + "\n") # Verify response structure assert "text" in data @@ -87,9 +88,9 @@ async def test_reflect_without_include_facts(api_client): f"/v1/default/banks/{bank_id}/reflect", json={ "query": "Hello world", - "budget": "low" + "budget": "low", # No include.facts - } + }, ) assert response.status_code == 200 diff --git a/hindsight-api-slim/tests/test_reflect_internal_billing.py b/hindsight-api-slim/tests/test_reflect_internal_billing.py index 9022a810a4..ac5aac84e2 100644 --- a/hindsight-api-slim/tests/test_reflect_internal_billing.py +++ b/hindsight-api-slim/tests/test_reflect_internal_billing.py @@ -30,9 +30,7 @@ class _FakeRequestContext: def _mock_engine(): engine = MagicMock() - engine.recall_async = AsyncMock( - return_value=RecallResult(results=[], source_facts={}) - ) + engine.recall_async = AsyncMock(return_value=RecallResult(results=[], source_facts={})) return engine diff --git a/hindsight-api-slim/tests/test_reflect_prompt_builder.py b/hindsight-api-slim/tests/test_reflect_prompt_builder.py index 33cb9dd60d..55a4e4a196 100644 --- a/hindsight-api-slim/tests/test_reflect_prompt_builder.py +++ b/hindsight-api-slim/tests/test_reflect_prompt_builder.py @@ -19,7 +19,7 @@ mechanical join, not a re-implementation of the builder. """ -from hindsight_api.engine.reflect.prompts import build_system_prompt_for_tools +from hindsight_api.engine.reflect.prompts import build_final_system_prompt, build_system_prompt_for_tools BANK = {"name": "TestBank", "mission": ""} @@ -386,9 +386,7 @@ def test_budget_low_inserts_shallow_block(self): include_observations=False, budget="low", ) - assert actual == _assemble( - _RETRIEVAL_RECALL_ONLY, _WORKFLOW_RECALL_ONLY, budget=_BUDGET_LOW - ) + assert actual == _assemble(_RETRIEVAL_RECALL_ONLY, _WORKFLOW_RECALL_ONLY, budget=_BUDGET_LOW) def test_budget_mid_inserts_moderate_block(self): actual = build_system_prompt_for_tools( @@ -397,9 +395,7 @@ def test_budget_mid_inserts_moderate_block(self): include_observations=False, budget="mid", ) - assert actual == _assemble( - _RETRIEVAL_RECALL_ONLY, _WORKFLOW_RECALL_ONLY, budget=_BUDGET_MID - ) + assert actual == _assemble(_RETRIEVAL_RECALL_ONLY, _WORKFLOW_RECALL_ONLY, budget=_BUDGET_MID) def test_budget_high_inserts_deep_block(self): actual = build_system_prompt_for_tools( @@ -408,9 +404,7 @@ def test_budget_high_inserts_deep_block(self): include_observations=False, budget="high", ) - assert actual == _assemble( - _RETRIEVAL_RECALL_ONLY, _WORKFLOW_RECALL_ONLY, budget=_BUDGET_HIGH - ) + assert actual == _assemble(_RETRIEVAL_RECALL_ONLY, _WORKFLOW_RECALL_ONLY, budget=_BUDGET_HIGH) def test_unknown_budget_inserts_nothing(self): # The builder only recognises low/mid/high; any other value is a no-op. @@ -544,7 +538,53 @@ def test_include_observations_defaults_to_true(): """Callers that don't pass ``include_observations`` get the original observations-enabled prompt — this guards the API default so reflect paths that don't gate the flag aren't silently changed.""" - actual = build_system_prompt_for_tools( - bank_profile=BANK, has_mental_models=False - ) + actual = build_system_prompt_for_tools(bank_profile=BANK, has_mental_models=False) assert actual == _assemble(_RETRIEVAL_OBS_ONLY, _WORKFLOW_OBS_ONLY) + + +# ========================================================================= +# build_final_system_prompt: language rule + directives +# +# The final synthesis is a SEPARATE LLM call from the agent loop, so its +# system prompt must independently carry the language rule and any directives +# — otherwise the answer-writing model has no instruction to stay in the +# query's language / obey a language directive and weaker models drift to +# English (flaky multilingual reflect tests). +# ========================================================================= + +_FRENCH_DIRECTIVE = { + "name": "Language Policy", + "content": "ALWAYS respond in French language. Never respond in English.", +} + + +def test_final_prompt_always_includes_language_rule(): + prompt = build_final_system_prompt() + assert "## LANGUAGE" in prompt + assert "SAME language as the user's question" in prompt + + +def test_final_prompt_without_directives_omits_directives_section(): + prompt = build_final_system_prompt() + assert "## DIRECTIVES (MANDATORY)" not in prompt + assert "REMINDER: MANDATORY DIRECTIVES" not in prompt + + +def test_final_prompt_injects_directives_so_answer_obeys_them(): + """The answer-writing model — not just the reasoning loop — must see the + directive, else a 'respond in French' rule is silently dropped at synthesis.""" + prompt = build_final_system_prompt(directives=[_FRENCH_DIRECTIVE]) + assert "## DIRECTIVES (MANDATORY)" in prompt + assert "respond in French" in prompt + # End-of-prompt reminder reinforces compliance, mirroring the reasoning prompt. + assert "REMINDER: MANDATORY DIRECTIVES" in prompt + # The default language rule still defers to the directive. + assert "takes precedence over this default" in prompt + + +def test_final_prompt_output_language_override_is_appended_last(): + """HINDSIGHT_API_LLM_OUTPUT_LANGUAGE forces a language regardless of query/directive.""" + prompt = build_final_system_prompt(llm_output_language="Spanish") + assert "Respond exclusively in Spanish" in prompt + # The config override is appended after the default LANGUAGE rule so it wins. + assert prompt.index("Respond exclusively in Spanish") > prompt.index("## LANGUAGE") diff --git a/hindsight-api-slim/tests/test_reflect_source_facts_config.py b/hindsight-api-slim/tests/test_reflect_source_facts_config.py index 49f6cc41a0..5d8ab24267 100644 --- a/hindsight-api-slim/tests/test_reflect_source_facts_config.py +++ b/hindsight-api-slim/tests/test_reflect_source_facts_config.py @@ -40,9 +40,7 @@ async def test_default_disables_source_facts(self, mock_request_context): """Default source_facts_max_tokens=-1 should disable source facts.""" engine = _make_mock_engine() - await tool_search_observations( - engine, "bank-1", "test query", mock_request_context - ) + await tool_search_observations(engine, "bank-1", "test query", mock_request_context) engine.recall_async.assert_called_once() call_kwargs = engine.recall_async.call_args.kwargs @@ -55,7 +53,10 @@ async def test_zero_enables_source_facts_unlimited(self, mock_request_context): engine = _make_mock_engine() await tool_search_observations( - engine, "bank-1", "test query", mock_request_context, + engine, + "bank-1", + "test query", + mock_request_context, source_facts_max_tokens=0, ) @@ -70,7 +71,10 @@ async def test_positive_enables_source_facts_with_limit(self, mock_request_conte engine = _make_mock_engine() await tool_search_observations( - engine, "bank-1", "test query", mock_request_context, + engine, + "bank-1", + "test query", + mock_request_context, source_facts_max_tokens=5000, ) @@ -85,7 +89,10 @@ async def test_negative_one_disables_source_facts(self, mock_request_context): engine = _make_mock_engine() await tool_search_observations( - engine, "bank-1", "test query", mock_request_context, + engine, + "bank-1", + "test query", + mock_request_context, source_facts_max_tokens=-1, ) diff --git a/hindsight-api-slim/tests/test_reflect_tracing.py b/hindsight-api-slim/tests/test_reflect_tracing.py index 63ff9b9ef3..8b58a38d50 100644 --- a/hindsight-api-slim/tests/test_reflect_tracing.py +++ b/hindsight-api-slim/tests/test_reflect_tracing.py @@ -1,6 +1,7 @@ """ Test to verify reflect operation creates proper span hierarchy. """ + import pytest @@ -11,11 +12,7 @@ async def test_reflect_creates_child_spans(memory, request_context): from hindsight_api.tracing import initialize_tracing, get_span_recorder, create_span_recorder # Initialize tracing with a mock endpoint - initialize_tracing( - service_name="test-hindsight", - endpoint="http://localhost:4318", - deployment_environment="test" - ) + initialize_tracing(service_name="test-hindsight", endpoint="http://localhost:4318", deployment_environment="test") # Create span recorder recorder = create_span_recorder() diff --git a/hindsight-api-slim/tests/test_reranker_score_normalization.py b/hindsight-api-slim/tests/test_reranker_score_normalization.py index 4e463f6e11..726d8fe50d 100644 --- a/hindsight-api-slim/tests/test_reranker_score_normalization.py +++ b/hindsight-api-slim/tests/test_reranker_score_normalization.py @@ -23,6 +23,7 @@ # Helpers # --------------------------------------------------------------------------- + def _make_candidates(n: int) -> list[MergedCandidate]: """Create *n* minimal MergedCandidate objects.""" candidates = [] @@ -34,9 +35,7 @@ def _make_candidates(n: int) -> list[MergedCandidate]: occurred_start=None, occurred_end=None, ) - candidates.append( - MergedCandidate(retrieval=retrieval, rrf_score=1.0 / (i + 1)) - ) + candidates.append(MergedCandidate(retrieval=retrieval, rrf_score=1.0 / (i + 1))) return candidates @@ -53,6 +52,7 @@ def _make_cross_encoder(predict_return: list[float]): # Tests # --------------------------------------------------------------------------- + @pytest.mark.asyncio async def test_passthrough_for_0_1_scores(): """Scores already in [0, 1] should be passed through as-is.""" diff --git a/hindsight-api-slim/tests/test_reranker_timeouts.py b/hindsight-api-slim/tests/test_reranker_timeouts.py index af29986ac4..9fd155775b 100644 --- a/hindsight-api-slim/tests/test_reranker_timeouts.py +++ b/hindsight-api-slim/tests/test_reranker_timeouts.py @@ -50,7 +50,10 @@ def _make_config(**overrides) -> HindsightConfig: ), ( "openrouter", - {"reranker_openrouter_api_key": "k"}, + { + "reranker_openrouter_api_key": "k", + "reranker_openrouter_base_url": "https://openrouter.ai/api/v1/rerank", + }, "reranker_openrouter_timeout", ("_http_client", "timeout"), ), diff --git a/hindsight-api-slim/tests/test_reranking_proof_count.py b/hindsight-api-slim/tests/test_reranking_proof_count.py index 3f00994970..0a4b3f03cd 100644 --- a/hindsight-api-slim/tests/test_reranking_proof_count.py +++ b/hindsight-api-slim/tests/test_reranking_proof_count.py @@ -11,6 +11,7 @@ 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( @@ -22,7 +23,7 @@ def create_mock_scored_result(proof_count: int | None = None, ce_score: float = proof_count=proof_count, # Use None for neutral recency so only proof_count changes score occurred_start=None, - occurred_end=None + occurred_end=None, ) candidate = MergedCandidate( retrieval=retrieval, @@ -35,57 +36,60 @@ def create_mock_scored_result(proof_count: int | None = None, ce_score: float = 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 - diff --git a/hindsight-api-slim/tests/test_response_exclude_none.py b/hindsight-api-slim/tests/test_response_exclude_none.py new file mode 100644 index 0000000000..6cd8e53bcb --- /dev/null +++ b/hindsight-api-slim/tests/test_response_exclude_none.py @@ -0,0 +1,82 @@ +"""Responses drop null fields where it is wire-compatible to do so. + +`create_app` installs `ExcludeNoneRoute`, which enables `response_model_exclude_none` +for every route whose response model has no required-and-nullable field. Routes whose +model *does* have such a field (an omitted key would break strict generated clients) keep +emitting nulls. These tests lock in that classification and the resulting serialization. +""" + +from fastapi.encoders import jsonable_encoder +from pydantic import BaseModel + +from hindsight_api.api.http import ( + DocumentResponse, + ExcludeNoneRoute, + OperationResponse, + RecallResponse, + ReflectResponse, + RetainResponse, + WebhookDeliveryResponse, + WebhookResponse, + _response_model_has_required_nullable, +) + + +class _RequiredNullable(BaseModel): + value: str | None # required (no default) AND nullable + + +class _OptionalNullable(BaseModel): + value: str | None = None # optional (has default) + + +class _NestsRequiredNullable(BaseModel): + items: list[_RequiredNullable] + + +def test_required_nullable_detection() -> None: + # Direct required-nullable field. + assert _response_model_has_required_nullable(_RequiredNullable) is True + # Optional (has default) is fine to drop. + assert _response_model_has_required_nullable(_OptionalNullable) is False + # Detection recurses through nested models and generic containers. + assert _response_model_has_required_nullable(_NestsRequiredNullable) is True + assert _response_model_has_required_nullable(list[_RequiredNullable]) is True + assert _response_model_has_required_nullable(_RequiredNullable | None) is True + + +def test_high_traffic_responses_are_cleaned() -> None: + # These have only optional (defaulted) nullable fields -> safe to drop nulls. + for model in (RecallResponse, RetainResponse, ReflectResponse): + assert _response_model_has_required_nullable(model) is False + + +def test_required_nullable_responses_are_preserved() -> None: + # These carry a required-nullable field (e.g. error_message, content_hash) that + # strict clients expect present -> must keep emitting nulls. + for model in (DocumentResponse, OperationResponse, WebhookResponse, WebhookDeliveryResponse): + assert _response_model_has_required_nullable(model) is True + + +def _make_route(response_model: type[BaseModel]) -> ExcludeNoneRoute: + return ExcludeNoneRoute("/_t", endpoint=lambda: None, response_model=response_model) + + +def test_route_class_sets_exclude_none_per_model() -> None: + assert _make_route(RecallResponse).response_model_exclude_none is True + assert _make_route(DocumentResponse).response_model_exclude_none is False + + +def test_explicit_decorator_flag_is_respected() -> None: + # An explicit response_model_exclude_none on the decorator is not overridden. + route = ExcludeNoneRoute( + "/_t", endpoint=lambda: None, response_model=DocumentResponse, response_model_exclude_none=True + ) + assert route.response_model_exclude_none is True + + +def test_cleaned_response_omits_null_keys() -> None: + resp = RecallResponse(results=[], trace=None, entities=None, chunks=None, source_facts=None) + cleaned = jsonable_encoder(resp, exclude_none=True) + assert cleaned == {"results": []} + assert "trace" not in cleaned and "entities" not in cleaned diff --git a/hindsight-api-slim/tests/test_retain.py b/hindsight-api-slim/tests/test_retain.py index 64e23d828f..4eb58ec0d9 100644 --- a/hindsight-api-slim/tests/test_retain.py +++ b/hindsight-api-slim/tests/test_retain.py @@ -3205,7 +3205,7 @@ async def test_temporal_links_scoped_by_fact_type(memory, request_context): import pytest_asyncio -from hindsight_api.engine.llm_wrapper import TokenUsage +from hindsight_api.engine.response_models import TokenUsage from hindsight_api.engine.memory_engine import MemoryEngine from hindsight_api.engine.task_backend import SyncTaskBackend diff --git a/hindsight-api-slim/tests/test_schema_isolation.py b/hindsight-api-slim/tests/test_schema_isolation.py index 9eded90bd0..274842678a 100644 --- a/hindsight-api-slim/tests/test_schema_isolation.py +++ b/hindsight-api-slim/tests/test_schema_isolation.py @@ -107,10 +107,12 @@ async def test_concurrent_inserts_isolated_by_schema(self, memory, pg0_db_url): await conn.close() # Configure tenant extension that provisions schemas via run_migrations - tenant_ext = MultiSchemaTestTenantExtension({ - "db_url": pg0_db_url, - "valid_schemas": set(schemas), - }) + tenant_ext = MultiSchemaTestTenantExtension( + { + "db_url": pg0_db_url, + "valid_schemas": set(schemas), + } + ) memory._tenant_extension = tenant_ext # Define concurrent insert tasks for each tenant @@ -129,7 +131,7 @@ async def insert_for_tenant(schema_name: str, content_prefix: str): for i in range(3): await conn.execute( f""" - INSERT INTO {fq_table('memory_units')} (bank_id, text, event_date, fact_type) + INSERT INTO {fq_table("memory_units")} (bank_id, text, event_date, fact_type) VALUES ($1, $2, now(), 'world') """, bank_id, @@ -155,9 +157,7 @@ async def insert_for_tenant(schema_name: str, content_prefix: str): # All texts should contain the schema's marker for text in texts: - assert f"MARKER_{prefix}" in text, ( - f"Memory in {schema} missing its marker: {text}" - ) + assert f"MARKER_{prefix}" in text, f"Memory in {schema} missing its marker: {text}" # Should NOT contain other tenants' markers other_prefixes = ["ALPHA", "BETA", "GAMMA"] @@ -265,10 +265,12 @@ async def test_list_memories_respects_schema(self, memory, pg0_db_url): await conn.close() # Configure tenant extension - tenant_ext = MultiSchemaTestTenantExtension({ - "db_url": pg0_db_url, - "valid_schemas": set(schemas), - }) + tenant_ext = MultiSchemaTestTenantExtension( + { + "db_url": pg0_db_url, + "valid_schemas": set(schemas), + } + ) memory._tenant_extension = tenant_ext try: @@ -333,10 +335,12 @@ async def test_high_concurrency_schema_isolation(self, memory, pg0_db_url): run_migrations(pg0_db_url, schema=schema) # Configure tenant extension (schemas already provisioned) - tenant_ext = MultiSchemaTestTenantExtension({ - "db_url": pg0_db_url, - "valid_schemas": set(schemas), - }) + tenant_ext = MultiSchemaTestTenantExtension( + { + "db_url": pg0_db_url, + "valid_schemas": set(schemas), + } + ) # Mark schemas as already provisioned so extension doesn't re-run migrations tenant_ext._provisioned = set(schemas) memory._tenant_extension = tenant_ext @@ -357,7 +361,7 @@ async def insert_one(schema: str, item_id: int): async with acquire_with_retry(pool) as conn: await conn.execute( f""" - INSERT INTO {fq_table('memory_units')} (bank_id, text, event_date, fact_type) + INSERT INTO {fq_table("memory_units")} (bank_id, text, event_date, fact_type) VALUES ($1, $2, now(), 'world') """, bank_id, diff --git a/hindsight-api-slim/tests/test_search_trace.py b/hindsight-api-slim/tests/test_search_trace.py index 8fc4adbe1b..f7bb0b0162 100644 --- a/hindsight-api-slim/tests/test_search_trace.py +++ b/hindsight-api-slim/tests/test_search_trace.py @@ -1,10 +1,30 @@ """ Test search tracing functionality. """ + +from datetime import datetime, timezone + import pytest + from hindsight_api.engine.memory_engine import Budget -from hindsight_api import SearchTrace, RequestContext -from datetime import datetime, timezone +from hindsight_api.engine.search.tracer import SearchTracer + + +def test_rrf_trace_preserves_flattened_source_ranks(): + """Source ranks flattened by the recall pipeline remain visible in traces.""" + tracer = SearchTracer(query="test", budget=10, max_tokens=100) + + tracer.add_rrf_merged( + [ + ( + "memory-1", + {"text": "alpha"}, + {"rrf_score": 0.1, "semantic_rank": 1, "bm25_rank": 2}, + ) + ] + ) + + assert tracer.rrf_merged[0].source_ranks == {"semantic_rank": 1, "bm25_rank": 2} @pytest.mark.asyncio @@ -14,7 +34,6 @@ async def test_search_with_trace(memory, request_context): bank_id = f"test_trace_{datetime.now(timezone.utc).timestamp()}" try: - # Store some test memories await memory.retain_async( bank_id=bank_id, @@ -40,7 +59,7 @@ async def test_search_with_trace(memory, request_context): bank_id=bank_id, query="Who works at Google?", fact_type=["world"], - budget=Budget.LOW, # 20, + budget=Budget.LOW, # 20, max_tokens=512, enable_trace=True, request_context=request_context, @@ -115,7 +134,6 @@ async def test_search_without_trace(memory, request_context): bank_id = f"test_no_trace_{datetime.now(timezone.utc).timestamp()}" try: - # Store a test memory await memory.retain_async( bank_id=bank_id, @@ -129,7 +147,7 @@ async def test_search_without_trace(memory, request_context): bank_id=bank_id, query="test", fact_type=["world"], - budget=Budget.LOW, # 10, + budget=Budget.LOW, # 10, max_tokens=512, enable_trace=False, request_context=request_context, diff --git a/hindsight-api-slim/tests/test_server_module.py b/hindsight-api-slim/tests/test_server_module.py index 5559f51933..aaf498ffa7 100644 --- a/hindsight-api-slim/tests/test_server_module.py +++ b/hindsight-api-slim/tests/test_server_module.py @@ -53,12 +53,13 @@ def tracking_load_extension(name, base_class): # Patch at source level BEFORE importing server # Note: We patch the entire hindsight_api module namespace - with patch("hindsight_api.MemoryEngine") as mock_engine, \ - patch("hindsight_api.api.create_app") as mock_create_app, \ - patch("hindsight_api.config.get_config") as mock_get_config, \ - patch("hindsight_api.extensions.load_extension", side_effect=tracking_load_extension), \ - patch("hindsight_api.extensions.DefaultExtensionContext"): - + with ( + patch("hindsight_api.MemoryEngine") as mock_engine, + patch("hindsight_api.api.create_app") as mock_create_app, + patch("hindsight_api.config.get_config") as mock_get_config, + patch("hindsight_api.extensions.load_extension", side_effect=tracking_load_extension), + patch("hindsight_api.extensions.DefaultExtensionContext"), + ): mock_config = MagicMock() mock_config.mcp_enabled = False mock_config.run_migrations_on_startup = False @@ -71,12 +72,15 @@ def tracking_load_extension(name, base_class): import hindsight_api.server # Verify TENANT extension was loaded - assert "TENANT" in loaded_extensions, \ + assert "TENANT" in loaded_extensions, ( "server.py did not call load_extension('TENANT', ...) - extensions not loaded!" - assert loaded_extensions["TENANT"] is not None, \ + ) + assert loaded_extensions["TENANT"] is not None, ( "load_extension('TENANT', ...) returned None despite env var being set" - assert isinstance(loaded_extensions["TENANT"], MockTenantExtension), \ + ) + assert isinstance(loaded_extensions["TENANT"], MockTenantExtension), ( f"Expected MockTenantExtension, got {type(loaded_extensions['TENANT'])}" + ) def test_server_loads_operation_validator_when_configured(self, monkeypatch): """ @@ -98,12 +102,13 @@ def tracking_load_extension(name, base_class): loaded_extensions[name] = result return result - with patch("hindsight_api.MemoryEngine") as mock_engine, \ - patch("hindsight_api.api.create_app") as mock_create_app, \ - patch("hindsight_api.config.get_config") as mock_get_config, \ - patch("hindsight_api.extensions.load_extension", side_effect=tracking_load_extension), \ - patch("hindsight_api.extensions.DefaultExtensionContext"): - + with ( + patch("hindsight_api.MemoryEngine") as mock_engine, + patch("hindsight_api.api.create_app") as mock_create_app, + patch("hindsight_api.config.get_config") as mock_get_config, + patch("hindsight_api.extensions.load_extension", side_effect=tracking_load_extension), + patch("hindsight_api.extensions.DefaultExtensionContext"), + ): mock_config = MagicMock() mock_config.mcp_enabled = False mock_config.run_migrations_on_startup = False @@ -114,8 +119,9 @@ def tracking_load_extension(name, base_class): import hindsight_api.server - assert "OPERATION_VALIDATOR" in loaded_extensions, \ + assert "OPERATION_VALIDATOR" in loaded_extensions, ( "server.py did not call load_extension('OPERATION_VALIDATOR', ...)" + ) assert loaded_extensions["OPERATION_VALIDATOR"] is not None assert isinstance(loaded_extensions["OPERATION_VALIDATOR"], MockOperationValidator) @@ -139,11 +145,12 @@ def capture_memory_engine(*args, **kwargs): memory_engine_calls.append({"args": args, "kwargs": kwargs}) return MagicMock() - with patch("hindsight_api.MemoryEngine", side_effect=capture_memory_engine), \ - patch("hindsight_api.api.create_app") as mock_create_app, \ - patch("hindsight_api.config.get_config") as mock_get_config, \ - patch("hindsight_api.extensions.DefaultExtensionContext"): - + with ( + patch("hindsight_api.MemoryEngine", side_effect=capture_memory_engine), + patch("hindsight_api.api.create_app") as mock_create_app, + patch("hindsight_api.config.get_config") as mock_get_config, + patch("hindsight_api.extensions.DefaultExtensionContext"), + ): mock_config = MagicMock() mock_config.mcp_enabled = False mock_config.run_migrations_on_startup = False @@ -159,10 +166,10 @@ def capture_memory_engine(*args, **kwargs): call_kwargs = memory_engine_calls[0]["kwargs"] # THE CRITICAL ASSERTION: tenant_extension must be passed and not None - assert "tenant_extension" in call_kwargs, \ - "MemoryEngine was not called with tenant_extension parameter!" - assert call_kwargs["tenant_extension"] is not None, \ + assert "tenant_extension" in call_kwargs, "MemoryEngine was not called with tenant_extension parameter!" + assert call_kwargs["tenant_extension"] is not None, ( "tenant_extension was None - server.py did not pass loaded extension to MemoryEngine!" + ) def test_server_sets_extension_context_on_tenant_extension(self, monkeypatch): """ @@ -189,11 +196,12 @@ def capture_context(*args, **kwargs): context_set_calls.append(ctx) return ctx - with patch("hindsight_api.MemoryEngine", side_effect=capture_memory_engine), \ - patch("hindsight_api.api.create_app") as mock_create_app, \ - patch("hindsight_api.config.get_config") as mock_get_config, \ - patch("hindsight_api.extensions.DefaultExtensionContext", side_effect=capture_context): - + with ( + patch("hindsight_api.MemoryEngine", side_effect=capture_memory_engine), + patch("hindsight_api.api.create_app") as mock_create_app, + patch("hindsight_api.config.get_config") as mock_get_config, + patch("hindsight_api.extensions.DefaultExtensionContext", side_effect=capture_context), + ): mock_config = MagicMock() mock_config.mcp_enabled = False mock_config.run_migrations_on_startup = False @@ -206,8 +214,7 @@ def capture_context(*args, **kwargs): # Verify context was created and set assert len(context_set_calls) == 1, "DefaultExtensionContext should be created" assert captured_tenant_ext[0] is not None, "Tenant extension should be captured" - assert captured_tenant_ext[0]._context_set, \ - "set_context was not called on tenant extension" + assert captured_tenant_ext[0]._context_set, "set_context was not called on tenant extension" def test_server_works_without_extensions(self, monkeypatch): """ @@ -225,10 +232,11 @@ def capture_memory_engine(*args, **kwargs): memory_engine_calls.append({"args": args, "kwargs": kwargs}) return MagicMock() - with patch("hindsight_api.MemoryEngine", side_effect=capture_memory_engine), \ - patch("hindsight_api.api.create_app") as mock_create_app, \ - patch("hindsight_api.config.get_config") as mock_get_config: - + with ( + patch("hindsight_api.MemoryEngine", side_effect=capture_memory_engine), + patch("hindsight_api.api.create_app") as mock_create_app, + patch("hindsight_api.config.get_config") as mock_get_config, + ): mock_config = MagicMock() mock_config.mcp_enabled = False mock_config.run_migrations_on_startup = False diff --git a/hindsight-api-slim/tests/test_source_facts_tokens.py b/hindsight-api-slim/tests/test_source_facts_tokens.py index cfc84e93d0..e5b5dcce18 100644 --- a/hindsight-api-slim/tests/test_source_facts_tokens.py +++ b/hindsight-api-slim/tests/test_source_facts_tokens.py @@ -96,7 +96,7 @@ async def test_per_observation_cap_does_not_mix_between_observations(self, memor fact_type=["observation"], max_tokens=4096, include_source_facts=True, - max_source_facts_tokens=4096, # large global budget + max_source_facts_tokens=4096, # large global budget max_source_facts_tokens_per_observation=512, # reasonable per-obs limit budget=Budget.MID, request_context=request_context, diff --git a/hindsight-api-slim/tests/test_sql_schema_safety.py b/hindsight-api-slim/tests/test_sql_schema_safety.py index 60350abfcf..a926700c78 100644 --- a/hindsight-api-slim/tests/test_sql_schema_safety.py +++ b/hindsight-api-slim/tests/test_sql_schema_safety.py @@ -93,9 +93,7 @@ def find_unqualified_table_refs(content: str, filename: str) -> list[tuple[int, qualified_pattern = rf"\.\s*{table}(?:\s|$|,|\))" fq_table_pattern = rf'fq_table\s*\(\s*["\']?{table}' - if not re.search(qualified_pattern, line) and not re.search( - fq_table_pattern, line - ): + if not re.search(qualified_pattern, line) and not re.search(fq_table_pattern, line): # Additional check: line must have SQL indicators # This avoids false positives in docstrings like "split into chunks" if sql_indicators.search(line): @@ -117,8 +115,7 @@ def test_no_unqualified_table_references(self): for line_num, table, line in violations: all_violations.append( - f"{py_file.relative_to(py_file.parent.parent)}:{line_num} - " - f"unqualified '{table}': {line[:80]}..." + f"{py_file.relative_to(py_file.parent.parent)}:{line_num} - unqualified '{table}': {line[:80]}..." ) if all_violations: diff --git a/hindsight-api-slim/tests/test_store_document_text.py b/hindsight-api-slim/tests/test_store_document_text.py new file mode 100644 index 0000000000..7fb0ac6825 --- /dev/null +++ b/hindsight-api-slim/tests/test_store_document_text.py @@ -0,0 +1,213 @@ +""" +Tests for the HINDSIGHT_API_STORE_DOCUMENT_TEXT flag. + +When disabled, the retain pipeline still extracts facts/entities and embeds +them, but the raw source text is dropped: documents.original_text is stored as +NULL and chunks.chunk_text is stored as an empty string. Recall must be +unaffected because it reads from memory_units, not original_text. +""" + +import os +from datetime import datetime, timezone + +import httpx +import pytest +import pytest_asyncio + +from hindsight_api import config as config_module +from hindsight_api.api import create_app +from hindsight_api.engine.memory_engine import Budget +from hindsight_api.engine.reflect.tools_schema import get_reflect_tools + + +@pytest_asyncio.fixture +async def api_client(memory): + """Async HTTP client against the FastAPI app (exercises response-model validation).""" + app = create_app(memory, initialize_memory=False) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + yield client + + +LONG_CONTENT = """ +Alice Johnson is a senior software engineer at Acme Corp. She specializes in +distributed systems and leads the platform team. Bob Smith works in marketing +and reports to Carol. The team uses Kubernetes and deploys to AWS. Code reviews +are mandatory before merging. +""" + + +@pytest.fixture +def store_document_text_disabled(): + """Disable raw document/chunk text storage for the duration of a test. + + Restores the environment and clears the config cache afterwards so other + tests in the same worker see the default behaviour again. + """ + original = os.environ.get("HINDSIGHT_API_STORE_DOCUMENT_TEXT") + os.environ["HINDSIGHT_API_STORE_DOCUMENT_TEXT"] = "false" + config_module.clear_config_cache() + try: + yield + finally: + if original is None: + os.environ.pop("HINDSIGHT_API_STORE_DOCUMENT_TEXT", None) + else: + os.environ["HINDSIGHT_API_STORE_DOCUMENT_TEXT"] = original + config_module.clear_config_cache() + + +@pytest.mark.asyncio +async def test_text_storage_disabled_nulls_text_but_keeps_memories( + memory, request_context, store_document_text_disabled +): + """With the flag off, raw text is dropped but facts/recall still work.""" + bank_id = f"test_text_off_{datetime.now(timezone.utc).timestamp()}" + document_id = "doc-text-001" + + try: + unit_ids = await memory.retain_async( + bank_id=bank_id, + content=LONG_CONTENT, + context="team overview", + document_id=document_id, + request_context=request_context, + ) + + # Pipeline still ran: facts were extracted and stored. + assert len(unit_ids) > 0, "Facts should still be extracted when text storage is disabled" + + # documents.original_text is dropped (NULL). + doc = await memory.get_document(document_id, bank_id, request_context=request_context) + assert doc is not None + assert doc["original_text"] is None, "Raw document text must not be stored" + assert doc["memory_unit_count"] > 0, "Memory units should still be created" + + # chunks.chunk_text is blanked. + chunks = await memory.list_document_chunks( + bank_id=bank_id, document_id=document_id, request_context=request_context + ) + assert chunks["total"] > 0, "Chunks should still be stored (for graph/structure)" + for chunk in chunks["items"]: + assert chunk["chunk_text"] == "", "Raw chunk text must not be stored" + + # Recall is unaffected — it reads from memory_units, not original_text. + result = await memory.recall_async( + bank_id=bank_id, + query="Where does Alice work?", + budget=Budget.LOW, + max_tokens=500, + request_context=request_context, + ) + assert len(result.results) > 0, "Recall must still return facts when text storage is disabled" + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +@pytest.mark.asyncio +async def test_default_mode_stores_text(memory, request_context): + """By default (flag on) raw document and chunk text are persisted.""" + bank_id = f"test_text_on_{datetime.now(timezone.utc).timestamp()}" + document_id = "doc-text-002" + + try: + await memory.retain_async( + bank_id=bank_id, + content=LONG_CONTENT, + context="team overview", + document_id=document_id, + request_context=request_context, + ) + + doc = await memory.get_document(document_id, bank_id, request_context=request_context) + assert doc is not None + assert doc["original_text"] is not None + assert "Alice Johnson" in doc["original_text"] + + chunks = await memory.list_document_chunks( + bank_id=bank_id, document_id=document_id, request_context=request_context + ) + assert chunks["total"] > 0 + assert any(chunk["chunk_text"] for chunk in chunks["items"]), "Chunk text should be stored by default" + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +@pytest.mark.asyncio +async def test_append_mode_rejected_when_text_disabled(memory, request_context, store_document_text_disabled): + """update_mode='append' must be rejected when document text storage is disabled. + + Append rebuilds the document by reading back the stored original_text; with + storage off there is nothing to read, so appending would silently drop the + prior content. The pipeline rejects it instead of losing data. + """ + bank_id = f"test_append_text_off_{datetime.now(timezone.utc).timestamp()}" + + with pytest.raises(ValueError, match="update_mode='append' is not supported"): + await memory.retain_batch_async( + bank_id=bank_id, + contents=[ + { + "content": "Some content", + "document_id": "doc-append-text-off", + "update_mode": "append", + } + ], + request_context=request_context, + ) + + +@pytest.mark.asyncio +async def test_get_document_endpoint_returns_null_text( + api_client, memory, request_context, store_document_text_disabled +): + """GET document must return 200 with null original_text (not fail response validation). + + The DocumentResponse model declares original_text as optional; a non-optional + str would raise ResponseValidationError -> HTTP 500 when the text is NULL. + """ + bank_id = f"test_get_doc_text_off_{datetime.now(timezone.utc).timestamp()}" + document_id = "doc-http-text-off" + + try: + retain = await api_client.post( + f"/v1/default/banks/{bank_id}/memories", + json={"items": [{"content": "Alice works at Acme Corp.", "document_id": document_id}]}, + ) + assert retain.status_code == 200, retain.text + + resp = await api_client.get(f"/v1/default/banks/{bank_id}/documents/{document_id}") + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["original_text"] is None, "Raw text must be null when text storage is disabled" + assert body["memory_unit_count"] > 0 + + # Append to the same document is rejected as a client error (400), not a 500. + append = await api_client.post( + f"/v1/default/banks/{bank_id}/memories", + json={"items": [{"content": "More text.", "document_id": document_id, "update_mode": "append"}]}, + ) + assert append.status_code == 400, append.text + assert "append" in append.json()["detail"] + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +@pytest.mark.asyncio +async def test_version_endpoint_reports_store_document_text(api_client, store_document_text_disabled): + """The /version feature flags expose store_document_text so the UI can warn.""" + resp = await api_client.get("/version") + assert resp.status_code == 200, resp.text + assert resp.json()["features"]["store_document_text"] is False + + +def test_reflect_excludes_expand_tool_when_text_disabled(): + """The reflect 'expand' tool (get chunk/document source text) is dropped when text storage is disabled.""" + with_text = {t["function"]["name"] for t in get_reflect_tools(include_expand=True)} + without_text = {t["function"]["name"] for t in get_reflect_tools(include_expand=False)} + + assert "expand" in with_text, "expand should be available by default" + assert "expand" not in without_text, "expand must be excluded when document text is not stored" + # Other reflect tools are unaffected. + assert "recall" in without_text + assert "done" in without_text diff --git a/hindsight-api-slim/tests/test_strip_reasoning_tags.py b/hindsight-api-slim/tests/test_strip_reasoning_tags.py new file mode 100644 index 0000000000..bc0e18c9b1 --- /dev/null +++ b/hindsight-api-slim/tests/test_strip_reasoning_tags.py @@ -0,0 +1,78 @@ +"""Tests for _strip_reasoning_tags helper in OpenAI-compatible LLM provider.""" + +from hindsight_api.engine.providers.openai_compatible_llm import _strip_reasoning_tags + + +class TestStripReasoningTags: + """Test reasoning/thinking tag stripping from LLM responses.""" + + def test_plain_text_unchanged(self): + """Text without reasoning tags passes through (modulo edge whitespace).""" + content = "User prefers functional programming patterns." + assert _strip_reasoning_tags(content) == content + + def test_empty_string(self): + """Empty string passes through.""" + assert _strip_reasoning_tags("") == "" + + def test_closed_think_stripped(self): + """A closed ... block is removed.""" + content = "let me reasonThe answer is 42." + assert _strip_reasoning_tags(content) == "The answer is 42." + + def test_closed_thinking_stripped(self): + assert _strip_reasoning_tags("reasoningResult") == "Result" + + def test_closed_thought_stripped(self): + assert _strip_reasoning_tags("hmmResult") == "Result" + + def test_closed_reasoning_stripped(self): + assert _strip_reasoning_tags("step by stepResult") == "Result" + + def test_startthink_endthink_stripped(self): + """The |startthink|...|endthink| marker style is removed.""" + content = "|startthink|internal monologue|endthink|Final output" + assert _strip_reasoning_tags(content) == "Final output" + + def test_multiline_think_stripped(self): + """DOTALL: a multi-line thinking block is fully removed.""" + content = "\nline one\nline two\n\nThe real content." + assert _strip_reasoning_tags(content) == "The real content." + + def test_unclosed_think_stripped_to_end(self): + """An unclosed (truncated output) is removed to end-of-string.""" + content = "Partial answer.\nI started thinking but got cut off" + assert _strip_reasoning_tags(content) == "Partial answer." + + def test_unclosed_thinking_stripped_to_end(self): + content = "result text\ndangling reasoning with no close" + assert _strip_reasoning_tags(content) == "result text" + + def test_only_unclosed_think_becomes_empty(self): + """Content that is entirely an unclosed thinking block collapses to empty.""" + content = "everything is reasoning and it never closed" + assert _strip_reasoning_tags(content) == "" + + def test_multiple_blocks_stripped(self): + """Multiple closed blocks are all removed.""" + content = "aHello bWorld" + assert _strip_reasoning_tags(content) == "Hello World" + + def test_mental_model_markdown_contamination(self): + """Real-world MiniMax-M3 free-form leak: wrapping a markdown mental model.""" + content = ( + "\n" + "The user keeps asking about FP. I should consolidate this.\n" + "\n" + "# Mental Model: Coding Preferences\n\n" + "The user prefers functional programming patterns and immutable data." + ) + result = _strip_reasoning_tags(content) + assert "" not in result + assert "" not in result + assert result.startswith("# Mental Model: Coding Preferences") + + def test_unclosed_think_after_json_payload(self): + """Truncated trailing valid JSON is stripped (closing tag absent).""" + content = '{"facts": [{"what": "test"}]}\noops truncated' + assert _strip_reasoning_tags(content) == '{"facts": [{"what": "test"}]}' diff --git a/hindsight-api-slim/tests/test_structured_delta_prompt_budget.py b/hindsight-api-slim/tests/test_structured_delta_prompt_budget.py new file mode 100644 index 0000000000..237b7380c3 --- /dev/null +++ b/hindsight-api-slim/tests/test_structured_delta_prompt_budget.py @@ -0,0 +1,42 @@ +"""Tests for structured-delta prompt input budgeting.""" + +from hindsight_api.engine.reflect.prompts import ( + STRUCTURED_DELTA_SYSTEM_PROMPT, + _fit_structured_delta_prompt_parts, + build_structured_delta_prompt, +) +from hindsight_api.engine.reflect.tokenization import count_cl100k_tokens + + +def test_build_structured_delta_prompt_truncates_huge_document(): + huge_doc = ( + '{"sections": [{"id": "s1", "heading": "H", "level": 1, "blocks": [{"type": "paragraph", "text": "' + + ("word " * 50_000) + + '"}]}]}' + ) + prompt = build_structured_delta_prompt( + current_document_json=huge_doc, + candidate_markdown="short synthesis", + supporting_facts=[{"id": "1", "text": "new fact", "type": "world"}], + source_query="topic?", + max_input_tokens=4000, + ) + total = count_cl100k_tokens(STRUCTURED_DELTA_SYSTEM_PROMPT) + count_cl100k_tokens(prompt) + assert total < 12_000 + assert "truncated to fit the model" in prompt + + +def test_fit_structured_delta_keeps_small_prompt_unchanged(): + doc_out, cand_out, facts_out, truncated = _fit_structured_delta_prompt_parts( + source_query="q", + current_document_json='{"sections": []}', + candidate_markdown="hello", + facts_block="one line", + budget_hint="", + task_footer="## Task\nDo it.", + max_input_tokens=24_000, + ) + assert not truncated + assert doc_out == '{"sections": []}' + assert cand_out == "hello" + assert facts_out == "one line" diff --git a/hindsight-api-slim/tests/test_structured_doc.py b/hindsight-api-slim/tests/test_structured_doc.py index eed15396b7..92e8d3796f 100644 --- a/hindsight-api-slim/tests/test_structured_doc.py +++ b/hindsight-api-slim/tests/test_structured_doc.py @@ -127,9 +127,7 @@ def test_code_block_no_language(self): assert render_block(block) == "```\nraw text\n```" def test_section_heading_level(self): - section = Section( - id="purpose", heading="Purpose", level=3, blocks=[ParagraphBlock(text="hi")] - ) + section = Section(id="purpose", heading="Purpose", level=3, blocks=[ParagraphBlock(text="hi")]) assert render_section(section).startswith("### Purpose\n\nhi") def test_document_round_trip_is_stable(self): @@ -157,18 +155,7 @@ def test_empty_document_renders_empty(self): class TestParser: def test_simple_document(self): markdown = ( - "# Team Overview\n" - "\n" - "Quick summary.\n" - "\n" - "## Members\n" - "\n" - "- Alice\n" - "- Bob\n" - "\n" - "## Cadence\n" - "\n" - "Standups daily.\n" + "# Team Overview\n\nQuick summary.\n\n## Members\n\n- Alice\n- Bob\n\n## Cadence\n\nStandups daily.\n" ) doc = parse_markdown(markdown) assert [s.id for s in doc.sections] == ["team-overview", "members", "cadence"] @@ -182,11 +169,7 @@ def test_horizontal_rule_treated_as_blank(self): doc = parse_markdown(markdown) assert [s.id for s in doc.sections] == ["rules", "stop"] # Horizontal rule must NOT become a paragraph. - assert all( - not (isinstance(b, ParagraphBlock) and "---" in b.text) - for s in doc.sections - for b in s.blocks - ) + assert all(not (isinstance(b, ParagraphBlock) and "---" in b.text) for s in doc.sections for b in s.blocks) def test_ordered_list(self): markdown = "## Steps\n\n1. one\n2. two\n3. three\n" @@ -275,9 +258,7 @@ def test_insert_block_at_index(self): def test_insert_block_out_of_range_skipped(self): doc = _team_overview_doc() - op = InsertBlockOp( - section_id="members", index=99, block=ParagraphBlock(text="x") - ) + op = InsertBlockOp(section_id="members", index=99, block=ParagraphBlock(text="x")) result = apply_operations(doc, [op]) assert result.applied == [] assert "index out of range" in result.skipped[0]["reason"] @@ -381,13 +362,9 @@ def test_unmodified_sections_byte_identical_in_render(self): ) result = apply_operations(doc, [op]) before_overview = render_section(doc.section_by_id("team-overview")) - after_overview = render_section( - result.document.section_by_id("team-overview") - ) + after_overview = render_section(result.document.section_by_id("team-overview")) before_cadence = render_section(doc.section_by_id("cadence")) - after_cadence = render_section( - result.document.section_by_id("cadence") - ) + after_cadence = render_section(result.document.section_by_id("cadence")) assert before_overview == after_overview assert before_cadence == after_cadence @@ -418,9 +395,7 @@ def test_round_trip_via_json(self): def test_invalid_op_field_rejected(self): with pytest.raises(Exception): # pydantic ValidationError - DeltaOperationList.model_validate( - {"operations": [{"op": "not_a_real_op", "section_id": "x"}]} - ) + DeltaOperationList.model_validate({"operations": [{"op": "not_a_real_op", "section_id": "x"}]}) def test_extra_field_rejected(self): with pytest.raises(Exception): diff --git a/hindsight-api-slim/tests/test_subbatch_multichunk_coverage.py b/hindsight-api-slim/tests/test_subbatch_multichunk_coverage.py new file mode 100644 index 0000000000..0d2e96059c --- /dev/null +++ b/hindsight-api-slim/tests/test_subbatch_multichunk_coverage.py @@ -0,0 +1,168 @@ +"""Regression: sub-batch slices that each span MULTIPLE extraction chunks must +keep full chunk coverage on BOTH the sync (inline) and async (submitted) retain +paths. + +Two distinct bugs hid behind the same symptom — ingesting a large plain-text +document dropped most of its body (and any fact past the first slice). Both only +trigger when an oversized single item is split into sequential sub-batches whose +*slices each re-chunk into several extraction chunks* (the default config: batch +tokens 10k → ~30k-char slices, re-chunked at 3k → ~10 chunks/slice): + +1. chunk_index offset (sync + async). retain_batch_async advanced the per-document + chunk_index cursor by re-chunking ``item["content"]`` AFTER the orchestrator + had consumed (popped) it — ``chunk_text("")`` returns ``[""]`` (count 1), so + the cursor moved by 1 per sub-batch instead of by the real chunk count. Later + slices restarted ~1 slot in, colliding ``chunk_id = {bank}_{doc}_{index}`` and + overwriting earlier chunks via upsert. + +2. whole-document recovery skip (async only). All sub-batches of one submitted + operation share one ``operation_id``; the first slice stamps the document into + ``result_metadata.facts_committed_document_ids``. The crash-recovery fast-path + then saw every later slice's document already "committed" and skipped + extraction entirely, so only the first slice survived. + +The existing #1888 coverage tests use ``RETAIN_BATCH_TOKENS=100`` (a ~300-char +budget, under the chunk size) so every slice collapses to ONE chunk — which masks +both bugs (offset-by-1 happens to equal the real count, and a 1-chunk doc isn't +re-sliced). These tests size the body so each slice fans out to ~6 chunks, with +globally-unique tokens so no chunk-hash dedup hides a dropped slice, and assert +full coverage + contiguous indices + a needle planted in a late slice. +""" + +from datetime import datetime, timezone + +import pytest + +from hindsight_api.config import clear_config_cache + +# The async test submits via submit_async_retain, which inserts parent/child rows +# into async_operations. test_worker.py drives its own WorkerPoller.claim_batch() +# against the same pool, so running the two files on different xdist workers lets +# them steal each other's pending rows. Share the "worker_tests" group so they +# serialize on the same xdist process (matches test_async_batch_retain.py). +pytestmark = pytest.mark.xdist_group("worker_tests") + +# Planted in a late paragraph so it lands in a late sub-batch slice — the first +# thing either bug drops (mirrors the field-reported "165 commits" fact that +# vanished on the async path). A single no-space token so it can't straddle a +# chunk boundary (a multi-word phrase can split across two chunks at this test's +# small 500-char chunk size and read as "dropped" when it wasn't). +NEEDLE = "NEEDLE_165_COMMITS_MERGED_INTO_THE_MAIN_BRANCH" + + +def _ts() -> float: + return datetime.now(timezone.utc).timestamp() + + +@pytest.fixture(autouse=True) +def _multichunk_split_env(monkeypatch): + # Small extraction chunks (500 chars) with a batch-token budget whose char + # budget (700 * 3 = 2100) spans several chunks, so each oversized sub-batch + # slice fans out to ~6 extraction chunks. Skip consolidation/observations to + # keep the test fast and deterministic. + monkeypatch.setenv("HINDSIGHT_API_RETAIN_CHUNK_SIZE", "500") + monkeypatch.setenv("HINDSIGHT_API_RETAIN_BATCH_TOKENS", "700") + monkeypatch.setenv("HINDSIGHT_API_ENABLE_AUTO_CONSOLIDATION", "false") + monkeypatch.setenv("HINDSIGHT_API_ENABLE_OBSERVATIONS", "false") + clear_config_cache() + yield + clear_config_cache() + + +def _make_body(paragraphs: int = 24, needle_at: int = 20) -> str: + """Plain-text transcript whose every token is unique across the whole body, + so no two extraction chunks can hash-collide (a real content-hash collision + would legitimately dedup and mask a dropped slice). The needle sits in a late + paragraph.""" + lines = [] + for i in range(paragraphs): + toks = " ".join(f"w{i:03d}t{j:03d}" for j in range(60)) + if i == needle_at: + lines.append(f"[Turn {i}] Assistant: {NEEDLE} fact {toks}") + else: + lines.append(f"[Turn {i}] Assistant: progress {i}: {toks}") + return "\n\n".join(lines) + + +async def _chunk_coverage(memory, bank_id, document_id, request_context): + doc = await memory.get_document(document_id, bank_id, request_context=request_context) + assert doc is not None + original_len = len(doc["original_text"]) + chunks = await memory.list_document_chunks(bank_id, document_id, limit=10000, request_context=request_context) + items = chunks["items"] + sum_chunk_text = sum(len(c["chunk_text"]) for c in items) + indices = sorted(c["chunk_index"] for c in items) + needle_present = any(NEEDLE in c["chunk_text"] for c in items) + return original_len, sum_chunk_text, indices, needle_present + + +def _assert_full_coverage(label, original_len, sum_chunk_text, indices, needle_present): + # Sanity: the body must actually fan out to many chunks across several + # multi-chunk slices, or the test wouldn't exercise the bug at all. + assert len(indices) >= 16, f"{label}: only {len(indices)} chunks — body too small to exercise multi-chunk slices" + assert sum_chunk_text >= original_len * 0.9, ( + f"{label}: chunks cover only {sum_chunk_text}/{original_len} chars " + f"(~{100 * sum_chunk_text // original_len}%) — a sub-batch slice was overwritten or skipped" + ) + assert indices == list(range(len(indices))), ( + f"{label}: chunk_index sequence is not contiguous: {indices} — sub-batch slices collided on chunk_id" + ) + assert needle_present, f"{label}: the late-slice needle fact was dropped (offset collision or recovery skip)" + + +@pytest.mark.asyncio +async def test_sync_inline_multichunk_subbatch_coverage(memory, request_context): + """Sync inline path (retain_batch_async): an oversized doc whose slices each + span several extraction chunks must keep full coverage (offset bug).""" + bank_id = f"test_multichunk_sync_{_ts()}" + document_id = "doc-multichunk-sync" + try: + body = _make_body() + await memory.retain_batch_async( + bank_id=bank_id, + contents=[{"content": body, "context": "big doc", "document_id": document_id}], + request_context=request_context, + ) + cov = await _chunk_coverage(memory, bank_id, document_id, request_context) + _assert_full_coverage("sync", *cov) + finally: + await memory.delete_bank(bank_id, request_context=request_context) + + +@pytest.mark.asyncio +@pytest.mark.timeout(600) +async def test_async_submit_multichunk_subbatch_coverage(memory, request_context): + """Async submit path (submit_async_retain → child op → worker): the same + oversized doc must keep full coverage too. Exercises both the offset bug and + the shared-operation_id whole-document recovery skip.""" + import asyncio + + bank_id = f"test_multichunk_async_{_ts()}" + document_id = "doc-multichunk-async" + try: + body = _make_body() + result = await memory.submit_async_retain( + bank_id=bank_id, + contents=[{"content": body, "context": "big doc", "document_id": document_id}], + request_context=request_context, + ) + operation_id = result["operation_id"] + + # SyncTaskBackend (test backend) drains children inline; wait for the + # parent to reach a terminal state before reading chunks. + status = None + for _ in range(600): + status = await memory.get_operation_status( + bank_id=bank_id, operation_id=operation_id, request_context=request_context + ) + if status["status"] in ("completed", "failed"): + break + await asyncio.sleep(0.1) + assert status is not None and status["status"] == "completed", ( + f"async retain did not complete: {status['status'] if status else 'no status'}" + ) + + cov = await _chunk_coverage(memory, bank_id, document_id, request_context) + _assert_full_coverage("async", *cov) + finally: + await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api-slim/tests/test_tags_visibility.py b/hindsight-api-slim/tests/test_tags_visibility.py index 9d2fb9a9b2..138ea16f13 100644 --- a/hindsight-api-slim/tests/test_tags_visibility.py +++ b/hindsight-api-slim/tests/test_tags_visibility.py @@ -9,6 +9,7 @@ The tags use OR-based matching: a memory matches if ANY of its tags overlap with the request tags. """ + from datetime import datetime import httpx @@ -118,6 +119,23 @@ def test_tags_match_all_strict_uses_contains(self): result = build_tags_where_clause_simple(["user_a"], 5, match="all_strict") assert "@>" in result + # ---- Test "exact" mode (set equality, excludes untagged) ---- + + def test_tags_match_exact_uses_set_equality(self): + """When match='exact', should require superset AND subset (set equality).""" + result = build_tags_where_clause_simple(["user_a"], 5, match="exact") + assert "@>" in result # contains-all + assert "<@" in result # contained-by + # Both halves bind the same parameter + assert result.count("$5") == 2 + + def test_tags_match_exact_with_table_alias(self): + """Should include table alias on both halves of the exact clause.""" + result = build_tags_where_clause_simple(["user_a", "user_b"], 3, table_alias="mu.", match="exact") + assert result.count("mu.tags") == 2 + assert "@>" in result + assert "<@" in result + # ---- Test table alias with all modes ---- def test_tags_match_any_with_table_alias(self): @@ -213,6 +231,30 @@ def test_all_mode_requires_all_tags(self): tags_found = [r.tags for r in filtered] assert ["a", "b"] in tags_found + # ---- Test "exact" mode (set equality, excludes untagged) ---- + + def test_exact_mode_matches_only_equal_set(self): + """'exact' mode should match only results whose tag set equals the scope.""" + results = [MockResult(["a"]), MockResult(["a", "b"]), MockResult(["b"]), MockResult(None)] + filtered = filter_results_by_tags(results, ["a"], match="exact") + # Only the exact scope ["a"] matches; ["a", "b"] is a different scope. + assert len(filtered) == 1 + assert filtered[0].tags == ["a"] + + def test_exact_mode_is_order_independent(self): + """'exact' mode should treat tag order as irrelevant (set equality).""" + results = [MockResult(["b", "a"]), MockResult(["a"]), MockResult(["a", "b", "c"])] + filtered = filter_results_by_tags(results, ["a", "b"], match="exact") + assert len(filtered) == 1 + assert filtered[0].tags == ["b", "a"] + + def test_exact_mode_excludes_untagged(self): + """'exact' mode with a non-empty scope should exclude untagged results.""" + results = [MockResult(["a"]), MockResult(None), MockResult([])] + filtered = filter_results_by_tags(results, ["a"], match="exact") + assert len(filtered) == 1 + assert filtered[0].tags == ["a"] + def test_all_mode_includes_untagged(self): """'all' mode should include untagged results.""" results = [MockResult(["a", "b"]), MockResult(None), MockResult([])] @@ -329,10 +371,12 @@ def test_and_of_two_leaves(self): """AND of two leaves generates AND-joined clause.""" groups = [ TagGroupAnd.model_validate( - {"and": [ - {"tags": ["step:5"], "match": "any_strict"}, - {"tags": ["user:ep_42"], "match": "all_strict"}, - ]} + { + "and": [ + {"tags": ["step:5"], "match": "any_strict"}, + {"tags": ["user:ep_42"], "match": "all_strict"}, + ] + } ) ] clause, params, next_offset = build_tag_groups_where_clause(groups, 3) @@ -348,10 +392,12 @@ def test_or_of_two_leaves(self): """OR of two leaves generates OR-joined clause.""" groups = [ TagGroupOr.model_validate( - {"or": [ - {"tags": ["step:5"], "match": "any_strict"}, - {"tags": ["priority:high"], "match": "all_strict"}, - ]} + { + "or": [ + {"tags": ["step:5"], "match": "any_strict"}, + {"tags": ["priority:high"], "match": "all_strict"}, + ] + } ) ] clause, params, next_offset = build_tag_groups_where_clause(groups, 1) @@ -363,11 +409,7 @@ def test_or_of_two_leaves(self): def test_not_wraps_with_not(self): """NOT group wraps child clause with NOT.""" - groups = [ - TagGroupNot.model_validate( - {"not": {"tags": ["archived"], "match": "any_strict"}} - ) - ] + groups = [TagGroupNot.model_validate({"not": {"tags": ["archived"], "match": "any_strict"}})] clause, params, next_offset = build_tag_groups_where_clause(groups, 2) assert "NOT" in clause assert "$2" in clause @@ -378,13 +420,17 @@ def test_nested_and_containing_or(self): """AND containing an OR generates correct nested SQL.""" groups = [ TagGroupAnd.model_validate( - {"and": [ - {"tags": ["user:alice"], "match": "all_strict"}, - {"or": [ - {"tags": ["step:5"], "match": "any_strict"}, - {"tags": ["priority:high"], "match": "all_strict"}, - ]}, - ]} + { + "and": [ + {"tags": ["user:alice"], "match": "all_strict"}, + { + "or": [ + {"tags": ["step:5"], "match": "any_strict"}, + {"tags": ["priority:high"], "match": "all_strict"}, + ] + }, + ] + } ) ] clause, params, next_offset = build_tag_groups_where_clause(groups, 1) @@ -397,11 +443,13 @@ def test_param_numbering_sequential(self): """Params are numbered sequentially starting from param_offset.""" groups = [ TagGroupAnd.model_validate( - {"and": [ - {"tags": ["a"], "match": "any_strict"}, - {"tags": ["b"], "match": "any_strict"}, - {"tags": ["c"], "match": "any_strict"}, - ]} + { + "and": [ + {"tags": ["a"], "match": "any_strict"}, + {"tags": ["b"], "match": "any_strict"}, + {"tags": ["c"], "match": "any_strict"}, + ] + } ) ] clause, params, next_offset = build_tag_groups_where_clause(groups, 5) @@ -421,10 +469,12 @@ def test_table_alias_propagates_to_nested(self): """Table alias propagates to nested leaves (each leaf uses the alias).""" groups = [ TagGroupAnd.model_validate( - {"and": [ - {"tags": ["a"], "match": "any_strict"}, - {"tags": ["b"], "match": "any_strict"}, - ]} + { + "and": [ + {"tags": ["a"], "match": "any_strict"}, + {"tags": ["b"], "match": "any_strict"}, + ] + } ) ] clause, params, next_offset = build_tag_groups_where_clause(groups, 1, table_alias="mu.") @@ -433,6 +483,7 @@ def test_table_alias_propagates_to_nested(self): assert "mu.tags" in clause # No bare 'tags' keyword without the alias prefix (other than inside the alias itself) import re + bare_tags = re.findall(r"(? 1, "Multiple requests should run concurrently" @pytest.mark.asyncio @@ -627,13 +630,13 @@ async def test_tei_reranker_performance(): # Test configurations: (batch_size, max_concurrent) configs = [ - (128, 8), # Default - (256, 4), # Larger batches, fewer concurrent - (256, 8), # Larger batches, same concurrent - (512, 2), # Very large batches, few concurrent - (512, 4), # Very large batches, moderate concurrent - (64, 16), # Smaller batches, more concurrent - (800, 1), # Single batch (all at once) + (128, 8), # Default + (256, 4), # Larger batches, fewer concurrent + (256, 8), # Larger batches, same concurrent + (512, 2), # Very large batches, few concurrent + (512, 4), # Very large batches, moderate concurrent + (64, 16), # Smaller batches, more concurrent + (800, 1), # Single batch (all at once) ] results = [] @@ -662,17 +665,21 @@ async def test_tei_reranker_performance(): avg_time = sum(times) / len(times) min_time = min(times) - results.append({ - "batch_size": batch_size, - "max_concurrent": max_concurrent, - "avg_ms": avg_time * 1000, - "min_ms": min_time * 1000, - "num_batches": (num_pairs + batch_size - 1) // batch_size, - }) - - print(f" batch_size={batch_size:4d}, max_concurrent={max_concurrent:2d}: " - f"avg={avg_time * 1000:6.1f}ms, min={min_time * 1000:6.1f}ms " - f"({results[-1]['num_batches']} batches)") + results.append( + { + "batch_size": batch_size, + "max_concurrent": max_concurrent, + "avg_ms": avg_time * 1000, + "min_ms": min_time * 1000, + "num_batches": (num_pairs + batch_size - 1) // batch_size, + } + ) + + print( + f" batch_size={batch_size:4d}, max_concurrent={max_concurrent:2d}: " + f"avg={avg_time * 1000:6.1f}ms, min={min_time * 1000:6.1f}ms " + f"({results[-1]['num_batches']} batches)" + ) # Find best configuration best = min(results, key=lambda x: x["avg_ms"]) @@ -703,21 +710,19 @@ async def test_tei_reranker_concurrent_requests(): num_concurrent_requests = 4 query = "Tell me about machine learning and AI training" - test_pairs = [ - (query, f"Document {i} about ML and training.") - for i in range(num_pairs_per_request) - ] + test_pairs = [(query, f"Document {i} about ML and training.") for i in range(num_pairs_per_request)] # Test configurations configs = [ - (128, 8), # Default - (256, 4), # Larger batches - (512, 2), # Very large batches - (200, 1), # Single batch per request + (128, 8), # Default + (256, 4), # Larger batches + (512, 2), # Very large batches + (200, 1), # Single batch per request ] - print(f"\n⏱️ Concurrent Load Test: {num_concurrent_requests} parallel requests, " - f"{num_pairs_per_request} pairs each:\n") + print( + f"\n⏱️ Concurrent Load Test: {num_concurrent_requests} parallel requests, {num_pairs_per_request} pairs each:\n" + ) for batch_size, max_concurrent in configs: encoder = RemoteTEICrossEncoder( @@ -744,17 +749,21 @@ async def run_single_request(): total_time = time.time() - start individual_times = [r[0] for r in results] - times.append({ - "total": total_time, - "max_individual": max(individual_times), - "avg_individual": sum(individual_times) / len(individual_times), - }) + times.append( + { + "total": total_time, + "max_individual": max(individual_times), + "avg_individual": sum(individual_times) / len(individual_times), + } + ) avg_total = sum(t["total"] for t in times) / len(times) avg_max_individual = sum(t["max_individual"] for t in times) / len(times) - print(f" batch_size={batch_size:4d}, max_concurrent={max_concurrent:2d}: " - f"total={avg_total * 1000:6.1f}ms, slowest_req={avg_max_individual * 1000:6.1f}ms") + print( + f" batch_size={batch_size:4d}, max_concurrent={max_concurrent:2d}: " + f"total={avg_total * 1000:6.1f}ms, slowest_req={avg_max_individual * 1000:6.1f}ms" + ) @requires_tei_server diff --git a/hindsight-api-slim/tests/test_temporal_ranges.py b/hindsight-api-slim/tests/test_temporal_ranges.py index 1fb965d39f..3aac7a5588 100644 --- a/hindsight-api-slim/tests/test_temporal_ranges.py +++ b/hindsight-api-slim/tests/test_temporal_ranges.py @@ -1,4 +1,5 @@ """Tests for temporal range support (occurred_start, occurred_end, mentioned_at).""" + import asyncio from datetime import datetime, timezone, timedelta import pytest @@ -54,12 +55,12 @@ async def test_temporal_ranges_are_written(memory_real_llm, request_context): WHERE bank_id = $1 ORDER BY created_at """, - bank_id + bank_id, ) print(f"\n\n=== Retrieved {len(rows)} facts ===") for i, row in enumerate(rows): - print(f"\nFact {i+1}:") + print(f"\nFact {i + 1}:") print(f" Text: {row['text'][:80]}...") print(f" event_date: {row['event_date']}") print(f" occurred_start: {row['occurred_start']}") @@ -71,16 +72,16 @@ async def test_temporal_ranges_are_written(memory_real_llm, request_context): # Check that temporal fields are populated for row in rows: - assert row['occurred_start'] is not None, f"occurred_start is None for fact: {row['text'][:50]}" - assert row['occurred_end'] is not None, f"occurred_end is None for fact: {row['text'][:50]}" - assert row['mentioned_at'] is not None, f"mentioned_at is None for fact: {row['text'][:50]}" + assert row["occurred_start"] is not None, f"occurred_start is None for fact: {row['text'][:50]}" + assert row["occurred_end"] is not None, f"occurred_end is None for fact: {row['text'][:50]}" + assert row["mentioned_at"] is not None, f"mentioned_at is None for fact: {row['text'][:50]}" # mentioned_at should be close to the conversation date - time_diff = abs((row['mentioned_at'] - conversation_date).total_seconds()) + time_diff = abs((row["mentioned_at"] - conversation_date).total_seconds()) assert time_diff < 60, f"mentioned_at is too far from conversation_date: {time_diff}s" # Find the pottery fact (point event) - pottery_fact = next((r for r in rows if 'pottery' in r['text'].lower()), None) + pottery_fact = next((r for r in rows if "pottery" in r["text"].lower()), None) if pottery_fact: print(f"\n=== Pottery Fact (Point Event) ===") print(f" occurred_start: {pottery_fact['occurred_start']}") @@ -89,11 +90,13 @@ async def test_temporal_ranges_are_written(memory_real_llm, request_context): # For "yesterday", occurred_start and occurred_end should be Nov 16 # (or the same day - it should be a point event) # We'll check they're within the same day - time_diff = abs((pottery_fact['occurred_end'] - pottery_fact['occurred_start']).total_seconds()) - assert time_diff < 86400, f"Point event should have occurred_start and occurred_end within same day, got diff: {time_diff}s" + time_diff = abs((pottery_fact["occurred_end"] - pottery_fact["occurred_start"]).total_seconds()) + assert time_diff < 86400, ( + f"Point event should have occurred_start and occurred_end within same day, got diff: {time_diff}s" + ) # Find the Paris fact (period event) - paris_fact = next((r for r in rows if 'paris' in r['text'].lower() or 'february' in r['text'].lower()), None) + paris_fact = next((r for r in rows if "paris" in r["text"].lower() or "february" in r["text"].lower()), None) if paris_fact: print(f"\n=== Paris Fact (Period Event) ===") print(f" occurred_start: {paris_fact['occurred_start']}") @@ -103,13 +106,13 @@ async def test_temporal_ranges_are_written(memory_real_llm, request_context): # 1. A month-long period (Feb 1 - Feb 29) - ideal interpretation # 2. A point event sometime in February - also valid # We accept either interpretation as long as the dates are in February 2024 - if paris_fact['occurred_start'] and paris_fact['occurred_end']: - time_diff_days = (paris_fact['occurred_end'] - paris_fact['occurred_start']).days + if paris_fact["occurred_start"] and paris_fact["occurred_end"]: + time_diff_days = (paris_fact["occurred_end"] - paris_fact["occurred_start"]).days print(f" Duration: {time_diff_days} days") # Verify the dates are in February 2024 - assert paris_fact['occurred_start'].year == 2024, f"occurred_start should be 2024" - assert paris_fact['occurred_start'].month == 2, f"occurred_start should be in February" + assert paris_fact["occurred_start"].year == 2024, f"occurred_start should be 2024" + assert paris_fact["occurred_start"].month == 2, f"occurred_start should be in February" else: print(" Note: occurred_start/end not set (fact may not have been classified as event)") diff --git a/hindsight-api-slim/tests/test_temporal_recall_selection.py b/hindsight-api-slim/tests/test_temporal_recall_selection.py index 80f74f0ba9..0cca4ae8d8 100644 --- a/hindsight-api-slim/tests/test_temporal_recall_selection.py +++ b/hindsight-api-slim/tests/test_temporal_recall_selection.py @@ -34,7 +34,13 @@ def _vec(*leading: float) -> str: def _row(sim: float, day: datetime) -> dict: """A minimal pool row for the pure selector test.""" - return {"id": f"{sim}-{day.isoformat()}", "similarity": sim, "occurred_start": None, "mentioned_at": day, "occurred_end": None} + return { + "id": f"{sim}-{day.isoformat()}", + "similarity": sim, + "occurred_start": None, + "mentioned_at": day, + "occurred_end": None, + } # --------------------------------------------------------------------------- @@ -110,7 +116,12 @@ async def test_temporal_recall_selects_by_similarity_not_recency(memory): # Newer, less-similar units. for i in range(8): await _insert_unit( - conn, bank_id, f"recent less-relevant {i}", "world", datetime(2025, 1, 20, tzinfo=UTC) + timedelta(hours=i), _SIM_050 + conn, + bank_id, + f"recent less-relevant {i}", + "world", + datetime(2025, 1, 20, tzinfo=UTC) + timedelta(hours=i), + _SIM_050, ) # Out-of-window, perfect similarity → must be excluded by the window. before = await _insert_unit(conn, bank_id, "before", "world", datetime(2024, 12, 1, tzinfo=UTC), _SIM_100) diff --git a/hindsight-api-slim/tests/test_think.py b/hindsight-api-slim/tests/test_think.py index 65892865a2..0c11d37ccd 100644 --- a/hindsight-api-slim/tests/test_think.py +++ b/hindsight-api-slim/tests/test_think.py @@ -1,6 +1,7 @@ """ Test reflect (think) function. """ + import pytest from datetime import datetime, timezone from hindsight_api.engine.memory_engine import Budget @@ -28,4 +29,3 @@ async def test_think_without_prior_context(memory, request_context): # Should still return an answer (even if it says it doesn't have enough info) assert result.text, "Should return some answer" assert result.based_on, "Should return based_on structure" - diff --git a/hindsight-api-slim/tests/test_tracing_spans_verification.py b/hindsight-api-slim/tests/test_tracing_spans_verification.py index f43973f460..6647dd8b21 100644 --- a/hindsight-api-slim/tests/test_tracing_spans_verification.py +++ b/hindsight-api-slim/tests/test_tracing_spans_verification.py @@ -48,6 +48,7 @@ async def test_recall_span_hierarchy(mock_tracer, memory, request_context): # Wait a bit for any background tasks to settle import asyncio + await asyncio.sleep(0.5) # Reset mocks after retain diff --git a/hindsight-api-slim/tests/test_vector_index.py b/hindsight-api-slim/tests/test_vector_index.py index 6834d76d8b..a65fdd74bb 100644 --- a/hindsight-api-slim/tests/test_vector_index.py +++ b/hindsight-api-slim/tests/test_vector_index.py @@ -81,15 +81,17 @@ def test_ann_search_tuning_settings_pgvector_dispatches_hnsw_ef_search(): assert ann_search_tuning_settings("pgvector", kind="high_recall") == (("hnsw.ef_search", "200"),) -def test_ann_search_tuning_settings_vchord_dispatches_vchordrq_probes(): - # vchord doesn't recognize hnsw.ef_search; the dispatcher must route to - # the vchordrq equivalent (probes), otherwise the GUC silently does nothing. - assert ann_search_tuning_settings("vchord", kind="low_latency") == (("vchordrq.probes", "10"),) - assert ann_search_tuning_settings("vchord", kind="high_recall") == (("vchordrq.probes", "30"),) +def test_ann_search_tuning_settings_vchord_leaves_probes_to_index_defaults(): + # vchordrq.probes must match the index's build.internal.lists shape. + # VectorChord 1.1 supports per-index fallback parameters for this; a + # session GUC would override every index and can be invalid for listless or + # mixed-layout indexes. + assert ann_search_tuning_settings("vchord", kind="low_latency") == () + assert ann_search_tuning_settings("vchord", kind="high_recall") == () def test_ann_search_tuning_settings_returns_empty_for_backends_without_knob(): - for ext in ("pgvectorscale", "pg_diskann", "scann"): + for ext in ("vchord", "pgvectorscale", "pg_diskann", "scann"): assert ann_search_tuning_settings(ext, kind="low_latency") == () assert ann_search_tuning_settings(ext, kind="high_recall") == () diff --git a/hindsight-api-slim/tests/test_webhooks.py b/hindsight-api-slim/tests/test_webhooks.py index 2b3cdeadb7..711eb7f014 100644 --- a/hindsight-api-slim/tests/test_webhooks.py +++ b/hindsight-api-slim/tests/test_webhooks.py @@ -22,6 +22,8 @@ from hindsight_api.webhooks.manager import MAX_ATTEMPTS, RETRY_DELAYS, WebhookManager from hindsight_api.webhooks.models import ( ConsolidationEventData, + MemoryDefenseEventData, + MemoryDefenseHit, RetainEventData, WebhookConfig, WebhookEvent, @@ -81,7 +83,7 @@ def test_hmac_signing_format(self): manager = self._make_manager() sig = manager._sign_payload("my-secret", b"hello world") assert sig.startswith("sha256="), f"Expected 'sha256=' prefix, got: {sig!r}" - hex_part = sig[len("sha256="):] + hex_part = sig[len("sha256=") :] # SHA-256 hex digest is always 64 characters assert len(hex_part) == 64 # Hex characters only @@ -148,9 +150,7 @@ class TestFireEvent: """Integration tests for WebhookManager.fire_event().""" @pytest.mark.asyncio - async def test_fire_event_creates_delivery( - self, memory: MemoryEngine, webhook_manager: WebhookManager - ): + async def test_fire_event_creates_delivery(self, memory: MemoryEngine, webhook_manager: WebhookManager): """fire_event() inserts a pending webhook_delivery task in async_operations.""" bank_id = f"wh-test-{uuid.uuid4().hex[:8]}" webhook_id = uuid.uuid4() @@ -200,9 +200,7 @@ async def test_fire_event_creates_delivery( await conn.execute("DELETE FROM webhooks WHERE id = $1", webhook_id) @pytest.mark.asyncio - async def test_fire_event_global_webhook( - self, memory: MemoryEngine - ): + async def test_fire_event_global_webhook(self, memory: MemoryEngine): """fire_event() also queues delivery tasks for global webhooks (not stored in DB).""" bank_id = f"wh-global-{uuid.uuid4().hex[:8]}" await _ensure_bank(memory._pool, bank_id) @@ -229,8 +227,7 @@ async def test_fire_event_global_webhook( AND task_payload->>'url' = 'https://global.example.com/hook' ORDER BY created_at DESC LIMIT 1 - """ - , + """, bank_id, ) @@ -292,9 +289,7 @@ class TestFireEventWithConn: """Integration tests for WebhookManager.fire_event_with_conn().""" @pytest.mark.asyncio - async def test_fire_event_with_conn_queues_delivery( - self, memory: MemoryEngine, webhook_manager: WebhookManager - ): + async def test_fire_event_with_conn_queues_delivery(self, memory: MemoryEngine, webhook_manager: WebhookManager): """fire_event_with_conn() inserts a delivery task using the provided connection.""" bank_id = f"wh-conn-{uuid.uuid4().hex[:8]}" webhook_id = uuid.uuid4() @@ -417,9 +412,7 @@ async def test_deliver_failure_raises_retry_task_at(self, memory: MemoryEngine): """A failed HTTP POST raises RetryTaskAt when retries remain.""" task_dict = _make_delivery_task(retry_count=0) - with patch.object( - memory._http_client, "post", new=AsyncMock(side_effect=Exception("connection refused")) - ): + with patch.object(memory._http_client, "post", new=AsyncMock(side_effect=Exception("connection refused"))): with pytest.raises(RetryTaskAt): await memory._handle_webhook_delivery(task_dict) @@ -428,9 +421,7 @@ async def test_deliver_exhausted_retries_raises(self, memory: MemoryEngine): """When retry_count reaches MAX_ATTEMPTS-1, a failure raises the original exception.""" task_dict = _make_delivery_task(retry_count=MAX_ATTEMPTS - 1) - with patch.object( - memory._http_client, "post", new=AsyncMock(side_effect=Exception("server error")) - ): + with patch.object(memory._http_client, "post", new=AsyncMock(side_effect=Exception("server error"))): with pytest.raises(Exception, match="server error"): await memory._handle_webhook_delivery(task_dict) @@ -441,9 +432,7 @@ async def test_deliver_retry_at_uses_delay_schedule(self, memory: MemoryEngine): task_dict = _make_delivery_task(retry_count=1) - with patch.object( - memory._http_client, "post", new=AsyncMock(side_effect=Exception("fail")) - ): + with patch.object(memory._http_client, "post", new=AsyncMock(side_effect=Exception("fail"))): before = datetime.now(timezone.utc) with pytest.raises(RetryTaskAt) as exc_info: await memory._handle_webhook_delivery(task_dict) @@ -537,9 +526,7 @@ async def test_http_create_webhook(self, api_client: httpx.AsyncClient): assert data["secret"] is None # secrets are never echoed back # Cleanup - await api_client.delete( - f"/v1/default/banks/{bank_id}/webhooks/{data['id']}" - ) + await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{data['id']}") @pytest.mark.asyncio async def test_http_create_webhook_creates_missing_bank(self, api_client: httpx.AsyncClient): @@ -598,9 +585,7 @@ async def test_http_delete_webhook(self, api_client: httpx.AsyncClient): assert create_resp.status_code == 201 webhook_id = create_resp.json()["id"] - delete_resp = await api_client.delete( - f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}" - ) + delete_resp = await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}") assert delete_resp.status_code == 200 assert delete_resp.json()["success"] is True @@ -614,15 +599,11 @@ async def test_http_delete_webhook_not_found(self, api_client: httpx.AsyncClient """DELETE with a non-existent webhook id returns 404.""" bank_id = f"http-wh-{uuid.uuid4().hex[:8]}" missing_id = str(uuid.uuid4()) - response = await api_client.delete( - f"/v1/default/banks/{bank_id}/webhooks/{missing_id}" - ) + response = await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{missing_id}") assert response.status_code == 404 @pytest.mark.asyncio - async def test_http_list_deliveries( - self, memory: MemoryEngine, api_client: httpx.AsyncClient - ): + async def test_http_list_deliveries(self, memory: MemoryEngine, api_client: httpx.AsyncClient): """GET /webhooks/{id}/deliveries returns delivery records for a webhook.""" bank_id = f"http-wh-{uuid.uuid4().hex[:8]}" @@ -665,9 +646,7 @@ async def test_http_list_deliveries( ) try: - deliveries_resp = await api_client.get( - f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}/deliveries" - ) + deliveries_resp = await api_client.get(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}/deliveries") assert deliveries_resp.status_code == 200 items = deliveries_resp.json()["items"] ids = [item["id"] for item in items] @@ -680,21 +659,15 @@ async def test_http_list_deliveries( assert delivery["attempts"] == 1 finally: async with memory._pool.acquire() as conn: - await conn.execute( - "DELETE FROM async_operations WHERE operation_id = $1", delivery_id - ) - await api_client.delete( - f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}" - ) + await conn.execute("DELETE FROM async_operations WHERE operation_id = $1", delivery_id) + await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}") @pytest.mark.asyncio async def test_http_list_deliveries_webhook_not_found(self, api_client: httpx.AsyncClient): """GET /webhooks/{id}/deliveries for a non-existent webhook returns 404.""" bank_id = f"http-wh-{uuid.uuid4().hex[:8]}" missing_id = str(uuid.uuid4()) - response = await api_client.get( - f"/v1/default/banks/{bank_id}/webhooks/{missing_id}/deliveries" - ) + response = await api_client.get(f"/v1/default/banks/{bank_id}/webhooks/{missing_id}/deliveries") assert response.status_code == 404 @pytest.mark.asyncio @@ -855,9 +828,7 @@ def test_retain_event_type_value(self): assert WebhookEventType.RETAIN_COMPLETED == "retain.completed" @pytest.mark.asyncio - async def test_fire_retain_webhook_queues_per_document( - self, memory: MemoryEngine, webhook_manager: WebhookManager - ): + async def test_fire_retain_webhook_queues_per_document(self, memory: MemoryEngine, webhook_manager: WebhookManager): """_fire_retain_webhook queues one delivery task per content item.""" bank_id = f"wh-retain-{uuid.uuid4().hex[:8]}" webhook_id = uuid.uuid4() @@ -1107,14 +1078,11 @@ async def test_create_webhook_lands_in_resolved_schema( uuid.UUID(webhook_id), ) - assert row_in_target is not None, ( - "Webhook row should be inserted into the resolved schema" - ) + assert row_in_target is not None, "Webhook row should be inserted into the resolved schema" assert row_in_target["bank_id"] == bank_id assert row_in_target["url"] == "https://example.com/iso" assert row_in_public is None, ( - "Webhook row must NOT be written to public when a non-default " - "schema is resolved by the tenant extension" + "Webhook row must NOT be written to public when a non-default schema is resolved by the tenant extension" ) @pytest.mark.asyncio @@ -1162,18 +1130,13 @@ async def test_list_webhooks_reads_from_resolved_schema( assert list_resp.status_code == 200 ids = {item["id"] for item in list_resp.json()["items"]} - assert target_webhook_id in ids, ( - "list_webhooks should return rows from the resolved schema" - ) + assert target_webhook_id in ids, "list_webhooks should return rows from the resolved schema" assert str(public_webhook_id) not in ids, ( - "list_webhooks must NOT leak rows from public when a non-default " - "schema is resolved" + "list_webhooks must NOT leak rows from public when a non-default schema is resolved" ) finally: async with memory._pool.acquire() as conn: - await conn.execute( - "DELETE FROM public.webhooks WHERE id = $1", public_webhook_id - ) + await conn.execute("DELETE FROM public.webhooks WHERE id = $1", public_webhook_id) @pytest.mark.asyncio async def test_update_webhook_targets_resolved_schema( @@ -1232,9 +1195,7 @@ async def test_update_webhook_targets_resolved_schema( assert public_url == "https://example.com/public-stale" finally: async with memory._pool.acquire() as conn: - await conn.execute( - "DELETE FROM public.webhooks WHERE id = $1", uuid.UUID(webhook_id) - ) + await conn.execute("DELETE FROM public.webhooks WHERE id = $1", uuid.UUID(webhook_id)) @pytest.mark.asyncio async def test_delete_webhook_targets_resolved_schema( @@ -1270,9 +1231,7 @@ async def test_delete_webhook_targets_resolved_schema( ) try: - del_resp = await api_client.delete( - f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}" - ) + del_resp = await api_client.delete(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}") assert del_resp.status_code == 200 assert del_resp.json()["success"] is True @@ -1287,14 +1246,10 @@ async def test_delete_webhook_targets_resolved_schema( ) assert target_row is None, "row in resolved schema should have been deleted" - assert public_row is not None, ( - "row in public must NOT be deleted when delete targets a non-default schema" - ) + assert public_row is not None, "row in public must NOT be deleted when delete targets a non-default schema" finally: async with memory._pool.acquire() as conn: - await conn.execute( - "DELETE FROM public.webhooks WHERE id = $1", uuid.UUID(webhook_id) - ) + await conn.execute("DELETE FROM public.webhooks WHERE id = $1", uuid.UUID(webhook_id)) @pytest.mark.asyncio async def test_list_deliveries_targets_resolved_schema( @@ -1328,17 +1283,13 @@ async def test_list_deliveries_targets_resolved_schema( ) try: - resp = await api_client.get( - f"/v1/default/banks/{bank_id}/webhooks/{orphan_webhook_id}/deliveries" - ) + resp = await api_client.get(f"/v1/default/banks/{bank_id}/webhooks/{orphan_webhook_id}/deliveries") # The webhook does not exist in the resolved schema, so this must 404 # — not silently fall through to public. assert resp.status_code == 404, resp.text finally: async with memory._pool.acquire() as conn: - await conn.execute( - "DELETE FROM public.webhooks WHERE id = $1", orphan_webhook_id - ) + await conn.execute("DELETE FROM public.webhooks WHERE id = $1", orphan_webhook_id) @pytest.mark.asyncio async def test_list_deliveries_returns_rows_from_resolved_schema( @@ -1416,14 +1367,10 @@ async def test_list_deliveries_returns_rows_from_resolved_schema( ) try: - resp = await api_client.get( - f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}/deliveries" - ) + resp = await api_client.get(f"/v1/default/banks/{bank_id}/webhooks/{webhook_id}/deliveries") assert resp.status_code == 200 ids = {item["id"] for item in resp.json()["items"]} - assert str(target_delivery_id) in ids, ( - "deliveries from the resolved schema should be returned" - ) + assert str(target_delivery_id) in ids, "deliveries from the resolved schema should be returned" assert str(public_delivery_id) not in ids, ( "deliveries from public must NOT leak when a non-default schema is resolved" ) @@ -1433,3 +1380,109 @@ async def test_list_deliveries_returns_rows_from_resolved_schema( "DELETE FROM public.async_operations WHERE operation_id = $1", public_delivery_id, ) + + +# ─── MemoryDefenseEventData SIEM enrichment fields ────────────────────────────── +# +# OSS only populates action / detector / document_id / matched_types / message. +# The remaining fields are optional SIEM enrichment that downstream extensions +# (e.g. hindsight-cloud) populate when they have richer per-decision context. +# These tests pin the wire contract so OSS evolution doesn't break extensions +# that depend on the optional fields being present and JSON-serialisable. + + +def test_memory_defense_event_data_base_shape() -> None: + """The five base fields populated by every implementation round-trip cleanly + and the optional SIEM-enrichment fields default to None when omitted.""" + data = MemoryDefenseEventData( + action="redact", + detector="sensitive_data", + document_id="doc-1", + matched_types=["github_token"], + message="Secrets redacted by policy-driven pre-screen", + ) + + # Base fields populated. + assert data.action == "redact" + assert data.detector == "sensitive_data" + assert data.document_id == "doc-1" + assert data.matched_types == ["github_token"] + assert data.message == "Secrets redacted by policy-driven pre-screen" + + # Optional enrichment fields default to None — OSS receivers must see no + # change vs. before this commit. + assert data.severity is None + assert data.api_key_name is None + assert data.hits is None + assert data.memory_unit_id is None + assert data.receipt_uri is None + + # JSON shape: explicit None for absent fields, no extra keys. + dumped = data.model_dump() + assert dumped["severity"] is None + assert dumped["hits"] is None + assert set(dumped.keys()) == { + "action", + "detector", + "document_id", + "matched_types", + "message", + "severity", + "api_key_name", + "hits", + "memory_unit_id", + "receipt_uri", + } + + +def test_memory_defense_event_data_with_siem_enrichment() -> None: + """When an extension populates the enrichment fields, they round-trip via + the model and through WebhookEvent JSON serialisation.""" + hit = MemoryDefenseHit(detector="GitHub Token", preview="ghp_AAAA...BBBB") + data = MemoryDefenseEventData( + action="redact", + detector="sensitive_data", + document_id="doc-42", + matched_types=["github_token"], + message="rotate immediately", + severity="high", + api_key_name="Connect Key", + hits=[hit], + memory_unit_id="mu-123", + receipt_uri="memdef://bank/abc/receipt/xyz", + ) + + assert data.severity == "high" + assert data.api_key_name == "Connect Key" + assert data.hits == [hit] + assert data.hits[0].detector == "GitHub Token" + assert data.hits[0].preview == "ghp_AAAA...BBBB" + assert data.memory_unit_id == "mu-123" + assert data.receipt_uri == "memdef://bank/abc/receipt/xyz" + + # Nested-event round trip via JSON (this is what the webhook manager + # serialises before queuing the delivery). + event = WebhookEvent( + event=WebhookEventType.MEMORY_DEFENSE_TRIGGERED, + bank_id="bank-1", + operation_id="", + status="redact", + timestamp=datetime(2026, 6, 12, 0, 0, tzinfo=timezone.utc), + data=data, + ) + payload = json.loads(event.model_dump_json()) + assert payload["data"]["severity"] == "high" + assert payload["data"]["api_key_name"] == "Connect Key" + assert payload["data"]["hits"] == [{"detector": "GitHub Token", "preview": "ghp_AAAA...BBBB"}] + assert payload["data"]["memory_unit_id"] == "mu-123" + assert payload["data"]["receipt_uri"] == "memdef://bank/abc/receipt/xyz" + + +def test_memory_defense_hit_rejects_missing_preview() -> None: + """MemoryDefenseHit requires both fields — guards against extensions + accidentally posting raw secrets as the only payload (preview must be + explicit) or omitting the inner detector label.""" + with pytest.raises(Exception): # pydantic ValidationError + MemoryDefenseHit(detector="GitHub Token") # type: ignore[call-arg] + with pytest.raises(Exception): + MemoryDefenseHit(preview="ghp_AAAA...BBBB") # type: ignore[call-arg] diff --git a/hindsight-api-slim/tests/test_worker.py b/hindsight-api-slim/tests/test_worker.py index 19581db893..11433ed644 100644 --- a/hindsight-api-slim/tests/test_worker.py +++ b/hindsight-api-slim/tests/test_worker.py @@ -13,6 +13,7 @@ import asyncio import json import uuid +from unittest.mock import AsyncMock, MagicMock, patch import pytest import pytest_asyncio @@ -70,6 +71,99 @@ async def clean_operations(pool): ) +def test_metric_operation_label_normalises_retain_variants(): + """Worker completion metrics collapse retain variants onto operation="retain" + so they share the API path's series; other types pass through unchanged.""" + from hindsight_api.worker.poller import _metric_operation_label + + assert _metric_operation_label("retain") == "retain" + assert _metric_operation_label("batch_retain") == "retain" + assert _metric_operation_label("file_convert_retain") == "retain" + assert _metric_operation_label("consolidation") == "consolidation" + assert _metric_operation_label("reflect") == "reflect" + assert _metric_operation_label(None) == "unknown" + + +class TestWorkerOperationMetrics: + """_execute_task_inner emits operation metrics on terminal outcomes only (no DB).""" + + def _make_poller(self, executor): + from hindsight_api.worker import WorkerPoller + + poller = WorkerPoller(backend=MagicMock(), worker_id="w-test", executor=executor) + # Stub terminal-state handlers so _execute_task_inner never touches the DB. + poller._mark_failed = AsyncMock() + poller._defer_operation = AsyncMock() + poller._schedule_retry = AsyncMock() + return poller + + async def _run(self, executor, task_type="batch_retain"): + from hindsight_api.worker.poller import ClaimedTask + + poller = self._make_poller(executor) + task = ClaimedTask( + operation_id=str(uuid.uuid4()), + task_dict={"type": task_type, "operation_type": task_type, "bank_id": "bank-1"}, + schema=None, + ) + collector = MagicMock() + with patch("hindsight_api.worker.poller.get_metrics_collector", return_value=collector): + await poller._execute_task_inner(task) + return collector + + @pytest.mark.asyncio + async def test_executor_returning_normally_records_success(self): + """Success is inferred from the executor returning without raising to the + poller. This deliberately includes deterministic failures that + memory_engine.execute_task handles itself and returns from normally + (file_convert_retain, non-retryable errors) — at the poller boundary they + are indistinguishable from a clean completion, so they also record + success=true. The worker counter is therefore a completion-throughput + signal; authoritative failure visibility comes from the + hindsight_async_operations{status="failed"} gauge, which reads each + operation's final DB status. + """ + collector = await self._run(AsyncMock()) # executor returns normally + collector.record_operation_result.assert_called_once() + call = collector.record_operation_result.call_args + assert call.args[0] == "retain" # batch_retain normalised + assert call.kwargs["success"] is True + assert call.kwargs["source"] == "worker" + + @pytest.mark.asyncio + async def test_failure_records_failure(self): + async def boom(_): + raise RuntimeError("kaboom") + + collector = await self._run(boom) + collector.record_operation_result.assert_called_once() + assert collector.record_operation_result.call_args.kwargs["success"] is False + + @pytest.mark.asyncio + async def test_deferral_not_counted(self): + from datetime import datetime, timezone + + from hindsight_api.worker.exceptions import DeferOperation + + async def defer(_): + raise DeferOperation(exec_date=datetime.now(timezone.utc), reason="later") + + collector = await self._run(defer) + collector.record_operation_result.assert_not_called() + + @pytest.mark.asyncio + async def test_retry_not_counted(self): + from datetime import datetime, timezone + + from hindsight_api.worker.exceptions import RetryTaskAt + + async def retry(_): + raise RetryTaskAt(retry_at=datetime.now(timezone.utc), message="transient") + + collector = await self._run(retry) + collector.record_operation_result.assert_not_called() + + def test_all_operation_types_have_slot_reservation_config(): """Every operation_type used in memory_engine must be listed in WORKER_SLOT_RESERVATION_TYPES so it can be reserved via env var. @@ -832,6 +926,34 @@ async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult: # Defensive: confirm it wasn't a RetryTaskAt masquerading as Defer. assert not isinstance(exc_info.value, RetryTaskAt) + @pytest.mark.asyncio + async def test_memory_engine_provider_quota_reset_becomes_defer_operation(self, memory, monkeypatch): + """Provider quota windows should park worker tasks until the reset time.""" + from datetime import UTC, datetime, timedelta + + from hindsight_api.engine.llm_interface import ProviderRateLimitResetError + from hindsight_api.worker.exceptions import DeferOperation, RetryTaskAt + + retry_at = (datetime.now(UTC) + timedelta(hours=5)).replace(microsecond=0) + + async def quota_limited_retain(_task_dict: object) -> None: + raise ProviderRateLimitResetError(retry_at=retry_at, message="quota resets later") + + monkeypatch.setattr(memory, "_handle_batch_retain", quota_limited_retain) + + with pytest.raises(DeferOperation) as exc_info: + await memory.execute_task( + { + "type": "batch_retain", + "bank_id": "test-provider-quota-defer", + "contents": [{"content": "x"}], + } + ) + + assert exc_info.value.exec_date == retry_at + assert exc_info.value.reason == "quota resets later" + assert not isinstance(exc_info.value, RetryTaskAt) + @pytest.mark.asyncio async def test_claim_batch_skips_consolidation_when_same_bank_processing(self, pool, backend, clean_operations): """Test that pending consolidation is skipped if same bank has one processing.""" @@ -947,9 +1069,11 @@ async def test_claim_batch_allows_non_consolidation_when_consolidation_processin claimed = await poller.claim_batch() - # Should claim the retain task (non-consolidation tasks are unaffected) - assert len(claimed) == 1 - assert claimed[0].operation_id == str(retain_op_id) + # Should claim the retain task (non-consolidation tasks are unaffected). + # Filter to our bank — parallel tests may contribute other claims. + my_claims = [c for c in claimed if c.task_dict.get("bank_id") == bank_id] + assert len(my_claims) == 1, f"Expected 1 claim for our bank, got {len(my_claims)}" + assert my_claims[0].operation_id == str(retain_op_id) class TestWorkerRecovery: @@ -1586,7 +1710,9 @@ async def list_tenants(self) -> list[Tenant]: # First claim_batch should call list_tenants claimed1 = await poller.claim_batch() assert mock_extension.list_tenants_calls == 1 - assert len(claimed1) == 2 + # Filter to our bank — parallel tests may contribute other claims. + my_claims1 = [c for c in claimed1 if c.task_dict.get("bank_id") == bank_id] + assert len(my_claims1) == 2, f"Expected 2 claims for our bank, got {len(my_claims1)}" # Add more tasks for i in range(2): @@ -1605,7 +1731,8 @@ async def list_tenants(self) -> list[Tenant]: # Second claim_batch should call list_tenants again claimed2 = await poller.claim_batch() assert mock_extension.list_tenants_calls == 2 - assert len(claimed2) == 2 + my_claims2 = [c for c in claimed2 if c.task_dict.get("bank_id") == bank_id] + assert len(my_claims2) == 2, f"Expected 2 claims for our bank, got {len(my_claims2)}" @pytest.mark.asyncio async def test_poller_picks_up_new_tenants_without_restart(self, pool, backend, clean_operations): @@ -1650,10 +1777,12 @@ async def list_tenants(self) -> list[Tenant]: tenant_extension=dynamic_extension, ) - # First poll - only public schema + # First poll - only public schema. Filter to our bank — parallel tests + # may contribute other claims. claimed1 = await poller.claim_batch() - assert len(claimed1) == 1 - assert claimed1[0].schema is None # public is represented as None + my_claims1 = [c for c in claimed1 if c.task_dict.get("bank_id") == bank_id] + assert len(my_claims1) == 1, f"Expected 1 claim for our bank, got {len(my_claims1)}" + assert my_claims1[0].schema is None # public is represented as None assert dynamic_extension.list_tenants_calls == 1 # Simulate tenant list changing (but we won't add a non-existent schema) @@ -1675,12 +1804,14 @@ async def list_tenants(self) -> list[Tenant]: # Second poll - list_tenants should be called again claimed2 = await poller.claim_batch() - assert len(claimed2) == 1 + my_claims2 = [c for c in claimed2 if c.task_dict.get("bank_id") == bank_id] + assert len(my_claims2) == 1, f"Expected 1 claim for our bank, got {len(my_claims2)}" assert dynamic_extension.list_tenants_calls == 2 # Called again on second poll # Third poll with no tasks - still calls list_tenants claimed3 = await poller.claim_batch() - assert len(claimed3) == 0 + my_claims3 = [c for c in claimed3 if c.task_dict.get("bank_id") == bank_id] + assert len(my_claims3) == 0, f"Expected 0 claims for our bank, got {len(my_claims3)}" assert dynamic_extension.list_tenants_calls == 3 # Called again even with no tasks @pytest.mark.asyncio @@ -2896,8 +3027,7 @@ async def test_scan_uses_optional_routine_when_installed(self, pool, backend, cl from hindsight_api.worker import WorkerPoller # Minimal contract-satisfying implementation: returns the empty - # set. Use a non-default schema so the default-schema consistency - # check does not intentionally fall back to the per-schema scan. + # set. Enough to prove the poller follows the server-side path. await pool.execute( "CREATE OR REPLACE FUNCTION public.schemas_with_pending_work() " "RETURNS SETOF text AS $$ BEGIN RETURN; END $$ LANGUAGE plpgsql STABLE" @@ -2925,7 +3055,7 @@ async def spy_fetchval(self, query, *args, column=0, timeout=None): PostgresConnection.fetch = spy_fetch # type: ignore[method-assign] PostgresConnection.fetchval = spy_fetchval # type: ignore[method-assign] try: - await poller._scan_active_schemas(["tenant_alpha"]) + await poller._scan_active_schemas([None]) finally: PostgresConnection.fetch = original_fetch # type: ignore[method-assign] PostgresConnection.fetchval = original_fetchval # type: ignore[method-assign] @@ -2973,42 +3103,6 @@ async def test_scan_normalizes_public_from_optional_routine(self, pool, backend, finally: await pool.execute("DROP FUNCTION IF EXISTS public.schemas_with_pending_work()") - @pytest.mark.asyncio - async def test_scan_falls_back_when_optional_routine_misses_public(self, pool, backend, clean_operations, caplog): - """If an installed routine scans tenant schemas only, public - single-tenant workers must still use the correct per-schema fallback. - """ - from hindsight_api.worker import WorkerPoller - - bank_id = f"test-worker-missed-{uuid.uuid4().hex[:8]}" - await _ensure_bank(pool, bank_id) - await pool.execute( - """INSERT INTO async_operations - (operation_id, bank_id, operation_type, status, task_payload) - VALUES ($1, $2, 'test', 'pending', $3::jsonb)""", - uuid.uuid4(), - bank_id, - json.dumps({"type": "test", "bank_id": bank_id}), - ) - await pool.execute( - "CREATE OR REPLACE FUNCTION public.schemas_with_pending_work() " - "RETURNS SETOF text AS $$ BEGIN RETURN; END $$ LANGUAGE plpgsql STABLE" - ) - try: - poller = WorkerPoller( - backend=backend, - worker_id="test-routine-missed-public", - executor=lambda x: None, - ) - - with caplog.at_level("WARNING", logger="hindsight_api.worker.poller"): - result = await poller._scan_active_schemas([None]) - - assert None in result - assert any("missed claimable schema" in record.message for record in caplog.records) - finally: - await pool.execute("DROP FUNCTION IF EXISTS public.schemas_with_pending_work()") - @pytest.mark.asyncio async def test_claim_batch_only_queries_active_schemas(self, pool, backend, clean_operations): """claim_batch uses _scan_active_schemas to pre-filter, then diff --git a/hindsight-api/pyproject.toml b/hindsight-api/pyproject.toml index 6feea85f63..d56f34be52 100644 --- a/hindsight-api/pyproject.toml +++ b/hindsight-api/pyproject.toml @@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "hindsight-api" -version = "0.7.2" +version = "0.8.3" description = "Hindsight: Agent Memory That Works Like Human Memory" readme = "README.md" requires-python = ">=3.11" dependencies = [ - "hindsight-api-slim[all]==0.7.2", + "hindsight-api-slim[all]==0.8.3", ] [tool.uv.sources] diff --git a/hindsight-cli/.openapi-coverage.toml b/hindsight-cli/.openapi-coverage.toml index 5bb1e7e8a5..63e9837db9 100644 --- a/hindsight-cli/.openapi-coverage.toml +++ b/hindsight-cli/.openapi-coverage.toml @@ -25,6 +25,10 @@ # Zero-filled bucket arrays don't map to a useful CLI command. get_memories_timeseries = "UI-only endpoint for the control plane stats chart" +# Per-bank LLM connectivity probe — a control-plane/diagnostics action (the "Health" +# dialog). Makes a real provider call and is off by default; not an end-user CLI command. +test_bank_llm = "Control-plane diagnostics action (Health dialog); not exposed in the CLI" + # UI-only endpoint powering the control-plane entity constellation view. # Returns nodes/edges in cytoscape shape; not a useful CLI command. get_entity_graph = "UI-only endpoint for the control plane entity constellation" @@ -32,12 +36,25 @@ get_entity_graph = "UI-only endpoint for the control plane entity constellation" # Document chunks listing is a UI-only endpoint for the document detail dialog. list_document_chunks = "UI-only endpoint for the control plane document detail dialog" +# Observation scope enumeration powers the control-plane scope filter/clusters; +# not a useful end-user CLI command. +list_observation_scopes = "UI-only endpoint for the control plane observation scope filter" + # Reprocess triggers an async retain re-run; exposed in the control plane UI only. reprocess_document = "UI-only endpoint for the control plane document detail dialog" # Clear mental model content is a new endpoint; CLI subcommand not yet implemented. clear_mental_model = "Not yet exposed in the CLI; use the HTTP API or SDK" +# Dry-run extraction is a preview/diagnostics tool (control-plane "Dry-run extraction" dialog): +# it runs extraction only, stores nothing, and previews what a candidate config would extract. +# Not an end-user CLI command; available via the HTTP API and SDKs. +dry_run_extract_memories = "Preview/diagnostics endpoint (control-plane dialog); not exposed in the CLI" + +# Memory curation (edit / invalidate / revert) is exposed via the HTTP API, SDKs, +# and the control plane, not the end-user CLI. update_memory covers all three. +update_memory = "Curation endpoint; exposed via the API, SDKs, and control plane, not the CLI" + # UI-only endpoints powering the control-plane LLM Requests (per-bank tracing) tab. # The trace waterfall, token charts, and metadata viewers don't map to a useful # CLI command. diff --git a/hindsight-cli/Cargo.toml b/hindsight-cli/Cargo.toml index c7eaa916d5..016350d5f6 100644 --- a/hindsight-cli/Cargo.toml +++ b/hindsight-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hindsight-cli" -version = "0.7.2" +version = "0.8.3" edition = "2021" authors = ["Hindsight Team"] description = "A beautiful CLI for Hindsight - semantic memory system" diff --git a/hindsight-cli/src/api.rs b/hindsight-cli/src/api.rs index ea0797c20a..eaa4e0e54f 100644 --- a/hindsight-cli/src/api.rs +++ b/hindsight-cli/src/api.rs @@ -520,7 +520,17 @@ impl ApiClient { self.runtime.block_on(async { let response = self .client - .list_memories(bank_id, None, limit, offset, q, type_filter, None) + .list_memories( + bank_id, + None, // consolidation_state + None, // document_id + limit, + offset, + q, + None, // state + type_filter, + None, // authorization + ) .await?; Ok(response.into_inner()) }) diff --git a/hindsight-cli/src/commands/bank.rs b/hindsight-cli/src/commands/bank.rs index f4ecfa1876..600c19f2f4 100644 --- a/hindsight-cli/src/commands/bank.rs +++ b/hindsight-cli/src/commands/bank.rs @@ -797,6 +797,8 @@ pub fn set_config( llm_base_url: Option, retain_mission: Option, retain_extraction_mode: Option, + retain_chunk_size: Option, + retain_structured_chunk_size: Option, observations_mission: Option, reflect_mission: Option, disposition_skepticism: Option, @@ -842,6 +844,18 @@ pub fn set_config( serde_json::Value::String(mode), ); } + if let Some(size) = retain_chunk_size { + updates.insert( + "retain_chunk_size".to_string(), + serde_json::Value::Number(size.into()), + ); + } + if let Some(size) = retain_structured_chunk_size { + updates.insert( + "retain_structured_chunk_size".to_string(), + serde_json::Value::Number(size.into()), + ); + } if let Some(mission) = observations_mission { updates.insert( "observations_mission".to_string(), @@ -874,7 +888,7 @@ pub fn set_config( } if updates.is_empty() { - return Err(anyhow!("No config updates provided. Use --llm-provider, --llm-model, --retain-mission, --observations-mission, or other flags".to_string())); + return Err(anyhow!("No config updates provided. Use --llm-provider, --llm-model, --retain-mission, --retain-chunk-size, --observations-mission, or other flags".to_string())); } let spinner = if output_format == OutputFormat::Pretty { diff --git a/hindsight-cli/src/commands/document.rs b/hindsight-cli/src/commands/document.rs index 0648ab2ac8..8da939f6f0 100644 --- a/hindsight-cli/src/commands/document.rs +++ b/hindsight-cli/src/commands/document.rs @@ -237,7 +237,10 @@ pub fn get( println!(" Created: {}", doc.created_at); println!(" Updated: {}", doc.updated_at); println!(" Memory Units: {}", doc.memory_unit_count); - println!("\n Text:\n{}", doc.original_text); + println!( + "\n Text:\n{}", + doc.original_text.as_deref().unwrap_or("(not stored)") + ); } else { output::print_output(&doc, output_format)?; } diff --git a/hindsight-cli/src/commands/memory.rs b/hindsight-cli/src/commands/memory.rs index e8c5d9c77b..41825f9e64 100644 --- a/hindsight-cli/src/commands/memory.rs +++ b/hindsight-cli/src/commands/memory.rs @@ -16,27 +16,25 @@ use hindsight_client::types::{ use serde::Deserialize; use serde_json; -// Local types for serde_json::Value deserialization +// Local types for serde_json::Value deserialization. +// +// Field names/shapes must mirror what `GET /memories/{memory_id}` actually +// returns (see MemoryEngine.get_memory_unit): the fact type is exposed as +// `type`, and `entities` is a flat list of canonical-name strings, not objects. #[derive(Debug, Deserialize)] struct MemoryUnitDetail { id: String, text: String, - #[serde(rename = "fact_type")] + #[serde(rename = "type")] type_: Option, document_id: Option, context: Option, occurred_start: Option, occurred_end: Option, - entities: Option>, + entities: Option>, tags: Option>, } -#[derive(Debug, Deserialize)] -struct EntityRef { - id: String, - name: String, -} - // Helper function to parse budget string to Budget enum fn parse_budget(budget: &str) -> Budget { match budget.to_lowercase().as_str() { @@ -224,7 +222,7 @@ pub fn get( println!(); println!("{}", ui::gradient_text("─── Entities ───")); for entity in entities { - println!(" • {} ({})", entity.name, entity.id); + println!(" • {}", entity); } } } @@ -923,4 +921,64 @@ mod tests { assert!(matches!(parse_budget(""), Budget::Mid)); assert!(matches!(parse_budget("unknown"), Budget::Mid)); } + + // Mirrors a real `GET /memories/{memory_id}` payload (see + // MemoryEngine.get_memory_unit): `type` for the fact type and `entities` + // as a flat list of canonical-name strings. This previously failed to parse + // ("Invalid API response format") because the struct expected `fact_type` + // and entity objects. + #[test] + fn test_memory_unit_detail_parses_api_response() { + let value = serde_json::json!({ + "id": "11111111-1111-1111-1111-111111111111", + "text": "Alice met Bob in Paris.", + "context": "trip notes", + "date": "2023-05-01", + "type": "experience", + "mentioned_at": null, + "occurred_start": "2023-05-01T00:00:00", + "occurred_end": null, + "entities": ["Alice", "Bob", "Paris"], + "document_id": "doc-1", + "chunk_id": null, + "tags": ["travel"], + "observation_scopes": null + }); + + let result: MemoryUnitDetail = + serde_json::from_value(value).expect("should parse API response"); + + assert_eq!(result.id, "11111111-1111-1111-1111-111111111111"); + assert_eq!(result.text, "Alice met Bob in Paris."); + assert_eq!(result.type_.as_deref(), Some("experience")); + assert_eq!(result.document_id.as_deref(), Some("doc-1")); + assert_eq!( + result.entities, + Some(vec![ + "Alice".to_string(), + "Bob".to_string(), + "Paris".to_string() + ]) + ); + assert_eq!(result.tags, Some(vec!["travel".to_string()])); + } + + // A world/experience fact has no entities/tags populated; the response still + // parses with those fields absent or empty. + #[test] + fn test_memory_unit_detail_parses_minimal_response() { + let value = serde_json::json!({ + "id": "22222222-2222-2222-2222-222222222222", + "text": "The sky is blue.", + "type": "world", + "entities": [], + "tags": [] + }); + + let result: MemoryUnitDetail = + serde_json::from_value(value).expect("should parse minimal response"); + + assert_eq!(result.type_.as_deref(), Some("world")); + assert_eq!(result.entities, Some(vec![])); + } } diff --git a/hindsight-cli/src/main.rs b/hindsight-cli/src/main.rs index ac7b0dffd7..01809874b4 100644 --- a/hindsight-cli/src/main.rs +++ b/hindsight-cli/src/main.rs @@ -369,6 +369,14 @@ enum BankCommands { #[arg(long)] retain_extraction_mode: Option, + /// Target maximum characters for each content chunk during retain + #[arg(long, value_parser = clap::value_parser!(i64).range(1..))] + retain_chunk_size: Option, + + /// Maximum characters for a JSONL line or conversation turn to keep whole during retain + #[arg(long, value_parser = clap::value_parser!(i64).range(1..))] + retain_structured_chunk_size: Option, + /// Observations mission: what to synthesize into durable observations #[arg(long)] observations_mission: Option, @@ -1294,6 +1302,8 @@ fn run() -> Result<()> { llm_base_url, retain_mission, retain_extraction_mode, + retain_chunk_size, + retain_structured_chunk_size, observations_mission, reflect_mission, disposition_skepticism, @@ -1308,6 +1318,8 @@ fn run() -> Result<()> { llm_base_url, retain_mission, retain_extraction_mode, + retain_chunk_size, + retain_structured_chunk_size, observations_mission, reflect_mission, disposition_skepticism, @@ -2083,7 +2095,11 @@ fn handle_profile(cmd: ProfileCommands, output_format: OutputFormat) -> Result<( } let deleted = Config::delete_profile(&name)?; if output_format == OutputFormat::Pretty { - ui::print_success(&format!("Deleted profile '{}' ({})", name, deleted.display())); + ui::print_success(&format!( + "Deleted profile '{}' ({})", + name, + deleted.display() + )); } else { output::print_output( &serde_json::json!({ diff --git a/hindsight-clients/go/api/openapi.yaml b/hindsight-clients/go/api/openapi.yaml index f460d46427..2e8b9d5f24 100644 --- a/hindsight-clients/go/api/openapi.yaml +++ b/hindsight-clients/go/api/openapi.yaml @@ -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.7.2 + version: 0.8.3 servers: - url: / paths: @@ -55,7 +55,7 @@ paths: /v1/default/banks/{bank_id}/graph: get: description: "Retrieve graph data for visualization, optionally filtered by\ - \ type (world/experience/opinion)." + \ type (world/experience/observation)." operationId: get_graph parameters: - explode: false @@ -190,6 +190,22 @@ paths: nullable: true type: string style: form + - explode: true + in: query + name: state + required: false + schema: + nullable: true + type: string + style: form + - explode: true + in: query + name: document_id + required: false + schema: + nullable: true + type: string + style: form - explode: true in: query name: limit @@ -232,6 +248,54 @@ paths: summary: List memory units tags: - Memory + /v1/default/banks/{bank_id}/memories/dry-run-extract: + post: + description: "Preview what the retain step would extract from text WITHOUT changing\ + \ the bank — no entity resolution, links, embeddings, or persistence. Returns\ + \ the candidate facts and the LLM token usage. Every prompt-affecting setting\ + \ (retain mission, extraction mode, chunk size, …) is overridable in the body\ + \ to A/B a candidate config against the bank's current one. This is a read-only\ + \ tool: nothing is stored." + operationId: dry_run_extract_memories + parameters: + - explode: false + in: path + name: bank_id + required: true + schema: + title: Bank Id + type: string + style: simple + - explode: false + in: header + name: authorization + required: false + schema: + nullable: true + type: string + style: simple + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DryRunExtractRequest' + required: true + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/DryRunExtractionResult' + description: Successful Response + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: "Dry-run fact extraction (preview, no persistence)" + tags: + - Memory /v1/default/banks/{bank_id}/memories/{memory_id}: get: description: "Get a single memory unit by ID with all its metadata including\ @@ -278,6 +342,58 @@ paths: summary: Get memory unit tags: - Memory + patch: + description: "Edit a memory's text and/or change its curation state (invalidate\ + \ / revert). Invalidated memories are excluded from recall, consolidation,\ + \ and graph maintenance but kept for audit (reversible). Only world/experience\ + \ facts can be curated; observations are derived." + operationId: update_memory + parameters: + - explode: false + in: path + name: bank_id + required: true + schema: + title: Bank Id + type: string + style: simple + - explode: false + in: path + name: memory_id + required: true + schema: + title: Memory Id + type: string + style: simple + - explode: false + in: header + name: authorization + required: false + schema: + nullable: true + type: string + style: simple + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateMemoryRequest' + required: true + responses: + "200": + content: + application/json: + schema: {} + description: Successful Response + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Curate memory unit + tags: + - Memory /v1/default/banks/{bank_id}/memories/{memory_id}/history: get: description: "Get the full history of an observation, with each change's source\ @@ -374,12 +490,12 @@ paths: /v1/default/banks/{bank_id}/reflect: post: description: |- - Reflect and formulate an answer using bank identity, world facts, and opinions. + Reflect and formulate an answer using bank identity, world facts, observations, and mental models. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query - 3. Retrieves existing opinions (bank's perspectives) + 3. Retrieves observations and mental models (bank's synthesized perspectives) 4. Uses LLM to formulate a contextual answer 5. Returns plain text answer and the facts used operationId: reflect @@ -488,6 +604,48 @@ paths: summary: Get statistics for memory bank tags: - Banks + /v1/default/banks/{bank_id}/health/llm: + post: + description: "Probe the LLMs this bank would use for retain / consolidation\ + \ / reflect with one minimal call each (configs shared across operations are\ + \ probed once), so you can discover 'not configured / unreachable' instead\ + \ of a silent stall. Deliberate action (makes a real provider call); not for\ + \ polling. Returns status only — never the provider, model, endpoint, API\ + \ key, or raw error. Disable with HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH=false." + operationId: test_bank_llm + parameters: + - explode: false + in: path + name: bank_id + required: true + schema: + title: Bank Id + type: string + style: simple + - explode: false + in: header + name: authorization + required: false + schema: + nullable: true + type: string + style: simple + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/BankLlmHealthResponse' + description: Successful Response + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: Test the bank's LLM connectivity + tags: + - Banks /v1/default/banks/{bank_id}/stats/memories-timeseries: get: description: "Memories ingested over a period, bucketed by time and broken down\ @@ -2724,6 +2882,48 @@ paths: summary: Clear all observations tags: - Banks + /v1/default/banks/{bank_id}/observations/scopes: + get: + description: "Enumerate the distinct scopes across a bank's observations. Each\ + \ observation lives under a scope: the exact set of tags it was consolidated\ + \ with. Returns every distinct scope (tag order normalized) with the number\ + \ of observations in it; the empty tag list is the global/untagged scope.\ + \ Use a returned scope with the graph endpoint (tags= & tags_match=exact)\ + \ to filter observations to exactly that scope." + operationId: list_observation_scopes + parameters: + - explode: false + in: path + name: bank_id + required: true + schema: + title: Bank Id + type: string + style: simple + - explode: false + in: header + name: authorization + required: false + schema: + nullable: true + type: string + style: simple + responses: + "200": + content: + application/json: + schema: + $ref: '#/components/schemas/ObservationScopesResponse' + description: Successful Response + "422": + content: + application/json: + schema: + $ref: '#/components/schemas/HTTPValidationError' + description: Validation Error + summary: List observation scopes + tags: + - Memory /v1/default/banks/{bank_id}/consolidation/recover: post: description: Reset all memories that were permanently marked as failed during @@ -3221,9 +3421,9 @@ paths: /v1/default/banks/{bank_id}/memories: delete: description: "Delete memory units for a memory bank. Optionally filter by type\ - \ (world, experience, opinion) to delete only specific types. This is a destructive\ - \ operation that cannot be undone. The bank profile (disposition and background)\ - \ will be preserved." + \ (world, experience, observation) to delete only specific types. This is\ + \ a destructive operation that cannot be undone. The bank profile (disposition\ + \ and background) will be preserved." operationId: clear_bank_memories parameters: - explode: false @@ -3234,7 +3434,7 @@ paths: title: Bank Id type: string style: simple - - description: "Optional fact type filter (world, experience, opinion)" + - description: "Optional fact type filter (world, experience, observation)" explode: true in: query name: type @@ -3341,7 +3541,7 @@ paths: This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) + - Supports PDF, DOCX, PPTX, XLSX, images (parser-dependent OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags @@ -4109,6 +4309,40 @@ components: required: - banks title: BankListResponse + BankLlmHealthResponse: + description: |- + Per-bank LLM connectivity probe across retain/consolidation/reflect. Operations + that share a configuration are probed once. Discloses status only — never the + provider, model, endpoint, API key, or raw error. + example: + bank_id: user123 + operations: + - latency_ms: 412.0 + ok: true + operation: retain + status: connected + - latency_ms: 412.0 + ok: true + operation: consolidation + status: connected + - ok: false + operation: reflect + status: not_configured + properties: + bank_id: + description: Bank identifier + title: Bank Id + type: string + operations: + description: "Connectivity status per operation (retain, consolidation,\ + \ reflect)" + items: + $ref: '#/components/schemas/LlmOperationHealth' + type: array + required: + - bank_id + - operations + title: BankLlmHealthResponse BankProfileResponse: description: Response model for bank profile. example: @@ -4268,6 +4502,9 @@ components: retain_chunk_size: nullable: true type: integer + retain_structured_chunk_size: + nullable: true + type: integer enable_observations: nullable: true type: boolean @@ -4323,6 +4560,11 @@ components: max_observations_per_scope: nullable: true type: integer + observation_scope_limits: + items: + additionalProperties: {} + nullable: true + type: array reflect_source_facts_max_tokens: nullable: true type: integer @@ -4781,6 +5023,9 @@ components: retain_chunk_size: nullable: true type: integer + retain_structured_chunk_size: + nullable: true + type: integer enable_observations: nullable: true type: boolean @@ -4912,7 +5157,8 @@ components: event_types: default: - consolidation.completed - description: "List of event types to deliver. Currently supported: 'consolidation.completed'" + description: "List of event types to deliver. Supported: 'retain.completed',\ + \ 'consolidation.completed', 'memory_defense.triggered'." items: type: string type: array @@ -5135,7 +5381,7 @@ components: title: Bank Id type: string original_text: - title: Original Text + nullable: true type: string content_hash: nullable: true @@ -5165,6 +5411,8 @@ components: retain_params: additionalProperties: {} nullable: true + observation_scopes: + $ref: '#/components/schemas/Observation_Scopes' required: - bank_id - content_hash @@ -5174,6 +5422,105 @@ components: - original_text - updated_at title: DocumentResponse + DryRunExtractRequest: + description: |- + Request to run fact extraction ONLY (no resolution/links/embeddings/persistence). + + Every field below the content/context/date is a prompt-affecting override applied just for this + call — used to preview what a candidate retain mission (or any extraction setting) would extract, + without changing the bank. Unset (null) fields fall back to the bank's resolved config. + example: + retain_extraction_mode: retain_extraction_mode + retain_mission: retain_mission + agent_name: agent_name + retain_chunk_size: 0 + context: "" + llm_output_language: llm_output_language + retain_extract_causal_links: true + entity_labels: + - "" + - "" + retain_custom_instructions: retain_custom_instructions + content: content + entities_allow_free_form: true + timestamp: 2000-01-23T04:56:07.000+00:00 + properties: + content: + description: Text to extract facts from (e.g. a document or a single chunk). + title: Content + type: string + context: + default: "" + description: Optional context about the content. + title: Context + type: string + timestamp: + format: date-time + nullable: true + type: string + agent_name: + nullable: true + type: string + retain_mission: + nullable: true + type: string + retain_extraction_mode: + nullable: true + type: string + retain_custom_instructions: + nullable: true + type: string + retain_extract_causal_links: + nullable: true + type: boolean + retain_chunk_size: + nullable: true + type: integer + entity_labels: + items: {} + nullable: true + type: array + entities_allow_free_form: + nullable: true + type: boolean + llm_output_language: + nullable: true + type: string + required: + - content + title: DryRunExtractRequest + DryRunExtractionResult: + description: "Result of dry-run fact extraction: candidate facts plus aggregated\ + \ LLM token usage." + example: + usage: + input_tokens: 1500 + output_tokens: 500 + total_tokens: 2000 + facts: + - occurred_start: occurred_start + entities: + - entities + - entities + occurred_end: occurred_end + text: text + fact_type: fact_type + - occurred_start: occurred_start + entities: + - entities + - entities + occurred_end: occurred_end + text: text + fact_type: fact_type + properties: + facts: + description: Candidate facts the retain step would extract. + items: + $ref: '#/components/schemas/ExtractedFact' + type: array + usage: + $ref: '#/components/schemas/TokenUsage' + title: DryRunExtractionResult EntityDetailResponse: description: Response model for entity detail endpoint. example: @@ -5382,6 +5729,45 @@ components: - entity_id - observations title: EntityStateResponse + ExtractedFact: + description: |- + A single candidate fact produced by dry-run extraction (no resolution/links/persistence). + + A deliberate subset of the persisted memory-unit shape — only the fields a fresh extraction + yields. Storage/consolidation/curation fields (id, document_id, chunk_id, proof_count, state, …) + are omitted because nothing is stored. Entities are raw, unresolved names. + example: + occurred_start: occurred_start + entities: + - entities + - entities + occurred_end: occurred_end + text: text + fact_type: fact_type + properties: + text: + description: The extracted fact text. + title: Text + type: string + fact_type: + description: "Perspective classification: 'world' or 'experience'." + title: Fact Type + type: string + occurred_start: + nullable: true + type: string + occurred_end: + nullable: true + type: string + entities: + description: Raw (unresolved) entity names mentioned in the fact. + items: + type: string + type: array + required: + - fact_type + - text + title: ExtractedFact FactsIncludeOptions: description: Options for including facts (based_on) in reflect results. properties: {} @@ -5406,6 +5792,10 @@ components: description: Whether per-bank configuration API is enabled title: Bank Config Api type: boolean + bank_llm_health: + description: Whether the per-bank LLM connectivity probe is enabled + title: Bank Llm Health + type: boolean file_upload_api: description: Whether file upload/conversion API is enabled title: File Upload Api @@ -5426,15 +5816,22 @@ components: description: Whether per-bank LLM request tracing is enabled title: Llm Trace type: boolean + store_document_text: + description: "Whether raw source text is persisted. When false, document/chunk\ + \ source text is not stored." + title: Store Document Text + type: boolean required: - audit_log - bank_config_api + - bank_llm_health - document_export_api - document_import_api - file_upload_api - llm_trace - mcp - observations + - store_document_text - worker title: FeaturesInfo FileRetainResponse: @@ -5977,6 +6374,43 @@ components: - offset - total title: ListTagsResponse + LlmOperationHealth: + description: |- + LLM connectivity status for a single operation. Status only — no provider/model/ + endpoint/error, so the probe never discloses the LLM configuration. + properties: + operation: + description: Operation whose LLM was probed + enum: + - retain + - consolidation + - reflect + title: LlmHealthOperation + type: string + ok: + description: True only when the probe connected successfully + title: Ok + type: boolean + status: + description: '''connected''; ''not_configured'' (provider is ''none''); + ''auth_failed'' (rejected — usually a wrong/expired API key); ''unreachable'' + (call failed); ''timeout''' + enum: + - connected + - not_configured + - auth_failed + - unreachable + - timeout + title: LlmHealthStatus + type: string + latency_ms: + nullable: true + type: number + required: + - ok + - operation + - status + title: LlmOperationHealth MemoriesTimeseriesResponse: description: Time-series of memory ingestion bucketed by time and fact type. example: @@ -6327,6 +6761,7 @@ components: - all - any_strict - all_strict + - exact nullable: true type: string tag_groups: @@ -6415,6 +6850,7 @@ components: - all - any_strict - all_strict + - exact nullable: true type: string tag_groups: @@ -6432,6 +6868,46 @@ components: nullable: true type: integer title: MentalModelTrigger + ObservationScope: + description: "A distinct observation scope: an exact tag set plus its observation\ + \ count." + properties: + tags: + description: The exact tag set defining this scope (normalized order). Empty + list is the global/untagged scope. + items: + type: string + type: array + count: + description: Number of observations that live under this scope + title: Count + type: integer + required: + - count + - tags + title: ObservationScope + ObservationScopesResponse: + description: Response model for the observation scopes enumeration endpoint. + example: + scopes: + - count: 12 + tags: + - user:alice + - count: 4 + tags: + - user:alice + - project:apollo + - count: 2 + tags: [] + properties: + scopes: + description: "Distinct observation scopes, most populous first" + items: + $ref: '#/components/schemas/ObservationScope' + type: array + required: + - scopes + title: ObservationScopesResponse OperationProgress: description: |- Last-known progress snapshot for a long-running async operation. @@ -6667,6 +7143,7 @@ components: - all - any_strict - all_strict + - exact title: Tags Match type: string tag_groups: @@ -6989,6 +7466,7 @@ components: - all - any_strict - all_strict + - exact title: Tags Match type: string tag_groups: @@ -7290,6 +7768,7 @@ components: - all - any_strict - all_strict + - exact title: Match type: string required: @@ -7453,6 +7932,45 @@ components: title: Success type: boolean title: UpdateDocumentResponse + UpdateMemoryRequest: + description: |- + Request model for curating a single memory unit (edit / invalidate / revert). + + Provide ``text`` to correct the fact, and/or ``state`` to invalidate + ('invalidated') or revert ('valid') it. ``reason`` is optional free text + recorded on the memory. At least one of ``text`` or ``state`` must be set. + Only world/experience facts can be curated; observations are derived. + example: + reason: "superseded: server decommissioned 2026-06-01" + state: invalidated + properties: + text: + nullable: true + type: string + context: + nullable: true + type: string + occurred_start: + nullable: true + type: string + occurred_end: + nullable: true + type: string + fact_type: + nullable: true + type: string + entities: + items: + type: string + nullable: true + type: array + state: + nullable: true + type: string + reason: + nullable: true + type: string + title: UpdateMemoryRequest UpdateMentalModelRequest: description: Request model for updating a mental model. example: @@ -7809,6 +8327,20 @@ components: - id - url title: WebhookResponse + Observation_Scopes: + anyOf: + - type: string + - items: + items: + type: string + type: array + type: array + description: "The observation_scopes spec configured at retain time (e.g. 'all_combinations',\ + \ 'per_tag', or explicit tag-set lists), captured into retain_params. None\ + \ when none was set (default 'combined' scoping) or for documents retained\ + \ before this was captured." + nullable: true + title: Observation Scopes Timestamp: anyOf: - format: date-time @@ -7826,6 +8358,7 @@ components: - per_tag - combined - all_combinations + - shared type: string - items: items: @@ -7835,8 +8368,11 @@ components: description: "How to scope observations during consolidation. 'per_tag' runs\ \ one consolidation pass per individual tag, creating separate observations\ \ for each tag. 'combined' (default) runs a single pass with all tags together.\ - \ A list of tag lists runs one pass per inner list, giving full control over\ - \ which combinations to use." + \ 'shared' runs a single pass over one global, untagged scope, so memories\ + \ consolidate together regardless of their tags — useful for deduplicating\ + \ across volatile per-call provenance tags (e.g. per-session ids) while keeping\ + \ those tags on the source facts. A list of tag lists runs one pass per inner\ + \ list, giving full control over which combinations to use." nullable: true title: ObservationScopes MentalModelTrigger_Input_tag_groups_inner: diff --git a/hindsight-clients/go/api_audit.go b/hindsight-clients/go/api_audit.go index 67a3df3b2f..3629202be5 100644 --- a/hindsight-clients/go/api_audit.go +++ b/hindsight-clients/go/api_audit.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/api_bank_templates.go b/hindsight-clients/go/api_bank_templates.go index 564c2f8684..65bef29316 100644 --- a/hindsight-clients/go/api_bank_templates.go +++ b/hindsight-clients/go/api_bank_templates.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/api_banks.go b/hindsight-clients/go/api_banks.go index 605586ff5a..b8a873228c 100644 --- a/hindsight-clients/go/api_banks.go +++ b/hindsight-clients/go/api_banks.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. @@ -1414,6 +1414,128 @@ func (a *BanksAPIService) ResetBankConfigExecute(r ApiResetBankConfigRequest) (* return localVarReturnValue, localVarHTTPResponse, nil } +type ApiTestBankLlmRequest struct { + ctx context.Context + ApiService *BanksAPIService + bankId string + authorization *string +} + +func (r ApiTestBankLlmRequest) Authorization(authorization string) ApiTestBankLlmRequest { + r.authorization = &authorization + return r +} + +func (r ApiTestBankLlmRequest) Execute() (*BankLlmHealthResponse, *http.Response, error) { + return r.ApiService.TestBankLlmExecute(r) +} + +/* +TestBankLlm Test the bank's LLM connectivity + +Probe the LLMs this bank would use for retain / consolidation / reflect with one minimal call each (configs shared across operations are probed once), so you can discover 'not configured / unreachable' instead of a silent stall. Deliberate action (makes a real provider call); not for polling. Returns status only — never the provider, model, endpoint, API key, or raw error. Disable with HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH=false. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param bankId + @return ApiTestBankLlmRequest +*/ +func (a *BanksAPIService) TestBankLlm(ctx context.Context, bankId string) ApiTestBankLlmRequest { + return ApiTestBankLlmRequest{ + ApiService: a, + ctx: ctx, + bankId: bankId, + } +} + +// Execute executes the request +// @return BankLlmHealthResponse +func (a *BanksAPIService) TestBankLlmExecute(r ApiTestBankLlmRequest) (*BankLlmHealthResponse, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPost + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *BankLlmHealthResponse + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "BanksAPIService.TestBankLlm") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v1/default/banks/{bank_id}/health/llm" + localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + if r.authorization != nil { + parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "") + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 422 { + var v HTTPValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type ApiTriggerConsolidationRequest struct { ctx context.Context ApiService *BanksAPIService diff --git a/hindsight-clients/go/api_directives.go b/hindsight-clients/go/api_directives.go index a409a5006a..f9df0c5e8f 100644 --- a/hindsight-clients/go/api_directives.go +++ b/hindsight-clients/go/api_directives.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/api_document_transfer.go b/hindsight-clients/go/api_document_transfer.go index 2ae4e0c125..e60e3a8a56 100644 --- a/hindsight-clients/go/api_document_transfer.go +++ b/hindsight-clients/go/api_document_transfer.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/api_documents.go b/hindsight-clients/go/api_documents.go index d9ddd2c2e5..1fe2d38873 100644 --- a/hindsight-clients/go/api_documents.go +++ b/hindsight-clients/go/api_documents.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/api_entities.go b/hindsight-clients/go/api_entities.go index 7ceb9d7ad2..7b2df005fb 100644 --- a/hindsight-clients/go/api_entities.go +++ b/hindsight-clients/go/api_entities.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/api_files.go b/hindsight-clients/go/api_files.go index 25335d7f0c..1dd65dc208 100644 --- a/hindsight-clients/go/api_files.go +++ b/hindsight-clients/go/api_files.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. @@ -62,7 +62,7 @@ Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** -- Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) +- Supports PDF, DOCX, PPTX, XLSX, images (parser-dependent OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags diff --git a/hindsight-clients/go/api_llm_traces.go b/hindsight-clients/go/api_llm_traces.go index 911fa970cd..690bb0d1c5 100644 --- a/hindsight-clients/go/api_llm_traces.go +++ b/hindsight-clients/go/api_llm_traces.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/api_memory.go b/hindsight-clients/go/api_memory.go index da204a0996..95c8a5ad58 100644 --- a/hindsight-clients/go/api_memory.go +++ b/hindsight-clients/go/api_memory.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. @@ -32,7 +32,7 @@ type ApiClearBankMemoriesRequest struct { authorization *string } -// Optional fact type filter (world, experience, opinion) +// Optional fact type filter (world, experience, observation) func (r ApiClearBankMemoriesRequest) Type_(type_ string) ApiClearBankMemoriesRequest { r.type_ = &type_ return r @@ -50,7 +50,7 @@ func (r ApiClearBankMemoriesRequest) Execute() (*DeleteResponse, *http.Response, /* ClearBankMemories Clear memory bank memories -Delete memory units for a memory bank. Optionally filter by type (world, experience, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved. +Delete memory units for a memory bank. Optionally filter by type (world, experience, observation) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved. @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). @param bankId @@ -282,6 +282,139 @@ func (a *MemoryAPIService) ClearMemoryObservationsExecute(r ApiClearMemoryObserv return localVarReturnValue, localVarHTTPResponse, nil } +type ApiDryRunExtractMemoriesRequest struct { + ctx context.Context + ApiService *MemoryAPIService + bankId string + dryRunExtractRequest *DryRunExtractRequest + authorization *string +} + +func (r ApiDryRunExtractMemoriesRequest) DryRunExtractRequest(dryRunExtractRequest DryRunExtractRequest) ApiDryRunExtractMemoriesRequest { + r.dryRunExtractRequest = &dryRunExtractRequest + return r +} + +func (r ApiDryRunExtractMemoriesRequest) Authorization(authorization string) ApiDryRunExtractMemoriesRequest { + r.authorization = &authorization + return r +} + +func (r ApiDryRunExtractMemoriesRequest) Execute() (*DryRunExtractionResult, *http.Response, error) { + return r.ApiService.DryRunExtractMemoriesExecute(r) +} + +/* +DryRunExtractMemories Dry-run fact extraction (preview, no persistence) + +Preview what the retain step would extract from text WITHOUT changing the bank — no entity resolution, links, embeddings, or persistence. Returns the candidate facts and the LLM token usage. Every prompt-affecting setting (retain mission, extraction mode, chunk size, …) is overridable in the body to A/B a candidate config against the bank's current one. This is a read-only tool: nothing is stored. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param bankId + @return ApiDryRunExtractMemoriesRequest +*/ +func (a *MemoryAPIService) DryRunExtractMemories(ctx context.Context, bankId string) ApiDryRunExtractMemoriesRequest { + return ApiDryRunExtractMemoriesRequest{ + ApiService: a, + ctx: ctx, + bankId: bankId, + } +} + +// Execute executes the request +// @return DryRunExtractionResult +func (a *MemoryAPIService) DryRunExtractMemoriesExecute(r ApiDryRunExtractMemoriesRequest) (*DryRunExtractionResult, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPost + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *DryRunExtractionResult + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MemoryAPIService.DryRunExtractMemories") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v1/default/banks/{bank_id}/memories/dry-run-extract" + localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.dryRunExtractRequest == nil { + return localVarReturnValue, nil, reportError("dryRunExtractRequest is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + if r.authorization != nil { + parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "") + } + // body params + localVarPostBody = r.dryRunExtractRequest + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 422 { + var v HTTPValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type ApiGetGraphRequest struct { ctx context.Context ApiService *MemoryAPIService @@ -343,7 +476,7 @@ func (r ApiGetGraphRequest) Execute() (*GraphDataResponse, *http.Response, error /* GetGraph Get memory graph data -Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). +Retrieve graph data for visualization, optionally filtered by type (world/experience/observation). @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). @param bankId @@ -740,6 +873,8 @@ type ApiListMemoriesRequest struct { type_ *string q *string consolidationState *string + state *string + documentId *string limit *int32 offset *int32 authorization *string @@ -760,6 +895,16 @@ func (r ApiListMemoriesRequest) ConsolidationState(consolidationState string) Ap return r } +func (r ApiListMemoriesRequest) State(state string) ApiListMemoriesRequest { + r.state = &state + return r +} + +func (r ApiListMemoriesRequest) DocumentId(documentId string) ApiListMemoriesRequest { + r.documentId = &documentId + return r +} + func (r ApiListMemoriesRequest) Limit(limit int32) ApiListMemoriesRequest { r.limit = &limit return r @@ -827,6 +972,12 @@ func (a *MemoryAPIService) ListMemoriesExecute(r ApiListMemoriesRequest) (*ListM if r.consolidationState != nil { parameterAddToHeaderOrQuery(localVarQueryParams, "consolidation_state", r.consolidationState, "form", "") } + if r.state != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "state", r.state, "form", "") + } + if r.documentId != nil { + parameterAddToHeaderOrQuery(localVarQueryParams, "document_id", r.documentId, "form", "") + } if r.limit != nil { parameterAddToHeaderOrQuery(localVarQueryParams, "limit", r.limit, "form", "") } else { @@ -906,6 +1057,128 @@ func (a *MemoryAPIService) ListMemoriesExecute(r ApiListMemoriesRequest) (*ListM return localVarReturnValue, localVarHTTPResponse, nil } +type ApiListObservationScopesRequest struct { + ctx context.Context + ApiService *MemoryAPIService + bankId string + authorization *string +} + +func (r ApiListObservationScopesRequest) Authorization(authorization string) ApiListObservationScopesRequest { + r.authorization = &authorization + return r +} + +func (r ApiListObservationScopesRequest) Execute() (*ObservationScopesResponse, *http.Response, error) { + return r.ApiService.ListObservationScopesExecute(r) +} + +/* +ListObservationScopes List observation scopes + +Enumerate the distinct scopes across a bank's observations. Each observation lives under a scope: the exact set of tags it was consolidated with. Returns every distinct scope (tag order normalized) with the number of observations in it; the empty tag list is the global/untagged scope. Use a returned scope with the graph endpoint (tags= & tags_match=exact) to filter observations to exactly that scope. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param bankId + @return ApiListObservationScopesRequest +*/ +func (a *MemoryAPIService) ListObservationScopes(ctx context.Context, bankId string) ApiListObservationScopesRequest { + return ApiListObservationScopesRequest{ + ApiService: a, + ctx: ctx, + bankId: bankId, + } +} + +// Execute executes the request +// @return ObservationScopesResponse +func (a *MemoryAPIService) ListObservationScopesExecute(r ApiListObservationScopesRequest) (*ObservationScopesResponse, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodGet + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue *ObservationScopesResponse + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MemoryAPIService.ListObservationScopes") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v1/default/banks/{bank_id}/observations/scopes" + localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + if r.authorization != nil { + parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "") + } + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 422 { + var v HTTPValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} + type ApiListTagsRequest struct { ctx context.Context ApiService *MemoryAPIService @@ -1239,12 +1512,12 @@ func (r ApiReflectRequest) Execute() (*ReflectResponse, *http.Response, error) { /* Reflect Reflect and generate answer -Reflect and formulate an answer using bank identity, world facts, and opinions. +Reflect and formulate an answer using bank identity, world facts, observations, and mental models. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query -3. Retrieves existing opinions (bank's perspectives) +3. Retrieves observations and mental models (bank's synthesized perspectives) 4. Uses LLM to formulate a contextual answer 5. Returns plain text answer and the facts used @@ -1509,3 +1782,140 @@ func (a *MemoryAPIService) RetainMemoriesExecute(r ApiRetainMemoriesRequest) (*R return localVarReturnValue, localVarHTTPResponse, nil } + +type ApiUpdateMemoryRequest struct { + ctx context.Context + ApiService *MemoryAPIService + bankId string + memoryId string + updateMemoryRequest *UpdateMemoryRequest + authorization *string +} + +func (r ApiUpdateMemoryRequest) UpdateMemoryRequest(updateMemoryRequest UpdateMemoryRequest) ApiUpdateMemoryRequest { + r.updateMemoryRequest = &updateMemoryRequest + return r +} + +func (r ApiUpdateMemoryRequest) Authorization(authorization string) ApiUpdateMemoryRequest { + r.authorization = &authorization + return r +} + +func (r ApiUpdateMemoryRequest) Execute() (interface{}, *http.Response, error) { + return r.ApiService.UpdateMemoryExecute(r) +} + +/* +UpdateMemory Curate memory unit + +Edit a memory's text and/or change its curation state (invalidate / revert). Invalidated memories are excluded from recall, consolidation, and graph maintenance but kept for audit (reversible). Only world/experience facts can be curated; observations are derived. + + @param ctx context.Context - for authentication, logging, cancellation, deadlines, tracing, etc. Passed from http.Request or context.Background(). + @param bankId + @param memoryId + @return ApiUpdateMemoryRequest +*/ +func (a *MemoryAPIService) UpdateMemory(ctx context.Context, bankId string, memoryId string) ApiUpdateMemoryRequest { + return ApiUpdateMemoryRequest{ + ApiService: a, + ctx: ctx, + bankId: bankId, + memoryId: memoryId, + } +} + +// Execute executes the request +// @return interface{} +func (a *MemoryAPIService) UpdateMemoryExecute(r ApiUpdateMemoryRequest) (interface{}, *http.Response, error) { + var ( + localVarHTTPMethod = http.MethodPatch + localVarPostBody interface{} + formFiles []formFile + localVarReturnValue interface{} + ) + + localBasePath, err := a.client.cfg.ServerURLWithContext(r.ctx, "MemoryAPIService.UpdateMemory") + if err != nil { + return localVarReturnValue, nil, &GenericOpenAPIError{error: err.Error()} + } + + localVarPath := localBasePath + "/v1/default/banks/{bank_id}/memories/{memory_id}" + localVarPath = strings.Replace(localVarPath, "{"+"bank_id"+"}", url.PathEscape(parameterValueToString(r.bankId, "bankId")), -1) + localVarPath = strings.Replace(localVarPath, "{"+"memory_id"+"}", url.PathEscape(parameterValueToString(r.memoryId, "memoryId")), -1) + + localVarHeaderParams := make(map[string]string) + localVarQueryParams := url.Values{} + localVarFormParams := url.Values{} + if r.updateMemoryRequest == nil { + return localVarReturnValue, nil, reportError("updateMemoryRequest is required and must be specified") + } + + // to determine the Content-Type header + localVarHTTPContentTypes := []string{"application/json"} + + // set Content-Type header + localVarHTTPContentType := selectHeaderContentType(localVarHTTPContentTypes) + if localVarHTTPContentType != "" { + localVarHeaderParams["Content-Type"] = localVarHTTPContentType + } + + // to determine the Accept header + localVarHTTPHeaderAccepts := []string{"application/json"} + + // set Accept header + localVarHTTPHeaderAccept := selectHeaderAccept(localVarHTTPHeaderAccepts) + if localVarHTTPHeaderAccept != "" { + localVarHeaderParams["Accept"] = localVarHTTPHeaderAccept + } + if r.authorization != nil { + parameterAddToHeaderOrQuery(localVarHeaderParams, "authorization", r.authorization, "simple", "") + } + // body params + localVarPostBody = r.updateMemoryRequest + req, err := a.client.prepareRequest(r.ctx, localVarPath, localVarHTTPMethod, localVarPostBody, localVarHeaderParams, localVarQueryParams, localVarFormParams, formFiles) + if err != nil { + return localVarReturnValue, nil, err + } + + localVarHTTPResponse, err := a.client.callAPI(req) + if err != nil || localVarHTTPResponse == nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + localVarBody, err := io.ReadAll(localVarHTTPResponse.Body) + localVarHTTPResponse.Body.Close() + localVarHTTPResponse.Body = io.NopCloser(bytes.NewBuffer(localVarBody)) + if err != nil { + return localVarReturnValue, localVarHTTPResponse, err + } + + if localVarHTTPResponse.StatusCode >= 300 { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: localVarHTTPResponse.Status, + } + if localVarHTTPResponse.StatusCode == 422 { + var v HTTPValidationError + err = a.client.decode(&v, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr.error = err.Error() + return localVarReturnValue, localVarHTTPResponse, newErr + } + newErr.error = formatErrorMessage(localVarHTTPResponse.Status, &v) + newErr.model = v + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + err = a.client.decode(&localVarReturnValue, localVarBody, localVarHTTPResponse.Header.Get("Content-Type")) + if err != nil { + newErr := &GenericOpenAPIError{ + body: localVarBody, + error: err.Error(), + } + return localVarReturnValue, localVarHTTPResponse, newErr + } + + return localVarReturnValue, localVarHTTPResponse, nil +} diff --git a/hindsight-clients/go/api_mental_models.go b/hindsight-clients/go/api_mental_models.go index aadc7c49ae..0b5f50b713 100644 --- a/hindsight-clients/go/api_mental_models.go +++ b/hindsight-clients/go/api_mental_models.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/api_monitoring.go b/hindsight-clients/go/api_monitoring.go index 58be1c3c3e..f1894541cf 100644 --- a/hindsight-clients/go/api_monitoring.go +++ b/hindsight-clients/go/api_monitoring.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/api_operations.go b/hindsight-clients/go/api_operations.go index 00d333b088..e1c000030a 100644 --- a/hindsight-clients/go/api_operations.go +++ b/hindsight-clients/go/api_operations.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/api_webhooks.go b/hindsight-clients/go/api_webhooks.go index de841bc625..08ab79401c 100644 --- a/hindsight-clients/go/api_webhooks.go +++ b/hindsight-clients/go/api_webhooks.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/client.go b/hindsight-clients/go/client.go index fcd6feb8e7..3ae49dd8dd 100644 --- a/hindsight-clients/go/client.go +++ b/hindsight-clients/go/client.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // 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.7.2 +// APIClient manages communication with the Hindsight HTTP API API v0.8.3 // In most cases there should be only one, shared, APIClient. type APIClient struct { cfg *Configuration diff --git a/hindsight-clients/go/configuration.go b/hindsight-clients/go/configuration.go index 9806954113..403c5a07f2 100644 --- a/hindsight-clients/go/configuration.go +++ b/hindsight-clients/go/configuration.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_add_background_request.go b/hindsight-clients/go/model_add_background_request.go index 0aa897bc4d..77cd2e0810 100644 --- a/hindsight-clients/go/model_add_background_request.go +++ b/hindsight-clients/go/model_add_background_request.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_async_operation_submit_response.go b/hindsight-clients/go/model_async_operation_submit_response.go index 8584a76474..c91166eb98 100644 --- a/hindsight-clients/go/model_async_operation_submit_response.go +++ b/hindsight-clients/go/model_async_operation_submit_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_audit_log_entry.go b/hindsight-clients/go/model_audit_log_entry.go index 1d2a318cde..7548e5d4ea 100644 --- a/hindsight-clients/go/model_audit_log_entry.go +++ b/hindsight-clients/go/model_audit_log_entry.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_audit_log_list_response.go b/hindsight-clients/go/model_audit_log_list_response.go index e16f2b3c3f..8cd32f1605 100644 --- a/hindsight-clients/go/model_audit_log_list_response.go +++ b/hindsight-clients/go/model_audit_log_list_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_audit_log_stats_bucket.go b/hindsight-clients/go/model_audit_log_stats_bucket.go index 3c972349d8..0171fda514 100644 --- a/hindsight-clients/go/model_audit_log_stats_bucket.go +++ b/hindsight-clients/go/model_audit_log_stats_bucket.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_audit_log_stats_response.go b/hindsight-clients/go/model_audit_log_stats_response.go index 59218e6fcd..e8cfc60bc4 100644 --- a/hindsight-clients/go/model_audit_log_stats_response.go +++ b/hindsight-clients/go/model_audit_log_stats_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_background_response.go b/hindsight-clients/go/model_background_response.go index 02bcc60b4e..2ddd298545 100644 --- a/hindsight-clients/go/model_background_response.go +++ b/hindsight-clients/go/model_background_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_bank_config_response.go b/hindsight-clients/go/model_bank_config_response.go index 8bcd6bf505..2812a27138 100644 --- a/hindsight-clients/go/model_bank_config_response.go +++ b/hindsight-clients/go/model_bank_config_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_bank_config_update.go b/hindsight-clients/go/model_bank_config_update.go index e4dc21b286..cb8449901e 100644 --- a/hindsight-clients/go/model_bank_config_update.go +++ b/hindsight-clients/go/model_bank_config_update.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_bank_list_item.go b/hindsight-clients/go/model_bank_list_item.go index 224a7f8da5..2f78ca22de 100644 --- a/hindsight-clients/go/model_bank_list_item.go +++ b/hindsight-clients/go/model_bank_list_item.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_bank_list_response.go b/hindsight-clients/go/model_bank_list_response.go index f9542a964f..0c8be93642 100644 --- a/hindsight-clients/go/model_bank_list_response.go +++ b/hindsight-clients/go/model_bank_list_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_bank_llm_health_response.go b/hindsight-clients/go/model_bank_llm_health_response.go new file mode 100644 index 0000000000..6f5c2cb5c1 --- /dev/null +++ b/hindsight-clients/go/model_bank_llm_health_response.go @@ -0,0 +1,188 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.8.3 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" + "bytes" + "fmt" +) + +// checks if the BankLlmHealthResponse type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &BankLlmHealthResponse{} + +// BankLlmHealthResponse Per-bank LLM connectivity probe across retain/consolidation/reflect. Operations that share a configuration are probed once. Discloses status only — never the provider, model, endpoint, API key, or raw error. +type BankLlmHealthResponse struct { + // Bank identifier + BankId string `json:"bank_id"` + // Connectivity status per operation (retain, consolidation, reflect) + Operations []LlmOperationHealth `json:"operations"` +} + +type _BankLlmHealthResponse BankLlmHealthResponse + +// NewBankLlmHealthResponse instantiates a new BankLlmHealthResponse object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewBankLlmHealthResponse(bankId string, operations []LlmOperationHealth) *BankLlmHealthResponse { + this := BankLlmHealthResponse{} + this.BankId = bankId + this.Operations = operations + return &this +} + +// NewBankLlmHealthResponseWithDefaults instantiates a new BankLlmHealthResponse object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewBankLlmHealthResponseWithDefaults() *BankLlmHealthResponse { + this := BankLlmHealthResponse{} + return &this +} + +// GetBankId returns the BankId field value +func (o *BankLlmHealthResponse) GetBankId() string { + if o == nil { + var ret string + return ret + } + + return o.BankId +} + +// GetBankIdOk returns a tuple with the BankId field value +// and a boolean to check if the value has been set. +func (o *BankLlmHealthResponse) GetBankIdOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.BankId, true +} + +// SetBankId sets field value +func (o *BankLlmHealthResponse) SetBankId(v string) { + o.BankId = v +} + +// GetOperations returns the Operations field value +func (o *BankLlmHealthResponse) GetOperations() []LlmOperationHealth { + if o == nil { + var ret []LlmOperationHealth + return ret + } + + return o.Operations +} + +// GetOperationsOk returns a tuple with the Operations field value +// and a boolean to check if the value has been set. +func (o *BankLlmHealthResponse) GetOperationsOk() ([]LlmOperationHealth, bool) { + if o == nil { + return nil, false + } + return o.Operations, true +} + +// SetOperations sets field value +func (o *BankLlmHealthResponse) SetOperations(v []LlmOperationHealth) { + o.Operations = v +} + +func (o BankLlmHealthResponse) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o BankLlmHealthResponse) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["bank_id"] = o.BankId + toSerialize["operations"] = o.Operations + return toSerialize, nil +} + +func (o *BankLlmHealthResponse) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "bank_id", + "operations", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err; + } + + for _, requiredProperty := range(requiredProperties) { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varBankLlmHealthResponse := _BankLlmHealthResponse{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varBankLlmHealthResponse) + + if err != nil { + return err + } + + *o = BankLlmHealthResponse(varBankLlmHealthResponse) + + return err +} + +type NullableBankLlmHealthResponse struct { + value *BankLlmHealthResponse + isSet bool +} + +func (v NullableBankLlmHealthResponse) Get() *BankLlmHealthResponse { + return v.value +} + +func (v *NullableBankLlmHealthResponse) Set(val *BankLlmHealthResponse) { + v.value = val + v.isSet = true +} + +func (v NullableBankLlmHealthResponse) IsSet() bool { + return v.isSet +} + +func (v *NullableBankLlmHealthResponse) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableBankLlmHealthResponse(val *BankLlmHealthResponse) *NullableBankLlmHealthResponse { + return &NullableBankLlmHealthResponse{value: val, isSet: true} +} + +func (v NullableBankLlmHealthResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableBankLlmHealthResponse) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/go/model_bank_profile_response.go b/hindsight-clients/go/model_bank_profile_response.go index 13c34cb4e1..3577c4cd7f 100644 --- a/hindsight-clients/go/model_bank_profile_response.go +++ b/hindsight-clients/go/model_bank_profile_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_bank_stats_response.go b/hindsight-clients/go/model_bank_stats_response.go index a6ec9d479e..964ab8b678 100644 --- a/hindsight-clients/go/model_bank_stats_response.go +++ b/hindsight-clients/go/model_bank_stats_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_bank_template_config.go b/hindsight-clients/go/model_bank_template_config.go index f7fcff45a9..b2d76944d4 100644 --- a/hindsight-clients/go/model_bank_template_config.go +++ b/hindsight-clients/go/model_bank_template_config.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. @@ -24,6 +24,7 @@ type BankTemplateConfig struct { RetainExtractionMode NullableString `json:"retain_extraction_mode,omitempty"` RetainCustomInstructions NullableString `json:"retain_custom_instructions,omitempty"` RetainChunkSize NullableInt32 `json:"retain_chunk_size,omitempty"` + RetainStructuredChunkSize NullableInt32 `json:"retain_structured_chunk_size,omitempty"` EnableObservations NullableBool `json:"enable_observations,omitempty"` ObservationsMission NullableString `json:"observations_mission,omitempty"` DispositionSkepticism NullableInt32 `json:"disposition_skepticism,omitempty"` @@ -39,6 +40,7 @@ type BankTemplateConfig struct { ConsolidationSourceFactsMaxTokens NullableInt32 `json:"consolidation_source_facts_max_tokens,omitempty"` ConsolidationSourceFactsMaxTokensPerObservation NullableInt32 `json:"consolidation_source_facts_max_tokens_per_observation,omitempty"` MaxObservationsPerScope NullableInt32 `json:"max_observations_per_scope,omitempty"` + ObservationScopeLimits []map[string]interface{} `json:"observation_scope_limits,omitempty"` ReflectSourceFactsMaxTokens NullableInt32 `json:"reflect_source_facts_max_tokens,omitempty"` LlmGeminiSafetySettings []interface{} `json:"llm_gemini_safety_settings,omitempty"` RecallBudgetFunction NullableString `json:"recall_budget_function,omitempty"` @@ -279,6 +281,48 @@ func (o *BankTemplateConfig) UnsetRetainChunkSize() { o.RetainChunkSize.Unset() } +// GetRetainStructuredChunkSize returns the RetainStructuredChunkSize field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *BankTemplateConfig) GetRetainStructuredChunkSize() int32 { + if o == nil || IsNil(o.RetainStructuredChunkSize.Get()) { + var ret int32 + return ret + } + return *o.RetainStructuredChunkSize.Get() +} + +// GetRetainStructuredChunkSizeOk returns a tuple with the RetainStructuredChunkSize field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *BankTemplateConfig) GetRetainStructuredChunkSizeOk() (*int32, bool) { + if o == nil { + return nil, false + } + return o.RetainStructuredChunkSize.Get(), o.RetainStructuredChunkSize.IsSet() +} + +// HasRetainStructuredChunkSize returns a boolean if a field has been set. +func (o *BankTemplateConfig) HasRetainStructuredChunkSize() bool { + if o != nil && o.RetainStructuredChunkSize.IsSet() { + return true + } + + return false +} + +// SetRetainStructuredChunkSize gets a reference to the given NullableInt32 and assigns it to the RetainStructuredChunkSize field. +func (o *BankTemplateConfig) SetRetainStructuredChunkSize(v int32) { + o.RetainStructuredChunkSize.Set(&v) +} +// SetRetainStructuredChunkSizeNil sets the value for RetainStructuredChunkSize to be an explicit nil +func (o *BankTemplateConfig) SetRetainStructuredChunkSizeNil() { + o.RetainStructuredChunkSize.Set(nil) +} + +// UnsetRetainStructuredChunkSize ensures that no value is present for RetainStructuredChunkSize, not even an explicit nil +func (o *BankTemplateConfig) UnsetRetainStructuredChunkSize() { + o.RetainStructuredChunkSize.Unset() +} + // GetEnableObservations returns the EnableObservations field value if set, zero value otherwise (both if not set or set to explicit null). func (o *BankTemplateConfig) GetEnableObservations() bool { if o == nil || IsNil(o.EnableObservations.Get()) { @@ -882,6 +926,39 @@ func (o *BankTemplateConfig) UnsetMaxObservationsPerScope() { o.MaxObservationsPerScope.Unset() } +// GetObservationScopeLimits returns the ObservationScopeLimits field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *BankTemplateConfig) GetObservationScopeLimits() []map[string]interface{} { + if o == nil { + var ret []map[string]interface{} + return ret + } + return o.ObservationScopeLimits +} + +// GetObservationScopeLimitsOk returns a tuple with the ObservationScopeLimits field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *BankTemplateConfig) GetObservationScopeLimitsOk() ([]map[string]interface{}, bool) { + if o == nil || IsNil(o.ObservationScopeLimits) { + return nil, false + } + return o.ObservationScopeLimits, true +} + +// HasObservationScopeLimits returns a boolean if a field has been set. +func (o *BankTemplateConfig) HasObservationScopeLimits() bool { + if o != nil && !IsNil(o.ObservationScopeLimits) { + return true + } + + return false +} + +// SetObservationScopeLimits gets a reference to the given []map[string]interface{} and assigns it to the ObservationScopeLimits field. +func (o *BankTemplateConfig) SetObservationScopeLimits(v []map[string]interface{}) { + o.ObservationScopeLimits = v +} + // GetReflectSourceFactsMaxTokens returns the ReflectSourceFactsMaxTokens field value if set, zero value otherwise (both if not set or set to explicit null). func (o *BankTemplateConfig) GetReflectSourceFactsMaxTokens() int32 { if o == nil || IsNil(o.ReflectSourceFactsMaxTokens.Get()) { @@ -1360,6 +1437,9 @@ func (o BankTemplateConfig) ToMap() (map[string]interface{}, error) { if o.RetainChunkSize.IsSet() { toSerialize["retain_chunk_size"] = o.RetainChunkSize.Get() } + if o.RetainStructuredChunkSize.IsSet() { + toSerialize["retain_structured_chunk_size"] = o.RetainStructuredChunkSize.Get() + } if o.EnableObservations.IsSet() { toSerialize["enable_observations"] = o.EnableObservations.Get() } @@ -1405,6 +1485,9 @@ func (o BankTemplateConfig) ToMap() (map[string]interface{}, error) { if o.MaxObservationsPerScope.IsSet() { toSerialize["max_observations_per_scope"] = o.MaxObservationsPerScope.Get() } + if o.ObservationScopeLimits != nil { + toSerialize["observation_scope_limits"] = o.ObservationScopeLimits + } if o.ReflectSourceFactsMaxTokens.IsSet() { toSerialize["reflect_source_facts_max_tokens"] = o.ReflectSourceFactsMaxTokens.Get() } diff --git a/hindsight-clients/go/model_bank_template_directive.go b/hindsight-clients/go/model_bank_template_directive.go index d90a0e7c53..e420f7195b 100644 --- a/hindsight-clients/go/model_bank_template_directive.go +++ b/hindsight-clients/go/model_bank_template_directive.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_bank_template_import_response.go b/hindsight-clients/go/model_bank_template_import_response.go index 851aea295c..fe72608985 100644 --- a/hindsight-clients/go/model_bank_template_import_response.go +++ b/hindsight-clients/go/model_bank_template_import_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_bank_template_manifest.go b/hindsight-clients/go/model_bank_template_manifest.go index 2bfdf385e3..2288913060 100644 --- a/hindsight-clients/go/model_bank_template_manifest.go +++ b/hindsight-clients/go/model_bank_template_manifest.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_bank_template_mental_model.go b/hindsight-clients/go/model_bank_template_mental_model.go index f544302241..f4a70a1322 100644 --- a/hindsight-clients/go/model_bank_template_mental_model.go +++ b/hindsight-clients/go/model_bank_template_mental_model.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_budget.go b/hindsight-clients/go/model_budget.go index c56df8aae5..88c9a54cb6 100644 --- a/hindsight-clients/go/model_budget.go +++ b/hindsight-clients/go/model_budget.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_cancel_operation_response.go b/hindsight-clients/go/model_cancel_operation_response.go index c301495988..26ab7a48b9 100644 --- a/hindsight-clients/go/model_cancel_operation_response.go +++ b/hindsight-clients/go/model_cancel_operation_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_child_operation_status.go b/hindsight-clients/go/model_child_operation_status.go index b8002538ec..3437f5b54f 100644 --- a/hindsight-clients/go/model_child_operation_status.go +++ b/hindsight-clients/go/model_child_operation_status.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_chunk_data.go b/hindsight-clients/go/model_chunk_data.go index 9be8a4a5da..fa71e9f548 100644 --- a/hindsight-clients/go/model_chunk_data.go +++ b/hindsight-clients/go/model_chunk_data.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_chunk_include_options.go b/hindsight-clients/go/model_chunk_include_options.go index 2b0482aa1a..110f88ccb0 100644 --- a/hindsight-clients/go/model_chunk_include_options.go +++ b/hindsight-clients/go/model_chunk_include_options.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_chunk_response.go b/hindsight-clients/go/model_chunk_response.go index 3a099d6ab5..b39b4f780e 100644 --- a/hindsight-clients/go/model_chunk_response.go +++ b/hindsight-clients/go/model_chunk_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_clear_memory_observations_response.go b/hindsight-clients/go/model_clear_memory_observations_response.go index c47892a0b1..3d04d57f2a 100644 --- a/hindsight-clients/go/model_clear_memory_observations_response.go +++ b/hindsight-clients/go/model_clear_memory_observations_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_consolidation_request.go b/hindsight-clients/go/model_consolidation_request.go index d4bda702b6..fd63dc1193 100644 --- a/hindsight-clients/go/model_consolidation_request.go +++ b/hindsight-clients/go/model_consolidation_request.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_consolidation_response.go b/hindsight-clients/go/model_consolidation_response.go index b4118a2bd7..ee2cebf35c 100644 --- a/hindsight-clients/go/model_consolidation_response.go +++ b/hindsight-clients/go/model_consolidation_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_create_bank_request.go b/hindsight-clients/go/model_create_bank_request.go index b77ef505aa..0830216d71 100644 --- a/hindsight-clients/go/model_create_bank_request.go +++ b/hindsight-clients/go/model_create_bank_request.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. @@ -31,6 +31,7 @@ type CreateBankRequest struct { RetainExtractionMode NullableString `json:"retain_extraction_mode,omitempty"` RetainCustomInstructions NullableString `json:"retain_custom_instructions,omitempty"` RetainChunkSize NullableInt32 `json:"retain_chunk_size,omitempty"` + RetainStructuredChunkSize NullableInt32 `json:"retain_structured_chunk_size,omitempty"` EnableObservations NullableBool `json:"enable_observations,omitempty"` ObservationsMission NullableString `json:"observations_mission,omitempty"` } @@ -556,6 +557,48 @@ func (o *CreateBankRequest) UnsetRetainChunkSize() { o.RetainChunkSize.Unset() } +// GetRetainStructuredChunkSize returns the RetainStructuredChunkSize field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *CreateBankRequest) GetRetainStructuredChunkSize() int32 { + if o == nil || IsNil(o.RetainStructuredChunkSize.Get()) { + var ret int32 + return ret + } + return *o.RetainStructuredChunkSize.Get() +} + +// GetRetainStructuredChunkSizeOk returns a tuple with the RetainStructuredChunkSize field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *CreateBankRequest) GetRetainStructuredChunkSizeOk() (*int32, bool) { + if o == nil { + return nil, false + } + return o.RetainStructuredChunkSize.Get(), o.RetainStructuredChunkSize.IsSet() +} + +// HasRetainStructuredChunkSize returns a boolean if a field has been set. +func (o *CreateBankRequest) HasRetainStructuredChunkSize() bool { + if o != nil && o.RetainStructuredChunkSize.IsSet() { + return true + } + + return false +} + +// SetRetainStructuredChunkSize gets a reference to the given NullableInt32 and assigns it to the RetainStructuredChunkSize field. +func (o *CreateBankRequest) SetRetainStructuredChunkSize(v int32) { + o.RetainStructuredChunkSize.Set(&v) +} +// SetRetainStructuredChunkSizeNil sets the value for RetainStructuredChunkSize to be an explicit nil +func (o *CreateBankRequest) SetRetainStructuredChunkSizeNil() { + o.RetainStructuredChunkSize.Set(nil) +} + +// UnsetRetainStructuredChunkSize ensures that no value is present for RetainStructuredChunkSize, not even an explicit nil +func (o *CreateBankRequest) UnsetRetainStructuredChunkSize() { + o.RetainStructuredChunkSize.Unset() +} + // GetEnableObservations returns the EnableObservations field value if set, zero value otherwise (both if not set or set to explicit null). func (o *CreateBankRequest) GetEnableObservations() bool { if o == nil || IsNil(o.EnableObservations.Get()) { @@ -686,6 +729,9 @@ func (o CreateBankRequest) ToMap() (map[string]interface{}, error) { if o.RetainChunkSize.IsSet() { toSerialize["retain_chunk_size"] = o.RetainChunkSize.Get() } + if o.RetainStructuredChunkSize.IsSet() { + toSerialize["retain_structured_chunk_size"] = o.RetainStructuredChunkSize.Get() + } if o.EnableObservations.IsSet() { toSerialize["enable_observations"] = o.EnableObservations.Get() } diff --git a/hindsight-clients/go/model_create_directive_request.go b/hindsight-clients/go/model_create_directive_request.go index 6c0a1dcdaf..874036770d 100644 --- a/hindsight-clients/go/model_create_directive_request.go +++ b/hindsight-clients/go/model_create_directive_request.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_create_mental_model_request.go b/hindsight-clients/go/model_create_mental_model_request.go index 87fac63c28..072fe7be97 100644 --- a/hindsight-clients/go/model_create_mental_model_request.go +++ b/hindsight-clients/go/model_create_mental_model_request.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_create_mental_model_response.go b/hindsight-clients/go/model_create_mental_model_response.go index 6f8af9f7dd..2a49f6ed55 100644 --- a/hindsight-clients/go/model_create_mental_model_response.go +++ b/hindsight-clients/go/model_create_mental_model_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_create_webhook_request.go b/hindsight-clients/go/model_create_webhook_request.go index 07b572dbcd..886a1240b6 100644 --- a/hindsight-clients/go/model_create_webhook_request.go +++ b/hindsight-clients/go/model_create_webhook_request.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. @@ -24,7 +24,7 @@ type CreateWebhookRequest struct { // HTTP(S) endpoint URL to deliver events to Url string `json:"url"` Secret NullableString `json:"secret,omitempty"` - // List of event types to deliver. Currently supported: 'consolidation.completed' + // List of event types to deliver. Supported: 'retain.completed', 'consolidation.completed', 'memory_defense.triggered'. EventTypes []string `json:"event_types,omitempty"` // Whether this webhook is active Enabled *bool `json:"enabled,omitempty"` diff --git a/hindsight-clients/go/model_delete_document_response.go b/hindsight-clients/go/model_delete_document_response.go index e7e0562057..2acc6b7419 100644 --- a/hindsight-clients/go/model_delete_document_response.go +++ b/hindsight-clients/go/model_delete_document_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_delete_response.go b/hindsight-clients/go/model_delete_response.go index e340ae0466..04352b33af 100644 --- a/hindsight-clients/go/model_delete_response.go +++ b/hindsight-clients/go/model_delete_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_directive_list_response.go b/hindsight-clients/go/model_directive_list_response.go index 881c61b882..a53d595684 100644 --- a/hindsight-clients/go/model_directive_list_response.go +++ b/hindsight-clients/go/model_directive_list_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_directive_response.go b/hindsight-clients/go/model_directive_response.go index ed31b06cf7..41ea04b7ef 100644 --- a/hindsight-clients/go/model_directive_response.go +++ b/hindsight-clients/go/model_directive_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_disposition_traits.go b/hindsight-clients/go/model_disposition_traits.go index 3ea44cfca3..30b435e3ce 100644 --- a/hindsight-clients/go/model_disposition_traits.go +++ b/hindsight-clients/go/model_disposition_traits.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_document_import_submit_response.go b/hindsight-clients/go/model_document_import_submit_response.go index 2496b0f74a..d3514d961c 100644 --- a/hindsight-clients/go/model_document_import_submit_response.go +++ b/hindsight-clients/go/model_document_import_submit_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_document_response.go b/hindsight-clients/go/model_document_response.go index 6425dfa129..3312dbb7a6 100644 --- a/hindsight-clients/go/model_document_response.go +++ b/hindsight-clients/go/model_document_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. @@ -23,7 +23,7 @@ var _ MappedNullable = &DocumentResponse{} type DocumentResponse struct { Id string `json:"id"` BankId string `json:"bank_id"` - OriginalText string `json:"original_text"` + OriginalText NullableString `json:"original_text"` ContentHash NullableString `json:"content_hash"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` @@ -33,6 +33,7 @@ type DocumentResponse struct { Tags []string `json:"tags,omitempty"` DocumentMetadata map[string]interface{} `json:"document_metadata,omitempty"` RetainParams map[string]interface{} `json:"retain_params,omitempty"` + ObservationScopes NullableObservationScopes `json:"observation_scopes,omitempty"` } type _DocumentResponse DocumentResponse @@ -41,7 +42,7 @@ type _DocumentResponse DocumentResponse // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewDocumentResponse(id string, bankId string, originalText string, contentHash NullableString, createdAt string, updatedAt string, memoryUnitCount int32) *DocumentResponse { +func NewDocumentResponse(id string, bankId string, originalText NullableString, contentHash NullableString, createdAt string, updatedAt string, memoryUnitCount int32) *DocumentResponse { this := DocumentResponse{} this.Id = id this.BankId = bankId @@ -110,27 +111,29 @@ func (o *DocumentResponse) SetBankId(v string) { } // GetOriginalText returns the OriginalText field value +// If the value is explicit nil, the zero value for string will be returned func (o *DocumentResponse) GetOriginalText() string { - if o == nil { + if o == nil || o.OriginalText.Get() == nil { var ret string return ret } - return o.OriginalText + return *o.OriginalText.Get() } // GetOriginalTextOk returns a tuple with the OriginalText field value // and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned func (o *DocumentResponse) GetOriginalTextOk() (*string, bool) { if o == nil { return nil, false } - return &o.OriginalText, true + return o.OriginalText.Get(), o.OriginalText.IsSet() } // SetOriginalText sets field value func (o *DocumentResponse) SetOriginalText(v string) { - o.OriginalText = v + o.OriginalText.Set(&v) } // GetContentHash returns the ContentHash field value @@ -362,6 +365,48 @@ func (o *DocumentResponse) SetRetainParams(v map[string]interface{}) { o.RetainParams = v } +// GetObservationScopes returns the ObservationScopes field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *DocumentResponse) GetObservationScopes() ObservationScopes { + if o == nil || IsNil(o.ObservationScopes.Get()) { + var ret ObservationScopes + return ret + } + return *o.ObservationScopes.Get() +} + +// GetObservationScopesOk returns a tuple with the ObservationScopes field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *DocumentResponse) GetObservationScopesOk() (*ObservationScopes, bool) { + if o == nil { + return nil, false + } + return o.ObservationScopes.Get(), o.ObservationScopes.IsSet() +} + +// HasObservationScopes returns a boolean if a field has been set. +func (o *DocumentResponse) HasObservationScopes() bool { + if o != nil && o.ObservationScopes.IsSet() { + return true + } + + return false +} + +// SetObservationScopes gets a reference to the given NullableObservationScopes and assigns it to the ObservationScopes field. +func (o *DocumentResponse) SetObservationScopes(v ObservationScopes) { + o.ObservationScopes.Set(&v) +} +// SetObservationScopesNil sets the value for ObservationScopes to be an explicit nil +func (o *DocumentResponse) SetObservationScopesNil() { + o.ObservationScopes.Set(nil) +} + +// UnsetObservationScopes ensures that no value is present for ObservationScopes, not even an explicit nil +func (o *DocumentResponse) UnsetObservationScopes() { + o.ObservationScopes.Unset() +} + func (o DocumentResponse) MarshalJSON() ([]byte, error) { toSerialize,err := o.ToMap() if err != nil { @@ -374,7 +419,7 @@ func (o DocumentResponse) ToMap() (map[string]interface{}, error) { toSerialize := map[string]interface{}{} toSerialize["id"] = o.Id toSerialize["bank_id"] = o.BankId - toSerialize["original_text"] = o.OriginalText + toSerialize["original_text"] = o.OriginalText.Get() toSerialize["content_hash"] = o.ContentHash.Get() toSerialize["created_at"] = o.CreatedAt toSerialize["updated_at"] = o.UpdatedAt @@ -391,6 +436,9 @@ func (o DocumentResponse) ToMap() (map[string]interface{}, error) { if o.RetainParams != nil { toSerialize["retain_params"] = o.RetainParams } + if o.ObservationScopes.IsSet() { + toSerialize["observation_scopes"] = o.ObservationScopes.Get() + } return toSerialize, nil } diff --git a/hindsight-clients/go/model_dry_run_extract_request.go b/hindsight-clients/go/model_dry_run_extract_request.go new file mode 100644 index 0000000000..440645f25a --- /dev/null +++ b/hindsight-clients/go/model_dry_run_extract_request.go @@ -0,0 +1,652 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.8.3 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" + "time" + "bytes" + "fmt" +) + +// checks if the DryRunExtractRequest type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &DryRunExtractRequest{} + +// DryRunExtractRequest Request to run fact extraction ONLY (no resolution/links/embeddings/persistence). Every field below the content/context/date is a prompt-affecting override applied just for this call — used to preview what a candidate retain mission (or any extraction setting) would extract, without changing the bank. Unset (null) fields fall back to the bank's resolved config. +type DryRunExtractRequest struct { + // Text to extract facts from (e.g. a document or a single chunk). + Content string `json:"content"` + // Optional context about the content. + Context *string `json:"context,omitempty"` + Timestamp NullableTime `json:"timestamp,omitempty"` + AgentName NullableString `json:"agent_name,omitempty"` + RetainMission NullableString `json:"retain_mission,omitempty"` + RetainExtractionMode NullableString `json:"retain_extraction_mode,omitempty"` + RetainCustomInstructions NullableString `json:"retain_custom_instructions,omitempty"` + RetainExtractCausalLinks NullableBool `json:"retain_extract_causal_links,omitempty"` + RetainChunkSize NullableInt32 `json:"retain_chunk_size,omitempty"` + EntityLabels []interface{} `json:"entity_labels,omitempty"` + EntitiesAllowFreeForm NullableBool `json:"entities_allow_free_form,omitempty"` + LlmOutputLanguage NullableString `json:"llm_output_language,omitempty"` +} + +type _DryRunExtractRequest DryRunExtractRequest + +// NewDryRunExtractRequest instantiates a new DryRunExtractRequest object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewDryRunExtractRequest(content string) *DryRunExtractRequest { + this := DryRunExtractRequest{} + this.Content = content + var context string = "" + this.Context = &context + return &this +} + +// NewDryRunExtractRequestWithDefaults instantiates a new DryRunExtractRequest object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewDryRunExtractRequestWithDefaults() *DryRunExtractRequest { + this := DryRunExtractRequest{} + var context string = "" + this.Context = &context + return &this +} + +// GetContent returns the Content field value +func (o *DryRunExtractRequest) GetContent() string { + if o == nil { + var ret string + return ret + } + + return o.Content +} + +// GetContentOk returns a tuple with the Content field value +// and a boolean to check if the value has been set. +func (o *DryRunExtractRequest) GetContentOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Content, true +} + +// SetContent sets field value +func (o *DryRunExtractRequest) SetContent(v string) { + o.Content = v +} + +// GetContext returns the Context field value if set, zero value otherwise. +func (o *DryRunExtractRequest) GetContext() string { + if o == nil || IsNil(o.Context) { + var ret string + return ret + } + return *o.Context +} + +// GetContextOk returns a tuple with the Context field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DryRunExtractRequest) GetContextOk() (*string, bool) { + if o == nil || IsNil(o.Context) { + return nil, false + } + return o.Context, true +} + +// HasContext returns a boolean if a field has been set. +func (o *DryRunExtractRequest) HasContext() bool { + if o != nil && !IsNil(o.Context) { + return true + } + + return false +} + +// SetContext gets a reference to the given string and assigns it to the Context field. +func (o *DryRunExtractRequest) SetContext(v string) { + o.Context = &v +} + +// GetTimestamp returns the Timestamp field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *DryRunExtractRequest) GetTimestamp() time.Time { + if o == nil || IsNil(o.Timestamp.Get()) { + var ret time.Time + return ret + } + return *o.Timestamp.Get() +} + +// GetTimestampOk returns a tuple with the Timestamp field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *DryRunExtractRequest) GetTimestampOk() (*time.Time, bool) { + if o == nil { + return nil, false + } + return o.Timestamp.Get(), o.Timestamp.IsSet() +} + +// HasTimestamp returns a boolean if a field has been set. +func (o *DryRunExtractRequest) HasTimestamp() bool { + if o != nil && o.Timestamp.IsSet() { + return true + } + + return false +} + +// SetTimestamp gets a reference to the given NullableTime and assigns it to the Timestamp field. +func (o *DryRunExtractRequest) SetTimestamp(v time.Time) { + o.Timestamp.Set(&v) +} +// SetTimestampNil sets the value for Timestamp to be an explicit nil +func (o *DryRunExtractRequest) SetTimestampNil() { + o.Timestamp.Set(nil) +} + +// UnsetTimestamp ensures that no value is present for Timestamp, not even an explicit nil +func (o *DryRunExtractRequest) UnsetTimestamp() { + o.Timestamp.Unset() +} + +// GetAgentName returns the AgentName field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *DryRunExtractRequest) GetAgentName() string { + if o == nil || IsNil(o.AgentName.Get()) { + var ret string + return ret + } + return *o.AgentName.Get() +} + +// GetAgentNameOk returns a tuple with the AgentName field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *DryRunExtractRequest) GetAgentNameOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.AgentName.Get(), o.AgentName.IsSet() +} + +// HasAgentName returns a boolean if a field has been set. +func (o *DryRunExtractRequest) HasAgentName() bool { + if o != nil && o.AgentName.IsSet() { + return true + } + + return false +} + +// SetAgentName gets a reference to the given NullableString and assigns it to the AgentName field. +func (o *DryRunExtractRequest) SetAgentName(v string) { + o.AgentName.Set(&v) +} +// SetAgentNameNil sets the value for AgentName to be an explicit nil +func (o *DryRunExtractRequest) SetAgentNameNil() { + o.AgentName.Set(nil) +} + +// UnsetAgentName ensures that no value is present for AgentName, not even an explicit nil +func (o *DryRunExtractRequest) UnsetAgentName() { + o.AgentName.Unset() +} + +// GetRetainMission returns the RetainMission field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *DryRunExtractRequest) GetRetainMission() string { + if o == nil || IsNil(o.RetainMission.Get()) { + var ret string + return ret + } + return *o.RetainMission.Get() +} + +// GetRetainMissionOk returns a tuple with the RetainMission field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *DryRunExtractRequest) GetRetainMissionOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.RetainMission.Get(), o.RetainMission.IsSet() +} + +// HasRetainMission returns a boolean if a field has been set. +func (o *DryRunExtractRequest) HasRetainMission() bool { + if o != nil && o.RetainMission.IsSet() { + return true + } + + return false +} + +// SetRetainMission gets a reference to the given NullableString and assigns it to the RetainMission field. +func (o *DryRunExtractRequest) SetRetainMission(v string) { + o.RetainMission.Set(&v) +} +// SetRetainMissionNil sets the value for RetainMission to be an explicit nil +func (o *DryRunExtractRequest) SetRetainMissionNil() { + o.RetainMission.Set(nil) +} + +// UnsetRetainMission ensures that no value is present for RetainMission, not even an explicit nil +func (o *DryRunExtractRequest) UnsetRetainMission() { + o.RetainMission.Unset() +} + +// GetRetainExtractionMode returns the RetainExtractionMode field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *DryRunExtractRequest) GetRetainExtractionMode() string { + if o == nil || IsNil(o.RetainExtractionMode.Get()) { + var ret string + return ret + } + return *o.RetainExtractionMode.Get() +} + +// GetRetainExtractionModeOk returns a tuple with the RetainExtractionMode field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *DryRunExtractRequest) GetRetainExtractionModeOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.RetainExtractionMode.Get(), o.RetainExtractionMode.IsSet() +} + +// HasRetainExtractionMode returns a boolean if a field has been set. +func (o *DryRunExtractRequest) HasRetainExtractionMode() bool { + if o != nil && o.RetainExtractionMode.IsSet() { + return true + } + + return false +} + +// SetRetainExtractionMode gets a reference to the given NullableString and assigns it to the RetainExtractionMode field. +func (o *DryRunExtractRequest) SetRetainExtractionMode(v string) { + o.RetainExtractionMode.Set(&v) +} +// SetRetainExtractionModeNil sets the value for RetainExtractionMode to be an explicit nil +func (o *DryRunExtractRequest) SetRetainExtractionModeNil() { + o.RetainExtractionMode.Set(nil) +} + +// UnsetRetainExtractionMode ensures that no value is present for RetainExtractionMode, not even an explicit nil +func (o *DryRunExtractRequest) UnsetRetainExtractionMode() { + o.RetainExtractionMode.Unset() +} + +// GetRetainCustomInstructions returns the RetainCustomInstructions field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *DryRunExtractRequest) GetRetainCustomInstructions() string { + if o == nil || IsNil(o.RetainCustomInstructions.Get()) { + var ret string + return ret + } + return *o.RetainCustomInstructions.Get() +} + +// GetRetainCustomInstructionsOk returns a tuple with the RetainCustomInstructions field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *DryRunExtractRequest) GetRetainCustomInstructionsOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.RetainCustomInstructions.Get(), o.RetainCustomInstructions.IsSet() +} + +// HasRetainCustomInstructions returns a boolean if a field has been set. +func (o *DryRunExtractRequest) HasRetainCustomInstructions() bool { + if o != nil && o.RetainCustomInstructions.IsSet() { + return true + } + + return false +} + +// SetRetainCustomInstructions gets a reference to the given NullableString and assigns it to the RetainCustomInstructions field. +func (o *DryRunExtractRequest) SetRetainCustomInstructions(v string) { + o.RetainCustomInstructions.Set(&v) +} +// SetRetainCustomInstructionsNil sets the value for RetainCustomInstructions to be an explicit nil +func (o *DryRunExtractRequest) SetRetainCustomInstructionsNil() { + o.RetainCustomInstructions.Set(nil) +} + +// UnsetRetainCustomInstructions ensures that no value is present for RetainCustomInstructions, not even an explicit nil +func (o *DryRunExtractRequest) UnsetRetainCustomInstructions() { + o.RetainCustomInstructions.Unset() +} + +// GetRetainExtractCausalLinks returns the RetainExtractCausalLinks field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *DryRunExtractRequest) GetRetainExtractCausalLinks() bool { + if o == nil || IsNil(o.RetainExtractCausalLinks.Get()) { + var ret bool + return ret + } + return *o.RetainExtractCausalLinks.Get() +} + +// GetRetainExtractCausalLinksOk returns a tuple with the RetainExtractCausalLinks field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *DryRunExtractRequest) GetRetainExtractCausalLinksOk() (*bool, bool) { + if o == nil { + return nil, false + } + return o.RetainExtractCausalLinks.Get(), o.RetainExtractCausalLinks.IsSet() +} + +// HasRetainExtractCausalLinks returns a boolean if a field has been set. +func (o *DryRunExtractRequest) HasRetainExtractCausalLinks() bool { + if o != nil && o.RetainExtractCausalLinks.IsSet() { + return true + } + + return false +} + +// SetRetainExtractCausalLinks gets a reference to the given NullableBool and assigns it to the RetainExtractCausalLinks field. +func (o *DryRunExtractRequest) SetRetainExtractCausalLinks(v bool) { + o.RetainExtractCausalLinks.Set(&v) +} +// SetRetainExtractCausalLinksNil sets the value for RetainExtractCausalLinks to be an explicit nil +func (o *DryRunExtractRequest) SetRetainExtractCausalLinksNil() { + o.RetainExtractCausalLinks.Set(nil) +} + +// UnsetRetainExtractCausalLinks ensures that no value is present for RetainExtractCausalLinks, not even an explicit nil +func (o *DryRunExtractRequest) UnsetRetainExtractCausalLinks() { + o.RetainExtractCausalLinks.Unset() +} + +// GetRetainChunkSize returns the RetainChunkSize field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *DryRunExtractRequest) GetRetainChunkSize() int32 { + if o == nil || IsNil(o.RetainChunkSize.Get()) { + var ret int32 + return ret + } + return *o.RetainChunkSize.Get() +} + +// GetRetainChunkSizeOk returns a tuple with the RetainChunkSize field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *DryRunExtractRequest) GetRetainChunkSizeOk() (*int32, bool) { + if o == nil { + return nil, false + } + return o.RetainChunkSize.Get(), o.RetainChunkSize.IsSet() +} + +// HasRetainChunkSize returns a boolean if a field has been set. +func (o *DryRunExtractRequest) HasRetainChunkSize() bool { + if o != nil && o.RetainChunkSize.IsSet() { + return true + } + + return false +} + +// SetRetainChunkSize gets a reference to the given NullableInt32 and assigns it to the RetainChunkSize field. +func (o *DryRunExtractRequest) SetRetainChunkSize(v int32) { + o.RetainChunkSize.Set(&v) +} +// SetRetainChunkSizeNil sets the value for RetainChunkSize to be an explicit nil +func (o *DryRunExtractRequest) SetRetainChunkSizeNil() { + o.RetainChunkSize.Set(nil) +} + +// UnsetRetainChunkSize ensures that no value is present for RetainChunkSize, not even an explicit nil +func (o *DryRunExtractRequest) UnsetRetainChunkSize() { + o.RetainChunkSize.Unset() +} + +// GetEntityLabels returns the EntityLabels field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *DryRunExtractRequest) GetEntityLabels() []interface{} { + if o == nil { + var ret []interface{} + return ret + } + return o.EntityLabels +} + +// GetEntityLabelsOk returns a tuple with the EntityLabels field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *DryRunExtractRequest) GetEntityLabelsOk() ([]interface{}, bool) { + if o == nil || IsNil(o.EntityLabels) { + return nil, false + } + return o.EntityLabels, true +} + +// HasEntityLabels returns a boolean if a field has been set. +func (o *DryRunExtractRequest) HasEntityLabels() bool { + if o != nil && !IsNil(o.EntityLabels) { + return true + } + + return false +} + +// SetEntityLabels gets a reference to the given []interface{} and assigns it to the EntityLabels field. +func (o *DryRunExtractRequest) SetEntityLabels(v []interface{}) { + o.EntityLabels = v +} + +// GetEntitiesAllowFreeForm returns the EntitiesAllowFreeForm field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *DryRunExtractRequest) GetEntitiesAllowFreeForm() bool { + if o == nil || IsNil(o.EntitiesAllowFreeForm.Get()) { + var ret bool + return ret + } + return *o.EntitiesAllowFreeForm.Get() +} + +// GetEntitiesAllowFreeFormOk returns a tuple with the EntitiesAllowFreeForm field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *DryRunExtractRequest) GetEntitiesAllowFreeFormOk() (*bool, bool) { + if o == nil { + return nil, false + } + return o.EntitiesAllowFreeForm.Get(), o.EntitiesAllowFreeForm.IsSet() +} + +// HasEntitiesAllowFreeForm returns a boolean if a field has been set. +func (o *DryRunExtractRequest) HasEntitiesAllowFreeForm() bool { + if o != nil && o.EntitiesAllowFreeForm.IsSet() { + return true + } + + return false +} + +// SetEntitiesAllowFreeForm gets a reference to the given NullableBool and assigns it to the EntitiesAllowFreeForm field. +func (o *DryRunExtractRequest) SetEntitiesAllowFreeForm(v bool) { + o.EntitiesAllowFreeForm.Set(&v) +} +// SetEntitiesAllowFreeFormNil sets the value for EntitiesAllowFreeForm to be an explicit nil +func (o *DryRunExtractRequest) SetEntitiesAllowFreeFormNil() { + o.EntitiesAllowFreeForm.Set(nil) +} + +// UnsetEntitiesAllowFreeForm ensures that no value is present for EntitiesAllowFreeForm, not even an explicit nil +func (o *DryRunExtractRequest) UnsetEntitiesAllowFreeForm() { + o.EntitiesAllowFreeForm.Unset() +} + +// GetLlmOutputLanguage returns the LlmOutputLanguage field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *DryRunExtractRequest) GetLlmOutputLanguage() string { + if o == nil || IsNil(o.LlmOutputLanguage.Get()) { + var ret string + return ret + } + return *o.LlmOutputLanguage.Get() +} + +// GetLlmOutputLanguageOk returns a tuple with the LlmOutputLanguage field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *DryRunExtractRequest) GetLlmOutputLanguageOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.LlmOutputLanguage.Get(), o.LlmOutputLanguage.IsSet() +} + +// HasLlmOutputLanguage returns a boolean if a field has been set. +func (o *DryRunExtractRequest) HasLlmOutputLanguage() bool { + if o != nil && o.LlmOutputLanguage.IsSet() { + return true + } + + return false +} + +// SetLlmOutputLanguage gets a reference to the given NullableString and assigns it to the LlmOutputLanguage field. +func (o *DryRunExtractRequest) SetLlmOutputLanguage(v string) { + o.LlmOutputLanguage.Set(&v) +} +// SetLlmOutputLanguageNil sets the value for LlmOutputLanguage to be an explicit nil +func (o *DryRunExtractRequest) SetLlmOutputLanguageNil() { + o.LlmOutputLanguage.Set(nil) +} + +// UnsetLlmOutputLanguage ensures that no value is present for LlmOutputLanguage, not even an explicit nil +func (o *DryRunExtractRequest) UnsetLlmOutputLanguage() { + o.LlmOutputLanguage.Unset() +} + +func (o DryRunExtractRequest) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o DryRunExtractRequest) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["content"] = o.Content + if !IsNil(o.Context) { + toSerialize["context"] = o.Context + } + if o.Timestamp.IsSet() { + toSerialize["timestamp"] = o.Timestamp.Get() + } + if o.AgentName.IsSet() { + toSerialize["agent_name"] = o.AgentName.Get() + } + if o.RetainMission.IsSet() { + toSerialize["retain_mission"] = o.RetainMission.Get() + } + if o.RetainExtractionMode.IsSet() { + toSerialize["retain_extraction_mode"] = o.RetainExtractionMode.Get() + } + if o.RetainCustomInstructions.IsSet() { + toSerialize["retain_custom_instructions"] = o.RetainCustomInstructions.Get() + } + if o.RetainExtractCausalLinks.IsSet() { + toSerialize["retain_extract_causal_links"] = o.RetainExtractCausalLinks.Get() + } + if o.RetainChunkSize.IsSet() { + toSerialize["retain_chunk_size"] = o.RetainChunkSize.Get() + } + if o.EntityLabels != nil { + toSerialize["entity_labels"] = o.EntityLabels + } + if o.EntitiesAllowFreeForm.IsSet() { + toSerialize["entities_allow_free_form"] = o.EntitiesAllowFreeForm.Get() + } + if o.LlmOutputLanguage.IsSet() { + toSerialize["llm_output_language"] = o.LlmOutputLanguage.Get() + } + return toSerialize, nil +} + +func (o *DryRunExtractRequest) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "content", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err; + } + + for _, requiredProperty := range(requiredProperties) { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varDryRunExtractRequest := _DryRunExtractRequest{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varDryRunExtractRequest) + + if err != nil { + return err + } + + *o = DryRunExtractRequest(varDryRunExtractRequest) + + return err +} + +type NullableDryRunExtractRequest struct { + value *DryRunExtractRequest + isSet bool +} + +func (v NullableDryRunExtractRequest) Get() *DryRunExtractRequest { + return v.value +} + +func (v *NullableDryRunExtractRequest) Set(val *DryRunExtractRequest) { + v.value = val + v.isSet = true +} + +func (v NullableDryRunExtractRequest) IsSet() bool { + return v.isSet +} + +func (v *NullableDryRunExtractRequest) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableDryRunExtractRequest(val *DryRunExtractRequest) *NullableDryRunExtractRequest { + return &NullableDryRunExtractRequest{value: val, isSet: true} +} + +func (v NullableDryRunExtractRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableDryRunExtractRequest) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/go/model_dry_run_extraction_result.go b/hindsight-clients/go/model_dry_run_extraction_result.go new file mode 100644 index 0000000000..17173b1e92 --- /dev/null +++ b/hindsight-clients/go/model_dry_run_extraction_result.go @@ -0,0 +1,164 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.8.3 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" +) + +// checks if the DryRunExtractionResult type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &DryRunExtractionResult{} + +// DryRunExtractionResult Result of dry-run fact extraction: candidate facts plus aggregated LLM token usage. +type DryRunExtractionResult struct { + // Candidate facts the retain step would extract. + Facts []ExtractedFact `json:"facts,omitempty"` + // Aggregated token usage across the extraction LLM calls. + Usage *TokenUsage `json:"usage,omitempty"` +} + +// NewDryRunExtractionResult instantiates a new DryRunExtractionResult object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewDryRunExtractionResult() *DryRunExtractionResult { + this := DryRunExtractionResult{} + return &this +} + +// NewDryRunExtractionResultWithDefaults instantiates a new DryRunExtractionResult object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewDryRunExtractionResultWithDefaults() *DryRunExtractionResult { + this := DryRunExtractionResult{} + return &this +} + +// GetFacts returns the Facts field value if set, zero value otherwise. +func (o *DryRunExtractionResult) GetFacts() []ExtractedFact { + if o == nil || IsNil(o.Facts) { + var ret []ExtractedFact + return ret + } + return o.Facts +} + +// GetFactsOk returns a tuple with the Facts field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DryRunExtractionResult) GetFactsOk() ([]ExtractedFact, bool) { + if o == nil || IsNil(o.Facts) { + return nil, false + } + return o.Facts, true +} + +// HasFacts returns a boolean if a field has been set. +func (o *DryRunExtractionResult) HasFacts() bool { + if o != nil && !IsNil(o.Facts) { + return true + } + + return false +} + +// SetFacts gets a reference to the given []ExtractedFact and assigns it to the Facts field. +func (o *DryRunExtractionResult) SetFacts(v []ExtractedFact) { + o.Facts = v +} + +// GetUsage returns the Usage field value if set, zero value otherwise. +func (o *DryRunExtractionResult) GetUsage() TokenUsage { + if o == nil || IsNil(o.Usage) { + var ret TokenUsage + return ret + } + return *o.Usage +} + +// GetUsageOk returns a tuple with the Usage field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *DryRunExtractionResult) GetUsageOk() (*TokenUsage, bool) { + if o == nil || IsNil(o.Usage) { + return nil, false + } + return o.Usage, true +} + +// HasUsage returns a boolean if a field has been set. +func (o *DryRunExtractionResult) HasUsage() bool { + if o != nil && !IsNil(o.Usage) { + return true + } + + return false +} + +// SetUsage gets a reference to the given TokenUsage and assigns it to the Usage field. +func (o *DryRunExtractionResult) SetUsage(v TokenUsage) { + o.Usage = &v +} + +func (o DryRunExtractionResult) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o DryRunExtractionResult) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if !IsNil(o.Facts) { + toSerialize["facts"] = o.Facts + } + if !IsNil(o.Usage) { + toSerialize["usage"] = o.Usage + } + return toSerialize, nil +} + +type NullableDryRunExtractionResult struct { + value *DryRunExtractionResult + isSet bool +} + +func (v NullableDryRunExtractionResult) Get() *DryRunExtractionResult { + return v.value +} + +func (v *NullableDryRunExtractionResult) Set(val *DryRunExtractionResult) { + v.value = val + v.isSet = true +} + +func (v NullableDryRunExtractionResult) IsSet() bool { + return v.isSet +} + +func (v *NullableDryRunExtractionResult) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableDryRunExtractionResult(val *DryRunExtractionResult) *NullableDryRunExtractionResult { + return &NullableDryRunExtractionResult{value: val, isSet: true} +} + +func (v NullableDryRunExtractionResult) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableDryRunExtractionResult) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/go/model_entity_detail_response.go b/hindsight-clients/go/model_entity_detail_response.go index b4c0cb1099..2e0a84b30a 100644 --- a/hindsight-clients/go/model_entity_detail_response.go +++ b/hindsight-clients/go/model_entity_detail_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_entity_graph_response.go b/hindsight-clients/go/model_entity_graph_response.go index 185d35749d..048fcd3ec5 100644 --- a/hindsight-clients/go/model_entity_graph_response.go +++ b/hindsight-clients/go/model_entity_graph_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_entity_include_options.go b/hindsight-clients/go/model_entity_include_options.go index ab96a9dfdb..a2e6c335d5 100644 --- a/hindsight-clients/go/model_entity_include_options.go +++ b/hindsight-clients/go/model_entity_include_options.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_entity_input.go b/hindsight-clients/go/model_entity_input.go index 90fbfa6ffd..9630c5cd6d 100644 --- a/hindsight-clients/go/model_entity_input.go +++ b/hindsight-clients/go/model_entity_input.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_entity_list_item.go b/hindsight-clients/go/model_entity_list_item.go index 33de6a00ce..c935bfde69 100644 --- a/hindsight-clients/go/model_entity_list_item.go +++ b/hindsight-clients/go/model_entity_list_item.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_entity_list_response.go b/hindsight-clients/go/model_entity_list_response.go index 38b04eb6ec..de165d9095 100644 --- a/hindsight-clients/go/model_entity_list_response.go +++ b/hindsight-clients/go/model_entity_list_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_entity_observation_response.go b/hindsight-clients/go/model_entity_observation_response.go index 24c3abf022..7601e1a68b 100644 --- a/hindsight-clients/go/model_entity_observation_response.go +++ b/hindsight-clients/go/model_entity_observation_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_entity_state_response.go b/hindsight-clients/go/model_entity_state_response.go index 0fe04f7904..0da5cb5dfb 100644 --- a/hindsight-clients/go/model_entity_state_response.go +++ b/hindsight-clients/go/model_entity_state_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_extracted_fact.go b/hindsight-clients/go/model_extracted_fact.go new file mode 100644 index 0000000000..533b4daf40 --- /dev/null +++ b/hindsight-clients/go/model_extracted_fact.go @@ -0,0 +1,317 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.8.3 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" + "bytes" + "fmt" +) + +// checks if the ExtractedFact type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &ExtractedFact{} + +// ExtractedFact A single candidate fact produced by dry-run extraction (no resolution/links/persistence). A deliberate subset of the persisted memory-unit shape — only the fields a fresh extraction yields. Storage/consolidation/curation fields (id, document_id, chunk_id, proof_count, state, …) are omitted because nothing is stored. Entities are raw, unresolved names. +type ExtractedFact struct { + // The extracted fact text. + Text string `json:"text"` + // Perspective classification: 'world' or 'experience'. + FactType string `json:"fact_type"` + OccurredStart NullableString `json:"occurred_start,omitempty"` + OccurredEnd NullableString `json:"occurred_end,omitempty"` + // Raw (unresolved) entity names mentioned in the fact. + Entities []string `json:"entities,omitempty"` +} + +type _ExtractedFact ExtractedFact + +// NewExtractedFact instantiates a new ExtractedFact object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewExtractedFact(text string, factType string) *ExtractedFact { + this := ExtractedFact{} + this.Text = text + this.FactType = factType + return &this +} + +// NewExtractedFactWithDefaults instantiates a new ExtractedFact object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewExtractedFactWithDefaults() *ExtractedFact { + this := ExtractedFact{} + return &this +} + +// GetText returns the Text field value +func (o *ExtractedFact) GetText() string { + if o == nil { + var ret string + return ret + } + + return o.Text +} + +// GetTextOk returns a tuple with the Text field value +// and a boolean to check if the value has been set. +func (o *ExtractedFact) GetTextOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Text, true +} + +// SetText sets field value +func (o *ExtractedFact) SetText(v string) { + o.Text = v +} + +// GetFactType returns the FactType field value +func (o *ExtractedFact) GetFactType() string { + if o == nil { + var ret string + return ret + } + + return o.FactType +} + +// GetFactTypeOk returns a tuple with the FactType field value +// and a boolean to check if the value has been set. +func (o *ExtractedFact) GetFactTypeOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.FactType, true +} + +// SetFactType sets field value +func (o *ExtractedFact) SetFactType(v string) { + o.FactType = v +} + +// GetOccurredStart returns the OccurredStart field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *ExtractedFact) GetOccurredStart() string { + if o == nil || IsNil(o.OccurredStart.Get()) { + var ret string + return ret + } + return *o.OccurredStart.Get() +} + +// GetOccurredStartOk returns a tuple with the OccurredStart field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *ExtractedFact) GetOccurredStartOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.OccurredStart.Get(), o.OccurredStart.IsSet() +} + +// HasOccurredStart returns a boolean if a field has been set. +func (o *ExtractedFact) HasOccurredStart() bool { + if o != nil && o.OccurredStart.IsSet() { + return true + } + + return false +} + +// SetOccurredStart gets a reference to the given NullableString and assigns it to the OccurredStart field. +func (o *ExtractedFact) SetOccurredStart(v string) { + o.OccurredStart.Set(&v) +} +// SetOccurredStartNil sets the value for OccurredStart to be an explicit nil +func (o *ExtractedFact) SetOccurredStartNil() { + o.OccurredStart.Set(nil) +} + +// UnsetOccurredStart ensures that no value is present for OccurredStart, not even an explicit nil +func (o *ExtractedFact) UnsetOccurredStart() { + o.OccurredStart.Unset() +} + +// GetOccurredEnd returns the OccurredEnd field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *ExtractedFact) GetOccurredEnd() string { + if o == nil || IsNil(o.OccurredEnd.Get()) { + var ret string + return ret + } + return *o.OccurredEnd.Get() +} + +// GetOccurredEndOk returns a tuple with the OccurredEnd field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *ExtractedFact) GetOccurredEndOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.OccurredEnd.Get(), o.OccurredEnd.IsSet() +} + +// HasOccurredEnd returns a boolean if a field has been set. +func (o *ExtractedFact) HasOccurredEnd() bool { + if o != nil && o.OccurredEnd.IsSet() { + return true + } + + return false +} + +// SetOccurredEnd gets a reference to the given NullableString and assigns it to the OccurredEnd field. +func (o *ExtractedFact) SetOccurredEnd(v string) { + o.OccurredEnd.Set(&v) +} +// SetOccurredEndNil sets the value for OccurredEnd to be an explicit nil +func (o *ExtractedFact) SetOccurredEndNil() { + o.OccurredEnd.Set(nil) +} + +// UnsetOccurredEnd ensures that no value is present for OccurredEnd, not even an explicit nil +func (o *ExtractedFact) UnsetOccurredEnd() { + o.OccurredEnd.Unset() +} + +// GetEntities returns the Entities field value if set, zero value otherwise. +func (o *ExtractedFact) GetEntities() []string { + if o == nil || IsNil(o.Entities) { + var ret []string + return ret + } + return o.Entities +} + +// GetEntitiesOk returns a tuple with the Entities field value if set, nil otherwise +// and a boolean to check if the value has been set. +func (o *ExtractedFact) GetEntitiesOk() ([]string, bool) { + if o == nil || IsNil(o.Entities) { + return nil, false + } + return o.Entities, true +} + +// HasEntities returns a boolean if a field has been set. +func (o *ExtractedFact) HasEntities() bool { + if o != nil && !IsNil(o.Entities) { + return true + } + + return false +} + +// SetEntities gets a reference to the given []string and assigns it to the Entities field. +func (o *ExtractedFact) SetEntities(v []string) { + o.Entities = v +} + +func (o ExtractedFact) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o ExtractedFact) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["text"] = o.Text + toSerialize["fact_type"] = o.FactType + if o.OccurredStart.IsSet() { + toSerialize["occurred_start"] = o.OccurredStart.Get() + } + if o.OccurredEnd.IsSet() { + toSerialize["occurred_end"] = o.OccurredEnd.Get() + } + if !IsNil(o.Entities) { + toSerialize["entities"] = o.Entities + } + return toSerialize, nil +} + +func (o *ExtractedFact) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "text", + "fact_type", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err; + } + + for _, requiredProperty := range(requiredProperties) { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varExtractedFact := _ExtractedFact{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varExtractedFact) + + if err != nil { + return err + } + + *o = ExtractedFact(varExtractedFact) + + return err +} + +type NullableExtractedFact struct { + value *ExtractedFact + isSet bool +} + +func (v NullableExtractedFact) Get() *ExtractedFact { + return v.value +} + +func (v *NullableExtractedFact) Set(val *ExtractedFact) { + v.value = val + v.isSet = true +} + +func (v NullableExtractedFact) IsSet() bool { + return v.isSet +} + +func (v *NullableExtractedFact) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableExtractedFact(val *ExtractedFact) *NullableExtractedFact { + return &NullableExtractedFact{value: val, isSet: true} +} + +func (v NullableExtractedFact) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableExtractedFact) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/go/model_features_info.go b/hindsight-clients/go/model_features_info.go index c50929796b..1165a2a977 100644 --- a/hindsight-clients/go/model_features_info.go +++ b/hindsight-clients/go/model_features_info.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. @@ -29,6 +29,8 @@ type FeaturesInfo struct { Worker bool `json:"worker"` // Whether per-bank configuration API is enabled BankConfigApi bool `json:"bank_config_api"` + // Whether the per-bank LLM connectivity probe is enabled + BankLlmHealth bool `json:"bank_llm_health"` // Whether file upload/conversion API is enabled FileUploadApi bool `json:"file_upload_api"` // Whether the document export endpoint is enabled @@ -39,6 +41,8 @@ type FeaturesInfo struct { AuditLog bool `json:"audit_log"` // Whether per-bank LLM request tracing is enabled LlmTrace bool `json:"llm_trace"` + // Whether raw source text is persisted. When false, document/chunk source text is not stored. + StoreDocumentText bool `json:"store_document_text"` } type _FeaturesInfo FeaturesInfo @@ -47,17 +51,19 @@ type _FeaturesInfo FeaturesInfo // This constructor will assign default values to properties that have it defined, // and makes sure properties required by API are set, but the set of arguments // will change when the set of required properties is changed -func NewFeaturesInfo(observations bool, mcp bool, worker bool, bankConfigApi bool, fileUploadApi bool, documentExportApi bool, documentImportApi bool, auditLog bool, llmTrace bool) *FeaturesInfo { +func NewFeaturesInfo(observations bool, mcp bool, worker bool, bankConfigApi bool, bankLlmHealth bool, fileUploadApi bool, documentExportApi bool, documentImportApi bool, auditLog bool, llmTrace bool, storeDocumentText bool) *FeaturesInfo { this := FeaturesInfo{} this.Observations = observations this.Mcp = mcp this.Worker = worker this.BankConfigApi = bankConfigApi + this.BankLlmHealth = bankLlmHealth this.FileUploadApi = fileUploadApi this.DocumentExportApi = documentExportApi this.DocumentImportApi = documentImportApi this.AuditLog = auditLog this.LlmTrace = llmTrace + this.StoreDocumentText = storeDocumentText return &this } @@ -165,6 +171,30 @@ func (o *FeaturesInfo) SetBankConfigApi(v bool) { o.BankConfigApi = v } +// GetBankLlmHealth returns the BankLlmHealth field value +func (o *FeaturesInfo) GetBankLlmHealth() bool { + if o == nil { + var ret bool + return ret + } + + return o.BankLlmHealth +} + +// GetBankLlmHealthOk returns a tuple with the BankLlmHealth field value +// and a boolean to check if the value has been set. +func (o *FeaturesInfo) GetBankLlmHealthOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.BankLlmHealth, true +} + +// SetBankLlmHealth sets field value +func (o *FeaturesInfo) SetBankLlmHealth(v bool) { + o.BankLlmHealth = v +} + // GetFileUploadApi returns the FileUploadApi field value func (o *FeaturesInfo) GetFileUploadApi() bool { if o == nil { @@ -285,6 +315,30 @@ func (o *FeaturesInfo) SetLlmTrace(v bool) { o.LlmTrace = v } +// GetStoreDocumentText returns the StoreDocumentText field value +func (o *FeaturesInfo) GetStoreDocumentText() bool { + if o == nil { + var ret bool + return ret + } + + return o.StoreDocumentText +} + +// GetStoreDocumentTextOk returns a tuple with the StoreDocumentText field value +// and a boolean to check if the value has been set. +func (o *FeaturesInfo) GetStoreDocumentTextOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.StoreDocumentText, true +} + +// SetStoreDocumentText sets field value +func (o *FeaturesInfo) SetStoreDocumentText(v bool) { + o.StoreDocumentText = v +} + func (o FeaturesInfo) MarshalJSON() ([]byte, error) { toSerialize,err := o.ToMap() if err != nil { @@ -299,11 +353,13 @@ func (o FeaturesInfo) ToMap() (map[string]interface{}, error) { toSerialize["mcp"] = o.Mcp toSerialize["worker"] = o.Worker toSerialize["bank_config_api"] = o.BankConfigApi + toSerialize["bank_llm_health"] = o.BankLlmHealth toSerialize["file_upload_api"] = o.FileUploadApi toSerialize["document_export_api"] = o.DocumentExportApi toSerialize["document_import_api"] = o.DocumentImportApi toSerialize["audit_log"] = o.AuditLog toSerialize["llm_trace"] = o.LlmTrace + toSerialize["store_document_text"] = o.StoreDocumentText return toSerialize, nil } @@ -316,11 +372,13 @@ func (o *FeaturesInfo) UnmarshalJSON(data []byte) (err error) { "mcp", "worker", "bank_config_api", + "bank_llm_health", "file_upload_api", "document_export_api", "document_import_api", "audit_log", "llm_trace", + "store_document_text", } allProperties := make(map[string]interface{}) diff --git a/hindsight-clients/go/model_file_retain_response.go b/hindsight-clients/go/model_file_retain_response.go index 6c353f2813..2c9016668b 100644 --- a/hindsight-clients/go/model_file_retain_response.go +++ b/hindsight-clients/go/model_file_retain_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_graph_data_response.go b/hindsight-clients/go/model_graph_data_response.go index 196dfef79d..2df2c76ae6 100644 --- a/hindsight-clients/go/model_graph_data_response.go +++ b/hindsight-clients/go/model_graph_data_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_http_validation_error.go b/hindsight-clients/go/model_http_validation_error.go index 84006395fc..a75588d6f3 100644 --- a/hindsight-clients/go/model_http_validation_error.go +++ b/hindsight-clients/go/model_http_validation_error.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_include_options.go b/hindsight-clients/go/model_include_options.go index e03bd26d7d..15422e5554 100644 --- a/hindsight-clients/go/model_include_options.go +++ b/hindsight-clients/go/model_include_options.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_list_chunks_response.go b/hindsight-clients/go/model_list_chunks_response.go index 9cc6e82a52..65de38cd76 100644 --- a/hindsight-clients/go/model_list_chunks_response.go +++ b/hindsight-clients/go/model_list_chunks_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_list_documents_response.go b/hindsight-clients/go/model_list_documents_response.go index d6bd958d07..ce4a54c157 100644 --- a/hindsight-clients/go/model_list_documents_response.go +++ b/hindsight-clients/go/model_list_documents_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_list_memory_units_response.go b/hindsight-clients/go/model_list_memory_units_response.go index 05bf4bb72d..e2587d4279 100644 --- a/hindsight-clients/go/model_list_memory_units_response.go +++ b/hindsight-clients/go/model_list_memory_units_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_list_tags_response.go b/hindsight-clients/go/model_list_tags_response.go index 8422c661e3..a892b5f9d1 100644 --- a/hindsight-clients/go/model_list_tags_response.go +++ b/hindsight-clients/go/model_list_tags_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_llm_operation_health.go b/hindsight-clients/go/model_llm_operation_health.go new file mode 100644 index 0000000000..b490caa222 --- /dev/null +++ b/hindsight-clients/go/model_llm_operation_health.go @@ -0,0 +1,263 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.8.3 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" + "bytes" + "fmt" +) + +// checks if the LlmOperationHealth type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &LlmOperationHealth{} + +// LlmOperationHealth LLM connectivity status for a single operation. Status only — no provider/model/ endpoint/error, so the probe never discloses the LLM configuration. +type LlmOperationHealth struct { + // Operation whose LLM was probed + Operation string `json:"operation"` + // True only when the probe connected successfully + Ok bool `json:"ok"` + // 'connected'; 'not_configured' (provider is 'none'); 'auth_failed' (rejected — usually a wrong/expired API key); 'unreachable' (call failed); 'timeout' + Status string `json:"status"` + LatencyMs NullableFloat32 `json:"latency_ms,omitempty"` +} + +type _LlmOperationHealth LlmOperationHealth + +// NewLlmOperationHealth instantiates a new LlmOperationHealth object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewLlmOperationHealth(operation string, ok bool, status string) *LlmOperationHealth { + this := LlmOperationHealth{} + this.Operation = operation + this.Ok = ok + this.Status = status + return &this +} + +// NewLlmOperationHealthWithDefaults instantiates a new LlmOperationHealth object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewLlmOperationHealthWithDefaults() *LlmOperationHealth { + this := LlmOperationHealth{} + return &this +} + +// GetOperation returns the Operation field value +func (o *LlmOperationHealth) GetOperation() string { + if o == nil { + var ret string + return ret + } + + return o.Operation +} + +// GetOperationOk returns a tuple with the Operation field value +// and a boolean to check if the value has been set. +func (o *LlmOperationHealth) GetOperationOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Operation, true +} + +// SetOperation sets field value +func (o *LlmOperationHealth) SetOperation(v string) { + o.Operation = v +} + +// GetOk returns the Ok field value +func (o *LlmOperationHealth) GetOk() bool { + if o == nil { + var ret bool + return ret + } + + return o.Ok +} + +// GetOkOk returns a tuple with the Ok field value +// and a boolean to check if the value has been set. +func (o *LlmOperationHealth) GetOkOk() (*bool, bool) { + if o == nil { + return nil, false + } + return &o.Ok, true +} + +// SetOk sets field value +func (o *LlmOperationHealth) SetOk(v bool) { + o.Ok = v +} + +// GetStatus returns the Status field value +func (o *LlmOperationHealth) GetStatus() string { + if o == nil { + var ret string + return ret + } + + return o.Status +} + +// GetStatusOk returns a tuple with the Status field value +// and a boolean to check if the value has been set. +func (o *LlmOperationHealth) GetStatusOk() (*string, bool) { + if o == nil { + return nil, false + } + return &o.Status, true +} + +// SetStatus sets field value +func (o *LlmOperationHealth) SetStatus(v string) { + o.Status = v +} + +// GetLatencyMs returns the LatencyMs field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *LlmOperationHealth) GetLatencyMs() float32 { + if o == nil || IsNil(o.LatencyMs.Get()) { + var ret float32 + return ret + } + return *o.LatencyMs.Get() +} + +// GetLatencyMsOk returns a tuple with the LatencyMs field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *LlmOperationHealth) GetLatencyMsOk() (*float32, bool) { + if o == nil { + return nil, false + } + return o.LatencyMs.Get(), o.LatencyMs.IsSet() +} + +// HasLatencyMs returns a boolean if a field has been set. +func (o *LlmOperationHealth) HasLatencyMs() bool { + if o != nil && o.LatencyMs.IsSet() { + return true + } + + return false +} + +// SetLatencyMs gets a reference to the given NullableFloat32 and assigns it to the LatencyMs field. +func (o *LlmOperationHealth) SetLatencyMs(v float32) { + o.LatencyMs.Set(&v) +} +// SetLatencyMsNil sets the value for LatencyMs to be an explicit nil +func (o *LlmOperationHealth) SetLatencyMsNil() { + o.LatencyMs.Set(nil) +} + +// UnsetLatencyMs ensures that no value is present for LatencyMs, not even an explicit nil +func (o *LlmOperationHealth) UnsetLatencyMs() { + o.LatencyMs.Unset() +} + +func (o LlmOperationHealth) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o LlmOperationHealth) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["operation"] = o.Operation + toSerialize["ok"] = o.Ok + toSerialize["status"] = o.Status + if o.LatencyMs.IsSet() { + toSerialize["latency_ms"] = o.LatencyMs.Get() + } + return toSerialize, nil +} + +func (o *LlmOperationHealth) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "operation", + "ok", + "status", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err; + } + + for _, requiredProperty := range(requiredProperties) { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varLlmOperationHealth := _LlmOperationHealth{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varLlmOperationHealth) + + if err != nil { + return err + } + + *o = LlmOperationHealth(varLlmOperationHealth) + + return err +} + +type NullableLlmOperationHealth struct { + value *LlmOperationHealth + isSet bool +} + +func (v NullableLlmOperationHealth) Get() *LlmOperationHealth { + return v.value +} + +func (v *NullableLlmOperationHealth) Set(val *LlmOperationHealth) { + v.value = val + v.isSet = true +} + +func (v NullableLlmOperationHealth) IsSet() bool { + return v.isSet +} + +func (v *NullableLlmOperationHealth) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableLlmOperationHealth(val *LlmOperationHealth) *NullableLlmOperationHealth { + return &NullableLlmOperationHealth{value: val, isSet: true} +} + +func (v NullableLlmOperationHealth) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableLlmOperationHealth) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/go/model_llm_request_entry.go b/hindsight-clients/go/model_llm_request_entry.go index f05a52735a..73b8f1f71c 100644 --- a/hindsight-clients/go/model_llm_request_entry.go +++ b/hindsight-clients/go/model_llm_request_entry.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_llm_request_list_response.go b/hindsight-clients/go/model_llm_request_list_response.go index 46b5150f7c..93ef483e13 100644 --- a/hindsight-clients/go/model_llm_request_list_response.go +++ b/hindsight-clients/go/model_llm_request_list_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_llm_request_stats_bucket.go b/hindsight-clients/go/model_llm_request_stats_bucket.go index df08fb8782..00fcde9db8 100644 --- a/hindsight-clients/go/model_llm_request_stats_bucket.go +++ b/hindsight-clients/go/model_llm_request_stats_bucket.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_llm_request_stats_response.go b/hindsight-clients/go/model_llm_request_stats_response.go index 2845d954ea..126d470a10 100644 --- a/hindsight-clients/go/model_llm_request_stats_response.go +++ b/hindsight-clients/go/model_llm_request_stats_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_llm_request_token_sums.go b/hindsight-clients/go/model_llm_request_token_sums.go index e9615f3324..78f857437c 100644 --- a/hindsight-clients/go/model_llm_request_token_sums.go +++ b/hindsight-clients/go/model_llm_request_token_sums.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_memories_timeseries_response.go b/hindsight-clients/go/model_memories_timeseries_response.go index 08b77bfaaa..a82850a20b 100644 --- a/hindsight-clients/go/model_memories_timeseries_response.go +++ b/hindsight-clients/go/model_memories_timeseries_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_memory_item.go b/hindsight-clients/go/model_memory_item.go index 0086b637db..950f4a7975 100644 --- a/hindsight-clients/go/model_memory_item.go +++ b/hindsight-clients/go/model_memory_item.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_memory_timeseries_bucket.go b/hindsight-clients/go/model_memory_timeseries_bucket.go index 4448c1fd54..da9550bed9 100644 --- a/hindsight-clients/go/model_memory_timeseries_bucket.go +++ b/hindsight-clients/go/model_memory_timeseries_bucket.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_mental_model_list_response.go b/hindsight-clients/go/model_mental_model_list_response.go index 75712ff483..76e4802531 100644 --- a/hindsight-clients/go/model_mental_model_list_response.go +++ b/hindsight-clients/go/model_mental_model_list_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_mental_model_response.go b/hindsight-clients/go/model_mental_model_response.go index f3ec14715b..a7ed1d408f 100644 --- a/hindsight-clients/go/model_mental_model_response.go +++ b/hindsight-clients/go/model_mental_model_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_mental_model_trigger_input.go b/hindsight-clients/go/model_mental_model_trigger_input.go index bd29726492..2210632140 100644 --- a/hindsight-clients/go/model_mental_model_trigger_input.go +++ b/hindsight-clients/go/model_mental_model_trigger_input.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_mental_model_trigger_input_tag_groups_inner.go b/hindsight-clients/go/model_mental_model_trigger_input_tag_groups_inner.go index 2a14a82cdd..1dc71fc64f 100644 --- a/hindsight-clients/go/model_mental_model_trigger_input_tag_groups_inner.go +++ b/hindsight-clients/go/model_mental_model_trigger_input_tag_groups_inner.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_mental_model_trigger_output.go b/hindsight-clients/go/model_mental_model_trigger_output.go index d3fdf0796f..f6b53277e8 100644 --- a/hindsight-clients/go/model_mental_model_trigger_output.go +++ b/hindsight-clients/go/model_mental_model_trigger_output.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_mental_model_trigger_output_tag_groups_inner.go b/hindsight-clients/go/model_mental_model_trigger_output_tag_groups_inner.go index 1a771acdd7..3ff991281d 100644 --- a/hindsight-clients/go/model_mental_model_trigger_output_tag_groups_inner.go +++ b/hindsight-clients/go/model_mental_model_trigger_output_tag_groups_inner.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_not.go b/hindsight-clients/go/model_not.go index 931824dbf6..3653bfd148 100644 --- a/hindsight-clients/go/model_not.go +++ b/hindsight-clients/go/model_not.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_not_1.go b/hindsight-clients/go/model_not_1.go index dc31eb4fcb..b793e8febb 100644 --- a/hindsight-clients/go/model_not_1.go +++ b/hindsight-clients/go/model_not_1.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_observation_scope.go b/hindsight-clients/go/model_observation_scope.go new file mode 100644 index 0000000000..4cc719e8d7 --- /dev/null +++ b/hindsight-clients/go/model_observation_scope.go @@ -0,0 +1,188 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.8.3 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" + "bytes" + "fmt" +) + +// checks if the ObservationScope type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &ObservationScope{} + +// ObservationScope A distinct observation scope: an exact tag set plus its observation count. +type ObservationScope struct { + // The exact tag set defining this scope (normalized order). Empty list is the global/untagged scope. + Tags []string `json:"tags"` + // Number of observations that live under this scope + Count int32 `json:"count"` +} + +type _ObservationScope ObservationScope + +// NewObservationScope instantiates a new ObservationScope object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewObservationScope(tags []string, count int32) *ObservationScope { + this := ObservationScope{} + this.Tags = tags + this.Count = count + return &this +} + +// NewObservationScopeWithDefaults instantiates a new ObservationScope object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewObservationScopeWithDefaults() *ObservationScope { + this := ObservationScope{} + return &this +} + +// GetTags returns the Tags field value +func (o *ObservationScope) GetTags() []string { + if o == nil { + var ret []string + return ret + } + + return o.Tags +} + +// GetTagsOk returns a tuple with the Tags field value +// and a boolean to check if the value has been set. +func (o *ObservationScope) GetTagsOk() ([]string, bool) { + if o == nil { + return nil, false + } + return o.Tags, true +} + +// SetTags sets field value +func (o *ObservationScope) SetTags(v []string) { + o.Tags = v +} + +// GetCount returns the Count field value +func (o *ObservationScope) GetCount() int32 { + if o == nil { + var ret int32 + return ret + } + + return o.Count +} + +// GetCountOk returns a tuple with the Count field value +// and a boolean to check if the value has been set. +func (o *ObservationScope) GetCountOk() (*int32, bool) { + if o == nil { + return nil, false + } + return &o.Count, true +} + +// SetCount sets field value +func (o *ObservationScope) SetCount(v int32) { + o.Count = v +} + +func (o ObservationScope) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o ObservationScope) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["tags"] = o.Tags + toSerialize["count"] = o.Count + return toSerialize, nil +} + +func (o *ObservationScope) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "tags", + "count", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err; + } + + for _, requiredProperty := range(requiredProperties) { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varObservationScope := _ObservationScope{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varObservationScope) + + if err != nil { + return err + } + + *o = ObservationScope(varObservationScope) + + return err +} + +type NullableObservationScope struct { + value *ObservationScope + isSet bool +} + +func (v NullableObservationScope) Get() *ObservationScope { + return v.value +} + +func (v *NullableObservationScope) Set(val *ObservationScope) { + v.value = val + v.isSet = true +} + +func (v NullableObservationScope) IsSet() bool { + return v.isSet +} + +func (v *NullableObservationScope) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableObservationScope(val *ObservationScope) *NullableObservationScope { + return &NullableObservationScope{value: val, isSet: true} +} + +func (v NullableObservationScope) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableObservationScope) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/go/model_observation_scopes.go b/hindsight-clients/go/model_observation_scopes.go index f56cfa1070..69bca1ad0c 100644 --- a/hindsight-clients/go/model_observation_scopes.go +++ b/hindsight-clients/go/model_observation_scopes.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. @@ -16,7 +16,7 @@ import ( ) -// ObservationScopes How to scope observations during consolidation. 'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. 'combined' (default) runs a single pass with all tags together. A list of tag lists runs one pass per inner list, giving full control over which combinations to use. +// ObservationScopes How to scope observations during consolidation. 'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. 'combined' (default) runs a single pass with all tags together. 'shared' runs a single pass over one global, untagged scope, so memories consolidate together regardless of their tags — useful for deduplicating across volatile per-call provenance tags (e.g. per-session ids) while keeping those tags on the source facts. A list of tag lists runs one pass per inner list, giving full control over which combinations to use. type ObservationScopes struct { ArrayOfArrayOfString *[][]string String *string diff --git a/hindsight-clients/go/model_observation_scopes_response.go b/hindsight-clients/go/model_observation_scopes_response.go new file mode 100644 index 0000000000..ca40197e9a --- /dev/null +++ b/hindsight-clients/go/model_observation_scopes_response.go @@ -0,0 +1,159 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.8.3 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" + "bytes" + "fmt" +) + +// checks if the ObservationScopesResponse type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &ObservationScopesResponse{} + +// ObservationScopesResponse Response model for the observation scopes enumeration endpoint. +type ObservationScopesResponse struct { + // Distinct observation scopes, most populous first + Scopes []ObservationScope `json:"scopes"` +} + +type _ObservationScopesResponse ObservationScopesResponse + +// NewObservationScopesResponse instantiates a new ObservationScopesResponse object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewObservationScopesResponse(scopes []ObservationScope) *ObservationScopesResponse { + this := ObservationScopesResponse{} + this.Scopes = scopes + return &this +} + +// NewObservationScopesResponseWithDefaults instantiates a new ObservationScopesResponse object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewObservationScopesResponseWithDefaults() *ObservationScopesResponse { + this := ObservationScopesResponse{} + return &this +} + +// GetScopes returns the Scopes field value +func (o *ObservationScopesResponse) GetScopes() []ObservationScope { + if o == nil { + var ret []ObservationScope + return ret + } + + return o.Scopes +} + +// GetScopesOk returns a tuple with the Scopes field value +// and a boolean to check if the value has been set. +func (o *ObservationScopesResponse) GetScopesOk() ([]ObservationScope, bool) { + if o == nil { + return nil, false + } + return o.Scopes, true +} + +// SetScopes sets field value +func (o *ObservationScopesResponse) SetScopes(v []ObservationScope) { + o.Scopes = v +} + +func (o ObservationScopesResponse) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o ObservationScopesResponse) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + toSerialize["scopes"] = o.Scopes + return toSerialize, nil +} + +func (o *ObservationScopesResponse) UnmarshalJSON(data []byte) (err error) { + // This validates that all required properties are included in the JSON object + // by unmarshalling the object into a generic map with string keys and checking + // that every required field exists as a key in the generic map. + requiredProperties := []string{ + "scopes", + } + + allProperties := make(map[string]interface{}) + + err = json.Unmarshal(data, &allProperties) + + if err != nil { + return err; + } + + for _, requiredProperty := range(requiredProperties) { + if _, exists := allProperties[requiredProperty]; !exists { + return fmt.Errorf("no value given for required property %v", requiredProperty) + } + } + + varObservationScopesResponse := _ObservationScopesResponse{} + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + err = decoder.Decode(&varObservationScopesResponse) + + if err != nil { + return err + } + + *o = ObservationScopesResponse(varObservationScopesResponse) + + return err +} + +type NullableObservationScopesResponse struct { + value *ObservationScopesResponse + isSet bool +} + +func (v NullableObservationScopesResponse) Get() *ObservationScopesResponse { + return v.value +} + +func (v *NullableObservationScopesResponse) Set(val *ObservationScopesResponse) { + v.value = val + v.isSet = true +} + +func (v NullableObservationScopesResponse) IsSet() bool { + return v.isSet +} + +func (v *NullableObservationScopesResponse) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableObservationScopesResponse(val *ObservationScopesResponse) *NullableObservationScopesResponse { + return &NullableObservationScopesResponse{value: val, isSet: true} +} + +func (v NullableObservationScopesResponse) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableObservationScopesResponse) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/go/model_operation_progress.go b/hindsight-clients/go/model_operation_progress.go index e7796f0f22..0e860c1039 100644 --- a/hindsight-clients/go/model_operation_progress.go +++ b/hindsight-clients/go/model_operation_progress.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_operation_response.go b/hindsight-clients/go/model_operation_response.go index 787adc501d..89270140f2 100644 --- a/hindsight-clients/go/model_operation_response.go +++ b/hindsight-clients/go/model_operation_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_operation_status_response.go b/hindsight-clients/go/model_operation_status_response.go index cb9962b6d4..509bae1985 100644 --- a/hindsight-clients/go/model_operation_status_response.go +++ b/hindsight-clients/go/model_operation_status_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_operations_list_response.go b/hindsight-clients/go/model_operations_list_response.go index 4d75a05766..6bbad82a57 100644 --- a/hindsight-clients/go/model_operations_list_response.go +++ b/hindsight-clients/go/model_operations_list_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_recall_request.go b/hindsight-clients/go/model_recall_request.go index 5e6c6a075e..e8ee279dea 100644 --- a/hindsight-clients/go/model_recall_request.go +++ b/hindsight-clients/go/model_recall_request.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_recall_response.go b/hindsight-clients/go/model_recall_response.go index 7017c6ed2a..4b472be780 100644 --- a/hindsight-clients/go/model_recall_response.go +++ b/hindsight-clients/go/model_recall_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_recall_result.go b/hindsight-clients/go/model_recall_result.go index db9bb4e504..a4e5061595 100644 --- a/hindsight-clients/go/model_recall_result.go +++ b/hindsight-clients/go/model_recall_result.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_recover_consolidation_response.go b/hindsight-clients/go/model_recover_consolidation_response.go index 0f6d6b8c42..f3ea982ee2 100644 --- a/hindsight-clients/go/model_recover_consolidation_response.go +++ b/hindsight-clients/go/model_recover_consolidation_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_reflect_based_on.go b/hindsight-clients/go/model_reflect_based_on.go index 9e6a9c8f0a..0bbde8e177 100644 --- a/hindsight-clients/go/model_reflect_based_on.go +++ b/hindsight-clients/go/model_reflect_based_on.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_reflect_directive.go b/hindsight-clients/go/model_reflect_directive.go index bd9c61bbd3..e9043d6578 100644 --- a/hindsight-clients/go/model_reflect_directive.go +++ b/hindsight-clients/go/model_reflect_directive.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_reflect_fact.go b/hindsight-clients/go/model_reflect_fact.go index 947963d963..34858b7f90 100644 --- a/hindsight-clients/go/model_reflect_fact.go +++ b/hindsight-clients/go/model_reflect_fact.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_reflect_include_options.go b/hindsight-clients/go/model_reflect_include_options.go index d4e4a9983f..57686889f7 100644 --- a/hindsight-clients/go/model_reflect_include_options.go +++ b/hindsight-clients/go/model_reflect_include_options.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_reflect_llm_call.go b/hindsight-clients/go/model_reflect_llm_call.go index 1939961ca2..12146944db 100644 --- a/hindsight-clients/go/model_reflect_llm_call.go +++ b/hindsight-clients/go/model_reflect_llm_call.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_reflect_mental_model.go b/hindsight-clients/go/model_reflect_mental_model.go index d99077224f..a79efdbbfd 100644 --- a/hindsight-clients/go/model_reflect_mental_model.go +++ b/hindsight-clients/go/model_reflect_mental_model.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_reflect_request.go b/hindsight-clients/go/model_reflect_request.go index 1054f5dabc..c2b1e2e26f 100644 --- a/hindsight-clients/go/model_reflect_request.go +++ b/hindsight-clients/go/model_reflect_request.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_reflect_response.go b/hindsight-clients/go/model_reflect_response.go index 45a6757269..e6c9e652bb 100644 --- a/hindsight-clients/go/model_reflect_response.go +++ b/hindsight-clients/go/model_reflect_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_reflect_tool_call.go b/hindsight-clients/go/model_reflect_tool_call.go index 5a59ecb2c2..5d22090c94 100644 --- a/hindsight-clients/go/model_reflect_tool_call.go +++ b/hindsight-clients/go/model_reflect_tool_call.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_reflect_trace.go b/hindsight-clients/go/model_reflect_trace.go index af09c16bf7..71cd3a95d5 100644 --- a/hindsight-clients/go/model_reflect_trace.go +++ b/hindsight-clients/go/model_reflect_trace.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_reprocess_document_response.go b/hindsight-clients/go/model_reprocess_document_response.go index fbb6d24608..87e7dff08f 100644 --- a/hindsight-clients/go/model_reprocess_document_response.go +++ b/hindsight-clients/go/model_reprocess_document_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_retain_request.go b/hindsight-clients/go/model_retain_request.go index 37d732adc1..d9c163da25 100644 --- a/hindsight-clients/go/model_retain_request.go +++ b/hindsight-clients/go/model_retain_request.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_retain_response.go b/hindsight-clients/go/model_retain_response.go index 424bad684d..4e27be265a 100644 --- a/hindsight-clients/go/model_retain_response.go +++ b/hindsight-clients/go/model_retain_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_retry_operation_response.go b/hindsight-clients/go/model_retry_operation_response.go index b7074a0c69..40cc70cde8 100644 --- a/hindsight-clients/go/model_retry_operation_response.go +++ b/hindsight-clients/go/model_retry_operation_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_source_facts_include_options.go b/hindsight-clients/go/model_source_facts_include_options.go index 0618ada660..06eba60716 100644 --- a/hindsight-clients/go/model_source_facts_include_options.go +++ b/hindsight-clients/go/model_source_facts_include_options.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_tag_group_and_input.go b/hindsight-clients/go/model_tag_group_and_input.go index b840856694..3d161a3a73 100644 --- a/hindsight-clients/go/model_tag_group_and_input.go +++ b/hindsight-clients/go/model_tag_group_and_input.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_tag_group_and_output.go b/hindsight-clients/go/model_tag_group_and_output.go index f4c3edb0da..72f018f367 100644 --- a/hindsight-clients/go/model_tag_group_and_output.go +++ b/hindsight-clients/go/model_tag_group_and_output.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_tag_group_leaf.go b/hindsight-clients/go/model_tag_group_leaf.go index 932f013e6a..993f3cfe66 100644 --- a/hindsight-clients/go/model_tag_group_leaf.go +++ b/hindsight-clients/go/model_tag_group_leaf.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_tag_group_not_input.go b/hindsight-clients/go/model_tag_group_not_input.go index 350db5db7d..7f5d65dd7f 100644 --- a/hindsight-clients/go/model_tag_group_not_input.go +++ b/hindsight-clients/go/model_tag_group_not_input.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_tag_group_not_output.go b/hindsight-clients/go/model_tag_group_not_output.go index 03a7eac078..87b6ebaeab 100644 --- a/hindsight-clients/go/model_tag_group_not_output.go +++ b/hindsight-clients/go/model_tag_group_not_output.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_tag_group_or_input.go b/hindsight-clients/go/model_tag_group_or_input.go index 62371f5004..628c642cd2 100644 --- a/hindsight-clients/go/model_tag_group_or_input.go +++ b/hindsight-clients/go/model_tag_group_or_input.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_tag_group_or_output.go b/hindsight-clients/go/model_tag_group_or_output.go index 32ded59036..ab420154e6 100644 --- a/hindsight-clients/go/model_tag_group_or_output.go +++ b/hindsight-clients/go/model_tag_group_or_output.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_tag_item.go b/hindsight-clients/go/model_tag_item.go index c05d59ed5c..f48da824c7 100644 --- a/hindsight-clients/go/model_tag_item.go +++ b/hindsight-clients/go/model_tag_item.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_timestamp.go b/hindsight-clients/go/model_timestamp.go index 6ab5da948d..f5fe51bbb8 100644 --- a/hindsight-clients/go/model_timestamp.go +++ b/hindsight-clients/go/model_timestamp.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_token_usage.go b/hindsight-clients/go/model_token_usage.go index 56a1056558..b00f446fbc 100644 --- a/hindsight-clients/go/model_token_usage.go +++ b/hindsight-clients/go/model_token_usage.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_tool_calls_include_options.go b/hindsight-clients/go/model_tool_calls_include_options.go index fd5d0c0407..d1c8a40303 100644 --- a/hindsight-clients/go/model_tool_calls_include_options.go +++ b/hindsight-clients/go/model_tool_calls_include_options.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_update_directive_request.go b/hindsight-clients/go/model_update_directive_request.go index 28cba65d58..cc132833ee 100644 --- a/hindsight-clients/go/model_update_directive_request.go +++ b/hindsight-clients/go/model_update_directive_request.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_update_disposition_request.go b/hindsight-clients/go/model_update_disposition_request.go index 792fee96fd..28f639c6eb 100644 --- a/hindsight-clients/go/model_update_disposition_request.go +++ b/hindsight-clients/go/model_update_disposition_request.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_update_document_request.go b/hindsight-clients/go/model_update_document_request.go index 288c354200..14a08c07bb 100644 --- a/hindsight-clients/go/model_update_document_request.go +++ b/hindsight-clients/go/model_update_document_request.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_update_document_response.go b/hindsight-clients/go/model_update_document_response.go index bd7927077d..2aab777b5d 100644 --- a/hindsight-clients/go/model_update_document_response.go +++ b/hindsight-clients/go/model_update_document_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_update_memory_request.go b/hindsight-clients/go/model_update_memory_request.go new file mode 100644 index 0000000000..aba159883d --- /dev/null +++ b/hindsight-clients/go/model_update_memory_request.go @@ -0,0 +1,449 @@ +/* +Hindsight HTTP API + +HTTP API for Hindsight + +API version: 0.8.3 +*/ + +// Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. + +package hindsight + +import ( + "encoding/json" +) + +// checks if the UpdateMemoryRequest type satisfies the MappedNullable interface at compile time +var _ MappedNullable = &UpdateMemoryRequest{} + +// UpdateMemoryRequest Request model for curating a single memory unit (edit / invalidate / revert). Provide ``text`` to correct the fact, and/or ``state`` to invalidate ('invalidated') or revert ('valid') it. ``reason`` is optional free text recorded on the memory. At least one of ``text`` or ``state`` must be set. Only world/experience facts can be curated; observations are derived. +type UpdateMemoryRequest struct { + Text NullableString `json:"text,omitempty"` + Context NullableString `json:"context,omitempty"` + OccurredStart NullableString `json:"occurred_start,omitempty"` + OccurredEnd NullableString `json:"occurred_end,omitempty"` + FactType NullableString `json:"fact_type,omitempty"` + Entities []string `json:"entities,omitempty"` + State NullableString `json:"state,omitempty"` + Reason NullableString `json:"reason,omitempty"` +} + +// NewUpdateMemoryRequest instantiates a new UpdateMemoryRequest object +// This constructor will assign default values to properties that have it defined, +// and makes sure properties required by API are set, but the set of arguments +// will change when the set of required properties is changed +func NewUpdateMemoryRequest() *UpdateMemoryRequest { + this := UpdateMemoryRequest{} + return &this +} + +// NewUpdateMemoryRequestWithDefaults instantiates a new UpdateMemoryRequest object +// This constructor will only assign default values to properties that have it defined, +// but it doesn't guarantee that properties required by API are set +func NewUpdateMemoryRequestWithDefaults() *UpdateMemoryRequest { + this := UpdateMemoryRequest{} + return &this +} + +// GetText returns the Text field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetText() string { + if o == nil || IsNil(o.Text.Get()) { + var ret string + return ret + } + return *o.Text.Get() +} + +// GetTextOk returns a tuple with the Text field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetTextOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Text.Get(), o.Text.IsSet() +} + +// HasText returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasText() bool { + if o != nil && o.Text.IsSet() { + return true + } + + return false +} + +// SetText gets a reference to the given NullableString and assigns it to the Text field. +func (o *UpdateMemoryRequest) SetText(v string) { + o.Text.Set(&v) +} +// SetTextNil sets the value for Text to be an explicit nil +func (o *UpdateMemoryRequest) SetTextNil() { + o.Text.Set(nil) +} + +// UnsetText ensures that no value is present for Text, not even an explicit nil +func (o *UpdateMemoryRequest) UnsetText() { + o.Text.Unset() +} + +// GetContext returns the Context field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetContext() string { + if o == nil || IsNil(o.Context.Get()) { + var ret string + return ret + } + return *o.Context.Get() +} + +// GetContextOk returns a tuple with the Context field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetContextOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Context.Get(), o.Context.IsSet() +} + +// HasContext returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasContext() bool { + if o != nil && o.Context.IsSet() { + return true + } + + return false +} + +// SetContext gets a reference to the given NullableString and assigns it to the Context field. +func (o *UpdateMemoryRequest) SetContext(v string) { + o.Context.Set(&v) +} +// SetContextNil sets the value for Context to be an explicit nil +func (o *UpdateMemoryRequest) SetContextNil() { + o.Context.Set(nil) +} + +// UnsetContext ensures that no value is present for Context, not even an explicit nil +func (o *UpdateMemoryRequest) UnsetContext() { + o.Context.Unset() +} + +// GetOccurredStart returns the OccurredStart field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetOccurredStart() string { + if o == nil || IsNil(o.OccurredStart.Get()) { + var ret string + return ret + } + return *o.OccurredStart.Get() +} + +// GetOccurredStartOk returns a tuple with the OccurredStart field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetOccurredStartOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.OccurredStart.Get(), o.OccurredStart.IsSet() +} + +// HasOccurredStart returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasOccurredStart() bool { + if o != nil && o.OccurredStart.IsSet() { + return true + } + + return false +} + +// SetOccurredStart gets a reference to the given NullableString and assigns it to the OccurredStart field. +func (o *UpdateMemoryRequest) SetOccurredStart(v string) { + o.OccurredStart.Set(&v) +} +// SetOccurredStartNil sets the value for OccurredStart to be an explicit nil +func (o *UpdateMemoryRequest) SetOccurredStartNil() { + o.OccurredStart.Set(nil) +} + +// UnsetOccurredStart ensures that no value is present for OccurredStart, not even an explicit nil +func (o *UpdateMemoryRequest) UnsetOccurredStart() { + o.OccurredStart.Unset() +} + +// GetOccurredEnd returns the OccurredEnd field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetOccurredEnd() string { + if o == nil || IsNil(o.OccurredEnd.Get()) { + var ret string + return ret + } + return *o.OccurredEnd.Get() +} + +// GetOccurredEndOk returns a tuple with the OccurredEnd field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetOccurredEndOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.OccurredEnd.Get(), o.OccurredEnd.IsSet() +} + +// HasOccurredEnd returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasOccurredEnd() bool { + if o != nil && o.OccurredEnd.IsSet() { + return true + } + + return false +} + +// SetOccurredEnd gets a reference to the given NullableString and assigns it to the OccurredEnd field. +func (o *UpdateMemoryRequest) SetOccurredEnd(v string) { + o.OccurredEnd.Set(&v) +} +// SetOccurredEndNil sets the value for OccurredEnd to be an explicit nil +func (o *UpdateMemoryRequest) SetOccurredEndNil() { + o.OccurredEnd.Set(nil) +} + +// UnsetOccurredEnd ensures that no value is present for OccurredEnd, not even an explicit nil +func (o *UpdateMemoryRequest) UnsetOccurredEnd() { + o.OccurredEnd.Unset() +} + +// GetFactType returns the FactType field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetFactType() string { + if o == nil || IsNil(o.FactType.Get()) { + var ret string + return ret + } + return *o.FactType.Get() +} + +// GetFactTypeOk returns a tuple with the FactType field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetFactTypeOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.FactType.Get(), o.FactType.IsSet() +} + +// HasFactType returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasFactType() bool { + if o != nil && o.FactType.IsSet() { + return true + } + + return false +} + +// SetFactType gets a reference to the given NullableString and assigns it to the FactType field. +func (o *UpdateMemoryRequest) SetFactType(v string) { + o.FactType.Set(&v) +} +// SetFactTypeNil sets the value for FactType to be an explicit nil +func (o *UpdateMemoryRequest) SetFactTypeNil() { + o.FactType.Set(nil) +} + +// UnsetFactType ensures that no value is present for FactType, not even an explicit nil +func (o *UpdateMemoryRequest) UnsetFactType() { + o.FactType.Unset() +} + +// GetEntities returns the Entities field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetEntities() []string { + if o == nil { + var ret []string + return ret + } + return o.Entities +} + +// GetEntitiesOk returns a tuple with the Entities field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetEntitiesOk() ([]string, bool) { + if o == nil || IsNil(o.Entities) { + return nil, false + } + return o.Entities, true +} + +// HasEntities returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasEntities() bool { + if o != nil && !IsNil(o.Entities) { + return true + } + + return false +} + +// SetEntities gets a reference to the given []string and assigns it to the Entities field. +func (o *UpdateMemoryRequest) SetEntities(v []string) { + o.Entities = v +} + +// GetState returns the State field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetState() string { + if o == nil || IsNil(o.State.Get()) { + var ret string + return ret + } + return *o.State.Get() +} + +// GetStateOk returns a tuple with the State field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetStateOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.State.Get(), o.State.IsSet() +} + +// HasState returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasState() bool { + if o != nil && o.State.IsSet() { + return true + } + + return false +} + +// SetState gets a reference to the given NullableString and assigns it to the State field. +func (o *UpdateMemoryRequest) SetState(v string) { + o.State.Set(&v) +} +// SetStateNil sets the value for State to be an explicit nil +func (o *UpdateMemoryRequest) SetStateNil() { + o.State.Set(nil) +} + +// UnsetState ensures that no value is present for State, not even an explicit nil +func (o *UpdateMemoryRequest) UnsetState() { + o.State.Unset() +} + +// GetReason returns the Reason field value if set, zero value otherwise (both if not set or set to explicit null). +func (o *UpdateMemoryRequest) GetReason() string { + if o == nil || IsNil(o.Reason.Get()) { + var ret string + return ret + } + return *o.Reason.Get() +} + +// GetReasonOk returns a tuple with the Reason field value if set, nil otherwise +// and a boolean to check if the value has been set. +// NOTE: If the value is an explicit nil, `nil, true` will be returned +func (o *UpdateMemoryRequest) GetReasonOk() (*string, bool) { + if o == nil { + return nil, false + } + return o.Reason.Get(), o.Reason.IsSet() +} + +// HasReason returns a boolean if a field has been set. +func (o *UpdateMemoryRequest) HasReason() bool { + if o != nil && o.Reason.IsSet() { + return true + } + + return false +} + +// SetReason gets a reference to the given NullableString and assigns it to the Reason field. +func (o *UpdateMemoryRequest) SetReason(v string) { + o.Reason.Set(&v) +} +// SetReasonNil sets the value for Reason to be an explicit nil +func (o *UpdateMemoryRequest) SetReasonNil() { + o.Reason.Set(nil) +} + +// UnsetReason ensures that no value is present for Reason, not even an explicit nil +func (o *UpdateMemoryRequest) UnsetReason() { + o.Reason.Unset() +} + +func (o UpdateMemoryRequest) MarshalJSON() ([]byte, error) { + toSerialize,err := o.ToMap() + if err != nil { + return []byte{}, err + } + return json.Marshal(toSerialize) +} + +func (o UpdateMemoryRequest) ToMap() (map[string]interface{}, error) { + toSerialize := map[string]interface{}{} + if o.Text.IsSet() { + toSerialize["text"] = o.Text.Get() + } + if o.Context.IsSet() { + toSerialize["context"] = o.Context.Get() + } + if o.OccurredStart.IsSet() { + toSerialize["occurred_start"] = o.OccurredStart.Get() + } + if o.OccurredEnd.IsSet() { + toSerialize["occurred_end"] = o.OccurredEnd.Get() + } + if o.FactType.IsSet() { + toSerialize["fact_type"] = o.FactType.Get() + } + if o.Entities != nil { + toSerialize["entities"] = o.Entities + } + if o.State.IsSet() { + toSerialize["state"] = o.State.Get() + } + if o.Reason.IsSet() { + toSerialize["reason"] = o.Reason.Get() + } + return toSerialize, nil +} + +type NullableUpdateMemoryRequest struct { + value *UpdateMemoryRequest + isSet bool +} + +func (v NullableUpdateMemoryRequest) Get() *UpdateMemoryRequest { + return v.value +} + +func (v *NullableUpdateMemoryRequest) Set(val *UpdateMemoryRequest) { + v.value = val + v.isSet = true +} + +func (v NullableUpdateMemoryRequest) IsSet() bool { + return v.isSet +} + +func (v *NullableUpdateMemoryRequest) Unset() { + v.value = nil + v.isSet = false +} + +func NewNullableUpdateMemoryRequest(val *UpdateMemoryRequest) *NullableUpdateMemoryRequest { + return &NullableUpdateMemoryRequest{value: val, isSet: true} +} + +func (v NullableUpdateMemoryRequest) MarshalJSON() ([]byte, error) { + return json.Marshal(v.value) +} + +func (v *NullableUpdateMemoryRequest) UnmarshalJSON(src []byte) error { + v.isSet = true + return json.Unmarshal(src, &v.value) +} + + diff --git a/hindsight-clients/go/model_update_mental_model_request.go b/hindsight-clients/go/model_update_mental_model_request.go index 75f729b138..bd55b71d88 100644 --- a/hindsight-clients/go/model_update_mental_model_request.go +++ b/hindsight-clients/go/model_update_mental_model_request.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_update_webhook_request.go b/hindsight-clients/go/model_update_webhook_request.go index 92a89dfa62..04317d0655 100644 --- a/hindsight-clients/go/model_update_webhook_request.go +++ b/hindsight-clients/go/model_update_webhook_request.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_validation_error.go b/hindsight-clients/go/model_validation_error.go index 4b4d03c203..c01645ed29 100644 --- a/hindsight-clients/go/model_validation_error.go +++ b/hindsight-clients/go/model_validation_error.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_validation_error_loc_inner.go b/hindsight-clients/go/model_validation_error_loc_inner.go index 530e35e2a6..3d4c76d785 100644 --- a/hindsight-clients/go/model_validation_error_loc_inner.go +++ b/hindsight-clients/go/model_validation_error_loc_inner.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_version_response.go b/hindsight-clients/go/model_version_response.go index e397ff2a9a..3f8973721a 100644 --- a/hindsight-clients/go/model_version_response.go +++ b/hindsight-clients/go/model_version_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_webhook_delivery_list_response.go b/hindsight-clients/go/model_webhook_delivery_list_response.go index 9c2aa40987..566274d129 100644 --- a/hindsight-clients/go/model_webhook_delivery_list_response.go +++ b/hindsight-clients/go/model_webhook_delivery_list_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_webhook_delivery_response.go b/hindsight-clients/go/model_webhook_delivery_response.go index fba9000c71..bcd6afd5dd 100644 --- a/hindsight-clients/go/model_webhook_delivery_response.go +++ b/hindsight-clients/go/model_webhook_delivery_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_webhook_http_config.go b/hindsight-clients/go/model_webhook_http_config.go index 9517d3a91d..0a1f32c998 100644 --- a/hindsight-clients/go/model_webhook_http_config.go +++ b/hindsight-clients/go/model_webhook_http_config.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_webhook_list_response.go b/hindsight-clients/go/model_webhook_list_response.go index 289b8e625b..b6eca71105 100644 --- a/hindsight-clients/go/model_webhook_list_response.go +++ b/hindsight-clients/go/model_webhook_list_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/model_webhook_response.go b/hindsight-clients/go/model_webhook_response.go index af706438e6..b35b9207b4 100644 --- a/hindsight-clients/go/model_webhook_response.go +++ b/hindsight-clients/go/model_webhook_response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/response.go b/hindsight-clients/go/response.go index 843a5459c3..7bb93e4e19 100644 --- a/hindsight-clients/go/response.go +++ b/hindsight-clients/go/response.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/go/utils.go b/hindsight-clients/go/utils.go index 9f71b396ac..20054796ba 100644 --- a/hindsight-clients/go/utils.go +++ b/hindsight-clients/go/utils.go @@ -3,7 +3,7 @@ Hindsight HTTP API HTTP API for Hindsight -API version: 0.7.2 +API version: 0.8.3 */ // Code generated by OpenAPI Generator (https://openapi-generator.tech); DO NOT EDIT. diff --git a/hindsight-clients/python/.openapi-generator/FILES b/hindsight-clients/python/.openapi-generator/FILES index 4c999742ed..250ac50037 100644 --- a/hindsight-clients/python/.openapi-generator/FILES +++ b/hindsight-clients/python/.openapi-generator/FILES @@ -31,6 +31,7 @@ hindsight_client_api/models/bank_config_response.py hindsight_client_api/models/bank_config_update.py hindsight_client_api/models/bank_list_item.py hindsight_client_api/models/bank_list_response.py +hindsight_client_api/models/bank_llm_health_response.py hindsight_client_api/models/bank_profile_response.py hindsight_client_api/models/bank_stats_response.py hindsight_client_api/models/bank_template_config.py @@ -59,6 +60,8 @@ hindsight_client_api/models/directive_response.py hindsight_client_api/models/disposition_traits.py hindsight_client_api/models/document_import_submit_response.py hindsight_client_api/models/document_response.py +hindsight_client_api/models/dry_run_extract_request.py +hindsight_client_api/models/dry_run_extraction_result.py hindsight_client_api/models/entity_detail_response.py hindsight_client_api/models/entity_graph_response.py hindsight_client_api/models/entity_include_options.py @@ -67,6 +70,7 @@ hindsight_client_api/models/entity_list_item.py hindsight_client_api/models/entity_list_response.py hindsight_client_api/models/entity_observation_response.py hindsight_client_api/models/entity_state_response.py +hindsight_client_api/models/extracted_fact.py hindsight_client_api/models/features_info.py hindsight_client_api/models/file_retain_response.py hindsight_client_api/models/graph_data_response.py @@ -76,6 +80,7 @@ hindsight_client_api/models/list_chunks_response.py hindsight_client_api/models/list_documents_response.py hindsight_client_api/models/list_memory_units_response.py hindsight_client_api/models/list_tags_response.py +hindsight_client_api/models/llm_operation_health.py hindsight_client_api/models/llm_request_entry.py hindsight_client_api/models/llm_request_list_response.py hindsight_client_api/models/llm_request_stats_bucket.py @@ -92,7 +97,9 @@ hindsight_client_api/models/mental_model_trigger_output.py hindsight_client_api/models/mental_model_trigger_output_tag_groups_inner.py hindsight_client_api/models/model_not.py hindsight_client_api/models/not1.py +hindsight_client_api/models/observation_scope.py hindsight_client_api/models/observation_scopes.py +hindsight_client_api/models/observation_scopes_response.py hindsight_client_api/models/operation_progress.py hindsight_client_api/models/operation_response.py hindsight_client_api/models/operation_status_response.py @@ -131,6 +138,7 @@ hindsight_client_api/models/update_directive_request.py hindsight_client_api/models/update_disposition_request.py hindsight_client_api/models/update_document_request.py hindsight_client_api/models/update_document_response.py +hindsight_client_api/models/update_memory_request.py hindsight_client_api/models/update_mental_model_request.py hindsight_client_api/models/update_webhook_request.py hindsight_client_api/models/validation_error.py diff --git a/hindsight-clients/python/hindsight_client/__init__.py b/hindsight-clients/python/hindsight_client/__init__.py index 9444d4403f..3cb1083787 100644 --- a/hindsight-clients/python/hindsight_client/__init__.py +++ b/hindsight-clients/python/hindsight_client/__init__.py @@ -53,6 +53,7 @@ # Re-export response types for convenient access from hindsight_client_api.models.retain_response import RetainResponse +from hindsight_client_api.models.version_response import VersionResponse from .hindsight_client import Hindsight @@ -159,4 +160,5 @@ def _recall_response_to_prompt_string(self) -> str: "ListMemoryUnitsResponse", "BankProfileResponse", "DispositionTraits", + "VersionResponse", ] diff --git a/hindsight-clients/python/hindsight_client/hindsight_client.py b/hindsight-clients/python/hindsight_client/hindsight_client.py index 207ee032bc..63b9ede662 100644 --- a/hindsight-clients/python/hindsight_client/hindsight_client.py +++ b/hindsight-clients/python/hindsight_client/hindsight_client.py @@ -44,9 +44,9 @@ from hindsight_client_api.models.file_retain_response import FileRetainResponse from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse from hindsight_client_api.models.recall_response import RecallResponse -from hindsight_client_api.models.recall_result import RecallResult from hindsight_client_api.models.reflect_response import ReflectResponse from hindsight_client_api.models.retain_response import RetainResponse +from hindsight_client_api.models.version_response import VersionResponse def _run_async(coro): @@ -246,6 +246,29 @@ async def aclose(self): if self._api_client: await self._api_client.close() + def get_version(self) -> VersionResponse: + """ + Read the connected Hindsight API version and feature flags + (sync wrapper — prefer :meth:`aget_version` in async code). + + Useful for integrations that need to enforce a minimum server version + before enabling a workflow. + + Returns: + VersionResponse with ``api_version`` and ``features``. + """ + return _run_async(self._monitoring_api.get_version(_request_timeout=self._timeout)) + + async def aget_version(self) -> VersionResponse: + """ + Read the connected Hindsight API version and feature flags + (async — preferred over :meth:`get_version`). + + Returns: + VersionResponse with ``api_version`` and ``features``. + """ + return await self._monitoring_api.get_version(_request_timeout=self._timeout) + # Simplified methods for main operations def retain( @@ -384,7 +407,7 @@ def recall( include_source_facts: bool = False, max_source_facts_tokens: int = 4096, tags: list[str] | None = None, - tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any", + tags_match: Literal["any", "all", "any_strict", "all_strict", "exact"] = "any", tag_groups: list[dict[str, Any]] | None = None, ) -> RecallResponse: """ @@ -407,7 +430,8 @@ def recall( max_source_facts_tokens: Maximum tokens for source facts (default: 4096) tags: Optional list of tags to filter memories by tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged), - "any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any" + "any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged), + "exact" (set equality, excludes untagged). Default: "any" tag_groups: Optional list of tag group filters for advanced boolean tag matching. Returns: @@ -443,7 +467,7 @@ def reflect( max_tokens: int | None = None, response_schema: dict[str, Any] | None = None, tags: list[str] | None = None, - tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any", + tags_match: Literal["any", "all", "any_strict", "all_strict", "exact"] = "any", include_facts: bool = False, include_tool_calls: bool = False, include_tool_call_output: bool = True, @@ -466,7 +490,8 @@ def reflect( response parsed according to this schema. tags: Optional list of tags to filter memories by tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged), - "any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any" + "any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged), + "exact" (set equality, excludes untagged). Default: "any" include_facts: If True, the response will include a 'based_on' field listing the memories, mental models, and directives used to construct the answer. include_tool_calls: If True, the response will include a 'trace' field with the @@ -538,6 +563,7 @@ def create_bank( retain_extraction_mode: str | None = None, retain_custom_instructions: str | None = None, retain_chunk_size: int | None = None, + retain_structured_chunk_size: int | None = None, enable_observations: bool | None = None, observations_mission: str | None = None, reflect_mission: str | None = None, @@ -556,7 +582,9 @@ def create_bank( retain_mission: Steers what gets extracted during retain(). Injected alongside built-in rules. retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', or 'custom'. retain_custom_instructions: Custom extraction prompt (only active when mode is 'custom'). - retain_chunk_size: Maximum token size for each content chunk during retain. + retain_chunk_size: Target maximum characters for each content chunk during retain. + retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation + turn to keep whole during retain. Defaults to retain_chunk_size when unset. enable_observations: Toggle automatic observation consolidation after retain(). observations_mission: Controls what gets synthesised into observations. Replaces built-in rules. reflect_mission: Mission/context for Reflect operations. @@ -576,6 +604,7 @@ def create_bank( retain_extraction_mode=retain_extraction_mode, retain_custom_instructions=retain_custom_instructions, retain_chunk_size=retain_chunk_size, + retain_structured_chunk_size=retain_structured_chunk_size, enable_observations=enable_observations, observations_mission=observations_mission, background=background, @@ -596,6 +625,7 @@ async def _acreate_bank( retain_extraction_mode: str | None = None, retain_custom_instructions: str | None = None, retain_chunk_size: int | None = None, + retain_structured_chunk_size: int | None = None, enable_observations: bool | None = None, observations_mission: str | None = None, background: str | None = None, @@ -632,6 +662,8 @@ async def _acreate_bank( body["retain_custom_instructions"] = retain_custom_instructions if retain_chunk_size is not None: body["retain_chunk_size"] = retain_chunk_size + if retain_structured_chunk_size is not None: + body["retain_structured_chunk_size"] = retain_structured_chunk_size if enable_observations is not None: body["enable_observations"] = enable_observations if observations_mission is not None: @@ -670,6 +702,7 @@ async def acreate_bank( retain_extraction_mode: str | None = None, retain_custom_instructions: str | None = None, retain_chunk_size: int | None = None, + retain_structured_chunk_size: int | None = None, enable_observations: bool | None = None, observations_mission: str | None = None, reflect_mission: str | None = None, @@ -688,7 +721,9 @@ async def acreate_bank( retain_mission: Steers what gets extracted during retain(). Injected alongside built-in rules. retain_extraction_mode: Fact extraction mode: 'concise' (default), 'verbose', or 'custom'. retain_custom_instructions: Custom extraction prompt (only active when mode is 'custom'). - retain_chunk_size: Maximum token size for each content chunk during retain. + retain_chunk_size: Target maximum characters for each content chunk during retain. + retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation + turn to keep whole during retain. Defaults to retain_chunk_size when unset. enable_observations: Toggle automatic observation consolidation after retain(). observations_mission: Controls what gets synthesised into observations. Replaces built-in rules. reflect_mission: Mission/context for Reflect operations. @@ -707,6 +742,7 @@ async def acreate_bank( retain_extraction_mode=retain_extraction_mode, retain_custom_instructions=retain_custom_instructions, retain_chunk_size=retain_chunk_size, + retain_structured_chunk_size=retain_structured_chunk_size, enable_observations=enable_observations, observations_mission=observations_mission, background=background, @@ -845,7 +881,7 @@ async def arecall( include_source_facts: bool = False, max_source_facts_tokens: int = 4096, tags: list[str] | None = None, - tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any", + tags_match: Literal["any", "all", "any_strict", "all_strict", "exact"] = "any", tag_groups: list[dict[str, Any]] | None = None, ) -> RecallResponse: """ @@ -868,7 +904,8 @@ async def arecall( max_source_facts_tokens: Maximum tokens for source facts (default: 4096) tags: Optional list of tags to filter memories by tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged), - "any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any" + "any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged), + "exact" (set equality, excludes untagged). Default: "any" tag_groups: Optional list of tag group filters for advanced boolean tag matching. Each element is a dict representing a tag group node (TagGroupLeaf, TagGroupAnd, TagGroupOr, or TagGroupNot). Example:: @@ -925,7 +962,7 @@ async def areflect( max_tokens: int | None = None, response_schema: dict[str, Any] | None = None, tags: list[str] | None = None, - tags_match: Literal["any", "all", "any_strict", "all_strict"] = "any", + tags_match: Literal["any", "all", "any_strict", "all_strict", "exact"] = "any", include_facts: bool = False, include_tool_calls: bool = False, include_tool_call_output: bool = True, @@ -948,7 +985,8 @@ async def areflect( response parsed according to this schema. tags: Optional list of tags to filter memories by tags_match: How to match tags - "any" (OR, includes untagged), "all" (AND, includes untagged), - "any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged). Default: "any" + "any_strict" (OR, excludes untagged), "all_strict" (AND, excludes untagged), + "exact" (set equality, excludes untagged). Default: "any" include_facts: If True, the response will include a 'based_on' field listing the memories, mental models, and directives used to construct the answer. include_tool_calls: If True, the response will include a 'trace' field with the @@ -1323,6 +1361,7 @@ def update_bank_config( retain_extraction_mode: str | None = None, retain_custom_instructions: str | None = None, retain_chunk_size: int | None = None, + retain_structured_chunk_size: int | None = None, retain_default_strategy: str | None = None, retain_strategies: dict[str, Any] | None = None, # Entity settings @@ -1356,7 +1395,9 @@ def update_bank_config( retain_mission: Steers what gets extracted during retain(). retain_extraction_mode: Fact extraction mode: 'concise', 'verbose', or 'custom'. retain_custom_instructions: Custom extraction prompt (only active when mode is 'custom'). - retain_chunk_size: Maximum token size for each content chunk during retain. + retain_chunk_size: Target maximum characters for each content chunk during retain. + retain_structured_chunk_size: Maximum characters for a single JSONL line or conversation + turn to keep whole during retain. Defaults to retain_chunk_size when unset. retain_default_strategy: Default retain strategy name. retain_strategies: Named strategy definitions (dict of strategy name to config). entity_labels: Controlled vocabulary for entity type classification. @@ -1387,6 +1428,7 @@ def update_bank_config( "retain_extraction_mode": retain_extraction_mode, "retain_custom_instructions": retain_custom_instructions, "retain_chunk_size": retain_chunk_size, + "retain_structured_chunk_size": retain_structured_chunk_size, "retain_default_strategy": retain_default_strategy, "retain_strategies": retain_strategies, "entity_labels": entity_labels, diff --git a/hindsight-clients/python/hindsight_client_api/__init__.py b/hindsight-clients/python/hindsight_client_api/__init__.py index 8df787aeb1..d1b40ea502 100644 --- a/hindsight-clients/python/hindsight_client_api/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/__init__.py @@ -7,7 +7,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -55,6 +55,7 @@ from hindsight_client_api.models.bank_config_update import BankConfigUpdate from hindsight_client_api.models.bank_list_item import BankListItem from hindsight_client_api.models.bank_list_response import BankListResponse +from hindsight_client_api.models.bank_llm_health_response import BankLlmHealthResponse from hindsight_client_api.models.bank_profile_response import BankProfileResponse from hindsight_client_api.models.bank_stats_response import BankStatsResponse from hindsight_client_api.models.bank_template_config import BankTemplateConfig @@ -83,6 +84,8 @@ from hindsight_client_api.models.disposition_traits import DispositionTraits from hindsight_client_api.models.document_import_submit_response import DocumentImportSubmitResponse from hindsight_client_api.models.document_response import DocumentResponse +from hindsight_client_api.models.dry_run_extract_request import DryRunExtractRequest +from hindsight_client_api.models.dry_run_extraction_result import DryRunExtractionResult from hindsight_client_api.models.entity_detail_response import EntityDetailResponse from hindsight_client_api.models.entity_graph_response import EntityGraphResponse from hindsight_client_api.models.entity_include_options import EntityIncludeOptions @@ -91,6 +94,7 @@ from hindsight_client_api.models.entity_list_response import EntityListResponse from hindsight_client_api.models.entity_observation_response import EntityObservationResponse from hindsight_client_api.models.entity_state_response import EntityStateResponse +from hindsight_client_api.models.extracted_fact import ExtractedFact from hindsight_client_api.models.features_info import FeaturesInfo from hindsight_client_api.models.file_retain_response import FileRetainResponse from hindsight_client_api.models.graph_data_response import GraphDataResponse @@ -105,6 +109,7 @@ from hindsight_client_api.models.list_documents_response import ListDocumentsResponse from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse from hindsight_client_api.models.list_tags_response import ListTagsResponse +from hindsight_client_api.models.llm_operation_health import LlmOperationHealth from hindsight_client_api.models.memories_timeseries_response import MemoriesTimeseriesResponse from hindsight_client_api.models.memory_item import MemoryItem from hindsight_client_api.models.memory_timeseries_bucket import MemoryTimeseriesBucket @@ -116,7 +121,9 @@ from hindsight_client_api.models.mental_model_trigger_output_tag_groups_inner import MentalModelTriggerOutputTagGroupsInner from hindsight_client_api.models.model_not import ModelNot from hindsight_client_api.models.not1 import Not1 +from hindsight_client_api.models.observation_scope import ObservationScope from hindsight_client_api.models.observation_scopes import ObservationScopes +from hindsight_client_api.models.observation_scopes_response import ObservationScopesResponse from hindsight_client_api.models.operation_progress import OperationProgress from hindsight_client_api.models.operation_response import OperationResponse from hindsight_client_api.models.operation_status_response import OperationStatusResponse @@ -155,6 +162,7 @@ from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest from hindsight_client_api.models.update_document_request import UpdateDocumentRequest from hindsight_client_api.models.update_document_response import UpdateDocumentResponse +from hindsight_client_api.models.update_memory_request import UpdateMemoryRequest from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest from hindsight_client_api.models.update_webhook_request import UpdateWebhookRequest from hindsight_client_api.models.validation_error import ValidationError diff --git a/hindsight-clients/python/hindsight_client_api/api/audit_api.py b/hindsight-clients/python/hindsight_client_api/api/audit_api.py index 16df9fdc11..e6367936b4 100644 --- a/hindsight-clients/python/hindsight_client_api/api/audit_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/audit_api.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/api/bank_templates_api.py b/hindsight-clients/python/hindsight_client_api/api/bank_templates_api.py index d98a88ee62..371cac0bd6 100644 --- a/hindsight-clients/python/hindsight_client_api/api/bank_templates_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/bank_templates_api.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/api/banks_api.py b/hindsight-clients/python/hindsight_client_api/api/banks_api.py index ed3155de53..b785971cae 100644 --- a/hindsight-clients/python/hindsight_client_api/api/banks_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/banks_api.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -24,6 +24,7 @@ from hindsight_client_api.models.bank_config_response import BankConfigResponse from hindsight_client_api.models.bank_config_update import BankConfigUpdate from hindsight_client_api.models.bank_list_response import BankListResponse +from hindsight_client_api.models.bank_llm_health_response import BankLlmHealthResponse from hindsight_client_api.models.bank_profile_response import BankProfileResponse from hindsight_client_api.models.bank_stats_response import BankStatsResponse from hindsight_client_api.models.consolidation_request import ConsolidationRequest @@ -3191,6 +3192,284 @@ def _reset_bank_config_serialize( + @validate_call + async def test_bank_llm( + self, + bank_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> BankLlmHealthResponse: + """Test the bank's LLM connectivity + + Probe the LLMs this bank would use for retain / consolidation / reflect with one minimal call each (configs shared across operations are probed once), so you can discover 'not configured / unreachable' instead of a silent stall. Deliberate action (makes a real provider call); not for polling. Returns status only — never the provider, model, endpoint, API key, or raw error. Disable with HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH=false. + + :param bank_id: (required) + :type bank_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._test_bank_llm_serialize( + bank_id=bank_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "BankLlmHealthResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def test_bank_llm_with_http_info( + self, + bank_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[BankLlmHealthResponse]: + """Test the bank's LLM connectivity + + Probe the LLMs this bank would use for retain / consolidation / reflect with one minimal call each (configs shared across operations are probed once), so you can discover 'not configured / unreachable' instead of a silent stall. Deliberate action (makes a real provider call); not for polling. Returns status only — never the provider, model, endpoint, API key, or raw error. Disable with HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH=false. + + :param bank_id: (required) + :type bank_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._test_bank_llm_serialize( + bank_id=bank_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "BankLlmHealthResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def test_bank_llm_without_preload_content( + self, + bank_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Test the bank's LLM connectivity + + Probe the LLMs this bank would use for retain / consolidation / reflect with one minimal call each (configs shared across operations are probed once), so you can discover 'not configured / unreachable' instead of a silent stall. Deliberate action (makes a real provider call); not for polling. Returns status only — never the provider, model, endpoint, API key, or raw error. Disable with HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH=false. + + :param bank_id: (required) + :type bank_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._test_bank_llm_serialize( + bank_id=bank_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "BankLlmHealthResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _test_bank_llm_serialize( + self, + bank_id, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='POST', + resource_path='/v1/default/banks/{bank_id}/health/llm', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + @validate_call async def trigger_consolidation( self, diff --git a/hindsight-clients/python/hindsight_client_api/api/directives_api.py b/hindsight-clients/python/hindsight_client_api/api/directives_api.py index ef60729dbb..aad55df800 100644 --- a/hindsight-clients/python/hindsight_client_api/api/directives_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/directives_api.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/api/document_transfer_api.py b/hindsight-clients/python/hindsight_client_api/api/document_transfer_api.py index 9b10cad7f4..8bff898725 100644 --- a/hindsight-clients/python/hindsight_client_api/api/document_transfer_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/document_transfer_api.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/api/documents_api.py b/hindsight-clients/python/hindsight_client_api/api/documents_api.py index 3044118db3..c82983f720 100644 --- a/hindsight-clients/python/hindsight_client_api/api/documents_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/documents_api.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/api/entities_api.py b/hindsight-clients/python/hindsight_client_api/api/entities_api.py index 81529a0cec..4e1eaa4448 100644 --- a/hindsight-clients/python/hindsight_client_api/api/entities_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/entities_api.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/api/files_api.py b/hindsight-clients/python/hindsight_client_api/api/files_api.py index ef4e94a44a..8062a461fa 100644 --- a/hindsight-clients/python/hindsight_client_api/api/files_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/files_api.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -61,7 +61,7 @@ async def file_retain( ) -> FileRetainResponse: """Convert files to memories - Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously — returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model **Parser selection:** - Set `parser` in the request body to override the server default for all files. - Set `parser` inside a `files_metadata` entry for per-file control. - Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — each parser is tried in sequence until one succeeds. - Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified. - Only parsers enabled on the server may be requested; others return HTTP 400. + Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (parser-dependent OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously — returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model **Parser selection:** - Set `parser` in the request body to override the server default for all files. - Set `parser` inside a `files_metadata` entry for per-file control. - Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — each parser is tried in sequence until one succeeds. - Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified. - Only parsers enabled on the server may be requested; others return HTTP 400. :param bank_id: (required) :type bank_id: str @@ -141,7 +141,7 @@ async def file_retain_with_http_info( ) -> ApiResponse[FileRetainResponse]: """Convert files to memories - Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously — returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model **Parser selection:** - Set `parser` in the request body to override the server default for all files. - Set `parser` inside a `files_metadata` entry for per-file control. - Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — each parser is tried in sequence until one succeeds. - Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified. - Only parsers enabled on the server may be requested; others return HTTP 400. + Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (parser-dependent OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously — returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model **Parser selection:** - Set `parser` in the request body to override the server default for all files. - Set `parser` inside a `files_metadata` entry for per-file control. - Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — each parser is tried in sequence until one succeeds. - Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified. - Only parsers enabled on the server may be requested; others return HTTP 400. :param bank_id: (required) :type bank_id: str @@ -221,7 +221,7 @@ async def file_retain_without_preload_content( ) -> RESTResponseType: """Convert files to memories - Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously — returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model **Parser selection:** - Set `parser` in the request body to override the server default for all files. - Set `parser` inside a `files_metadata` entry for per-file control. - Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — each parser is tried in sequence until one succeeds. - Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified. - Only parsers enabled on the server may be requested; others return HTTP 400. + Upload files (PDF, DOCX, etc.), convert them to markdown, and retain as memories. This endpoint handles file upload, conversion, and memory creation in a single operation. **Features:** - Supports PDF, DOCX, PPTX, XLSX, images (parser-dependent OCR), audio (with transcription) - Automatic file-to-markdown conversion using pluggable parsers - Files stored in object storage (PostgreSQL by default, S3 for production) - Each file becomes a separate document with optional metadata/tags - Always processes asynchronously — returns operation IDs immediately **The system automatically:** 1. Stores uploaded files in object storage 2. Converts files to markdown 3. Creates document records with file metadata 4. Extracts facts and creates memory units (same as regular retain) Use the operations endpoint to monitor progress. **Request format:** multipart/form-data with: - `files`: One or more files to upload - `request`: JSON string with FileRetainRequest model **Parser selection:** - Set `parser` in the request body to override the server default for all files. - Set `parser` inside a `files_metadata` entry for per-file control. - Pass a list (e.g. `['iris', 'markitdown']`) to define an ordered fallback chain — each parser is tried in sequence until one succeeds. - Falls back to the server default (`HINDSIGHT_API_FILE_PARSER`) if not specified. - Only parsers enabled on the server may be requested; others return HTTP 400. :param bank_id: (required) :type bank_id: str diff --git a/hindsight-clients/python/hindsight_client_api/api/llm_traces_api.py b/hindsight-clients/python/hindsight_client_api/api/llm_traces_api.py index aa27e6b3d0..06f3427b50 100644 --- a/hindsight-clients/python/hindsight_client_api/api/llm_traces_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/llm_traces_api.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/api/memory_api.py b/hindsight-clients/python/hindsight_client_api/api/memory_api.py index df02db0130..88e4bcd123 100644 --- a/hindsight-clients/python/hindsight_client_api/api/memory_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/memory_api.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -21,15 +21,19 @@ from typing_extensions import Annotated from hindsight_client_api.models.clear_memory_observations_response import ClearMemoryObservationsResponse from hindsight_client_api.models.delete_response import DeleteResponse +from hindsight_client_api.models.dry_run_extract_request import DryRunExtractRequest +from hindsight_client_api.models.dry_run_extraction_result import DryRunExtractionResult from hindsight_client_api.models.graph_data_response import GraphDataResponse from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse from hindsight_client_api.models.list_tags_response import ListTagsResponse +from hindsight_client_api.models.observation_scopes_response import ObservationScopesResponse from hindsight_client_api.models.recall_request import RecallRequest from hindsight_client_api.models.recall_response import RecallResponse from hindsight_client_api.models.reflect_request import ReflectRequest from hindsight_client_api.models.reflect_response import ReflectResponse from hindsight_client_api.models.retain_request import RetainRequest from hindsight_client_api.models.retain_response import RetainResponse +from hindsight_client_api.models.update_memory_request import UpdateMemoryRequest from hindsight_client_api.api_client import ApiClient, RequestSerialized from hindsight_client_api.api_response import ApiResponse @@ -53,7 +57,7 @@ def __init__(self, api_client=None) -> None: async def clear_bank_memories( self, bank_id: StrictStr, - type: Annotated[Optional[StrictStr], Field(description="Optional fact type filter (world, experience, opinion)")] = None, + type: Annotated[Optional[StrictStr], Field(description="Optional fact type filter (world, experience, observation)")] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -70,11 +74,11 @@ async def clear_bank_memories( ) -> DeleteResponse: """Clear memory bank memories - Delete memory units for a memory bank. Optionally filter by type (world, experience, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved. + Delete memory units for a memory bank. Optionally filter by type (world, experience, observation) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved. :param bank_id: (required) :type bank_id: str - :param type: Optional fact type filter (world, experience, opinion) + :param type: Optional fact type filter (world, experience, observation) :type type: str :param authorization: :type authorization: str @@ -129,7 +133,7 @@ async def clear_bank_memories( async def clear_bank_memories_with_http_info( self, bank_id: StrictStr, - type: Annotated[Optional[StrictStr], Field(description="Optional fact type filter (world, experience, opinion)")] = None, + type: Annotated[Optional[StrictStr], Field(description="Optional fact type filter (world, experience, observation)")] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -146,11 +150,11 @@ async def clear_bank_memories_with_http_info( ) -> ApiResponse[DeleteResponse]: """Clear memory bank memories - Delete memory units for a memory bank. Optionally filter by type (world, experience, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved. + Delete memory units for a memory bank. Optionally filter by type (world, experience, observation) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved. :param bank_id: (required) :type bank_id: str - :param type: Optional fact type filter (world, experience, opinion) + :param type: Optional fact type filter (world, experience, observation) :type type: str :param authorization: :type authorization: str @@ -205,7 +209,7 @@ async def clear_bank_memories_with_http_info( async def clear_bank_memories_without_preload_content( self, bank_id: StrictStr, - type: Annotated[Optional[StrictStr], Field(description="Optional fact type filter (world, experience, opinion)")] = None, + type: Annotated[Optional[StrictStr], Field(description="Optional fact type filter (world, experience, observation)")] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -222,11 +226,11 @@ async def clear_bank_memories_without_preload_content( ) -> RESTResponseType: """Clear memory bank memories - Delete memory units for a memory bank. Optionally filter by type (world, experience, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved. + Delete memory units for a memory bank. Optionally filter by type (world, experience, observation) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved. :param bank_id: (required) :type bank_id: str - :param type: Optional fact type filter (world, experience, opinion) + :param type: Optional fact type filter (world, experience, observation) :type type: str :param authorization: :type authorization: str @@ -637,17 +641,791 @@ def _clear_memory_observations_serialize( + @validate_call + async def dry_run_extract_memories( + self, + bank_id: StrictStr, + dry_run_extract_request: DryRunExtractRequest, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> DryRunExtractionResult: + """Dry-run fact extraction (preview, no persistence) + + Preview what the retain step would extract from text WITHOUT changing the bank — no entity resolution, links, embeddings, or persistence. Returns the candidate facts and the LLM token usage. Every prompt-affecting setting (retain mission, extraction mode, chunk size, …) is overridable in the body to A/B a candidate config against the bank's current one. This is a read-only tool: nothing is stored. + + :param bank_id: (required) + :type bank_id: str + :param dry_run_extract_request: (required) + :type dry_run_extract_request: DryRunExtractRequest + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._dry_run_extract_memories_serialize( + bank_id=bank_id, + dry_run_extract_request=dry_run_extract_request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DryRunExtractionResult", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def dry_run_extract_memories_with_http_info( + self, + bank_id: StrictStr, + dry_run_extract_request: DryRunExtractRequest, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[DryRunExtractionResult]: + """Dry-run fact extraction (preview, no persistence) + + Preview what the retain step would extract from text WITHOUT changing the bank — no entity resolution, links, embeddings, or persistence. Returns the candidate facts and the LLM token usage. Every prompt-affecting setting (retain mission, extraction mode, chunk size, …) is overridable in the body to A/B a candidate config against the bank's current one. This is a read-only tool: nothing is stored. + + :param bank_id: (required) + :type bank_id: str + :param dry_run_extract_request: (required) + :type dry_run_extract_request: DryRunExtractRequest + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._dry_run_extract_memories_serialize( + bank_id=bank_id, + dry_run_extract_request=dry_run_extract_request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DryRunExtractionResult", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def dry_run_extract_memories_without_preload_content( + self, + bank_id: StrictStr, + dry_run_extract_request: DryRunExtractRequest, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Dry-run fact extraction (preview, no persistence) + + Preview what the retain step would extract from text WITHOUT changing the bank — no entity resolution, links, embeddings, or persistence. Returns the candidate facts and the LLM token usage. Every prompt-affecting setting (retain mission, extraction mode, chunk size, …) is overridable in the body to A/B a candidate config against the bank's current one. This is a read-only tool: nothing is stored. + + :param bank_id: (required) + :type bank_id: str + :param dry_run_extract_request: (required) + :type dry_run_extract_request: DryRunExtractRequest + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._dry_run_extract_memories_serialize( + bank_id=bank_id, + dry_run_extract_request=dry_run_extract_request, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "DryRunExtractionResult", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _dry_run_extract_memories_serialize( + self, + bank_id, + dry_run_extract_request, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + if dry_run_extract_request is not None: + _body_params = dry_run_extract_request + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'application/json' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='POST', + resource_path='/v1/default/banks/{bank_id}/memories/dry-run-extract', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + @validate_call async def get_graph( self, bank_id: StrictStr, - type: Optional[StrictStr] = None, - limit: Optional[StrictInt] = None, - q: Optional[StrictStr] = None, - tags: Optional[List[Optional[StrictStr]]] = None, - tags_match: Optional[StrictStr] = None, - document_id: Optional[StrictStr] = None, - chunk_id: Optional[StrictStr] = None, + type: Optional[StrictStr] = None, + limit: Optional[StrictInt] = None, + q: Optional[StrictStr] = None, + tags: Optional[List[Optional[StrictStr]]] = None, + tags_match: Optional[StrictStr] = None, + document_id: Optional[StrictStr] = None, + chunk_id: Optional[StrictStr] = None, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> GraphDataResponse: + """Get memory graph data + + Retrieve graph data for visualization, optionally filtered by type (world/experience/observation). + + :param bank_id: (required) + :type bank_id: str + :param type: + :type type: str + :param limit: + :type limit: int + :param q: + :type q: str + :param tags: + :type tags: List[Optional[str]] + :param tags_match: + :type tags_match: str + :param document_id: + :type document_id: str + :param chunk_id: + :type chunk_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_graph_serialize( + bank_id=bank_id, + type=type, + limit=limit, + q=q, + tags=tags, + tags_match=tags_match, + document_id=document_id, + chunk_id=chunk_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "GraphDataResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def get_graph_with_http_info( + self, + bank_id: StrictStr, + type: Optional[StrictStr] = None, + limit: Optional[StrictInt] = None, + q: Optional[StrictStr] = None, + tags: Optional[List[Optional[StrictStr]]] = None, + tags_match: Optional[StrictStr] = None, + document_id: Optional[StrictStr] = None, + chunk_id: Optional[StrictStr] = None, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> ApiResponse[GraphDataResponse]: + """Get memory graph data + + Retrieve graph data for visualization, optionally filtered by type (world/experience/observation). + + :param bank_id: (required) + :type bank_id: str + :param type: + :type type: str + :param limit: + :type limit: int + :param q: + :type q: str + :param tags: + :type tags: List[Optional[str]] + :param tags_match: + :type tags_match: str + :param document_id: + :type document_id: str + :param chunk_id: + :type chunk_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_graph_serialize( + bank_id=bank_id, + type=type, + limit=limit, + q=q, + tags=tags, + tags_match=tags_match, + document_id=document_id, + chunk_id=chunk_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "GraphDataResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def get_graph_without_preload_content( + self, + bank_id: StrictStr, + type: Optional[StrictStr] = None, + limit: Optional[StrictInt] = None, + q: Optional[StrictStr] = None, + tags: Optional[List[Optional[StrictStr]]] = None, + tags_match: Optional[StrictStr] = None, + document_id: Optional[StrictStr] = None, + chunk_id: Optional[StrictStr] = None, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Get memory graph data + + Retrieve graph data for visualization, optionally filtered by type (world/experience/observation). + + :param bank_id: (required) + :type bank_id: str + :param type: + :type type: str + :param limit: + :type limit: int + :param q: + :type q: str + :param tags: + :type tags: List[Optional[str]] + :param tags_match: + :type tags_match: str + :param document_id: + :type document_id: str + :param chunk_id: + :type chunk_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_graph_serialize( + bank_id=bank_id, + type=type, + limit=limit, + q=q, + tags=tags, + tags_match=tags_match, + document_id=document_id, + chunk_id=chunk_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "GraphDataResponse", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _get_graph_serialize( + self, + bank_id, + type, + limit, + q, + tags, + tags_match, + document_id, + chunk_id, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + 'tags': 'multi', + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + # process the query parameters + if type is not None: + + _query_params.append(('type', type)) + + if limit is not None: + + _query_params.append(('limit', limit)) + + if q is not None: + + _query_params.append(('q', q)) + + if tags is not None: + + _query_params.append(('tags', tags)) + + if tags_match is not None: + + _query_params.append(('tags_match', tags_match)) + + if document_id is not None: + + _query_params.append(('document_id', document_id)) + + if chunk_id is not None: + + _query_params.append(('chunk_id', chunk_id)) + + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/v1/default/banks/{bank_id}/graph', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + + @validate_call + async def get_memory( + self, + bank_id: StrictStr, + memory_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> object: + """Get memory unit + + Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead. + + :param bank_id: (required) + :type bank_id: str + :param memory_id: (required) + :type memory_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_memory_serialize( + bank_id=bank_id, + memory_id=memory_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ).data + + + @validate_call + async def get_memory_with_http_info( + self, + bank_id: StrictStr, + memory_id: StrictStr, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -661,27 +1439,15 @@ async def get_graph( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> GraphDataResponse: - """Get memory graph data + ) -> ApiResponse[object]: + """Get memory unit - Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). + Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead. :param bank_id: (required) :type bank_id: str - :param type: - :type type: str - :param limit: - :type limit: int - :param q: - :type q: str - :param tags: - :type tags: List[Optional[str]] - :param tags_match: - :type tags_match: str - :param document_id: - :type document_id: str - :param chunk_id: - :type chunk_id: str + :param memory_id: (required) + :type memory_id: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -706,15 +1472,226 @@ async def get_graph( :return: Returns the result object. """ # noqa: E501 - _param = self._get_graph_serialize( + _param = self._get_memory_serialize( bank_id=bank_id, - type=type, - limit=limit, - q=q, - tags=tags, - tags_match=tags_match, - document_id=document_id, - chunk_id=chunk_id, + memory_id=memory_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + await response_data.read() + return self.api_client.response_deserialize( + response_data=response_data, + response_types_map=_response_types_map, + ) + + + @validate_call + async def get_memory_without_preload_content( + self, + bank_id: StrictStr, + memory_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> RESTResponseType: + """Get memory unit + + Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead. + + :param bank_id: (required) + :type bank_id: str + :param memory_id: (required) + :type memory_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_memory_serialize( + bank_id=bank_id, + memory_id=memory_id, + authorization=authorization, + _request_auth=_request_auth, + _content_type=_content_type, + _headers=_headers, + _host_index=_host_index + ) + + _response_types_map: Dict[str, Optional[str]] = { + '200': "object", + '422': "HTTPValidationError", + } + response_data = await self.api_client.call_api( + *_param, + _request_timeout=_request_timeout + ) + return response_data.response + + + def _get_memory_serialize( + self, + bank_id, + memory_id, + authorization, + _request_auth, + _content_type, + _headers, + _host_index, + ) -> RequestSerialized: + + _host = None + + _collection_formats: Dict[str, str] = { + } + + _path_params: Dict[str, str] = {} + _query_params: List[Tuple[str, str]] = [] + _header_params: Dict[str, Optional[str]] = _headers or {} + _form_params: List[Tuple[str, str]] = [] + _files: Dict[ + str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]] + ] = {} + _body_params: Optional[bytes] = None + + # process the path parameters + if bank_id is not None: + _path_params['bank_id'] = bank_id + if memory_id is not None: + _path_params['memory_id'] = memory_id + # process the query parameters + # process the header parameters + if authorization is not None: + _header_params['authorization'] = authorization + # process the form parameters + # process the body parameter + + + # set the HTTP header `Accept` + if 'Accept' not in _header_params: + _header_params['Accept'] = self.api_client.select_header_accept( + [ + 'application/json' + ] + ) + + + # authentication setting + _auth_settings: List[str] = [ + ] + + return self.api_client.param_serialize( + method='GET', + resource_path='/v1/default/banks/{bank_id}/memories/{memory_id}', + path_params=_path_params, + query_params=_query_params, + header_params=_header_params, + body=_body_params, + post_params=_form_params, + files=_files, + auth_settings=_auth_settings, + collection_formats=_collection_formats, + _host=_host, + _request_auth=_request_auth + ) + + + + + @validate_call + async def get_observation_history( + self, + bank_id: StrictStr, + memory_id: StrictStr, + authorization: Optional[StrictStr] = None, + _request_timeout: Union[ + None, + Annotated[StrictFloat, Field(gt=0)], + Tuple[ + Annotated[StrictFloat, Field(gt=0)], + Annotated[StrictFloat, Field(gt=0)] + ] + ] = None, + _request_auth: Optional[Dict[StrictStr, Any]] = None, + _content_type: Optional[StrictStr] = None, + _headers: Optional[Dict[StrictStr, Any]] = None, + _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, + ) -> object: + """Get observation history + + Get the full history of an observation, with each change's source facts resolved to their text. + + :param bank_id: (required) + :type bank_id: str + :param memory_id: (required) + :type memory_id: str + :param authorization: + :type authorization: str + :param _request_timeout: timeout setting for this request. If one + number provided, it will be total request + timeout. It can also be a pair (tuple) of + (connection, read) timeouts. + :type _request_timeout: int, tuple(int, int), optional + :param _request_auth: set to override the auth_settings for an a single + request; this effectively ignores the + authentication in the spec for a single request. + :type _request_auth: dict, optional + :param _content_type: force content-type for the request. + :type _content_type: str, Optional + :param _headers: set to override the headers for a single + request; this effectively ignores the headers + in the spec for a single request. + :type _headers: dict, optional + :param _host_index: set to override the host_index for a single + request; this effectively ignores the host_index + in the spec for a single request. + :type _host_index: int, optional + :return: Returns the result object. + """ # noqa: E501 + + _param = self._get_observation_history_serialize( + bank_id=bank_id, + memory_id=memory_id, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -723,7 +1700,7 @@ async def get_graph( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "GraphDataResponse", + '200': "object", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -738,16 +1715,10 @@ async def get_graph( @validate_call - async def get_graph_with_http_info( + async def get_observation_history_with_http_info( self, bank_id: StrictStr, - type: Optional[StrictStr] = None, - limit: Optional[StrictInt] = None, - q: Optional[StrictStr] = None, - tags: Optional[List[Optional[StrictStr]]] = None, - tags_match: Optional[StrictStr] = None, - document_id: Optional[StrictStr] = None, - chunk_id: Optional[StrictStr] = None, + memory_id: StrictStr, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -761,27 +1732,15 @@ async def get_graph_with_http_info( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[GraphDataResponse]: - """Get memory graph data + ) -> ApiResponse[object]: + """Get observation history - Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). + Get the full history of an observation, with each change's source facts resolved to their text. :param bank_id: (required) :type bank_id: str - :param type: - :type type: str - :param limit: - :type limit: int - :param q: - :type q: str - :param tags: - :type tags: List[Optional[str]] - :param tags_match: - :type tags_match: str - :param document_id: - :type document_id: str - :param chunk_id: - :type chunk_id: str + :param memory_id: (required) + :type memory_id: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -806,15 +1765,9 @@ async def get_graph_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._get_graph_serialize( + _param = self._get_observation_history_serialize( bank_id=bank_id, - type=type, - limit=limit, - q=q, - tags=tags, - tags_match=tags_match, - document_id=document_id, - chunk_id=chunk_id, + memory_id=memory_id, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -823,7 +1776,7 @@ async def get_graph_with_http_info( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "GraphDataResponse", + '200': "object", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -838,16 +1791,10 @@ async def get_graph_with_http_info( @validate_call - async def get_graph_without_preload_content( + async def get_observation_history_without_preload_content( self, bank_id: StrictStr, - type: Optional[StrictStr] = None, - limit: Optional[StrictInt] = None, - q: Optional[StrictStr] = None, - tags: Optional[List[Optional[StrictStr]]] = None, - tags_match: Optional[StrictStr] = None, - document_id: Optional[StrictStr] = None, - chunk_id: Optional[StrictStr] = None, + memory_id: StrictStr, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -862,26 +1809,14 @@ async def get_graph_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Get memory graph data + """Get observation history - Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). + Get the full history of an observation, with each change's source facts resolved to their text. :param bank_id: (required) :type bank_id: str - :param type: - :type type: str - :param limit: - :type limit: int - :param q: - :type q: str - :param tags: - :type tags: List[Optional[str]] - :param tags_match: - :type tags_match: str - :param document_id: - :type document_id: str - :param chunk_id: - :type chunk_id: str + :param memory_id: (required) + :type memory_id: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -906,15 +1841,9 @@ async def get_graph_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._get_graph_serialize( + _param = self._get_observation_history_serialize( bank_id=bank_id, - type=type, - limit=limit, - q=q, - tags=tags, - tags_match=tags_match, - document_id=document_id, - chunk_id=chunk_id, + memory_id=memory_id, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -923,7 +1852,7 @@ async def get_graph_without_preload_content( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "GraphDataResponse", + '200': "object", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -933,16 +1862,10 @@ async def get_graph_without_preload_content( return response_data.response - def _get_graph_serialize( + def _get_observation_history_serialize( self, bank_id, - type, - limit, - q, - tags, - tags_match, - document_id, - chunk_id, + memory_id, authorization, _request_auth, _content_type, @@ -953,7 +1876,6 @@ def _get_graph_serialize( _host = None _collection_formats: Dict[str, str] = { - 'tags': 'multi', } _path_params: Dict[str, str] = {} @@ -968,35 +1890,9 @@ def _get_graph_serialize( # process the path parameters if bank_id is not None: _path_params['bank_id'] = bank_id + if memory_id is not None: + _path_params['memory_id'] = memory_id # process the query parameters - if type is not None: - - _query_params.append(('type', type)) - - if limit is not None: - - _query_params.append(('limit', limit)) - - if q is not None: - - _query_params.append(('q', q)) - - if tags is not None: - - _query_params.append(('tags', tags)) - - if tags_match is not None: - - _query_params.append(('tags_match', tags_match)) - - if document_id is not None: - - _query_params.append(('document_id', document_id)) - - if chunk_id is not None: - - _query_params.append(('chunk_id', chunk_id)) - # process the header parameters if authorization is not None: _header_params['authorization'] = authorization @@ -1019,7 +1915,7 @@ def _get_graph_serialize( return self.api_client.param_serialize( method='GET', - resource_path='/v1/default/banks/{bank_id}/graph', + resource_path='/v1/default/banks/{bank_id}/memories/{memory_id}/history', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -1036,10 +1932,16 @@ def _get_graph_serialize( @validate_call - async def get_memory( + async def list_memories( self, bank_id: StrictStr, - memory_id: StrictStr, + type: Optional[StrictStr] = None, + q: Optional[StrictStr] = None, + consolidation_state: Optional[StrictStr] = None, + state: Optional[StrictStr] = None, + document_id: Optional[StrictStr] = None, + limit: Optional[StrictInt] = None, + offset: Optional[StrictInt] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -1053,15 +1955,27 @@ async def get_memory( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> object: - """Get memory unit + ) -> ListMemoryUnitsResponse: + """List memory units - Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead. + List memory units with pagination and optional full-text search. Supports filtering by type. Results are sorted by most recent first (mentioned_at DESC, then created_at DESC). :param bank_id: (required) :type bank_id: str - :param memory_id: (required) - :type memory_id: str + :param type: + :type type: str + :param q: + :type q: str + :param consolidation_state: + :type consolidation_state: str + :param state: + :type state: str + :param document_id: + :type document_id: str + :param limit: + :type limit: int + :param offset: + :type offset: int :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -1086,9 +2000,15 @@ async def get_memory( :return: Returns the result object. """ # noqa: E501 - _param = self._get_memory_serialize( + _param = self._list_memories_serialize( bank_id=bank_id, - memory_id=memory_id, + type=type, + q=q, + consolidation_state=consolidation_state, + state=state, + document_id=document_id, + limit=limit, + offset=offset, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -1097,7 +2017,7 @@ async def get_memory( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "object", + '200': "ListMemoryUnitsResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -1112,10 +2032,16 @@ async def get_memory( @validate_call - async def get_memory_with_http_info( + async def list_memories_with_http_info( self, bank_id: StrictStr, - memory_id: StrictStr, + type: Optional[StrictStr] = None, + q: Optional[StrictStr] = None, + consolidation_state: Optional[StrictStr] = None, + state: Optional[StrictStr] = None, + document_id: Optional[StrictStr] = None, + limit: Optional[StrictInt] = None, + offset: Optional[StrictInt] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -1129,15 +2055,27 @@ async def get_memory_with_http_info( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[object]: - """Get memory unit + ) -> ApiResponse[ListMemoryUnitsResponse]: + """List memory units - Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead. + List memory units with pagination and optional full-text search. Supports filtering by type. Results are sorted by most recent first (mentioned_at DESC, then created_at DESC). :param bank_id: (required) :type bank_id: str - :param memory_id: (required) - :type memory_id: str + :param type: + :type type: str + :param q: + :type q: str + :param consolidation_state: + :type consolidation_state: str + :param state: + :type state: str + :param document_id: + :type document_id: str + :param limit: + :type limit: int + :param offset: + :type offset: int :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -1162,9 +2100,15 @@ async def get_memory_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._get_memory_serialize( + _param = self._list_memories_serialize( bank_id=bank_id, - memory_id=memory_id, + type=type, + q=q, + consolidation_state=consolidation_state, + state=state, + document_id=document_id, + limit=limit, + offset=offset, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -1173,7 +2117,7 @@ async def get_memory_with_http_info( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "object", + '200': "ListMemoryUnitsResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -1188,10 +2132,16 @@ async def get_memory_with_http_info( @validate_call - async def get_memory_without_preload_content( + async def list_memories_without_preload_content( self, bank_id: StrictStr, - memory_id: StrictStr, + type: Optional[StrictStr] = None, + q: Optional[StrictStr] = None, + consolidation_state: Optional[StrictStr] = None, + state: Optional[StrictStr] = None, + document_id: Optional[StrictStr] = None, + limit: Optional[StrictInt] = None, + offset: Optional[StrictInt] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -1206,14 +2156,26 @@ async def get_memory_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Get memory unit + """List memory units - Get a single memory unit by ID with all its metadata including entities and tags. Note: the 'history' field is deprecated and always returns an empty list - use GET /memories/{memory_id}/history instead. + List memory units with pagination and optional full-text search. Supports filtering by type. Results are sorted by most recent first (mentioned_at DESC, then created_at DESC). :param bank_id: (required) :type bank_id: str - :param memory_id: (required) - :type memory_id: str + :param type: + :type type: str + :param q: + :type q: str + :param consolidation_state: + :type consolidation_state: str + :param state: + :type state: str + :param document_id: + :type document_id: str + :param limit: + :type limit: int + :param offset: + :type offset: int :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -1238,9 +2200,15 @@ async def get_memory_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._get_memory_serialize( + _param = self._list_memories_serialize( bank_id=bank_id, - memory_id=memory_id, + type=type, + q=q, + consolidation_state=consolidation_state, + state=state, + document_id=document_id, + limit=limit, + offset=offset, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -1249,7 +2217,7 @@ async def get_memory_without_preload_content( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "object", + '200': "ListMemoryUnitsResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -1259,10 +2227,16 @@ async def get_memory_without_preload_content( return response_data.response - def _get_memory_serialize( + def _list_memories_serialize( self, bank_id, - memory_id, + type, + q, + consolidation_state, + state, + document_id, + limit, + offset, authorization, _request_auth, _content_type, @@ -1287,9 +2261,35 @@ def _get_memory_serialize( # process the path parameters if bank_id is not None: _path_params['bank_id'] = bank_id - if memory_id is not None: - _path_params['memory_id'] = memory_id # process the query parameters + if type is not None: + + _query_params.append(('type', type)) + + if q is not None: + + _query_params.append(('q', q)) + + if consolidation_state is not None: + + _query_params.append(('consolidation_state', consolidation_state)) + + if state is not None: + + _query_params.append(('state', state)) + + if document_id is not None: + + _query_params.append(('document_id', document_id)) + + if limit is not None: + + _query_params.append(('limit', limit)) + + if offset is not None: + + _query_params.append(('offset', offset)) + # process the header parameters if authorization is not None: _header_params['authorization'] = authorization @@ -1312,7 +2312,7 @@ def _get_memory_serialize( return self.api_client.param_serialize( method='GET', - resource_path='/v1/default/banks/{bank_id}/memories/{memory_id}', + resource_path='/v1/default/banks/{bank_id}/memories/list', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -1329,10 +2329,9 @@ def _get_memory_serialize( @validate_call - async def get_observation_history( + async def list_observation_scopes( self, bank_id: StrictStr, - memory_id: StrictStr, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -1346,15 +2345,13 @@ async def get_observation_history( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> object: - """Get observation history + ) -> ObservationScopesResponse: + """List observation scopes - Get the full history of an observation, with each change's source facts resolved to their text. + Enumerate the distinct scopes across a bank's observations. Each observation lives under a scope: the exact set of tags it was consolidated with. Returns every distinct scope (tag order normalized) with the number of observations in it; the empty tag list is the global/untagged scope. Use a returned scope with the graph endpoint (tags= & tags_match=exact) to filter observations to exactly that scope. :param bank_id: (required) :type bank_id: str - :param memory_id: (required) - :type memory_id: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -1379,9 +2376,8 @@ async def get_observation_history( :return: Returns the result object. """ # noqa: E501 - _param = self._get_observation_history_serialize( + _param = self._list_observation_scopes_serialize( bank_id=bank_id, - memory_id=memory_id, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -1390,7 +2386,7 @@ async def get_observation_history( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "object", + '200': "ObservationScopesResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -1405,10 +2401,9 @@ async def get_observation_history( @validate_call - async def get_observation_history_with_http_info( + async def list_observation_scopes_with_http_info( self, bank_id: StrictStr, - memory_id: StrictStr, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -1422,15 +2417,13 @@ async def get_observation_history_with_http_info( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[object]: - """Get observation history + ) -> ApiResponse[ObservationScopesResponse]: + """List observation scopes - Get the full history of an observation, with each change's source facts resolved to their text. + Enumerate the distinct scopes across a bank's observations. Each observation lives under a scope: the exact set of tags it was consolidated with. Returns every distinct scope (tag order normalized) with the number of observations in it; the empty tag list is the global/untagged scope. Use a returned scope with the graph endpoint (tags= & tags_match=exact) to filter observations to exactly that scope. :param bank_id: (required) :type bank_id: str - :param memory_id: (required) - :type memory_id: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -1455,9 +2448,8 @@ async def get_observation_history_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._get_observation_history_serialize( + _param = self._list_observation_scopes_serialize( bank_id=bank_id, - memory_id=memory_id, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -1466,7 +2458,7 @@ async def get_observation_history_with_http_info( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "object", + '200': "ObservationScopesResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -1481,10 +2473,9 @@ async def get_observation_history_with_http_info( @validate_call - async def get_observation_history_without_preload_content( + async def list_observation_scopes_without_preload_content( self, bank_id: StrictStr, - memory_id: StrictStr, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -1499,14 +2490,12 @@ async def get_observation_history_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Get observation history + """List observation scopes - Get the full history of an observation, with each change's source facts resolved to their text. + Enumerate the distinct scopes across a bank's observations. Each observation lives under a scope: the exact set of tags it was consolidated with. Returns every distinct scope (tag order normalized) with the number of observations in it; the empty tag list is the global/untagged scope. Use a returned scope with the graph endpoint (tags= & tags_match=exact) to filter observations to exactly that scope. :param bank_id: (required) :type bank_id: str - :param memory_id: (required) - :type memory_id: str :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -1531,9 +2520,8 @@ async def get_observation_history_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._get_observation_history_serialize( + _param = self._list_observation_scopes_serialize( bank_id=bank_id, - memory_id=memory_id, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -1542,7 +2530,7 @@ async def get_observation_history_without_preload_content( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "object", + '200': "ObservationScopesResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -1552,10 +2540,9 @@ async def get_observation_history_without_preload_content( return response_data.response - def _get_observation_history_serialize( + def _list_observation_scopes_serialize( self, bank_id, - memory_id, authorization, _request_auth, _content_type, @@ -1580,8 +2567,6 @@ def _get_observation_history_serialize( # process the path parameters if bank_id is not None: _path_params['bank_id'] = bank_id - if memory_id is not None: - _path_params['memory_id'] = memory_id # process the query parameters # process the header parameters if authorization is not None: @@ -1605,7 +2590,7 @@ def _get_observation_history_serialize( return self.api_client.param_serialize( method='GET', - resource_path='/v1/default/banks/{bank_id}/memories/{memory_id}/history', + resource_path='/v1/default/banks/{bank_id}/observations/scopes', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -1622,14 +2607,13 @@ def _get_observation_history_serialize( @validate_call - async def list_memories( + async def list_tags( self, bank_id: StrictStr, - type: Optional[StrictStr] = None, - q: Optional[StrictStr] = None, - consolidation_state: Optional[StrictStr] = None, - limit: Optional[StrictInt] = None, - offset: Optional[StrictInt] = None, + q: Annotated[Optional[StrictStr], Field(description="Wildcard pattern to filter tags (e.g., 'user:*' for user:alice, '*-admin' for role-admin). Use '*' as wildcard. Case-insensitive.")] = None, + source: Annotated[Optional[StrictStr], Field(description="Where to read tags from: 'memories' (memory_units, default) or 'mental_models'.")] = None, + limit: Annotated[Optional[StrictInt], Field(description="Maximum number of tags to return")] = None, + offset: Annotated[Optional[StrictInt], Field(description="Offset for pagination")] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -1643,22 +2627,20 @@ async def list_memories( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ListMemoryUnitsResponse: - """List memory units + ) -> ListTagsResponse: + """List tags - List memory units with pagination and optional full-text search. Supports filtering by type. Results are sorted by most recent first (mentioned_at DESC, then created_at DESC). + List all unique tags in a memory bank with usage counts. Supports wildcard search using '*' (e.g., 'user:*', '*-fred', 'tag*-2'). Case-insensitive. Use `source=mental_models` to list tags used on mental models instead of memories. :param bank_id: (required) :type bank_id: str - :param type: - :type type: str - :param q: + :param q: Wildcard pattern to filter tags (e.g., 'user:*' for user:alice, '*-admin' for role-admin). Use '*' as wildcard. Case-insensitive. :type q: str - :param consolidation_state: - :type consolidation_state: str - :param limit: + :param source: Where to read tags from: 'memories' (memory_units, default) or 'mental_models'. + :type source: str + :param limit: Maximum number of tags to return :type limit: int - :param offset: + :param offset: Offset for pagination :type offset: int :param authorization: :type authorization: str @@ -1684,11 +2666,10 @@ async def list_memories( :return: Returns the result object. """ # noqa: E501 - _param = self._list_memories_serialize( + _param = self._list_tags_serialize( bank_id=bank_id, - type=type, q=q, - consolidation_state=consolidation_state, + source=source, limit=limit, offset=offset, authorization=authorization, @@ -1699,7 +2680,7 @@ async def list_memories( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "ListMemoryUnitsResponse", + '200': "ListTagsResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -1714,14 +2695,13 @@ async def list_memories( @validate_call - async def list_memories_with_http_info( + async def list_tags_with_http_info( self, bank_id: StrictStr, - type: Optional[StrictStr] = None, - q: Optional[StrictStr] = None, - consolidation_state: Optional[StrictStr] = None, - limit: Optional[StrictInt] = None, - offset: Optional[StrictInt] = None, + q: Annotated[Optional[StrictStr], Field(description="Wildcard pattern to filter tags (e.g., 'user:*' for user:alice, '*-admin' for role-admin). Use '*' as wildcard. Case-insensitive.")] = None, + source: Annotated[Optional[StrictStr], Field(description="Where to read tags from: 'memories' (memory_units, default) or 'mental_models'.")] = None, + limit: Annotated[Optional[StrictInt], Field(description="Maximum number of tags to return")] = None, + offset: Annotated[Optional[StrictInt], Field(description="Offset for pagination")] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -1735,22 +2715,20 @@ async def list_memories_with_http_info( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[ListMemoryUnitsResponse]: - """List memory units + ) -> ApiResponse[ListTagsResponse]: + """List tags - List memory units with pagination and optional full-text search. Supports filtering by type. Results are sorted by most recent first (mentioned_at DESC, then created_at DESC). + List all unique tags in a memory bank with usage counts. Supports wildcard search using '*' (e.g., 'user:*', '*-fred', 'tag*-2'). Case-insensitive. Use `source=mental_models` to list tags used on mental models instead of memories. :param bank_id: (required) :type bank_id: str - :param type: - :type type: str - :param q: + :param q: Wildcard pattern to filter tags (e.g., 'user:*' for user:alice, '*-admin' for role-admin). Use '*' as wildcard. Case-insensitive. :type q: str - :param consolidation_state: - :type consolidation_state: str - :param limit: + :param source: Where to read tags from: 'memories' (memory_units, default) or 'mental_models'. + :type source: str + :param limit: Maximum number of tags to return :type limit: int - :param offset: + :param offset: Offset for pagination :type offset: int :param authorization: :type authorization: str @@ -1776,11 +2754,10 @@ async def list_memories_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._list_memories_serialize( + _param = self._list_tags_serialize( bank_id=bank_id, - type=type, q=q, - consolidation_state=consolidation_state, + source=source, limit=limit, offset=offset, authorization=authorization, @@ -1791,7 +2768,7 @@ async def list_memories_with_http_info( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "ListMemoryUnitsResponse", + '200': "ListTagsResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -1806,14 +2783,13 @@ async def list_memories_with_http_info( @validate_call - async def list_memories_without_preload_content( + async def list_tags_without_preload_content( self, bank_id: StrictStr, - type: Optional[StrictStr] = None, - q: Optional[StrictStr] = None, - consolidation_state: Optional[StrictStr] = None, - limit: Optional[StrictInt] = None, - offset: Optional[StrictInt] = None, + q: Annotated[Optional[StrictStr], Field(description="Wildcard pattern to filter tags (e.g., 'user:*' for user:alice, '*-admin' for role-admin). Use '*' as wildcard. Case-insensitive.")] = None, + source: Annotated[Optional[StrictStr], Field(description="Where to read tags from: 'memories' (memory_units, default) or 'mental_models'.")] = None, + limit: Annotated[Optional[StrictInt], Field(description="Maximum number of tags to return")] = None, + offset: Annotated[Optional[StrictInt], Field(description="Offset for pagination")] = None, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -1828,21 +2804,19 @@ async def list_memories_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """List memory units + """List tags - List memory units with pagination and optional full-text search. Supports filtering by type. Results are sorted by most recent first (mentioned_at DESC, then created_at DESC). + List all unique tags in a memory bank with usage counts. Supports wildcard search using '*' (e.g., 'user:*', '*-fred', 'tag*-2'). Case-insensitive. Use `source=mental_models` to list tags used on mental models instead of memories. :param bank_id: (required) :type bank_id: str - :param type: - :type type: str - :param q: + :param q: Wildcard pattern to filter tags (e.g., 'user:*' for user:alice, '*-admin' for role-admin). Use '*' as wildcard. Case-insensitive. :type q: str - :param consolidation_state: - :type consolidation_state: str - :param limit: + :param source: Where to read tags from: 'memories' (memory_units, default) or 'mental_models'. + :type source: str + :param limit: Maximum number of tags to return :type limit: int - :param offset: + :param offset: Offset for pagination :type offset: int :param authorization: :type authorization: str @@ -1868,11 +2842,10 @@ async def list_memories_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._list_memories_serialize( + _param = self._list_tags_serialize( bank_id=bank_id, - type=type, q=q, - consolidation_state=consolidation_state, + source=source, limit=limit, offset=offset, authorization=authorization, @@ -1883,7 +2856,7 @@ async def list_memories_without_preload_content( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "ListMemoryUnitsResponse", + '200': "ListTagsResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -1893,12 +2866,11 @@ async def list_memories_without_preload_content( return response_data.response - def _list_memories_serialize( + def _list_tags_serialize( self, bank_id, - type, q, - consolidation_state, + source, limit, offset, authorization, @@ -1926,17 +2898,13 @@ def _list_memories_serialize( if bank_id is not None: _path_params['bank_id'] = bank_id # process the query parameters - if type is not None: - - _query_params.append(('type', type)) - if q is not None: _query_params.append(('q', q)) - if consolidation_state is not None: + if source is not None: - _query_params.append(('consolidation_state', consolidation_state)) + _query_params.append(('source', source)) if limit is not None: @@ -1968,7 +2936,7 @@ def _list_memories_serialize( return self.api_client.param_serialize( method='GET', - resource_path='/v1/default/banks/{bank_id}/memories/list', + resource_path='/v1/default/banks/{bank_id}/tags', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -1985,13 +2953,10 @@ def _list_memories_serialize( @validate_call - async def list_tags( + async def recall_memories( self, bank_id: StrictStr, - q: Annotated[Optional[StrictStr], Field(description="Wildcard pattern to filter tags (e.g., 'user:*' for user:alice, '*-admin' for role-admin). Use '*' as wildcard. Case-insensitive.")] = None, - source: Annotated[Optional[StrictStr], Field(description="Where to read tags from: 'memories' (memory_units, default) or 'mental_models'.")] = None, - limit: Annotated[Optional[StrictInt], Field(description="Maximum number of tags to return")] = None, - offset: Annotated[Optional[StrictInt], Field(description="Offset for pagination")] = None, + recall_request: RecallRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -2005,21 +2970,15 @@ async def list_tags( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ListTagsResponse: - """List tags + ) -> RecallResponse: + """Recall memory - List all unique tags in a memory bank with usage counts. Supports wildcard search using '*' (e.g., 'user:*', '*-fred', 'tag*-2'). Case-insensitive. Use `source=mental_models` to list tags used on mental models instead of memories. + Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - `world`: General knowledge about people, places, events, and things that happen - `experience`: Memories about experience, conversations, actions taken, and tasks performed :param bank_id: (required) :type bank_id: str - :param q: Wildcard pattern to filter tags (e.g., 'user:*' for user:alice, '*-admin' for role-admin). Use '*' as wildcard. Case-insensitive. - :type q: str - :param source: Where to read tags from: 'memories' (memory_units, default) or 'mental_models'. - :type source: str - :param limit: Maximum number of tags to return - :type limit: int - :param offset: Offset for pagination - :type offset: int + :param recall_request: (required) + :type recall_request: RecallRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -2044,12 +3003,9 @@ async def list_tags( :return: Returns the result object. """ # noqa: E501 - _param = self._list_tags_serialize( + _param = self._recall_memories_serialize( bank_id=bank_id, - q=q, - source=source, - limit=limit, - offset=offset, + recall_request=recall_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -2058,7 +3014,7 @@ async def list_tags( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "ListTagsResponse", + '200': "RecallResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -2073,13 +3029,10 @@ async def list_tags( @validate_call - async def list_tags_with_http_info( + async def recall_memories_with_http_info( self, bank_id: StrictStr, - q: Annotated[Optional[StrictStr], Field(description="Wildcard pattern to filter tags (e.g., 'user:*' for user:alice, '*-admin' for role-admin). Use '*' as wildcard. Case-insensitive.")] = None, - source: Annotated[Optional[StrictStr], Field(description="Where to read tags from: 'memories' (memory_units, default) or 'mental_models'.")] = None, - limit: Annotated[Optional[StrictInt], Field(description="Maximum number of tags to return")] = None, - offset: Annotated[Optional[StrictInt], Field(description="Offset for pagination")] = None, + recall_request: RecallRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -2093,21 +3046,15 @@ async def list_tags_with_http_info( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[ListTagsResponse]: - """List tags + ) -> ApiResponse[RecallResponse]: + """Recall memory - List all unique tags in a memory bank with usage counts. Supports wildcard search using '*' (e.g., 'user:*', '*-fred', 'tag*-2'). Case-insensitive. Use `source=mental_models` to list tags used on mental models instead of memories. + Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - `world`: General knowledge about people, places, events, and things that happen - `experience`: Memories about experience, conversations, actions taken, and tasks performed :param bank_id: (required) :type bank_id: str - :param q: Wildcard pattern to filter tags (e.g., 'user:*' for user:alice, '*-admin' for role-admin). Use '*' as wildcard. Case-insensitive. - :type q: str - :param source: Where to read tags from: 'memories' (memory_units, default) or 'mental_models'. - :type source: str - :param limit: Maximum number of tags to return - :type limit: int - :param offset: Offset for pagination - :type offset: int + :param recall_request: (required) + :type recall_request: RecallRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -2132,12 +3079,9 @@ async def list_tags_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._list_tags_serialize( + _param = self._recall_memories_serialize( bank_id=bank_id, - q=q, - source=source, - limit=limit, - offset=offset, + recall_request=recall_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -2146,7 +3090,7 @@ async def list_tags_with_http_info( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "ListTagsResponse", + '200': "RecallResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -2161,13 +3105,10 @@ async def list_tags_with_http_info( @validate_call - async def list_tags_without_preload_content( + async def recall_memories_without_preload_content( self, bank_id: StrictStr, - q: Annotated[Optional[StrictStr], Field(description="Wildcard pattern to filter tags (e.g., 'user:*' for user:alice, '*-admin' for role-admin). Use '*' as wildcard. Case-insensitive.")] = None, - source: Annotated[Optional[StrictStr], Field(description="Where to read tags from: 'memories' (memory_units, default) or 'mental_models'.")] = None, - limit: Annotated[Optional[StrictInt], Field(description="Maximum number of tags to return")] = None, - offset: Annotated[Optional[StrictInt], Field(description="Offset for pagination")] = None, + recall_request: RecallRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -2182,20 +3123,14 @@ async def list_tags_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """List tags + """Recall memory - List all unique tags in a memory bank with usage counts. Supports wildcard search using '*' (e.g., 'user:*', '*-fred', 'tag*-2'). Case-insensitive. Use `source=mental_models` to list tags used on mental models instead of memories. + Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - `world`: General knowledge about people, places, events, and things that happen - `experience`: Memories about experience, conversations, actions taken, and tasks performed :param bank_id: (required) :type bank_id: str - :param q: Wildcard pattern to filter tags (e.g., 'user:*' for user:alice, '*-admin' for role-admin). Use '*' as wildcard. Case-insensitive. - :type q: str - :param source: Where to read tags from: 'memories' (memory_units, default) or 'mental_models'. - :type source: str - :param limit: Maximum number of tags to return - :type limit: int - :param offset: Offset for pagination - :type offset: int + :param recall_request: (required) + :type recall_request: RecallRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -2220,12 +3155,9 @@ async def list_tags_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._list_tags_serialize( + _param = self._recall_memories_serialize( bank_id=bank_id, - q=q, - source=source, - limit=limit, - offset=offset, + recall_request=recall_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -2234,7 +3166,7 @@ async def list_tags_without_preload_content( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "ListTagsResponse", + '200': "RecallResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -2244,13 +3176,10 @@ async def list_tags_without_preload_content( return response_data.response - def _list_tags_serialize( + def _recall_memories_serialize( self, bank_id, - q, - source, - limit, - offset, + recall_request, authorization, _request_auth, _content_type, @@ -2276,27 +3205,13 @@ def _list_tags_serialize( if bank_id is not None: _path_params['bank_id'] = bank_id # process the query parameters - if q is not None: - - _query_params.append(('q', q)) - - if source is not None: - - _query_params.append(('source', source)) - - if limit is not None: - - _query_params.append(('limit', limit)) - - if offset is not None: - - _query_params.append(('offset', offset)) - # process the header parameters if authorization is not None: _header_params['authorization'] = authorization # process the form parameters # process the body parameter + if recall_request is not None: + _body_params = recall_request # set the HTTP header `Accept` @@ -2307,14 +3222,27 @@ def _list_tags_serialize( ] ) + # set the HTTP header `Content-Type` + if _content_type: + _header_params['Content-Type'] = _content_type + else: + _default_content_type = ( + self.api_client.select_header_content_type( + [ + 'application/json' + ] + ) + ) + if _default_content_type is not None: + _header_params['Content-Type'] = _default_content_type # authentication setting _auth_settings: List[str] = [ ] return self.api_client.param_serialize( - method='GET', - resource_path='/v1/default/banks/{bank_id}/tags', + method='POST', + resource_path='/v1/default/banks/{bank_id}/memories/recall', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -2331,10 +3259,10 @@ def _list_tags_serialize( @validate_call - async def recall_memories( + async def reflect( self, bank_id: StrictStr, - recall_request: RecallRequest, + reflect_request: ReflectRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -2348,15 +3276,15 @@ async def recall_memories( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> RecallResponse: - """Recall memory + ) -> ReflectResponse: + """Reflect and generate answer - Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - `world`: General knowledge about people, places, events, and things that happen - `experience`: Memories about experience, conversations, actions taken, and tasks performed + Reflect and formulate an answer using bank identity, world facts, observations, and mental models. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query 3. Retrieves observations and mental models (bank's synthesized perspectives) 4. Uses LLM to formulate a contextual answer 5. Returns plain text answer and the facts used :param bank_id: (required) :type bank_id: str - :param recall_request: (required) - :type recall_request: RecallRequest + :param reflect_request: (required) + :type reflect_request: ReflectRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -2381,9 +3309,9 @@ async def recall_memories( :return: Returns the result object. """ # noqa: E501 - _param = self._recall_memories_serialize( + _param = self._reflect_serialize( bank_id=bank_id, - recall_request=recall_request, + reflect_request=reflect_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -2392,7 +3320,7 @@ async def recall_memories( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "RecallResponse", + '200': "ReflectResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -2407,10 +3335,10 @@ async def recall_memories( @validate_call - async def recall_memories_with_http_info( + async def reflect_with_http_info( self, bank_id: StrictStr, - recall_request: RecallRequest, + reflect_request: ReflectRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -2424,15 +3352,15 @@ async def recall_memories_with_http_info( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[RecallResponse]: - """Recall memory + ) -> ApiResponse[ReflectResponse]: + """Reflect and generate answer - Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - `world`: General knowledge about people, places, events, and things that happen - `experience`: Memories about experience, conversations, actions taken, and tasks performed + Reflect and formulate an answer using bank identity, world facts, observations, and mental models. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query 3. Retrieves observations and mental models (bank's synthesized perspectives) 4. Uses LLM to formulate a contextual answer 5. Returns plain text answer and the facts used :param bank_id: (required) :type bank_id: str - :param recall_request: (required) - :type recall_request: RecallRequest + :param reflect_request: (required) + :type reflect_request: ReflectRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -2457,9 +3385,9 @@ async def recall_memories_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._recall_memories_serialize( + _param = self._reflect_serialize( bank_id=bank_id, - recall_request=recall_request, + reflect_request=reflect_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -2468,7 +3396,7 @@ async def recall_memories_with_http_info( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "RecallResponse", + '200': "ReflectResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -2483,10 +3411,10 @@ async def recall_memories_with_http_info( @validate_call - async def recall_memories_without_preload_content( + async def reflect_without_preload_content( self, bank_id: StrictStr, - recall_request: RecallRequest, + reflect_request: ReflectRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -2501,14 +3429,14 @@ async def recall_memories_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Recall memory + """Reflect and generate answer - Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - `world`: General knowledge about people, places, events, and things that happen - `experience`: Memories about experience, conversations, actions taken, and tasks performed + Reflect and formulate an answer using bank identity, world facts, observations, and mental models. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query 3. Retrieves observations and mental models (bank's synthesized perspectives) 4. Uses LLM to formulate a contextual answer 5. Returns plain text answer and the facts used :param bank_id: (required) :type bank_id: str - :param recall_request: (required) - :type recall_request: RecallRequest + :param reflect_request: (required) + :type reflect_request: ReflectRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -2533,9 +3461,9 @@ async def recall_memories_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._recall_memories_serialize( + _param = self._reflect_serialize( bank_id=bank_id, - recall_request=recall_request, + reflect_request=reflect_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -2544,7 +3472,7 @@ async def recall_memories_without_preload_content( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "RecallResponse", + '200': "ReflectResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -2554,10 +3482,10 @@ async def recall_memories_without_preload_content( return response_data.response - def _recall_memories_serialize( + def _reflect_serialize( self, bank_id, - recall_request, + reflect_request, authorization, _request_auth, _content_type, @@ -2588,8 +3516,8 @@ def _recall_memories_serialize( _header_params['authorization'] = authorization # process the form parameters # process the body parameter - if recall_request is not None: - _body_params = recall_request + if reflect_request is not None: + _body_params = reflect_request # set the HTTP header `Accept` @@ -2620,7 +3548,7 @@ def _recall_memories_serialize( return self.api_client.param_serialize( method='POST', - resource_path='/v1/default/banks/{bank_id}/memories/recall', + resource_path='/v1/default/banks/{bank_id}/reflect', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -2637,10 +3565,10 @@ def _recall_memories_serialize( @validate_call - async def reflect( + async def retain_memories( self, bank_id: StrictStr, - reflect_request: ReflectRequest, + retain_request: RetainRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -2654,15 +3582,15 @@ async def reflect( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ReflectResponse: - """Reflect and generate answer + ) -> RetainResponse: + """Retain memories - Reflect and formulate an answer using bank identity, world facts, and opinions. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query 3. Retrieves existing opinions (bank's perspectives) 4. Uses LLM to formulate a contextual answer 5. Returns plain text answer and the facts used + Retain memory items with automatic fact extraction. This is the main endpoint for storing memories. It supports both synchronous and asynchronous processing via the `async` parameter. **Features:** - Efficient batch processing - Automatic fact extraction from natural language - Entity recognition and linking - Document tracking with automatic upsert (when document_id is provided) - Temporal and semantic linking - Optional asynchronous processing **The system automatically:** 1. Extracts semantic facts from the content 2. Generates embeddings 3. Deduplicates similar facts 4. Creates temporal, semantic, and entity links 5. Tracks document metadata **When `async=true`:** Returns immediately after queuing. Use the operations endpoint to monitor progress. **When `async=false` (default):** Waits for processing to complete. **Note:** If a memory item has a `document_id` that already exists, the old document and its memory units will be deleted before creating new ones (upsert behavior). :param bank_id: (required) :type bank_id: str - :param reflect_request: (required) - :type reflect_request: ReflectRequest + :param retain_request: (required) + :type retain_request: RetainRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -2687,9 +3615,9 @@ async def reflect( :return: Returns the result object. """ # noqa: E501 - _param = self._reflect_serialize( + _param = self._retain_memories_serialize( bank_id=bank_id, - reflect_request=reflect_request, + retain_request=retain_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -2698,7 +3626,7 @@ async def reflect( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "ReflectResponse", + '200': "RetainResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -2713,10 +3641,10 @@ async def reflect( @validate_call - async def reflect_with_http_info( + async def retain_memories_with_http_info( self, bank_id: StrictStr, - reflect_request: ReflectRequest, + retain_request: RetainRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -2730,15 +3658,15 @@ async def reflect_with_http_info( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[ReflectResponse]: - """Reflect and generate answer + ) -> ApiResponse[RetainResponse]: + """Retain memories - Reflect and formulate an answer using bank identity, world facts, and opinions. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query 3. Retrieves existing opinions (bank's perspectives) 4. Uses LLM to formulate a contextual answer 5. Returns plain text answer and the facts used + Retain memory items with automatic fact extraction. This is the main endpoint for storing memories. It supports both synchronous and asynchronous processing via the `async` parameter. **Features:** - Efficient batch processing - Automatic fact extraction from natural language - Entity recognition and linking - Document tracking with automatic upsert (when document_id is provided) - Temporal and semantic linking - Optional asynchronous processing **The system automatically:** 1. Extracts semantic facts from the content 2. Generates embeddings 3. Deduplicates similar facts 4. Creates temporal, semantic, and entity links 5. Tracks document metadata **When `async=true`:** Returns immediately after queuing. Use the operations endpoint to monitor progress. **When `async=false` (default):** Waits for processing to complete. **Note:** If a memory item has a `document_id` that already exists, the old document and its memory units will be deleted before creating new ones (upsert behavior). :param bank_id: (required) :type bank_id: str - :param reflect_request: (required) - :type reflect_request: ReflectRequest + :param retain_request: (required) + :type retain_request: RetainRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -2763,9 +3691,9 @@ async def reflect_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._reflect_serialize( + _param = self._retain_memories_serialize( bank_id=bank_id, - reflect_request=reflect_request, + retain_request=retain_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -2774,7 +3702,7 @@ async def reflect_with_http_info( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "ReflectResponse", + '200': "RetainResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -2789,10 +3717,10 @@ async def reflect_with_http_info( @validate_call - async def reflect_without_preload_content( + async def retain_memories_without_preload_content( self, bank_id: StrictStr, - reflect_request: ReflectRequest, + retain_request: RetainRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -2807,14 +3735,14 @@ async def reflect_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Reflect and generate answer + """Retain memories - Reflect and formulate an answer using bank identity, world facts, and opinions. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query 3. Retrieves existing opinions (bank's perspectives) 4. Uses LLM to formulate a contextual answer 5. Returns plain text answer and the facts used + Retain memory items with automatic fact extraction. This is the main endpoint for storing memories. It supports both synchronous and asynchronous processing via the `async` parameter. **Features:** - Efficient batch processing - Automatic fact extraction from natural language - Entity recognition and linking - Document tracking with automatic upsert (when document_id is provided) - Temporal and semantic linking - Optional asynchronous processing **The system automatically:** 1. Extracts semantic facts from the content 2. Generates embeddings 3. Deduplicates similar facts 4. Creates temporal, semantic, and entity links 5. Tracks document metadata **When `async=true`:** Returns immediately after queuing. Use the operations endpoint to monitor progress. **When `async=false` (default):** Waits for processing to complete. **Note:** If a memory item has a `document_id` that already exists, the old document and its memory units will be deleted before creating new ones (upsert behavior). :param bank_id: (required) :type bank_id: str - :param reflect_request: (required) - :type reflect_request: ReflectRequest + :param retain_request: (required) + :type retain_request: RetainRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -2839,9 +3767,9 @@ async def reflect_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._reflect_serialize( + _param = self._retain_memories_serialize( bank_id=bank_id, - reflect_request=reflect_request, + retain_request=retain_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -2850,7 +3778,7 @@ async def reflect_without_preload_content( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "ReflectResponse", + '200': "RetainResponse", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -2860,10 +3788,10 @@ async def reflect_without_preload_content( return response_data.response - def _reflect_serialize( + def _retain_memories_serialize( self, bank_id, - reflect_request, + retain_request, authorization, _request_auth, _content_type, @@ -2894,8 +3822,8 @@ def _reflect_serialize( _header_params['authorization'] = authorization # process the form parameters # process the body parameter - if reflect_request is not None: - _body_params = reflect_request + if retain_request is not None: + _body_params = retain_request # set the HTTP header `Accept` @@ -2926,7 +3854,7 @@ def _reflect_serialize( return self.api_client.param_serialize( method='POST', - resource_path='/v1/default/banks/{bank_id}/reflect', + resource_path='/v1/default/banks/{bank_id}/memories', path_params=_path_params, query_params=_query_params, header_params=_header_params, @@ -2943,10 +3871,11 @@ def _reflect_serialize( @validate_call - async def retain_memories( + async def update_memory( self, bank_id: StrictStr, - retain_request: RetainRequest, + memory_id: StrictStr, + update_memory_request: UpdateMemoryRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -2960,15 +3889,17 @@ async def retain_memories( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> RetainResponse: - """Retain memories + ) -> object: + """Curate memory unit - Retain memory items with automatic fact extraction. This is the main endpoint for storing memories. It supports both synchronous and asynchronous processing via the `async` parameter. **Features:** - Efficient batch processing - Automatic fact extraction from natural language - Entity recognition and linking - Document tracking with automatic upsert (when document_id is provided) - Temporal and semantic linking - Optional asynchronous processing **The system automatically:** 1. Extracts semantic facts from the content 2. Generates embeddings 3. Deduplicates similar facts 4. Creates temporal, semantic, and entity links 5. Tracks document metadata **When `async=true`:** Returns immediately after queuing. Use the operations endpoint to monitor progress. **When `async=false` (default):** Waits for processing to complete. **Note:** If a memory item has a `document_id` that already exists, the old document and its memory units will be deleted before creating new ones (upsert behavior). + Edit a memory's text and/or change its curation state (invalidate / revert). Invalidated memories are excluded from recall, consolidation, and graph maintenance but kept for audit (reversible). Only world/experience facts can be curated; observations are derived. :param bank_id: (required) :type bank_id: str - :param retain_request: (required) - :type retain_request: RetainRequest + :param memory_id: (required) + :type memory_id: str + :param update_memory_request: (required) + :type update_memory_request: UpdateMemoryRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -2993,9 +3924,10 @@ async def retain_memories( :return: Returns the result object. """ # noqa: E501 - _param = self._retain_memories_serialize( + _param = self._update_memory_serialize( bank_id=bank_id, - retain_request=retain_request, + memory_id=memory_id, + update_memory_request=update_memory_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -3004,7 +3936,7 @@ async def retain_memories( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "RetainResponse", + '200': "object", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -3019,10 +3951,11 @@ async def retain_memories( @validate_call - async def retain_memories_with_http_info( + async def update_memory_with_http_info( self, bank_id: StrictStr, - retain_request: RetainRequest, + memory_id: StrictStr, + update_memory_request: UpdateMemoryRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -3036,15 +3969,17 @@ async def retain_memories_with_http_info( _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, - ) -> ApiResponse[RetainResponse]: - """Retain memories + ) -> ApiResponse[object]: + """Curate memory unit - Retain memory items with automatic fact extraction. This is the main endpoint for storing memories. It supports both synchronous and asynchronous processing via the `async` parameter. **Features:** - Efficient batch processing - Automatic fact extraction from natural language - Entity recognition and linking - Document tracking with automatic upsert (when document_id is provided) - Temporal and semantic linking - Optional asynchronous processing **The system automatically:** 1. Extracts semantic facts from the content 2. Generates embeddings 3. Deduplicates similar facts 4. Creates temporal, semantic, and entity links 5. Tracks document metadata **When `async=true`:** Returns immediately after queuing. Use the operations endpoint to monitor progress. **When `async=false` (default):** Waits for processing to complete. **Note:** If a memory item has a `document_id` that already exists, the old document and its memory units will be deleted before creating new ones (upsert behavior). + Edit a memory's text and/or change its curation state (invalidate / revert). Invalidated memories are excluded from recall, consolidation, and graph maintenance but kept for audit (reversible). Only world/experience facts can be curated; observations are derived. :param bank_id: (required) :type bank_id: str - :param retain_request: (required) - :type retain_request: RetainRequest + :param memory_id: (required) + :type memory_id: str + :param update_memory_request: (required) + :type update_memory_request: UpdateMemoryRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -3069,9 +4004,10 @@ async def retain_memories_with_http_info( :return: Returns the result object. """ # noqa: E501 - _param = self._retain_memories_serialize( + _param = self._update_memory_serialize( bank_id=bank_id, - retain_request=retain_request, + memory_id=memory_id, + update_memory_request=update_memory_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -3080,7 +4016,7 @@ async def retain_memories_with_http_info( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "RetainResponse", + '200': "object", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -3095,10 +4031,11 @@ async def retain_memories_with_http_info( @validate_call - async def retain_memories_without_preload_content( + async def update_memory_without_preload_content( self, bank_id: StrictStr, - retain_request: RetainRequest, + memory_id: StrictStr, + update_memory_request: UpdateMemoryRequest, authorization: Optional[StrictStr] = None, _request_timeout: Union[ None, @@ -3113,14 +4050,16 @@ async def retain_memories_without_preload_content( _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> RESTResponseType: - """Retain memories + """Curate memory unit - Retain memory items with automatic fact extraction. This is the main endpoint for storing memories. It supports both synchronous and asynchronous processing via the `async` parameter. **Features:** - Efficient batch processing - Automatic fact extraction from natural language - Entity recognition and linking - Document tracking with automatic upsert (when document_id is provided) - Temporal and semantic linking - Optional asynchronous processing **The system automatically:** 1. Extracts semantic facts from the content 2. Generates embeddings 3. Deduplicates similar facts 4. Creates temporal, semantic, and entity links 5. Tracks document metadata **When `async=true`:** Returns immediately after queuing. Use the operations endpoint to monitor progress. **When `async=false` (default):** Waits for processing to complete. **Note:** If a memory item has a `document_id` that already exists, the old document and its memory units will be deleted before creating new ones (upsert behavior). + Edit a memory's text and/or change its curation state (invalidate / revert). Invalidated memories are excluded from recall, consolidation, and graph maintenance but kept for audit (reversible). Only world/experience facts can be curated; observations are derived. :param bank_id: (required) :type bank_id: str - :param retain_request: (required) - :type retain_request: RetainRequest + :param memory_id: (required) + :type memory_id: str + :param update_memory_request: (required) + :type update_memory_request: UpdateMemoryRequest :param authorization: :type authorization: str :param _request_timeout: timeout setting for this request. If one @@ -3145,9 +4084,10 @@ async def retain_memories_without_preload_content( :return: Returns the result object. """ # noqa: E501 - _param = self._retain_memories_serialize( + _param = self._update_memory_serialize( bank_id=bank_id, - retain_request=retain_request, + memory_id=memory_id, + update_memory_request=update_memory_request, authorization=authorization, _request_auth=_request_auth, _content_type=_content_type, @@ -3156,7 +4096,7 @@ async def retain_memories_without_preload_content( ) _response_types_map: Dict[str, Optional[str]] = { - '200': "RetainResponse", + '200': "object", '422': "HTTPValidationError", } response_data = await self.api_client.call_api( @@ -3166,10 +4106,11 @@ async def retain_memories_without_preload_content( return response_data.response - def _retain_memories_serialize( + def _update_memory_serialize( self, bank_id, - retain_request, + memory_id, + update_memory_request, authorization, _request_auth, _content_type, @@ -3194,14 +4135,16 @@ def _retain_memories_serialize( # process the path parameters if bank_id is not None: _path_params['bank_id'] = bank_id + if memory_id is not None: + _path_params['memory_id'] = memory_id # process the query parameters # process the header parameters if authorization is not None: _header_params['authorization'] = authorization # process the form parameters # process the body parameter - if retain_request is not None: - _body_params = retain_request + if update_memory_request is not None: + _body_params = update_memory_request # set the HTTP header `Accept` @@ -3231,8 +4174,8 @@ def _retain_memories_serialize( ] return self.api_client.param_serialize( - method='POST', - resource_path='/v1/default/banks/{bank_id}/memories', + method='PATCH', + resource_path='/v1/default/banks/{bank_id}/memories/{memory_id}', path_params=_path_params, query_params=_query_params, header_params=_header_params, diff --git a/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py b/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py index b0a56ac87e..9b2c2bb322 100644 --- a/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/mental_models_api.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/api/monitoring_api.py b/hindsight-clients/python/hindsight_client_api/api/monitoring_api.py index a70bb4a175..809138f6c0 100644 --- a/hindsight-clients/python/hindsight_client_api/api/monitoring_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/monitoring_api.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/api/operations_api.py b/hindsight-clients/python/hindsight_client_api/api/operations_api.py index 368576173c..1023bd3f74 100644 --- a/hindsight-clients/python/hindsight_client_api/api/operations_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/operations_api.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/api/webhooks_api.py b/hindsight-clients/python/hindsight_client_api/api/webhooks_api.py index 6a42ebcfed..e931c9c902 100644 --- a/hindsight-clients/python/hindsight_client_api/api/webhooks_api.py +++ b/hindsight-clients/python/hindsight_client_api/api/webhooks_api.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/api_client.py b/hindsight-clients/python/hindsight_client_api/api_client.py index c8b9a6d5e7..029e2f4805 100644 --- a/hindsight-clients/python/hindsight_client_api/api_client.py +++ b/hindsight-clients/python/hindsight_client_api/api_client.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/configuration.py b/hindsight-clients/python/hindsight_client_api/configuration.py index 45e1f07d35..9472674b19 100644 --- a/hindsight-clients/python/hindsight_client_api/configuration.py +++ b/hindsight-clients/python/hindsight_client_api/configuration.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -489,7 +489,7 @@ def to_debug_report(self) -> str: return "Python SDK Debug Report:\n"\ "OS: {env}\n"\ "Python Version: {pyversion}\n"\ - "Version of the API: 0.7.2\n"\ + "Version of the API: 0.8.3\n"\ "SDK Package Version: 0.0.7".\ format(env=sys.platform, pyversion=sys.version) diff --git a/hindsight-clients/python/hindsight_client_api/exceptions.py b/hindsight-clients/python/hindsight_client_api/exceptions.py index 8788ad582e..7c7fd71220 100644 --- a/hindsight-clients/python/hindsight_client_api/exceptions.py +++ b/hindsight-clients/python/hindsight_client_api/exceptions.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/__init__.py b/hindsight-clients/python/hindsight_client_api/models/__init__.py index 835a788b74..93d5fccc89 100644 --- a/hindsight-clients/python/hindsight_client_api/models/__init__.py +++ b/hindsight-clients/python/hindsight_client_api/models/__init__.py @@ -6,7 +6,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -25,6 +25,7 @@ from hindsight_client_api.models.bank_config_update import BankConfigUpdate from hindsight_client_api.models.bank_list_item import BankListItem from hindsight_client_api.models.bank_list_response import BankListResponse +from hindsight_client_api.models.bank_llm_health_response import BankLlmHealthResponse from hindsight_client_api.models.bank_profile_response import BankProfileResponse from hindsight_client_api.models.bank_stats_response import BankStatsResponse from hindsight_client_api.models.bank_template_config import BankTemplateConfig @@ -53,6 +54,8 @@ from hindsight_client_api.models.disposition_traits import DispositionTraits from hindsight_client_api.models.document_import_submit_response import DocumentImportSubmitResponse from hindsight_client_api.models.document_response import DocumentResponse +from hindsight_client_api.models.dry_run_extract_request import DryRunExtractRequest +from hindsight_client_api.models.dry_run_extraction_result import DryRunExtractionResult from hindsight_client_api.models.entity_detail_response import EntityDetailResponse from hindsight_client_api.models.entity_graph_response import EntityGraphResponse from hindsight_client_api.models.entity_include_options import EntityIncludeOptions @@ -61,6 +64,7 @@ from hindsight_client_api.models.entity_list_response import EntityListResponse from hindsight_client_api.models.entity_observation_response import EntityObservationResponse from hindsight_client_api.models.entity_state_response import EntityStateResponse +from hindsight_client_api.models.extracted_fact import ExtractedFact from hindsight_client_api.models.features_info import FeaturesInfo from hindsight_client_api.models.file_retain_response import FileRetainResponse from hindsight_client_api.models.graph_data_response import GraphDataResponse @@ -75,6 +79,7 @@ from hindsight_client_api.models.list_documents_response import ListDocumentsResponse from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse from hindsight_client_api.models.list_tags_response import ListTagsResponse +from hindsight_client_api.models.llm_operation_health import LlmOperationHealth from hindsight_client_api.models.memories_timeseries_response import MemoriesTimeseriesResponse from hindsight_client_api.models.memory_item import MemoryItem from hindsight_client_api.models.memory_timeseries_bucket import MemoryTimeseriesBucket @@ -86,7 +91,9 @@ from hindsight_client_api.models.mental_model_trigger_output_tag_groups_inner import MentalModelTriggerOutputTagGroupsInner from hindsight_client_api.models.model_not import ModelNot from hindsight_client_api.models.not1 import Not1 +from hindsight_client_api.models.observation_scope import ObservationScope from hindsight_client_api.models.observation_scopes import ObservationScopes +from hindsight_client_api.models.observation_scopes_response import ObservationScopesResponse from hindsight_client_api.models.operation_progress import OperationProgress from hindsight_client_api.models.operation_response import OperationResponse from hindsight_client_api.models.operation_status_response import OperationStatusResponse @@ -125,6 +132,7 @@ from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest from hindsight_client_api.models.update_document_request import UpdateDocumentRequest from hindsight_client_api.models.update_document_response import UpdateDocumentResponse +from hindsight_client_api.models.update_memory_request import UpdateMemoryRequest from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest from hindsight_client_api.models.update_webhook_request import UpdateWebhookRequest from hindsight_client_api.models.validation_error import ValidationError diff --git a/hindsight-clients/python/hindsight_client_api/models/add_background_request.py b/hindsight-clients/python/hindsight_client_api/models/add_background_request.py index c5cc1f151f..110979cd95 100644 --- a/hindsight-clients/python/hindsight_client_api/models/add_background_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/add_background_request.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/async_operation_submit_response.py b/hindsight-clients/python/hindsight_client_api/models/async_operation_submit_response.py index a0e6720211..d7e4e4d842 100644 --- a/hindsight-clients/python/hindsight_client_api/models/async_operation_submit_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/async_operation_submit_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/audit_log_entry.py b/hindsight-clients/python/hindsight_client_api/models/audit_log_entry.py index e3859f8a52..1e46455468 100644 --- a/hindsight-clients/python/hindsight_client_api/models/audit_log_entry.py +++ b/hindsight-clients/python/hindsight_client_api/models/audit_log_entry.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/audit_log_list_response.py b/hindsight-clients/python/hindsight_client_api/models/audit_log_list_response.py index 1dcf422f6b..c8ab03d80b 100644 --- a/hindsight-clients/python/hindsight_client_api/models/audit_log_list_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/audit_log_list_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/audit_log_stats_bucket.py b/hindsight-clients/python/hindsight_client_api/models/audit_log_stats_bucket.py index 02d1ceeabb..992da30754 100644 --- a/hindsight-clients/python/hindsight_client_api/models/audit_log_stats_bucket.py +++ b/hindsight-clients/python/hindsight_client_api/models/audit_log_stats_bucket.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/audit_log_stats_response.py b/hindsight-clients/python/hindsight_client_api/models/audit_log_stats_response.py index 2f794c07e2..b5a41716d1 100644 --- a/hindsight-clients/python/hindsight_client_api/models/audit_log_stats_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/audit_log_stats_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/background_response.py b/hindsight-clients/python/hindsight_client_api/models/background_response.py index f75eef8fab..a15f2526a5 100644 --- a/hindsight-clients/python/hindsight_client_api/models/background_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/background_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_config_response.py b/hindsight-clients/python/hindsight_client_api/models/bank_config_response.py index 5a2e81c8d0..6fb02fe409 100644 --- a/hindsight-clients/python/hindsight_client_api/models/bank_config_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/bank_config_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_config_update.py b/hindsight-clients/python/hindsight_client_api/models/bank_config_update.py index 77cce2c9ba..3f6a431cd3 100644 --- a/hindsight-clients/python/hindsight_client_api/models/bank_config_update.py +++ b/hindsight-clients/python/hindsight_client_api/models/bank_config_update.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_list_item.py b/hindsight-clients/python/hindsight_client_api/models/bank_list_item.py index b4c14e4c66..64d6db0b8b 100644 --- a/hindsight-clients/python/hindsight_client_api/models/bank_list_item.py +++ b/hindsight-clients/python/hindsight_client_api/models/bank_list_item.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_list_response.py b/hindsight-clients/python/hindsight_client_api/models/bank_list_response.py index 41f6f5a21a..aba65405e5 100644 --- a/hindsight-clients/python/hindsight_client_api/models/bank_list_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/bank_list_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_llm_health_response.py b/hindsight-clients/python/hindsight_client_api/models/bank_llm_health_response.py new file mode 100644 index 0000000000..02589af099 --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/bank_llm_health_response.py @@ -0,0 +1,97 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.8.3 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing import Any, ClassVar, Dict, List +from hindsight_client_api.models.llm_operation_health import LlmOperationHealth +from typing import Optional, Set +from typing_extensions import Self + +class BankLlmHealthResponse(BaseModel): + """ + Per-bank LLM connectivity probe across retain/consolidation/reflect. Operations that share a configuration are probed once. Discloses status only — never the provider, model, endpoint, API key, or raw error. + """ # noqa: E501 + bank_id: StrictStr = Field(description="Bank identifier") + operations: List[LlmOperationHealth] = Field(description="Connectivity status per operation (retain, consolidation, reflect)") + __properties: ClassVar[List[str]] = ["bank_id", "operations"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of BankLlmHealthResponse from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of each item in operations (list) + _items = [] + if self.operations: + for _item_operations in self.operations: + if _item_operations: + _items.append(_item_operations.to_dict()) + _dict['operations'] = _items + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of BankLlmHealthResponse from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "bank_id": obj.get("bank_id"), + "operations": [LlmOperationHealth.from_dict(_item) for _item in obj["operations"]] if obj.get("operations") is not None else None + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_profile_response.py b/hindsight-clients/python/hindsight_client_api/models/bank_profile_response.py index f23cefdaa9..90f5bfb13f 100644 --- a/hindsight-clients/python/hindsight_client_api/models/bank_profile_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/bank_profile_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_stats_response.py b/hindsight-clients/python/hindsight_client_api/models/bank_stats_response.py index bb7fb07220..cb85a0b14c 100644 --- a/hindsight-clients/python/hindsight_client_api/models/bank_stats_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/bank_stats_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_template_config.py b/hindsight-clients/python/hindsight_client_api/models/bank_template_config.py index b7c10355eb..5304b416c9 100644 --- a/hindsight-clients/python/hindsight_client_api/models/bank_template_config.py +++ b/hindsight-clients/python/hindsight_client_api/models/bank_template_config.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -32,6 +32,7 @@ class BankTemplateConfig(BaseModel): retain_extraction_mode: Optional[StrictStr] = None retain_custom_instructions: Optional[StrictStr] = None retain_chunk_size: Optional[StrictInt] = None + retain_structured_chunk_size: Optional[StrictInt] = None enable_observations: Optional[StrictBool] = None observations_mission: Optional[StrictStr] = None disposition_skepticism: Optional[Annotated[int, Field(le=5, strict=True, ge=1)]] = None @@ -47,6 +48,7 @@ class BankTemplateConfig(BaseModel): consolidation_source_facts_max_tokens: Optional[StrictInt] = None consolidation_source_facts_max_tokens_per_observation: Optional[StrictInt] = None max_observations_per_scope: Optional[StrictInt] = None + observation_scope_limits: Optional[List[Dict[str, Any]]] = None reflect_source_facts_max_tokens: Optional[StrictInt] = None llm_gemini_safety_settings: Optional[List[Any]] = None recall_budget_function: Optional[StrictStr] = None @@ -58,7 +60,7 @@ class BankTemplateConfig(BaseModel): recall_budget_adaptive_high: Optional[Union[StrictFloat, StrictInt]] = None recall_budget_min: Optional[StrictInt] = None recall_budget_max: Optional[StrictInt] = None - __properties: ClassVar[List[str]] = ["reflect_mission", "retain_mission", "retain_extraction_mode", "retain_custom_instructions", "retain_chunk_size", "enable_observations", "observations_mission", "disposition_skepticism", "disposition_literalism", "disposition_empathy", "entity_labels", "entities_allow_free_form", "retain_default_strategy", "retain_strategies", "retain_chunk_batch_size", "mcp_enabled_tools", "consolidation_llm_batch_size", "consolidation_source_facts_max_tokens", "consolidation_source_facts_max_tokens_per_observation", "max_observations_per_scope", "reflect_source_facts_max_tokens", "llm_gemini_safety_settings", "recall_budget_function", "recall_budget_fixed_low", "recall_budget_fixed_mid", "recall_budget_fixed_high", "recall_budget_adaptive_low", "recall_budget_adaptive_mid", "recall_budget_adaptive_high", "recall_budget_min", "recall_budget_max"] + __properties: ClassVar[List[str]] = ["reflect_mission", "retain_mission", "retain_extraction_mode", "retain_custom_instructions", "retain_chunk_size", "retain_structured_chunk_size", "enable_observations", "observations_mission", "disposition_skepticism", "disposition_literalism", "disposition_empathy", "entity_labels", "entities_allow_free_form", "retain_default_strategy", "retain_strategies", "retain_chunk_batch_size", "mcp_enabled_tools", "consolidation_llm_batch_size", "consolidation_source_facts_max_tokens", "consolidation_source_facts_max_tokens_per_observation", "max_observations_per_scope", "observation_scope_limits", "reflect_source_facts_max_tokens", "llm_gemini_safety_settings", "recall_budget_function", "recall_budget_fixed_low", "recall_budget_fixed_mid", "recall_budget_fixed_high", "recall_budget_adaptive_low", "recall_budget_adaptive_mid", "recall_budget_adaptive_high", "recall_budget_min", "recall_budget_max"] model_config = ConfigDict( populate_by_name=True, @@ -124,6 +126,11 @@ def to_dict(self) -> Dict[str, Any]: if self.retain_chunk_size is None and "retain_chunk_size" in self.model_fields_set: _dict['retain_chunk_size'] = None + # set to None if retain_structured_chunk_size (nullable) is None + # and model_fields_set contains the field + if self.retain_structured_chunk_size is None and "retain_structured_chunk_size" in self.model_fields_set: + _dict['retain_structured_chunk_size'] = None + # set to None if enable_observations (nullable) is None # and model_fields_set contains the field if self.enable_observations is None and "enable_observations" in self.model_fields_set: @@ -199,6 +206,11 @@ def to_dict(self) -> Dict[str, Any]: if self.max_observations_per_scope is None and "max_observations_per_scope" in self.model_fields_set: _dict['max_observations_per_scope'] = None + # set to None if observation_scope_limits (nullable) is None + # and model_fields_set contains the field + if self.observation_scope_limits is None and "observation_scope_limits" in self.model_fields_set: + _dict['observation_scope_limits'] = None + # set to None if reflect_source_facts_max_tokens (nullable) is None # and model_fields_set contains the field if self.reflect_source_facts_max_tokens is None and "reflect_source_facts_max_tokens" in self.model_fields_set: @@ -271,6 +283,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "retain_extraction_mode": obj.get("retain_extraction_mode"), "retain_custom_instructions": obj.get("retain_custom_instructions"), "retain_chunk_size": obj.get("retain_chunk_size"), + "retain_structured_chunk_size": obj.get("retain_structured_chunk_size"), "enable_observations": obj.get("enable_observations"), "observations_mission": obj.get("observations_mission"), "disposition_skepticism": obj.get("disposition_skepticism"), @@ -286,6 +299,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "consolidation_source_facts_max_tokens": obj.get("consolidation_source_facts_max_tokens"), "consolidation_source_facts_max_tokens_per_observation": obj.get("consolidation_source_facts_max_tokens_per_observation"), "max_observations_per_scope": obj.get("max_observations_per_scope"), + "observation_scope_limits": obj.get("observation_scope_limits"), "reflect_source_facts_max_tokens": obj.get("reflect_source_facts_max_tokens"), "llm_gemini_safety_settings": obj.get("llm_gemini_safety_settings"), "recall_budget_function": obj.get("recall_budget_function"), diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_template_directive.py b/hindsight-clients/python/hindsight_client_api/models/bank_template_directive.py index 5cc32905d3..00e302903f 100644 --- a/hindsight-clients/python/hindsight_client_api/models/bank_template_directive.py +++ b/hindsight-clients/python/hindsight_client_api/models/bank_template_directive.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_template_import_response.py b/hindsight-clients/python/hindsight_client_api/models/bank_template_import_response.py index 76922975ce..2d2e69b5b5 100644 --- a/hindsight-clients/python/hindsight_client_api/models/bank_template_import_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/bank_template_import_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_template_manifest.py b/hindsight-clients/python/hindsight_client_api/models/bank_template_manifest.py index 079d0bb4fe..6fe581a7c2 100644 --- a/hindsight-clients/python/hindsight_client_api/models/bank_template_manifest.py +++ b/hindsight-clients/python/hindsight_client_api/models/bank_template_manifest.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/bank_template_mental_model.py b/hindsight-clients/python/hindsight_client_api/models/bank_template_mental_model.py index a1b237430c..b99ecac7d3 100644 --- a/hindsight-clients/python/hindsight_client_api/models/bank_template_mental_model.py +++ b/hindsight-clients/python/hindsight_client_api/models/bank_template_mental_model.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/budget.py b/hindsight-clients/python/hindsight_client_api/models/budget.py index b4e131f0d9..98232d87d3 100644 --- a/hindsight-clients/python/hindsight_client_api/models/budget.py +++ b/hindsight-clients/python/hindsight_client_api/models/budget.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/cancel_operation_response.py b/hindsight-clients/python/hindsight_client_api/models/cancel_operation_response.py index a2c932d55a..396f45c333 100644 --- a/hindsight-clients/python/hindsight_client_api/models/cancel_operation_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/cancel_operation_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/child_operation_status.py b/hindsight-clients/python/hindsight_client_api/models/child_operation_status.py index 0f92011dbe..8398f6f9f8 100644 --- a/hindsight-clients/python/hindsight_client_api/models/child_operation_status.py +++ b/hindsight-clients/python/hindsight_client_api/models/child_operation_status.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/chunk_data.py b/hindsight-clients/python/hindsight_client_api/models/chunk_data.py index 75bb5d605d..43ad0d1d34 100644 --- a/hindsight-clients/python/hindsight_client_api/models/chunk_data.py +++ b/hindsight-clients/python/hindsight_client_api/models/chunk_data.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/chunk_include_options.py b/hindsight-clients/python/hindsight_client_api/models/chunk_include_options.py index 1157cbb4a5..ad5d7464bd 100644 --- a/hindsight-clients/python/hindsight_client_api/models/chunk_include_options.py +++ b/hindsight-clients/python/hindsight_client_api/models/chunk_include_options.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/chunk_response.py b/hindsight-clients/python/hindsight_client_api/models/chunk_response.py index 48eb8d8646..77ec19829e 100644 --- a/hindsight-clients/python/hindsight_client_api/models/chunk_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/chunk_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/clear_memory_observations_response.py b/hindsight-clients/python/hindsight_client_api/models/clear_memory_observations_response.py index 1f33a957d6..704e279ec0 100644 --- a/hindsight-clients/python/hindsight_client_api/models/clear_memory_observations_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/clear_memory_observations_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/consolidation_request.py b/hindsight-clients/python/hindsight_client_api/models/consolidation_request.py index 94c24cfe1e..cb657ef0d1 100644 --- a/hindsight-clients/python/hindsight_client_api/models/consolidation_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/consolidation_request.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/consolidation_response.py b/hindsight-clients/python/hindsight_client_api/models/consolidation_response.py index 9aa46716ac..cd9563c9fe 100644 --- a/hindsight-clients/python/hindsight_client_api/models/consolidation_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/consolidation_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/create_bank_request.py b/hindsight-clients/python/hindsight_client_api/models/create_bank_request.py index 6a8d7d8af9..631d03be6d 100644 --- a/hindsight-clients/python/hindsight_client_api/models/create_bank_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/create_bank_request.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -40,9 +40,10 @@ class CreateBankRequest(BaseModel): retain_extraction_mode: Optional[StrictStr] = None retain_custom_instructions: Optional[StrictStr] = None retain_chunk_size: Optional[StrictInt] = None + retain_structured_chunk_size: Optional[StrictInt] = None enable_observations: Optional[StrictBool] = None observations_mission: Optional[StrictStr] = None - __properties: ClassVar[List[str]] = ["name", "disposition", "disposition_skepticism", "disposition_literalism", "disposition_empathy", "mission", "background", "reflect_mission", "retain_mission", "retain_extraction_mode", "retain_custom_instructions", "retain_chunk_size", "enable_observations", "observations_mission"] + __properties: ClassVar[List[str]] = ["name", "disposition", "disposition_skepticism", "disposition_literalism", "disposition_empathy", "mission", "background", "reflect_mission", "retain_mission", "retain_extraction_mode", "retain_custom_instructions", "retain_chunk_size", "retain_structured_chunk_size", "enable_observations", "observations_mission"] model_config = ConfigDict( populate_by_name=True, @@ -146,6 +147,11 @@ def to_dict(self) -> Dict[str, Any]: if self.retain_chunk_size is None and "retain_chunk_size" in self.model_fields_set: _dict['retain_chunk_size'] = None + # set to None if retain_structured_chunk_size (nullable) is None + # and model_fields_set contains the field + if self.retain_structured_chunk_size is None and "retain_structured_chunk_size" in self.model_fields_set: + _dict['retain_structured_chunk_size'] = None + # set to None if enable_observations (nullable) is None # and model_fields_set contains the field if self.enable_observations is None and "enable_observations" in self.model_fields_set: @@ -180,6 +186,7 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "retain_extraction_mode": obj.get("retain_extraction_mode"), "retain_custom_instructions": obj.get("retain_custom_instructions"), "retain_chunk_size": obj.get("retain_chunk_size"), + "retain_structured_chunk_size": obj.get("retain_structured_chunk_size"), "enable_observations": obj.get("enable_observations"), "observations_mission": obj.get("observations_mission") }) diff --git a/hindsight-clients/python/hindsight_client_api/models/create_directive_request.py b/hindsight-clients/python/hindsight_client_api/models/create_directive_request.py index bff967b4b6..09bdb56f12 100644 --- a/hindsight-clients/python/hindsight_client_api/models/create_directive_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/create_directive_request.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py b/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py index cf7fe4dc10..1543528260 100644 --- a/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/create_mental_model_request.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/create_mental_model_response.py b/hindsight-clients/python/hindsight_client_api/models/create_mental_model_response.py index 3cf37f57b4..ac3bfc63d6 100644 --- a/hindsight-clients/python/hindsight_client_api/models/create_mental_model_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/create_mental_model_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/create_webhook_request.py b/hindsight-clients/python/hindsight_client_api/models/create_webhook_request.py index 29c7fb9b1d..dab2411d27 100644 --- a/hindsight-clients/python/hindsight_client_api/models/create_webhook_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/create_webhook_request.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -29,7 +29,7 @@ class CreateWebhookRequest(BaseModel): """ # noqa: E501 url: StrictStr = Field(description="HTTP(S) endpoint URL to deliver events to") secret: Optional[StrictStr] = None - event_types: Optional[List[StrictStr]] = Field(default=None, description="List of event types to deliver. Currently supported: 'consolidation.completed'") + event_types: Optional[List[StrictStr]] = Field(default=None, description="List of event types to deliver. Supported: 'retain.completed', 'consolidation.completed', 'memory_defense.triggered'.") enabled: Optional[StrictBool] = Field(default=True, description="Whether this webhook is active") http_config: Optional[WebhookHttpConfig] = Field(default=None, description="HTTP delivery configuration (method, timeout, headers, params)") __properties: ClassVar[List[str]] = ["url", "secret", "event_types", "enabled", "http_config"] diff --git a/hindsight-clients/python/hindsight_client_api/models/delete_document_response.py b/hindsight-clients/python/hindsight_client_api/models/delete_document_response.py index 1069af4b0a..ae80c69ce7 100644 --- a/hindsight-clients/python/hindsight_client_api/models/delete_document_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/delete_document_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/delete_response.py b/hindsight-clients/python/hindsight_client_api/models/delete_response.py index 9c92295d61..fd1a52a3c3 100644 --- a/hindsight-clients/python/hindsight_client_api/models/delete_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/delete_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/directive_list_response.py b/hindsight-clients/python/hindsight_client_api/models/directive_list_response.py index 2f73f50103..4bc6d5e06e 100644 --- a/hindsight-clients/python/hindsight_client_api/models/directive_list_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/directive_list_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/directive_response.py b/hindsight-clients/python/hindsight_client_api/models/directive_response.py index c1d34e14b7..fcf7a1075d 100644 --- a/hindsight-clients/python/hindsight_client_api/models/directive_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/directive_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/disposition_traits.py b/hindsight-clients/python/hindsight_client_api/models/disposition_traits.py index dfb52918cb..bc717a011c 100644 --- a/hindsight-clients/python/hindsight_client_api/models/disposition_traits.py +++ b/hindsight-clients/python/hindsight_client_api/models/disposition_traits.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/document_import_submit_response.py b/hindsight-clients/python/hindsight_client_api/models/document_import_submit_response.py index c682e0f035..d75fd18a87 100644 --- a/hindsight-clients/python/hindsight_client_api/models/document_import_submit_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/document_import_submit_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/document_response.py b/hindsight-clients/python/hindsight_client_api/models/document_response.py index 344278a8a0..3acafff18f 100644 --- a/hindsight-clients/python/hindsight_client_api/models/document_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/document_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -19,6 +19,7 @@ from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr from typing import Any, ClassVar, Dict, List, Optional +from hindsight_client_api.models.observation_scopes import ObservationScopes from typing import Optional, Set from typing_extensions import Self @@ -28,7 +29,7 @@ class DocumentResponse(BaseModel): """ # noqa: E501 id: StrictStr bank_id: StrictStr - original_text: StrictStr + original_text: Optional[StrictStr] content_hash: Optional[StrictStr] created_at: StrictStr updated_at: StrictStr @@ -37,7 +38,8 @@ class DocumentResponse(BaseModel): tags: Optional[List[StrictStr]] = Field(default=None, description="Tags associated with this document") document_metadata: Optional[Dict[str, Any]] = None retain_params: Optional[Dict[str, Any]] = None - __properties: ClassVar[List[str]] = ["id", "bank_id", "original_text", "content_hash", "created_at", "updated_at", "memory_unit_count", "nodes_by_fact_type", "tags", "document_metadata", "retain_params"] + observation_scopes: Optional[ObservationScopes] = None + __properties: ClassVar[List[str]] = ["id", "bank_id", "original_text", "content_hash", "created_at", "updated_at", "memory_unit_count", "nodes_by_fact_type", "tags", "document_metadata", "retain_params", "observation_scopes"] model_config = ConfigDict( populate_by_name=True, @@ -78,6 +80,14 @@ def to_dict(self) -> Dict[str, Any]: exclude=excluded_fields, exclude_none=True, ) + # override the default output from pydantic by calling `to_dict()` of observation_scopes + if self.observation_scopes: + _dict['observation_scopes'] = self.observation_scopes.to_dict() + # set to None if original_text (nullable) is None + # and model_fields_set contains the field + if self.original_text is None and "original_text" in self.model_fields_set: + _dict['original_text'] = None + # set to None if content_hash (nullable) is None # and model_fields_set contains the field if self.content_hash is None and "content_hash" in self.model_fields_set: @@ -98,6 +108,11 @@ def to_dict(self) -> Dict[str, Any]: if self.retain_params is None and "retain_params" in self.model_fields_set: _dict['retain_params'] = None + # set to None if observation_scopes (nullable) is None + # and model_fields_set contains the field + if self.observation_scopes is None and "observation_scopes" in self.model_fields_set: + _dict['observation_scopes'] = None + return _dict @classmethod @@ -120,7 +135,8 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "nodes_by_fact_type": obj.get("nodes_by_fact_type"), "tags": obj.get("tags"), "document_metadata": obj.get("document_metadata"), - "retain_params": obj.get("retain_params") + "retain_params": obj.get("retain_params"), + "observation_scopes": ObservationScopes.from_dict(obj["observation_scopes"]) if obj.get("observation_scopes") is not None else None }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/dry_run_extract_request.py b/hindsight-clients/python/hindsight_client_api/models/dry_run_extract_request.py new file mode 100644 index 0000000000..d4668ba11e --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/dry_run_extract_request.py @@ -0,0 +1,160 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.8.3 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from datetime import datetime +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from typing import Optional, Set +from typing_extensions import Self + +class DryRunExtractRequest(BaseModel): + """ + Request to run fact extraction ONLY (no resolution/links/embeddings/persistence). Every field below the content/context/date is a prompt-affecting override applied just for this call — used to preview what a candidate retain mission (or any extraction setting) would extract, without changing the bank. Unset (null) fields fall back to the bank's resolved config. + """ # noqa: E501 + content: StrictStr = Field(description="Text to extract facts from (e.g. a document or a single chunk).") + context: Optional[StrictStr] = Field(default='', description="Optional context about the content.") + timestamp: Optional[datetime] = None + agent_name: Optional[StrictStr] = None + retain_mission: Optional[StrictStr] = None + retain_extraction_mode: Optional[StrictStr] = None + retain_custom_instructions: Optional[StrictStr] = None + retain_extract_causal_links: Optional[StrictBool] = None + retain_chunk_size: Optional[StrictInt] = None + entity_labels: Optional[List[Any]] = None + entities_allow_free_form: Optional[StrictBool] = None + llm_output_language: Optional[StrictStr] = None + __properties: ClassVar[List[str]] = ["content", "context", "timestamp", "agent_name", "retain_mission", "retain_extraction_mode", "retain_custom_instructions", "retain_extract_causal_links", "retain_chunk_size", "entity_labels", "entities_allow_free_form", "llm_output_language"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of DryRunExtractRequest from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # set to None if timestamp (nullable) is None + # and model_fields_set contains the field + if self.timestamp is None and "timestamp" in self.model_fields_set: + _dict['timestamp'] = None + + # set to None if agent_name (nullable) is None + # and model_fields_set contains the field + if self.agent_name is None and "agent_name" in self.model_fields_set: + _dict['agent_name'] = None + + # set to None if retain_mission (nullable) is None + # and model_fields_set contains the field + if self.retain_mission is None and "retain_mission" in self.model_fields_set: + _dict['retain_mission'] = None + + # set to None if retain_extraction_mode (nullable) is None + # and model_fields_set contains the field + if self.retain_extraction_mode is None and "retain_extraction_mode" in self.model_fields_set: + _dict['retain_extraction_mode'] = None + + # set to None if retain_custom_instructions (nullable) is None + # and model_fields_set contains the field + if self.retain_custom_instructions is None and "retain_custom_instructions" in self.model_fields_set: + _dict['retain_custom_instructions'] = None + + # set to None if retain_extract_causal_links (nullable) is None + # and model_fields_set contains the field + if self.retain_extract_causal_links is None and "retain_extract_causal_links" in self.model_fields_set: + _dict['retain_extract_causal_links'] = None + + # set to None if retain_chunk_size (nullable) is None + # and model_fields_set contains the field + if self.retain_chunk_size is None and "retain_chunk_size" in self.model_fields_set: + _dict['retain_chunk_size'] = None + + # set to None if entity_labels (nullable) is None + # and model_fields_set contains the field + if self.entity_labels is None and "entity_labels" in self.model_fields_set: + _dict['entity_labels'] = None + + # set to None if entities_allow_free_form (nullable) is None + # and model_fields_set contains the field + if self.entities_allow_free_form is None and "entities_allow_free_form" in self.model_fields_set: + _dict['entities_allow_free_form'] = None + + # set to None if llm_output_language (nullable) is None + # and model_fields_set contains the field + if self.llm_output_language is None and "llm_output_language" in self.model_fields_set: + _dict['llm_output_language'] = None + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of DryRunExtractRequest from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "content": obj.get("content"), + "context": obj.get("context") if obj.get("context") is not None else '', + "timestamp": obj.get("timestamp"), + "agent_name": obj.get("agent_name"), + "retain_mission": obj.get("retain_mission"), + "retain_extraction_mode": obj.get("retain_extraction_mode"), + "retain_custom_instructions": obj.get("retain_custom_instructions"), + "retain_extract_causal_links": obj.get("retain_extract_causal_links"), + "retain_chunk_size": obj.get("retain_chunk_size"), + "entity_labels": obj.get("entity_labels"), + "entities_allow_free_form": obj.get("entities_allow_free_form"), + "llm_output_language": obj.get("llm_output_language") + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/dry_run_extraction_result.py b/hindsight-clients/python/hindsight_client_api/models/dry_run_extraction_result.py new file mode 100644 index 0000000000..bc4214f8cb --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/dry_run_extraction_result.py @@ -0,0 +1,101 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.8.3 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field +from typing import Any, ClassVar, Dict, List, Optional +from hindsight_client_api.models.extracted_fact import ExtractedFact +from hindsight_client_api.models.token_usage import TokenUsage +from typing import Optional, Set +from typing_extensions import Self + +class DryRunExtractionResult(BaseModel): + """ + Result of dry-run fact extraction: candidate facts plus aggregated LLM token usage. + """ # noqa: E501 + facts: Optional[List[ExtractedFact]] = Field(default=None, description="Candidate facts the retain step would extract.") + usage: Optional[TokenUsage] = Field(default=None, description="Aggregated token usage across the extraction LLM calls.") + __properties: ClassVar[List[str]] = ["facts", "usage"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of DryRunExtractionResult from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of each item in facts (list) + _items = [] + if self.facts: + for _item_facts in self.facts: + if _item_facts: + _items.append(_item_facts.to_dict()) + _dict['facts'] = _items + # override the default output from pydantic by calling `to_dict()` of usage + if self.usage: + _dict['usage'] = self.usage.to_dict() + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of DryRunExtractionResult from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "facts": [ExtractedFact.from_dict(_item) for _item in obj["facts"]] if obj.get("facts") is not None else None, + "usage": TokenUsage.from_dict(obj["usage"]) if obj.get("usage") is not None else None + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/entity_detail_response.py b/hindsight-clients/python/hindsight_client_api/models/entity_detail_response.py index 7d8f471974..3c912c6d54 100644 --- a/hindsight-clients/python/hindsight_client_api/models/entity_detail_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/entity_detail_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/entity_graph_response.py b/hindsight-clients/python/hindsight_client_api/models/entity_graph_response.py index 1131751bf6..6cd853f8d5 100644 --- a/hindsight-clients/python/hindsight_client_api/models/entity_graph_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/entity_graph_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/entity_include_options.py b/hindsight-clients/python/hindsight_client_api/models/entity_include_options.py index 692bf3b172..2a5c827435 100644 --- a/hindsight-clients/python/hindsight_client_api/models/entity_include_options.py +++ b/hindsight-clients/python/hindsight_client_api/models/entity_include_options.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/entity_input.py b/hindsight-clients/python/hindsight_client_api/models/entity_input.py index 0a8212b053..57f8e8047b 100644 --- a/hindsight-clients/python/hindsight_client_api/models/entity_input.py +++ b/hindsight-clients/python/hindsight_client_api/models/entity_input.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/entity_list_item.py b/hindsight-clients/python/hindsight_client_api/models/entity_list_item.py index 0375a39c96..15ca59fa03 100644 --- a/hindsight-clients/python/hindsight_client_api/models/entity_list_item.py +++ b/hindsight-clients/python/hindsight_client_api/models/entity_list_item.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/entity_list_response.py b/hindsight-clients/python/hindsight_client_api/models/entity_list_response.py index 4c952dcf55..f5cd68c67a 100644 --- a/hindsight-clients/python/hindsight_client_api/models/entity_list_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/entity_list_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/entity_observation_response.py b/hindsight-clients/python/hindsight_client_api/models/entity_observation_response.py index fd035d587c..7ad7ea518e 100644 --- a/hindsight-clients/python/hindsight_client_api/models/entity_observation_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/entity_observation_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/entity_state_response.py b/hindsight-clients/python/hindsight_client_api/models/entity_state_response.py index 93d2ee95e0..8056bdd889 100644 --- a/hindsight-clients/python/hindsight_client_api/models/entity_state_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/entity_state_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/extracted_fact.py b/hindsight-clients/python/hindsight_client_api/models/extracted_fact.py new file mode 100644 index 0000000000..f6664e17c7 --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/extracted_fact.py @@ -0,0 +1,105 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.8.3 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from typing import Optional, Set +from typing_extensions import Self + +class ExtractedFact(BaseModel): + """ + A single candidate fact produced by dry-run extraction (no resolution/links/persistence). A deliberate subset of the persisted memory-unit shape — only the fields a fresh extraction yields. Storage/consolidation/curation fields (id, document_id, chunk_id, proof_count, state, …) are omitted because nothing is stored. Entities are raw, unresolved names. + """ # noqa: E501 + text: StrictStr = Field(description="The extracted fact text.") + fact_type: StrictStr = Field(description="Perspective classification: 'world' or 'experience'.") + occurred_start: Optional[StrictStr] = None + occurred_end: Optional[StrictStr] = None + entities: Optional[List[StrictStr]] = Field(default=None, description="Raw (unresolved) entity names mentioned in the fact.") + __properties: ClassVar[List[str]] = ["text", "fact_type", "occurred_start", "occurred_end", "entities"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of ExtractedFact from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # set to None if occurred_start (nullable) is None + # and model_fields_set contains the field + if self.occurred_start is None and "occurred_start" in self.model_fields_set: + _dict['occurred_start'] = None + + # set to None if occurred_end (nullable) is None + # and model_fields_set contains the field + if self.occurred_end is None and "occurred_end" in self.model_fields_set: + _dict['occurred_end'] = None + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of ExtractedFact from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "text": obj.get("text"), + "fact_type": obj.get("fact_type"), + "occurred_start": obj.get("occurred_start"), + "occurred_end": obj.get("occurred_end"), + "entities": obj.get("entities") + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/features_info.py b/hindsight-clients/python/hindsight_client_api/models/features_info.py index e017624959..00a80c54c6 100644 --- a/hindsight-clients/python/hindsight_client_api/models/features_info.py +++ b/hindsight-clients/python/hindsight_client_api/models/features_info.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -30,12 +30,14 @@ class FeaturesInfo(BaseModel): mcp: StrictBool = Field(description="Whether MCP (Model Context Protocol) server is enabled") worker: StrictBool = Field(description="Whether the background worker is enabled") bank_config_api: StrictBool = Field(description="Whether per-bank configuration API is enabled") + bank_llm_health: StrictBool = Field(description="Whether the per-bank LLM connectivity probe is enabled") file_upload_api: StrictBool = Field(description="Whether file upload/conversion API is enabled") document_export_api: StrictBool = Field(description="Whether the document export endpoint is enabled") document_import_api: StrictBool = Field(description="Whether the document import endpoint is enabled") audit_log: StrictBool = Field(description="Whether audit logging is enabled") llm_trace: StrictBool = Field(description="Whether per-bank LLM request tracing is enabled") - __properties: ClassVar[List[str]] = ["observations", "mcp", "worker", "bank_config_api", "file_upload_api", "document_export_api", "document_import_api", "audit_log", "llm_trace"] + store_document_text: StrictBool = Field(description="Whether raw source text is persisted. When false, document/chunk source text is not stored.") + __properties: ClassVar[List[str]] = ["observations", "mcp", "worker", "bank_config_api", "bank_llm_health", "file_upload_api", "document_export_api", "document_import_api", "audit_log", "llm_trace", "store_document_text"] model_config = ConfigDict( populate_by_name=True, @@ -92,11 +94,13 @@ def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: "mcp": obj.get("mcp"), "worker": obj.get("worker"), "bank_config_api": obj.get("bank_config_api"), + "bank_llm_health": obj.get("bank_llm_health"), "file_upload_api": obj.get("file_upload_api"), "document_export_api": obj.get("document_export_api"), "document_import_api": obj.get("document_import_api"), "audit_log": obj.get("audit_log"), - "llm_trace": obj.get("llm_trace") + "llm_trace": obj.get("llm_trace"), + "store_document_text": obj.get("store_document_text") }) return _obj diff --git a/hindsight-clients/python/hindsight_client_api/models/file_retain_response.py b/hindsight-clients/python/hindsight_client_api/models/file_retain_response.py index 6400fbdbab..d72752881c 100644 --- a/hindsight-clients/python/hindsight_client_api/models/file_retain_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/file_retain_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/graph_data_response.py b/hindsight-clients/python/hindsight_client_api/models/graph_data_response.py index 4ee3cf9772..9d7e53d7a7 100644 --- a/hindsight-clients/python/hindsight_client_api/models/graph_data_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/graph_data_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/http_validation_error.py b/hindsight-clients/python/hindsight_client_api/models/http_validation_error.py index fc8cb7471c..35786c2ffd 100644 --- a/hindsight-clients/python/hindsight_client_api/models/http_validation_error.py +++ b/hindsight-clients/python/hindsight_client_api/models/http_validation_error.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/include_options.py b/hindsight-clients/python/hindsight_client_api/models/include_options.py index 06a50fa078..35d238d599 100644 --- a/hindsight-clients/python/hindsight_client_api/models/include_options.py +++ b/hindsight-clients/python/hindsight_client_api/models/include_options.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/list_chunks_response.py b/hindsight-clients/python/hindsight_client_api/models/list_chunks_response.py index 5b0c3e9b84..ac5bd9fffe 100644 --- a/hindsight-clients/python/hindsight_client_api/models/list_chunks_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/list_chunks_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/list_documents_response.py b/hindsight-clients/python/hindsight_client_api/models/list_documents_response.py index ea728b106d..659643463b 100644 --- a/hindsight-clients/python/hindsight_client_api/models/list_documents_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/list_documents_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/list_memory_units_response.py b/hindsight-clients/python/hindsight_client_api/models/list_memory_units_response.py index 456d4a317b..e9b78e7ae6 100644 --- a/hindsight-clients/python/hindsight_client_api/models/list_memory_units_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/list_memory_units_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/list_tags_response.py b/hindsight-clients/python/hindsight_client_api/models/list_tags_response.py index d04f3c4211..5f15a1f893 100644 --- a/hindsight-clients/python/hindsight_client_api/models/list_tags_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/list_tags_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/llm_operation_health.py b/hindsight-clients/python/hindsight_client_api/models/llm_operation_health.py new file mode 100644 index 0000000000..33d8ea30f4 --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/llm_operation_health.py @@ -0,0 +1,112 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.8.3 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictFloat, StrictInt, StrictStr, field_validator +from typing import Any, ClassVar, Dict, List, Optional, Union +from typing import Optional, Set +from typing_extensions import Self + +class LlmOperationHealth(BaseModel): + """ + LLM connectivity status for a single operation. Status only — no provider/model/ endpoint/error, so the probe never discloses the LLM configuration. + """ # noqa: E501 + operation: StrictStr = Field(description="Operation whose LLM was probed") + ok: StrictBool = Field(description="True only when the probe connected successfully") + status: StrictStr = Field(description="'connected'; 'not_configured' (provider is 'none'); 'auth_failed' (rejected — usually a wrong/expired API key); 'unreachable' (call failed); 'timeout'") + latency_ms: Optional[Union[StrictFloat, StrictInt]] = None + __properties: ClassVar[List[str]] = ["operation", "ok", "status", "latency_ms"] + + @field_validator('operation') + def operation_validate_enum(cls, value): + """Validates the enum""" + if value not in set(['retain', 'consolidation', 'reflect']): + raise ValueError("must be one of enum values ('retain', 'consolidation', 'reflect')") + return value + + @field_validator('status') + def status_validate_enum(cls, value): + """Validates the enum""" + if value not in set(['connected', 'not_configured', 'auth_failed', 'unreachable', 'timeout']): + raise ValueError("must be one of enum values ('connected', 'not_configured', 'auth_failed', 'unreachable', 'timeout')") + return value + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of LlmOperationHealth from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # set to None if latency_ms (nullable) is None + # and model_fields_set contains the field + if self.latency_ms is None and "latency_ms" in self.model_fields_set: + _dict['latency_ms'] = None + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of LlmOperationHealth from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "operation": obj.get("operation"), + "ok": obj.get("ok"), + "status": obj.get("status"), + "latency_ms": obj.get("latency_ms") + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/llm_request_entry.py b/hindsight-clients/python/hindsight_client_api/models/llm_request_entry.py index 9b97f97f5e..ca889f1d13 100644 --- a/hindsight-clients/python/hindsight_client_api/models/llm_request_entry.py +++ b/hindsight-clients/python/hindsight_client_api/models/llm_request_entry.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/llm_request_list_response.py b/hindsight-clients/python/hindsight_client_api/models/llm_request_list_response.py index b8e703ec3e..a12dee0ddd 100644 --- a/hindsight-clients/python/hindsight_client_api/models/llm_request_list_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/llm_request_list_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/llm_request_stats_bucket.py b/hindsight-clients/python/hindsight_client_api/models/llm_request_stats_bucket.py index aed40542da..056b093251 100644 --- a/hindsight-clients/python/hindsight_client_api/models/llm_request_stats_bucket.py +++ b/hindsight-clients/python/hindsight_client_api/models/llm_request_stats_bucket.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/llm_request_stats_response.py b/hindsight-clients/python/hindsight_client_api/models/llm_request_stats_response.py index f707f8e3f4..7d89560419 100644 --- a/hindsight-clients/python/hindsight_client_api/models/llm_request_stats_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/llm_request_stats_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/llm_request_token_sums.py b/hindsight-clients/python/hindsight_client_api/models/llm_request_token_sums.py index 138b4725e1..fe24449bdf 100644 --- a/hindsight-clients/python/hindsight_client_api/models/llm_request_token_sums.py +++ b/hindsight-clients/python/hindsight_client_api/models/llm_request_token_sums.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/memories_timeseries_response.py b/hindsight-clients/python/hindsight_client_api/models/memories_timeseries_response.py index fbd0e8d325..e92a50e79c 100644 --- a/hindsight-clients/python/hindsight_client_api/models/memories_timeseries_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/memories_timeseries_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/memory_item.py b/hindsight-clients/python/hindsight_client_api/models/memory_item.py index 179dcfc8d9..cfee2e97e1 100644 --- a/hindsight-clients/python/hindsight_client_api/models/memory_item.py +++ b/hindsight-clients/python/hindsight_client_api/models/memory_item.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/memory_timeseries_bucket.py b/hindsight-clients/python/hindsight_client_api/models/memory_timeseries_bucket.py index e1adb81b56..ff43cf5725 100644 --- a/hindsight-clients/python/hindsight_client_api/models/memory_timeseries_bucket.py +++ b/hindsight-clients/python/hindsight_client_api/models/memory_timeseries_bucket.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_list_response.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_list_response.py index baa69929c9..e73cf034da 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_list_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_list_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_response.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_response.py index beb8becdd8..72b86392cb 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input.py index 30db2837d9..d42b21c81e 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -66,8 +66,8 @@ def tags_match_validate_enum(cls, value): if value is None: return value - if value not in set(['any', 'all', 'any_strict', 'all_strict']): - raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict')") + if value not in set(['any', 'all', 'any_strict', 'all_strict', 'exact']): + raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict', 'exact')") return value model_config = ConfigDict( diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input_tag_groups_inner.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input_tag_groups_inner.py index 580d206bf3..054059f4ad 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input_tag_groups_inner.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_input_tag_groups_inner.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output.py index acd843f5a6..b0ad9c5c44 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -66,8 +66,8 @@ def tags_match_validate_enum(cls, value): if value is None: return value - if value not in set(['any', 'all', 'any_strict', 'all_strict']): - raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict')") + if value not in set(['any', 'all', 'any_strict', 'all_strict', 'exact']): + raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict', 'exact')") return value model_config = ConfigDict( diff --git a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output_tag_groups_inner.py b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output_tag_groups_inner.py index a38d26c0ad..9884cdd835 100644 --- a/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output_tag_groups_inner.py +++ b/hindsight-clients/python/hindsight_client_api/models/mental_model_trigger_output_tag_groups_inner.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/model_not.py b/hindsight-clients/python/hindsight_client_api/models/model_not.py index 5eafc385ac..6b73ca13c0 100644 --- a/hindsight-clients/python/hindsight_client_api/models/model_not.py +++ b/hindsight-clients/python/hindsight_client_api/models/model_not.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/not1.py b/hindsight-clients/python/hindsight_client_api/models/not1.py index d472dcdb53..bacc3d9c62 100644 --- a/hindsight-clients/python/hindsight_client_api/models/not1.py +++ b/hindsight-clients/python/hindsight_client_api/models/not1.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/observation_scope.py b/hindsight-clients/python/hindsight_client_api/models/observation_scope.py new file mode 100644 index 0000000000..5b8858fccf --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/observation_scope.py @@ -0,0 +1,89 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.8.3 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr +from typing import Any, ClassVar, Dict, List +from typing import Optional, Set +from typing_extensions import Self + +class ObservationScope(BaseModel): + """ + A distinct observation scope: an exact tag set plus its observation count. + """ # noqa: E501 + tags: List[StrictStr] = Field(description="The exact tag set defining this scope (normalized order). Empty list is the global/untagged scope.") + count: StrictInt = Field(description="Number of observations that live under this scope") + __properties: ClassVar[List[str]] = ["tags", "count"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of ObservationScope from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of ObservationScope from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "tags": obj.get("tags"), + "count": obj.get("count") + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/observation_scopes.py b/hindsight-clients/python/hindsight_client_api/models/observation_scopes.py index ae295322d5..10fc97cd88 100644 --- a/hindsight-clients/python/hindsight_client_api/models/observation_scopes.py +++ b/hindsight-clients/python/hindsight_client_api/models/observation_scopes.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -27,7 +27,7 @@ class ObservationScopes(BaseModel): """ - How to scope observations during consolidation. 'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. 'combined' (default) runs a single pass with all tags together. A list of tag lists runs one pass per inner list, giving full control over which combinations to use. + How to scope observations during consolidation. 'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. 'combined' (default) runs a single pass with all tags together. 'shared' runs a single pass over one global, untagged scope, so memories consolidate together regardless of their tags — useful for deduplicating across volatile per-call provenance tags (e.g. per-session ids) while keeping those tags on the source facts. A list of tag lists runs one pass per inner list, giving full control over which combinations to use. """ # data type: str diff --git a/hindsight-clients/python/hindsight_client_api/models/observation_scopes_response.py b/hindsight-clients/python/hindsight_client_api/models/observation_scopes_response.py new file mode 100644 index 0000000000..bd826ac888 --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/observation_scopes_response.py @@ -0,0 +1,95 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.8.3 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, Field +from typing import Any, ClassVar, Dict, List +from hindsight_client_api.models.observation_scope import ObservationScope +from typing import Optional, Set +from typing_extensions import Self + +class ObservationScopesResponse(BaseModel): + """ + Response model for the observation scopes enumeration endpoint. + """ # noqa: E501 + scopes: List[ObservationScope] = Field(description="Distinct observation scopes, most populous first") + __properties: ClassVar[List[str]] = ["scopes"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of ObservationScopesResponse from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # override the default output from pydantic by calling `to_dict()` of each item in scopes (list) + _items = [] + if self.scopes: + for _item_scopes in self.scopes: + if _item_scopes: + _items.append(_item_scopes.to_dict()) + _dict['scopes'] = _items + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of ObservationScopesResponse from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "scopes": [ObservationScope.from_dict(_item) for _item in obj["scopes"]] if obj.get("scopes") is not None else None + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/operation_progress.py b/hindsight-clients/python/hindsight_client_api/models/operation_progress.py index ddbd47e046..a01e761700 100644 --- a/hindsight-clients/python/hindsight_client_api/models/operation_progress.py +++ b/hindsight-clients/python/hindsight_client_api/models/operation_progress.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/operation_response.py b/hindsight-clients/python/hindsight_client_api/models/operation_response.py index 572468b7a8..9de4166900 100644 --- a/hindsight-clients/python/hindsight_client_api/models/operation_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/operation_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/operation_status_response.py b/hindsight-clients/python/hindsight_client_api/models/operation_status_response.py index ac0cfb0e27..39977afb90 100644 --- a/hindsight-clients/python/hindsight_client_api/models/operation_status_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/operation_status_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/operations_list_response.py b/hindsight-clients/python/hindsight_client_api/models/operations_list_response.py index 188bd35277..afa5418851 100644 --- a/hindsight-clients/python/hindsight_client_api/models/operations_list_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/operations_list_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/recall_request.py b/hindsight-clients/python/hindsight_client_api/models/recall_request.py index 2a89ffbc84..1035ca7aa3 100644 --- a/hindsight-clients/python/hindsight_client_api/models/recall_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/recall_request.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -47,8 +47,8 @@ def tags_match_validate_enum(cls, value): if value is None: return value - if value not in set(['any', 'all', 'any_strict', 'all_strict']): - raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict')") + if value not in set(['any', 'all', 'any_strict', 'all_strict', 'exact']): + raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict', 'exact')") return value model_config = ConfigDict( diff --git a/hindsight-clients/python/hindsight_client_api/models/recall_response.py b/hindsight-clients/python/hindsight_client_api/models/recall_response.py index 1ab5baa41a..870256317c 100644 --- a/hindsight-clients/python/hindsight_client_api/models/recall_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/recall_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/recall_result.py b/hindsight-clients/python/hindsight_client_api/models/recall_result.py index c355e45d3a..88bf61d9f3 100644 --- a/hindsight-clients/python/hindsight_client_api/models/recall_result.py +++ b/hindsight-clients/python/hindsight_client_api/models/recall_result.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/recover_consolidation_response.py b/hindsight-clients/python/hindsight_client_api/models/recover_consolidation_response.py index c5bdf0a571..ed4bde2a2c 100644 --- a/hindsight-clients/python/hindsight_client_api/models/recover_consolidation_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/recover_consolidation_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_based_on.py b/hindsight-clients/python/hindsight_client_api/models/reflect_based_on.py index 91811b63e5..091cc5af69 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_based_on.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_based_on.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_directive.py b/hindsight-clients/python/hindsight_client_api/models/reflect_directive.py index f02c2c4f4a..6718116408 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_directive.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_directive.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_fact.py b/hindsight-clients/python/hindsight_client_api/models/reflect_fact.py index 478006483e..c3bf366fd6 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_fact.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_fact.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_include_options.py b/hindsight-clients/python/hindsight_client_api/models/reflect_include_options.py index 296f3be4da..d73099c92a 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_include_options.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_include_options.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_llm_call.py b/hindsight-clients/python/hindsight_client_api/models/reflect_llm_call.py index da76c21a62..411f55bffd 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_llm_call.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_llm_call.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_mental_model.py b/hindsight-clients/python/hindsight_client_api/models/reflect_mental_model.py index c0283afa3c..4616964dcb 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_mental_model.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_mental_model.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_request.py b/hindsight-clients/python/hindsight_client_api/models/reflect_request.py index e04feeed65..bd2af25923 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_request.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -49,8 +49,8 @@ def tags_match_validate_enum(cls, value): if value is None: return value - if value not in set(['any', 'all', 'any_strict', 'all_strict']): - raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict')") + if value not in set(['any', 'all', 'any_strict', 'all_strict', 'exact']): + raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict', 'exact')") return value @field_validator('fact_types') diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_response.py b/hindsight-clients/python/hindsight_client_api/models/reflect_response.py index bac33e7aab..b3dd1a7b92 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_tool_call.py b/hindsight-clients/python/hindsight_client_api/models/reflect_tool_call.py index aa2391059b..e6a0526724 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_tool_call.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_tool_call.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_trace.py b/hindsight-clients/python/hindsight_client_api/models/reflect_trace.py index 8d1d51f70f..9871efa932 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_trace.py +++ b/hindsight-clients/python/hindsight_client_api/models/reflect_trace.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/reprocess_document_response.py b/hindsight-clients/python/hindsight_client_api/models/reprocess_document_response.py index ac28393b68..3831d3a3e9 100644 --- a/hindsight-clients/python/hindsight_client_api/models/reprocess_document_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/reprocess_document_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/retain_request.py b/hindsight-clients/python/hindsight_client_api/models/retain_request.py index 06afb88839..0a061f7b4d 100644 --- a/hindsight-clients/python/hindsight_client_api/models/retain_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/retain_request.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/retain_response.py b/hindsight-clients/python/hindsight_client_api/models/retain_response.py index 7282ebd021..f05a7e1ec3 100644 --- a/hindsight-clients/python/hindsight_client_api/models/retain_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/retain_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/retry_operation_response.py b/hindsight-clients/python/hindsight_client_api/models/retry_operation_response.py index 2b2b1f6a3d..b4f19aa1ea 100644 --- a/hindsight-clients/python/hindsight_client_api/models/retry_operation_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/retry_operation_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/source_facts_include_options.py b/hindsight-clients/python/hindsight_client_api/models/source_facts_include_options.py index 6862f1d473..07b7eacb90 100644 --- a/hindsight-clients/python/hindsight_client_api/models/source_facts_include_options.py +++ b/hindsight-clients/python/hindsight_client_api/models/source_facts_include_options.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/tag_group_and_input.py b/hindsight-clients/python/hindsight_client_api/models/tag_group_and_input.py index 6f7e9c80ae..b6c9cf4e91 100644 --- a/hindsight-clients/python/hindsight_client_api/models/tag_group_and_input.py +++ b/hindsight-clients/python/hindsight_client_api/models/tag_group_and_input.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/tag_group_and_output.py b/hindsight-clients/python/hindsight_client_api/models/tag_group_and_output.py index 023a115de5..4f029a77da 100644 --- a/hindsight-clients/python/hindsight_client_api/models/tag_group_and_output.py +++ b/hindsight-clients/python/hindsight_client_api/models/tag_group_and_output.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/tag_group_leaf.py b/hindsight-clients/python/hindsight_client_api/models/tag_group_leaf.py index 6b605b554c..f583e9c0ff 100644 --- a/hindsight-clients/python/hindsight_client_api/models/tag_group_leaf.py +++ b/hindsight-clients/python/hindsight_client_api/models/tag_group_leaf.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. @@ -36,8 +36,8 @@ def match_validate_enum(cls, value): if value is None: return value - if value not in set(['any', 'all', 'any_strict', 'all_strict']): - raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict')") + if value not in set(['any', 'all', 'any_strict', 'all_strict', 'exact']): + raise ValueError("must be one of enum values ('any', 'all', 'any_strict', 'all_strict', 'exact')") return value model_config = ConfigDict( diff --git a/hindsight-clients/python/hindsight_client_api/models/tag_group_not_input.py b/hindsight-clients/python/hindsight_client_api/models/tag_group_not_input.py index cb9409cb4f..25638b533f 100644 --- a/hindsight-clients/python/hindsight_client_api/models/tag_group_not_input.py +++ b/hindsight-clients/python/hindsight_client_api/models/tag_group_not_input.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/tag_group_not_output.py b/hindsight-clients/python/hindsight_client_api/models/tag_group_not_output.py index 06f9616a83..d4d3f5ca43 100644 --- a/hindsight-clients/python/hindsight_client_api/models/tag_group_not_output.py +++ b/hindsight-clients/python/hindsight_client_api/models/tag_group_not_output.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/tag_group_or_input.py b/hindsight-clients/python/hindsight_client_api/models/tag_group_or_input.py index 93921dd38b..f339e3218d 100644 --- a/hindsight-clients/python/hindsight_client_api/models/tag_group_or_input.py +++ b/hindsight-clients/python/hindsight_client_api/models/tag_group_or_input.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/tag_group_or_output.py b/hindsight-clients/python/hindsight_client_api/models/tag_group_or_output.py index 7be04c7c77..38b6a78217 100644 --- a/hindsight-clients/python/hindsight_client_api/models/tag_group_or_output.py +++ b/hindsight-clients/python/hindsight_client_api/models/tag_group_or_output.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/tag_item.py b/hindsight-clients/python/hindsight_client_api/models/tag_item.py index a8f386f9e6..06696fb729 100644 --- a/hindsight-clients/python/hindsight_client_api/models/tag_item.py +++ b/hindsight-clients/python/hindsight_client_api/models/tag_item.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/timestamp.py b/hindsight-clients/python/hindsight_client_api/models/timestamp.py index c806a12e14..ebc7006fba 100644 --- a/hindsight-clients/python/hindsight_client_api/models/timestamp.py +++ b/hindsight-clients/python/hindsight_client_api/models/timestamp.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/token_usage.py b/hindsight-clients/python/hindsight_client_api/models/token_usage.py index 659d292e54..38a900b2b9 100644 --- a/hindsight-clients/python/hindsight_client_api/models/token_usage.py +++ b/hindsight-clients/python/hindsight_client_api/models/token_usage.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/tool_calls_include_options.py b/hindsight-clients/python/hindsight_client_api/models/tool_calls_include_options.py index cf053318fe..eb5a00fda8 100644 --- a/hindsight-clients/python/hindsight_client_api/models/tool_calls_include_options.py +++ b/hindsight-clients/python/hindsight_client_api/models/tool_calls_include_options.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/update_directive_request.py b/hindsight-clients/python/hindsight_client_api/models/update_directive_request.py index 3462ef39b2..498ae99969 100644 --- a/hindsight-clients/python/hindsight_client_api/models/update_directive_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/update_directive_request.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/update_disposition_request.py b/hindsight-clients/python/hindsight_client_api/models/update_disposition_request.py index f275cc5d61..72ae6ca289 100644 --- a/hindsight-clients/python/hindsight_client_api/models/update_disposition_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/update_disposition_request.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/update_document_request.py b/hindsight-clients/python/hindsight_client_api/models/update_document_request.py index b176b79f70..e396d71f58 100644 --- a/hindsight-clients/python/hindsight_client_api/models/update_document_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/update_document_request.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/update_document_response.py b/hindsight-clients/python/hindsight_client_api/models/update_document_response.py index 1bb68a9e3c..8a384cc21b 100644 --- a/hindsight-clients/python/hindsight_client_api/models/update_document_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/update_document_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/update_memory_request.py b/hindsight-clients/python/hindsight_client_api/models/update_memory_request.py new file mode 100644 index 0000000000..db3e54b15a --- /dev/null +++ b/hindsight-clients/python/hindsight_client_api/models/update_memory_request.py @@ -0,0 +1,141 @@ +# coding: utf-8 + +""" + Hindsight HTTP API + + HTTP API for Hindsight + + The version of the OpenAPI document: 0.8.3 + Generated by OpenAPI Generator (https://openapi-generator.tech) + + Do not edit the class manually. +""" # noqa: E501 + + +from __future__ import annotations +import pprint +import re # noqa: F401 +import json + +from pydantic import BaseModel, ConfigDict, StrictStr +from typing import Any, ClassVar, Dict, List, Optional +from typing import Optional, Set +from typing_extensions import Self + +class UpdateMemoryRequest(BaseModel): + """ + Request model for curating a single memory unit (edit / invalidate / revert). Provide ``text`` to correct the fact, and/or ``state`` to invalidate ('invalidated') or revert ('valid') it. ``reason`` is optional free text recorded on the memory. At least one of ``text`` or ``state`` must be set. Only world/experience facts can be curated; observations are derived. + """ # noqa: E501 + text: Optional[StrictStr] = None + context: Optional[StrictStr] = None + occurred_start: Optional[StrictStr] = None + occurred_end: Optional[StrictStr] = None + fact_type: Optional[StrictStr] = None + entities: Optional[List[StrictStr]] = None + state: Optional[StrictStr] = None + reason: Optional[StrictStr] = None + __properties: ClassVar[List[str]] = ["text", "context", "occurred_start", "occurred_end", "fact_type", "entities", "state", "reason"] + + model_config = ConfigDict( + populate_by_name=True, + validate_assignment=True, + protected_namespaces=(), + ) + + + def to_str(self) -> str: + """Returns the string representation of the model using alias""" + return pprint.pformat(self.model_dump(by_alias=True)) + + def to_json(self) -> str: + """Returns the JSON representation of the model using alias""" + # TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead + return json.dumps(self.to_dict()) + + @classmethod + def from_json(cls, json_str: str) -> Optional[Self]: + """Create an instance of UpdateMemoryRequest from a JSON string""" + return cls.from_dict(json.loads(json_str)) + + def to_dict(self) -> Dict[str, Any]: + """Return the dictionary representation of the model using alias. + + This has the following differences from calling pydantic's + `self.model_dump(by_alias=True)`: + + * `None` is only added to the output dict for nullable fields that + were set at model initialization. Other fields with value `None` + are ignored. + """ + excluded_fields: Set[str] = set([ + ]) + + _dict = self.model_dump( + by_alias=True, + exclude=excluded_fields, + exclude_none=True, + ) + # set to None if text (nullable) is None + # and model_fields_set contains the field + if self.text is None and "text" in self.model_fields_set: + _dict['text'] = None + + # set to None if context (nullable) is None + # and model_fields_set contains the field + if self.context is None and "context" in self.model_fields_set: + _dict['context'] = None + + # set to None if occurred_start (nullable) is None + # and model_fields_set contains the field + if self.occurred_start is None and "occurred_start" in self.model_fields_set: + _dict['occurred_start'] = None + + # set to None if occurred_end (nullable) is None + # and model_fields_set contains the field + if self.occurred_end is None and "occurred_end" in self.model_fields_set: + _dict['occurred_end'] = None + + # set to None if fact_type (nullable) is None + # and model_fields_set contains the field + if self.fact_type is None and "fact_type" in self.model_fields_set: + _dict['fact_type'] = None + + # set to None if entities (nullable) is None + # and model_fields_set contains the field + if self.entities is None and "entities" in self.model_fields_set: + _dict['entities'] = None + + # set to None if state (nullable) is None + # and model_fields_set contains the field + if self.state is None and "state" in self.model_fields_set: + _dict['state'] = None + + # set to None if reason (nullable) is None + # and model_fields_set contains the field + if self.reason is None and "reason" in self.model_fields_set: + _dict['reason'] = None + + return _dict + + @classmethod + def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: + """Create an instance of UpdateMemoryRequest from a dict""" + if obj is None: + return None + + if not isinstance(obj, dict): + return cls.model_validate(obj) + + _obj = cls.model_validate({ + "text": obj.get("text"), + "context": obj.get("context"), + "occurred_start": obj.get("occurred_start"), + "occurred_end": obj.get("occurred_end"), + "fact_type": obj.get("fact_type"), + "entities": obj.get("entities"), + "state": obj.get("state"), + "reason": obj.get("reason") + }) + return _obj + + diff --git a/hindsight-clients/python/hindsight_client_api/models/update_mental_model_request.py b/hindsight-clients/python/hindsight_client_api/models/update_mental_model_request.py index e152fc7eb4..0a570f5f1c 100644 --- a/hindsight-clients/python/hindsight_client_api/models/update_mental_model_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/update_mental_model_request.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/update_webhook_request.py b/hindsight-clients/python/hindsight_client_api/models/update_webhook_request.py index d10248df07..692ff9f948 100644 --- a/hindsight-clients/python/hindsight_client_api/models/update_webhook_request.py +++ b/hindsight-clients/python/hindsight_client_api/models/update_webhook_request.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/validation_error.py b/hindsight-clients/python/hindsight_client_api/models/validation_error.py index 342c2be779..d330c3a112 100644 --- a/hindsight-clients/python/hindsight_client_api/models/validation_error.py +++ b/hindsight-clients/python/hindsight_client_api/models/validation_error.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/validation_error_loc_inner.py b/hindsight-clients/python/hindsight_client_api/models/validation_error_loc_inner.py index 7b3c19f811..78ad51c918 100644 --- a/hindsight-clients/python/hindsight_client_api/models/validation_error_loc_inner.py +++ b/hindsight-clients/python/hindsight_client_api/models/validation_error_loc_inner.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/version_response.py b/hindsight-clients/python/hindsight_client_api/models/version_response.py index 3f90b65b96..a1dc0ed9d4 100644 --- a/hindsight-clients/python/hindsight_client_api/models/version_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/version_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/webhook_delivery_list_response.py b/hindsight-clients/python/hindsight_client_api/models/webhook_delivery_list_response.py index 88af27447d..e156ce5840 100644 --- a/hindsight-clients/python/hindsight_client_api/models/webhook_delivery_list_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/webhook_delivery_list_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/webhook_delivery_response.py b/hindsight-clients/python/hindsight_client_api/models/webhook_delivery_response.py index 5d328db354..014ed2987d 100644 --- a/hindsight-clients/python/hindsight_client_api/models/webhook_delivery_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/webhook_delivery_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/webhook_http_config.py b/hindsight-clients/python/hindsight_client_api/models/webhook_http_config.py index 74162d8936..62bd3cd207 100644 --- a/hindsight-clients/python/hindsight_client_api/models/webhook_http_config.py +++ b/hindsight-clients/python/hindsight_client_api/models/webhook_http_config.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/webhook_list_response.py b/hindsight-clients/python/hindsight_client_api/models/webhook_list_response.py index a28d060eb9..b278c068e6 100644 --- a/hindsight-clients/python/hindsight_client_api/models/webhook_list_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/webhook_list_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/models/webhook_response.py b/hindsight-clients/python/hindsight_client_api/models/webhook_response.py index 78cf322085..262353ae15 100644 --- a/hindsight-clients/python/hindsight_client_api/models/webhook_response.py +++ b/hindsight-clients/python/hindsight_client_api/models/webhook_response.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/hindsight_client_api/rest.py b/hindsight-clients/python/hindsight_client_api/rest.py index a2c6fd72b4..7923bec543 100644 --- a/hindsight-clients/python/hindsight_client_api/rest.py +++ b/hindsight-clients/python/hindsight_client_api/rest.py @@ -5,7 +5,7 @@ HTTP API for Hindsight - The version of the OpenAPI document: 0.7.2 + The version of the OpenAPI document: 0.8.3 Generated by OpenAPI Generator (https://openapi-generator.tech) Do not edit the class manually. diff --git a/hindsight-clients/python/pyproject.toml b/hindsight-clients/python/pyproject.toml index 27e3c029d2..b367bead89 100644 --- a/hindsight-clients/python/pyproject.toml +++ b/hindsight-clients/python/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "hindsight-client" -version = "0.7.2" +version = "0.8.3" description = "Python client for Hindsight - Semantic memory system with personality-driven thinking" authors = [ {name = "Hindsight Team"} diff --git a/hindsight-clients/python/tests/test_bank_config_update_payload.py b/hindsight-clients/python/tests/test_bank_config_update_payload.py new file mode 100644 index 0000000000..5ee5e63e48 --- /dev/null +++ b/hindsight-clients/python/tests/test_bank_config_update_payload.py @@ -0,0 +1,38 @@ +from hindsight_client import Hindsight + + +def test_update_bank_config_can_set_retain_structured_chunk_size(monkeypatch): + captured: dict[str, object] = {} + + async def fake_update(self, bank_id, updates): + captured["bank_id"] = bank_id + captured["updates"] = updates + return {"bank_id": bank_id, "config": {}, "overrides": updates} + + monkeypatch.setattr(Hindsight, "_aupdate_bank_config", fake_update) + + client = Hindsight(base_url="http://example.invalid") + result = client.update_bank_config( + "test-bank", + retain_structured_chunk_size=12000, + ) + + assert result["bank_id"] == "test-bank" + assert captured["updates"] == {"retain_structured_chunk_size": 12000} + + +def test_update_bank_config_omits_retain_structured_chunk_size_when_unset(monkeypatch): + captured: dict[str, object] = {} + + async def fake_update(self, bank_id, updates): + captured["bank_id"] = bank_id + captured["updates"] = updates + return {"bank_id": bank_id, "config": {}, "overrides": updates} + + monkeypatch.setattr(Hindsight, "_aupdate_bank_config", fake_update) + + client = Hindsight(base_url="http://example.invalid") + result = client.update_bank_config("test-bank") + + assert result["bank_id"] == "test-bank" + assert captured["updates"] == {} diff --git a/hindsight-clients/python/tests/test_version.py b/hindsight-clients/python/tests/test_version.py new file mode 100644 index 0000000000..346c34e74a --- /dev/null +++ b/hindsight-clients/python/tests/test_version.py @@ -0,0 +1,57 @@ +""" +Tests for the get_version()/aget_version() convenience wrappers. + +These mock the underlying MonitoringApi so no running server is required — +they verify the wrapper delegates to the generated client and returns the +typed VersionResponse (parity with the TypeScript client's getVersion helper). +""" + +from unittest.mock import AsyncMock + +from hindsight_client import Hindsight, VersionResponse +from hindsight_client_api.models.features_info import FeaturesInfo + + +def _make_client() -> Hindsight: + return Hindsight(base_url="http://localhost:8888") + + +def _version_response() -> VersionResponse: + features = FeaturesInfo( + observations=True, + mcp=True, + worker=True, + bank_config_api=True, + bank_llm_health=True, + file_upload_api=True, + document_export_api=True, + document_import_api=True, + audit_log=True, + llm_trace=True, + store_document_text=True, + ) + return VersionResponse(api_version="0.8.2", features=features) + + +async def test_aget_version_delegates_to_monitoring_api(): + """aget_version() should call MonitoringApi.get_version and return its result.""" + client = _make_client() + client._monitoring_api.get_version = AsyncMock(return_value=_version_response()) + + version = await client.aget_version() + + assert version.api_version == "0.8.2" + assert version.features.observations is True + client._monitoring_api.get_version.assert_awaited_once() + + +def test_get_version_delegates_to_monitoring_api(): + """get_version() (sync) should call MonitoringApi.get_version and return its result.""" + client = _make_client() + # AsyncMock returns an awaitable when called, which the sync wrapper awaits via _run_async. + client._monitoring_api.get_version = AsyncMock(return_value=_version_response()) + + version = client.get_version() + + assert version.api_version == "0.8.2" + client._monitoring_api.get_version.assert_called_once() diff --git a/hindsight-clients/typescript/README.md b/hindsight-clients/typescript/README.md index 001f037823..d4161846d4 100644 --- a/hindsight-clients/typescript/README.md +++ b/hindsight-clients/typescript/README.md @@ -74,6 +74,35 @@ const response = await client.reflect("my-bank", "What should I do this weekend? console.log(response.text); ``` +### `getVersion(options?)` + +Read the connected Hindsight API version and feature flags. This is useful for +integrations that need to enforce a minimum server version before enabling a +workflow. + +```typescript +const version = await client.getVersion(); + +const isAtLeast = (actual: string, minimum: string) => { + const actualParts = actual.split(".").map(Number); + const minimumParts = minimum.split(".").map(Number); + for (let i = 0; i < Math.max(actualParts.length, minimumParts.length); i++) { + const actualPart = actualParts[i] ?? 0; + const minimumPart = minimumParts[i] ?? 0; + if (actualPart !== minimumPart) return actualPart > minimumPart; + } + return true; +}; + +if (!isAtLeast(version.api_version, "0.8.2")) { + throw new Error(`Hindsight ${version.api_version} is too old for this integration`); +} + +if (version.features.observations) { + console.log("Observation consolidation is enabled."); +} +``` + ### `createBank(bankId, options)` Create or update a memory bank with personality. diff --git a/hindsight-clients/typescript/generated/sdk.gen.ts b/hindsight-clients/typescript/generated/sdk.gen.ts index 7f112836c4..6705f158c8 100644 --- a/hindsight-clients/typescript/generated/sdk.gen.ts +++ b/hindsight-clients/typescript/generated/sdk.gen.ts @@ -56,6 +56,9 @@ import type { DeleteWebhookData, DeleteWebhookErrors, DeleteWebhookResponses, + DryRunExtractMemoriesData, + DryRunExtractMemoriesErrors, + DryRunExtractMemoriesResponses, ExportBankTemplateData, ExportBankTemplateErrors, ExportBankTemplateResponses, @@ -149,6 +152,9 @@ import type { ListMentalModelsData, ListMentalModelsErrors, ListMentalModelsResponses, + ListObservationScopesData, + ListObservationScopesErrors, + ListObservationScopesResponses, ListOperationsData, ListOperationsErrors, ListOperationsResponses, @@ -193,6 +199,9 @@ import type { RetryOperationData, RetryOperationErrors, RetryOperationResponses, + TestBankLlmData, + TestBankLlmErrors, + TestBankLlmResponses, TriggerConsolidationData, TriggerConsolidationErrors, TriggerConsolidationResponses, @@ -211,6 +220,9 @@ import type { UpdateDocumentData, UpdateDocumentErrors, UpdateDocumentResponses, + UpdateMemoryData, + UpdateMemoryErrors, + UpdateMemoryResponses, UpdateMentalModelData, UpdateMentalModelErrors, UpdateMentalModelResponses, @@ -278,7 +290,7 @@ export const metricsEndpointMetricsGet = ( /** * Get memory graph data * - * Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). + * Retrieve graph data for visualization, optionally filtered by type (world/experience/observation). */ export const getGraph = ( options: Options @@ -301,6 +313,27 @@ export const listMemories = ( ...options, }); +/** + * Dry-run fact extraction (preview, no persistence) + * + * Preview what the retain step would extract from text WITHOUT changing the bank — no entity resolution, links, embeddings, or persistence. Returns the candidate facts and the LLM token usage. Every prompt-affecting setting (retain mission, extraction mode, chunk size, …) is overridable in the body to A/B a candidate config against the bank's current one. This is a read-only tool: nothing is stored. + */ +export const dryRunExtractMemories = ( + options: Options +) => + (options.client ?? client).post< + DryRunExtractMemoriesResponses, + DryRunExtractMemoriesErrors, + ThrowOnError + >({ + url: "/v1/default/banks/{bank_id}/memories/dry-run-extract", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + /** * Get memory unit * @@ -314,6 +347,23 @@ export const getMemory = ( ...options, }); +/** + * Curate memory unit + * + * Edit a memory's text and/or change its curation state (invalidate / revert). Invalidated memories are excluded from recall, consolidation, and graph maintenance but kept for audit (reversible). Only world/experience facts can be curated; observations are derived. + */ +export const updateMemory = ( + options: Options +) => + (options.client ?? client).patch({ + url: "/v1/default/banks/{bank_id}/memories/{memory_id}", + ...options, + headers: { + "Content-Type": "application/json", + ...options.headers, + }, + }); + /** * Get observation history * @@ -352,12 +402,12 @@ export const recallMemories = ( /** * Reflect and generate answer * - * Reflect and formulate an answer using bank identity, world facts, and opinions. + * Reflect and formulate an answer using bank identity, world facts, observations, and mental models. * * This endpoint: * 1. Retrieves experience (conversations and events) * 2. Retrieves world facts relevant to the query - * 3. Retrieves existing opinions (bank's perspectives) + * 3. Retrieves observations and mental models (bank's synthesized perspectives) * 4. Uses LLM to formulate a contextual answer * 5. Returns plain text answer and the facts used */ @@ -399,6 +449,19 @@ export const getAgentStats = ( ...options, }); +/** + * Test the bank's LLM connectivity + * + * Probe the LLMs this bank would use for retain / consolidation / reflect with one minimal call each (configs shared across operations are probed once), so you can discover 'not configured / unreachable' instead of a silent stall. Deliberate action (makes a real provider call); not for polling. Returns status only — never the provider, model, endpoint, API key, or raw error. Disable with HINDSIGHT_API_ENABLE_BANK_LLM_HEALTH=false. + */ +export const testBankLlm = ( + options: Options +) => + (options.client ?? client).post({ + url: "/v1/default/banks/{bank_id}/health/llm", + ...options, + }); + /** * Memory ingestion time-series * @@ -1036,6 +1099,20 @@ export const clearObservations = ( ThrowOnError >({ url: "/v1/default/banks/{bank_id}/observations", ...options }); +/** + * List observation scopes + * + * Enumerate the distinct scopes across a bank's observations. Each observation lives under a scope: the exact set of tags it was consolidated with. Returns every distinct scope (tag order normalized) with the number of observations in it; the empty tag list is the global/untagged scope. Use a returned scope with the graph endpoint (tags= & tags_match=exact) to filter observations to exactly that scope. + */ +export const listObservationScopes = ( + options: Options +) => + (options.client ?? client).get< + ListObservationScopesResponses, + ListObservationScopesErrors, + ThrowOnError + >({ url: "/v1/default/banks/{bank_id}/observations/scopes", ...options }); + /** * Recover failed consolidation * @@ -1207,7 +1284,7 @@ export const listWebhookDeliveries = ( /** * Clear memory bank memories * - * Delete memory units for a memory bank. Optionally filter by type (world, experience, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved. + * Delete memory units for a memory bank. Optionally filter by type (world, experience, observation) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved. */ export const clearBankMemories = ( options: Options @@ -1266,7 +1343,7 @@ export const retainMemories = ( * This endpoint handles file upload, conversion, and memory creation in a single operation. * * **Features:** - * - Supports PDF, DOCX, PPTX, XLSX, images (with OCR), audio (with transcription) + * - Supports PDF, DOCX, PPTX, XLSX, images (parser-dependent OCR), audio (with transcription) * - Automatic file-to-markdown conversion using pluggable parsers * - Files stored in object storage (PostgreSQL by default, S3 for production) * - Each file becomes a separate document with optional metadata/tags diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts index bf085e62c5..4f20fb2903 100644 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ b/hindsight-clients/typescript/generated/types.gen.ts @@ -288,6 +288,28 @@ export type BankListResponse = { banks: Array; }; +/** + * BankLlmHealthResponse + * + * Per-bank LLM connectivity probe across retain/consolidation/reflect. Operations + * that share a configuration are probed once. Discloses status only — never the + * provider, model, endpoint, API key, or raw error. + */ +export type BankLlmHealthResponse = { + /** + * Bank Id + * + * Bank identifier + */ + bank_id: string; + /** + * Operations + * + * Connectivity status per operation (retain, consolidation, reflect) + */ + operations: Array; +}; + /** * BankProfileResponse * @@ -443,9 +465,15 @@ export type BankTemplateConfig = { /** * Retain Chunk Size * - * Max token size for each content chunk + * Target max characters for each content chunk */ retain_chunk_size?: number | null; + /** + * Retain Structured Chunk Size + * + * Max characters for a single JSONL line or conversation turn to keep whole; defaults to retain_chunk_size when unset + */ + retain_structured_chunk_size?: number | null; /** * Enable Observations * @@ -540,6 +568,14 @@ export type BankTemplateConfig = { * Max observations to retain per consolidation scope */ max_observations_per_scope?: number | null; + /** + * Observation Scope Limits + * + * Per-scope overrides of max_observations_per_scope: [{"scope": ["run_*", "shared"], "limit": 1}]. Each scope is a list of fnmatch tag-globs; a consolidation scope matches under exact cover (every tag matched by a glob and every glob matched by a tag). The first matching rule wins; unmatched scopes fall back to max_observations_per_scope. + */ + observation_scope_limits?: Array<{ + [key: string]: unknown; + }> | null; /** * Reflect Source Facts Max Tokens * @@ -1056,9 +1092,15 @@ export type CreateBankRequest = { /** * Retain Chunk Size * - * Maximum token size for each content chunk during retain. + * Target maximum characters for each content chunk during retain. */ retain_chunk_size?: number | null; + /** + * Retain Structured Chunk Size + * + * Maximum characters for a single JSONL line or conversation turn to keep whole during retain. Defaults to retain_chunk_size when unset. + */ + retain_structured_chunk_size?: number | null; /** * Enable Observations * @@ -1194,7 +1236,7 @@ export type CreateWebhookRequest = { /** * Event Types * - * List of event types to deliver. Currently supported: 'consolidation.completed' + * List of event types to deliver. Supported: 'retain.completed', 'consolidation.completed', 'memory_defense.triggered'. */ event_types?: Array; /** @@ -1372,7 +1414,7 @@ export type DocumentResponse = { /** * Original Text */ - original_text: string; + original_text: string | null; /** * Content Hash */ @@ -1419,6 +1461,98 @@ export type DocumentResponse = { retain_params?: { [key: string]: unknown; } | null; + /** + * Observation Scopes + * + * The observation_scopes spec configured at retain time (e.g. 'all_combinations', 'per_tag', or explicit tag-set lists), captured into retain_params. None when none was set (default 'combined' scoping) or for documents retained before this was captured. + */ + observation_scopes?: string | Array> | null; +}; + +/** + * DryRunExtractRequest + * + * Request to run fact extraction ONLY (no resolution/links/embeddings/persistence). + * + * Every field below the content/context/date is a prompt-affecting override applied just for this + * call — used to preview what a candidate retain mission (or any extraction setting) would extract, + * without changing the bank. Unset (null) fields fall back to the bank's resolved config. + */ +export type DryRunExtractRequest = { + /** + * Content + * + * Text to extract facts from (e.g. a document or a single chunk). + */ + content: string; + /** + * Context + * + * Optional context about the content. + */ + context?: string; + /** + * Timestamp + * + * Reference timestamp for resolving relative times (ISO 8601). + */ + timestamp?: string | null; + /** + * Agent Name + * + * Narrator override (memory owner) primed in the prompt. + */ + agent_name?: string | null; + /** + * Retain Mission + */ + retain_mission?: string | null; + /** + * Retain Extraction Mode + */ + retain_extraction_mode?: string | null; + /** + * Retain Custom Instructions + */ + retain_custom_instructions?: string | null; + /** + * Retain Extract Causal Links + */ + retain_extract_causal_links?: boolean | null; + /** + * Retain Chunk Size + */ + retain_chunk_size?: number | null; + /** + * Entity Labels + */ + entity_labels?: Array | null; + /** + * Entities Allow Free Form + */ + entities_allow_free_form?: boolean | null; + /** + * Llm Output Language + */ + llm_output_language?: string | null; +}; + +/** + * DryRunExtractionResult + * + * Result of dry-run fact extraction: candidate facts plus aggregated LLM token usage. + */ +export type DryRunExtractionResult = { + /** + * Facts + * + * Candidate facts the retain step would extract. + */ + facts?: Array; + /** + * Aggregated token usage across the extraction LLM calls. + */ + usage?: TokenUsage; }; /** @@ -1619,6 +1753,48 @@ export type EntityStateResponse = { observations: Array; }; +/** + * ExtractedFact + * + * A single candidate fact produced by dry-run extraction (no resolution/links/persistence). + * + * A deliberate subset of the persisted memory-unit shape — only the fields a fresh extraction + * yields. Storage/consolidation/curation fields (id, document_id, chunk_id, proof_count, state, …) + * are omitted because nothing is stored. Entities are raw, unresolved names. + */ +export type ExtractedFact = { + /** + * Text + * + * The extracted fact text. + */ + text: string; + /** + * Fact Type + * + * Perspective classification: 'world' or 'experience'. + */ + fact_type: string; + /** + * Occurred Start + * + * ISO timestamp the fact's event started, if dated. + */ + occurred_start?: string | null; + /** + * Occurred End + * + * ISO timestamp the fact's event ended, if dated. + */ + occurred_end?: string | null; + /** + * Entities + * + * Raw (unresolved) entity names mentioned in the fact. + */ + entities?: Array; +}; + /** * FactsIncludeOptions * @@ -1658,6 +1834,12 @@ export type FeaturesInfo = { * Whether per-bank configuration API is enabled */ bank_config_api: boolean; + /** + * Bank Llm Health + * + * Whether the per-bank LLM connectivity probe is enabled + */ + bank_llm_health: boolean; /** * File Upload Api * @@ -1688,6 +1870,12 @@ export type FeaturesInfo = { * Whether per-bank LLM request tracing is enabled */ llm_trace: boolean; + /** + * Store Document Text + * + * Whether raw source text is persisted. When false, document/chunk source text is not stored. + */ + store_document_text: boolean; }; /** @@ -2071,6 +2259,39 @@ export type ListTagsResponse = { offset: number; }; +/** + * LlmOperationHealth + * + * LLM connectivity status for a single operation. Status only — no provider/model/ + * endpoint/error, so the probe never discloses the LLM configuration. + */ +export type LlmOperationHealth = { + /** + * LlmHealthOperation + * + * Operation whose LLM was probed + */ + operation: "retain" | "consolidation" | "reflect"; + /** + * Ok + * + * True only when the probe connected successfully + */ + ok: boolean; + /** + * LlmHealthStatus + * + * 'connected'; 'not_configured' (provider is 'none'); 'auth_failed' (rejected — usually a wrong/expired API key); 'unreachable' (call failed); 'timeout' + */ + status: "connected" | "not_configured" | "auth_failed" | "unreachable" | "timeout"; + /** + * Latency Ms + * + * Round-trip latency of the probe call + */ + latency_ms?: number | null; +}; + /** * MemoriesTimeseriesResponse * @@ -2154,9 +2375,15 @@ export type MemoryItem = { /** * ObservationScopes * - * How to scope observations during consolidation. 'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. 'combined' (default) runs a single pass with all tags together. A list of tag lists runs one pass per inner list, giving full control over which combinations to use. + * How to scope observations during consolidation. 'per_tag' runs one consolidation pass per individual tag, creating separate observations for each tag. 'combined' (default) runs a single pass with all tags together. 'shared' runs a single pass over one global, untagged scope, so memories consolidate together regardless of their tags — useful for deduplicating across volatile per-call provenance tags (e.g. per-session ids) while keeping those tags on the source facts. A list of tag lists runs one pass per inner list, giving full control over which combinations to use. */ - observation_scopes?: "per_tag" | "combined" | "all_combinations" | Array> | null; + observation_scopes?: + | "per_tag" + | "combined" + | "all_combinations" + | "shared" + | Array> + | null; /** * Strategy * @@ -2317,7 +2544,7 @@ export type MentalModelTriggerInput = { * * Override how the model's tags filter memories during refresh. If not set, defaults to 'all_strict' when the model has tags (security isolation) or 'any' when the model has no tags. Set to 'any' to include untagged memories alongside tagged ones during refresh. */ - tags_match?: "any" | "all" | "any_strict" | "all_strict" | null; + tags_match?: "any" | "all" | "any_strict" | "all_strict" | "exact" | null; /** * Tag Groups * @@ -2385,7 +2612,7 @@ export type MentalModelTriggerOutput = { * * Override how the model's tags filter memories during refresh. If not set, defaults to 'all_strict' when the model has tags (security isolation) or 'any' when the model has no tags. Set to 'any' to include untagged memories alongside tagged ones during refresh. */ - tags_match?: "any" | "all" | "any_strict" | "all_strict" | null; + tags_match?: "any" | "all" | "any_strict" | "all_strict" | "exact" | null; /** * Tag Groups * @@ -2414,6 +2641,40 @@ export type MentalModelTriggerOutput = { recall_chunks_max_tokens?: number | null; }; +/** + * ObservationScope + * + * A distinct observation scope: an exact tag set plus its observation count. + */ +export type ObservationScope = { + /** + * Tags + * + * The exact tag set defining this scope (normalized order). Empty list is the global/untagged scope. + */ + tags: Array; + /** + * Count + * + * Number of observations that live under this scope + */ + count: number; +}; + +/** + * ObservationScopesResponse + * + * Response model for the observation scopes enumeration endpoint. + */ +export type ObservationScopesResponse = { + /** + * Scopes + * + * Distinct observation scopes, most populous first + */ + scopes: Array; +}; + /** * OperationProgress * @@ -2666,7 +2927,7 @@ export type RecallRequest = { * * How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged). */ - tags_match?: "any" | "all" | "any_strict" | "all_strict"; + tags_match?: "any" | "all" | "any_strict" | "all_strict" | "exact"; /** * Tag Groups * @@ -2987,7 +3248,7 @@ export type ReflectRequest = { * * How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged). */ - tags_match?: "any" | "all" | "any_strict" | "all_strict"; + tags_match?: "any" | "all" | "any_strict" | "all_strict" | "exact"; /** * Tag Groups * @@ -3275,7 +3536,7 @@ export type TagGroupLeaf = { /** * Match */ - match?: "any" | "all" | "any_strict" | "all_strict"; + match?: "any" | "all" | "any_strict" | "all_strict" | "exact"; }; /** @@ -3468,6 +3729,67 @@ export type UpdateDocumentResponse = { success?: boolean; }; +/** + * UpdateMemoryRequest + * + * Request model for curating a single memory unit (edit / invalidate / revert). + * + * Provide ``text`` to correct the fact, and/or ``state`` to invalidate + * ('invalidated') or revert ('valid') it. ``reason`` is optional free text + * recorded on the memory. At least one of ``text`` or ``state`` must be set. + * Only world/experience facts can be curated; observations are derived. + */ +export type UpdateMemoryRequest = { + /** + * Text + * + * New fact text. Re-embeds the memory, drops its derived observations and links, and triggers re-consolidation. + */ + text?: string | null; + /** + * Context + * + * New context for the fact. '' clears it; omit to leave unchanged. + */ + context?: string | null; + /** + * Occurred Start + * + * New occurred-range start (ISO 8601). '' clears it; omit to leave unchanged. + */ + occurred_start?: string | null; + /** + * Occurred End + * + * New occurred-range end (ISO 8601). '' clears it; omit to leave unchanged. + */ + occurred_end?: string | null; + /** + * Fact Type + * + * Reclassify the fact: 'world' or 'experience'. Omit to leave unchanged. + */ + fact_type?: string | null; + /** + * Entities + * + * Replace the fact's entities. Names are resolved/find-or-created the same way retain does; '[]' detaches all entities. Omit to leave unchanged. + */ + entities?: Array | null; + /** + * State + * + * Curation state: 'invalidated' to soft-retire the memory (excluded from recall/consolidation, links and derived observations pruned, moved to the archive) or 'valid' to revert. Reversible. + */ + state?: string | null; + /** + * Reason + * + * Optional free-text reason recorded when invalidating. + */ + reason?: string | null; +}; + /** * UpdateMentalModelRequest * @@ -3889,6 +4211,14 @@ export type ListMemoriesData = { * Consolidation State */ consolidation_state?: string | null; + /** + * State + */ + state?: string | null; + /** + * Document Id + */ + document_id?: string | null; /** * Limit */ @@ -3919,6 +4249,44 @@ export type ListMemoriesResponses = { export type ListMemoriesResponse = ListMemoriesResponses[keyof ListMemoriesResponses]; +export type DryRunExtractMemoriesData = { + body: DryRunExtractRequest; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/memories/dry-run-extract"; +}; + +export type DryRunExtractMemoriesErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type DryRunExtractMemoriesError = + DryRunExtractMemoriesErrors[keyof DryRunExtractMemoriesErrors]; + +export type DryRunExtractMemoriesResponses = { + /** + * Successful Response + */ + 200: DryRunExtractionResult; +}; + +export type DryRunExtractMemoriesResponse = + DryRunExtractMemoriesResponses[keyof DryRunExtractMemoriesResponses]; + export type GetMemoryData = { body?: never; headers?: { @@ -3957,6 +4325,44 @@ export type GetMemoryResponses = { 200: unknown; }; +export type UpdateMemoryData = { + body: UpdateMemoryRequest; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + /** + * Memory Id + */ + memory_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/memories/{memory_id}"; +}; + +export type UpdateMemoryErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type UpdateMemoryError = UpdateMemoryErrors[keyof UpdateMemoryErrors]; + +export type UpdateMemoryResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + export type GetObservationHistoryData = { body?: never; headers?: { @@ -4135,6 +4541,42 @@ export type GetAgentStatsResponses = { export type GetAgentStatsResponse = GetAgentStatsResponses[keyof GetAgentStatsResponses]; +export type TestBankLlmData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/health/llm"; +}; + +export type TestBankLlmErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type TestBankLlmError = TestBankLlmErrors[keyof TestBankLlmErrors]; + +export type TestBankLlmResponses = { + /** + * Successful Response + */ + 200: BankLlmHealthResponse; +}; + +export type TestBankLlmResponse = TestBankLlmResponses[keyof TestBankLlmResponses]; + export type GetMemoriesTimeseriesData = { body?: never; headers?: { @@ -5939,6 +6381,44 @@ export type ClearObservationsResponses = { export type ClearObservationsResponse = ClearObservationsResponses[keyof ClearObservationsResponses]; +export type ListObservationScopesData = { + body?: never; + headers?: { + /** + * Authorization + */ + authorization?: string | null; + }; + path: { + /** + * Bank Id + */ + bank_id: string; + }; + query?: never; + url: "/v1/default/banks/{bank_id}/observations/scopes"; +}; + +export type ListObservationScopesErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type ListObservationScopesError = + ListObservationScopesErrors[keyof ListObservationScopesErrors]; + +export type ListObservationScopesResponses = { + /** + * Successful Response + */ + 200: ObservationScopesResponse; +}; + +export type ListObservationScopesResponse = + ListObservationScopesResponses[keyof ListObservationScopesResponses]; + export type RecoverConsolidationData = { body?: never; headers?: { @@ -6393,7 +6873,7 @@ export type ClearBankMemoriesData = { /** * Type * - * Optional fact type filter (world, experience, opinion) + * Optional fact type filter (world, experience, observation) */ type?: string | null; }; diff --git a/hindsight-clients/typescript/package.json b/hindsight-clients/typescript/package.json index bfa59bdbfd..99b0bb92f0 100644 --- a/hindsight-clients/typescript/package.json +++ b/hindsight-clients/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@vectorize-io/hindsight-client", - "version": "0.7.2", + "version": "0.8.3", "description": "TypeScript client for Hindsight - Semantic memory system with personality-driven thinking", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/hindsight-clients/typescript/src/index.ts b/hindsight-clients/typescript/src/index.ts index 1ab44fe850..e2f5a3141c 100644 --- a/hindsight-clients/typescript/src/index.ts +++ b/hindsight-clients/typescript/src/index.ts @@ -60,6 +60,7 @@ import type { MentalModelListResponse, MentalModelResponse, UpdateDocumentResponse, + VersionResponse, } from "../generated/types.gen"; // __CLIENT_VERSION__ is replaced by tsup's `define` with package.json's version @@ -114,7 +115,7 @@ export interface MemoryItemInput { document_id?: string; entities?: EntityInput[]; tags?: string[]; - observation_scopes?: "per_tag" | "combined" | "all_combinations" | string[][]; + observation_scopes?: "per_tag" | "combined" | "all_combinations" | "shared" | string[][]; strategy?: string; update_mode?: "replace" | "append"; } @@ -137,6 +138,18 @@ export class HindsightClient { ); } + /** + * Get API version and feature flags for the connected Hindsight deployment. + */ + async getVersion(options?: { signal?: AbortSignal }): Promise { + const response = await sdk.getVersion({ + client: this.client, + signal: options?.signal, + }); + + return this.validateResponse(response, "getVersion"); + } + /** * Validates the API response and throws an error if the request failed. */ @@ -180,8 +193,8 @@ export class HindsightClient { tags?: string[]; /** How to handle existing documents: 'replace' (default) or 'append' */ updateMode?: "replace" | "append"; - /** Observation scoping strategy: 'per_tag', 'combined', 'all_combinations', or explicit scope groups */ - observationScopes?: "per_tag" | "combined" | "all_combinations" | string[][]; + /** Observation scoping strategy: 'per_tag', 'combined', 'all_combinations', 'shared', or explicit scope groups */ + observationScopes?: "per_tag" | "combined" | "all_combinations" | "shared" | string[][]; /** Extraction strategy override */ strategy?: string; signal?: AbortSignal; @@ -318,8 +331,8 @@ export class HindsightClient { maxSourceFactsTokens?: number; /** Optional list of tags to filter memories by */ tags?: string[]; - /** How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged). Default: 'any' */ - tagsMatch?: "any" | "all" | "any_strict" | "all_strict"; + /** How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged), 'exact' (set equality, excludes untagged). Default: 'any' */ + tagsMatch?: "any" | "all" | "any_strict" | "all_strict" | "exact"; /** Compound tag filter using boolean groups. Groups are AND-ed. Each group is a leaf {tags, match} or compound {and: [...]}, {or: [...]}, {not: ...}. Mutually exclusive with tags/tagsMatch. */ tagGroups?: Array; signal?: AbortSignal; @@ -370,8 +383,8 @@ export class HindsightClient { budget?: Budget; /** Optional list of tags to filter memories by */ tags?: string[]; - /** How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged). Default: 'any' */ - tagsMatch?: "any" | "all" | "any_strict" | "all_strict"; + /** How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged), 'exact' (set equality, excludes untagged). Default: 'any' */ + tagsMatch?: "any" | "all" | "any_strict" | "all_strict" | "exact"; /** Compound tag filter using boolean groups. Groups are AND-ed. Mutually exclusive with tags/tagsMatch. */ tagGroups?: Array; /** Optional JSON Schema for structured output. When provided, the response includes a 'structured_output' field. */ @@ -433,6 +446,8 @@ export class HindsightClient { type?: string; q?: string; consolidationState?: "failed" | "pending" | "done"; + state?: "valid" | "invalidated"; + documentId?: string; signal?: AbortSignal; } ): Promise { @@ -445,6 +460,8 @@ export class HindsightClient { type: options?.type, q: options?.q, consolidation_state: options?.consolidationState, + state: options?.state, + document_id: options?.documentId, }, signal: options?.signal, }); @@ -480,8 +497,10 @@ export class HindsightClient { retainExtractionMode?: string; /** Custom extraction prompt (only active when retainExtractionMode is 'custom'). */ retainCustomInstructions?: string; - /** Maximum token size for each content chunk during retain. */ + /** Target maximum characters for each content chunk during retain. */ retainChunkSize?: number; + /** Maximum characters for a single JSONL line or conversation turn to keep whole during retain. */ + retainStructuredChunkSize?: number; /** Toggle automatic observation consolidation after retain(). */ enableObservations?: boolean; /** Controls what gets synthesised into observations. Replaces built-in rules. */ @@ -505,6 +524,7 @@ export class HindsightClient { retain_extraction_mode: options.retainExtractionMode, retain_custom_instructions: options.retainCustomInstructions, retain_chunk_size: options.retainChunkSize, + retain_structured_chunk_size: options.retainStructuredChunkSize, enable_observations: options.enableObservations, observations_mission: options.observationsMission, }, @@ -576,6 +596,7 @@ export class HindsightClient { retainExtractionMode?: string; retainCustomInstructions?: string; retainChunkSize?: number; + retainStructuredChunkSize?: number; enableObservations?: boolean; observationsMission?: string; /** How skeptical vs trusting (1=trusting, 5=skeptical). */ @@ -595,6 +616,8 @@ export class HindsightClient { if (options.retainCustomInstructions !== undefined) updates.retain_custom_instructions = options.retainCustomInstructions; if (options.retainChunkSize !== undefined) updates.retain_chunk_size = options.retainChunkSize; + if (options.retainStructuredChunkSize !== undefined) + updates.retain_structured_chunk_size = options.retainStructuredChunkSize; if (options.enableObservations !== undefined) updates.enable_observations = options.enableObservations; if (options.observationsMission !== undefined) @@ -1088,6 +1111,7 @@ export type { MentalModelListResponse, MentalModelResponse, UpdateDocumentResponse, + VersionResponse, }; // Also export low-level SDK functions for advanced usage diff --git a/hindsight-clients/typescript/tests/version.test.ts b/hindsight-clients/typescript/tests/version.test.ts new file mode 100644 index 0000000000..df01a1f54e --- /dev/null +++ b/hindsight-clients/typescript/tests/version.test.ts @@ -0,0 +1,42 @@ +import { HindsightClient } from "../src"; +import * as sdk from "../generated/sdk.gen"; + +function makeClient(): HindsightClient { + return new HindsightClient({ baseUrl: "http://localhost:8888" }); +} + +describe("getVersion", () => { + let spy: jest.SpyInstance; + + beforeEach(() => { + spy = jest.spyOn(sdk, "getVersion").mockResolvedValue({ + data: { + api_version: "0.8.2", + features: { + observations: true, + mcp: true, + document_upload: true, + bank_config: true, + directives: true, + metrics: true, + custom_llm_provider: true, + bank_llm_health: true, + file_conversion: true, + }, + }, + } as any); + }); + + afterEach(() => { + spy.mockRestore(); + }); + + test("calls the version endpoint through the generated client", async () => { + const version = await makeClient().getVersion(); + + expect(version.api_version).toBe("0.8.2"); + expect(version.features.observations).toBe(true); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0]).toHaveProperty("client"); + }); +}); diff --git a/hindsight-control-plane/knip.json b/hindsight-control-plane/knip.json new file mode 100644 index 0000000000..f4c7987870 --- /dev/null +++ b/hindsight-control-plane/knip.json @@ -0,0 +1,24 @@ +{ + "$schema": "https://unpkg.com/knip@5/schema.json", + "entry": [ + "src/app/**/{page,layout,route,loading,error,not-found,template,default,global-error,sitemap,robots,manifest,opengraph-image,icon}.{ts,tsx}", + "next.config.{js,mjs,ts}", + "tailwind.config.{js,ts}", + "postcss.config.{js,mjs}" + ], + "project": ["src/**/*.{ts,tsx}"], + "ignoreDependencies": [ + "eslint", + "eslint-config-next", + "@eslint/eslintrc", + "autoprefixer", + "tailwindcss-animate", + "postcss-load-config", + "react-is", + "react-dom", + "@types/react-dom", + "@vectorize-io/hindsight-client", + "prettier", + "tsx" + ] +} diff --git a/hindsight-control-plane/package.json b/hindsight-control-plane/package.json index 052444faaf..97cd2ba46d 100644 --- a/hindsight-control-plane/package.json +++ b/hindsight-control-plane/package.json @@ -1,6 +1,6 @@ { "name": "@vectorize-io/hindsight-control-plane", - "version": "0.7.2", + "version": "0.8.3", "description": "Control plane for Hindsight - Semantic memory system", "bin": { "hindsight-control-plane": "./bin/cli.js" @@ -37,13 +37,12 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-popover": "^1.1.15", - "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-slider": "^1.3.6", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", - "@radix-ui/react-tooltip": "^1.2.8", + "@radix-ui/react-visually-hidden": "^1.2.5", "@tailwindcss/postcss": "^4.1.17", "@tailwindcss/typography": "^0.5.19", "@types/cytoscape": "^3.21.9", @@ -64,7 +63,6 @@ "next-themes": "^0.4.6", "postcss": "^8.5.6", "react": "^19.2.0", - "react-chrono": "^2.9.1", "react-dom": "^19.2.0", "react-is": "^19.2.4", "react-markdown": "^10.1.0", @@ -75,7 +73,6 @@ "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.17", "tailwindcss-animate": "^1.0.7", - "three": "^0.182.0", "typescript": "^5.9.3" }, "devDependencies": { diff --git a/hindsight-control-plane/src/app/[locale]/banks/[bankId]/page.tsx b/hindsight-control-plane/src/app/[locale]/banks/[bankId]/page.tsx index 19d72ef003..faad0a8ae4 100644 --- a/hindsight-control-plane/src/app/[locale]/banks/[bankId]/page.tsx +++ b/hindsight-control-plane/src/app/[locale]/banks/[bankId]/page.tsx @@ -13,6 +13,7 @@ import { ThinkView } from "@/components/think-view"; import { SearchDebugView } from "@/components/search-debug-view"; import { BankProfileView } from "@/components/bank-profile-view"; import { BankConfigView } from "@/components/bank-config-view"; +import { MemoryDefenseSection } from "@/components/memory-defense-section"; import { BankStatsView } from "@/components/bank-stats-view"; import { BankOperationsView } from "@/components/bank-operations-view"; import { MentalModelsView } from "@/components/mental-models-view"; @@ -42,11 +43,29 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@/components/ui/alert-dialog"; -import { Brain, Download, Trash2, Loader2, MoreVertical, Pencil, RotateCcw } from "lucide-react"; +import { + Brain, + Download, + Trash2, + Loader2, + MoreVertical, + Pencil, + RotateCcw, + Activity, + FlaskConical, +} from "lucide-react"; +import { LlmHealthDialog } from "@/components/llm-health-dialog"; +import { ExtractDialog } from "@/components/extract-dialog"; type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile"; type DataSubTab = "world" | "experience" | "observations" | "mental-models"; -type BankConfigTab = "general" | "configuration" | "webhooks" | "audit-logs" | "llm-requests"; +type BankConfigTab = + | "general" + | "memory-defense" + | "configuration" + | "webhooks" + | "audit-logs" + | "llm-requests"; export default function BankPage() { const params = useParams(); @@ -64,8 +83,11 @@ export default function BankPage() { const bankConfigEnabled = features?.bank_config_api ?? false; const auditLogEnabled = features?.audit_log ?? false; const llmTraceEnabled = features?.llm_trace ?? false; + const llmHealthEnabled = features?.bank_llm_health ?? false; // Bank actions state + const [showLlmHealthDialog, setShowLlmHealthDialog] = useState(false); + const [showExtractDialog, setShowExtractDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [isDeleting, setIsDeleting] = useState(false); const [showClearObservationsDialog, setShowClearObservationsDialog] = useState(false); @@ -207,6 +229,16 @@ export default function BankPage() { {t("exportTemplate")} + setShowExtractDialog(true)}> + + {t("dryRunExtraction")} + + {llmHealthEnabled && ( + setShowLlmHealthDialog(true)}> + + {t("health")} + + )} )} + {bankConfigEnabled && ( + + )} {bankConfigEnabled && ( {bank.fact_count > 0 ? ( <> @@ -1172,7 +1200,12 @@ function BankSelectorInner() { value={docObservationScopes} onValueChange={(v) => setDocObservationScopes( - v as "per_tag" | "combined" | "all_combinations" | "custom" + v as + | "per_tag" + | "combined" + | "all_combinations" + | "custom" + | "shared" ) } > @@ -1187,10 +1220,21 @@ function BankSelectorInner() { {tAddDocument("observationScopeAllCombinations")} - Custom + + {tAddDocument("observationScopeShared")} + + + {tAddDocument("observationScopeCustom")} + + {docObservationScopes === "shared" && ( +

+ {tAddDocument("observationScopeSharedHelp")} +

+ )} {docObservationScopes !== "custom" && + docObservationScopes !== "shared" && (() => { const tags = docTags .split(",") @@ -1276,6 +1320,13 @@ function BankSelectorInner() { )} + {features?.store_document_text === false && ( +
+ + {tAddDocument("textNotStoredWarning")} +
+ )} + + {t("exportSvgLabel")} + + + {/* Fullscreen toggle */} + + {/* Tooltip */}
([]); + // Observation scope filtering: the distinct scopes available, and the selected + // one. `null` = all scopes; `[]` = the global (untagged) scope; otherwise an + // exact tag set. Mutually exclusive with the free-form tag filter above. + const [scopes, setScopes] = useState([]); + const [selectedScope, setSelectedScope] = useState(null); const [currentPage, setCurrentPage] = useState(1); const [selectedGraphNode, setSelectedGraphNode] = useState(null); const [modalMemoryId, setModalMemoryId] = useState(null); + // Table view: toggle between live facts (graph-fed) and invalidated facts (archive). + const [showInvalidated, setShowInvalidated] = useState(false); + const [invalidatedRows, setInvalidatedRows] = useState([]); const itemsPerPage = 100; // Fetch limit state - how many memories to load from the API @@ -92,6 +123,8 @@ export function DataView({ occurred_end: t("recencyBasisOccurredEnd"), }; const [recencyBasis, setRecencyBasis] = useState("mentioned_at"); + // Constellation: group observations into per-scope clusters (with colored blobs). + const [groupByScope, setGroupByScope] = useState(false); // Consolidation status for mental models const [consolidationStatus, setConsolidationStatus] = useState<{ @@ -130,10 +163,18 @@ export function DataView({ return () => window.removeEventListener("keydown", handleKeyDown); }, [selectedGraphNode]); - const loadData = async (limit?: number, q?: string, tags?: string[]) => { + // `silent` skips the loading spinner — used by the background consolidation + // poll so the view refreshes in place without flashing. + const loadData = async ( + limit?: number, + q?: string, + tags?: string[], + tagsMatch?: string, + silent = false + ) => { if (!currentBank) return; - setLoading(true); + if (!silent) setLoading(true); try { const graphData: any = await client.getGraph({ bank_id: currentBank, @@ -141,6 +182,7 @@ export function DataView({ limit: limit ?? fetchLimit, q, tags, + tags_match: tagsMatch, document_id: documentId, chunk_id: chunkId, }); @@ -157,14 +199,37 @@ export function DataView({ } catch (error) { // Error toast is shown automatically by the API client interceptor } finally { - setLoading(false); + if (!silent) setLoading(false); } }; - // Table rows are already filtered server-side + // Invalidated facts live in a separate archive, not the graph — fetch them via list. + const loadInvalidated = useCallback(async () => { + if (!currentBank) return; + try { + const resp: any = await client.listMemories(currentBank, { + state: "invalidated", + type: factType, + limit: fetchLimit, + }); + setInvalidatedRows(resp?.items ?? []); + } catch { + setInvalidatedRows([]); + } + }, [currentBank, factType, fetchLimit]); + + useEffect(() => { + if (showInvalidated && viewMode === "table") { + loadInvalidated(); + } + }, [showInvalidated, viewMode, loadInvalidated]); + + // Table rows: live rows are graph-fed (filtered server-side); invalidated rows + // come from the archive via list. const filteredTableRows = useMemo(() => { + if (showInvalidated) return invalidatedRows; return data?.table_rows ?? []; - }, [data]); + }, [data, showInvalidated, invalidatedRows]); // Helper to get normalized link type const getLinkTypeCategory = (type: string | undefined): string => { @@ -284,6 +349,39 @@ export function DataView({ [recencyLookup] ); + // Assign each distinct observation scope (exact tag set) a stable color from + // the palette, in order of first appearance, for the "Group by scope" clusters. + const scopeColorLookup = useMemo(() => { + if (factType !== "observation" || !data?.table_rows) return null; + const map = new Map(); + let i = 0; + for (const row of data.table_rows as Array<{ tags?: string[] }>) { + const key = scopeKeyOf(row.tags); + if (!map.has(key)) map.set(key, SCOPE_PALETTE[i++ % SCOPE_PALETTE.length]); + } + return map; + }, [factType, data]); + + const scopeClusterKeyFn = useCallback( + (node: GraphNode) => scopeKeyOf(node.metadata?.tags as string[] | undefined), + [] + ); + const scopeClusterColorFn = useCallback( + (key: string) => scopeColorLookup?.get(key) || "#0074d9", + [scopeColorLookup] + ); + const scopeClusterLabelFn = useCallback( + (key: string) => { + try { + const tags = JSON.parse(key) as string[]; + return tags.length ? tags.map((tag) => `#${tag}`).join(" ") : t("scopeGlobal"); + } catch { + return key; + } + }, + [t] + ); + const observationNodeSizeFn = useCallback( (node: GraphNode) => { if (!observationSizeLookup) return 3; @@ -311,29 +409,105 @@ export function DataView({ // Reset to first page when filters change useEffect(() => { setCurrentPage(1); - }, [tagFilters]); + }, [tagFilters, selectedScope]); + + // Resolve the active tag filter into (tags, tags_match) for the graph query. + // A selected observation scope takes precedence and uses exact set-equality + // matching (so scope [a] excludes [a, b]); otherwise the free-form tag filter + // uses the default contains semantics. `null` scope means "no scope filter". + const resolveTagQuery = useCallback( + (scopeOverride?: string[] | null): { tags?: string[]; match?: string } => { + const scope = scopeOverride === undefined ? selectedScope : scopeOverride; + if (scope !== null) { + return { tags: scope, match: "exact" }; + } + return { tags: tagFilters.length > 0 ? tagFilters : undefined }; + }, + [selectedScope, tagFilters] + ); // Trigger text search on Enter key const executeSearch = () => { if (currentBank) { setCurrentPage(1); - loadData(undefined, searchQuery || undefined, tagFilters.length > 0 ? tagFilters : undefined); + const { tags, match } = resolveTagQuery(); + loadData(undefined, searchQuery || undefined, tags, match); } }; - // Trigger server-side reload immediately when tag filters change + // Single auto-loader for the graph data. This deliberately replaces what used + // to be two effects (mount/context + filter change) that BOTH fired on mount, + // doubling the initial /api/graph request (see issue #2158). When the context + // (factType/bank/document/chunk) changes we drop the now-meaningless scope + // filter and feed the cleared value straight into the same reload, so the + // scope reset never triggers a second fetch. + const contextKeyRef = useRef(null); + const skipScopeResetReload = useRef(false); + const lastAutoLoadSig = useRef(null); useEffect(() => { - if (currentBank) { - loadData(undefined, searchQuery || undefined, tagFilters.length > 0 ? tagFilters : undefined); + if (!currentBank) return; + // The previous run already issued the reload with scope=null; this run is + // only the echo of our own setSelectedScope(null), so skip it. + if (skipScopeResetReload.current) { + skipScopeResetReload.current = false; + return; + } + const contextKey = `${factType}${currentBank}${documentId ?? ""}${chunkId ?? ""}`; + const contextChanged = contextKeyRef.current !== contextKey; + contextKeyRef.current = contextKey; + + let scope = selectedScope; + if (contextChanged && selectedScope !== null) { + scope = null; + skipScopeResetReload.current = true; + setSelectedScope(null); + } + const { tags, match } = resolveTagQuery(scope); + // Collapse identical consecutive auto-loads into a single request. This makes + // the effect idempotent, so React's mount-effect double-invoke (dev + // StrictMode, and any redundant re-render) can't re-issue the same /api/graph + // query. Manual reloads (search, load-more, consolidation poll) call loadData + // directly and intentionally bypass this guard. + const sig = JSON.stringify([contextKey, tags ?? null, match ?? null]); + if (sig === lastAutoLoadSig.current) return; + lastAutoLoadSig.current = sig; + loadData(undefined, searchQuery || undefined, tags, match); + }, [factType, currentBank, documentId, chunkId, tagFilters, selectedScope]); + + // Load the available observation scopes for the scope filter dropdown. + const loadScopes = useCallback(async () => { + if (!currentBank || factType !== "observation") { + setScopes([]); + return; + } + try { + const resp = await client.listObservationScopes(currentBank); + setScopes(resp.scopes ?? []); + } catch { + setScopes([]); } - }, [tagFilters]); + }, [currentBank, factType]); - // Auto-load data when component mounts or factType/currentBank changes useEffect(() => { - if (currentBank) { - loadData(); - } - }, [factType, currentBank, documentId, chunkId]); + loadScopes(); + }, [loadScopes]); + + // While consolidation is in progress, poll so the observations + scopes (and + // the "In Sync" badge) refresh live instead of showing a stale, one-shot read + // (bank stats are also cached for up to 60s, so a single fetch can lag well + // behind reality). Silent reloads avoid flashing the spinner. The effect only + // restarts when consolidation starts/stops, not on every tick. + const isConsolidating = + factType === "observation" && (consolidationStatus?.pending_consolidation ?? 0) > 0; + useEffect(() => { + if (!isConsolidating || !currentBank) return; + const id = setInterval(() => { + const { tags, match } = resolveTagQuery(); + loadData(undefined, searchQuery || undefined, tags, match, true); + loadScopes(); + }, 4000); + return () => clearInterval(id); + }, [isConsolidating, currentBank]); // Enforce 50 node limit to prevent UI instability, default to 20 or max whichever is smaller useEffect(() => { @@ -404,8 +578,28 @@ export function DataView({ className="pl-8 h-9" />
- {/* Tag input */} - + {/* Tag input. Setting a tag filter clears any selected scope + so the two filters never fight over the same query. */} + { + if (next.length > 0) setSelectedScope(null); + setTagFilters(next); + }} + bankId={currentBank} + /> + {/* Observation scope filter. Selecting a scope clears the + free-form tag filter (mutually exclusive). */} + {factType === "observation" && scopes.length > 0 && ( + { + if (scope !== null) setTagFilters([]); + setSelectedScope(scope); + }} + /> + )} )} @@ -824,14 +1018,29 @@ export function DataView({ linkColorFn={linkColorFn} nodeSizeFn={factType === "observation" ? observationNodeSizeFn : undefined} sizeLegendLabel={factType === "observation" ? t("sourceFactsLabel") : undefined} - nodeHeatFn={recencyLookup ? recencyHeatFn : undefined} + clusterKeyFn={ + factType === "observation" && groupByScope ? scopeClusterKeyFn : undefined + } + clusterColorFn={ + factType === "observation" && groupByScope ? scopeClusterColorFn : undefined + } + clusterLabelFn={ + factType === "observation" && groupByScope ? scopeClusterLabelFn : undefined + } + // When grouping by scope, color encodes scope (not recency), so + // suppress the recency heat to avoid a misleading legend. + nodeHeatFn={ + !(factType === "observation" && groupByScope) && recencyLookup + ? recencyHeatFn + : undefined + } heatLegendLabel={ - recencyLookup + !(factType === "observation" && groupByScope) && recencyLookup ? t("recencyLabel", { basis: RECENCY_BASIS_LABEL[recencyBasis] }) : undefined } heatLegendEndpoints={ - recencyLookup + !(factType === "observation" && groupByScope) && recencyLookup ? [ new Date(recencyLookup.minT).toISOString().slice(0, 10), new Date(recencyLookup.maxT).toISOString().slice(0, 10), @@ -874,24 +1083,39 @@ export function DataView({

{t("constellationViewDescription")}

-
-

- {t("colorBy")} -

- -
+ {factType === "observation" && ( +
+
+ +

+ {t("groupByScope")} +

+
+ +
+ )} + {!(factType === "observation" && groupByScope) && ( +
+

+ {t("colorBy")} +

+ +
+ )}

{t("linkTypes")} @@ -943,6 +1167,41 @@ export function DataView({ {!compactMode && viewMode === "table" && (
+ {factType !== "observation" && ( +
+
+ + +
+ {showInvalidated && ( + {t("invalidatedHint")} + )} +
+ )}
{filteredTableRows.length > 0 ? ( @@ -1166,7 +1425,13 @@ export function DataView({ )} {/* Memory Detail Modal */} - setModalMemoryId(null)} /> + setModalMemoryId(null)} + onChanged={() => { + if (showInvalidated) loadInvalidated(); + }} + />
); } diff --git a/hindsight-control-plane/src/components/documents-view.tsx b/hindsight-control-plane/src/components/documents-view.tsx index 15c459f4c3..ed0b25847e 100644 --- a/hindsight-control-plane/src/components/documents-view.tsx +++ b/hindsight-control-plane/src/components/documents-view.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useTranslations } from "next-intl"; import { toast } from "sonner"; import { client, LLMRequestEntry } from "@/lib/api"; @@ -73,6 +73,8 @@ import { Activity, Download, Upload, + Lock, + RotateCcw, } from "lucide-react"; const ITEMS_PER_PAGE = 50; @@ -255,6 +257,29 @@ function MetadataRow({ label, value }: { label: string; value: React.ReactNode } ); } +// Renders the observation_scopes spec a document was retained with: a mode +// keyword ("per_tag" / "combined" / "all_combinations") shown as a mono badge, +// or explicit tag-set lists shown as scope chips. Surfacing this lets you see +// which scoping was requested (e.g. all_combinations on 2 tags → 3 scopes), +// which otherwise only becomes visible once async consolidation finishes. +function ObservationScopesValue({ spec }: { spec: string | string[][] }) { + if (typeof spec === "string") { + return {spec}; + } + return ( +
+ {spec.map((scope, j) => ( + + {scope.length === 0 ? "—" : scope.map((tag) => `#${tag}`).join(" ")} + + ))} +
+ ); +} + const COMPOSITION_COLORS = { world: "#8b5cf6", experience: "#ec4899", @@ -353,6 +378,115 @@ function ChunkMemoriesHeader({ ); } +// Document-level audit: facts extracted from this document that were later +// invalidated (moved to the curation archive, so they no longer appear in the +// chunk memory views). Each can be restored in place. +function InvalidatedFactsSection({ bankId, documentId }: { bankId: string; documentId: string }) { + const t = useTranslations("documentsView"); + const tCuration = useTranslations("memoryDetailPanel"); + const [rows, setRows] = useState([]); + const [loaded, setLoaded] = useState(false); + const [restoringId, setRestoringId] = useState(null); + + const load = useCallback(async () => { + if (!bankId || !documentId) return; + try { + const resp: any = await client.listMemories(bankId, { + state: "invalidated", + documentId, + limit: 200, + }); + setRows(resp?.items ?? []); + } catch { + setRows([]); + } finally { + setLoaded(true); + } + }, [bankId, documentId]); + + useEffect(() => { + load(); + }, [load]); + + const restore = async (id: string) => { + setRestoringId(id); + try { + await client.updateMemory(id, bankId, { state: "valid" }); + await load(); + } finally { + setRestoringId(null); + } + }; + + if (!loaded || rows.length === 0) return null; + + return ( + +
+ {rows.map((row) => ( +
+
+
+ {row.fact_type && ( + + {row.fact_type} + + )} +
{row.text}
+
+ {row.entities && ( +
+ {row.entities + .split(", ") + .filter(Boolean) + .slice(0, 6) + .map((e: string, i: number) => ( + + {e} + + ))} +
+ )} + {row.occurred_start && ( +
+ {new Date(row.occurred_start).toLocaleDateString()} + {row.occurred_end && row.occurred_end !== row.occurred_start && ( + <> → {new Date(row.occurred_end).toLocaleDateString()} + )} +
+ )} + {(row.invalidation_reason || row.invalidated_at) && ( +
+ {row.invalidation_reason && ( + <> + {tCuration("curationReasonLabel")}: {row.invalidation_reason} + + )} + {row.invalidation_reason && row.invalidated_at && " · "} + {row.invalidated_at && new Date(row.invalidated_at).toLocaleString()} +
+ )} +
+ +
+ ))} +
+
+ ); +} + function ChunkRow({ chunk }: { chunk: any }) { const [expanded, setExpanded] = useState(false); const [memoriesExpanded, setMemoriesExpanded] = useState(false); @@ -1141,9 +1275,9 @@ export function DocumentsView() { General - - - Content + + + Memories
- {/* Content Tab */} - - {selectedDocument.original_text !== undefined && - (editingContent ? ( -
-
-
- - -
-
-