diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 00000000..cb95e3f9 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,15 @@ +// For format details, see https://aka.ms/devcontainer.json. For config options, see the +// README at: https://github.com/devcontainers/templates/tree/main/src/debian +{ + "name": "Development", + "image": "mcr.microsoft.com/devcontainers/typescript-node:latest", + "features": { + "ghcr.io/devcontainers/features/node:1": {} + }, + "postCreateCommand": "pnpm install", + "customizations": { + "vscode": { + "extensions": ["esbenp.prettier-vscode"] + } + } +} diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 1dfbd45f..00000000 --- a/.dockerignore +++ /dev/null @@ -1,32 +0,0 @@ -# Node modules (platform-specific native bindings) -**/node_modules -**/.next - -# Python -**/__pycache__ -**/*.pyc -**/.venv -**/dist -**/*.egg-info - -# Git -.git -.gitignore - -# IDE -.idea -.vscode -*.swp - -# OS -.DS_Store -Thumbs.db - -# Build artifacts -**/target -**/*.log - -# Test/Dev -**/coverage -**/.pytest_cache -**/.mypy_cache diff --git a/.env.example b/.env.example deleted file mode 100644 index 104048fb..00000000 --- a/.env.example +++ /dev/null @@ -1,44 +0,0 @@ -# Hindsight Environment Variables -# Copy this file to .env and fill in your values - -# LLM Configuration (Required) -HINDSIGHT_API_LLM_PROVIDER=openai -HINDSIGHT_API_LLM_API_KEY=your-api-key-here -HINDSIGHT_API_LLM_MODEL=o3-mini -HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1 - -# Azure AI Foundry (OpenAI v1 endpoint) example: -# HINDSIGHT_API_LLM_API_KEY=your-foundry-key-here -# HINDSIGHT_API_LLM_MODEL=gpt-5.2-chat # deployment name -# HINDSIGHT_API_LLM_BASE_URL=https://.services.ai.azure.com/openai/v1 - -# API Configuration (Optional) -HINDSIGHT_API_HOST=0.0.0.0 -HINDSIGHT_API_PORT=8888 -HINDSIGHT_API_LOG_LEVEL=info - -# Database (Optional - uses embedded pg0 by default) -# HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db - -# Embeddings Configuration (Optional - uses local by default) -# Provider: "local" (default), "tei" (HuggingFace Text Embeddings Inference), or "openai" (OpenAI-compatible) -# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local -# For local provider: -# HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5 -# For TEI provider: -# HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080 - -# Azure AI Foundry embeddings (OpenAI v1 endpoint) example: -# HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai -# HINDSIGHT_API_EMBEDDINGS_API_KEY=your-foundry-key-here -# HINDSIGHT_API_EMBEDDINGS_BASE_URL=https://.services.ai.azure.com/openai/v1 -# HINDSIGHT_API_EMBEDDINGS_MODEL=text-embedding-3-large -# HINDSIGHT_API_EMBEDDINGS_DIMENSIONS=384 - -# Reranker Configuration (Optional - uses local by default) -# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference) -# HINDSIGHT_API_RERANKER_PROVIDER=local -# For local provider: -# HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2 -# For TEI provider: -# HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081 diff --git a/.githooks/pre-commit b/.githooks/pre-commit deleted file mode 100755 index 4119fc61..00000000 --- a/.githooks/pre-commit +++ /dev/null @@ -1,27 +0,0 @@ -#!/bin/bash -# Pre-commit hook - runs all scripts in scripts/hooks/ - -set -e - -REPO_ROOT="$(git rev-parse --show-toplevel)" -HOOKS_DIR="$REPO_ROOT/scripts/hooks" - -if [ ! -d "$HOOKS_DIR" ]; then - exit 0 -fi - -echo "" -echo "=== Running pre-commit hooks ===" -echo "" - -# Run all executable scripts in hooks directory -for hook in "$HOOKS_DIR"/*.sh; do - if [ -x "$hook" ]; then - echo "[hook] $(basename "$hook")" - (cd "$REPO_ROOT" && "$hook") - fi -done - -echo "" -echo "=== Pre-commit hooks completed ===" -echo "" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..2407957d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,103 @@ +name: CI +on: + push: + branches-ignore: + - 'generated' + - 'codegen/**' + - 'integrated/**' + - 'stl-preview-head/**' + - 'stl-preview-base/**' + pull_request: + branches-ignore: + - 'stl-preview-head/**' + - 'stl-preview-base/**' + +jobs: + lint: + timeout-minutes: 10 + name: lint + runs-on: ${{ github.repository == 'stainless-sdks/hindsight-typescript' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: '10.24.0' + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Check types + run: ./scripts/lint + + build: + timeout-minutes: 5 + name: build + runs-on: ${{ github.repository == 'stainless-sdks/hindsight-typescript' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + permissions: + contents: read + id-token: write + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: '10.24.0' + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Check build + run: ./scripts/build + + - name: Get GitHub OIDC Token + if: github.repository == 'stainless-sdks/hindsight-typescript' + id: github-oidc + uses: actions/github-script@v6 + with: + script: core.setOutput('github_token', await core.getIDToken()); + + - name: Upload tarball + if: github.repository == 'stainless-sdks/hindsight-typescript' + env: + URL: https://pkg.stainless.com/s + AUTH: ${{ steps.github-oidc.outputs.github_token }} + SHA: ${{ github.sha }} + run: ./scripts/utils/upload-artifact.sh + test: + timeout-minutes: 10 + name: test + runs-on: ${{ github.repository == 'stainless-sdks/hindsight-typescript' && 'depot-ubuntu-24.04' || 'ubuntu-latest' }} + if: github.event_name == 'push' || github.event.pull_request.head.repo.fork + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: '10.24.0' + + - name: Bootstrap + run: ./scripts/bootstrap + + - name: Run tests + run: ./scripts/test diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml deleted file mode 100644 index bf5690d4..00000000 --- a/.github/workflows/deploy-docs.yml +++ /dev/null @@ -1,45 +0,0 @@ -name: Deploy Docs to GitHub Pages - -on: - push: - branches: [main] - paths: - - 'hindsight-docs/**' - - '.github/workflows/deploy-docs.yml' - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: false - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: package-lock.json - - uses: astral-sh/setup-uv@v4 - - run: npm ci --workspace=hindsight-docs - - run: uv run generate-llms-full - - run: npm run build --workspace=hindsight-docs - - uses: actions/upload-pages-artifact@v3 - with: - path: hindsight-docs/build - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - needs: build - steps: - - uses: actions/deploy-pages@v4 - id: deployment diff --git a/.github/workflows/publish-npm.yml b/.github/workflows/publish-npm.yml new file mode 100644 index 00000000..b1d41262 --- /dev/null +++ b/.github/workflows/publish-npm.yml @@ -0,0 +1,35 @@ +# This workflow is triggered when a GitHub release is created. +# It can also be run manually to re-publish to NPM in case it failed for some reason. +# You can run this workflow by navigating to https://www.github.com/jacob-split/hindsight/actions/workflows/publish-npm.yml +name: Publish NPM +on: + workflow_dispatch: + + release: + types: [published] + +jobs: + publish: + name: publish + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v3 + with: + node-version: '20' + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + + - name: Install dependencies + run: | + pnpm install + + - name: Publish to NPM + run: | + bash ./bin/publish-npm + env: + NPM_TOKEN: ${{ secrets.HINDSIGHT_NPM_TOKEN || secrets.NPM_TOKEN }} diff --git a/.github/workflows/release-doctor.yml b/.github/workflows/release-doctor.yml new file mode 100644 index 00000000..d4de3e77 --- /dev/null +++ b/.github/workflows/release-doctor.yml @@ -0,0 +1,22 @@ +name: Release Doctor +on: + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + release_doctor: + name: release doctor + runs-on: ubuntu-latest + if: github.repository == 'jacob-split/hindsight' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release-please') || github.head_ref == 'next') + + steps: + - uses: actions/checkout@v4 + + - name: Check release environment + run: | + bash ./bin/check-release-environment + env: + NPM_TOKEN: ${{ secrets.HINDSIGHT_NPM_TOKEN || secrets.NPM_TOKEN }} + diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 2f1f5655..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,451 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - -jobs: - release-python-packages: - runs-on: ubuntu-latest - environment: pypi - permissions: - id-token: write - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version-file: ".python-version" - - # Build all packages - - name: Build hindsight-client - working-directory: ./hindsight-clients/python - run: uv build --out-dir dist - - - name: Build hindsight-api - working-directory: ./hindsight-api - run: uv build --out-dir dist - - - name: Build hindsight-all - working-directory: ./hindsight - run: uv build --out-dir dist - - - name: Build hindsight-litellm - working-directory: ./hindsight-integrations/litellm - run: uv build --out-dir dist - - - name: Build hindsight-embed - working-directory: ./hindsight-embed - run: uv build --out-dir dist - - # Publish in order (client and api first, then hindsight-all which depends on them) - - name: Publish hindsight-client to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: ./hindsight-clients/python/dist - skip-existing: true - - - name: Publish hindsight-api to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: ./hindsight-api/dist - skip-existing: true - - - name: Publish hindsight-all to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: ./hindsight/dist - skip-existing: true - - - name: Publish hindsight-litellm to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: ./hindsight-integrations/litellm/dist - skip-existing: true - - - name: Publish hindsight-embed to PyPI - uses: pypa/gh-action-pypi-publish@release/v1 - with: - packages-dir: ./hindsight-embed/dist - skip-existing: true - - # Upload artifacts for GitHub release - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: python-packages - path: | - hindsight-clients/python/dist/* - hindsight-api/dist/* - hindsight/dist/* - hindsight-integrations/litellm/dist/* - hindsight-embed/dist/* - retention-days: 1 - - release-typescript-client: - runs-on: ubuntu-latest - environment: npm - - steps: - - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - registry-url: 'https://registry.npmjs.org' - cache: 'npm' - cache-dependency-path: package-lock.json - - - name: Install dependencies - run: npm ci --workspace=hindsight-clients/typescript - - - name: Build - run: npm run build --workspace=hindsight-clients/typescript - - - name: Publish to npm - working-directory: ./hindsight-clients/typescript - run: | - set +e - OUTPUT=$(npm publish --access public 2>&1) - EXIT_CODE=$? - echo "$OUTPUT" - if [ $EXIT_CODE -ne 0 ]; then - if echo "$OUTPUT" | grep -q "cannot publish over"; then - echo "Package version already published, skipping..." - exit 0 - fi - exit $EXIT_CODE - fi - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Pack for GitHub release - working-directory: ./hindsight-clients/typescript - run: npm pack - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: typescript-client - path: hindsight-clients/typescript/*.tgz - retention-days: 1 - - release-control-plane: - runs-on: ubuntu-latest - environment: npm - - steps: - - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - registry-url: 'https://registry.npmjs.org' - cache: 'npm' - cache-dependency-path: package-lock.json - - - name: Install dependencies - run: npm ci - - - name: Build TypeScript client (dependency) - run: npm run build --workspace=hindsight-clients/typescript - - - name: Fix platform-specific native modules - run: | - # npm ci installs from lockfile which may have wrong platform binaries - # Delete hoisted native modules and reinstall for current platform - rm -rf node_modules/lightningcss node_modules/@tailwindcss - npm install lightningcss @tailwindcss/postcss @tailwindcss/node - - - name: Build - run: npm run build --workspace=hindsight-control-plane - - - name: Publish to npm - working-directory: ./hindsight-control-plane - run: | - set +e - OUTPUT=$(npm publish --access public 2>&1) - EXIT_CODE=$? - echo "$OUTPUT" - if [ $EXIT_CODE -ne 0 ]; then - if echo "$OUTPUT" | grep -q "cannot publish over"; then - echo "Package version already published, skipping..." - exit 0 - fi - exit $EXIT_CODE - fi - env: - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} - - - name: Pack for GitHub release - working-directory: ./hindsight-control-plane - run: npm pack - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: control-plane - path: hindsight-control-plane/*.tgz - retention-days: 1 - - release-rust-cli: - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - artifact_name: hindsight - asset_name: hindsight-linux-amd64 - - os: macos-latest - target: x86_64-apple-darwin - artifact_name: hindsight - asset_name: hindsight-darwin-amd64 - - os: macos-latest - target: aarch64-apple-darwin - artifact_name: hindsight - asset_name: hindsight-darwin-arm64 - - steps: - - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - - name: Build - working-directory: hindsight-cli - run: cargo build --release --target ${{ matrix.target }} - - - name: Prepare artifact - run: | - mkdir -p artifacts - cp hindsight-cli/target/${{ matrix.target }}/release/${{ matrix.artifact_name }} artifacts/${{ matrix.asset_name }} - chmod +x artifacts/${{ matrix.asset_name }} - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: rust-cli-${{ matrix.asset_name }} - path: artifacts/${{ matrix.asset_name }} - retention-days: 1 - - release-docker-images: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - strategy: - matrix: - include: - - target: api-only - image_name: hindsight-api - - target: cp-only - image_name: hindsight-control-plane - - target: standalone - image_name: hindsight - - steps: - - uses: actions/checkout@v4 - - - name: Free Disk Space - uses: jlumbroso/free-disk-space@main - with: - tool-cache: true - android: true - dotnet: true - haskell: true - large-packages: true - docker-images: true - swap-storage: true - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to GitHub Container Registry - uses: docker/login-action@v3 - with: - registry: ghcr.io - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract version from tag - id: get_version - run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - - - name: Extract metadata for release tags - id: meta - uses: docker/metadata-action@v5 - with: - images: ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }} - tags: | - type=semver,pattern={{version}},value=${{ steps.get_version.outputs.VERSION }} - type=semver,pattern={{major}}.{{minor}},value=${{ steps.get_version.outputs.VERSION }} - type=semver,pattern={{major}},value=${{ steps.get_version.outputs.VERSION }} - type=raw,value=latest - - # TODO: Re-enable smoke test when disk space issue is resolved - # # Step 1: Build for local testing (single platform, no push) - # # This creates an identical image to what will be released, just for one platform - # - name: Build image for testing - # uses: docker/build-push-action@v6 - # with: - # context: . - # file: docker/standalone/Dockerfile - # target: ${{ matrix.target }} - # push: false - # load: true - # tags: ${{ matrix.image_name }}:test - # cache-from: type=gha - # cache-to: type=gha,mode=max - - # # Step 2: Test the image before pushing anything - # - name: Smoke test - verify container starts - # env: - # GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} - # run: ./scripts/docker-smoke-test.sh "${{ matrix.image_name }}:test" "${{ matrix.target }}" - - # Build multi-platform and push to release tags - - name: Build and push release images - uses: docker/build-push-action@v6 - with: - context: . - file: docker/standalone/Dockerfile - target: ${{ matrix.target }} - push: true - platforms: linux/amd64,linux/arm64 - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - - release-helm-chart: - runs-on: ubuntu-latest - permissions: - contents: read - packages: write - - steps: - - uses: actions/checkout@v4 - - - name: Install Helm - uses: azure/setup-helm@v4 - with: - version: 'latest' - - - name: Log in to GHCR - run: echo "${{ secrets.GITHUB_TOKEN }}" | helm registry login ghcr.io -u ${{ github.actor }} --password-stdin - - - name: Lint Helm chart - run: helm lint helm/hindsight - - - name: Package Helm chart - run: helm package helm/hindsight --destination ./helm-packages - - - name: Push to GHCR OCI - run: helm push helm-packages/*.tgz oci://ghcr.io/${{ github.repository_owner }}/charts - - - name: Upload artifacts - uses: actions/upload-artifact@v4 - with: - name: helm-chart - path: helm-packages/*.tgz - retention-days: 1 - - create-github-release: - runs-on: ubuntu-latest - needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart] - permissions: - contents: write - - steps: - - uses: actions/checkout@v4 - - - name: Extract version from tag - id: get_version - run: echo "VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_OUTPUT - - - name: Download Python packages - uses: actions/download-artifact@v4 - with: - name: python-packages - path: ./artifacts/python-packages - - - name: Download TypeScript client - uses: actions/download-artifact@v4 - with: - name: typescript-client - path: ./artifacts/typescript-client - - - name: Download Control Plane - uses: actions/download-artifact@v4 - with: - name: control-plane - path: ./artifacts/control-plane - - - name: Download Rust CLI (Linux) - uses: actions/download-artifact@v4 - with: - name: rust-cli-hindsight-linux-amd64 - path: ./artifacts/rust-cli-linux - - - name: Download Rust CLI (macOS Intel) - uses: actions/download-artifact@v4 - with: - name: rust-cli-hindsight-darwin-amd64 - path: ./artifacts/rust-cli-darwin-amd64 - - - name: Download Rust CLI (macOS ARM) - uses: actions/download-artifact@v4 - with: - name: rust-cli-hindsight-darwin-arm64 - path: ./artifacts/rust-cli-darwin-arm64 - - - name: Download Helm chart - uses: actions/download-artifact@v4 - with: - name: helm-chart - path: ./artifacts/helm-chart - - - name: Prepare release assets - run: | - mkdir -p release-assets - # Python packages - cp artifacts/python-packages/hindsight-clients/python/dist/* release-assets/ || true - cp artifacts/python-packages/hindsight-api/dist/* release-assets/ || true - cp artifacts/python-packages/hindsight/dist/* release-assets/ || true - cp artifacts/python-packages/hindsight-integrations/litellm/dist/* release-assets/ || true - cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true - # TypeScript client - cp artifacts/typescript-client/*.tgz release-assets/ || true - # Control Plane - cp artifacts/control-plane/*.tgz release-assets/ || true - # Rust CLI binaries - cp artifacts/rust-cli-linux/hindsight-linux-amd64 release-assets/ || true - cp artifacts/rust-cli-darwin-amd64/hindsight-darwin-amd64 release-assets/ || true - cp artifacts/rust-cli-darwin-arm64/hindsight-darwin-arm64 release-assets/ || true - # Helm chart - cp artifacts/helm-chart/*.tgz release-assets/ || true - ls -la release-assets/ - - - name: Create GitHub Release - uses: softprops/action-gh-release@v2 - with: - files: release-assets/* - generate_release_notes: true - draft: false - prerelease: false - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml deleted file mode 100644 index 8068c845..00000000 --- a/.github/workflows/test.yml +++ /dev/null @@ -1,718 +0,0 @@ -name: CI - -on: - pull_request: - branches: [ main ] - -concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - build-python-packages: - runs-on: ubuntu-latest - strategy: - matrix: - include: - - name: hindsight-all - path: hindsight - - name: hindsight-api - path: hindsight-api - - name: hindsight-client - path: hindsight-clients/python - - name: hindsight-embed - path: hindsight-embed - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version-file: ".python-version" - - - name: Build ${{ matrix.name }} - working-directory: ./${{ matrix.path }} - run: uv build - - build-api-python-versions: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ['3.11', '3.12', '3.13'] - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - - name: Build hindsight-api - working-directory: ./hindsight-api - run: uv build - - build-typescript-client: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: package-lock.json - - - name: Install dependencies - run: npm ci --workspace=hindsight-clients/typescript - - - name: Build TypeScript client - run: npm run build --workspace=hindsight-clients/typescript - - build-control-plane: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: package-lock.json - - - name: Install SDK dependencies - run: npm ci --workspace=hindsight-clients/typescript - - - name: Build SDK - run: npm run build --workspace=hindsight-clients/typescript - - # Install control plane deps and fix hoisted lightningcss binary - # lightningcss gets hoisted to root node_modules, so we need to reinstall it there - - name: Install Control Plane dependencies - run: | - npm install --workspace=hindsight-control-plane - rm -rf node_modules/lightningcss node_modules/@tailwindcss - npm install lightningcss @tailwindcss/postcss @tailwindcss/node - - - name: Build Control Plane - run: npm run build --workspace=hindsight-control-plane - - - name: Verify standalone build - run: | - test -f hindsight-control-plane/standalone/server.js || exit 1 - test -d hindsight-control-plane/standalone/node_modules || exit 1 - node hindsight-control-plane/bin/cli.js --help - - - name: Smoke test - verify server starts - run: | - cd hindsight-control-plane - node bin/cli.js --port 9999 & - SERVER_PID=$! - sleep 5 - if curl -sf http://localhost:9999 > /dev/null 2>&1; then - echo "Server started successfully" - kill $SERVER_PID 2>/dev/null || true - exit 0 - else - echo "Server failed to respond" - kill $SERVER_PID 2>/dev/null || true - exit 1 - fi - - build-docs: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: package-lock.json - - - name: Install dependencies - run: npm ci --workspace=hindsight-docs - - - name: Build docs - run: npm run build --workspace=hindsight-docs - - build-rust-cli: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - hindsight-cli/target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - - name: Build CLI - working-directory: hindsight-cli - run: cargo build --release - - - name: Upload CLI artifact - uses: actions/upload-artifact@v4 - with: - name: hindsight-cli - path: hindsight-cli/target/release/hindsight - retention-days: 1 - - lint-helm-chart: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Install Helm - uses: azure/setup-helm@v4 - with: - version: 'latest' - - - name: Lint Helm chart - run: helm lint helm/hindsight - - build-docker-images: - runs-on: ubuntu-latest - strategy: - matrix: - include: - - target: api-only - name: api - - target: cp-only - name: control-plane - - target: standalone - name: standalone - - steps: - - uses: actions/checkout@v4 - - - name: Free Disk Space - uses: jlumbroso/free-disk-space@main - with: - tool-cache: true - android: true - dotnet: true - haskell: true - large-packages: true - docker-images: true - swap-storage: true - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Build ${{ matrix.name }} image - uses: docker/build-push-action@v6 - with: - context: . - file: docker/standalone/Dockerfile - target: ${{ matrix.target }} - push: false - load: false - - # TODO: Re-enable smoke test when disk space issue is resolved - # - name: Smoke test - verify container starts - # env: - # GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} - # run: ./scripts/docker-smoke-test.sh "hindsight-${{ matrix.name }}:test" "${{ matrix.target }}" - - test-api: - runs-on: ubuntu-latest - env: - HINDSIGHT_API_LLM_PROVIDER: groq - HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }} - GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }} - GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} - OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Prefer CPU-only PyTorch in CI (but keep PyPI for everything else) - UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - prune-cache: false - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version-file: ".python-version" - - - name: Build API - working-directory: ./hindsight-api - run: uv build - - - name: Install dependencies - working-directory: ./hindsight-api - run: uv sync --extra test --no-install-project --index-strategy unsafe-best-match - - - name: Cache HuggingFace models - uses: actions/cache@v4 - with: - path: ~/.cache/huggingface - key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-huggingface- - - - name: Pre-download models - working-directory: ./hindsight-api - run: | - uv run python -c " - from sentence_transformers import SentenceTransformer, CrossEncoder - print('Downloading embedding model...') - SentenceTransformer('BAAI/bge-small-en-v1.5') - print('Downloading cross-encoder model...') - CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2') - print('Models downloaded successfully') - " - - - name: Run tests - working-directory: ./hindsight-api - run: uv run pytest tests -v - - test-python-client: - runs-on: ubuntu-latest - env: - HINDSIGHT_API_LLM_PROVIDER: groq - HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }} - HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b - HINDSIGHT_API_URL: http://localhost:8888 - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Prefer CPU-only PyTorch in CI (but keep PyPI for everything else) - UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - prune-cache: false - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version-file: ".python-version" - - - name: Build API - working-directory: ./hindsight-api - run: uv build - - - name: Build Python client - working-directory: ./hindsight-clients/python - run: uv build - - - name: Install client test dependencies - working-directory: ./hindsight-clients/python - run: uv sync --extra test --index-strategy unsafe-best-match - - - name: Install API dependencies - working-directory: ./hindsight-api - run: uv sync --no-install-project --index-strategy unsafe-best-match - - - name: Create .env file - run: | - cat > .env << EOF - HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }} - HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }} - HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }} - EOF - - - name: Start API server - run: | - ./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 & - echo "Waiting for API server to be ready..." - for i in {1..60}; do - if curl -sf http://localhost:8888/health > /dev/null 2>&1; then - echo "API server is ready after ${i}s" - break - fi - if [ $i -eq 60 ]; then - echo "API server failed to start after 60s" - cat /tmp/api-server.log - exit 1 - fi - sleep 1 - done - - - name: Run Python client tests - working-directory: ./hindsight-clients/python - run: uv run pytest tests -v - - - name: Show API server logs - if: always() - run: | - echo "=== API Server Logs ===" - cat /tmp/api-server.log || echo "No API server log found" - - test-typescript-client: - runs-on: ubuntu-latest - env: - HINDSIGHT_API_LLM_PROVIDER: groq - HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }} - HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b - HINDSIGHT_API_URL: http://localhost:8888 - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Prefer CPU-only PyTorch in CI (but keep PyPI for everything else) - UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - prune-cache: false - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version-file: ".python-version" - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Build API - working-directory: ./hindsight-api - run: uv build - - - name: Install API dependencies - working-directory: ./hindsight-api - run: uv sync --no-install-project --index-strategy unsafe-best-match - - - name: Install TypeScript client dependencies - working-directory: ./hindsight-clients/typescript - run: npm ci - - - name: Build TypeScript client - working-directory: ./hindsight-clients/typescript - run: npm run build - - - name: Create .env file - run: | - cat > .env << EOF - HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }} - HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }} - HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }} - EOF - - - name: Start API server - run: | - ./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 & - echo "Waiting for API server to be ready..." - for i in {1..60}; do - if curl -sf http://localhost:8888/health > /dev/null 2>&1; then - echo "API server is ready after ${i}s" - break - fi - if [ $i -eq 60 ]; then - echo "API server failed to start after 60s" - cat /tmp/api-server.log - exit 1 - fi - sleep 1 - done - - - name: Run TypeScript client tests - working-directory: ./hindsight-clients/typescript - run: npm test - - - name: Show API server logs - if: always() - run: | - echo "=== API Server Logs ===" - cat /tmp/api-server.log || echo "No API server log found" - - test-rust-client: - runs-on: ubuntu-latest - env: - HINDSIGHT_API_LLM_PROVIDER: groq - HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }} - HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b - HINDSIGHT_API_URL: http://localhost:8888 - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - # Prefer CPU-only PyTorch in CI (but keep PyPI for everything else) - UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - prune-cache: false - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version-file: ".python-version" - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - - - name: Cache cargo - uses: actions/cache@v4 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - hindsight-clients/rust/target - key: ${{ runner.os }}-cargo-client-${{ hashFiles('hindsight-clients/rust/Cargo.lock') }} - - - name: Build API - working-directory: ./hindsight-api - run: uv build - - - name: Install API dependencies - working-directory: ./hindsight-api - run: uv sync --no-install-project --index-strategy unsafe-best-match - - - name: Create .env file - run: | - cat > .env << EOF - HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }} - HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }} - HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }} - EOF - - - name: Start API server - run: | - ./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 & - echo "Waiting for API server to be ready..." - for i in {1..60}; do - if curl -sf http://localhost:8888/health > /dev/null 2>&1; then - echo "API server is ready after ${i}s" - break - fi - if [ $i -eq 60 ]; then - echo "API server failed to start after 60s" - cat /tmp/api-server.log - exit 1 - fi - sleep 1 - done - - - name: Run Rust client tests - working-directory: ./hindsight-clients/rust - run: cargo test --lib - - - name: Show API server logs - if: always() - run: | - echo "=== API Server Logs ===" - cat /tmp/api-server.log || echo "No API server log found" - - test-litellm-integration: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - prune-cache: false - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version-file: ".python-version" - - - name: Build litellm integration - working-directory: ./hindsight-integrations/litellm - run: uv build - - - name: Install dependencies - working-directory: ./hindsight-integrations/litellm - run: uv sync --extra dev - - - name: Run tests - working-directory: ./hindsight-integrations/litellm - run: uv run pytest tests -v - - test-embed: - runs-on: ubuntu-latest - env: - HINDSIGHT_EMBED_LLM_PROVIDER: groq - HINDSIGHT_EMBED_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }} - HINDSIGHT_EMBED_LLM_MODEL: openai/gpt-oss-20b - # Prefer CPU-only PyTorch in CI - UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu - - steps: - - uses: actions/checkout@v4 - - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - prune-cache: false - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version-file: ".python-version" - - - name: Install dependencies - working-directory: ./hindsight-embed - run: uv sync --index-strategy unsafe-best-match - - - name: Cache HuggingFace models - uses: actions/cache@v4 - with: - path: ~/.cache/huggingface - key: ${{ runner.os }}-huggingface-embed-${{ hashFiles('hindsight-embed/pyproject.toml') }} - restore-keys: | - ${{ runner.os }}-huggingface-embed- - ${{ runner.os }}-huggingface- - - - name: Run smoke test - working-directory: ./hindsight-embed - run: ./test.sh - - test-doc-examples: - runs-on: ubuntu-latest - needs: build-rust-cli - env: - HINDSIGHT_API_LLM_PROVIDER: groq - HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }} - HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b - HINDSIGHT_API_URL: http://localhost:8888 - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu - - steps: - - uses: actions/checkout@v4 - - - name: Download CLI artifact - uses: actions/download-artifact@v4 - with: - name: hindsight-cli - path: /usr/local/bin - - - name: Make CLI executable - run: chmod +x /usr/local/bin/hindsight - - - name: Install uv - uses: astral-sh/setup-uv@v5 - with: - enable-cache: true - prune-cache: false - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version-file: ".python-version" - - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'npm' - cache-dependency-path: package-lock.json - - - name: Build and install API - working-directory: ./hindsight-api - run: | - uv build - uv sync --no-install-project --index-strategy unsafe-best-match - - - name: Install Python client dependencies - working-directory: ./hindsight-clients/python - run: uv sync --extra test --index-strategy unsafe-best-match - - - name: Install TypeScript client - run: | - npm ci --workspace=hindsight-clients/typescript - npm run build --workspace=hindsight-clients/typescript - - - name: Create .env file - run: | - cat > .env << EOF - HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }} - HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }} - HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }} - EOF - - - name: Start API server - run: | - ./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 & - echo "Waiting for API server to be ready..." - for i in {1..60}; do - if curl -sf http://localhost:8888/health > /dev/null 2>&1; then - echo "API server is ready after ${i}s" - break - fi - if [ $i -eq 60 ]; then - echo "API server failed to start after 60s" - cat /tmp/api-server.log - exit 1 - fi - sleep 1 - done - - - name: Run Python doc examples - working-directory: ./hindsight-clients/python - run: | - for f in ../../hindsight-docs/examples/api/*.py; do - echo "Running $f..." - uv run python "$f" - done - - - name: Run Node.js doc examples - run: | - for f in hindsight-docs/examples/api/*.mjs; do - echo "Running $f..." - node "$f" - done - - - name: Configure CLI - run: hindsight configure --api-url http://localhost:8888 - - - name: Run CLI doc examples - run: | - for f in hindsight-docs/examples/api/*.sh; do - echo "Running $f..." - bash "$f" - done - - - name: Show API server logs - if: always() - run: | - echo "=== API Server Logs ===" - cat /tmp/api-server.log || echo "No API server log found" \ No newline at end of file diff --git a/.gitignore b/.gitignore index 08f87146..2412bb77 100644 --- a/.gitignore +++ b/.gitignore @@ -1,42 +1,11 @@ -# Python-generated files -__pycache__/ -*.py[oc] -build/ -dist/ -wheels/ -*.egg-info - -# Virtual environments -.venv - -# Node -node_modules/ - -# Environment variables -.env - -# IDE +.prism.log +node_modules +yarn-error.log +codegen.log +Brewfile.lock.json +dist +dist-deno +/*.tgz .idea/ -.vscode/ -*.swp -*.swo - -# NLTK data (will be downloaded automatically) -nltk_data/ - -# Large benchmark datasets (will be downloaded automatically) -**/longmemeval_s_cleaned.json - -# Debug logs -logs/ - -.DS_Store - -# Generated docs files -hindsight-docs/static/llms-full.txt - +.eslintcache -hindsight-dev/benchmarks/locomo/results/ -hindsight-dev/benchmarks/longmemeval/results/ -hindsight-cli/target -hindsight-clients/rust/target \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..3548c5af --- /dev/null +++ b/.prettierignore @@ -0,0 +1,7 @@ +CHANGELOG.md +/ecosystem-tests/*/** +/node_modules +/deno + +# don't format tsc output, will break source maps +/dist diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 00000000..af75adaf --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "arrowParens": "always", + "experimentalTernaries": true, + "printWidth": 110, + "singleQuote": true, + "trailingComma": "all" +} diff --git a/.python-version b/.python-version deleted file mode 100644 index 2c073331..00000000 --- a/.python-version +++ /dev/null @@ -1 +0,0 @@ -3.11 diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 00000000..466df71c --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.1.0" +} diff --git a/.sesskey b/.sesskey deleted file mode 100644 index a9b27f3f..00000000 --- a/.sesskey +++ /dev/null @@ -1 +0,0 @@ -fcac2839-1db5-432f-91e1-c5dac07d7290 \ No newline at end of file diff --git a/.stats.yml b/.stats.yml new file mode 100644 index 00000000..9b5a1cbd --- /dev/null +++ b/.stats.yml @@ -0,0 +1,4 @@ +configured_endpoints: 25 +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/split%2Fhindsight-02b64523acf2e2df2599597b44c3060726dd0de6aee1d17b83977886a8f421e8.yml +openapi_spec_hash: b750b718019bc97d10b10f207abfc381 +config_hash: 2cc4f09c84f76af31af29b7edf6929f9 diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 5038b812..00000000 --- a/AGENTS.md +++ /dev/null @@ -1,153 +0,0 @@ -# AGENTS.md - -This document captures architectural decisions and coding conventions for the Hindsight project. - -## Documentation - -- **Main documentation**: [hindsight-docs/docs/developer/](./hindsight-docs/docs/developer/) -- **Use case patterns**: [hindsight-docs/docs/cookbook/](./hindsight-docs/docs/cookbook/) -- **API reference**: Auto-generated from OpenAPI spec - -## Project Structure - -``` -hindsight/ # Python package for embedded usage -hindsight-api/ # FastAPI server (core memory engine) -hindsight-cli/ # Rust CLI client -hindsight-embed/ # Embedded CLI (no server needed) -hindsight-control-plane/ # Next.js admin UI -hindsight-docs/ # Docusaurus documentation site -hindsight-dev/ # Development tools and benchmarks -hindsight-integrations/ # Framework integrations (LangChain, etc.) -hindsight-clients/ # Generated API clients (Python, TypeScript, Rust) -``` - -## Core Concepts - -### Memory Banks -- Each bank is an isolated memory store (like a "brain" for one user/agent) -- Banks contain: memory units (facts), entities, documents, entity links -- Banks have a **disposition** (personality traits) and **background** (context) -- Bank isolation is strict - no cross-bank data leakage - -### Memory Types -- **World facts**: General knowledge ("The sky is blue") -- **Experience facts**: Personal experiences ("I visited Paris in 2023") -- **Opinion facts**: Beliefs with confidence scores ("Paris is beautiful" - 0.9 confidence) - -### Operations -- **Retain**: Store new memories (extracts facts, entities, relationships) -- **Recall**: Retrieve memories (semantic, BM25, graph, temporal search) -- **Reflect**: Deep analysis to form new insights/opinions - -## API Design Decisions - -### Single Bank Per Request -- All API endpoints (`recall`, `reflect`, `retain`) operate on a single bank -- Multi-bank queries are the **client/agent's responsibility** to orchestrate -- This keeps the API simple and the isolation model clear - -### Disposition Traits (3-trait system) -- **Skepticism** (1-5): How skeptical vs trusting when forming opinions -- **Literalism** (1-5): How literally to interpret information -- **Empathy** (1-5): How much to consider emotional context -- These influence the `reflect` operation, not `recall` -- Background info also only affects `reflect` (opinion formation) - -## Multi-Bank Architecture Patterns - -See [hindsight-docs/docs/cookbook/](./hindsight-docs/docs/cookbook/) for detailed guides: - -- **Per-User Memory**: One bank per user, simplest pattern -- **Support Agent + Shared Knowledge**: User bank + shared docs bank, client orchestrates - -## Developer Guide - -### Running the API Server - -```bash -# From project root -./scripts/dev/start-api.sh - -# With options -./scripts/dev/start-api.sh --reload --port 8888 --log-level debug -``` - -### Running Tests - -```bash -# API tests -cd hindsight-api -uv run pytest tests/ - -# Specific test -uv run pytest tests/test_http_api_integration.py -v -``` - -### Generating OpenAPI Spec - -After changing API endpoints, regenerate the OpenAPI spec and docs: - -```bash -./scripts/generate-openapi.sh -``` - -This will: -1. Generate `openapi.json` at project root -2. Copy to `hindsight-docs/openapi.json` -3. Regenerate API reference documentation - -### Generating API Clients - -After updating the OpenAPI spec, regenerate all clients: - -```bash -./scripts/generate-clients.sh -``` - -This generates: -- **Rust client**: `hindsight-clients/rust/` (via progenitor in build.rs) -- **Python client**: `hindsight-clients/python/` (via openapi-generator Docker) -- **TypeScript client**: `hindsight-clients/typescript/` (via @hey-api/openapi-ts) - -Note: The maintained wrapper `hindsight_client.py` and `README.md` are preserved during regeneration. - -### Running the Documentation Site - -```bash -./scripts/dev/start-docs.sh -``` - -### Running the Control Plane - -```bash -./scripts/dev/start-control-plane.sh -``` - -## Code Style - -### Python (hindsight-api) -- Use `uv` for package management -- Async throughout (asyncpg, async FastAPI endpoints) -- Pydantic models for request/response validation -- No py files at project root - maintain clean directory structure - -### TypeScript (control-plane, clients) -- Next.js with App Router for control plane -- Tailwind CSS with shadcn/ui components - -### Rust (CLI) -- Async with tokio -- reqwest for HTTP client -- progenitor for API client generation - -## Database - -- PostgreSQL with pgvector extension -- Schema managed via Alembic migrations in `hindsight-api/alembic/`, db migrations happen during api startup, no manual commands -- Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links` - -# Branding -## Colors -- Primary: gradient from #0074d9 to #009296 - diff --git a/Brewfile b/Brewfile new file mode 100644 index 00000000..e4feee60 --- /dev/null +++ b/Brewfile @@ -0,0 +1 @@ +brew "node" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..9b35e80b --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +## 0.1.0 (2025-12-23) + +Full Changelog: [v0.0.1...v0.1.0](https://github.com/jacob-split/hindsight/compare/v0.0.1...v0.1.0) + +### Features + +* add hindsight-embed and native agentic skill ([#64](https://github.com/jacob-split/hindsight/issues/64)) ([da44a5e](https://github.com/jacob-split/hindsight/commit/da44a5e83967899152559863868ded32bd4c16f7)) +* add local mcp server ([#32](https://github.com/jacob-split/hindsight/issues/32)) ([7dd6853](https://github.com/jacob-split/hindsight/commit/7dd68538bb1f5cbb07c72805d4f52373cf502c24)) +* add optional graph retriever MPFP ([#26](https://github.com/jacob-split/hindsight/issues/26)) ([7445cef](https://github.com/jacob-split/hindsight/commit/7445cef7b701ac887a2c5e2e92cdae4eef8ac860)) +* extensions ([#54](https://github.com/jacob-split/hindsight/issues/54)) ([2a0c490](https://github.com/jacob-split/hindsight/commit/2a0c490c9ed618bf2064aa5f1bdfe21fb58cccda)) +* refactor hindsight-embed architecture ([#66](https://github.com/jacob-split/hindsight/issues/66)) ([e6511e7](https://github.com/jacob-split/hindsight/commit/e6511e7d77a95d847f7386c7f2ca4a44abc81331)) +* simplify mcp installation + ui standalone ([#41](https://github.com/jacob-split/hindsight/issues/41)) ([1c6acc3](https://github.com/jacob-split/hindsight/commit/1c6acc3ba0a8a2e2d587996f135d6dd4b59d7fe5)) +* support for gemini-3-pro and gpt-5.2 ([#30](https://github.com/jacob-split/hindsight/issues/30)) ([bb1f9cb](https://github.com/jacob-split/hindsight/commit/bb1f9cb221171a417055408ad31216b908ff423e)) + + +### Bug Fixes + +* add DOM.Iterable lib to resolve URLSearchParams.entries() type error ([#27](https://github.com/jacob-split/hindsight/issues/27)) ([160c558](https://github.com/jacob-split/hindsight/commit/160c5581ec8acfb6928fb27f25487057645f48ea)) +* add procps to Docker image and smoke test to release workflow ([#45](https://github.com/jacob-split/hindsight/issues/45)) ([ae80876](https://github.com/jacob-split/hindsight/commit/ae808766718b5d6deec705f0b9bb0e0ff79bc441)) +* bank list response with no name banks ([04f01ab](https://github.com/jacob-split/hindsight/commit/04f01ab9abe86787aa75e0d5266e3314faa364cf)) +* bank selector race condition when switching banks ([#38](https://github.com/jacob-split/hindsight/issues/38)) ([#39](https://github.com/jacob-split/hindsight/issues/39)) ([e468a4e](https://github.com/jacob-split/hindsight/commit/e468a4e19ffb04e596facfdb8e299fb2ab342f86)) +* ci and ui build ([#9](https://github.com/jacob-split/hindsight/issues/9)) ([050a3d2](https://github.com/jacob-split/hindsight/commit/050a3d2743484de06ef840d4a7604544c80512b5)) +* ci and ui improvements ([#8](https://github.com/jacob-split/hindsight/issues/8)) ([e142435](https://github.com/jacob-split/hindsight/commit/e1424357c4580170e3ce66d72518aa135883ca1f)) +* doc build and lint files ([#34](https://github.com/jacob-split/hindsight/issues/34)) ([9394cf9](https://github.com/jacob-split/hindsight/commit/9394cf92f232d23f9f0fd5757f936cb5f9c8f666)) +* docker image and control plane standalone build ([2948cb6](https://github.com/jacob-split/hindsight/commit/2948cb62d222c2ff445f3f4080184e4d053c87d0)) +* docker image build and startup ([#46](https://github.com/jacob-split/hindsight/issues/46)) ([b52eb90](https://github.com/jacob-split/hindsight/commit/b52eb905ad5159333fd0e3b938692c5e356e147c)) +* make sure openai provider works + docs updates ([#23](https://github.com/jacob-split/hindsight/issues/23)) ([f42476b](https://github.com/jacob-split/hindsight/commit/f42476bf947b56fb7464170ed3c12ce295d4ad1c)) +* ollama structured support ([#63](https://github.com/jacob-split/hindsight/issues/63)) ([32bca12](https://github.com/jacob-split/hindsight/commit/32bca12c6f47f26e0b7ae0e0a30bc6f9c49e92e0)) +* propagate exceptions from task handlers to enable retry logic ([#65](https://github.com/jacob-split/hindsight/issues/65)) ([904ea4d](https://github.com/jacob-split/hindsight/commit/904ea4de24812eeeb625e3561454b0c4930d3308)) +* retain async fails ([#40](https://github.com/jacob-split/hindsight/issues/40)) ([63f5138](https://github.com/jacob-split/hindsight/commit/63f51385c413c8ae57826c817e9c5628cb849ede)) +* set max_completion_tokens to 100 in llm validation ([#59](https://github.com/jacob-split/hindsight/issues/59)) ([b94b5cf](https://github.com/jacob-split/hindsight/commit/b94b5cf26efa0ca73e6485e8e3d0fb336f95957a)) +* **ui:** timestamp is not considered in retain ([#68](https://github.com/jacob-split/hindsight/issues/68)) ([234d426](https://github.com/jacob-split/hindsight/commit/234d4264999d653a803c97b80c838b7ab036c60d)) +* update Docusaurus config for custom domain ([#20](https://github.com/jacob-split/hindsight/issues/20)) ([b5abeb5](https://github.com/jacob-split/hindsight/commit/b5abeb56131292ce05695a94c0182b3c4d52cfff)) +* upgrade Next.js to 16.0.10 to patch CVE-2025-55184 and CVE-2025-55183 ([#25](https://github.com/jacob-split/hindsight/issues/25)) ([f018cc5](https://github.com/jacob-split/hindsight/commit/f018cc567756fe03439a4ea960582281093090bc)) +* upgrade Next.js to 16.0.7 to patch CVE-2025-66478 ([#19](https://github.com/jacob-split/hindsight/issues/19)) ([b0c7bba](https://github.com/jacob-split/hindsight/commit/b0c7bba5a1500da4554f312e8dae2af68bfc9d3b)) + + +### Chores + +* sync repo ([437e129](https://github.com/jacob-split/hindsight/commit/437e1291b944bd2e64b4a21e17d9bf878ec221c3)) +* update SDK settings ([c78ebe6](https://github.com/jacob-split/hindsight/commit/c78ebe6025aa80555745b4274ec00a27eaf2e0de)) +* update SDK settings ([58bbc7c](https://github.com/jacob-split/hindsight/commit/58bbc7cca02bd51161c6ef8dc2892c764d4655cb)) + + +### Documentation + +* update documentation URL to custom domain ([#21](https://github.com/jacob-split/hindsight/issues/21)) ([6daa3ad](https://github.com/jacob-split/hindsight/commit/6daa3ad135ac0afdc9f5e89d1ec8b3f5c0012d31)) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md deleted file mode 100644 index 555b80f8..00000000 --- a/CODE_OF_CONDUCT.md +++ /dev/null @@ -1,127 +0,0 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -We as members, contributors, and leaders pledge to make participation in our -community a harassment-free experience for everyone, regardless of age, body -size, visible or invisible disability, ethnicity, sex characteristics, gender -identity and expression, level of experience, education, socio-economic status, -nationality, personal appearance, race, religion, or sexual identity -and orientation. - -We pledge to act and interact in ways that contribute to an open, welcoming, -diverse, inclusive, and healthy community. - -## Our Standards - -Examples of behavior that contributes to a positive environment for our -community include: - -* Demonstrating empathy and kindness toward other people -* Being respectful of differing opinions, viewpoints, and experiences -* Giving and gracefully accepting constructive feedback -* Accepting responsibility and apologizing to those affected by our mistakes, - and learning from the experience -* Focusing on what is best not just for us as individuals, but for the - overall community - -Examples of unacceptable behavior include: - -* The use of sexualized language or imagery, and sexual attention or - advances of any kind -* Trolling, insulting or derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or email - address, without their explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -## Enforcement Responsibilities - -Community leaders are responsible for clarifying and enforcing our standards of -acceptable behavior and will take appropriate and fair corrective action in -response to any behavior that they deem inappropriate, threatening, offensive, -or harmful. - -Community leaders have the right and responsibility to remove, edit, or reject -comments, commits, code, wiki edits, issues, and other contributions that are -not aligned to this Code of Conduct, and will communicate reasons for moderation -decisions when appropriate. - -## Scope - -This Code of Conduct applies within all community spaces, and also applies when -an individual is officially representing the community in public spaces. -Examples of representing our community include using an official e-mail address, -posting via an official social media account, or acting as an appointed -representative at an online or offline event. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported to the community leaders responsible for enforcement. -All complaints will be reviewed and investigated promptly and fairly. - -All community leaders are obligated to respect the privacy and security of the -reporter of any incident. - -## Enforcement Guidelines - -Community leaders will follow these Community Impact Guidelines in determining -the consequences for any action they deem in violation of this Code of Conduct: - -### 1. Correction - -**Community Impact**: Use of inappropriate language or other behavior deemed -unprofessional or unwelcome in the community. - -**Consequence**: A private, written warning from community leaders, providing -clarity around the nature of the violation and an explanation of why the -behavior was inappropriate. A public apology may be requested. - -### 2. Warning - -**Community Impact**: A violation through a single incident or series -of actions. - -**Consequence**: A warning with consequences for continued behavior. No -interaction with the people involved, including unsolicited interaction with -those enforcing the Code of Conduct, for a specified period of time. This -includes avoiding interactions in community spaces as well as external channels -like social media. Violating these terms may lead to a temporary or -permanent ban. - -### 3. Temporary Ban - -**Community Impact**: A serious violation of community standards, including -sustained inappropriate behavior. - -**Consequence**: A temporary ban from any sort of interaction or public -communication with the community for a specified period of time. No public or -private interaction with the people involved, including unsolicited interaction -with those enforcing the Code of Conduct, is allowed during this period. -Violating these terms may lead to a permanent ban. - -### 4. Permanent Ban - -**Community Impact**: Demonstrating a pattern of violation of community -standards, including sustained inappropriate behavior, harassment of an -individual, or aggression toward or disparagement of classes of individuals. - -**Consequence**: A permanent ban from any sort of public interaction within -the community. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], -version 2.0, available at -https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. - -Community Impact Guidelines were inspired by [Mozilla's code of conduct -enforcement ladder](https://github.com/mozilla/diversity). - -[homepage]: https://www.contributor-covenant.org - -For answers to common questions about this code of conduct, see the FAQ at -https://www.contributor-covenant.org/faq. Translations are available at -https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 02269919..c50f04e8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,77 +1,107 @@ -# Contributing to Hindsight +## Setting up the environment -Thanks for your interest in contributing to Hindsight! +This repository uses [`pnpm`](https://pnpm.io/). +Other package managers may work but are not officially supported for development. -## Getting Started +To set up the repository, run: -1. Fork and clone the repository - ```bash - git clone git@github.com:vectorize-io/hindsight.git - cd hindsight - ``` -2. Set up your environment: - ```bash - cp .env.example .env - ``` - Edit the .env to add LLM API key and config as required +```sh +$ pnpm install +$ pnpm build +``` + +This will install all the required dependencies and build output files to `dist/`. + +## Modifying/Adding code + +Most of the SDK is generated code. Modifications to code will be persisted between generations, but may +result in merge conflicts between manual patches and changes from the generator. The generator will never +modify the contents of the `src/lib/` and `examples/` directories. + +## Adding and running examples + +All files in the `examples/` directory are not modified by the generator and can be freely edited or added to. + +```ts +// add an example to examples/.ts + +#!/usr/bin/env -S npm run tsn -T +… +``` -3. Install dependencies: - ```bash - # Python dependencies - uv sync --directory hindsight-api/ +```sh +$ chmod +x examples/.ts +# run the example against your api +$ pnpm tsn -T examples/.ts +``` - # Node dependencies (uses npm workspaces) - npm install - ``` +## Using the repository from source -## Development +If you’d like to use the repository from source, you can either install from git or link to a cloned repository: -### Running the API locally +To install via git: -```bash -./scripts/dev/start-api.sh +```sh +$ npm install git+ssh://git@github.com:jacob-split/hindsight.git ``` -### Running the Control Plane locally +Alternatively, to link a local copy of the repo: + +```sh +# Clone +$ git clone https://www.github.com/jacob-split/hindsight +$ cd hindsight + +# With yarn +$ yarn link +$ cd ../my-package +$ yarn link hindsight -```bash -./scripts/dev/start-control-plane.sh +# With pnpm +$ pnpm link --global +$ cd ../my-package +$ pnpm link -—global hindsight ``` -### Running the documentation locally +## Running tests -```bash -./scripts/dev/start-docs.sh +Most tests require you to [set up a mock server](https://github.com/stoplightio/prism) against the OpenAPI spec to run the tests. + +```sh +$ npx prism mock path/to/your/openapi.yml ``` -### Running tests +```sh +$ pnpm run test +``` -```bash -cd hindsight-api -uv run pytest tests/ +## Linting and formatting + +This repository uses [prettier](https://www.npmjs.com/package/prettier) and +[eslint](https://www.npmjs.com/package/eslint) to format the code in the repository. + +To lint: + +```sh +$ pnpm lint ``` -### Code style +To format and fix all lint issues automatically: -- Use Python type hints -- Follow existing code patterns -- Keep functions focused and well-named +```sh +$ pnpm fix +``` -## Pull Requests +## Publishing and releases -1. Create a feature branch from `main` -2. Make your changes -3. Run tests to ensure nothing breaks -4. Submit a PR with a clear description of changes +Changes made to this repository via the automated release PR pipeline should publish to npm automatically. If +the changes aren't made through the automated pipeline, you may want to make releases manually. -## Reporting Issues +### Publish with a GitHub workflow -Open an issue on GitHub with: -- Clear description of the problem -- Steps to reproduce -- Expected vs actual behavior -- Environment details (OS, Python version) +You can release to package managers by using [the `Publish NPM` GitHub action](https://www.github.com/jacob-split/hindsight/actions/workflows/publish-npm.yml). This requires a setup organization or repository secret to be set up. -## Questions? +### Publish manually -Open a discussion on GitHub or reach out to the maintainers. +If you need to manually release a package, you can run the `bin/publish-npm` script with an `NPM_TOKEN` set on +the environment. diff --git a/LICENSE b/LICENSE index 8dce7b4d..a66edf92 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,201 @@ -MIT License - -Copyright (c) 2025 Vectorize AI, Inc. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Hindsight + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index daa0351d..47b8ddc2 100644 --- a/README.md +++ b/README.md @@ -1,262 +1,356 @@ -
+# Hindsight TypeScript API Library -![Hindsight Banner](./hindsight-docs/static/img/banner.svg) +[![NPM version]()](https://npmjs.org/package/hindsight) ![npm bundle size](https://img.shields.io/bundlephobia/minzip/hindsight) -[Documentation](https://hindsight.vectorize.io) • [Paper](https://arxiv.org/abs/2512.12818) • [Cookbook](https://hindsight.vectorize.io/cookbook) • [Hindsight Cloud](https://vectorize.io/hindsight/cloud) +This library provides convenient access to the Hindsight REST API from server-side TypeScript or JavaScript. -[![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-3klo21kua-VUCC_zHP5rIcXFB1_5yw6A) -[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -![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) +The full API of this library can be found in [api.md](api.md). +It is generated with [Stainless](https://www.stainless.com/). -
+## Installation ---- - -## What is Hindsight? +```sh +npm install hindsight +``` -Hindsight™ is an agent memory system built to create smarter agents that learn over time. It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph and delivers state-of-the-art performance on long term memory tasks. +## Usage -Hindsight addresses common challenges that have frustrated AI engineers building agents to automate tasks and assist users with conversational interfaces. Many of these challenges stem directly from a lack of memory. +The full API of this library can be found in [api.md](api.md). -- **Inconsistency:** Agents complete tasks successfully one time, then fail when asked to complete the same task again. Memory gives the agent a mechanism to remember what worked and what didn't and to use that information to reduce errors and improve consistency. -- **Hallucinations:** Long term memory can be seeded with external knowledge to ground agent behavior in reliable sources to augment training data. -- **Cognitive Overload:** As workflows get complex, retrievals, tool calls, user messages and agent responses can grow to fill the context window leading to context rot. Short term memory optimization allows agents to reduce tokens and focus context by removing irrelevant details. + +```js +import Hindsight from 'hindsight'; -## How is Hindsight Different From Other Memory Systems? +const client = new Hindsight({ + apiKey: process.env['HINDSIGHT_API_KEY'], // This is the default and can be omitted +}); -![Overview](./hindsight-docs/static/img/hindsight-overview.webp) +const response = await client.health.check(); +``` -Most agent memory implementation rely on basic vector search or sometimes use a knowledge graph. Hindsight uses biomimetic data structures to organize agent memories in a way that is more like how human memory works: +### Request & Response types -- **World:** Facts about the world ("The stove gets hot") -- **Experiences:** Agent's own experiences ("I touched the stove and it really hurt") -- **Opinion:** Beliefs with confidence scores ("I shouldn't touch the stove again" - .99 confidence) -- **Observation:** Complex mental models derived by reflecting on facts and experiences ("Curling irons, ovens, and fire are also hot. I shouldn't touch those either.") +This library includes TypeScript definitions for all request params and response fields. You may import and use them like so: -Memories in Hindsight are stored in banks (i.e. memory banks). When memories are added to Hindsight, they are pushed into either the world facts or experiences memory pathway. They are then represented as a combination of entities, relationships, and time series with sparse/dense vector representations to aid in later recall. + +```ts +import Hindsight from 'hindsight'; -Hindsight provides three simple methods to interact with the system: +const client = new Hindsight({ + apiKey: process.env['HINDSIGHT_API_KEY'], // This is the default and can be omitted +}); -- **Retain:** Provide information to Hindsight that you want it to remember -- **Recall:** Retrieve memories from Hindsight -- **Reflect:** Reflect on memories and experiences to generate new observations and insights from existing memories. +const response: unknown = await client.health.check(); +``` -### Agent Memory That Learns +Documentation for each method, request param, and response field are available in docstrings and will appear on hover in most modern editors. + +## Handling errors + +When the library is unable to connect to the API, +or if the API returns a non-success status code (i.e., 4xx or 5xx response), +a subclass of `APIError` will be thrown: + + +```ts +const response = await client.health.check().catch(async (err) => { + if (err instanceof Hindsight.APIError) { + console.log(err.status); // 400 + console.log(err.name); // BadRequestError + console.log(err.headers); // {server: 'nginx', ...} + } else { + throw err; + } +}); +``` -A key goal of Hindsight is to build agent memory that enables agents to learn and improve over time. This is the role of the `reflect` operation which provides the agent to form broader opinions and observations over time. +Error codes are as follows: + +| Status Code | Error Type | +| ----------- | -------------------------- | +| 400 | `BadRequestError` | +| 401 | `AuthenticationError` | +| 403 | `PermissionDeniedError` | +| 404 | `NotFoundError` | +| 422 | `UnprocessableEntityError` | +| 429 | `RateLimitError` | +| >=500 | `InternalServerError` | +| N/A | `APIConnectionError` | + +### Retries + +Certain errors will be automatically retried 2 times by default, with a short exponential backoff. +Connection errors (for example, due to a network connectivity problem), 408 Request Timeout, 409 Conflict, +429 Rate Limit, and >=500 Internal errors will all be retried by default. + +You can use the `maxRetries` option to configure or disable this: + + +```js +// Configure the default for all requests: +const client = new Hindsight({ + maxRetries: 0, // default is 2 +}); + +// Or, configure per-request: +await client.health.check({ + maxRetries: 5, +}); +``` -For example, imagine a product support agent that is helping a user troubleshoot a problem. It uses a `search-documentation` tool it found on an MCP server. Later in the conversation, the agent discovers that the documentation returned from the tool wasn't for the product the user was asking about. The agent now has an experience in its memory bank. And just like humans, we want that agent to learn from its experience. +### Timeouts -As the agent gains more experiences, `reflect` allows the agent to form observations about what worked, what didn't, and what to do differently the next time it encounters a similar task. +Requests time out after 1 minute by default. You can configure this with a `timeout` option: ---- + +```ts +// Configure the default for all requests: +const client = new Hindsight({ + timeout: 20 * 1000, // 20 seconds (default is 1 minute) +}); -## Memory Performance & Accuracy +// Override per-request: +await client.health.check({ + timeout: 5 * 1000, +}); +``` -Hindsight has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational -AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of December 2025 is shown here: +On timeout, an `APIConnectionTimeoutError` is thrown. -![Overview](./hindsight-docs/static/img/hindsight-bench.jpg) +Note that requests which time out will be [retried twice by default](#retries). -The benchmark performance data for Hindsight and GPT-4o (full context) have been reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors. +## Advanced Usage -A thorough examination of the techniques implemented in Hindsight and detailed breakdowns of benchmark performance are [available on arXiv](https://arxiv.org/abs/2512.12818). This research is currently being prepared for conference submission and the wider peer review process. +### Accessing raw Response data (e.g., headers) -The benchmark results from this research can be inspected in our [visual benchmark explorer](https://hindsight-benchmarks.vercel.app). As additional improvements are made to Hindsight, new benchmark data will be available for review using this same tool. +The "raw" `Response` returned by `fetch()` can be accessed through the `.asResponse()` method on the `APIPromise` type that all methods return. +This method returns as soon as the headers for a successful response are received and does not consume the response body, so you are free to write custom parsing or streaming logic. -## Quick Start +You can also use the `.withResponse()` method to get the raw `Response` along with the parsed data. +Unlike `.asResponse()` this method consumes the body, returning once it is parsed. -### Docker (recommended) + +```ts +const client = new Hindsight(); -```bash -export OPENAI_API_KEY=your-key +const response = await client.health.check().asResponse(); +console.log(response.headers.get('X-My-Header')); +console.log(response.statusText); // access the underlying Response object -docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \ - -e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \ - -e HINDSIGHT_API_LLM_MODEL=o3-mini \ - -v $HOME/.hindsight-docker:/home/hindsight/.pg0 \ - ghcr.io/vectorize-io/hindsight:latest +const { data: response, response: raw } = await client.health.check().withResponse(); +console.log(raw.headers.get('X-My-Header')); +console.log(response); ``` -API: http://localhost:8888 -UI: http://localhost:9999 +### Logging + +> [!IMPORTANT] +> All log messages are intended for debugging only. The format and content of log messages +> may change between releases. + +#### Log levels -Install client: +The log level can be configured in two ways: -```bash -pip install hindsight-client -U -# or -npm install @vectorize-io/hindsight-client +1. Via the `HINDSIGHT_LOG` environment variable +2. Using the `logLevel` client option (overrides the environment variable if set) + +```ts +import Hindsight from 'hindsight'; + +const client = new Hindsight({ + logLevel: 'debug', // Show all log messages +}); ``` -Python example: +Available log levels, from most to least verbose: -```python -from hindsight_client import Hindsight +- `'debug'` - Show debug messages, info, warnings, and errors +- `'info'` - Show info messages, warnings, and errors +- `'warn'` - Show warnings and errors (default) +- `'error'` - Show only errors +- `'off'` - Disable all logging -client = Hindsight(base_url="http://localhost:8888") +At the `'debug'` level, all HTTP requests and responses are logged, including headers and bodies. +Some authentication-related headers are redacted, but sensitive data in request and response bodies +may still be visible. -# Retain: Store information -client.retain(bank_id="my-bank", content="Alice works at Google as a software engineer") +#### Custom logger -# Recall: Search memories -client.recall(bank_id="my-bank", query="What does Alice do?") +By default, this library logs to `globalThis.console`. You can also provide a custom logger. +Most logging libraries are supported, including [pino](https://www.npmjs.com/package/pino), [winston](https://www.npmjs.com/package/winston), [bunyan](https://www.npmjs.com/package/bunyan), [consola](https://www.npmjs.com/package/consola), [signale](https://www.npmjs.com/package/signale), and [@std/log](https://jsr.io/@std/log). If your logger doesn't work, please open an issue. -# Reflect: Generate disposition-aware response -client.reflect(bank_id="my-bank", query="Tell me about Alice") -``` +When providing a custom logger, the `logLevel` option still controls which messages are emitted, messages +below the configured level will not be sent to your logger. -### Python (embedded, no Docker) +```ts +import Hindsight from 'hindsight'; +import pino from 'pino'; -```bash -pip install hindsight-all -U -``` +const logger = pino(); -```python -import os -from hindsight import HindsightServer, HindsightClient - -with HindsightServer( - llm_provider="openai", - llm_model="gpt-5-mini", - llm_api_key=os.environ["OPENAI_API_KEY"] -) as server: - client = HindsightClient(base_url=server.url) - client.retain(bank_id="my-bank", content="Alice works at Google") - results = client.recall(bank_id="my-bank", query="Where does Alice work?") +const client = new Hindsight({ + logger: logger.child({ name: 'Hindsight' }), + logLevel: 'debug', // Send all messages to pino, allowing it to filter +}); ``` -### Node.js / TypeScript +### Making custom/undocumented requests + +This library is typed for convenient access to the documented API. If you need to access undocumented +endpoints, params, or response properties, the library can still be used. + +#### Undocumented endpoints -```bash -npm install @vectorize-io/hindsight-client +To make requests to undocumented endpoints, you can use `client.get`, `client.post`, and other HTTP verbs. +Options on the client, such as retries, will be respected when making these requests. + +```ts +await client.post('/some/path', { + body: { some_prop: 'foo' }, + query: { some_query_arg: 'bar' }, +}); ``` -```javascript -const { HindsightClient } = require('@vectorize-io/hindsight-client'); +#### Undocumented request params -const client = new HindsightClient({ baseUrl: 'http://localhost:8888' }); +To make requests using undocumented parameters, you may use `// @ts-expect-error` on the undocumented +parameter. This library doesn't validate at runtime that the request matches the type, so any extra values you +send will be sent as-is. -await client.retain('my-bank', 'Alice loves hiking in Yosemite'); -await client.recall('my-bank', 'What does Alice like?'); +```ts +client.health.check({ + // ... + // @ts-expect-error baz is not yet public + baz: 'undocumented option', +}); ``` ---- +For requests with the `GET` verb, any extra params will be in the query, all other requests will send the +extra param in the body. -## Architecture & Operations +If you want to explicitly send an extra argument, you can do so with the `query`, `body`, and `headers` request +options. -### Retain +#### Undocumented response properties -The `retain` operation is used to push new memories into Hindsight. It tells Hindsight to _retain_ the information you pass in as an input. +To access undocumented response properties, you may access the response object with `// @ts-expect-error` on +the response object, or cast the response object to the requisite type. Like the request params, we do not +validate or strip extra properties from the response from the API. -```python -from hindsight_client import Hindsight +### Customizing the fetch client -client = Hindsight(base_url="http://localhost:8888") +By default, this library expects a global `fetch` function is defined. -# Simple -client.retain( - bank_id="my-bank", - content="Alice works at Google as a software engineer" -) +If you want to use a different `fetch` function, you can either polyfill the global: -# With context and timestamp -client.retain( - bank_id="my-bank", - content="Alice got promoted to senior engineer", - context="career update", - timestamp="2025-06-15T10:00:00Z" -) -``` +```ts +import fetch from 'my-fetch'; -Behind the scenes, the retain operation uses an LLM to extract key facts, temporal data, entities, and relationships. It passes these through a normalization process to transform extracted data into canonical entities, time series, and search indexes along with metadata. These representations create the pathways for accurate memory retrieval in the recall and reflect operations. +globalThis.fetch = fetch; +``` -![Retain Operation](hindsight-docs/static/img/retain-operation.webp) +Or pass it to the client: -### Recall +```ts +import Hindsight from 'hindsight'; +import fetch from 'my-fetch'; -The recall operation is used to retrieve memories. These memories can come from any of the memory types (world, experiences, etc.) +const client = new Hindsight({ fetch }); +``` -```python -from hindsight_client import Hindsight +### Fetch options -client = Hindsight(base_url="http://localhost:8888") +If you want to set custom `fetch` options without overriding the `fetch` function, you can provide a `fetchOptions` object when instantiating the client or making a request. (Request-specific options override client options.) -# Simple -client.recall(bank_id="my-bank", query="What does Alice do?") +```ts +import Hindsight from 'hindsight'; -# Temporal -client.recall(bank_id="my-bank", query="What happened in June?") +const client = new Hindsight({ + fetchOptions: { + // `RequestInit` options + }, +}); ``` -Recall performs 4 retrieval strategies in parallel: -- Semantic: Vector similarity -- Keyword: BM25 exact matching -- Graph: Entity/temporal/causal links -- Temporal: Time range filtering - -![Retain Operation](hindsight-docs/static/img/recall-operation.webp) +#### Configuring proxies -The individual results from the retrievals are merged, then ordered by relevance using reciprocal rank fusion and a cross-encoder reranking model. +To modify proxy behavior, you can provide custom `fetchOptions` that add runtime-specific proxy +options to requests: -The final output is trimmed as needed to fit within the token limit. + **Node** [[docs](https://github.com/nodejs/undici/blob/main/docs/docs/api/ProxyAgent.md#example---proxyagent-with-fetch)] -### Reflect +```ts +import Hindsight from 'hindsight'; +import * as undici from 'undici'; -The reflect operation is used to perform a more thorough analysis of existing memories. This allows the agent to form new connections between memories which are then persisted as opinions and/or observations. When building agents, the reflect operation is a key capability to enable the agent to learn from its experiences. +const proxyAgent = new undici.ProxyAgent('http://localhost:8888'); +const client = new Hindsight({ + fetchOptions: { + dispatcher: proxyAgent, + }, +}); +``` -For example, the `reflect` operation can be used to support use cases such as: + **Bun** [[docs](https://bun.sh/guides/http/proxy)] -- An **AI Project Manager** reflecting on what risks need to be mitigated on a project. -- A **Sales Agent** reflecting on why certain outreach messages have gotten responses while others haven't. -- A **Support Agent** reflecting on opportunities where customers have questions not answered by current product documentation. +```ts +import Hindsight from 'hindsight'; -The `reflect` operation can also be used to handle on-demand question answering or analysis which require more deep thinking. +const client = new Hindsight({ + fetchOptions: { + proxy: 'http://localhost:8888', + }, +}); +``` -```python -from hindsight_client import Hindsight + **Deno** [[docs](https://docs.deno.com/api/deno/~/Deno.createHttpClient)] -client = Hindsight(base_url="http://localhost:8888") +```ts +import Hindsight from 'npm:hindsight'; -client.reflect(bank_id="my-bank", query="What should I know about Alice?") +const httpClient = Deno.createHttpClient({ proxy: { url: 'http://localhost:8888' } }); +const client = new Hindsight({ + fetchOptions: { + client: httpClient, + }, +}); ``` -![Retain Operation](hindsight-docs/static/img/reflect-operation.webp) +## Frequently Asked Questions ---- +## Semantic versioning -## Resources +This package generally follows [SemVer](https://semver.org/spec/v2.0.0.html) conventions, though certain backwards-incompatible changes may be released as minor versions: -**Documentation:** -- [https://hindsight.vectorize.io](https://hindsight.vectorize.io) +1. Changes that only affect static types, without breaking runtime behavior. +2. Changes to library internals which are technically public but not intended or documented for external use. _(Please open a GitHub issue to let us know if you are relying on such internals.)_ +3. Changes that we do not expect to impact the vast majority of users in practice. -**Clients:** -- [Python](http://hindsight.vectorize.io/sdks/python) -- [Node.js](http://hindsight.vectorize.io/sdks/nodejs) -- [REST API](https://hindsight.vectorize.io/api-reference) -- [CLI](https://hindsight.vectorize.io/sdks/cli) +We take backwards-compatibility seriously and work hard to ensure you can rely on a smooth upgrade experience. -**Community:** -- [Slack](https://join.slack.com/t/hindsight-space/shared_invite/zt-3klo21kua-VUCC_zHP5rIcXFB1_5yw6A) -- [GitHub Issues](https://github.com/vectorize-io/hindsight/issues) +We are keen for your feedback; please open an [issue](https://www.github.com/jacob-split/hindsight/issues) with questions, bugs, or suggestions. ---- -## Star History +## Requirements -[![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) ---- +TypeScript >= 4.9 is supported. -## Contributing +The following runtimes are supported: -See [CONTRIBUTING.md](./CONTRIBUTING.md). +- Web browsers (Up-to-date Chrome, Firefox, Safari, Edge, and more) +- Node.js 20 LTS or later ([non-EOL](https://endoflife.date/nodejs)) versions. +- Deno v1.28.0 or higher. +- Bun 1.0 or later. +- Cloudflare Workers. +- Vercel Edge Runtime. +- Jest 28 or greater with the `"node"` environment (`"jsdom"` is not supported at this time). +- Nitro v2.6 or greater. -## License +Note that React Native is not supported at this time. -MIT — see [LICENSE](./LICENSE) +If you are interested in other runtime environments, please open or upvote an issue on GitHub. ---- +## Contributing -Built by [Vectorize.io](https://vectorize.io) +See [the contributing documentation](./CONTRIBUTING.md). diff --git a/SECURITY.md b/SECURITY.md index 13f96aee..91070b49 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,39 +1,23 @@ # Security Policy -## Supported Versions +## Reporting Security Issues -We release patches for security vulnerabilities. Which versions are eligible for -receiving such patches depends on the CVSS v3.0 Rating: +This SDK is generated by [Stainless Software Inc](http://stainless.com). Stainless takes security seriously, and encourages you to report any security vulnerability promptly so that appropriate action can be taken. -| Version | Supported | -| ------- | ------------------ | -| latest | :white_check_mark: | +To report a security issue, please contact the Stainless team at security@stainless.com. -## Reporting a Vulnerability +## Responsible Disclosure -Please report (suspected) security vulnerabilities to the maintainers privately. -You can do this by opening a [GitHub Security Advisory](https://github.com/vectorize-io/hindsight/security/advisories/new). +We appreciate the efforts of security researchers and individuals who help us maintain the security of +SDKs we generate. If you believe you have found a security vulnerability, please adhere to responsible +disclosure practices by allowing us a reasonable amount of time to investigate and address the issue +before making any information public. -You will receive a response from us within 48 hours. If the issue is confirmed, -we will release a patch as soon as possible depending on complexity but -typically within a few days. +## Reporting Non-SDK Related Security Issues -Please include the following information in your report: +If you encounter security issues that are not directly related to SDKs but pertain to the services +or products provided by Hindsight, please follow the respective company's security reporting guidelines. -- Type of issue (e.g., buffer overflow, SQL injection, cross-site scripting, etc.) -- Full paths of source file(s) related to the manifestation of the issue -- The location of the affected source code (tag/branch/commit or direct URL) -- Any special configuration required to reproduce the issue -- Step-by-step instructions to reproduce the issue -- Proof-of-concept or exploit code (if possible) -- Impact of the issue, including how an attacker might exploit the issue +--- -This information will help us triage your report more quickly. - -## Preferred Languages - -We prefer all communications to be in English. - -## Policy - -We follow the principle of [Coordinated Vulnerability Disclosure](https://www.cisa.gov/resources-tools/programs/coordinated-vulnerability-disclosure-program). +Thank you for helping us keep the SDKs and systems they interact with secure. diff --git a/api.md b/api.md new file mode 100644 index 00000000..4dd138df --- /dev/null +++ b/api.md @@ -0,0 +1,143 @@ +# Health + +Types: + +- HealthCheckResponse + +Methods: + +- client.health.check() -> unknown + +# Mcp + +Types: + +- McpCreateResponse + +Methods: + +- client.mcp.create(bankID, { ...params }) -> McpCreateResponse + +# Metrics + +Types: + +- MetricRetrieveResponse + +Methods: + +- client.metrics.retrieve() -> unknown + +# Default + +Types: + +- DefaultGetChunkResponse + +Methods: + +- client.default.getChunk(chunkID) -> DefaultGetChunkResponse + +## Banks + +Types: + +- DeleteResponse +- BankListResponse +- BankAddBackgroundResponse +- BankReflectResponse + +Methods: + +- client.default.banks.list() -> BankListResponse +- client.default.banks.delete(bankID) -> DeleteResponse +- client.default.banks.addBackground(bankID, { ...params }) -> BankAddBackgroundResponse +- client.default.banks.reflect(bankID, { ...params }) -> BankReflectResponse +- client.default.banks.updateOrCreate(bankID, { ...params }) -> BankProfile + +### Graph + +Types: + +- GraphRetrieveResponse + +Methods: + +- client.default.banks.graph.retrieve(bankID, { ...params }) -> GraphRetrieveResponse + +### Memories + +Types: + +- Budget +- MemoryListResponse +- MemoryRecallResponse +- MemoryRetainResponse + +Methods: + +- client.default.banks.memories.list(bankID, { ...params }) -> MemoryListResponse +- client.default.banks.memories.clear(bankID, { ...params }) -> DeleteResponse +- client.default.banks.memories.recall(bankID, { ...params }) -> MemoryRecallResponse +- client.default.banks.memories.retain(bankID, { ...params }) -> MemoryRetainResponse + +### Stats + +Types: + +- StatRetrieveResponse + +Methods: + +- client.default.banks.stats.retrieve(bankID) -> unknown + +### Entities + +Types: + +- EntityDetail +- EntityListResponse + +Methods: + +- client.default.banks.entities.retrieve(entityID, { ...params }) -> EntityDetail +- client.default.banks.entities.list(bankID, { ...params }) -> EntityListResponse +- client.default.banks.entities.regenerate(entityID, { ...params }) -> EntityDetail + +### Documents + +Types: + +- DocumentRetrieveResponse +- DocumentListResponse +- DocumentDeleteResponse + +Methods: + +- client.default.banks.documents.retrieve(documentID, { ...params }) -> DocumentRetrieveResponse +- client.default.banks.documents.list(bankID, { ...params }) -> DocumentListResponse +- client.default.banks.documents.delete(documentID, { ...params }) -> unknown + +### Operations + +Types: + +- OperationListResponse +- OperationCancelResponse + +Methods: + +- client.default.banks.operations.list(bankID) -> unknown +- client.default.banks.operations.cancel(operationID, { ...params }) -> unknown + +### Profile + +Types: + +- BankProfile +- DispositionTraits + +Methods: + +- client.default.banks.profile.retrieve(bankID) -> BankProfile +- client.default.banks.profile.update(bankID, { ...params }) -> BankProfile diff --git a/bin/check-release-environment b/bin/check-release-environment new file mode 100644 index 00000000..e4b6d58e --- /dev/null +++ b/bin/check-release-environment @@ -0,0 +1,22 @@ +#!/usr/bin/env bash + +errors=() + +if [ -z "${NPM_TOKEN}" ]; then + errors+=("The NPM_TOKEN secret has not been set. Please set it in either this repository's secrets or your organization secrets") +fi + +lenErrors=${#errors[@]} + +if [[ lenErrors -gt 0 ]]; then + echo -e "Found the following errors in the release environment:\n" + + for error in "${errors[@]}"; do + echo -e "- $error\n" + done + + exit 1 +fi + +echo "The environment is ready to push releases!" + diff --git a/bin/publish-npm b/bin/publish-npm new file mode 100644 index 00000000..a6099896 --- /dev/null +++ b/bin/publish-npm @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -eux + +npm config set '//registry.npmjs.org/:_authToken' "$NPM_TOKEN" + +pnpm build +cd dist + +# Get package name and version from package.json +PACKAGE_NAME="$(jq -r -e '.name' ./package.json)" +VERSION="$(jq -r -e '.version' ./package.json)" + +# Get latest version from npm +# +# If the package doesn't exist, npm will return: +# { +# "error": { +# "code": "E404", +# "summary": "Unpublished on 2025-06-05T09:54:53.528Z", +# "detail": "'the_package' is not in this registry..." +# } +# } +NPM_INFO="$(npm view "$PACKAGE_NAME" version --json 2>/dev/null || true)" + +# Check if we got an E404 error +if echo "$NPM_INFO" | jq -e '.error.code == "E404"' > /dev/null 2>&1; then + # Package doesn't exist yet, no last version + LAST_VERSION="" +elif echo "$NPM_INFO" | jq -e '.error' > /dev/null 2>&1; then + # Report other errors + echo "ERROR: npm returned unexpected data:" + echo "$NPM_INFO" + exit 1 +else + # Success - get the version + LAST_VERSION=$(echo "$NPM_INFO" | jq -r '.') # strip quotes +fi + +# Check if current version is pre-release (e.g. alpha / beta / rc) +CURRENT_IS_PRERELEASE=false +if [[ "$VERSION" =~ -([a-zA-Z]+) ]]; then + CURRENT_IS_PRERELEASE=true + CURRENT_TAG="${BASH_REMATCH[1]}" +fi + +# Check if last version is a stable release +LAST_IS_STABLE_RELEASE=true +if [[ -z "$LAST_VERSION" || "$LAST_VERSION" =~ -([a-zA-Z]+) ]]; then + LAST_IS_STABLE_RELEASE=false +fi + +# Use a corresponding alpha/beta tag if there already is a stable release and we're publishing a prerelease. +if $CURRENT_IS_PRERELEASE && $LAST_IS_STABLE_RELEASE; then + TAG="$CURRENT_TAG" +else + TAG="latest" +fi + +# Publish with the appropriate tag +pnpm publish --no-git-checks --tag "$TAG" diff --git a/cookbook/README.md b/cookbook/README.md deleted file mode 100644 index 0329635f..00000000 --- a/cookbook/README.md +++ /dev/null @@ -1,11 +0,0 @@ -# Hindsight Cookbook - -For the cookbook with detailed examples, tutorials, and integrations, visit: - -**[https://github.com/vectorize-io/hindsight-cookbook](https://github.com/vectorize-io/hindsight-cookbook)** - -The cookbook repository includes: -- Integration examples with popular frameworks -- Real-world use cases and patterns -- Step-by-step tutorials -- Best practices and tips diff --git a/docker/standalone/Dockerfile b/docker/standalone/Dockerfile deleted file mode 100644 index a6847111..00000000 --- a/docker/standalone/Dockerfile +++ /dev/null @@ -1,320 +0,0 @@ -# Hindsight Docker Image -# Supports building API-only, Control Plane-only, or both -# -# Build args: -# INCLUDE_API=true/false - Include API (default: true) -# INCLUDE_CP=true/false - Include Control Plane (default: true) -# PRELOAD_ML_MODELS=true/false - Pre-download ML models during build (default: true) -# -# Examples: -# docker build -t hindsight . # Both (standalone) -# docker build -t hindsight-api --build-arg INCLUDE_CP=false . # API only -# docker build -t hindsight-cp --build-arg INCLUDE_API=false . # Control Plane only -# docker build -t hindsight --build-arg PRELOAD_ML_MODELS=false . # Skip ML model preload - -ARG INCLUDE_API=true -ARG INCLUDE_CP=true -ARG PRELOAD_ML_MODELS=true - -# ============================================================================= -# Stage: API Builder -# ============================================================================= -FROM python:3.11-slim AS api-builder - -ARG INCLUDE_API -RUN if [ "$INCLUDE_API" != "true" ]; then echo "Skipping API build" && exit 0; fi - -WORKDIR /app - -# Install system dependencies and uv -RUN apt-get update && apt-get install -y \ - gcc \ - g++ \ - curl \ - && rm -rf /var/lib/apt/lists/* \ - && pip install --no-cache-dir uv - -# Copy dependency files and README (required by pyproject.toml) -COPY hindsight-api/pyproject.toml ./api/ -COPY hindsight-api/README.md ./api/ - -WORKDIR /app/api - -# Sync dependencies (will create lock file if needed) -RUN uv sync - -# Copy source code (alembic migrations are inside hindsight_api/) -COPY hindsight-api/hindsight_api ./hindsight_api - -# Install the local package (uv sync only installed dependencies, not the package itself) -RUN uv pip install -e . - -# ============================================================================= -# Stage: SDK Builder (needed for Control Plane) -# ============================================================================= -FROM node:20-slim AS sdk-builder - -ARG INCLUDE_CP -RUN if [ "$INCLUDE_CP" != "true" ]; then echo "Skipping SDK build" && exit 0; fi - -WORKDIR /app - -# Copy root package files for npm workspaces -COPY package.json package-lock.json ./ -COPY hindsight-clients/typescript/ ./hindsight-clients/typescript/ - -# Install and build SDK using workspace (--ignore-scripts skips git hooks setup) -RUN npm ci --ignore-scripts -w @vectorize-io/hindsight-client -RUN npm run build -w @vectorize-io/hindsight-client - -# ============================================================================= -# Stage: Control Plane Builder -# ============================================================================= -FROM node:20-slim AS cp-builder - -ARG INCLUDE_CP -RUN if [ "$INCLUDE_CP" != "true" ]; then echo "Skipping CP build" && exit 0; fi - -# Create directory structure matching the monorepo layout -# This is required because build:standalone script expects .next/standalone/memory-poc/hindsight-control-plane -WORKDIR /app/memory-poc/hindsight-control-plane - -# Install Control Plane dependencies -# Only copy package.json (not package-lock.json) to ensure npm installs -# correct platform-specific native bindings for lightningcss/tailwindcss -COPY hindsight-control-plane/package.json ./ -# Remove the file: dependency on SDK (we'll copy it directly later) -RUN sed -i '/"@vectorize-io\/hindsight-client":/d' package.json -RUN npm install - -# Copy Control Plane source (excluding node_modules via .dockerignore) -COPY hindsight-control-plane/ ./ -# Remove package-lock.json to avoid conflicts with installed native bindings -# Also remove the file: dependency from package.json (restored by COPY above) -RUN rm -f package-lock.json && sed -i '/"@vectorize-io\/hindsight-client":/d' package.json - -# Copy built SDK directly into node_modules (more reliable than npm link in Docker) -COPY --from=sdk-builder /app/hindsight-clients/typescript ./node_modules/@vectorize-io/hindsight-client - -# Build Control Plane - run next build first, then custom standalone copy -# (The build:standalone script expects a specific path structure that differs in Docker) -RUN npm exec -- next build - -# Create standalone directory structure manually -# Note: Must exclude node_modules from find to avoid wrong server.js from next/dist/experimental/testmode/ -# Note: Must explicitly copy .next since glob * doesn't match hidden directories -RUN STANDALONE_ROOT=$(find .next/standalone -path '*/node_modules' -prune -o -name 'server.js' -print | head -1 | xargs dirname) && \ - mkdir -p standalone && \ - cp -r "$STANDALONE_ROOT"/* standalone/ && \ - cp -r "$STANDALONE_ROOT"/.next standalone/.next && \ - # Copy node_modules if separate from app dir (monorepo structure) - if [ -d ".next/standalone/node_modules" ] && [ "$STANDALONE_ROOT" != ".next/standalone" ]; then \ - cp -r .next/standalone/node_modules standalone/node_modules; \ - fi && \ - cp -r .next/static standalone/.next/static && \ - mkdir -p standalone/public && \ - cp -r public/* standalone/public/ 2>/dev/null || true && \ - # Verify required files exist - test -f standalone/server.js || (echo "ERROR: server.js missing!" && exit 1) && \ - test -f standalone/.next/BUILD_ID || (echo "ERROR: BUILD_ID missing!" && exit 1) - -# ============================================================================= -# Stage: Final Image - API Only -# ============================================================================= -FROM python:3.11-slim AS api-only - -WORKDIR /app - -# Install pg0 dependencies (procps provides 'kill' command needed by pg0) -# Note: libicu version varies by Debian version - try common versions in order -RUN apt-get update && apt-get install -y \ - curl \ - procps \ - libxml2 \ - libssl3 \ - libgssapi-krb5-2 \ - libossp-uuid16 \ - && (apt-get install -y libicu72 2>/dev/null || apt-get install -y libicu74 2>/dev/null || apt-get install -y libicu76 2>/dev/null || true) \ - && rm -rf /var/lib/apt/lists/* \ - && pip install --no-cache-dir uv - -# Create non-root user (PostgreSQL cannot run as root) -RUN useradd -m -s /bin/bash hindsight - -# Copy API with virtual environment from builder -COPY --from=api-builder /app/api /app/api - -# Copy startup script -COPY docker/standalone/start-all.sh /app/start-all.sh -RUN chmod +x /app/start-all.sh - -# Create data directory for pg0 and set ownership -RUN mkdir -p /app/data && chown -R hindsight:hindsight /app - -# Switch to non-root user -USER hindsight - -# Set PATH for hindsight user -ENV PATH="/app/api/.venv/bin:${PATH}" - -# Pre-cache PostgreSQL binaries by starting/stopping pg0-embedded -ENV PG0_HOME=/home/hindsight/.pg0-cache - -ENV PG0_HOME=/home/hindsight/.pg0 - -# Pre-download ML models to avoid runtime download (conditional) -ARG PRELOAD_ML_MODELS -RUN if [ "$PRELOAD_ML_MODELS" = "true" ]; then \ - /app/api/.venv/bin/python -c "\ -from sentence_transformers import SentenceTransformer, CrossEncoder; \ -print('Downloading embedding model...'); \ -SentenceTransformer('BAAI/bge-small-en-v1.5'); \ -print('Downloading cross-encoder model...'); \ -CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \ -print('Models cached successfully')"; \ - else echo "Skipping ML model preload"; fi - -EXPOSE 8888 - -ENV HINDSIGHT_API_HOST=0.0.0.0 -ENV HINDSIGHT_API_PORT=8888 -ENV HINDSIGHT_API_LOG_LEVEL=info -ENV HINDSIGHT_ENABLE_API=true -ENV HINDSIGHT_ENABLE_CP=false -ENV PYTHONUNBUFFERED=1 - -CMD ["/app/start-all.sh"] - -# ============================================================================= -# Stage: Final Image - Control Plane Only -# ============================================================================= -FROM node:20-alpine AS cp-only - -WORKDIR /app - -# Copy built SDK -COPY --from=sdk-builder /app/hindsight-clients/typescript /app/sdk - -# Copy Control Plane standalone build -WORKDIR /app/control-plane -COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/standalone ./ -COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/.next/static ./.next/static -COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/public ./public - -WORKDIR /app - -# Copy startup script -COPY docker/standalone/start-all.sh /app/start-all.sh -RUN chmod +x /app/start-all.sh - -# Install curl for health checks -RUN apk add --no-cache curl bash - -EXPOSE 9999 - -ENV NODE_ENV=production -ENV HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888 -ENV HINDSIGHT_ENABLE_API=false -ENV HINDSIGHT_ENABLE_CP=true - -CMD ["/app/start-all.sh"] - -# ============================================================================= -# Stage: Final Image - Standalone (both API and Control Plane) -# ============================================================================= -FROM python:3.11-slim AS standalone - -WORKDIR /app - -# Install Node.js, curl, uv, and pg0 dependencies (procps provides 'kill' command needed by pg0) -# Note: libicu version varies by Debian version - try common versions in order -RUN apt-get update && apt-get install -y \ - curl \ - procps \ - libxml2 \ - libssl3 \ - libgssapi-krb5-2 \ - libossp-uuid16 \ - && (apt-get install -y libicu72 2>/dev/null || apt-get install -y libicu74 2>/dev/null || apt-get install -y libicu76 2>/dev/null || true) \ - && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ - && apt-get install -y nodejs \ - && rm -rf /var/lib/apt/lists/* \ - && pip install --no-cache-dir uv - -# Create non-root user (PostgreSQL cannot run as root) -RUN useradd -m -s /bin/bash hindsight - -# Copy API with virtual environment from builder -COPY --from=api-builder /app/api /app/api - -# Copy built SDK -COPY --from=sdk-builder /app/hindsight-clients/typescript /app/sdk - -# Copy Control Plane standalone build -WORKDIR /app/control-plane -COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/standalone ./ -COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/.next/static ./.next/static -COPY --from=cp-builder /app/memory-poc/hindsight-control-plane/public ./public - -WORKDIR /app - -# Copy startup script -COPY docker/standalone/start-all.sh /app/start-all.sh -RUN chmod +x /app/start-all.sh - -# Create data directory for pg0 and set ownership -RUN mkdir -p /app/data && chown -R hindsight:hindsight /app - -# Switch to non-root user -USER hindsight - -# Set PATH for hindsight user -ENV PATH="/app/api/.venv/bin:${PATH}" - -# Pre-cache PostgreSQL binaries by starting/stopping pg0-embedded -ENV PG0_HOME=/home/hindsight/.pg0-cache -RUN /app/api/.venv/bin/python -c "\ -from pg0 import Pg0; \ -print('Pre-caching PostgreSQL binaries...'); \ -pg = Pg0(name='hindsight', port=5555, username='hindsight', password='hindsight', database='hindsight'); \ -pg.start(); \ -pg.stop(); \ -print('PostgreSQL pre-cached to PG0_HOME')" || echo "Pre-download skipped" - -ENV PG0_HOME=/home/hindsight/.pg0 - -# Pre-download ML models to avoid runtime download (conditional) -ARG PRELOAD_ML_MODELS -RUN if [ "$PRELOAD_ML_MODELS" = "true" ]; then \ - /app/api/.venv/bin/python -c "\ -from sentence_transformers import SentenceTransformer, CrossEncoder; \ -print('Downloading embedding model...'); \ -SentenceTransformer('BAAI/bge-small-en-v1.5'); \ -print('Downloading cross-encoder model...'); \ -CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \ -print('Models cached successfully')"; \ - else echo "Skipping ML model preload"; fi - -EXPOSE 8888 9999 - -ENV HINDSIGHT_API_HOST=0.0.0.0 -ENV HINDSIGHT_API_PORT=8888 -ENV HINDSIGHT_API_LOG_LEVEL=info -ENV NODE_ENV=production -ENV HINDSIGHT_CP_DATAPLANE_API_URL=http://localhost:8888 -ENV HINDSIGHT_ENABLE_API=true -ENV HINDSIGHT_ENABLE_CP=true -ENV PYTHONUNBUFFERED=1 - -CMD ["/app/start-all.sh"] - -# ============================================================================= -# Default target selection based on build args -# ============================================================================= -FROM standalone AS default-both -FROM api-only AS default-api -FROM cp-only AS default-cp - -# This selects the final stage based on INCLUDE_API and INCLUDE_CP -# Use --target to override: docker build --target api-only . -FROM standalone diff --git a/docker/standalone/start-all.sh b/docker/standalone/start-all.sh deleted file mode 100755 index 0206534f..00000000 --- a/docker/standalone/start-all.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/bash -set -e - -# Service flags (default to true if not set) -ENABLE_API="${HINDSIGHT_ENABLE_API:-true}" -ENABLE_CP="${HINDSIGHT_ENABLE_CP:-true}" - -# Copy pre-cached PostgreSQL data if runtime directory is empty (first run with volume) -if [ "$ENABLE_API" = "true" ]; then - PG0_CACHE="/home/hindsight/.pg0-cache" - PG0_HOME="/home/hindsight/.pg0" - if [ -d "$PG0_CACHE" ] && [ "$(ls -A $PG0_CACHE 2>/dev/null)" ]; then - if [ ! "$(ls -A $PG0_HOME 2>/dev/null)" ]; then - echo "📦 Copying pre-cached PostgreSQL data..." - cp -r "$PG0_CACHE"/* "$PG0_HOME"/ 2>/dev/null || true - fi - fi -fi - -# Track PIDs for wait -PIDS=() - -# Start API if enabled -if [ "$ENABLE_API" = "true" ]; then - cd /app/api - # Run API directly - Python's PYTHONUNBUFFERED=1 handles output buffering - hindsight-api & - API_PID=$! - PIDS+=($API_PID) - - # Wait for API to be ready - for i in {1..60}; do - if curl -sf http://localhost:8888/health &>/dev/null; then - break - fi - sleep 1 - done -else - echo "API disabled (HINDSIGHT_ENABLE_API=false)" -fi - -# Start Control Plane if enabled -if [ "$ENABLE_CP" = "true" ]; then - echo "🎛️ Starting Control Plane..." - cd /app/control-plane - PORT=9999 node server.js & - CP_PID=$! - PIDS+=($CP_PID) -else - echo "Control Plane disabled (HINDSIGHT_ENABLE_CP=false)" -fi - -# Print status -echo "" -echo "✅ Hindsight is running!" -echo "" -echo "📍 Access:" -if [ "$ENABLE_CP" = "true" ]; then - echo " Control Plane: http://localhost:9999" -fi -if [ "$ENABLE_API" = "true" ]; then - echo " API: http://localhost:8888" -fi -echo "" - -# Check if any services are running -if [ ${#PIDS[@]} -eq 0 ]; then - echo "❌ No services enabled! Set HINDSIGHT_ENABLE_API=true or HINDSIGHT_ENABLE_CP=true" - exit 1 -fi - -# Wait for any process to exit -wait -n - -# Exit with status of first exited process -exit $? diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 00000000..8a59bf79 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,42 @@ +// @ts-check +import tseslint from 'typescript-eslint'; +import unusedImports from 'eslint-plugin-unused-imports'; +import prettier from 'eslint-plugin-prettier'; + +export default tseslint.config( + { + languageOptions: { + parser: tseslint.parser, + parserOptions: { sourceType: 'module' }, + }, + files: ['**/*.ts', '**/*.mts', '**/*.cts', '**/*.js', '**/*.mjs', '**/*.cjs'], + ignores: ['dist/'], + plugins: { + '@typescript-eslint': tseslint.plugin, + 'unused-imports': unusedImports, + prettier, + }, + rules: { + 'no-unused-vars': 'off', + 'prettier/prettier': 'error', + 'unused-imports/no-unused-imports': 'error', + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + regex: '^hindsight(/.*)?', + message: 'Use a relative import, not a package import.', + }, + ], + }, + ], + }, + }, + { + files: ['tests/**', 'examples/**'], + rules: { + 'no-restricted-imports': 'off', + }, + }, +); diff --git a/examples/.keep b/examples/.keep new file mode 100644 index 00000000..0651c89c --- /dev/null +++ b/examples/.keep @@ -0,0 +1,4 @@ +File generated from our OpenAPI spec by Stainless. + +This directory can be used to store example files demonstrating usage of this SDK. +It is ignored by Stainless code generation and its content (other than this keep file) won't be touched. diff --git a/helm/hindsight/.helmignore b/helm/hindsight/.helmignore deleted file mode 100644 index 0e8a0eb3..00000000 --- a/helm/hindsight/.helmignore +++ /dev/null @@ -1,23 +0,0 @@ -# Patterns to ignore when building packages. -# This supports shell glob matching, relative path matching, and -# negation (prefixed with !). Only one pattern per line. -.DS_Store -# Common VCS dirs -.git/ -.gitignore -.bzr/ -.bzrignore -.hg/ -.hgignore -.svn/ -# Common backup files -*.swp -*.bak -*.tmp -*.orig -*~ -# Various IDEs -.project -.idea/ -*.tmproj -.vscode/ diff --git a/helm/hindsight/Chart.lock b/helm/hindsight/Chart.lock deleted file mode 100644 index 68f77777..00000000 --- a/helm/hindsight/Chart.lock +++ /dev/null @@ -1,6 +0,0 @@ -dependencies: -- name: postgresql - repository: https://charts.bitnami.com/bitnami - version: 15.5.38 -digest: sha256:f67c7612736803ece8a669f8ca6b0555f3b78557bc0ecb732aa2e43f0df7750d -generated: "2025-12-10T17:20:57.058794+01:00" diff --git a/helm/hindsight/Chart.yaml b/helm/hindsight/Chart.yaml deleted file mode 100644 index cd159144..00000000 --- a/helm/hindsight/Chart.yaml +++ /dev/null @@ -1,13 +0,0 @@ -apiVersion: v2 -name: hindsight -description: Hindsight helm chart -type: application -version: 0.1.13 -appVersion: "0.1.13" -keywords: - - ai - - memory - - llm - - agents -maintainers: - - name: Hindsight Team diff --git a/helm/hindsight/README.md b/helm/hindsight/README.md deleted file mode 100644 index d0c55b09..00000000 --- a/helm/hindsight/README.md +++ /dev/null @@ -1,182 +0,0 @@ -# Hindsight Helm Chart - -Helm chart for deploying Hindsight - a temporal-semantic-entity memory system for AI agents. - -## Prerequisites - -- Kubernetes 1.19+ -- Helm 3.0+ -- PostgreSQL database (external or bundled) - -## Quick Start - -```bash -# Update dependencies first -helm dependency update ./helm/hindsight - -# Install (PostgreSQL included by default) -export OPENAI_API_KEY="sk-your-openai-key" -helm upgrade hindsight --install ./helm/hindsight -n hindsight --create-namespace \ - --set api.secrets.HINDSIGHT_API_LLM_API_KEY="$OPENAI_API_KEY" -``` - -To use an external database instead: - -```bash -helm install hindsight ./helm/hindsight -n hindsight --create-namespace \ - --set api.secrets.HINDSIGHT_API_LLM_API_KEY="sk-your-openai-key" \ - --set postgresql.enabled=false \ - --set postgresql.external.host=my-postgres.example.com \ - --set postgresql.external.password=mypassword -``` - -## Installation - -### Add the repository (if published) - -```bash -helm repo add hindsight https://your-helm-repo.com -helm repo update -``` - -### Install with custom values file - -Create a `values-override.yaml`: - -```yaml -api: - secrets: - HINDSIGHT_API_LLM_API_KEY: "sk-your-openai-key" - -postgresql: - external: - host: "my-postgres.example.com" - password: "mypassword" -``` - -Then install: - -```bash -helm install hindsight ./helm/hindsight -n hindsight --create-namespace -f values-override.yaml -``` - -## Configuration - -### Key Values - -| Parameter | Description | Default | -|-----------|-------------|---------| -| `version` | Default image tag for all components | `0.1.0` | -| `api.enabled` | Enable the API component | `true` | -| `api.image.repository` | API image repository | `hindsight/api` | -| `api.image.tag` | API image tag (defaults to `version`) | - | -| `api.service.port` | API service port | `8888` | -| `controlPlane.enabled` | Enable the control plane | `true` | -| `controlPlane.image.repository` | Control plane image repository | `hindsight/control-plane` | -| `controlPlane.image.tag` | Control plane image tag (defaults to `version`) | - | -| `controlPlane.service.port` | Control plane service port | `3000` | -| `postgresql.enabled` | Deploy PostgreSQL as subchart | `true` | -| `postgresql.external.host` | External PostgreSQL host | `postgresql` | -| `postgresql.external.port` | External PostgreSQL port | `5432` | -| `postgresql.external.database` | Database name | `hindsight` | -| `postgresql.external.username` | Database username | `hindsight` | -| `ingress.enabled` | Enable ingress | `false` | -| `autoscaling.enabled` | Enable HPA | `false` | - -### Environment Variables - -All environment variables in `api.env` and `controlPlane.env` are automatically added to the respective pods. Sensitive values should go in `api.secrets` or `controlPlane.secrets`. - -```yaml -api: - env: - HINDSIGHT_API_LLM_PROVIDER: "openai" - HINDSIGHT_API_LLM_MODEL: "gpt-4" - secrets: - HINDSIGHT_API_LLM_API_KEY: "your-api-key" - HINDSIGHT_API_LLM_BASE_URL: "https://api.openai.com/v1" - -controlPlane: - env: - NODE_ENV: "production" - secrets: {} -``` - -### External Database - -To connect to an external PostgreSQL database: - -```yaml -postgresql: - enabled: false - external: - host: "my-postgres.example.com" - port: 5432 - database: "hindsight" - username: "hindsight" - password: "your-password" -``` - -### Ingress - -To expose the services via ingress: - -```yaml -ingress: - enabled: true - className: "nginx" - annotations: - cert-manager.io/cluster-issuer: "letsencrypt-prod" - hosts: - - host: hindsight.example.com - paths: - - path: / - pathType: Prefix - service: controlPlane - - path: /api - pathType: Prefix - service: api - tls: - - secretName: hindsight-tls - hosts: - - hindsight.example.com -``` - -## Upgrading - -```bash -helm upgrade hindsight ./helm/hindsight -n hindsight -``` - -## Uninstalling - -```bash -helm uninstall hindsight -n hindsight -``` - -## Components - -The chart deploys: - -- **API**: The main Hindsight API server for memory operations -- **Control Plane**: Web UI for managing agents and viewing memories - -## Development - -### Lint the chart - -```bash -helm lint ./helm/hindsight -``` - -### Template locally - -```bash -helm template hindsight ./helm/hindsight --debug -``` - -### Dry run installation - -```bash -helm install hindsight ./helm/hindsight --dry-run --debug -``` diff --git a/helm/hindsight/templates/NOTES.txt b/helm/hindsight/templates/NOTES.txt deleted file mode 100644 index d269305f..00000000 --- a/helm/hindsight/templates/NOTES.txt +++ /dev/null @@ -1,2 +0,0 @@ -Hindsight installed. Access the control plane: - kubectl port-forward -n {{ .Release.Namespace }} svc/{{ include "hindsight.fullname" . }}-control-plane 3000:3000 diff --git a/helm/hindsight/templates/_helpers.tpl b/helm/hindsight/templates/_helpers.tpl deleted file mode 100644 index 9b6efc8b..00000000 --- a/helm/hindsight/templates/_helpers.tpl +++ /dev/null @@ -1,112 +0,0 @@ -{{/* -Expand the name of the chart. -*/}} -{{- define "hindsight.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Create a default fully qualified app name. -*/}} -{{- define "hindsight.fullname" -}} -{{- if .Values.fullnameOverride }} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- $name := default .Chart.Name .Values.nameOverride }} -{{- if contains $name .Release.Name }} -{{- .Release.Name | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} -{{- end }} -{{- end }} -{{- end }} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "hindsight.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Common labels -*/}} -{{- define "hindsight.labels" -}} -helm.sh/chart: {{ include "hindsight.chart" . }} -{{ include "hindsight.selectorLabels" . }} -{{- if .Chart.AppVersion }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} -{{- end }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end }} - -{{/* -Selector labels -*/}} -{{- define "hindsight.selectorLabels" -}} -app.kubernetes.io/name: {{ include "hindsight.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} - -{{/* -API labels -*/}} -{{- define "hindsight.api.labels" -}} -{{ include "hindsight.labels" . }} -app.kubernetes.io/component: api -{{- end }} - -{{/* -API selector labels -*/}} -{{- define "hindsight.api.selectorLabels" -}} -{{ include "hindsight.selectorLabels" . }} -app.kubernetes.io/component: api -{{- end }} - -{{/* -Control plane labels -*/}} -{{- define "hindsight.controlPlane.labels" -}} -{{ include "hindsight.labels" . }} -app.kubernetes.io/component: control-plane -{{- end }} - -{{/* -Control plane selector labels -*/}} -{{- define "hindsight.controlPlane.selectorLabels" -}} -{{ include "hindsight.selectorLabels" . }} -app.kubernetes.io/component: control-plane -{{- end }} - -{{/* -Create the name of the service account to use -*/}} -{{- define "hindsight.serviceAccountName" -}} -{{- if .Values.serviceAccount.create }} -{{- default (include "hindsight.fullname" .) .Values.serviceAccount.name }} -{{- else }} -{{- default "default" .Values.serviceAccount.name }} -{{- end }} -{{- end }} - -{{/* -Generate database URL -*/}} -{{- define "hindsight.databaseUrl" -}} -{{- if .Values.databaseUrl }} -{{- .Values.databaseUrl }} -{{- else if .Values.postgresql.enabled }} -{{- printf "postgresql://%s:%s@%s-postgresql:%d/%s" .Values.postgresql.auth.username .Values.postgresql.auth.password (include "hindsight.fullname" .) (.Values.postgresql.service.port | int) .Values.postgresql.auth.database }} -{{- else }} -{{- printf "postgresql://%s:$(POSTGRES_PASSWORD)@%s:%d/%s" .Values.postgresql.external.username .Values.postgresql.external.host (.Values.postgresql.external.port | int) .Values.postgresql.external.database }} -{{- end }} -{{- end }} - -{{/* -API URL for control plane -*/}} -{{- define "hindsight.apiUrl" -}} -{{- printf "http://%s-api:%d" (include "hindsight.fullname" .) (.Values.api.service.port | int) }} -{{- end }} diff --git a/helm/hindsight/templates/api-deployment.yaml b/helm/hindsight/templates/api-deployment.yaml deleted file mode 100644 index ad188e8d..00000000 --- a/helm/hindsight/templates/api-deployment.yaml +++ /dev/null @@ -1,79 +0,0 @@ -{{- if .Values.api.enabled }} -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "hindsight.fullname" . }}-api - labels: - {{- include "hindsight.api.labels" . | nindent 4 }} -spec: - {{- if not .Values.autoscaling.enabled }} - replicas: {{ .Values.api.replicaCount }} - {{- end }} - selector: - matchLabels: - {{- include "hindsight.api.selectorLabels" . | nindent 6 }} - template: - metadata: - annotations: - checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} - {{- with .Values.podAnnotations }} - {{- toYaml . | nindent 8 }} - {{- end }} - labels: - {{- include "hindsight.api.selectorLabels" . | nindent 8 }} - spec: - {{- if .Values.serviceAccount.create }} - serviceAccountName: {{ include "hindsight.serviceAccountName" . }} - {{- end }} - securityContext: - {{- toYaml .Values.podSecurityContext | nindent 8 }} - containers: - - name: api - securityContext: - {{- toYaml .Values.securityContext | nindent 10 }} - image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Values.version }}" - imagePullPolicy: {{ .Values.api.image.pullPolicy }} - ports: - - name: http - containerPort: {{ .Values.api.service.targetPort }} - protocol: TCP - env: - - name: HINDSIGHT_API_DATABASE_URL - value: {{ include "hindsight.databaseUrl" . | quote }} - {{- if not .Values.postgresql.enabled }} - - name: POSTGRES_PASSWORD - valueFrom: - secretKeyRef: - name: {{ include "hindsight.fullname" . }}-secret - key: postgres-password - {{- end }} - {{- range $key, $value := .Values.api.env }} - - name: {{ $key }} - value: {{ $value | quote }} - {{- end }} - {{- range $key, $value := .Values.api.secrets }} - - name: {{ $key }} - valueFrom: - secretKeyRef: - name: {{ include "hindsight.fullname" $ }}-secret - key: {{ $key }} - {{- end }} - livenessProbe: - {{- toYaml .Values.api.livenessProbe | nindent 10 }} - readinessProbe: - {{- toYaml .Values.api.readinessProbe | nindent 10 }} - resources: - {{- toYaml .Values.api.resources | nindent 10 }} - {{- with .Values.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} -{{- end }} diff --git a/helm/hindsight/templates/api-service.yaml b/helm/hindsight/templates/api-service.yaml deleted file mode 100644 index e2a5f889..00000000 --- a/helm/hindsight/templates/api-service.yaml +++ /dev/null @@ -1,17 +0,0 @@ -{{- if .Values.api.enabled }} -apiVersion: v1 -kind: Service -metadata: - name: {{ include "hindsight.fullname" . }}-api - labels: - {{- include "hindsight.api.labels" . | nindent 4 }} -spec: - type: {{ .Values.api.service.type }} - ports: - - port: {{ .Values.api.service.port }} - targetPort: http - protocol: TCP - name: http - selector: - {{- include "hindsight.api.selectorLabels" . | nindent 4 }} -{{- end }} diff --git a/helm/hindsight/templates/controlplane-deployment.yaml b/helm/hindsight/templates/controlplane-deployment.yaml deleted file mode 100644 index 1704330d..00000000 --- a/helm/hindsight/templates/controlplane-deployment.yaml +++ /dev/null @@ -1,72 +0,0 @@ -{{- if .Values.controlPlane.enabled }} -apiVersion: apps/v1 -kind: Deployment -metadata: - name: {{ include "hindsight.fullname" . }}-control-plane - labels: - {{- include "hindsight.controlPlane.labels" . | nindent 4 }} -spec: - {{- if not .Values.autoscaling.enabled }} - replicas: {{ .Values.controlPlane.replicaCount }} - {{- end }} - selector: - matchLabels: - {{- include "hindsight.controlPlane.selectorLabels" . | nindent 6 }} - template: - metadata: - annotations: - checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }} - {{- with .Values.podAnnotations }} - {{- toYaml . | nindent 8 }} - {{- end }} - labels: - {{- include "hindsight.controlPlane.selectorLabels" . | nindent 8 }} - spec: - {{- if .Values.serviceAccount.create }} - serviceAccountName: {{ include "hindsight.serviceAccountName" . }} - {{- end }} - securityContext: - {{- toYaml .Values.podSecurityContext | nindent 8 }} - containers: - - name: control-plane - securityContext: - {{- toYaml .Values.securityContext | nindent 10 }} - image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.controlPlane.image.tag | default .Values.version }}" - imagePullPolicy: {{ .Values.controlPlane.image.pullPolicy }} - ports: - - name: http - containerPort: {{ .Values.controlPlane.service.targetPort }} - protocol: TCP - env: - - name: HINDSIGHT_CP_DATAPLANE_API_URL - value: {{ include "hindsight.apiUrl" . | quote }} - {{- range $key, $value := .Values.controlPlane.env }} - - name: {{ $key }} - value: {{ $value | quote }} - {{- end }} - {{- range $key, $value := .Values.controlPlane.secrets }} - - name: {{ $key }} - valueFrom: - secretKeyRef: - name: {{ include "hindsight.fullname" $ }}-secret - key: {{ $key }} - {{- end }} - livenessProbe: - {{- toYaml .Values.controlPlane.livenessProbe | nindent 10 }} - readinessProbe: - {{- toYaml .Values.controlPlane.readinessProbe | nindent 10 }} - resources: - {{- toYaml .Values.controlPlane.resources | nindent 10 }} - {{- with .Values.nodeSelector }} - nodeSelector: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.affinity }} - affinity: - {{- toYaml . | nindent 8 }} - {{- end }} - {{- with .Values.tolerations }} - tolerations: - {{- toYaml . | nindent 8 }} - {{- end }} -{{- end }} diff --git a/helm/hindsight/templates/controlplane-service.yaml b/helm/hindsight/templates/controlplane-service.yaml deleted file mode 100644 index a8e601cb..00000000 --- a/helm/hindsight/templates/controlplane-service.yaml +++ /dev/null @@ -1,17 +0,0 @@ -{{- if .Values.controlPlane.enabled }} -apiVersion: v1 -kind: Service -metadata: - name: {{ include "hindsight.fullname" . }}-control-plane - labels: - {{- include "hindsight.controlPlane.labels" . | nindent 4 }} -spec: - type: {{ .Values.controlPlane.service.type }} - ports: - - port: {{ .Values.controlPlane.service.port }} - targetPort: http - protocol: TCP - name: http - selector: - {{- include "hindsight.controlPlane.selectorLabels" . | nindent 4 }} -{{- end }} diff --git a/helm/hindsight/templates/hpa.yaml b/helm/hindsight/templates/hpa.yaml deleted file mode 100644 index 97b16888..00000000 --- a/helm/hindsight/templates/hpa.yaml +++ /dev/null @@ -1,64 +0,0 @@ -{{- if .Values.autoscaling.enabled }} ---- -apiVersion: autoscaling/v2 -kind: HorizontalPodAutoscaler -metadata: - name: {{ include "hindsight.fullname" . }}-api - labels: - {{- include "hindsight.api.labels" . | nindent 4 }} -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: {{ include "hindsight.fullname" . }}-api - minReplicas: {{ .Values.autoscaling.minReplicas }} - maxReplicas: {{ .Values.autoscaling.maxReplicas }} - metrics: - {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} - {{- end }} - {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} - - type: Resource - resource: - name: memory - target: - type: Utilization - averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} - {{- end }} ---- -apiVersion: autoscaling/v2 -kind: HorizontalPodAutoscaler -metadata: - name: {{ include "hindsight.fullname" . }}-control-plane - labels: - {{- include "hindsight.controlPlane.labels" . | nindent 4 }} -spec: - scaleTargetRef: - apiVersion: apps/v1 - kind: Deployment - name: {{ include "hindsight.fullname" . }}-control-plane - minReplicas: {{ .Values.autoscaling.minReplicas }} - maxReplicas: {{ .Values.autoscaling.maxReplicas }} - metrics: - {{- if .Values.autoscaling.targetCPUUtilizationPercentage }} - - type: Resource - resource: - name: cpu - target: - type: Utilization - averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }} - {{- end }} - {{- if .Values.autoscaling.targetMemoryUtilizationPercentage }} - - type: Resource - resource: - name: memory - target: - type: Utilization - averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }} - {{- end }} -{{- end }} diff --git a/helm/hindsight/templates/ingress.yaml b/helm/hindsight/templates/ingress.yaml deleted file mode 100644 index fdbe17d2..00000000 --- a/helm/hindsight/templates/ingress.yaml +++ /dev/null @@ -1,47 +0,0 @@ -{{- if .Values.ingress.enabled }} -apiVersion: networking.k8s.io/v1 -kind: Ingress -metadata: - name: {{ include "hindsight.fullname" . }} - labels: - {{- include "hindsight.labels" . | nindent 4 }} - {{- with .Values.ingress.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -spec: - {{- if .Values.ingress.className }} - ingressClassName: {{ .Values.ingress.className }} - {{- end }} - {{- if .Values.ingress.tls }} - tls: - {{- range .Values.ingress.tls }} - - hosts: - {{- range .hosts }} - - {{ . | quote }} - {{- end }} - secretName: {{ .secretName }} - {{- end }} - {{- end }} - rules: - {{- range .Values.ingress.hosts }} - - host: {{ .host | quote }} - http: - paths: - {{- range .paths }} - - path: {{ .path }} - pathType: {{ .pathType }} - backend: - service: - {{- if eq .service "api" }} - name: {{ include "hindsight.fullname" $ }}-api - port: - number: {{ $.Values.api.service.port }} - {{- else if eq .service "controlPlane" }} - name: {{ include "hindsight.fullname" $ }}-control-plane - port: - number: {{ $.Values.controlPlane.service.port }} - {{- end }} - {{- end }} - {{- end }} -{{- end }} diff --git a/helm/hindsight/templates/postgresql-service.yaml b/helm/hindsight/templates/postgresql-service.yaml deleted file mode 100644 index f27d5e77..00000000 --- a/helm/hindsight/templates/postgresql-service.yaml +++ /dev/null @@ -1,19 +0,0 @@ -{{- if .Values.postgresql.enabled }} -apiVersion: v1 -kind: Service -metadata: - name: {{ include "hindsight.fullname" . }}-postgresql - labels: - {{- include "hindsight.labels" . | nindent 4 }} - app.kubernetes.io/component: postgresql -spec: - type: ClusterIP - ports: - - port: {{ .Values.postgresql.service.port }} - targetPort: postgresql - protocol: TCP - name: postgresql - selector: - {{- include "hindsight.selectorLabels" . | nindent 4 }} - app.kubernetes.io/component: postgresql -{{- end }} diff --git a/helm/hindsight/templates/postgresql-statefulset.yaml b/helm/hindsight/templates/postgresql-statefulset.yaml deleted file mode 100644 index 16db5286..00000000 --- a/helm/hindsight/templates/postgresql-statefulset.yaml +++ /dev/null @@ -1,85 +0,0 @@ -{{- if .Values.postgresql.enabled }} -apiVersion: apps/v1 -kind: StatefulSet -metadata: - name: {{ include "hindsight.fullname" . }}-postgresql - labels: - {{- include "hindsight.labels" . | nindent 4 }} - app.kubernetes.io/component: postgresql -spec: - serviceName: {{ include "hindsight.fullname" . }}-postgresql - replicas: 1 - selector: - matchLabels: - {{- include "hindsight.selectorLabels" . | nindent 6 }} - app.kubernetes.io/component: postgresql - template: - metadata: - labels: - {{- include "hindsight.selectorLabels" . | nindent 8 }} - app.kubernetes.io/component: postgresql - spec: - containers: - - name: postgresql - image: "{{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }}" - imagePullPolicy: {{ .Values.postgresql.image.pullPolicy }} - ports: - - name: postgresql - containerPort: 5432 - protocol: TCP - env: - - name: POSTGRES_USER - value: {{ .Values.postgresql.auth.username | quote }} - - name: POSTGRES_PASSWORD - value: {{ .Values.postgresql.auth.password | quote }} - - name: POSTGRES_DB - value: {{ .Values.postgresql.auth.database | quote }} - - name: PGDATA - value: /var/lib/postgresql/data/pgdata - livenessProbe: - exec: - command: - - pg_isready - - -U - - {{ .Values.postgresql.auth.username }} - - -d - - {{ .Values.postgresql.auth.database }} - initialDelaySeconds: 30 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 - readinessProbe: - exec: - command: - - pg_isready - - -U - - {{ .Values.postgresql.auth.username }} - - -d - - {{ .Values.postgresql.auth.database }} - initialDelaySeconds: 5 - periodSeconds: 5 - timeoutSeconds: 3 - failureThreshold: 3 - resources: - {{- toYaml .Values.postgresql.resources | nindent 10 }} - volumeMounts: - - name: data - mountPath: /var/lib/postgresql/data - {{- if .Values.postgresql.persistence.enabled }} - volumeClaimTemplates: - - metadata: - name: data - spec: - accessModes: ["ReadWriteOnce"] - {{- if .Values.postgresql.persistence.storageClass }} - storageClassName: {{ .Values.postgresql.persistence.storageClass | quote }} - {{- end }} - resources: - requests: - storage: {{ .Values.postgresql.persistence.size }} - {{- else }} - volumes: - - name: data - emptyDir: {} - {{- end }} -{{- end }} diff --git a/helm/hindsight/templates/secret.yaml b/helm/hindsight/templates/secret.yaml deleted file mode 100644 index 8cd6e800..00000000 --- a/helm/hindsight/templates/secret.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: v1 -kind: Secret -metadata: - name: {{ include "hindsight.fullname" . }}-secret - labels: - {{- include "hindsight.labels" . | nindent 4 }} -type: Opaque -data: - {{- range $key, $value := .Values.api.secrets }} - {{ $key }}: {{ $value | b64enc | quote }} - {{- end }} - {{- range $key, $value := .Values.controlPlane.secrets }} - {{ $key }}: {{ $value | b64enc | quote }} - {{- end }} - {{- if and (not .Values.postgresql.enabled) .Values.postgresql.external.password }} - postgres-password: {{ .Values.postgresql.external.password | b64enc | quote }} - {{- end }} diff --git a/helm/hindsight/templates/serviceaccount.yaml b/helm/hindsight/templates/serviceaccount.yaml deleted file mode 100644 index 6efb5316..00000000 --- a/helm/hindsight/templates/serviceaccount.yaml +++ /dev/null @@ -1,12 +0,0 @@ -{{- if .Values.serviceAccount.create -}} -apiVersion: v1 -kind: ServiceAccount -metadata: - name: {{ include "hindsight.serviceAccountName" . }} - labels: - {{- include "hindsight.labels" . | nindent 4 }} - {{- with .Values.serviceAccount.annotations }} - annotations: - {{- toYaml . | nindent 4 }} - {{- end }} -{{- end }} diff --git a/helm/hindsight/values.yaml b/helm/hindsight/values.yaml deleted file mode 100644 index dcbe3b68..00000000 --- a/helm/hindsight/values.yaml +++ /dev/null @@ -1,208 +0,0 @@ -# Default values for hindsight - -# Chart version - use this to set a consistent image tag across all components -version: "0.1.1" - -# Global settings -replicaCount: 1 - -# Image settings for api -api: - enabled: true - replicaCount: 1 - image: - repository: ghcr.io/vectorize-io/hindsight-api - pullPolicy: IfNotPresent - # tag defaults to .Values.version if not specified - - service: - type: ClusterIP - port: 8888 - targetPort: 8888 - - # Resource limits and requests - resources: - limits: - cpu: 2000m - memory: 4Gi - requests: - cpu: 500m - memory: 1Gi - - # Liveness and readiness probes - livenessProbe: - httpGet: - path: /health - port: 8888 - initialDelaySeconds: 30 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 - - readinessProbe: - httpGet: - path: /health - port: 8888 - initialDelaySeconds: 10 - periodSeconds: 5 - timeoutSeconds: 3 - failureThreshold: 3 - - # Environment variables - env: - #HINDSIGHT_API_LLM_PROVIDER: "groq" - HINDSIGHT_API_LLM_MODEL: "openai/gpt-oss-120b" - - # Secret environment variables - secrets: - # HINDSIGHT_API_LLM_API_KEY: "your-api-key" - # HINDSIGHT_API_LLM_BASE_URL: "https://api.groq.com/openai/v1" - -# Image settings for control plane -controlPlane: - enabled: true - replicaCount: 1 - image: - repository: ghcr.io/vectorize-io/hindsight-control-plane - pullPolicy: IfNotPresent - # tag defaults to .Values.version if not specified - - service: - type: ClusterIP - port: 3000 - targetPort: 3000 - - # Resource limits and requests - resources: - limits: - cpu: 1000m - memory: 2Gi - requests: - cpu: 250m - memory: 512Mi - - # Liveness and readiness probes (TCP check) - livenessProbe: - tcpSocket: - port: 3000 - initialDelaySeconds: 30 - periodSeconds: 10 - timeoutSeconds: 5 - failureThreshold: 3 - - readinessProbe: - tcpSocket: - port: 3000 - initialDelaySeconds: 10 - periodSeconds: 5 - timeoutSeconds: 3 - failureThreshold: 3 - - # Environment variables - env: - NODE_ENV: "production" - HINDSIGHT_CP_HOSTNAME: "0.0.0.0" - HINDSIGHT_CP_PORT: "3000" - -# PostgreSQL configuration -postgresql: - # Set to true to deploy PostgreSQL as part of this chart - enabled: true - - image: - repository: ankane/pgvector - tag: latest - pullPolicy: IfNotPresent - - auth: - username: "hindsight" - password: "hindsight" - database: "hindsight" - - service: - port: 5432 - - persistence: - enabled: true - size: 8Gi - # storageClass: "" - - resources: - limits: - cpu: 1000m - memory: 1Gi - requests: - cpu: 250m - memory: 256Mi - - # External PostgreSQL connection details - # Only used if postgresql.enabled is false - external: - host: "postgresql" - port: 5432 - database: "hindsight" - username: "hindsight" - # password: "" - -# Ingress configuration -ingress: - enabled: false - className: "nginx" - annotations: {} - # cert-manager.io/cluster-issuer: "letsencrypt-prod" - # nginx.ingress.kubernetes.io/ssl-redirect: "true" - - hosts: - - host: hindsight.example.com - paths: - - path: / - pathType: Prefix - service: controlPlane - - path: /api - pathType: Prefix - service: api - - tls: [] - # - secretName: hindsight-tls - # hosts: - # - hindsight.example.com - -# Service Account -serviceAccount: - create: true - annotations: {} - name: "" - -# Pod annotations -podAnnotations: {} - -# Pod security context -podSecurityContext: - fsGroup: 1000 - -# Security context -securityContext: - runAsNonRoot: true - runAsUser: 1000 - capabilities: - drop: - - ALL - readOnlyRootFilesystem: false - allowPrivilegeEscalation: false - -# Node selector -nodeSelector: {} - -# Tolerations -tolerations: [] - -# Affinity -affinity: {} - -# Autoscaling -autoscaling: - enabled: false - minReplicas: 1 - maxReplicas: 10 - targetCPUUtilizationPercentage: 80 - targetMemoryUtilizationPercentage: 80 diff --git a/hindsight-api/README.md b/hindsight-api/README.md deleted file mode 100644 index 8b35462e..00000000 --- a/hindsight-api/README.md +++ /dev/null @@ -1,137 +0,0 @@ -# Hindsight API - -**Memory System for AI Agents** — Temporal + Semantic + Entity Memory Architecture using PostgreSQL with pgvector. - -Hindsight gives AI agents persistent memory that works like human memory: it stores facts, tracks entities and relationships, handles temporal reasoning ("what happened last spring?"), and forms opinions based on configurable disposition traits. - -## Installation - -```bash -pip install hindsight-api -``` - -## Quick Start - -### Run the Server - -```bash -# Set your LLM provider -export HINDSIGHT_API_LLM_PROVIDER=openai -export HINDSIGHT_API_LLM_API_KEY=sk-xxxxxxxxxxxx - -# Start the server (uses embedded PostgreSQL by default) -hindsight-api -``` - -The server starts at http://localhost:8888 with: -- REST API for memory operations -- MCP server at `/mcp` for tool-use integration - -### Use the Python API - -```python -from hindsight_api import MemoryEngine - -# Create and initialize the memory engine -memory = MemoryEngine() -await memory.initialize() - -# Create a memory bank for your agent -bank = await memory.create_memory_bank( - name="my-assistant", - background="A helpful coding assistant" -) - -# Store a memory -await memory.retain( - memory_bank_id=bank.id, - content="The user prefers Python for data science projects" -) - -# Recall memories -results = await memory.recall( - memory_bank_id=bank.id, - query="What programming language does the user prefer?" -) - -# Reflect with reasoning -response = await memory.reflect( - memory_bank_id=bank.id, - query="Should I recommend Python or R for this ML project?" -) -``` - -## CLI Options - -```bash -hindsight-api --help - -# Common options -hindsight-api --port 9000 # Custom port (default: 8888) -hindsight-api --host 127.0.0.1 # Bind to localhost only -hindsight-api --workers 4 # Multiple worker processes -hindsight-api --log-level debug # Verbose logging -``` - -## Configuration - -Configure via environment variables: - -| Variable | Description | Default | -|----------|-------------|---------| -| `HINDSIGHT_API_DATABASE_URL` | PostgreSQL connection string | `pg0` (embedded) | -| `HINDSIGHT_API_LLM_PROVIDER` | `openai`, `groq`, `gemini`, `ollama` | `openai` | -| `HINDSIGHT_API_LLM_API_KEY` | API key for LLM provider | - | -| `HINDSIGHT_API_LLM_MODEL` | Model name | `gpt-4o-mini` | -| `HINDSIGHT_API_HOST` | Server bind address | `0.0.0.0` | -| `HINDSIGHT_API_PORT` | Server port | `8888` | - -### Example with External PostgreSQL - -```bash -export HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@localhost:5432/hindsight -export HINDSIGHT_API_LLM_PROVIDER=groq -export HINDSIGHT_API_LLM_API_KEY=gsk_xxxxxxxxxxxx - -hindsight-api -``` - -## Docker - -```bash -docker run --rm -it -p 8888:8888 \ - -e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \ - -v $HOME/.hindsight-docker:/home/hindsight/.pg0 \ - ghcr.io/vectorize-io/hindsight:latest -``` - -## MCP Server - -For local MCP integration without running the full API server: - -```bash -hindsight-local-mcp -``` - -This runs a stdio-based MCP server that can be used directly with MCP-compatible clients. - -## Key Features - -- **Multi-Strategy Retrieval (TEMPR)** — Semantic, keyword, graph, and temporal search combined with RRF fusion -- **Entity Graph** — Automatic entity extraction and relationship tracking -- **Temporal Reasoning** — Native support for time-based queries -- **Disposition Traits** — Configurable skepticism, literalism, and empathy influence opinion formation -- **Three Memory Types** — World facts, bank actions, and formed opinions with confidence scores - -## Documentation - -Full documentation: [https://hindsight.vectorize.io](https://hindsight.vectorize.io) - -- [Installation Guide](https://hindsight.vectorize.io/developer/installation) -- [Configuration Reference](https://hindsight.vectorize.io/developer/configuration) -- [API Reference](https://hindsight.vectorize.io/api-reference) -- [Python SDK](https://hindsight.vectorize.io/sdks/python) - -## License - -Apache 2.0 diff --git a/hindsight-api/hindsight_api/__init__.py b/hindsight-api/hindsight_api/__init__.py deleted file mode 100644 index 6440e593..00000000 --- a/hindsight-api/hindsight_api/__init__.py +++ /dev/null @@ -1,49 +0,0 @@ -""" -Memory System for AI Agents. - -Temporal + Semantic Memory Architecture using PostgreSQL with pgvector. -""" - -from .config import HindsightConfig, get_config -from .engine.cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder -from .engine.embeddings import Embeddings, LocalSTEmbeddings, RemoteTEIEmbeddings -from .engine.llm_wrapper import LLMConfig -from .engine.memory_engine import MemoryEngine -from .engine.search.trace import ( - EntryPoint, - LinkInfo, - NodeVisit, - PruningDecision, - QueryInfo, - SearchPhaseMetrics, - SearchSummary, - SearchTrace, - WeightComponents, -) -from .engine.search.tracer import SearchTracer -from .models import RequestContext - -__all__ = [ - "MemoryEngine", - "RequestContext", - "HindsightConfig", - "get_config", - "SearchTrace", - "SearchTracer", - "QueryInfo", - "EntryPoint", - "NodeVisit", - "WeightComponents", - "LinkInfo", - "PruningDecision", - "SearchSummary", - "SearchPhaseMetrics", - "Embeddings", - "LocalSTEmbeddings", - "RemoteTEIEmbeddings", - "CrossEncoderModel", - "LocalSTCrossEncoder", - "RemoteTEICrossEncoder", - "LLMConfig", -] -__version__ = "0.1.0" diff --git a/hindsight-api/hindsight_api/alembic/README b/hindsight-api/hindsight_api/alembic/README deleted file mode 100644 index 98e4f9c4..00000000 --- a/hindsight-api/hindsight_api/alembic/README +++ /dev/null @@ -1 +0,0 @@ -Generic single-database configuration. \ No newline at end of file diff --git a/hindsight-api/hindsight_api/alembic/env.py b/hindsight-api/hindsight_api/alembic/env.py deleted file mode 100644 index 80dd804c..00000000 --- a/hindsight-api/hindsight_api/alembic/env.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -Alembic environment configuration for SQLAlchemy with pgvector. -Uses synchronous psycopg2 driver for migrations to avoid pgbouncer issues. -""" - -import logging -import os -from pathlib import Path - -from alembic import context -from dotenv import load_dotenv -from sqlalchemy import engine_from_config, pool - -# Import your models here -from hindsight_api.models import Base - - -# Load environment variables based on HINDSIGHT_API_DATABASE_URL env var or default to local -def load_env(): - """Load environment variables from .env""" - # Check if HINDSIGHT_API_DATABASE_URL is already set (e.g., by CI/CD) - if os.getenv("HINDSIGHT_API_DATABASE_URL"): - return - - # Look for .env file in the parent directory (root of the workspace) - root_dir = Path(__file__).parent.parent.parent - env_file = root_dir / ".env" - - if env_file.exists(): - load_dotenv(env_file) - - -load_env() - -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config - -# Note: We don't call fileConfig() here to avoid overriding the application's logging configuration. -# Alembic will use the existing logging configuration from the application. - -# add your model's MetaData object here -# for 'autogenerate' support -target_metadata = Base.metadata - -# other values from the config, defined by the needs of env.py, -# can be acquired: -# my_important_option = config.get_main_option("my_important_option") -# ... etc. - - -def get_database_url() -> str: - """ - Get and process the database URL from config or environment. - - Returns the URL with the correct driver (psycopg2) for migrations. - """ - # Get database URL from config (set programmatically) or environment - database_url = config.get_main_option("sqlalchemy.url") - if not database_url: - database_url = os.getenv("HINDSIGHT_API_DATABASE_URL") - if not database_url: - raise ValueError( - "Database URL not found. " - "Set HINDSIGHT_API_DATABASE_URL environment variable or pass database_url to run_migrations()." - ) - - # For migrations, use psycopg2 (sync driver) to avoid pgbouncer prepared statement issues - if database_url.startswith("postgresql+asyncpg://"): - database_url = database_url.replace("postgresql+asyncpg://", "postgresql://", 1) - elif database_url.startswith("postgres+asyncpg://"): - database_url = database_url.replace("postgres+asyncpg://", "postgresql://", 1) - - # Update config with processed URL for engine_from_config to use - config.set_main_option("sqlalchemy.url", database_url) - - return database_url - - -def run_migrations_offline() -> None: - """Run migrations in 'offline' mode. - - This configures the context with just a URL - and not an Engine, though an Engine is acceptable - here as well. By skipping the Engine creation - we don't even need a DBAPI to be available. - - Calls to context.execute() here emit the given string to the - script output. - - """ - logging.info("running offline") - database_url = get_database_url() - - context.configure( - url=database_url, - target_metadata=target_metadata, - literal_binds=True, - dialect_opts={"paramstyle": "named"}, - ) - - with context.begin_transaction(): - context.run_migrations() - - -def run_migrations_online() -> None: - """Run migrations in 'online' mode with synchronous engine.""" - from sqlalchemy import event, text - - get_database_url() # Process and set the database URL in config - - # Check if we're targeting a specific schema (for multi-tenant isolation) - target_schema = config.get_main_option("target_schema") - - connectable = engine_from_config( - config.get_section(config.config_ini_section, {}), - prefix="sqlalchemy.", - poolclass=pool.NullPool, - ) - - # Add event listener to ensure connection is in read-write mode - # This is needed for Supabase which may start connections in read-only mode - @event.listens_for(connectable, "connect") - def set_read_write_mode(dbapi_connection, connection_record): - cursor = dbapi_connection.cursor() - cursor.execute("SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE") - # If targeting a specific schema, set search_path - # Include public in search_path for access to shared extensions (pgvector) - if target_schema: - cursor.execute(f'CREATE SCHEMA IF NOT EXISTS "{target_schema}"') - cursor.execute(f'SET search_path TO "{target_schema}", public') - cursor.close() - - with connectable.connect() as connection: - # Also explicitly set read-write mode on this connection - connection.execute(text("SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE")) - - # If targeting a specific schema, set search_path - # Include public in search_path for access to shared extensions (pgvector) - if target_schema: - connection.execute(text(f'CREATE SCHEMA IF NOT EXISTS "{target_schema}"')) - connection.execute(text(f'SET search_path TO "{target_schema}", public')) - - connection.commit() # Commit the SET command - - # Configure context with version_table_schema if using a specific schema - context_opts = { - "connection": connection, - "target_metadata": target_metadata, - } - if target_schema: - context_opts["version_table_schema"] = target_schema - - context.configure(**context_opts) - - with context.begin_transaction(): - context.run_migrations() - - # Explicit commit to ensure changes are persisted (especially for Supabase) - connection.commit() - - -if context.is_offline_mode(): - run_migrations_offline() -else: - run_migrations_online() diff --git a/hindsight-api/hindsight_api/alembic/script.py.mako b/hindsight-api/hindsight_api/alembic/script.py.mako deleted file mode 100644 index 11016301..00000000 --- a/hindsight-api/hindsight_api/alembic/script.py.mako +++ /dev/null @@ -1,28 +0,0 @@ -"""${message} - -Revision ID: ${up_revision} -Revises: ${down_revision | comma,n} -Create Date: ${create_date} - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -${imports if imports else ""} - -# revision identifiers, used by Alembic. -revision: str = ${repr(up_revision)} -down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} -branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} -depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} - - -def upgrade() -> None: - """Upgrade schema.""" - ${upgrades if upgrades else "pass"} - - -def downgrade() -> None: - """Downgrade schema.""" - ${downgrades if downgrades else "pass"} diff --git a/hindsight-api/hindsight_api/alembic/versions/5a366d414dce_initial_schema.py b/hindsight-api/hindsight_api/alembic/versions/5a366d414dce_initial_schema.py deleted file mode 100644 index 866e37dd..00000000 --- a/hindsight-api/hindsight_api/alembic/versions/5a366d414dce_initial_schema.py +++ /dev/null @@ -1,360 +0,0 @@ -"""initial_schema - -Revision ID: 5a366d414dce -Revises: -Create Date: 2025-11-27 11:54:19.228030 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op -from pgvector.sqlalchemy import Vector -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision: str = "5a366d414dce" -down_revision: str | Sequence[str] | None = None -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - """Upgrade schema - create all tables from scratch.""" - - # Enable required extensions - op.execute("CREATE EXTENSION IF NOT EXISTS vector") - - # Create banks table - op.create_table( - "banks", - sa.Column("bank_id", sa.Text(), nullable=False), - sa.Column("name", sa.Text(), nullable=True), - sa.Column( - "personality", - postgresql.JSONB(astext_type=sa.Text()), - server_default=sa.text("'{}'::jsonb"), - nullable=False, - ), - sa.Column("background", sa.Text(), nullable=True), - sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.PrimaryKeyConstraint("bank_id", name=op.f("pk_banks")), - ) - - # Create documents table - op.create_table( - "documents", - sa.Column("id", sa.Text(), nullable=False), - sa.Column("bank_id", sa.Text(), nullable=False), - sa.Column("original_text", sa.Text(), nullable=True), - sa.Column("content_hash", sa.Text(), nullable=True), - sa.Column( - "metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False - ), - sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.PrimaryKeyConstraint("id", "bank_id", name=op.f("pk_documents")), - ) - op.create_index("idx_documents_bank_id", "documents", ["bank_id"]) - op.create_index("idx_documents_content_hash", "documents", ["content_hash"]) - - # Create async_operations table - op.create_table( - "async_operations", - sa.Column( - "operation_id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False - ), - sa.Column("bank_id", sa.Text(), nullable=False), - sa.Column("operation_type", sa.Text(), nullable=False), - sa.Column("status", sa.Text(), server_default="pending", nullable=False), - sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("completed_at", postgresql.TIMESTAMP(timezone=True), nullable=True), - sa.Column("error_message", sa.Text(), nullable=True), - sa.Column( - "result_metadata", - postgresql.JSONB(astext_type=sa.Text()), - server_default=sa.text("'{}'::jsonb"), - nullable=False, - ), - sa.PrimaryKeyConstraint("operation_id", name=op.f("pk_async_operations")), - sa.CheckConstraint( - "status IN ('pending', 'processing', 'completed', 'failed')", name="async_operations_status_check" - ), - ) - op.create_index("idx_async_operations_bank_id", "async_operations", ["bank_id"]) - op.create_index("idx_async_operations_status", "async_operations", ["status"]) - op.create_index("idx_async_operations_bank_status", "async_operations", ["bank_id", "status"]) - - # Create entities table - op.create_table( - "entities", - sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False), - sa.Column("canonical_name", sa.Text(), nullable=False), - sa.Column("bank_id", sa.Text(), nullable=False), - sa.Column( - "metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False - ), - sa.Column("first_seen", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("last_seen", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("mention_count", sa.Integer(), server_default="1", nullable=False), - sa.PrimaryKeyConstraint("id", name=op.f("pk_entities")), - ) - op.create_index("idx_entities_bank_id", "entities", ["bank_id"]) - op.create_index("idx_entities_canonical_name", "entities", ["canonical_name"]) - op.create_index("idx_entities_bank_name", "entities", ["bank_id", "canonical_name"]) - # Create unique index on (bank_id, LOWER(canonical_name)) for entity resolution - op.execute("CREATE UNIQUE INDEX idx_entities_bank_lower_name ON entities (bank_id, LOWER(canonical_name))") - - # Create memory_units table - op.create_table( - "memory_units", - sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False), - sa.Column("bank_id", sa.Text(), nullable=False), - sa.Column("document_id", sa.Text(), nullable=True), - sa.Column("text", sa.Text(), nullable=False), - sa.Column("embedding", Vector(384), nullable=True), - sa.Column("context", sa.Text(), nullable=True), - sa.Column("event_date", postgresql.TIMESTAMP(timezone=True), nullable=False), - sa.Column("occurred_start", postgresql.TIMESTAMP(timezone=True), nullable=True), - sa.Column("occurred_end", postgresql.TIMESTAMP(timezone=True), nullable=True), - sa.Column("mentioned_at", postgresql.TIMESTAMP(timezone=True), nullable=True), - sa.Column("fact_type", sa.Text(), server_default="world", nullable=False), - sa.Column("confidence_score", sa.Float(), nullable=True), - sa.Column("access_count", sa.Integer(), server_default="0", nullable=False), - sa.Column( - "metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False - ), - sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.Column("updated_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.ForeignKeyConstraint( - ["document_id", "bank_id"], - ["documents.id", "documents.bank_id"], - name="memory_units_document_fkey", - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("id", name=op.f("pk_memory_units")), - sa.CheckConstraint( - "fact_type IN ('world', 'bank', 'opinion', 'observation')", name="memory_units_fact_type_check" - ), - sa.CheckConstraint( - "confidence_score IS NULL OR (confidence_score >= 0.0 AND confidence_score <= 1.0)", - name="memory_units_confidence_range_check", - ), - sa.CheckConstraint( - "(fact_type = 'opinion' AND confidence_score IS NOT NULL) OR " - "(fact_type = 'observation') OR " - "(fact_type NOT IN ('opinion', 'observation') AND confidence_score IS NULL)", - name="confidence_score_fact_type_check", - ), - ) - - # Add search_vector column for full-text search - op.execute(""" - ALTER TABLE memory_units - ADD COLUMN search_vector tsvector - GENERATED ALWAYS AS (to_tsvector('english', COALESCE(text, '') || ' ' || COALESCE(context, ''))) STORED - """) - - op.create_index("idx_memory_units_bank_id", "memory_units", ["bank_id"]) - op.create_index("idx_memory_units_document_id", "memory_units", ["document_id"]) - op.create_index("idx_memory_units_event_date", "memory_units", [sa.text("event_date DESC")]) - op.create_index("idx_memory_units_bank_date", "memory_units", ["bank_id", sa.text("event_date DESC")]) - op.create_index("idx_memory_units_access_count", "memory_units", [sa.text("access_count DESC")]) - op.create_index("idx_memory_units_fact_type", "memory_units", ["fact_type"]) - op.create_index("idx_memory_units_bank_fact_type", "memory_units", ["bank_id", "fact_type"]) - op.create_index( - "idx_memory_units_bank_type_date", "memory_units", ["bank_id", "fact_type", sa.text("event_date DESC")] - ) - op.create_index( - "idx_memory_units_opinion_confidence", - "memory_units", - ["bank_id", sa.text("confidence_score DESC")], - postgresql_where=sa.text("fact_type = 'opinion'"), - ) - op.create_index( - "idx_memory_units_opinion_date", - "memory_units", - ["bank_id", sa.text("event_date DESC")], - postgresql_where=sa.text("fact_type = 'opinion'"), - ) - op.create_index( - "idx_memory_units_observation_date", - "memory_units", - ["bank_id", sa.text("event_date DESC")], - postgresql_where=sa.text("fact_type = 'observation'"), - ) - op.create_index( - "idx_memory_units_embedding", - "memory_units", - ["embedding"], - postgresql_using="hnsw", - postgresql_ops={"embedding": "vector_cosine_ops"}, - ) - - # Create BM25 full-text search index on search_vector - op.execute(""" - CREATE INDEX idx_memory_units_text_search ON memory_units - USING gin(search_vector) - """) - - op.execute(""" - CREATE MATERIALIZED VIEW memory_units_bm25 AS - SELECT - id, - bank_id, - text, - to_tsvector('english', text) AS text_vector, - log(1.0 + length(text)::float / (SELECT avg(length(text)) FROM memory_units)) AS doc_length_factor - FROM memory_units - """) - - op.create_index("idx_memory_units_bm25_bank", "memory_units_bm25", ["bank_id"]) - op.create_index("idx_memory_units_bm25_text_vector", "memory_units_bm25", ["text_vector"], postgresql_using="gin") - - # Create entity_cooccurrences table - op.create_table( - "entity_cooccurrences", - sa.Column("entity_id_1", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("entity_id_2", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("cooccurrence_count", sa.Integer(), server_default="1", nullable=False), - sa.Column( - "last_cooccurred", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False - ), - sa.ForeignKeyConstraint( - ["entity_id_1"], - ["entities.id"], - name=op.f("fk_entity_cooccurrences_entity_id_1_entities"), - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["entity_id_2"], - ["entities.id"], - name=op.f("fk_entity_cooccurrences_entity_id_2_entities"), - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("entity_id_1", "entity_id_2", name=op.f("pk_entity_cooccurrences")), - sa.CheckConstraint("entity_id_1 < entity_id_2", name="entity_cooccurrence_order_check"), - ) - op.create_index("idx_entity_cooccurrences_entity1", "entity_cooccurrences", ["entity_id_1"]) - op.create_index("idx_entity_cooccurrences_entity2", "entity_cooccurrences", ["entity_id_2"]) - op.create_index("idx_entity_cooccurrences_count", "entity_cooccurrences", [sa.text("cooccurrence_count DESC")]) - - # Create memory_links table - op.create_table( - "memory_links", - sa.Column("from_unit_id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("to_unit_id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("link_type", sa.Text(), nullable=False), - sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=True), - sa.Column("weight", sa.Float(), server_default="1.0", nullable=False), - sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.ForeignKeyConstraint( - ["entity_id"], ["entities.id"], name=op.f("fk_memory_links_entity_id_entities"), ondelete="CASCADE" - ), - sa.ForeignKeyConstraint( - ["from_unit_id"], - ["memory_units.id"], - name=op.f("fk_memory_links_from_unit_id_memory_units"), - ondelete="CASCADE", - ), - sa.ForeignKeyConstraint( - ["to_unit_id"], - ["memory_units.id"], - name=op.f("fk_memory_links_to_unit_id_memory_units"), - ondelete="CASCADE", - ), - sa.CheckConstraint( - "link_type IN ('temporal', 'semantic', 'entity', 'causes', 'caused_by', 'enables', 'prevents')", - name="memory_links_link_type_check", - ), - sa.CheckConstraint("weight >= 0.0 AND weight <= 1.0", name="memory_links_weight_check"), - ) - # Create unique constraint using COALESCE for nullable entity_id - op.execute( - "CREATE UNIQUE INDEX idx_memory_links_unique ON memory_links (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid))" - ) - op.create_index("idx_memory_links_from_unit", "memory_links", ["from_unit_id"]) - op.create_index("idx_memory_links_to_unit", "memory_links", ["to_unit_id"]) - op.create_index("idx_memory_links_entity", "memory_links", ["entity_id"]) - op.create_index("idx_memory_links_link_type", "memory_links", ["link_type"]) - - # Create unit_entities table - op.create_table( - "unit_entities", - sa.Column("unit_id", postgresql.UUID(as_uuid=True), nullable=False), - sa.Column("entity_id", postgresql.UUID(as_uuid=True), nullable=False), - sa.ForeignKeyConstraint( - ["entity_id"], ["entities.id"], name=op.f("fk_unit_entities_entity_id_entities"), ondelete="CASCADE" - ), - sa.ForeignKeyConstraint( - ["unit_id"], ["memory_units.id"], name=op.f("fk_unit_entities_unit_id_memory_units"), ondelete="CASCADE" - ), - sa.PrimaryKeyConstraint("unit_id", "entity_id", name=op.f("pk_unit_entities")), - ) - op.create_index("idx_unit_entities_unit", "unit_entities", ["unit_id"]) - op.create_index("idx_unit_entities_entity", "unit_entities", ["entity_id"]) - - -def downgrade() -> None: - """Downgrade schema - drop all tables.""" - - # Drop tables in reverse dependency order - op.drop_index("idx_unit_entities_entity", table_name="unit_entities") - op.drop_index("idx_unit_entities_unit", table_name="unit_entities") - op.drop_table("unit_entities") - - op.drop_index("idx_memory_links_link_type", table_name="memory_links") - op.drop_index("idx_memory_links_entity", table_name="memory_links") - op.drop_index("idx_memory_links_to_unit", table_name="memory_links") - op.drop_index("idx_memory_links_from_unit", table_name="memory_links") - op.execute("DROP INDEX IF EXISTS idx_memory_links_unique") - op.drop_table("memory_links") - - op.drop_index("idx_entity_cooccurrences_count", table_name="entity_cooccurrences") - op.drop_index("idx_entity_cooccurrences_entity2", table_name="entity_cooccurrences") - op.drop_index("idx_entity_cooccurrences_entity1", table_name="entity_cooccurrences") - op.drop_table("entity_cooccurrences") - - # Drop BM25 materialized view and index - op.drop_index("idx_memory_units_bm25_text_vector", table_name="memory_units_bm25") - op.drop_index("idx_memory_units_bm25_bank", table_name="memory_units_bm25") - op.execute("DROP MATERIALIZED VIEW IF EXISTS memory_units_bm25") - - op.drop_index("idx_memory_units_embedding", table_name="memory_units") - op.drop_index("idx_memory_units_observation_date", table_name="memory_units") - op.drop_index("idx_memory_units_opinion_date", table_name="memory_units") - op.drop_index("idx_memory_units_opinion_confidence", table_name="memory_units") - op.drop_index("idx_memory_units_bank_type_date", table_name="memory_units") - op.drop_index("idx_memory_units_bank_fact_type", table_name="memory_units") - op.drop_index("idx_memory_units_fact_type", table_name="memory_units") - op.drop_index("idx_memory_units_access_count", table_name="memory_units") - op.drop_index("idx_memory_units_bank_date", table_name="memory_units") - op.drop_index("idx_memory_units_event_date", table_name="memory_units") - op.drop_index("idx_memory_units_document_id", table_name="memory_units") - op.drop_index("idx_memory_units_bank_id", table_name="memory_units") - op.execute("DROP INDEX IF EXISTS idx_memory_units_text_search") - op.drop_table("memory_units") - - op.execute("DROP INDEX IF EXISTS idx_entities_bank_lower_name") - op.drop_index("idx_entities_bank_name", table_name="entities") - op.drop_index("idx_entities_canonical_name", table_name="entities") - op.drop_index("idx_entities_bank_id", table_name="entities") - op.drop_table("entities") - - op.drop_index("idx_async_operations_bank_status", table_name="async_operations") - op.drop_index("idx_async_operations_status", table_name="async_operations") - op.drop_index("idx_async_operations_bank_id", table_name="async_operations") - op.drop_table("async_operations") - - op.drop_index("idx_documents_content_hash", table_name="documents") - op.drop_index("idx_documents_bank_id", table_name="documents") - op.drop_table("documents") - - op.drop_table("banks") - - # Drop extensions (optional - comment out if you want to keep them) - # op.execute('DROP EXTENSION IF EXISTS vector') - # op.execute('DROP EXTENSION IF EXISTS "uuid-ossp"') diff --git a/hindsight-api/hindsight_api/alembic/versions/b7c4d8e9f1a2_add_chunks_table.py b/hindsight-api/hindsight_api/alembic/versions/b7c4d8e9f1a2_add_chunks_table.py deleted file mode 100644 index d0dae396..00000000 --- a/hindsight-api/hindsight_api/alembic/versions/b7c4d8e9f1a2_add_chunks_table.py +++ /dev/null @@ -1,70 +0,0 @@ -"""add_chunks_table - -Revision ID: b7c4d8e9f1a2 -Revises: 5a366d414dce -Create Date: 2025-11-28 00:00:00.000000 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision: str = "b7c4d8e9f1a2" -down_revision: str | Sequence[str] | None = "5a366d414dce" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - """Add chunks table and link memory_units to chunks.""" - - # Create chunks table with single text PK (bank_id_document_id_chunk_index) - op.create_table( - "chunks", - sa.Column("chunk_id", sa.Text(), nullable=False), - sa.Column("document_id", sa.Text(), nullable=False), - sa.Column("bank_id", sa.Text(), nullable=False), - sa.Column("chunk_index", sa.Integer(), nullable=False), - sa.Column("chunk_text", sa.Text(), nullable=False), - sa.Column("created_at", postgresql.TIMESTAMP(timezone=True), server_default=sa.text("now()"), nullable=False), - sa.ForeignKeyConstraint( - ["document_id", "bank_id"], - ["documents.id", "documents.bank_id"], - name="chunks_document_fkey", - ondelete="CASCADE", - ), - sa.PrimaryKeyConstraint("chunk_id", name=op.f("pk_chunks")), - ) - - # Add indexes for efficient queries - op.create_index("idx_chunks_document_id", "chunks", ["document_id"]) - op.create_index("idx_chunks_bank_id", "chunks", ["bank_id"]) - - # Add chunk_id column to memory_units (nullable, as existing records won't have chunks) - op.add_column("memory_units", sa.Column("chunk_id", sa.Text(), nullable=True)) - - # Add foreign key constraint to chunks table - op.create_foreign_key( - "memory_units_chunk_fkey", "memory_units", "chunks", ["chunk_id"], ["chunk_id"], ondelete="SET NULL" - ) - - # Add index on chunk_id for efficient lookups - op.create_index("idx_memory_units_chunk_id", "memory_units", ["chunk_id"]) - - -def downgrade() -> None: - """Remove chunks table and chunk_id from memory_units.""" - - # Drop index and foreign key from memory_units - op.drop_index("idx_memory_units_chunk_id", table_name="memory_units") - op.drop_constraint("memory_units_chunk_fkey", "memory_units", type_="foreignkey") - op.drop_column("memory_units", "chunk_id") - - # Drop chunks table indexes and table - op.drop_index("idx_chunks_bank_id", table_name="chunks") - op.drop_index("idx_chunks_document_id", table_name="chunks") - op.drop_table("chunks") diff --git a/hindsight-api/hindsight_api/alembic/versions/c8e5f2a3b4d1_add_retain_params_to_documents.py b/hindsight-api/hindsight_api/alembic/versions/c8e5f2a3b4d1_add_retain_params_to_documents.py deleted file mode 100644 index edbcc13e..00000000 --- a/hindsight-api/hindsight_api/alembic/versions/c8e5f2a3b4d1_add_retain_params_to_documents.py +++ /dev/null @@ -1,39 +0,0 @@ -"""add_retain_params_to_documents - -Revision ID: c8e5f2a3b4d1 -Revises: b7c4d8e9f1a2 -Create Date: 2025-12-02 00:00:00.000000 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import op -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision: str = "c8e5f2a3b4d1" -down_revision: str | Sequence[str] | None = "b7c4d8e9f1a2" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def upgrade() -> None: - """Add retain_params JSONB column to documents table.""" - - # Add retain_params column to store parameters passed during retain - op.add_column("documents", sa.Column("retain_params", postgresql.JSONB(), nullable=True)) - - # Add index for efficient queries on retain_params - op.create_index("idx_documents_retain_params", "documents", ["retain_params"], postgresql_using="gin") - - -def downgrade() -> None: - """Remove retain_params column from documents table.""" - - # Drop index - op.drop_index("idx_documents_retain_params", table_name="documents") - - # Drop column - op.drop_column("documents", "retain_params") diff --git a/hindsight-api/hindsight_api/alembic/versions/d9f6a3b4c5e2_rename_bank_to_interactions.py b/hindsight-api/hindsight_api/alembic/versions/d9f6a3b4c5e2_rename_bank_to_interactions.py deleted file mode 100644 index bd04fcd7..00000000 --- a/hindsight-api/hindsight_api/alembic/versions/d9f6a3b4c5e2_rename_bank_to_interactions.py +++ /dev/null @@ -1,53 +0,0 @@ -"""Rename fact_type 'bank' to 'experience' - -Revision ID: d9f6a3b4c5e2 -Revises: c8e5f2a3b4d1 -Create Date: 2024-12-04 15:00:00.000000 - -""" - -from alembic import context, op - -# revision identifiers, used by Alembic. -revision = "d9f6a3b4c5e2" -down_revision = "c8e5f2a3b4d1" -branch_labels = None -depends_on = None - - -def _get_schema_prefix() -> str: - """Get schema prefix for table names (e.g., 'tenant_x.' or '' for public).""" - schema = context.config.get_main_option("target_schema") - return f'"{schema}".' if schema else "" - - -def upgrade(): - schema = _get_schema_prefix() - - # Drop old check constraint FIRST (before updating data) - op.drop_constraint("memory_units_fact_type_check", "memory_units", type_="check") - - # Update existing 'bank' values to 'experience' - op.execute(f"UPDATE {schema}memory_units SET fact_type = 'experience' WHERE fact_type = 'bank'") - # Also update any 'interactions' values (in case of partial migration) - op.execute(f"UPDATE {schema}memory_units SET fact_type = 'experience' WHERE fact_type = 'interactions'") - - # Create new check constraint with 'experience' instead of 'bank' - op.create_check_constraint( - "memory_units_fact_type_check", "memory_units", "fact_type IN ('world', 'experience', 'opinion', 'observation')" - ) - - -def downgrade(): - schema = _get_schema_prefix() - - # Drop new check constraint FIRST - op.drop_constraint("memory_units_fact_type_check", "memory_units", type_="check") - - # Update 'experience' back to 'bank' - op.execute(f"UPDATE {schema}memory_units SET fact_type = 'bank' WHERE fact_type = 'experience'") - - # Recreate old check constraint - op.create_check_constraint( - "memory_units_fact_type_check", "memory_units", "fact_type IN ('world', 'bank', 'opinion', 'observation')" - ) diff --git a/hindsight-api/hindsight_api/alembic/versions/e0a1b2c3d4e5_disposition_to_3_traits.py b/hindsight-api/hindsight_api/alembic/versions/e0a1b2c3d4e5_disposition_to_3_traits.py deleted file mode 100644 index 8db391ab..00000000 --- a/hindsight-api/hindsight_api/alembic/versions/e0a1b2c3d4e5_disposition_to_3_traits.py +++ /dev/null @@ -1,111 +0,0 @@ -"""disposition_to_3_traits - -Revision ID: e0a1b2c3d4e5 -Revises: rename_personality -Create Date: 2024-12-08 - -Migrate disposition traits from Big Five (openness, conscientiousness, extraversion, -agreeableness, neuroticism, bias_strength with 0-1 float values) to the new 3-trait -system (skepticism, literalism, empathy with 1-5 integer values). -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import context, op - -# revision identifiers, used by Alembic. -revision: str = "e0a1b2c3d4e5" -down_revision: str | Sequence[str] | None = "rename_personality" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def _get_schema_prefix() -> str: - """Get schema prefix for table names (e.g., 'tenant_x.' or '' for public).""" - schema = context.config.get_main_option("target_schema") - return f'"{schema}".' if schema else "" - - -def _get_target_schema() -> str: - """Get the target schema name (tenant schema or 'public').""" - schema = context.config.get_main_option("target_schema") - return schema if schema else "public" - - -def upgrade() -> None: - """Convert Big Five disposition to 3-trait disposition.""" - conn = op.get_bind() - schema = _get_schema_prefix() - target_schema = _get_target_schema() - - # Check if disposition column exists (should have been created by previous migration) - result = conn.execute( - sa.text(""" - SELECT column_name - FROM information_schema.columns - WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'disposition' - """), - {"schema": target_schema}, - ) - if not result.fetchone(): - # Column doesn't exist yet (shouldn't happen but be safe) - return - - # Update all existing banks to use the new disposition format - # Convert from old format to new format with reasonable mappings: - # - skepticism: derived from inverse of agreeableness (skeptical people are less agreeable) - # - literalism: derived from conscientiousness (detail-oriented people are more literal) - # - empathy: derived from agreeableness + inverse of neuroticism - # Default all to 3 (neutral) for simplicity - conn.execute( - sa.text(f""" - UPDATE {schema}banks - SET disposition = '{{"skepticism": 3, "literalism": 3, "empathy": 3}}'::jsonb - WHERE disposition IS NOT NULL - """) - ) - - # Update the default for new banks - conn.execute( - sa.text(f""" - ALTER TABLE {schema}banks - ALTER COLUMN disposition SET DEFAULT '{{"skepticism": 3, "literalism": 3, "empathy": 3}}'::jsonb - """) - ) - - -def downgrade() -> None: - """Convert back to Big Five disposition.""" - conn = op.get_bind() - schema = _get_schema_prefix() - target_schema = _get_target_schema() - - # Check if disposition column exists - result = conn.execute( - sa.text(""" - SELECT column_name - FROM information_schema.columns - WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'disposition' - """), - {"schema": target_schema}, - ) - if not result.fetchone(): - return - - # Revert to Big Five format with default values - conn.execute( - sa.text(f""" - UPDATE {schema}banks - SET disposition = '{{"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.5, "agreeableness": 0.5, "neuroticism": 0.5, "bias_strength": 0.5}}'::jsonb - WHERE disposition IS NOT NULL - """) - ) - - # Update the default for new banks - conn.execute( - sa.text(f""" - ALTER TABLE {schema}banks - ALTER COLUMN disposition SET DEFAULT '{{"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.5, "agreeableness": 0.5, "neuroticism": 0.5, "bias_strength": 0.5}}'::jsonb - """) - ) diff --git a/hindsight-api/hindsight_api/alembic/versions/rename_personality_to_disposition.py b/hindsight-api/hindsight_api/alembic/versions/rename_personality_to_disposition.py deleted file mode 100644 index 6a711458..00000000 --- a/hindsight-api/hindsight_api/alembic/versions/rename_personality_to_disposition.py +++ /dev/null @@ -1,85 +0,0 @@ -"""rename_personality_to_disposition - -Revision ID: rename_personality -Revises: d9f6a3b4c5e2 -Create Date: 2024-12-04 - -""" - -from collections.abc import Sequence - -import sqlalchemy as sa -from alembic import context, op -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision: str = "rename_personality" -down_revision: str | Sequence[str] | None = "d9f6a3b4c5e2" -branch_labels: str | Sequence[str] | None = None -depends_on: str | Sequence[str] | None = None - - -def _get_target_schema() -> str: - """Get the target schema name (tenant schema or 'public').""" - schema = context.config.get_main_option("target_schema") - return schema if schema else "public" - - -def upgrade() -> None: - """Rename personality column to disposition in banks table (if it exists).""" - conn = op.get_bind() - target_schema = _get_target_schema() - - # Check if 'personality' column exists (old database) - result = conn.execute( - sa.text(""" - SELECT column_name - FROM information_schema.columns - WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'personality' - """), - {"schema": target_schema}, - ) - has_personality = result.fetchone() is not None - - # Check if 'disposition' column exists (new database) - result = conn.execute( - sa.text(""" - SELECT column_name - FROM information_schema.columns - WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'disposition' - """), - {"schema": target_schema}, - ) - has_disposition = result.fetchone() is not None - - if has_personality and not has_disposition: - # Old database: rename personality -> disposition - op.alter_column("banks", "personality", new_column_name="disposition") - elif not has_personality and not has_disposition: - # Neither exists (shouldn't happen, but be safe): add disposition column - op.add_column( - "banks", - sa.Column( - "disposition", - postgresql.JSONB(astext_type=sa.Text()), - server_default=sa.text("'{}'::jsonb"), - nullable=False, - ), - ) - # else: disposition already exists, nothing to do - - -def downgrade() -> None: - """Revert disposition column back to personality.""" - conn = op.get_bind() - target_schema = _get_target_schema() - result = conn.execute( - sa.text(""" - SELECT column_name - FROM information_schema.columns - WHERE table_schema = :schema AND table_name = 'banks' AND column_name = 'disposition' - """), - {"schema": target_schema}, - ) - if result.fetchone(): - op.alter_column("banks", "disposition", new_column_name="personality") diff --git a/hindsight-api/hindsight_api/api/__init__.py b/hindsight-api/hindsight_api/api/__init__.py deleted file mode 100644 index e894c6a5..00000000 --- a/hindsight-api/hindsight_api/api/__init__.py +++ /dev/null @@ -1,102 +0,0 @@ -""" -Unified API module for Hindsight. - -Provides both HTTP REST API and MCP (Model Context Protocol) server. -""" - -import logging -from typing import Optional - -from fastapi import FastAPI - -from hindsight_api import MemoryEngine - -logger = logging.getLogger(__name__) - - -def create_app( - memory: MemoryEngine, - http_api_enabled: bool = True, - mcp_api_enabled: bool = False, - mcp_mount_path: str = "/mcp", - initialize_memory: bool = True, -) -> FastAPI: - """ - Create and configure the unified Hindsight API application. - - Args: - memory: MemoryEngine instance (already initialized with required parameters). - Migrations are controlled by the MemoryEngine's run_migrations parameter. - http_api_enabled: Whether to enable HTTP REST API endpoints (default: True) - mcp_api_enabled: Whether to enable MCP server (default: False) - mcp_mount_path: Path to mount MCP server (default: /mcp) - initialize_memory: Whether to initialize memory system on startup (default: True) - - Returns: - Configured FastAPI application with enabled APIs - - Example: - # HTTP only - app = create_app(memory) - - # MCP only - app = create_app(memory, http_api_enabled=False, mcp_api_enabled=True) - - # Both HTTP and MCP - app = create_app(memory, mcp_api_enabled=True) - """ - - # Import and create HTTP API if enabled - if http_api_enabled: - from .http import create_app as create_http_app - - app = create_http_app(memory=memory, initialize_memory=initialize_memory) - logger.info("HTTP REST API enabled") - else: - # Create minimal FastAPI app - app = FastAPI(title="Hindsight API", version="0.0.7") - logger.info("HTTP REST API disabled") - - # Mount MCP server if enabled - if mcp_api_enabled: - try: - from .mcp import create_mcp_app - - # Create MCP app with dynamic bank_id support - # Supports: /mcp/{bank_id}/sse (bank-specific SSE endpoint) - mcp_app = create_mcp_app(memory=memory) - app.mount(mcp_mount_path, mcp_app) - logger.info(f"MCP server enabled at {mcp_mount_path}/{{bank_id}}/sse") - except ImportError as e: - logger.error(f"MCP server requested but dependencies not available: {e}") - logger.error("Install with: pip install hindsight-api[mcp]") - raise - - return app - - -# Re-export commonly used items for backwards compatibility -from .http import ( - CreateBankRequest, - DispositionTraits, - MemoryItem, - RecallRequest, - RecallResponse, - RecallResult, - ReflectRequest, - ReflectResponse, - RetainRequest, -) - -__all__ = [ - "create_app", - "RecallRequest", - "RecallResult", - "RecallResponse", - "MemoryItem", - "RetainRequest", - "ReflectRequest", - "ReflectResponse", - "CreateBankRequest", - "DispositionTraits", -] diff --git a/hindsight-api/hindsight_api/api/http.py b/hindsight-api/hindsight_api/api/http.py deleted file mode 100644 index df900f4b..00000000 --- a/hindsight-api/hindsight_api/api/http.py +++ /dev/null @@ -1,1858 +0,0 @@ -""" -FastAPI application factory and API routes for memory system. - -This module provides the create_app function to create and configure -the FastAPI application with all API endpoints. -""" - -import json -import logging -import uuid -from contextlib import asynccontextmanager -from datetime import datetime -from typing import Any - -from fastapi import Depends, FastAPI, Header, HTTPException, Query - - -def _parse_metadata(metadata: Any) -> dict[str, Any]: - """Parse metadata that may be a dict, JSON string, or None.""" - if metadata is None: - return {} - if isinstance(metadata, dict): - return metadata - if isinstance(metadata, str): - try: - return json.loads(metadata) - except json.JSONDecodeError: - return {} - return {} - - -from pydantic import BaseModel, ConfigDict, Field, field_validator - -from hindsight_api import MemoryEngine -from hindsight_api.engine.db_utils import acquire_with_retry -from hindsight_api.engine.memory_engine import Budget, fq_table -from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES -from hindsight_api.extensions import HttpExtension, load_extension -from hindsight_api.metrics import create_metrics_collector, get_metrics_collector, initialize_metrics -from hindsight_api.models import RequestContext - -logger = logging.getLogger(__name__) - - -class EntityIncludeOptions(BaseModel): - """Options for including entity observations in recall results.""" - - max_tokens: int = Field(default=500, description="Maximum tokens for entity observations") - - -class ChunkIncludeOptions(BaseModel): - """Options for including chunks in recall results.""" - - max_tokens: int = Field(default=8192, description="Maximum tokens for chunks (chunks may be truncated)") - - -class IncludeOptions(BaseModel): - """Options for including additional data in recall results.""" - - entities: EntityIncludeOptions | None = Field( - default=EntityIncludeOptions(), - description="Include entity observations. Set to null to disable entity inclusion.", - ) - chunks: ChunkIncludeOptions | None = Field( - default=None, description="Include raw chunks. Set to {} to enable, null to disable (default: disabled)." - ) - - -class RecallRequest(BaseModel): - """Request model for recall endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "query": "What did Alice say about machine learning?", - "types": ["world", "experience"], - "budget": "mid", - "max_tokens": 4096, - "trace": True, - "query_timestamp": "2023-05-30T23:40:00", - "include": {"entities": {"max_tokens": 500}}, - } - } - ) - - query: str - types: list[str] | None = Field( - default=None, description="List of fact types to recall (defaults to all if not specified)" - ) - budget: Budget = Budget.MID - max_tokens: int = 4096 - trace: bool = False - query_timestamp: str | None = Field( - default=None, description="ISO format date string (e.g., '2023-05-30T23:40:00')" - ) - include: IncludeOptions = Field( - default_factory=IncludeOptions, - description="Options for including additional data (entities are included by default)", - ) - - -class RecallResult(BaseModel): - """Single recall result item.""" - - model_config = { - "populate_by_name": True, - "json_schema_extra": { - "example": { - "id": "123e4567-e89b-12d3-a456-426614174000", - "text": "Alice works at Google on the AI team", - "type": "world", - "entities": ["Alice", "Google"], - "context": "work info", - "occurred_start": "2024-01-15T10:30:00Z", - "occurred_end": "2024-01-15T10:30:00Z", - "mentioned_at": "2024-01-15T10:30:00Z", - "document_id": "session_abc123", - "metadata": {"source": "slack"}, - "chunk_id": "456e7890-e12b-34d5-a678-901234567890", - } - }, - } - - id: str - text: str - type: str | None = None # fact type: world, experience, opinion, 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 - occurred_end: str | None = None # ISO format date when the event ended - mentioned_at: str | None = None # ISO format date when the fact was mentioned - document_id: str | None = None # Document this memory belongs to - metadata: dict[str, str] | None = None # User-defined metadata - chunk_id: str | None = None # Chunk this fact was extracted from - - -class EntityObservationResponse(BaseModel): - """An observation about an entity.""" - - text: str - mentioned_at: str | None = None - - -class EntityStateResponse(BaseModel): - """Current mental model of an entity.""" - - entity_id: str - canonical_name: str - observations: list[EntityObservationResponse] - - -class EntityListItem(BaseModel): - """Entity list item with summary.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "id": "123e4567-e89b-12d3-a456-426614174000", - "canonical_name": "John", - "mention_count": 15, - "first_seen": "2024-01-15T10:30:00Z", - "last_seen": "2024-02-01T14:00:00Z", - } - } - ) - - id: str - canonical_name: str - mention_count: int - first_seen: str | None = None - last_seen: str | None = None - metadata: dict[str, Any] | None = None - - -class EntityListResponse(BaseModel): - """Response model for entity list endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "items": [ - { - "id": "123e4567-e89b-12d3-a456-426614174000", - "canonical_name": "John", - "mention_count": 15, - "first_seen": "2024-01-15T10:30:00Z", - "last_seen": "2024-02-01T14:00:00Z", - } - ] - } - } - ) - - items: list[EntityListItem] - - -class EntityDetailResponse(BaseModel): - """Response model for entity detail endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "id": "123e4567-e89b-12d3-a456-426614174000", - "canonical_name": "John", - "mention_count": 15, - "first_seen": "2024-01-15T10:30:00Z", - "last_seen": "2024-02-01T14:00:00Z", - "observations": [{"text": "John works at Google", "mentioned_at": "2024-01-15T10:30:00Z"}], - } - } - ) - - id: str - canonical_name: str - mention_count: int - first_seen: str | None = None - last_seen: str | None = None - metadata: dict[str, Any] | None = None - observations: list[EntityObservationResponse] - - -class ChunkData(BaseModel): - """Chunk data for a single chunk.""" - - id: str - text: str - chunk_index: int - truncated: bool = Field(default=False, description="Whether the chunk text was truncated due to token limits") - - -class RecallResponse(BaseModel): - """Response model for recall endpoints.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "results": [ - { - "id": "123e4567-e89b-12d3-a456-426614174000", - "text": "Alice works at Google on the AI team", - "type": "world", - "entities": ["Alice", "Google"], - "context": "work info", - "occurred_start": "2024-01-15T10:30:00Z", - "occurred_end": "2024-01-15T10:30:00Z", - "chunk_id": "456e7890-e12b-34d5-a678-901234567890", - } - ], - "trace": { - "query": "What did Alice say about machine learning?", - "num_results": 1, - "time_seconds": 0.123, - }, - "entities": { - "Alice": { - "entity_id": "123e4567-e89b-12d3-a456-426614174001", - "canonical_name": "Alice", - "observations": [ - {"text": "Alice works at Google on the AI team", "mentioned_at": "2024-01-15T10:30:00Z"} - ], - } - }, - "chunks": { - "456e7890-e12b-34d5-a678-901234567890": { - "id": "456e7890-e12b-34d5-a678-901234567890", - "text": "Alice works at Google on the AI team. She's been there for 3 years...", - "chunk_index": 0, - } - }, - } - } - ) - - results: list[RecallResult] - trace: dict[str, Any] | None = None - entities: dict[str, EntityStateResponse] | None = Field( - default=None, description="Entity states for entities mentioned in results" - ) - chunks: dict[str, ChunkData] | None = Field(default=None, description="Chunks for facts, keyed by chunk_id") - - -class MemoryItem(BaseModel): - """Single memory item for retain.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "content": "Alice mentioned she's working on a new ML model", - "timestamp": "2024-01-15T10:30:00Z", - "context": "team meeting", - "metadata": {"source": "slack", "channel": "engineering"}, - "document_id": "meeting_notes_2024_01_15", - } - }, - ) - - content: str - timestamp: datetime | None = None - context: str | None = None - metadata: dict[str, str] | None = None - document_id: str | None = Field(default=None, description="Optional document ID for this memory item.") - - @field_validator("timestamp", mode="before") - @classmethod - def validate_timestamp(cls, v): - if v is None or v == "": - return None - if isinstance(v, datetime): - return v - if isinstance(v, str): - try: - # Try parsing as ISO format - return datetime.fromisoformat(v.replace("Z", "+00:00")) - except ValueError as e: - raise ValueError( - f"Invalid timestamp/event_date format: '{v}'. Expected ISO format like '2024-01-15T10:30:00' or '2024-01-15T10:30:00Z'" - ) from e - raise ValueError(f"timestamp must be a string or datetime, got {type(v).__name__}") - - -class RetainRequest(BaseModel): - """Request model for retain endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "items": [ - {"content": "Alice works at Google", "context": "work", "document_id": "conversation_123"}, - { - "content": "Bob went hiking yesterday", - "timestamp": "2024-01-15T10:00:00Z", - "document_id": "conversation_123", - }, - ], - "async": False, - } - } - ) - - items: list[MemoryItem] - async_: bool = Field( - default=False, - alias="async", - description="If true, process asynchronously in background. If false, wait for completion (default: false)", - ) - - -class RetainResponse(BaseModel): - """Response model for retain endpoint.""" - - model_config = ConfigDict( - populate_by_name=True, - json_schema_extra={"example": {"success": True, "bank_id": "user123", "items_count": 2, "async": False}}, - ) - - success: bool - bank_id: str - items_count: int - is_async: bool = Field( - alias="async", serialization_alias="async", description="Whether the operation was processed asynchronously" - ) - - -class FactsIncludeOptions(BaseModel): - """Options for including facts (based_on) in reflect results.""" - - pass # No additional options needed, just enable/disable - - -class ReflectIncludeOptions(BaseModel): - """Options for including additional data in reflect results.""" - - facts: FactsIncludeOptions | None = Field( - default=None, - description="Include facts that the answer is based on. Set to {} to enable, null to disable (default: disabled).", - ) - - -class ReflectRequest(BaseModel): - """Request model for reflect endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "query": "What do you think about artificial intelligence?", - "budget": "low", - "context": "This is for a research paper on AI ethics", - "include": {"facts": {}}, - } - } - ) - - query: str - budget: Budget = Budget.LOW - context: str | None = None - include: ReflectIncludeOptions = Field( - default_factory=ReflectIncludeOptions, description="Options for including additional data (disabled by default)" - ) - - -class OpinionItem(BaseModel): - """Model for an opinion with confidence score.""" - - text: str - confidence: float - - -class ReflectFact(BaseModel): - """A fact used in think response.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "id": "123e4567-e89b-12d3-a456-426614174000", - "text": "AI is used in healthcare", - "type": "world", - "context": "healthcare discussion", - "occurred_start": "2024-01-15T10:30:00Z", - "occurred_end": "2024-01-15T10:30:00Z", - } - } - ) - - id: str | None = None - text: str - type: str | None = None # fact type: world, experience, opinion - context: str | None = None - occurred_start: str | None = None - occurred_end: str | None = None - - -class ReflectResponse(BaseModel): - """Response model for think endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "text": "Based on my understanding, AI is a transformative technology...", - "based_on": [ - {"id": "123", "text": "AI is used in healthcare", "type": "world"}, - {"id": "456", "text": "I discussed AI applications last week", "type": "experience"}, - ], - } - } - ) - - text: str - based_on: list[ReflectFact] = [] # Facts used to generate the response - - -class BanksResponse(BaseModel): - """Response model for banks list endpoint.""" - - model_config = ConfigDict(json_schema_extra={"example": {"banks": ["user123", "bank_alice", "bank_bob"]}}) - - banks: list[str] - - -class DispositionTraits(BaseModel): - """Disposition traits that influence how memories are formed and interpreted.""" - - model_config = ConfigDict(json_schema_extra={"example": {"skepticism": 3, "literalism": 3, "empathy": 3}}) - - skepticism: int = Field(ge=1, le=5, description="How skeptical vs trusting (1=trusting, 5=skeptical)") - literalism: int = Field(ge=1, le=5, description="How literally to interpret information (1=flexible, 5=literal)") - empathy: int = Field(ge=1, le=5, description="How much to consider emotional context (1=detached, 5=empathetic)") - - -class BankProfileResponse(BaseModel): - """Response model for bank profile.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "bank_id": "user123", - "name": "Alice", - "disposition": {"skepticism": 3, "literalism": 3, "empathy": 3}, - "background": "I am a software engineer with 10 years of experience in startups", - } - } - ) - - bank_id: str - name: str - disposition: DispositionTraits - background: str - - -class UpdateDispositionRequest(BaseModel): - """Request model for updating disposition traits.""" - - disposition: DispositionTraits - - -class AddBackgroundRequest(BaseModel): - """Request model for adding/merging background information.""" - - model_config = ConfigDict( - json_schema_extra={"example": {"content": "I was born in Texas", "update_disposition": True}} - ) - - content: str = Field(description="New background information to add or merge") - update_disposition: bool = Field( - default=True, description="If true, infer disposition traits from the merged background (default: true)" - ) - - -class BackgroundResponse(BaseModel): - """Response model for background update.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "background": "I was born in Texas. I am a software engineer with 10 years of experience.", - "disposition": {"skepticism": 3, "literalism": 3, "empathy": 3}, - } - } - ) - - background: str - disposition: DispositionTraits | None = None - - -class BankListItem(BaseModel): - """Bank list item with profile summary.""" - - bank_id: str - name: str | None = None - disposition: DispositionTraits - background: str | None = None - created_at: str | None = None - updated_at: str | None = None - - -class BankListResponse(BaseModel): - """Response model for listing all banks.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "banks": [ - { - "bank_id": "user123", - "name": "Alice", - "disposition": {"skepticism": 3, "literalism": 3, "empathy": 3}, - "background": "I am a software engineer", - "created_at": "2024-01-15T10:30:00Z", - "updated_at": "2024-01-16T14:20:00Z", - } - ] - } - } - ) - - banks: list[BankListItem] - - -class CreateBankRequest(BaseModel): - """Request model for creating/updating a bank.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "name": "Alice", - "disposition": {"skepticism": 3, "literalism": 3, "empathy": 3}, - "background": "I am a creative software engineer with 10 years of experience", - } - } - ) - - name: str | None = None - disposition: DispositionTraits | None = None - background: str | None = None - - -class GraphDataResponse(BaseModel): - """Response model for graph data endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "nodes": [ - {"id": "1", "label": "Alice works at Google", "type": "world"}, - {"id": "2", "label": "Bob went hiking", "type": "world"}, - ], - "edges": [{"from": "1", "to": "2", "type": "semantic", "weight": 0.8}], - "table_rows": [ - { - "id": "abc12345...", - "text": "Alice works at Google", - "context": "Work info", - "date": "2024-01-15 10:30", - "entities": "Alice (PERSON), Google (ORGANIZATION)", - } - ], - "total_units": 2, - } - } - ) - - nodes: list[dict[str, Any]] - edges: list[dict[str, Any]] - table_rows: list[dict[str, Any]] - total_units: int - - -class ListMemoryUnitsResponse(BaseModel): - """Response model for list memory units endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "items": [ - { - "id": "550e8400-e29b-41d4-a716-446655440000", - "text": "Alice works at Google on the AI team", - "context": "Work conversation", - "date": "2024-01-15T10:30:00Z", - "type": "world", - "entities": "Alice (PERSON), Google (ORGANIZATION)", - } - ], - "total": 150, - "limit": 100, - "offset": 0, - } - } - ) - - items: list[dict[str, Any]] - total: int - limit: int - offset: int - - -class ListDocumentsResponse(BaseModel): - """Response model for list documents endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "items": [ - { - "id": "session_1", - "bank_id": "user123", - "content_hash": "abc123", - "created_at": "2024-01-15T10:30:00Z", - "updated_at": "2024-01-15T10:30:00Z", - "text_length": 5420, - "memory_unit_count": 15, - } - ], - "total": 50, - "limit": 100, - "offset": 0, - } - } - ) - - items: list[dict[str, Any]] - total: int - limit: int - offset: int - - -class DocumentResponse(BaseModel): - """Response model for get document endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "id": "session_1", - "bank_id": "user123", - "original_text": "Full document text here...", - "content_hash": "abc123", - "created_at": "2024-01-15T10:30:00Z", - "updated_at": "2024-01-15T10:30:00Z", - "memory_unit_count": 15, - } - } - ) - - id: str - bank_id: str - original_text: str - content_hash: str | None - created_at: str - updated_at: str - memory_unit_count: int - - -class ChunkResponse(BaseModel): - """Response model for get chunk endpoint.""" - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "chunk_id": "user123_session_1_0", - "document_id": "session_1", - "bank_id": "user123", - "chunk_index": 0, - "chunk_text": "This is the first chunk of the document...", - "created_at": "2024-01-15T10:30:00Z", - } - } - ) - - chunk_id: str - document_id: str - bank_id: str - chunk_index: int - chunk_text: str - created_at: str - - -class DeleteResponse(BaseModel): - """Response model for delete operations.""" - - model_config = ConfigDict( - json_schema_extra={"example": {"success": True, "message": "Deleted successfully", "deleted_count": 10}} - ) - - success: bool - message: str | None = None - deleted_count: int | None = None - - -def create_app( - memory: MemoryEngine, - initialize_memory: bool = True, - http_extension: HttpExtension | None = None, -) -> FastAPI: - """ - Create and configure the FastAPI application. - - Args: - memory: MemoryEngine instance (already initialized with required parameters). - Migrations are controlled by the MemoryEngine's run_migrations parameter. - initialize_memory: Whether to initialize memory system on startup (default: True) - http_extension: Optional HTTP extension to mount custom endpoints under /extension/. - If None, attempts to load from HINDSIGHT_API_HTTP_EXTENSION env var. - - Returns: - Configured FastAPI application - - Note: - When mounting this app as a sub-application, the lifespan events may not fire. - In that case, you should call memory.initialize() manually before starting the server - and memory.close() when shutting down. - """ - # Load HTTP extension from environment if not provided - if http_extension is None: - http_extension = load_extension("HTTP", HttpExtension) - if http_extension: - logging.info(f"Loaded HTTP extension: {http_extension.__class__.__name__}") - - @asynccontextmanager - async def lifespan(app: FastAPI): - """ - Lifespan context manager for startup and shutdown events. - Note: This only fires when running the app standalone, not when mounted. - """ - # Initialize OpenTelemetry metrics - try: - prometheus_reader = initialize_metrics(service_name="hindsight-api", service_version="1.0.0") - create_metrics_collector() - app.state.prometheus_reader = prometheus_reader - logging.info("Metrics initialized - available at /metrics endpoint") - except Exception as e: - logging.warning(f"Failed to initialize metrics: {e}. Metrics will be disabled (using no-op collector).") - app.state.prometheus_reader = None - # Metrics collector is already initialized as no-op by default - - # Startup: Initialize database and memory system (migrations run inside initialize if enabled) - if initialize_memory: - await memory.initialize() - logging.info("Memory system initialized") - - # Call HTTP extension startup hook - if http_extension: - await http_extension.on_startup() - logging.info("HTTP extension started") - - yield - - # Call HTTP extension shutdown hook - if http_extension: - await http_extension.on_shutdown() - logging.info("HTTP extension stopped") - - # Shutdown: Cleanup memory system - await memory.close() - logging.info("Memory system closed") - - from hindsight_api import __version__ - - app = FastAPI( - title="Hindsight HTTP API", - version=__version__, - description="HTTP API for Hindsight", - contact={ - "name": "Memory System", - }, - license_info={ - "name": "Apache 2.0", - "url": "https://www.apache.org/licenses/LICENSE-2.0.html", - }, - lifespan=lifespan, - ) - - # 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 - - # Register all routes - _register_routes(app) - - # Mount HTTP extension router if available - if http_extension: - extension_router = http_extension.get_router(memory) - app.include_router(extension_router, prefix="/ext", tags=["Extension"]) - logging.info("HTTP extension router mounted at /ext/") - - return app - - -def _register_routes(app: FastAPI): - """Register all API routes on the given app instance.""" - - def get_request_context(authorization: str | None = Header(default=None)) -> RequestContext: - """ - Extract request context from Authorization header. - - Supports: - - Bearer token: "Bearer " - - Direct API key: "" - - Returns RequestContext with extracted API key (may be None if no auth header). - """ - api_key = None - if authorization: - if authorization.lower().startswith("bearer "): - api_key = authorization[7:].strip() - else: - api_key = authorization.strip() - return RequestContext(api_key=api_key) - - @app.get( - "/health", - summary="Health check endpoint", - description="Checks the health of the API and database connection", - tags=["Monitoring"], - ) - async def health_endpoint(): - """ - Health check endpoint that verifies database connectivity. - - Returns 200 if healthy, 503 if unhealthy. - """ - from fastapi.responses import JSONResponse - - health = await app.state.memory.health_check() - status_code = 200 if health.get("status") == "healthy" else 503 - return JSONResponse(content=health, status_code=status_code) - - @app.get( - "/metrics", - summary="Prometheus metrics endpoint", - description="Exports metrics in Prometheus format for scraping", - tags=["Monitoring"], - ) - async def metrics_endpoint(): - """Return Prometheus metrics.""" - from fastapi.responses import Response - from prometheus_client import CONTENT_TYPE_LATEST, generate_latest - - metrics_data = generate_latest() - return Response(content=metrics_data, media_type=CONTENT_TYPE_LATEST) - - @app.get( - "/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). Limited to 1000 most recent items.", - operation_id="get_graph", - tags=["Memory"], - ) - async def api_graph( - bank_id: str, type: str | None = None, request_context: RequestContext = Depends(get_request_context) - ): - """Get graph data from database, filtered by bank_id and optionally by type.""" - try: - data = await app.state.memory.get_graph_data(bank_id, type, request_context=request_context) - return data - 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}/graph: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.get( - "/v1/default/banks/{bank_id}/memories/list", - response_model=ListMemoryUnitsResponse, - summary="List memory units", - description="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).", - operation_id="list_memories", - tags=["Memory"], - ) - async def api_list( - bank_id: str, - type: str | None = None, - q: str | None = None, - limit: int = 100, - offset: int = 0, - request_context: RequestContext = Depends(get_request_context), - ): - """ - List memory units for table view with optional full-text search. - - Results are ordered by most recent first, using mentioned_at timestamp - (when the memory was mentioned/learned), falling back to created_at. - - Args: - bank_id: Memory Bank ID (from path) - type: Filter by fact type (world, experience, opinion) - q: Search query for full-text search (searches text and context) - limit: Maximum number of results (default: 100) - offset: Offset for pagination (default: 0) - """ - try: - data = await app.state.memory.list_memory_units( - bank_id=bank_id, - fact_type=type, - search_query=q, - limit=limit, - offset=offset, - request_context=request_context, - ) - return data - 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/list: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.post( - "/v1/default/banks/{bank_id}/memories/recall", - response_model=RecallResponse, - summary="Recall memory", - description="Recall memory using semantic similarity and spreading activation.\n\n" - "The type parameter is optional and must be one of:\n" - "- `world`: General knowledge about people, places, events, and things that happen\n" - "- `experience`: Memories about experience, conversations, actions taken, and tasks performed\n" - "- `opinion`: The bank's formed beliefs, perspectives, and viewpoints\n\n" - "Set `include_entities=true` to get entity observations alongside recall results.", - operation_id="recall_memories", - tags=["Memory"], - ) - async def api_recall( - bank_id: str, request: RecallRequest, request_context: RequestContext = Depends(get_request_context) - ): - """Run a recall and return results with trace.""" - metrics = get_metrics_collector() - - try: - # Default to world, experience, opinion if not specified (exclude observation by default) - fact_types = request.types if request.types else list(VALID_RECALL_FACT_TYPES) - - # Parse query_timestamp if provided - question_date = None - if request.query_timestamp: - try: - question_date = datetime.fromisoformat(request.query_timestamp.replace("Z", "+00:00")) - except ValueError as e: - raise HTTPException( - status_code=400, - detail=f"Invalid query_timestamp format. Expected ISO format (e.g., '2023-05-30T23:40:00'): {str(e)}", - ) - - # Determine entity inclusion settings - include_entities = request.include.entities is not None - max_entity_tokens = request.include.entities.max_tokens if include_entities else 500 - - # Determine chunk inclusion settings - include_chunks = request.include.chunks is not None - max_chunk_tokens = request.include.chunks.max_tokens if include_chunks else 8192 - - # Run recall with tracing (record metrics) - with metrics.record_operation( - "recall", bank_id=bank_id, budget=request.budget.value, max_tokens=request.max_tokens - ): - core_result = await 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, - request_context=request_context, - ) - - # Convert core MemoryFact objects to API RecallResult objects (excluding internal metrics) - recall_results = [ - RecallResult( - id=fact.id, - text=fact.text, - type=fact.fact_type, - entities=fact.entities, - context=fact.context, - occurred_start=fact.occurred_start, - occurred_end=fact.occurred_end, - mentioned_at=fact.mentioned_at, - document_id=fact.document_id, - chunk_id=fact.chunk_id, - ) - for fact in core_result.results - ] - - # Convert chunks from engine to HTTP API format - chunks_response = None - if core_result.chunks: - chunks_response = {} - for chunk_id, chunk_info in core_result.chunks.items(): - chunks_response[chunk_id] = ChunkData( - id=chunk_id, - text=chunk_info.chunk_text, - chunk_index=chunk_info.chunk_index, - truncated=chunk_info.truncated, - ) - - # Convert core EntityState objects to API EntityStateResponse objects - entities_response = None - if core_result.entities: - entities_response = {} - for name, state in core_result.entities.items(): - entities_response[name] = EntityStateResponse( - entity_id=state.entity_id, - canonical_name=state.canonical_name, - observations=[ - EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at) - for obs in state.observations - ], - ) - - return RecallResponse( - results=recall_results, trace=core_result.trace, entities=entities_response, chunks=chunks_response - ) - except 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/recall: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.post( - "/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" - "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" - "4. Uses LLM to formulate a contextual answer\n" - "5. Extracts and stores any new opinions formed\n" - "6. Returns plain text answer, the facts used, and new opinions", - operation_id="reflect", - tags=["Memory"], - ) - async def api_reflect( - bank_id: str, request: ReflectRequest, request_context: RequestContext = Depends(get_request_context) - ): - metrics = get_metrics_collector() - - try: - # Use the memory system's reflect_async method (record metrics) - with metrics.record_operation("reflect", bank_id=bank_id, budget=request.budget.value): - core_result = await app.state.memory.reflect_async( - bank_id=bank_id, - query=request.query, - budget=request.budget, - context=request.context, - request_context=request_context, - ) - - # Convert core MemoryFact objects to API ReflectFact objects if facts are requested - based_on_facts = [] - if request.include.facts is not None: - for fact_type, facts in core_result.based_on.items(): - for fact in facts: - based_on_facts.append( - ReflectFact( - id=fact.id, - text=fact.text, - type=fact.fact_type, - context=fact.context, - occurred_start=fact.occurred_start, - occurred_end=fact.occurred_end, - ) - ) - - return ReflectResponse( - text=core_result.text, - based_on=based_on_facts, - ) - - 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}/reflect: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.get( - "/v1/default/banks", - response_model=BankListResponse, - summary="List all memory banks", - description="Get a list of all agents with their profiles", - operation_id="list_banks", - tags=["Banks"], - ) - async def api_list_banks(request_context: RequestContext = Depends(get_request_context)): - """Get list of all banks with their profiles.""" - try: - banks = await app.state.memory.list_banks(request_context=request_context) - return BankListResponse(banks=banks) - 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: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.get( - "/v1/default/banks/{bank_id}/stats", - summary="Get statistics for memory bank", - description="Get statistics about nodes and links for a specific agent", - operation_id="get_agent_stats", - tags=["Banks"], - ) - async def api_stats(bank_id: str): - """Get statistics about memory nodes and links for a memory bank.""" - try: - pool = await app.state.memory._get_pool() - async with acquire_with_retry(pool) as conn: - # Get node counts by fact_type - node_stats = await conn.fetch( - f""" - SELECT fact_type, COUNT(*) as count - FROM {fq_table("memory_units")} - WHERE bank_id = $1 - GROUP BY fact_type - """, - bank_id, - ) - - # Get link counts by link_type - link_stats = await conn.fetch( - f""" - SELECT ml.link_type, COUNT(*) as count - FROM {fq_table("memory_links")} ml - JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id - WHERE mu.bank_id = $1 - GROUP BY ml.link_type - """, - bank_id, - ) - - # Get link counts by fact_type (from nodes) - link_fact_type_stats = await conn.fetch( - f""" - SELECT mu.fact_type, COUNT(*) as count - FROM {fq_table("memory_links")} ml - JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id - WHERE mu.bank_id = $1 - GROUP BY mu.fact_type - """, - bank_id, - ) - - # Get link counts by fact_type AND link_type - link_breakdown_stats = await conn.fetch( - f""" - SELECT mu.fact_type, ml.link_type, COUNT(*) as count - FROM {fq_table("memory_links")} ml - JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id - WHERE mu.bank_id = $1 - GROUP BY mu.fact_type, ml.link_type - """, - bank_id, - ) - - # Get pending and failed operations counts - ops_stats = await conn.fetch( - f""" - SELECT status, COUNT(*) as count - FROM {fq_table("async_operations")} - WHERE bank_id = $1 - GROUP BY status - """, - bank_id, - ) - ops_by_status = {row["status"]: row["count"] for row in ops_stats} - pending_operations = ops_by_status.get("pending", 0) - failed_operations = ops_by_status.get("failed", 0) - - # Get document count - doc_count_result = await conn.fetchrow( - f""" - SELECT COUNT(*) as count - FROM {fq_table("documents")} - WHERE bank_id = $1 - """, - bank_id, - ) - total_documents = doc_count_result["count"] if doc_count_result else 0 - - # Format results - nodes_by_type = {row["fact_type"]: row["count"] for row in node_stats} - links_by_type = {row["link_type"]: row["count"] for row in link_stats} - links_by_fact_type = {row["fact_type"]: row["count"] for row in link_fact_type_stats} - - # Build detailed breakdown: {fact_type: {link_type: count}} - links_breakdown = {} - for row in link_breakdown_stats: - fact_type = row["fact_type"] - link_type = row["link_type"] - count = row["count"] - if fact_type not in links_breakdown: - links_breakdown[fact_type] = {} - links_breakdown[fact_type][link_type] = count - - total_nodes = sum(nodes_by_type.values()) - total_links = sum(links_by_type.values()) - - return { - "bank_id": bank_id, - "total_nodes": total_nodes, - "total_links": total_links, - "total_documents": total_documents, - "nodes_by_fact_type": nodes_by_type, - "links_by_link_type": links_by_type, - "links_by_fact_type": links_by_fact_type, - "links_breakdown": links_breakdown, - "pending_operations": pending_operations, - "failed_operations": failed_operations, - } - - 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}/stats: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.get( - "/v1/default/banks/{bank_id}/entities", - response_model=EntityListResponse, - summary="List entities", - description="List all entities (people, organizations, etc.) known by the bank, ordered by mention count.", - operation_id="list_entities", - tags=["Entities"], - ) - async def api_list_entities( - bank_id: str, - limit: int = Query(default=100, description="Maximum number of entities to return"), - request_context: RequestContext = Depends(get_request_context), - ): - """List entities for a memory bank.""" - try: - entities = await app.state.memory.list_entities(bank_id, limit=limit, request_context=request_context) - return EntityListResponse(items=[EntityListItem(**e) for e in entities]) - 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}/entities: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.get( - "/v1/default/banks/{bank_id}/entities/{entity_id}", - response_model=EntityDetailResponse, - summary="Get entity details", - description="Get detailed information about an entity including observations (mental model).", - operation_id="get_entity", - tags=["Entities"], - ) - async def api_get_entity( - bank_id: str, entity_id: str, request_context: RequestContext = Depends(get_request_context) - ): - """Get entity details with observations.""" - try: - entity = await app.state.memory.get_entity(bank_id, entity_id, request_context=request_context) - - if entity is None: - raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found") - - return EntityDetailResponse( - id=entity["id"], - canonical_name=entity["canonical_name"], - mention_count=entity["mention_count"], - first_seen=entity["first_seen"], - last_seen=entity["last_seen"], - metadata=_parse_metadata(entity["metadata"]), - observations=[ - EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at) - for obs in entity["observations"] - ], - ) - except 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}/entities/{entity_id}: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.post( - "/v1/default/banks/{bank_id}/entities/{entity_id}/regenerate", - response_model=EntityDetailResponse, - summary="Regenerate entity observations", - description="Regenerate observations for an entity based on all facts mentioning it.", - operation_id="regenerate_entity_observations", - tags=["Entities"], - ) - async def api_regenerate_entity_observations( - bank_id: str, - entity_id: str, - request_context: RequestContext = Depends(get_request_context), - ): - """Regenerate observations for an entity.""" - try: - # Get the entity to verify it exists and get canonical_name - entity = await app.state.memory.get_entity(bank_id, entity_id, request_context=request_context) - - if entity is None: - raise HTTPException(status_code=404, detail=f"Entity {entity_id} not found") - - # Regenerate observations - await app.state.memory.regenerate_entity_observations( - bank_id=bank_id, - entity_id=entity_id, - entity_name=entity["canonical_name"], - request_context=request_context, - ) - - # Get updated entity with new observations - entity = await app.state.memory.get_entity(bank_id, entity_id, request_context=request_context) - - return EntityDetailResponse( - id=entity["id"], - canonical_name=entity["canonical_name"], - mention_count=entity["mention_count"], - first_seen=entity["first_seen"], - last_seen=entity["last_seen"], - metadata=_parse_metadata(entity["metadata"]), - observations=[ - EntityObservationResponse(text=obs.text, mentioned_at=obs.mentioned_at) - for obs in entity["observations"] - ], - ) - except 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}/entities/{entity_id}/regenerate: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.get( - "/v1/default/banks/{bank_id}/documents", - response_model=ListDocumentsResponse, - summary="List documents", - description="List documents with pagination and optional search. Documents are the source content from which memory units are extracted.", - operation_id="list_documents", - tags=["Documents"], - ) - async def api_list_documents( - bank_id: str, - q: str | None = None, - limit: int = 100, - offset: int = 0, - request_context: RequestContext = Depends(get_request_context), - ): - """ - List documents for a memory bank with optional search. - - Args: - bank_id: Memory Bank ID (from path) - q: Search query (searches document ID and metadata) - limit: Maximum number of results (default: 100) - offset: Offset for pagination (default: 0) - """ - try: - data = await app.state.memory.list_documents( - bank_id=bank_id, search_query=q, limit=limit, offset=offset, request_context=request_context - ) - return data - 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}/documents: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.get( - "/v1/default/banks/{bank_id}/documents/{document_id}", - response_model=DocumentResponse, - summary="Get document details", - description="Get a specific document including its original text", - operation_id="get_document", - tags=["Documents"], - ) - async def api_get_document( - bank_id: str, document_id: str, request_context: RequestContext = Depends(get_request_context) - ): - """ - Get a specific document with its original text. - - Args: - bank_id: Memory Bank ID (from path) - document_id: Document ID (from path) - """ - try: - document = await app.state.memory.get_document(document_id, bank_id, request_context=request_context) - if not document: - raise HTTPException(status_code=404, detail="Document not found") - return document - except 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}/documents/{document_id}: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.get( - "/v1/default/chunks/{chunk_id}", - response_model=ChunkResponse, - summary="Get chunk details", - description="Get a specific chunk by its ID", - operation_id="get_chunk", - tags=["Documents"], - ) - async def api_get_chunk(chunk_id: str, request_context: RequestContext = Depends(get_request_context)): - """ - Get a specific chunk with its text. - - Args: - chunk_id: Chunk ID (from path, format: bank_id_document_id_chunk_index) - """ - try: - chunk = await app.state.memory.get_chunk(chunk_id, request_context=request_context) - if not chunk: - raise HTTPException(status_code=404, detail="Chunk not found") - return chunk - except 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/chunks/{chunk_id}: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.delete( - "/v1/default/banks/{bank_id}/documents/{document_id}", - summary="Delete a document", - description="Delete a document and all its associated memory units and links.\n\n" - "This will cascade delete:\n" - "- The document itself\n" - "- All memory units extracted from this document\n" - "- All links (temporal, semantic, entity) associated with those memory units\n\n" - "This operation cannot be undone.", - operation_id="delete_document", - tags=["Documents"], - ) - async def api_delete_document( - bank_id: str, document_id: str, request_context: RequestContext = Depends(get_request_context) - ): - """ - Delete a document and all its associated memory units and links. - - Args: - bank_id: Memory Bank ID (from path) - document_id: Document ID to delete (from path) - """ - try: - result = await app.state.memory.delete_document(document_id, bank_id, request_context=request_context) - - if result["document_deleted"] == 0: - raise HTTPException(status_code=404, detail="Document not found") - - return { - "success": True, - "message": f"Document '{document_id}' and {result['memory_units_deleted']} associated memory units deleted successfully", - "document_id": document_id, - "memory_units_deleted": result["memory_units_deleted"], - } - except 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}/documents/{document_id}: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.get( - "/v1/default/banks/{bank_id}/operations", - summary="List async operations", - description="Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations", - operation_id="list_operations", - tags=["Operations"], - ) - async def api_list_operations(bank_id: str, request_context: RequestContext = Depends(get_request_context)): - """List all async operations (pending and failed) for a memory bank.""" - try: - operations = await app.state.memory.list_operations(bank_id, request_context=request_context) - return { - "bank_id": bank_id, - "operations": operations, - } - 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}/operations: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.delete( - "/v1/default/banks/{bank_id}/operations/{operation_id}", - summary="Cancel a pending async operation", - description="Cancel a pending async operation by removing it from the queue", - operation_id="cancel_operation", - tags=["Operations"], - ) - async def api_cancel_operation( - bank_id: str, operation_id: str, request_context: RequestContext = Depends(get_request_context) - ): - """Cancel a pending async operation.""" - try: - # Validate UUID format - try: - uuid.UUID(operation_id) - except ValueError: - raise HTTPException(status_code=400, detail=f"Invalid operation_id format: {operation_id}") - - result = await app.state.memory.cancel_operation(bank_id, operation_id, request_context=request_context) - return result - except ValueError as e: - raise HTTPException(status_code=404, detail=str(e)) - 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}/operations/{operation_id}: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.get( - "/v1/default/banks/{bank_id}/profile", - response_model=BankProfileResponse, - summary="Get memory bank profile", - description="Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists.", - operation_id="get_bank_profile", - tags=["Banks"], - ) - async def api_get_bank_profile(bank_id: str, request_context: RequestContext = Depends(get_request_context)): - """Get memory bank profile (disposition + background).""" - try: - profile = await app.state.memory.get_bank_profile(bank_id, request_context=request_context) - # Convert DispositionTraits object to dict for Pydantic - disposition_dict = ( - profile["disposition"].model_dump() - if hasattr(profile["disposition"], "model_dump") - else dict(profile["disposition"]) - ) - return BankProfileResponse( - bank_id=bank_id, - name=profile["name"], - disposition=DispositionTraits(**disposition_dict), - background=profile["background"], - ) - 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}/profile: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.put( - "/v1/default/banks/{bank_id}/profile", - response_model=BankProfileResponse, - summary="Update memory bank disposition", - description="Update bank's disposition traits (skepticism, literalism, empathy)", - operation_id="update_bank_disposition", - tags=["Banks"], - ) - async def api_update_bank_disposition( - bank_id: str, request: UpdateDispositionRequest, request_context: RequestContext = Depends(get_request_context) - ): - """Update bank disposition traits.""" - try: - # Update disposition - await app.state.memory.update_bank_disposition( - bank_id, request.disposition.model_dump(), request_context=request_context - ) - - # Get updated profile - profile = await app.state.memory.get_bank_profile(bank_id, request_context=request_context) - disposition_dict = ( - profile["disposition"].model_dump() - if hasattr(profile["disposition"], "model_dump") - else dict(profile["disposition"]) - ) - return BankProfileResponse( - bank_id=bank_id, - name=profile["name"], - disposition=DispositionTraits(**disposition_dict), - background=profile["background"], - ) - 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}/profile: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.post( - "/v1/default/banks/{bank_id}/background", - response_model=BackgroundResponse, - summary="Add/merge memory bank background", - description="Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits.", - operation_id="add_bank_background", - tags=["Banks"], - ) - async def api_add_bank_background( - bank_id: str, request: AddBackgroundRequest, request_context: RequestContext = Depends(get_request_context) - ): - """Add or merge bank background information. Optionally infer disposition traits.""" - try: - result = await app.state.memory.merge_bank_background( - bank_id, request.content, update_disposition=request.update_disposition, request_context=request_context - ) - - response = BackgroundResponse(background=result["background"]) - if "disposition" in result: - response.disposition = DispositionTraits(**result["disposition"]) - - return response - 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}/background: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.put( - "/v1/default/banks/{bank_id}", - response_model=BankProfileResponse, - summary="Create or update memory bank", - description="Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults.", - operation_id="create_or_update_bank", - tags=["Banks"], - ) - async def api_create_or_update_bank( - bank_id: str, request: CreateBankRequest, request_context: RequestContext = Depends(get_request_context) - ): - """Create or update an agent with disposition and background.""" - try: - # Ensure bank exists by getting profile (auto-creates with defaults) - await app.state.memory.get_bank_profile(bank_id, request_context=request_context) - - # Update name and/or background if provided - if request.name is not None or request.background is not None: - await app.state.memory.update_bank( - bank_id, - name=request.name, - background=request.background, - request_context=request_context, - ) - - # Update disposition if provided - if request.disposition is not None: - await app.state.memory.update_bank_disposition( - bank_id, request.disposition.model_dump(), request_context=request_context - ) - - # Get final profile - final_profile = await app.state.memory.get_bank_profile(bank_id, request_context=request_context) - disposition_dict = ( - final_profile["disposition"].model_dump() - if hasattr(final_profile["disposition"], "model_dump") - else dict(final_profile["disposition"]) - ) - return BankProfileResponse( - bank_id=bank_id, - name=final_profile["name"], - disposition=DispositionTraits(**disposition_dict), - background=final_profile["background"], - ) - 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}: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.delete( - "/v1/default/banks/{bank_id}", - response_model=DeleteResponse, - summary="Delete memory bank", - description="Delete an entire memory bank including all memories, entities, documents, and the bank profile itself. " - "This is a destructive operation that cannot be undone.", - operation_id="delete_bank", - tags=["Banks"], - ) - async def api_delete_bank(bank_id: str, request_context: RequestContext = Depends(get_request_context)): - """Delete an entire memory bank and all its data.""" - try: - result = await app.state.memory.delete_bank(bank_id, request_context=request_context) - return DeleteResponse( - success=True, - message=f"Bank '{bank_id}' and all associated data deleted successfully", - deleted_count=result.get("memory_units_deleted", 0) - + result.get("entities_deleted", 0) - + result.get("documents_deleted", 0), - ) - except Exception as e: - import traceback - - error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}" - logger.error(f"Error in DELETE /v1/default/banks/{bank_id}: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.post( - "/v1/default/banks/{bank_id}/memories", - response_model=RetainResponse, - summary="Retain memories", - description="Retain memory items with automatic fact extraction.\n\n" - "This is the main endpoint for storing memories. It supports both synchronous and asynchronous processing via the `async` parameter.\n\n" - "**Features:**\n" - "- Efficient batch processing\n" - "- Automatic fact extraction from natural language\n" - "- Entity recognition and linking\n" - "- Document tracking with automatic upsert (when document_id is provided)\n" - "- Temporal and semantic linking\n" - "- Optional asynchronous processing\n\n" - "**The system automatically:**\n" - "1. Extracts semantic facts from the content\n" - "2. Generates embeddings\n" - "3. Deduplicates similar facts\n" - "4. Creates temporal, semantic, and entity links\n" - "5. Tracks document metadata\n\n" - "**When `async=true`:** Returns immediately after queuing. Use the operations endpoint to monitor progress.\n\n" - "**When `async=false` (default):** Waits for processing to complete.\n\n" - "**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).", - operation_id="retain_memories", - tags=["Memory"], - ) - async def api_retain( - bank_id: str, request: RetainRequest, request_context: RequestContext = Depends(get_request_context) - ): - """Retain memories with optional async processing.""" - metrics = get_metrics_collector() - - try: - # Prepare contents for processing - contents = [] - for item in request.items: - content_dict = {"content": item.content} - if item.timestamp: - content_dict["event_date"] = item.timestamp - if item.context: - content_dict["context"] = item.context - if item.metadata: - content_dict["metadata"] = item.metadata - if item.document_id: - content_dict["document_id"] = item.document_id - contents.append(content_dict) - - if request.async_: - # Async processing: queue task and return immediately - result = await app.state.memory.submit_async_retain(bank_id, contents, request_context=request_context) - return RetainResponse.model_validate( - { - "success": True, - "bank_id": bank_id, - "items_count": result["items_count"], - "async": True, - } - ) - else: - # Synchronous processing: wait for completion (record metrics) - with metrics.record_operation("retain", bank_id=bank_id): - result = await app.state.memory.retain_batch_async( - bank_id=bank_id, contents=contents, request_context=request_context - ) - - return RetainResponse.model_validate( - {"success": True, "bank_id": bank_id, "items_count": len(contents), "async": False} - ) - except Exception as e: - import traceback - - # Create a summary of the input for debugging - input_summary = [] - for i, item in enumerate(request.items): - content_preview = item.content[:100] + "..." if len(item.content) > 100 else item.content - input_summary.append( - f" [{i}] content={content_preview!r}, context={item.context}, timestamp={item.timestamp}" - ) - input_debug = "\n".join(input_summary) - - error_detail = ( - f"{str(e)}\n\n" - f"Input ({len(request.items)} items):\n{input_debug}\n\n" - f"Traceback:\n{traceback.format_exc()}" - ) - logger.error(f"Error in /v1/default/banks/{bank_id}/memories (retain): {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) - - @app.delete( - "/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.", - operation_id="clear_bank_memories", - tags=["Memory"], - ) - async def api_clear_bank_memories( - bank_id: str, - type: str | None = Query(None, description="Optional fact type filter (world, experience, opinion)"), - request_context: RequestContext = Depends(get_request_context), - ): - """Clear memories for a memory bank, optionally filtered by type.""" - try: - await app.state.memory.delete_bank(bank_id, fact_type=type, request_context=request_context) - - return DeleteResponse(success=True) - 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: {error_detail}") - raise HTTPException(status_code=500, detail=str(e)) diff --git a/hindsight-api/hindsight_api/api/mcp.py b/hindsight-api/hindsight_api/api/mcp.py deleted file mode 100644 index f3a7bd04..00000000 --- a/hindsight-api/hindsight_api/api/mcp.py +++ /dev/null @@ -1,224 +0,0 @@ -"""Hindsight MCP Server implementation using FastMCP.""" - -import json -import logging -import os -from contextvars import ContextVar - -from fastmcp import FastMCP - -from hindsight_api import MemoryEngine -from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES -from hindsight_api.models import RequestContext - -# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable -_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower() -_log_level_map = { - "critical": logging.CRITICAL, - "error": logging.ERROR, - "warning": logging.WARNING, - "info": logging.INFO, - "debug": logging.DEBUG, - "trace": logging.DEBUG, -} -logging.basicConfig( - level=_log_level_map.get(_log_level_str, logging.INFO), - format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", -) -logger = logging.getLogger(__name__) - -# Context variable to hold the current bank_id from the URL path -_current_bank_id: ContextVar[str | None] = ContextVar("current_bank_id", default=None) - - -def get_current_bank_id() -> str | None: - """Get the current bank_id from context (set from URL path).""" - return _current_bank_id.get() - - -def create_mcp_server(memory: MemoryEngine) -> FastMCP: - """ - Create and configure the Hindsight MCP server. - - Args: - memory: MemoryEngine instance (required) - - Returns: - Configured FastMCP server instance - """ - mcp = FastMCP("hindsight-mcp-server") - - @mcp.tool() - async def retain(content: str, context: str = "general") -> str: - """ - Store important information to long-term memory. - - Use this tool PROACTIVELY whenever the user shares: - - Personal facts, preferences, or interests - - Important events or milestones - - User history, experiences, or background - - Decisions, opinions, or stated preferences - - Goals, plans, or future intentions - - Relationships or people mentioned - - Work context, projects, or responsibilities - - Args: - content: The fact/memory to store (be specific and include relevant details) - context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general' - """ - try: - bank_id = get_current_bank_id() - if bank_id is None: - return "Error: No bank_id configured" - await memory.retain_batch_async( - bank_id=bank_id, contents=[{"content": content, "context": context}], request_context=RequestContext() - ) - return "Memory stored successfully" - except Exception as e: - logger.error(f"Error storing memory: {e}", exc_info=True) - return f"Error: {str(e)}" - - @mcp.tool() - async def recall(query: str, max_results: int = 10) -> str: - """ - Search memories to provide personalized, context-aware responses. - - Use this tool PROACTIVELY to: - - Check user's preferences before making suggestions - - Recall user's history to provide continuity - - Remember user's goals and context - - Personalize responses based on past interactions - - Args: - query: Natural language search query (e.g., "user's food preferences", "what projects is user working on") - max_results: Maximum number of results to return (default: 10) - """ - try: - bank_id = get_current_bank_id() - if bank_id is None: - return "Error: No bank_id configured" - from hindsight_api.engine.memory_engine import Budget - - search_result = await memory.recall_async( - bank_id=bank_id, - query=query, - fact_type=list(VALID_RECALL_FACT_TYPES), - budget=Budget.LOW, - request_context=RequestContext(), - ) - - results = [ - { - "id": fact.id, - "text": fact.text, - "type": fact.fact_type, - "context": fact.context, - "occurred_start": fact.occurred_start, - } - for fact in search_result.results[:max_results] - ] - - return json.dumps({"results": results}, indent=2) - except Exception as e: - logger.error(f"Error searching: {e}", exc_info=True) - return json.dumps({"error": str(e), "results": []}) - - return mcp - - -class MCPMiddleware: - """ASGI middleware that extracts bank_id from path and sets context.""" - - def __init__(self, app, memory: MemoryEngine): - self.app = app - self.memory = memory - self.mcp_server = create_mcp_server(memory) - self.mcp_app = self.mcp_server.http_app() - - async def __call__(self, scope, receive, send): - if scope["type"] != "http": - await self.mcp_app(scope, receive, send) - return - - path = scope.get("path", "") - - # Strip any mount prefix (e.g., /mcp) that FastAPI might not have stripped - root_path = scope.get("root_path", "") - if root_path and path.startswith(root_path): - path = path[len(root_path) :] or "/" - - # Also handle case where mount path wasn't stripped (e.g., /mcp/...) - if path.startswith("/mcp/"): - path = path[4:] # Remove /mcp prefix - - # Extract bank_id from path: /{bank_id}/ or /{bank_id} - # http_app expects requests at / - if not path.startswith("/") or len(path) <= 1: - # No bank_id in path - return error - await self._send_error(send, 400, "bank_id required in path: /mcp/{bank_id}/") - return - - # Extract bank_id from first path segment - parts = path[1:].split("/", 1) - if not parts[0]: - await self._send_error(send, 400, "bank_id required in path: /mcp/{bank_id}/") - return - - bank_id = parts[0] - new_path = "/" + parts[1] if len(parts) > 1 else "/" - - # Set bank_id context - token = _current_bank_id.set(bank_id) - try: - new_scope = scope.copy() - new_scope["path"] = new_path - - # Wrap send to rewrite the SSE endpoint URL to include bank_id - # The SSE app sends "event: endpoint\ndata: /messages\n" but we need - # the client to POST to /{bank_id}/messages instead - async def send_wrapper(message): - if message["type"] == "http.response.body": - body = message.get("body", b"") - if body and b"/messages" in body: - # Rewrite /messages to /{bank_id}/messages in SSE endpoint event - body = body.replace(b"data: /messages", f"data: /{bank_id}/messages".encode()) - message = {**message, "body": body} - await send(message) - - await self.mcp_app(new_scope, receive, send_wrapper) - finally: - _current_bank_id.reset(token) - - async def _send_error(self, send, status: int, message: str): - """Send an error response.""" - body = json.dumps({"error": message}).encode() - await send( - { - "type": "http.response.start", - "status": status, - "headers": [(b"content-type", b"application/json")], - } - ) - await send( - { - "type": "http.response.body", - "body": body, - } - ) - - -def create_mcp_app(memory: MemoryEngine): - """ - Create an ASGI app that handles MCP requests. - - URL pattern: /mcp/{bank_id}/ - - The bank_id is extracted from the URL path and made available to tools. - - Args: - memory: MemoryEngine instance - - Returns: - ASGI application - """ - return MCPMiddleware(None, memory) diff --git a/hindsight-api/hindsight_api/banner.py b/hindsight-api/hindsight_api/banner.py deleted file mode 100644 index 00c935e3..00000000 --- a/hindsight-api/hindsight_api/banner.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -Banner display for Hindsight API startup. - -Shows the logo and tagline with gradient colors. -""" - -# Gradient colors: #0074d9 -> #009296 -GRADIENT_START = (0, 116, 217) # #0074d9 -GRADIENT_END = (0, 146, 150) # #009296 - -# Pre-generated logo (generated by test-logo.py) -LOGO = """\ - \033[38;2;9;127;184m\u2584\033[0m\033[48;2;8;130;178m\033[38;2;5;133;186m\u2584\033[0m \033[48;2;10;143;160m\033[38;2;10;143;165m\u2584\033[0m\033[38;2;7;140;156m\u2584\033[0m - \033[38;2;8;125;192m\u2584\033[0m \033[38;2;3;132;191m\u2580\033[0m\033[38;2;2;133;192m\u2584\033[0m \033[38;2;3;132;180m\u2584\033[0m\033[38;2;1;137;184m\u2584\033[0m\033[38;2;3;133;174m\u2584\033[0m \033[38;2;3;142;176m\u2584\033[0m\033[38;2;4;142;169m\u2580\033[0m \033[38;2;10;144;164m\u2584\033[0m -\033[38;2;6;121;195m\u2580\033[0m\033[38;2;5;128;203m\u2580\033[0m\033[48;2;5;124;195m\033[38;2;3;125;200m\u2584\033[0m\033[38;2;2;126;196m\u2584\033[0m\033[48;2;3;128;188m\033[38;2;1;131;196m\u2584\033[0m\033[48;2;0;152;219m\033[38;2;2;131;191m\u2584\033[0m\033[38;2;1;141;196m\u2580\033[0m\033[38;2;1;135;183m\u2580\033[0m\033[38;2;1;148;198m\u2580\033[0m\033[48;2;1;156;202m\033[38;2;2;135;180m\u2584\033[0m\033[48;2;4;134;169m\033[38;2;1;137;177m\u2584\033[0m\033[38;2;3;138;173m\u2584\033[0m\033[48;2;6;137;165m\033[38;2;2;140;170m\u2584\033[0m\033[38;2;7;144;169m\u2580\033[0m\033[38;2;7;139;158m\u2580\033[0m - \033[48;2;2;128;202m\033[38;2;2;124;201m\u2584\033[0m\033[48;2;1;130;201m\033[38;2;0;135;212m\u2584\033[0m\033[38;2;2;128;196m\u2584\033[0m \033[48;2;2;142;204m\033[38;2;7;138;199m\u2584\033[0m \033[38;2;1;135;186m\u2584\033[0m\033[48;2;1;142;186m\033[38;2;2;144;194m\u2584\033[0m\033[48;2;3;138;176m\033[38;2;2;134;176m\u2584\033[0m - \033[48;2;8;118;200m\033[38;2;8;121;209m\u2584\033[0m\033[38;2;3;121;203m\u2580\033[0m \033[38;2;3;122;192m\u2580\033[0m\033[38;2;1;138;216m\u2580\033[0m\033[48;2;0;138;210m\033[38;2;3;128;198m\u2584\033[0m\033[48;2;0;126;188m\033[38;2;2;131;198m\u2584\033[0m\033[48;2;0;142;205m\033[38;2;3;132;193m\u2584\033[0m\033[38;2;1;140;196m\u2580\033[0m \033[38;2;4;134;175m\u2580\033[0m\033[48;2;13;135;167m\033[38;2;8;136;174m\u2584\033[0m """ - - -def _interpolate_color(start: tuple, end: tuple, t: float) -> tuple: - """Interpolate between two RGB colors.""" - return ( - int(start[0] + (end[0] - start[0]) * t), - int(start[1] + (end[1] - start[1]) * t), - int(start[2] + (end[2] - start[2]) * t), - ) - - -def gradient_text(text: str, start: tuple = GRADIENT_START, end: tuple = GRADIENT_END) -> str: - """Render text with a gradient color effect.""" - result = [] - length = len(text) - for i, char in enumerate(text): - if char == " ": - result.append(" ") - else: - t = i / max(length - 1, 1) - r, g, b = _interpolate_color(start, end, t) - result.append(f"\033[38;2;{r};{g};{b}m{char}") - result.append("\033[0m") - return "".join(result) - - -def print_banner(): - """Print the Hindsight startup banner.""" - print(LOGO) - tagline = gradient_text("Hindsight: Agent Memory That Works Like Human Memory") - print(f"\n {tagline}\n") - - -def color(text: str, t: float = 0.0) -> str: - """Color text using gradient position (0.0 = start, 1.0 = end).""" - r, g, b = _interpolate_color(GRADIENT_START, GRADIENT_END, t) - return f"\033[38;2;{r};{g};{b}m{text}\033[0m" - - -def color_start(text: str) -> str: - """Color text with gradient start color (#0074d9).""" - return color(text, 0.0) - - -def color_end(text: str) -> str: - """Color text with gradient end color (#009296).""" - return color(text, 1.0) - - -def color_mid(text: str) -> str: - """Color text with gradient middle color.""" - return color(text, 0.5) - - -def dim(text: str) -> str: - """Dim/gray text.""" - return f"\033[38;2;128;128;128m{text}\033[0m" - - -def print_startup_info( - host: str, - port: int, - database_url: str, - llm_provider: str, - llm_model: str, - embeddings_provider: str, - reranker_provider: str, - mcp_enabled: bool = False, -): - """Print styled startup information.""" - print(color_start("Starting Hindsight API...")) - print(f" {dim('URL:')} {color(f'http://{host}:{port}', 0.2)}") - print(f" {dim('Database:')} {color(database_url, 0.4)}") - print(f" {dim('LLM:')} {color(f'{llm_provider} / {llm_model}', 0.6)}") - print(f" {dim('Embeddings:')} {color(embeddings_provider, 0.8)}") - print(f" {dim('Reranker:')} {color(reranker_provider, 1.0)}") - if mcp_enabled: - print(f" {dim('MCP:')} {color_end('enabled at /mcp')}") - print() diff --git a/hindsight-api/hindsight_api/config.py b/hindsight-api/hindsight_api/config.py deleted file mode 100644 index 1b52b8cc..00000000 --- a/hindsight-api/hindsight_api/config.py +++ /dev/null @@ -1,225 +0,0 @@ -""" -Centralized configuration for Hindsight API. - -All environment variables and their defaults are defined here. -""" - -import logging -import os -from dataclasses import dataclass - -logger = logging.getLogger(__name__) - -# Environment variable names -ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL" -ENV_LLM_PROVIDER = "HINDSIGHT_API_LLM_PROVIDER" -ENV_LLM_API_KEY = "HINDSIGHT_API_LLM_API_KEY" -ENV_LLM_MODEL = "HINDSIGHT_API_LLM_MODEL" -ENV_LLM_BASE_URL = "HINDSIGHT_API_LLM_BASE_URL" -ENV_LLM_AZURE_API_VERSION = "HINDSIGHT_API_LLM_AZURE_API_VERSION" - -ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER" -ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL" -ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL" - -# OpenAI / Azure OpenAI embeddings -ENV_EMBEDDINGS_MODEL = "HINDSIGHT_API_EMBEDDINGS_MODEL" -ENV_EMBEDDINGS_DIMENSIONS = "HINDSIGHT_API_EMBEDDINGS_DIMENSIONS" -ENV_EMBEDDINGS_API_KEY = "HINDSIGHT_API_EMBEDDINGS_API_KEY" -ENV_EMBEDDINGS_BASE_URL = "HINDSIGHT_API_EMBEDDINGS_BASE_URL" -ENV_EMBEDDINGS_AZURE_DEPLOYMENT = "HINDSIGHT_API_EMBEDDINGS_AZURE_DEPLOYMENT" -ENV_EMBEDDINGS_AZURE_API_VERSION = "HINDSIGHT_API_EMBEDDINGS_AZURE_API_VERSION" - -ENV_RERANKER_PROVIDER = "HINDSIGHT_API_RERANKER_PROVIDER" -ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL" -ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL" - -ENV_HOST = "HINDSIGHT_API_HOST" -ENV_PORT = "HINDSIGHT_API_PORT" -ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL" -ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED" -ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER" -ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID" -ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS" - -# Optimization flags -ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION" -ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER" - -# Default values -DEFAULT_DATABASE_URL = "pg0" -DEFAULT_LLM_PROVIDER = "openai" -DEFAULT_LLM_MODEL = "gpt-5-mini" -DEFAULT_LLM_AZURE_API_VERSION = "2024-02-15-preview" - -DEFAULT_EMBEDDINGS_PROVIDER = "local" -DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5" - -DEFAULT_EMBEDDINGS_MODEL = "text-embedding-3-large" -DEFAULT_EMBEDDINGS_DIMENSIONS = 384 -DEFAULT_EMBEDDINGS_AZURE_API_VERSION = "2024-02-15-preview" - -DEFAULT_RERANKER_PROVIDER = "none" # Options: 'none' (disabled), 'local' (sentence-transformers), 'tei' (remote) -DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2" - -DEFAULT_HOST = "0.0.0.0" -DEFAULT_PORT = 8888 -DEFAULT_LOG_LEVEL = "info" -DEFAULT_MCP_ENABLED = True -DEFAULT_GRAPH_RETRIEVER = "bfs" # Options: "bfs", "mpfp" -DEFAULT_MCP_LOCAL_BANK_ID = "mcp" - -# Default MCP tool descriptions (can be customized via env vars) -DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory. - -Use this tool PROACTIVELY whenever the user shares: -- Personal facts, preferences, or interests -- Important events or milestones -- User history, experiences, or background -- Decisions, opinions, or stated preferences -- Goals, plans, or future intentions -- Relationships or people mentioned -- Work context, projects, or responsibilities""" - -DEFAULT_MCP_RECALL_DESCRIPTION = """Search memories to provide personalized, context-aware responses. - -Use this tool PROACTIVELY to: -- Check user's preferences before making suggestions -- Recall user's history to provide continuity -- Remember user's goals and context -- Personalize responses based on past interactions""" - -# Required embedding dimension for database schema -EMBEDDING_DIMENSION = 384 - - -@dataclass -class HindsightConfig: - """Configuration container for Hindsight API.""" - - # Database - database_url: str - - # LLM - llm_provider: str - llm_api_key: str | None - llm_model: str - llm_base_url: str | None - llm_azure_api_version: str | None - - # Embeddings - embeddings_provider: str - embeddings_local_model: str - embeddings_tei_url: str | None - - embeddings_model: str - embeddings_dimensions: int - embeddings_api_key: str | None - embeddings_base_url: str | None - embeddings_azure_deployment: str | None - embeddings_azure_api_version: str | None - - # Reranker - reranker_provider: str - reranker_local_model: str - reranker_tei_url: str | None - - # Server - host: str - port: int - log_level: str - mcp_enabled: bool - - # Recall - graph_retriever: str - - # Optimization flags - skip_llm_verification: bool - lazy_reranker: bool - - @classmethod - def from_env(cls) -> "HindsightConfig": - """Create configuration from environment variables.""" - return cls( - # Database - database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL), - # LLM - llm_provider=os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER), - llm_api_key=os.getenv(ENV_LLM_API_KEY), - llm_model=os.getenv(ENV_LLM_MODEL, DEFAULT_LLM_MODEL), - llm_base_url=os.getenv(ENV_LLM_BASE_URL) or None, - llm_azure_api_version=os.getenv(ENV_LLM_AZURE_API_VERSION, DEFAULT_LLM_AZURE_API_VERSION) or None, - # Embeddings - embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER), - embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL), - embeddings_tei_url=os.getenv(ENV_EMBEDDINGS_TEI_URL), - - embeddings_model=os.getenv(ENV_EMBEDDINGS_MODEL, DEFAULT_EMBEDDINGS_MODEL), - embeddings_dimensions=int(os.getenv(ENV_EMBEDDINGS_DIMENSIONS, str(DEFAULT_EMBEDDINGS_DIMENSIONS))), - embeddings_api_key=os.getenv(ENV_EMBEDDINGS_API_KEY), - embeddings_base_url=os.getenv(ENV_EMBEDDINGS_BASE_URL) or None, - embeddings_azure_deployment=os.getenv(ENV_EMBEDDINGS_AZURE_DEPLOYMENT) or None, - embeddings_azure_api_version=os.getenv(ENV_EMBEDDINGS_AZURE_API_VERSION, DEFAULT_EMBEDDINGS_AZURE_API_VERSION) or None, - # Reranker - reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER), - reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL), - reranker_tei_url=os.getenv(ENV_RERANKER_TEI_URL), - # Server - host=os.getenv(ENV_HOST, DEFAULT_HOST), - port=int(os.getenv(ENV_PORT, DEFAULT_PORT)), - log_level=os.getenv(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL), - mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true", - # Recall - graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER), - # Optimization flags - skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true", - lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true", - ) - - def get_llm_base_url(self) -> str: - """Get the LLM base URL, with provider-specific defaults.""" - if self.llm_base_url: - return self.llm_base_url - - provider = self.llm_provider.lower() - if provider == "groq": - return "https://api.groq.com/openai/v1" - elif provider == "ollama": - return "http://localhost:11434/v1" - else: - return "" - - def get_python_log_level(self) -> int: - """Get the Python logging level from the configured log level string.""" - log_level_map = { - "critical": logging.CRITICAL, - "error": logging.ERROR, - "warning": logging.WARNING, - "info": logging.INFO, - "debug": logging.DEBUG, - "trace": logging.DEBUG, # Python doesn't have TRACE, use DEBUG - } - return log_level_map.get(self.log_level.lower(), logging.INFO) - - def configure_logging(self) -> None: - """Configure Python logging based on the log level.""" - logging.basicConfig( - level=self.get_python_log_level(), - format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", - force=True, # Override any existing configuration - ) - - def log_config(self) -> None: - """Log the current configuration (without sensitive values).""" - logger.info(f"Database: {self.database_url}") - logger.info(f"LLM: provider={self.llm_provider}, model={self.llm_model}") - logger.info( - f"Embeddings: provider={self.embeddings_provider}, model={self.embeddings_model}, dimensions={self.embeddings_dimensions}" - ) - logger.info(f"Reranker: provider={self.reranker_provider}") - logger.info(f"Graph retriever: {self.graph_retriever}") - - -def get_config() -> HindsightConfig: - """Get the current configuration from environment variables.""" - return HindsightConfig.from_env() diff --git a/hindsight-api/hindsight_api/daemon.py b/hindsight-api/hindsight_api/daemon.py deleted file mode 100644 index 094a2b3d..00000000 --- a/hindsight-api/hindsight_api/daemon.py +++ /dev/null @@ -1,204 +0,0 @@ -""" -Daemon mode support for Hindsight API. - -Provides idle timeout and lockfile management for running as a background daemon. -""" - -import asyncio -import fcntl -import logging -import os -import sys -import time -from pathlib import Path - -logger = logging.getLogger(__name__) - -# Default daemon configuration -DEFAULT_DAEMON_PORT = 8889 -DEFAULT_IDLE_TIMEOUT = 0 # 0 = no auto-exit (hindsight-embed passes its own timeout) -LOCKFILE_PATH = Path.home() / ".hindsight" / "daemon.lock" -DAEMON_LOG_PATH = Path.home() / ".hindsight" / "daemon.log" - - -class IdleTimeoutMiddleware: - """ASGI middleware that tracks activity and exits after idle timeout.""" - - def __init__(self, app, idle_timeout: int = DEFAULT_IDLE_TIMEOUT): - self.app = app - self.idle_timeout = idle_timeout - self.last_activity = time.time() - self._checker_task = None - - async def __call__(self, scope, receive, send): - # Update activity timestamp on each request - self.last_activity = time.time() - await self.app(scope, receive, send) - - def start_idle_checker(self): - """Start the background task that checks for idle timeout.""" - self._checker_task = asyncio.create_task(self._check_idle()) - - async def _check_idle(self): - """Background task that exits the process after idle timeout.""" - # If idle_timeout is 0, don't auto-exit - if self.idle_timeout <= 0: - return - - while True: - await asyncio.sleep(30) # Check every 30 seconds - idle_time = time.time() - self.last_activity - if idle_time > self.idle_timeout: - logger.info(f"Idle timeout reached ({self.idle_timeout}s), shutting down daemon") - # Give a moment for any in-flight requests - await asyncio.sleep(1) - os._exit(0) - - -class DaemonLock: - """ - File-based lock to prevent multiple daemon instances. - - Uses fcntl.flock for atomic locking on Unix systems. - """ - - def __init__(self, lockfile: Path = LOCKFILE_PATH): - self.lockfile = lockfile - self._fd = None - - def acquire(self) -> bool: - """ - Try to acquire the daemon lock. - - Returns True if lock acquired, False if another daemon is running. - """ - self.lockfile.parent.mkdir(parents=True, exist_ok=True) - - try: - self._fd = open(self.lockfile, "w") - fcntl.flock(self._fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - # Write PID for debugging - self._fd.write(str(os.getpid())) - self._fd.flush() - return True - except (IOError, OSError): - # Lock is held by another process - if self._fd: - self._fd.close() - self._fd = None - return False - - def release(self): - """Release the daemon lock.""" - if self._fd: - try: - fcntl.flock(self._fd.fileno(), fcntl.LOCK_UN) - self._fd.close() - except Exception: - pass - finally: - self._fd = None - # Remove lockfile - try: - self.lockfile.unlink() - except Exception: - pass - - def is_locked(self) -> bool: - """Check if the lock is held by another process.""" - if not self.lockfile.exists(): - return False - - try: - fd = open(self.lockfile, "r") - fcntl.flock(fd.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - # We got the lock, so no one else has it - fcntl.flock(fd.fileno(), fcntl.LOCK_UN) - fd.close() - return False - except (IOError, OSError): - return True - - def get_pid(self) -> int | None: - """Get the PID of the daemon holding the lock.""" - if not self.lockfile.exists(): - return None - try: - with open(self.lockfile, "r") as f: - return int(f.read().strip()) - except (ValueError, IOError): - return None - - -def daemonize(): - """ - Fork the current process into a background daemon. - - Uses double-fork technique to properly detach from terminal. - """ - # First fork - pid = os.fork() - if pid > 0: - # Parent exits - sys.exit(0) - - # Create new session - os.setsid() - - # Second fork to prevent zombie processes - pid = os.fork() - if pid > 0: - sys.exit(0) - - # Redirect standard file descriptors to log file - DAEMON_LOG_PATH.parent.mkdir(parents=True, exist_ok=True) - - sys.stdout.flush() - sys.stderr.flush() - - # Redirect stdin to /dev/null - with open("/dev/null", "r") as devnull: - os.dup2(devnull.fileno(), sys.stdin.fileno()) - - # Redirect stdout/stderr to log file - log_fd = open(DAEMON_LOG_PATH, "a") - os.dup2(log_fd.fileno(), sys.stdout.fileno()) - os.dup2(log_fd.fileno(), sys.stderr.fileno()) - - -def check_daemon_running(port: int = DEFAULT_DAEMON_PORT) -> bool: - """Check if a daemon is running and responsive on the given port.""" - import socket - - try: - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.settimeout(1) - result = sock.connect_ex(("127.0.0.1", port)) - sock.close() - return result == 0 - except Exception: - return False - - -def stop_daemon(port: int = DEFAULT_DAEMON_PORT) -> bool: - """Stop a running daemon by sending SIGTERM to the process.""" - lock = DaemonLock() - pid = lock.get_pid() - - if pid is None: - return False - - try: - import signal - - os.kill(pid, signal.SIGTERM) - # Wait for process to exit - for _ in range(50): # Wait up to 5 seconds - time.sleep(0.1) - try: - os.kill(pid, 0) # Check if process exists - except OSError: - return True # Process exited - return False - except OSError: - return False diff --git a/hindsight-api/hindsight_api/engine/__init__.py b/hindsight-api/hindsight_api/engine/__init__.py deleted file mode 100644 index 0e7b4836..00000000 --- a/hindsight-api/hindsight_api/engine/__init__.py +++ /dev/null @@ -1,63 +0,0 @@ -""" -Memory Engine - Core implementation of the memory system. - -This package contains all the implementation details of the memory engine: -- MemoryEngine: Main class for memory operations -- Utility modules: embedding_utils, link_utils, think_utils, bank_utils -- Supporting modules: embeddings, cross_encoder, entity_resolver, etc. -""" - -from .cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder -from .db_utils import acquire_with_retry -from .embeddings import Embeddings, LocalSTEmbeddings, RemoteTEIEmbeddings -from .llm_wrapper import LLMConfig -from .memory_engine import ( - MemoryEngine, - UnqualifiedTableError, - fq_table, - get_current_schema, - validate_sql_schema, -) -from .response_models import MemoryFact, RecallResult, ReflectResult -from .search.trace import ( - EntryPoint, - LinkInfo, - NodeVisit, - PruningDecision, - QueryInfo, - SearchPhaseMetrics, - SearchSummary, - SearchTrace, - WeightComponents, -) -from .search.tracer import SearchTracer - -__all__ = [ - "MemoryEngine", - "acquire_with_retry", - "Embeddings", - "LocalSTEmbeddings", - "RemoteTEIEmbeddings", - "CrossEncoderModel", - "LocalSTCrossEncoder", - "RemoteTEICrossEncoder", - "SearchTrace", - "SearchTracer", - "QueryInfo", - "EntryPoint", - "NodeVisit", - "WeightComponents", - "LinkInfo", - "PruningDecision", - "SearchSummary", - "SearchPhaseMetrics", - "LLMConfig", - "RecallResult", - "ReflectResult", - "MemoryFact", - # Schema safety utilities - "fq_table", - "get_current_schema", - "validate_sql_schema", - "UnqualifiedTableError", -] diff --git a/hindsight-api/hindsight_api/engine/cross_encoder.py b/hindsight-api/hindsight_api/engine/cross_encoder.py deleted file mode 100644 index 98ebdda9..00000000 --- a/hindsight-api/hindsight_api/engine/cross_encoder.py +++ /dev/null @@ -1,325 +0,0 @@ -""" -Cross-encoder abstraction for reranking. - -Provides an interface for reranking with different backends. - -Configuration via environment variables - see hindsight_api.config for all env var names. -""" - -import logging -import os -from abc import ABC, abstractmethod - -import httpx - -from ..config import ( - DEFAULT_RERANKER_LOCAL_MODEL, - DEFAULT_RERANKER_PROVIDER, - ENV_RERANKER_LOCAL_MODEL, - ENV_RERANKER_PROVIDER, - ENV_RERANKER_TEI_URL, -) - -logger = logging.getLogger(__name__) - - -class CrossEncoderModel(ABC): - """ - Abstract base class for cross-encoder reranking. - - Cross-encoders take query-document pairs and return relevance scores. - """ - - @property - @abstractmethod - def provider_name(self) -> str: - """Return a human-readable name for this provider (e.g., 'local', 'tei').""" - pass - - @abstractmethod - async def initialize(self) -> None: - """ - Initialize the cross-encoder model asynchronously. - - This should be called during startup to load/connect to the model - and avoid cold start latency on first predict() call. - """ - pass - - @abstractmethod - def predict(self, pairs: list[tuple[str, str]]) -> list[float]: - """ - Score query-document pairs for relevance. - - Args: - pairs: List of (query, document) tuples to score - - Returns: - List of relevance scores (higher = more relevant) - """ - pass - - -class NoOpCrossEncoder(CrossEncoderModel): - """ - No-op cross-encoder that returns neutral scores. - - Use this when reranking is disabled (provider='none'). - All pairs get a score of 0.5, preserving original ordering. - """ - - @property - def provider_name(self) -> str: - return "none" - - async def initialize(self) -> None: - """No initialization needed.""" - logger.info("Reranker: disabled (provider=none)") - - def predict(self, pairs: list[tuple[str, str]]) -> list[float]: - """Return neutral scores for all pairs.""" - return [0.5] * len(pairs) - - -class LocalSTCrossEncoder(CrossEncoderModel): - """ - Local cross-encoder implementation using SentenceTransformers. - - Call initialize() during startup to load the model and avoid cold starts. - - Default model is cross-encoder/ms-marco-MiniLM-L-6-v2: - - Fast inference (~80ms for 100 pairs on CPU) - - Small model (80MB) - - Trained for passage re-ranking - """ - - def __init__(self, model_name: str | None = None): - """ - Initialize local SentenceTransformers cross-encoder. - - Args: - model_name: Name of the CrossEncoder model to use. - Default: cross-encoder/ms-marco-MiniLM-L-6-v2 - """ - self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL - self._model = None - - @property - def provider_name(self) -> str: - return "local" - - async def initialize(self) -> None: - """Load the cross-encoder model.""" - if self._model is not None: - return - - try: - from sentence_transformers import CrossEncoder - except ImportError: - raise ImportError( - "sentence-transformers is required for LocalSTCrossEncoder. " - "Install it with: pip install sentence-transformers" - ) - - logger.info(f"Reranker: initializing local provider with model {self.model_name}") - self._model = CrossEncoder(self.model_name) - logger.info("Reranker: local provider initialized") - - def predict(self, pairs: list[tuple[str, str]]) -> list[float]: - """ - Score query-document pairs for relevance. - - Args: - pairs: List of (query, document) tuples to score - - Returns: - List of relevance scores (raw logits from the model) - """ - if self._model is None: - raise RuntimeError("Reranker not initialized. Call initialize() first.") - scores = self._model.predict(pairs, show_progress_bar=False) - return scores.tolist() if hasattr(scores, "tolist") else list(scores) - - -class RemoteTEICrossEncoder(CrossEncoderModel): - """ - Remote cross-encoder implementation using HuggingFace Text Embeddings Inference (TEI) HTTP API. - - TEI supports reranking via the /rerank endpoint. - See: https://github.com/huggingface/text-embeddings-inference - - Note: The TEI server must be running a cross-encoder/reranker model. - """ - - def __init__( - self, - base_url: str, - timeout: float = 30.0, - batch_size: int = 32, - max_retries: int = 3, - retry_delay: float = 0.5, - ): - """ - Initialize remote TEI cross-encoder client. - - Args: - base_url: Base URL of the TEI server (e.g., "http://localhost:8080") - timeout: Request timeout in seconds (default: 30.0) - batch_size: Maximum batch size for rerank requests (default: 32) - max_retries: Maximum number of retries for failed requests (default: 3) - retry_delay: Initial delay between retries in seconds, doubles each retry (default: 0.5) - """ - self.base_url = base_url.rstrip("/") - self.timeout = timeout - self.batch_size = batch_size - self.max_retries = max_retries - self.retry_delay = retry_delay - self._client: httpx.Client | None = None - self._model_id: str | None = None - - @property - def provider_name(self) -> str: - return "tei" - - def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response: - """Make an HTTP request with automatic retries on transient errors.""" - import time - - last_error = None - delay = self.retry_delay - - for attempt in range(self.max_retries + 1): - try: - if method == "GET": - response = self._client.get(url, **kwargs) - else: - response = self._client.post(url, **kwargs) - response.raise_for_status() - return response - except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e: - last_error = e - if attempt < self.max_retries: - logger.warning( - f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..." - ) - time.sleep(delay) - delay *= 2 # Exponential backoff - except httpx.HTTPStatusError as e: - # Retry on 5xx server errors - if e.response.status_code >= 500 and attempt < self.max_retries: - last_error = e - logger.warning( - f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..." - ) - time.sleep(delay) - delay *= 2 - else: - raise - - raise last_error - - async def initialize(self) -> None: - """Initialize the HTTP client and verify server connectivity.""" - if self._client is not None: - return - - logger.info(f"Reranker: initializing TEI provider at {self.base_url}") - self._client = httpx.Client(timeout=self.timeout) - - # Verify server is reachable and get model info - try: - response = self._request_with_retry("GET", f"{self.base_url}/info") - info = response.json() - self._model_id = info.get("model_id", "unknown") - logger.info(f"Reranker: TEI provider initialized (model: {self._model_id})") - except httpx.HTTPError as e: - raise RuntimeError(f"Failed to connect to TEI server at {self.base_url}: {e}") - - def predict(self, pairs: list[tuple[str, str]]) -> list[float]: - """ - Score query-document pairs using the remote TEI reranker. - - Args: - pairs: List of (query, document) tuples to score - - Returns: - List of relevance scores - """ - if self._client is None: - raise RuntimeError("Reranker not initialized. Call initialize() first.") - - if not pairs: - return [] - - all_scores = [] - - # Process in batches - for i in range(0, len(pairs), self.batch_size): - batch = pairs[i : i + self.batch_size] - - # TEI rerank endpoint expects query and texts separately - # All pairs in a batch should have the same query for optimal performance - # but we handle mixed queries by making separate requests per unique query - query_groups: dict[str, list[tuple[int, str]]] = {} - for idx, (query, text) in enumerate(batch): - if query not in query_groups: - query_groups[query] = [] - query_groups[query].append((idx, text)) - - batch_scores = [0.0] * len(batch) - - for query, indexed_texts in query_groups.items(): - texts = [text for _, text in indexed_texts] - indices = [idx for idx, _ in indexed_texts] - - try: - response = self._request_with_retry( - "POST", - f"{self.base_url}/rerank", - json={ - "query": query, - "texts": texts, - "return_text": False, - }, - ) - results = response.json() - - # TEI returns results sorted by score descending, with original index - for result in results: - original_idx = result["index"] - score = result["score"] - # Map back to batch position - batch_scores[indices[original_idx]] = score - - except httpx.HTTPError as e: - raise RuntimeError(f"TEI rerank request failed: {e}") - - all_scores.extend(batch_scores) - - return all_scores - - -def create_cross_encoder_from_env() -> CrossEncoderModel: - """ - Create a CrossEncoderModel instance based on environment variables. - - See hindsight_api.config for environment variable names and defaults. - - Returns: - Configured CrossEncoderModel instance - """ - provider = os.environ.get(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER).lower() - - if provider == "none" or provider == "disabled": - return NoOpCrossEncoder() - elif provider == "tei": - url = os.environ.get(ENV_RERANKER_TEI_URL) - if not url: - raise ValueError(f"{ENV_RERANKER_TEI_URL} is required when {ENV_RERANKER_PROVIDER} is 'tei'") - return RemoteTEICrossEncoder(base_url=url) - elif provider == "local": - model = os.environ.get(ENV_RERANKER_LOCAL_MODEL) - model_name = model or DEFAULT_RERANKER_LOCAL_MODEL - return LocalSTCrossEncoder(model_name=model_name) - else: - raise ValueError(f"Unknown reranker provider: {provider}. Supported: 'none', 'local', 'tei'") diff --git a/hindsight-api/hindsight_api/engine/db_utils.py b/hindsight-api/hindsight_api/engine/db_utils.py deleted file mode 100644 index 99dd0b2b..00000000 --- a/hindsight-api/hindsight_api/engine/db_utils.py +++ /dev/null @@ -1,94 +0,0 @@ -""" -Database utility functions for connection management with retry logic. -""" - -import asyncio -import logging -from contextlib import asynccontextmanager - -import asyncpg - -logger = logging.getLogger(__name__) - -# Default retry configuration for database operations -DEFAULT_MAX_RETRIES = 3 -DEFAULT_BASE_DELAY = 0.5 # seconds -DEFAULT_MAX_DELAY = 5.0 # seconds - -# Exceptions that indicate transient connection issues worth retrying -RETRYABLE_EXCEPTIONS = ( - asyncpg.exceptions.InterfaceError, - asyncpg.exceptions.ConnectionDoesNotExistError, - asyncpg.exceptions.TooManyConnectionsError, - OSError, - ConnectionError, - asyncio.TimeoutError, -) - - -async def retry_with_backoff( - func, - max_retries: int = DEFAULT_MAX_RETRIES, - base_delay: float = DEFAULT_BASE_DELAY, - max_delay: float = DEFAULT_MAX_DELAY, - retryable_exceptions: tuple = RETRYABLE_EXCEPTIONS, -): - """ - Execute an async function with exponential backoff retry. - - Args: - func: Async function to execute - max_retries: Maximum number of retry attempts - base_delay: Initial delay between retries (seconds) - max_delay: Maximum delay between retries (seconds) - retryable_exceptions: Tuple of exception types to retry on - - Returns: - Result of the function - - Raises: - The last exception if all retries fail - """ - last_exception = None - for attempt in range(max_retries + 1): - try: - return await func() - except retryable_exceptions as e: - last_exception = e - if attempt < max_retries: - delay = min(base_delay * (2**attempt), max_delay) - logger.warning( - f"Database operation failed (attempt {attempt + 1}/{max_retries + 1}): {e}. " - f"Retrying in {delay:.1f}s..." - ) - await asyncio.sleep(delay) - else: - logger.error(f"Database operation failed after {max_retries + 1} attempts: {e}") - raise last_exception - - -@asynccontextmanager -async def acquire_with_retry(pool: asyncpg.Pool, max_retries: int = DEFAULT_MAX_RETRIES): - """ - Async context manager to acquire a connection with retry logic. - - Usage: - async with acquire_with_retry(pool) as conn: - await conn.execute(...) - - Args: - pool: The asyncpg connection pool - max_retries: Maximum number of retry attempts - - Yields: - An asyncpg connection - """ - - async def acquire(): - return await pool.acquire() - - conn = await retry_with_backoff(acquire, max_retries=max_retries) - try: - yield conn - finally: - await pool.release(conn) diff --git a/hindsight-api/hindsight_api/engine/embeddings.py b/hindsight-api/hindsight_api/engine/embeddings.py deleted file mode 100644 index b0f5405e..00000000 --- a/hindsight-api/hindsight_api/engine/embeddings.py +++ /dev/null @@ -1,458 +0,0 @@ -""" -Embeddings abstraction for the memory system. - -Provides an interface for generating embeddings with different backends. - -IMPORTANT: All embeddings must produce 384-dimensional vectors to match -the database schema (pgvector column defined as vector(384)). - -Configuration via environment variables - see hindsight_api.config for all env var names. -""" - -import logging -import os -from abc import ABC, abstractmethod - -import httpx - -from ..config import ( - DEFAULT_EMBEDDINGS_LOCAL_MODEL, - DEFAULT_EMBEDDINGS_PROVIDER, - EMBEDDING_DIMENSION, - ENV_EMBEDDINGS_API_KEY, - ENV_EMBEDDINGS_AZURE_API_VERSION, - ENV_EMBEDDINGS_AZURE_DEPLOYMENT, - ENV_EMBEDDINGS_BASE_URL, - ENV_EMBEDDINGS_DIMENSIONS, - ENV_EMBEDDINGS_MODEL, - ENV_EMBEDDINGS_LOCAL_MODEL, - ENV_EMBEDDINGS_PROVIDER, - ENV_EMBEDDINGS_TEI_URL, -) - -logger = logging.getLogger(__name__) - - -class Embeddings(ABC): - """ - Abstract base class for embedding generation. - - All implementations MUST generate 384-dimensional embeddings to match - the database schema. - """ - - @property - @abstractmethod - def provider_name(self) -> str: - """Return a human-readable name for this provider (e.g., 'local', 'tei').""" - pass - - @abstractmethod - async def initialize(self) -> None: - """ - Initialize the embedding model asynchronously. - - This should be called during startup to load/connect to the model - and avoid cold start latency on first encode() call. - """ - pass - - @abstractmethod - def encode(self, texts: list[str]) -> list[list[float]]: - """ - Generate 384-dimensional embeddings for a list of texts. - - Args: - texts: List of text strings to encode - - Returns: - List of 384-dimensional embedding vectors (each is a list of floats) - """ - pass - - -class LocalSTEmbeddings(Embeddings): - """ - Local embeddings implementation using SentenceTransformers. - - Call initialize() during startup to load the model and avoid cold starts. - - Default model is BAAI/bge-small-en-v1.5 which produces 384-dimensional - embeddings matching the database schema. - """ - - def __init__(self, model_name: str | None = None): - """ - Initialize local SentenceTransformers embeddings. - - Args: - model_name: Name of the SentenceTransformer model to use. - Must produce 384-dimensional embeddings. - Default: BAAI/bge-small-en-v1.5 - """ - self.model_name = model_name or DEFAULT_EMBEDDINGS_LOCAL_MODEL - self._model = None - - @property - def provider_name(self) -> str: - return "local" - - async def initialize(self) -> None: - """Load the embedding model.""" - if self._model is not None: - return - - try: - from sentence_transformers import SentenceTransformer - except ImportError: - raise ImportError( - "sentence-transformers is required for LocalSTEmbeddings. " - "Install it with: pip install sentence-transformers" - ) - - logger.info(f"Embeddings: initializing local provider with model {self.model_name}") - # Disable lazy loading (meta tensors) which causes issues with newer transformers/accelerate - # Setting low_cpu_mem_usage=False and device_map=None ensures tensors are fully materialized - self._model = SentenceTransformer( - self.model_name, - model_kwargs={"low_cpu_mem_usage": False, "device_map": None}, - ) - - # Validate dimension matches database schema - model_dim = self._model.get_sentence_embedding_dimension() - if model_dim != EMBEDDING_DIMENSION: - raise ValueError( - f"Model {self.model_name} produces {model_dim}-dimensional embeddings, " - f"but database schema requires {EMBEDDING_DIMENSION} dimensions. " - f"Use a model that produces {EMBEDDING_DIMENSION}-dimensional embeddings." - ) - - logger.info(f"Embeddings: local provider initialized (dim: {model_dim})") - - def encode(self, texts: list[str]) -> list[list[float]]: - """ - Generate 384-dimensional embeddings for a list of texts. - - Args: - texts: List of text strings to encode - - Returns: - List of 384-dimensional embedding vectors - """ - if self._model is None: - raise RuntimeError("Embeddings not initialized. Call initialize() first.") - embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False) - return [emb.tolist() for emb in embeddings] - - -class RemoteTEIEmbeddings(Embeddings): - """ - Remote embeddings implementation using HuggingFace Text Embeddings Inference (TEI) HTTP API. - - TEI provides a high-performance inference server for embedding models. - See: https://github.com/huggingface/text-embeddings-inference - - The server should be running a model that produces 384-dimensional embeddings. - """ - - def __init__( - self, - base_url: str, - timeout: float = 30.0, - batch_size: int = 32, - max_retries: int = 3, - retry_delay: float = 0.5, - ): - """ - Initialize remote TEI embeddings client. - - Args: - base_url: Base URL of the TEI server (e.g., "http://localhost:8080") - timeout: Request timeout in seconds (default: 30.0) - batch_size: Maximum batch size for embedding requests (default: 32) - max_retries: Maximum number of retries for failed requests (default: 3) - retry_delay: Initial delay between retries in seconds, doubles each retry (default: 0.5) - """ - self.base_url = base_url.rstrip("/") - self.timeout = timeout - self.batch_size = batch_size - self.max_retries = max_retries - self.retry_delay = retry_delay - self._client: httpx.Client | None = None - self._model_id: str | None = None - - @property - def provider_name(self) -> str: - return "tei" - - def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response: - """Make an HTTP request with automatic retries on transient errors.""" - import time - - last_error = None - delay = self.retry_delay - - for attempt in range(self.max_retries + 1): - try: - if method == "GET": - response = self._client.get(url, **kwargs) - else: - response = self._client.post(url, **kwargs) - response.raise_for_status() - return response - except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e: - last_error = e - if attempt < self.max_retries: - logger.warning( - f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..." - ) - time.sleep(delay) - delay *= 2 # Exponential backoff - except httpx.HTTPStatusError as e: - # Retry on 5xx server errors - if e.response.status_code >= 500 and attempt < self.max_retries: - last_error = e - logger.warning( - f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s..." - ) - time.sleep(delay) - delay *= 2 - else: - raise - - raise last_error - - async def initialize(self) -> None: - """Initialize the HTTP client and verify server connectivity.""" - if self._client is not None: - return - - logger.info(f"Embeddings: initializing TEI provider at {self.base_url}") - self._client = httpx.Client(timeout=self.timeout) - - # Verify server is reachable and get model info - try: - response = self._request_with_retry("GET", f"{self.base_url}/info") - info = response.json() - self._model_id = info.get("model_id", "unknown") - logger.info(f"Embeddings: TEI provider initialized (model: {self._model_id})") - except httpx.HTTPError as e: - raise RuntimeError(f"Failed to connect to TEI server at {self.base_url}: {e}") - - def encode(self, texts: list[str]) -> list[list[float]]: - """ - Generate embeddings using the remote TEI server. - - Args: - texts: List of text strings to encode - - Returns: - List of embedding vectors - """ - if self._client is None: - raise RuntimeError("Embeddings not initialized. Call initialize() first.") - - if not texts: - return [] - - all_embeddings = [] - - # Process in batches - for i in range(0, len(texts), self.batch_size): - batch = texts[i : i + self.batch_size] - - try: - response = self._request_with_retry( - "POST", - f"{self.base_url}/embed", - json={"inputs": batch}, - ) - batch_embeddings = response.json() - all_embeddings.extend(batch_embeddings) - except httpx.HTTPError as e: - raise RuntimeError(f"TEI embedding request failed: {e}") - - return all_embeddings - - -class OpenAIEmbeddings(Embeddings): - """OpenAI-compatible embeddings backend. - - Supports: - - OpenAI: base_url like https://api.openai.com/v1, Authorization: Bearer - - Azure OpenAI (deployments route): base_url like https://.openai.azure.com, header: api-key, - and uses /openai/deployments//embeddings?api-version=... - - Azure AI Foundry / Azure OpenAI (OpenAI v1 route): base_url like - https://.services.ai.azure.com/openai/v1 (or https://.openai.azure.com/openai/v1), - header: api-key, and uses /embeddings with model= - - IMPORTANT: This backend enforces EMBEDDING_DIMENSION to match the pgvector schema. - If you want to use 3072 dims (text-embedding-3-large max), you must migrate the DB schema - and re-embed existing rows. - """ - - def __init__(self, *, timeout: float = 30.0, batch_size: int = 128): - self.timeout = timeout - self.batch_size = batch_size - - self.api_key = os.environ.get(ENV_EMBEDDINGS_API_KEY) - self.base_url = (os.environ.get(ENV_EMBEDDINGS_BASE_URL) or "").rstrip("/") - self.model = os.environ.get(ENV_EMBEDDINGS_MODEL, "text-embedding-3-large") - self.dimensions = int(os.environ.get(ENV_EMBEDDINGS_DIMENSIONS, str(EMBEDDING_DIMENSION))) - - # Azure OpenAI settings - self.azure_deployment = os.environ.get(ENV_EMBEDDINGS_AZURE_DEPLOYMENT) - self.azure_api_version = os.environ.get(ENV_EMBEDDINGS_AZURE_API_VERSION) - - self._client: httpx.Client | None = None - - @property - def provider_name(self) -> str: - return "openai" - - async def initialize(self) -> None: - if self._client is not None: - return - - if not self.api_key: - raise ValueError(f"{ENV_EMBEDDINGS_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'openai'") - - if not self.base_url: - # Default to OpenAI public API. - self.base_url = "https://api.openai.com/v1" - - lower_base = self.base_url.lower() - is_openai_v1_azure = ( - ("services.ai.azure.com" in lower_base or "openai.azure.com" in lower_base) and "/openai/v1" in lower_base - ) - - # Be forgiving: if a Foundry resource endpoint was provided without /openai/v1, add it. - if "services.ai.azure.com" in lower_base and "/openai/v1" not in lower_base: - self.base_url = self.base_url.rstrip("/") + "/openai/v1" - lower_base = self.base_url.lower() - is_openai_v1_azure = True - - if self.dimensions != EMBEDDING_DIMENSION: - raise ValueError( - f"Embeddings dimensions mismatch: configured {self.dimensions}, but schema requires {EMBEDDING_DIMENSION}. " - f"Either set {ENV_EMBEDDINGS_DIMENSIONS}={EMBEDDING_DIMENSION} or migrate the database/schema and re-embed." - ) - - if is_openai_v1_azure and self.azure_deployment: - raise ValueError( - f"Do not set {ENV_EMBEDDINGS_AZURE_DEPLOYMENT} when using an OpenAI v1 base URL ({ENV_EMBEDDINGS_BASE_URL} contains /openai/v1). " - "Set the embeddings deployment name via HINDSIGHT_API_EMBEDDINGS_MODEL instead." - ) - - if self.azure_deployment: - if not self.azure_api_version: - raise ValueError( - f"{ENV_EMBEDDINGS_AZURE_API_VERSION} is required when {ENV_EMBEDDINGS_AZURE_DEPLOYMENT} is set" - ) - if self.base_url.endswith("/v1"): - raise ValueError( - f"{ENV_EMBEDDINGS_BASE_URL} for Azure should be the resource endpoint (no /v1). Got: {self.base_url}" - ) - - self._client = httpx.Client(timeout=self.timeout) - - logger.info( - "Embeddings: OpenAI-compatible provider initialized " - f"(azure={bool(self.azure_deployment)}, model={self.model}, dimensions={self.dimensions})" - ) - - def _build_request(self, inputs: list[str]) -> tuple[str, dict[str, str], dict[str, object], dict[str, str] | None]: - lower_base = self.base_url.lower() - is_openai_v1_azure = ( - ("services.ai.azure.com" in lower_base or "openai.azure.com" in lower_base) and "/openai/v1" in lower_base - ) - - if is_openai_v1_azure: - url = f"{self.base_url}/embeddings" - headers = {"api-key": self.api_key, "Content-Type": "application/json"} # type: ignore[arg-type] - payload: dict[str, object] = {"model": self.model, "input": inputs} - if self.dimensions: - payload["dimensions"] = self.dimensions - return url, headers, payload, None - - if self.azure_deployment: - url = f"{self.base_url}/openai/deployments/{self.azure_deployment}/embeddings" - headers = {"api-key": self.api_key, "Content-Type": "application/json"} # type: ignore[arg-type] - params = {"api-version": self.azure_api_version} # type: ignore[dict-item] - # Azure selects model via deployment name; some API versions accept dimensions. - payload: dict[str, object] = {"input": inputs} - if self.dimensions: - payload["dimensions"] = self.dimensions - return url, headers, payload, params - - url = f"{self.base_url}/embeddings" - headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"} # type: ignore[arg-type] - payload = {"model": self.model, "input": inputs, "dimensions": self.dimensions} - return url, headers, payload, None - - def encode(self, texts: list[str]) -> list[list[float]]: - if self._client is None: - raise RuntimeError("Embeddings not initialized. Call initialize() first.") - - if not texts: - return [] - - all_embeddings: list[list[float]] = [] - - for i in range(0, len(texts), self.batch_size): - batch = texts[i : i + self.batch_size] - url, headers, payload, params = self._build_request(batch) - - resp = self._client.post(url, headers=headers, params=params, json=payload) - resp.raise_for_status() - body = resp.json() - - items = body.get("data") - if not isinstance(items, list): - raise RuntimeError("Unexpected embeddings response: missing 'data' list") - - # OpenAI returns in index order; be defensive and sort by index. - indexed: list[tuple[int, list[float]]] = [] - for item in items: - if not isinstance(item, dict): - raise RuntimeError("Unexpected embeddings response item") - idx = item.get("index") - emb = item.get("embedding") - if not isinstance(idx, int) or not isinstance(emb, list): - raise RuntimeError("Unexpected embeddings response item shape") - if len(emb) != EMBEDDING_DIMENSION: - raise RuntimeError( - f"Embedding dimension mismatch from provider: got {len(emb)}, expected {EMBEDDING_DIMENSION}." - ) - indexed.append((idx, emb)) - - indexed.sort(key=lambda t: t[0]) - all_embeddings.extend([emb for _, emb in indexed]) - - return all_embeddings - - -def create_embeddings_from_env() -> Embeddings: - """ - Create an Embeddings instance based on environment variables. - - See hindsight_api.config for environment variable names and defaults. - - Returns: - Configured Embeddings instance - """ - provider = os.environ.get(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER).lower() - - if provider == "openai": - return OpenAIEmbeddings() - elif provider == "tei": - url = os.environ.get(ENV_EMBEDDINGS_TEI_URL) - if not url: - raise ValueError(f"{ENV_EMBEDDINGS_TEI_URL} is required when {ENV_EMBEDDINGS_PROVIDER} is 'tei'") - return RemoteTEIEmbeddings(base_url=url) - elif provider == "local": - model = os.environ.get(ENV_EMBEDDINGS_LOCAL_MODEL) - model_name = model or DEFAULT_EMBEDDINGS_LOCAL_MODEL - return LocalSTEmbeddings(model_name=model_name) - else: - raise ValueError(f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei', 'openai'") diff --git a/hindsight-api/hindsight_api/engine/entity_resolver.py b/hindsight-api/hindsight_api/engine/entity_resolver.py deleted file mode 100644 index 88fd3700..00000000 --- a/hindsight-api/hindsight_api/engine/entity_resolver.py +++ /dev/null @@ -1,609 +0,0 @@ -""" -Entity extraction and resolution for memory system. - -Uses spaCy for entity extraction and implements resolution logic -to disambiguate entities across memory units. -""" - -from datetime import UTC, datetime -from difflib import SequenceMatcher - -import asyncpg - -from .db_utils import acquire_with_retry -from .memory_engine import fq_table - -# Load spaCy model (singleton) -_nlp = None - - -class EntityResolver: - """ - Resolves entities to canonical IDs with disambiguation. - """ - - def __init__(self, pool: asyncpg.Pool): - """ - Initialize entity resolver. - - Args: - pool: asyncpg connection pool - """ - self.pool = pool - - async def resolve_entities_batch( - self, - bank_id: str, - entities_data: list[dict], - context: str, - unit_event_date, - conn=None, - ) -> list[str]: - """ - Resolve multiple entities in batch (MUCH faster than sequential). - - Groups entities by type, queries candidates in bulk, and resolves - all entities with minimal DB queries. - - Args: - bank_id: bank ID - entities_data: List of dicts with 'text', 'type', 'nearby_entities' - context: Context where entities appear - unit_event_date: When this unit was created - conn: Optional connection to use (if None, acquires from pool) - - Returns: - List of entity IDs in same order as input - """ - if not entities_data: - return [] - - if conn is None: - async with acquire_with_retry(self.pool) as conn: - return await self._resolve_entities_batch_impl(conn, bank_id, entities_data, context, unit_event_date) - else: - return await self._resolve_entities_batch_impl(conn, bank_id, entities_data, context, unit_event_date) - - async def _resolve_entities_batch_impl( - self, conn, bank_id: str, entities_data: list[dict], context: str, unit_event_date - ) -> list[str]: - # Query ALL candidates for this bank - all_entities = await conn.fetch( - f""" - SELECT canonical_name, id, metadata, last_seen, mention_count - FROM {fq_table("entities")} - WHERE bank_id = $1 - """, - bank_id, - ) - - # Build entity ID to name mapping for co-occurrence lookups - entity_id_to_name = {row["id"]: row["canonical_name"].lower() for row in all_entities} - - # Query ALL co-occurrences for this bank's entities in one query - # This builds a map of entity_id -> set of co-occurring entity names - all_cooccurrences = await conn.fetch( - f""" - SELECT ec.entity_id_1, ec.entity_id_2, ec.cooccurrence_count - FROM {fq_table("entity_cooccurrences")} ec - WHERE ec.entity_id_1 IN (SELECT id FROM {fq_table("entities")} WHERE bank_id = $1) - OR ec.entity_id_2 IN (SELECT id FROM {fq_table("entities")} WHERE bank_id = $1) - """, - bank_id, - ) - - # Build co-occurrence map: entity_id -> set of co-occurring entity names (lowercase) - cooccurrence_map: dict[str, set[str]] = {} - for row in all_cooccurrences: - eid1, eid2 = row["entity_id_1"], row["entity_id_2"] - # Add both directions - if eid1 not in cooccurrence_map: - cooccurrence_map[eid1] = set() - if eid2 not in cooccurrence_map: - cooccurrence_map[eid2] = set() - # Map to canonical names for comparison with nearby_entities - if eid2 in entity_id_to_name: - cooccurrence_map[eid1].add(entity_id_to_name[eid2]) - if eid1 in entity_id_to_name: - cooccurrence_map[eid2].add(entity_id_to_name[eid1]) - - # Build candidate map for each entity text - all_candidates = {} # Maps entity_text -> list of candidates - entity_texts = list(set(e["text"] for e in entities_data)) - - for entity_text in entity_texts: - matching = [] - entity_text_lower = entity_text.lower() - for row in all_entities: - canonical_name = row["canonical_name"] - ent_id = row["id"] - metadata = row["metadata"] - last_seen = row["last_seen"] - mention_count = row["mention_count"] - canonical_lower = canonical_name.lower() - # Match if exact or substring match - if ( - entity_text_lower == canonical_lower - or entity_text_lower in canonical_lower - or canonical_lower in entity_text_lower - ): - matching.append((ent_id, canonical_name, metadata, last_seen, mention_count)) - all_candidates[entity_text] = matching - - # Resolve each entity using pre-fetched candidates - entity_ids = [None] * len(entities_data) - entities_to_update = [] # (entity_id, event_date) - entities_to_create = [] # (idx, entity_data, event_date) - - for idx, entity_data in enumerate(entities_data): - entity_text = entity_data["text"] - nearby_entities = entity_data.get("nearby_entities", []) - # Use per-entity date if available, otherwise fall back to batch-level date - entity_event_date = entity_data.get("event_date", unit_event_date) - - candidates = all_candidates.get(entity_text, []) - - if not candidates: - # Will create new entity - entities_to_create.append((idx, entity_data, entity_event_date)) - continue - - # Score candidates - best_candidate = None - best_score = 0.0 - - nearby_entity_set = {e["text"].lower() for e in nearby_entities if e["text"] != entity_text} - - for candidate_id, canonical_name, metadata, last_seen, mention_count in candidates: - score = 0.0 - - # 1. Name similarity (0-0.5) - name_similarity = SequenceMatcher(None, entity_text.lower(), canonical_name.lower()).ratio() - score += name_similarity * 0.5 - - # 2. Co-occurring entities (0-0.3) - if nearby_entity_set: - co_entities = cooccurrence_map.get(candidate_id, set()) - overlap = len(nearby_entity_set & co_entities) - co_entity_score = overlap / len(nearby_entity_set) - score += co_entity_score * 0.3 - - # 3. Temporal proximity (0-0.2) - if last_seen and entity_event_date: - # Normalize timezone awareness for comparison - event_date_utc = ( - entity_event_date if entity_event_date.tzinfo else entity_event_date.replace(tzinfo=UTC) - ) - last_seen_utc = last_seen if last_seen.tzinfo else last_seen.replace(tzinfo=UTC) - days_diff = abs((event_date_utc - last_seen_utc).total_seconds() / 86400) - if days_diff < 7: - temporal_score = max(0, 1.0 - (days_diff / 7)) - score += temporal_score * 0.2 - - if score > best_score: - best_score = score - best_candidate = candidate_id - - # Apply unified threshold - threshold = 0.6 - - if best_score > threshold: - entity_ids[idx] = best_candidate - entities_to_update.append((best_candidate, entity_event_date)) - else: - entities_to_create.append((idx, entity_data, entity_event_date)) - - # Batch update existing entities - if entities_to_update: - await conn.executemany( - f""" - UPDATE {fq_table("entities")} SET - mention_count = mention_count + 1, - last_seen = $2 - WHERE id = $1::uuid - """, - entities_to_update, - ) - - # Batch create new entities using COPY + INSERT for maximum speed - # This handles duplicates via ON CONFLICT and returns all IDs - if entities_to_create: - # Group entities by canonical name (lowercase) to handle duplicates within batch - # For duplicates, we only insert once and reuse the ID - unique_entities = {} # lowercase_name -> (entity_data, event_date, [indices]) - for idx, entity_data, event_date in entities_to_create: - name_lower = entity_data["text"].lower() - if name_lower not in unique_entities: - unique_entities[name_lower] = (entity_data, event_date, [idx]) - else: - # Same entity appears multiple times - add index to list - unique_entities[name_lower][2].append(idx) - - # Batch insert unique entities and get their IDs - # Use a single query with unnest for speed - entity_names = [] - entity_dates = [] - indices_map = [] # Maps result index -> list of original indices - - for name_lower, (entity_data, event_date, indices) in unique_entities.items(): - entity_names.append(entity_data["text"]) - entity_dates.append(event_date) - indices_map.append(indices) - - # Batch INSERT ... ON CONFLICT with RETURNING - # This is much faster than individual inserts - rows = await conn.fetch( - f""" - INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count) - SELECT $1, name, event_date, event_date, 1 - FROM unnest($2::text[], $3::timestamptz[]) AS t(name, event_date) - ON CONFLICT (bank_id, LOWER(canonical_name)) - DO UPDATE SET - mention_count = {fq_table("entities")}.mention_count + 1, - last_seen = EXCLUDED.last_seen - RETURNING id - """, - bank_id, - entity_names, - entity_dates, - ) - - # Map returned IDs back to original indices - for result_idx, row in enumerate(rows): - entity_id = row["id"] - for original_idx in indices_map[result_idx]: - entity_ids[original_idx] = entity_id - - return entity_ids - - async def resolve_entity( - self, - bank_id: str, - entity_text: str, - context: str, - nearby_entities: list[dict], - unit_event_date, - ) -> str: - """ - Resolve an entity to a canonical entity ID. - - Args: - bank_id: bank ID (entities are scoped to agents) - entity_text: Entity text ("Alice", "Google", etc.) - context: Context where entity appears - nearby_entities: Other entities in the same unit - unit_event_date: When this unit was created - - Returns: - Entity ID (creates new entity if needed) - """ - async with acquire_with_retry(self.pool) as conn: - # Find candidate entities with similar name - candidates = await conn.fetch( - f""" - SELECT id, canonical_name, metadata, last_seen - FROM {fq_table("entities")} - WHERE bank_id = $1 - AND ( - canonical_name ILIKE $2 - OR canonical_name ILIKE $3 - OR $2 ILIKE canonical_name || '%%' - ) - ORDER BY mention_count DESC - """, - bank_id, - entity_text, - f"%{entity_text}%", - ) - - if not candidates: - # New entity - create it - return await self._create_entity(conn, bank_id, entity_text, unit_event_date) - - # Score candidates based on: - # 1. Name similarity - # 2. Context overlap (TODO: could use embeddings) - # 3. Co-occurring entities - # 4. Temporal proximity - - 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 - - # 1. Name similarity (0-1) - name_similarity = SequenceMatcher(None, entity_text.lower(), canonical_name.lower()).ratio() - score += name_similarity * 0.5 - - # 2. Co-occurring entities (0-0.5) - # Get entities that co-occurred with this candidate before - # Use the materialized co-occurrence cache for fast lookup - co_entity_rows = await conn.fetch( - f""" - SELECT e.canonical_name, ec.cooccurrence_count - FROM {fq_table("entity_cooccurrences")} ec - JOIN {fq_table("entities")} e ON ( - CASE - WHEN ec.entity_id_1 = $1 THEN ec.entity_id_2 - WHEN ec.entity_id_2 = $1 THEN ec.entity_id_1 - END = e.id - ) - WHERE ec.entity_id_1 = $1 OR ec.entity_id_2 = $1 - """, - candidate_id, - ) - co_entities = {r["canonical_name"].lower() for r in co_entity_rows} - - # Check overlap with nearby entities - overlap = len(nearby_entity_set & co_entities) - if nearby_entity_set: - co_entity_score = overlap / len(nearby_entity_set) - score += co_entity_score * 0.3 - - # 3. Temporal proximity (0-0.2) - if last_seen: - days_diff = abs((unit_event_date - last_seen).total_seconds() / 86400) - if days_diff < 7: # Within a week - temporal_score = max(0, 1.0 - (days_diff / 7)) - score += temporal_score * 0.2 - - 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 - - if best_score > threshold: - # Update entity - await conn.execute( - f""" - UPDATE {fq_table("entities")} - SET mention_count = mention_count + 1, - last_seen = $1 - WHERE id = $2 - """, - unit_event_date, - best_candidate, - ) - return best_candidate - else: - # Not confident - create new entity - return await self._create_entity(conn, bank_id, entity_text, unit_event_date) - - async def _create_entity( - self, - conn, - bank_id: str, - entity_text: str, - event_date, - ) -> str: - """ - Create a new entity or get existing one if it already exists. - - Uses INSERT ... ON CONFLICT to handle race conditions where - two concurrent transactions try to create the same entity. - - Args: - conn: Database connection - bank_id: bank ID - entity_text: Entity text - event_date: When first seen - - Returns: - Entity ID - """ - entity_id = await conn.fetchval( - f""" - INSERT INTO {fq_table("entities")} (bank_id, canonical_name, first_seen, last_seen, mention_count) - VALUES ($1, $2, $3, $4, 1) - ON CONFLICT (bank_id, LOWER(canonical_name)) - DO UPDATE SET - mention_count = {fq_table("entities")}.mention_count + 1, - last_seen = EXCLUDED.last_seen - RETURNING id - """, - bank_id, - entity_text, - event_date, - event_date, - ) - return entity_id - - async def link_unit_to_entity(self, unit_id: str, entity_id: str): - """ - Link a memory unit to an entity. - Also updates co-occurrence cache with other entities in the same unit. - - Args: - unit_id: Memory unit ID - entity_id: Entity ID - """ - async with acquire_with_retry(self.pool) as conn: - # Insert unit-entity link - await conn.execute( - f""" - INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id) - VALUES ($1, $2) - ON CONFLICT DO NOTHING - """, - unit_id, - entity_id, - ) - - # Update co-occurrence cache: find other entities in this unit - rows = await conn.fetch( - f""" - SELECT entity_id - FROM {fq_table("unit_entities")} - WHERE unit_id = $1 AND entity_id != $2 - """, - unit_id, - entity_id, - ) - - other_entities = [row["entity_id"] for row in rows] - - # Update co-occurrences for each pair - for other_entity_id in other_entities: - await self._update_cooccurrence(conn, entity_id, other_entity_id) - - async def _update_cooccurrence(self, conn, entity_id_1: str, entity_id_2: str): - """ - Update the co-occurrence cache for two entities. - - Uses CHECK constraint ordering (entity_id_1 < entity_id_2) to avoid duplicates. - - Args: - conn: Database connection - entity_id_1: First entity ID - entity_id_2: Second entity ID - """ - # Ensure consistent ordering (smaller UUID first) - if entity_id_1 > entity_id_2: - entity_id_1, entity_id_2 = entity_id_2, entity_id_1 - - await conn.execute( - f""" - INSERT INTO {fq_table("entity_cooccurrences")} (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred) - VALUES ($1, $2, 1, NOW()) - ON CONFLICT (entity_id_1, entity_id_2) - DO UPDATE SET - cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + 1, - last_cooccurred = NOW() - """, - entity_id_1, - entity_id_2, - ) - - async def link_units_to_entities_batch(self, unit_entity_pairs: list[tuple[str, str]], conn=None): - """ - Link multiple memory units to entities in batch (MUCH faster than sequential). - - Also updates co-occurrence cache for entities that appear in the same unit. - - Args: - unit_entity_pairs: List of (unit_id, entity_id) tuples - conn: Optional connection to use (if None, acquires from pool) - """ - if not unit_entity_pairs: - return - - if conn is None: - async with acquire_with_retry(self.pool) as conn: - return await self._link_units_to_entities_batch_impl(conn, unit_entity_pairs) - else: - return await self._link_units_to_entities_batch_impl(conn, unit_entity_pairs) - - async def _link_units_to_entities_batch_impl(self, conn, unit_entity_pairs: list[tuple[str, str]]): - # Batch insert all unit-entity links - await conn.executemany( - f""" - INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id) - VALUES ($1, $2) - ON CONFLICT DO NOTHING - """, - unit_entity_pairs, - ) - - # Build map of unit -> entities for co-occurrence calculation - # Use sets to avoid duplicate entities in the same unit - unit_to_entities = {} - for unit_id, entity_id in unit_entity_pairs: - if unit_id not in unit_to_entities: - unit_to_entities[unit_id] = set() - unit_to_entities[unit_id].add(entity_id) - - # Update co-occurrences for all pairs in each unit - cooccurrence_pairs = set() # Use set to avoid duplicates - for unit_id, entity_ids in unit_to_entities.items(): - entity_list = list(entity_ids) # Convert set to list for iteration - # For each pair of entities in this unit, create co-occurrence - for i, entity_id_1 in enumerate(entity_list): - for entity_id_2 in entity_list[i + 1 :]: - # Skip if same entity (shouldn't happen with set, but be safe) - if entity_id_1 == entity_id_2: - continue - # Ensure consistent ordering (entity_id_1 < entity_id_2) - if entity_id_1 > entity_id_2: - entity_id_1, entity_id_2 = entity_id_2, entity_id_1 - cooccurrence_pairs.add((entity_id_1, entity_id_2)) - - # Batch update co-occurrences - if cooccurrence_pairs: - now = datetime.now(UTC) - await conn.executemany( - f""" - INSERT INTO {fq_table("entity_cooccurrences")} (entity_id_1, entity_id_2, cooccurrence_count, last_cooccurred) - VALUES ($1, $2, $3, $4) - ON CONFLICT (entity_id_1, entity_id_2) - DO UPDATE SET - cooccurrence_count = {fq_table("entity_cooccurrences")}.cooccurrence_count + 1, - last_cooccurred = EXCLUDED.last_cooccurred - """, - [(e1, e2, 1, now) for e1, e2 in cooccurrence_pairs], - ) - - async def get_units_by_entity(self, entity_id: str, limit: int = 100) -> list[str]: - """ - Get all units that mention an entity. - - Args: - entity_id: Entity ID - limit: Max results - - Returns: - List of unit IDs - """ - async with acquire_with_retry(self.pool) as conn: - rows = await conn.fetch( - f""" - SELECT unit_id - FROM {fq_table("unit_entities")} - WHERE entity_id = $1 - ORDER BY unit_id - LIMIT $2 - """, - entity_id, - limit, - ) - return [row["unit_id"] for row in rows] - - async def get_entity_by_text( - self, - bank_id: str, - entity_text: str, - ) -> str | None: - """ - Find an entity by text (for query resolution). - - Args: - bank_id: bank ID - entity_text: Entity text to search for - - Returns: - Entity ID if found, None otherwise - """ - async with acquire_with_retry(self.pool) as conn: - row = await conn.fetchrow( - f""" - SELECT id FROM {fq_table("entities")} - WHERE bank_id = $1 - AND canonical_name ILIKE $2 - ORDER BY mention_count DESC - LIMIT 1 - """, - bank_id, - entity_text, - ) - - return row["id"] if row else None diff --git a/hindsight-api/hindsight_api/engine/interface.py b/hindsight-api/hindsight_api/engine/interface.py deleted file mode 100644 index 937f8ab3..00000000 --- a/hindsight-api/hindsight_api/engine/interface.py +++ /dev/null @@ -1,592 +0,0 @@ -"""Abstract interface for MemoryEngine public methods. - -This module defines the public API that HTTP endpoints and extensions should use -to interact with the memory system. All methods require a RequestContext for -authentication when a TenantExtension is configured. -""" - -from abc import ABC, abstractmethod -from datetime import datetime -from typing import TYPE_CHECKING, Any - -if TYPE_CHECKING: - from hindsight_api.engine.memory_engine import Budget - from hindsight_api.engine.response_models import RecallResult, ReflectResult - from hindsight_api.models import RequestContext - - -class MemoryEngineInterface(ABC): - """ - Abstract interface for the Memory Engine. - - This defines the public API that should be used by HTTP endpoints and extensions. - All methods require a RequestContext for authentication. - """ - - # ========================================================================= - # Health & Status - # ========================================================================= - - @abstractmethod - async def health_check(self) -> dict: - """ - Check the health of the memory system. - - Returns: - Dict with 'status' key ('healthy' or 'unhealthy') and additional info. - """ - ... - - # ========================================================================= - # Core Memory Operations - # ========================================================================= - - @abstractmethod - async def retain_batch_async( - self, - bank_id: str, - contents: list[dict[str, Any]], - *, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - Retain a batch of memory items. - - Args: - bank_id: The memory bank ID. - contents: List of content dicts with 'content', optional 'event_date', - 'context', 'metadata', 'document_id'. - request_context: Request context for authentication. - - Returns: - Dict with processing results. - """ - ... - - @abstractmethod - async def recall_async( - self, - bank_id: str, - query: str, - *, - budget: "Budget | None" = None, - max_tokens: int = 4096, - enable_trace: bool = False, - fact_type: list[str] | None = None, - question_date: datetime | None = None, - include_entities: bool = False, - max_entity_tokens: int = 500, - include_chunks: bool = False, - max_chunk_tokens: int = 8192, - request_context: "RequestContext", - ) -> "RecallResult": - """ - Recall memories relevant to a query. - - Args: - bank_id: The memory bank ID. - query: The search query. - budget: Search budget (LOW, MID, HIGH). - max_tokens: Maximum tokens in response. - enable_trace: Include trace information. - fact_type: Filter by fact types. - question_date: Context date for temporal relevance. - include_entities: Include entity observations. - max_entity_tokens: Max tokens for entity observations. - include_chunks: Include raw chunks. - max_chunk_tokens: Max tokens for chunks. - request_context: Request context for authentication. - - Returns: - RecallResult with matching memories. - """ - ... - - @abstractmethod - async def reflect_async( - self, - bank_id: str, - query: str, - *, - budget: "Budget | None" = None, - context: str | None = None, - request_context: "RequestContext", - ) -> "ReflectResult": - """ - Reflect on a query and generate a thoughtful response. - - Args: - bank_id: The memory bank ID. - query: The question to reflect on. - budget: Search budget for retrieving context. - context: Additional context for the reflection. - request_context: Request context for authentication. - - Returns: - ReflectResult with generated response and supporting facts. - """ - ... - - # ========================================================================= - # Bank Management - # ========================================================================= - - @abstractmethod - async def list_banks( - self, - *, - request_context: "RequestContext", - ) -> list[dict[str, Any]]: - """ - List all memory banks. - - Args: - request_context: Request context for authentication. - - Returns: - List of bank info dicts. - """ - ... - - @abstractmethod - async def get_bank_profile( - self, - bank_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - Get bank profile including disposition and background. - - Args: - bank_id: The memory bank ID. - request_context: Request context for authentication. - - Returns: - Bank profile dict. - """ - ... - - @abstractmethod - async def update_bank_disposition( - self, - bank_id: str, - disposition: dict[str, int], - *, - request_context: "RequestContext", - ) -> None: - """ - Update bank disposition traits. - - Args: - bank_id: The memory bank ID. - disposition: Dict with trait values. - request_context: Request context for authentication. - """ - ... - - @abstractmethod - async def merge_bank_background( - self, - bank_id: str, - new_info: str, - *, - update_disposition: bool = True, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - Merge new background information into bank profile. - - Args: - bank_id: The memory bank ID. - new_info: New background information to merge. - update_disposition: Whether to infer disposition from background. - request_context: Request context for authentication. - - Returns: - Updated background info. - """ - ... - - @abstractmethod - async def delete_bank( - self, - bank_id: str, - *, - fact_type: str | None = None, - request_context: "RequestContext", - ) -> dict[str, int]: - """ - Delete a bank or its memories. - - Args: - bank_id: The memory bank ID. - fact_type: If specified, only delete memories of this type. - request_context: Request context for authentication. - - Returns: - Dict with deletion counts. - """ - ... - - # ========================================================================= - # Memory Units - # ========================================================================= - - @abstractmethod - async def list_memory_units( - self, - bank_id: str, - *, - fact_type: str | None = None, - search_query: str | None = None, - limit: int = 100, - offset: int = 0, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - List memory units with pagination. - - Args: - bank_id: The memory bank ID. - fact_type: Filter by fact type. - search_query: Full-text search query. - limit: Maximum results. - offset: Pagination offset. - request_context: Request context for authentication. - - Returns: - Dict with 'items', 'total', 'limit', 'offset'. - """ - ... - - @abstractmethod - async def delete_memory_unit( - self, - unit_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - Delete a specific memory unit. - - Args: - unit_id: The memory unit ID. - request_context: Request context for authentication. - - Returns: - Deletion result. - """ - ... - - @abstractmethod - async def get_graph_data( - self, - bank_id: str, - *, - fact_type: str | None = None, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - Get graph data for visualization. - - Args: - bank_id: The memory bank ID. - fact_type: Filter by fact type. - request_context: Request context for authentication. - - Returns: - Dict with nodes, edges, table_rows, total_units. - """ - ... - - # ========================================================================= - # Documents - # ========================================================================= - - @abstractmethod - async def list_documents( - self, - bank_id: str, - *, - search_query: str | None = None, - limit: int = 100, - offset: int = 0, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - List documents with pagination. - - Args: - bank_id: The memory bank ID. - search_query: Search query. - limit: Maximum results. - offset: Pagination offset. - request_context: Request context for authentication. - - Returns: - Dict with 'items', 'total', 'limit', 'offset'. - """ - ... - - @abstractmethod - async def get_document( - self, - document_id: str, - bank_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, Any] | None: - """ - Get a specific document. - - Args: - document_id: The document ID. - bank_id: The memory bank ID. - request_context: Request context for authentication. - - Returns: - Document dict or None if not found. - """ - ... - - @abstractmethod - async def delete_document( - self, - document_id: str, - bank_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, int]: - """ - Delete a document and its memory units. - - Args: - document_id: The document ID. - bank_id: The memory bank ID. - request_context: Request context for authentication. - - Returns: - Dict with deletion counts. - """ - ... - - @abstractmethod - async def get_chunk( - self, - chunk_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, Any] | None: - """ - Get a specific chunk. - - Args: - chunk_id: The chunk ID. - request_context: Request context for authentication. - - Returns: - Chunk dict or None if not found. - """ - ... - - # ========================================================================= - # Entities - # ========================================================================= - - @abstractmethod - async def list_entities( - self, - bank_id: str, - *, - limit: int = 100, - request_context: "RequestContext", - ) -> list[dict[str, Any]]: - """ - List entities for a bank. - - Args: - bank_id: The memory bank ID. - limit: Maximum results. - request_context: Request context for authentication. - - Returns: - List of entity dicts. - """ - ... - - @abstractmethod - async def get_entity_observations( - self, - bank_id: str, - entity_id: str, - *, - limit: int = 10, - request_context: "RequestContext", - ) -> list[Any]: - """ - Get observations for an entity. - - Args: - bank_id: The memory bank ID. - entity_id: The entity ID. - limit: Maximum observations. - request_context: Request context for authentication. - - Returns: - List of EntityObservation objects. - """ - ... - - @abstractmethod - async def regenerate_entity_observations( - self, - bank_id: str, - entity_id: str, - entity_name: str, - *, - request_context: "RequestContext", - ) -> None: - """ - Regenerate observations for an entity. - - Args: - bank_id: The memory bank ID. - entity_id: The entity ID. - entity_name: The entity's canonical name. - request_context: Request context for authentication. - """ - ... - - # ========================================================================= - # Statistics & Operations - # ========================================================================= - - @abstractmethod - async def get_bank_stats( - self, - bank_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - Get statistics about memory nodes and links for a bank. - - Args: - bank_id: The memory bank ID. - request_context: Request context for authentication. - - Returns: - Dict with node_counts, link_counts, link_counts_by_fact_type, - link_breakdown, and operations stats. - """ - ... - - @abstractmethod - async def get_entity( - self, - bank_id: str, - entity_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, Any] | None: - """ - Get entity details including metadata and observations. - - Args: - bank_id: The memory bank ID. - entity_id: The entity ID. - request_context: Request context for authentication. - - Returns: - Entity dict with id, canonical_name, mention_count, first_seen, - last_seen, metadata, and observations. None if not found. - """ - ... - - @abstractmethod - async def list_operations( - self, - bank_id: str, - *, - request_context: "RequestContext", - ) -> list[dict[str, Any]]: - """ - List async operations for a bank. - - Args: - bank_id: The memory bank ID. - request_context: Request context for authentication. - - Returns: - List of operation dicts with id, task_type, status, etc. - """ - ... - - @abstractmethod - async def cancel_operation( - self, - bank_id: str, - operation_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - Cancel a pending async operation. - - Args: - bank_id: The memory bank ID. - operation_id: The operation ID to cancel. - request_context: Request context for authentication. - - Returns: - Dict with success status and message. - - Raises: - ValueError: If operation not found. - """ - ... - - @abstractmethod - async def update_bank( - self, - bank_id: str, - *, - name: str | None = None, - background: str | None = None, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - Update bank name and/or background. - - Args: - bank_id: The memory bank ID. - name: New bank name (optional). - background: New background text (optional, replaces existing). - request_context: Request context for authentication. - - Returns: - Updated bank profile dict. - """ - ... - - @abstractmethod - async def submit_async_retain( - self, - bank_id: str, - contents: list[dict[str, Any]], - *, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - Submit a batch retain operation to run asynchronously. - - Args: - bank_id: The memory bank ID. - contents: List of content dicts to retain. - request_context: Request context for authentication. - - Returns: - Dict with operation_id and items_count. - """ - ... diff --git a/hindsight-api/hindsight_api/engine/llm_wrapper.py b/hindsight-api/hindsight_api/engine/llm_wrapper.py deleted file mode 100644 index 9a720695..00000000 --- a/hindsight-api/hindsight_api/engine/llm_wrapper.py +++ /dev/null @@ -1,724 +0,0 @@ -""" -LLM wrapper for unified configuration across providers. -""" - -import asyncio -import json -import logging -import os -import time -from typing import Any - -import httpx -from google import genai -from google.genai import errors as genai_errors -from google.genai import types as genai_types -from openai import APIConnectionError, APIStatusError, AsyncAzureOpenAI, AsyncOpenAI, LengthFinishReasonError - -# Seed applied to every Groq request for deterministic behavior. -DEFAULT_LLM_SEED = 4242 - -logger = logging.getLogger(__name__) - -# Disable httpx logging -logging.getLogger("httpx").setLevel(logging.WARNING) - -# Global semaphore to limit concurrent LLM requests across all instances -_global_llm_semaphore = asyncio.Semaphore(32) - - -class OutputTooLongError(Exception): - """ - Bridge exception raised when LLM output exceeds token limits. - - This wraps provider-specific errors (e.g., OpenAI's LengthFinishReasonError) - to allow callers to handle output length issues without depending on - provider-specific implementations. - """ - - pass - - -class LLMProvider: - """ - Unified LLM provider. - - Supports OpenAI, Groq, Ollama (OpenAI-compatible), and Gemini. - """ - - def __init__( - self, - provider: str, - api_key: str, - base_url: str, - model: str, - reasoning_effort: str = "low", - azure_api_version: str | None = None, - ): - """ - Initialize LLM provider. - - Args: - provider: Provider name ("openai", "groq", "ollama", "gemini"). - api_key: API key. - base_url: Base URL for the API. - model: Model name. - reasoning_effort: Reasoning effort level for supported providers. - """ - self.provider = provider.lower() - self.api_key = api_key - self.base_url = base_url - self.model = model - self.reasoning_effort = reasoning_effort - self.azure_api_version = azure_api_version - - # Validate provider - valid_providers = ["openai", "groq", "ollama", "gemini"] - if self.provider not in valid_providers: - raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}") - - # Set default base URLs - if not self.base_url: - if self.provider == "groq": - self.base_url = "https://api.groq.com/openai/v1" - elif self.provider == "ollama": - self.base_url = "http://localhost:11434/v1" - - # Validate API key (not needed for ollama) - if self.provider != "ollama" and not self.api_key: - raise ValueError(f"API key not found for {self.provider}") - - # Create client based on provider - if self.provider == "gemini": - self._gemini_client = genai.Client(api_key=self.api_key) - self._client = None - elif self.provider == "ollama": - self._client = AsyncOpenAI(api_key="ollama", base_url=self.base_url, max_retries=0) - self._gemini_client = None - elif self.provider == "openai" and self.base_url and "/openai/v1" in self.base_url.lower() and ( - "services.ai.azure.com" in self.base_url.lower() or "openai.azure.com" in self.base_url.lower() - ): - # Azure AI Foundry / Azure OpenAI OpenAI-v1 compatible endpoints. - # Example: https://.services.ai.azure.com/openai/v1/ - # Example: https://.openai.azure.com/openai/v1/ - normalized = self.base_url.rstrip("/") - self._client = AsyncOpenAI( - api_key="unused", - base_url=normalized, - default_headers={"api-key": self.api_key}, - max_retries=0, - ) - self._gemini_client = None - elif self.provider == "openai" and self.base_url and "services.ai.azure.com" in self.base_url.lower(): - # Be forgiving: if a Foundry resource endpoint was provided without /openai/v1, add it. - normalized = self.base_url.rstrip("/") + "/openai/v1" - self._client = AsyncOpenAI( - api_key="unused", - base_url=normalized, - default_headers={"api-key": self.api_key}, - max_retries=0, - ) - self._gemini_client = None - elif self.provider == "openai" and ( - (self.base_url and "openai.azure.com" in self.base_url.lower()) or self.azure_api_version - ): - if not self.base_url: - raise ValueError( - "Azure OpenAI requires a base URL like https://.openai.azure.com (no /v1)" - ) - - if self.base_url.endswith("/v1"): - raise ValueError( - "Azure OpenAI base URL should be the resource endpoint (no /v1). " - f"Got: {self.base_url}" - ) - - # Normalize Azure endpoint in case a full /openai/... path was provided. - azure_endpoint = self.base_url - lower = azure_endpoint.lower() - openai_path_idx = lower.find("/openai/") - if openai_path_idx != -1: - azure_endpoint = azure_endpoint[:openai_path_idx] - azure_endpoint = azure_endpoint.rstrip("/") - - api_version = self.azure_api_version or "2024-02-15-preview" - - self._client = AsyncAzureOpenAI( - api_key=self.api_key, - azure_endpoint=azure_endpoint, - api_version=api_version, - max_retries=0, - ) - self._gemini_client = None - else: - # Only pass base_url if it's set (OpenAI uses default URL otherwise) - client_kwargs = {"api_key": self.api_key, "max_retries": 0} - if self.base_url: - client_kwargs["base_url"] = self.base_url - self._client = AsyncOpenAI(**client_kwargs) # type: ignore[invalid-argument-type] - dict kwargs - self._gemini_client = None - - async def verify_connection(self) -> None: - """ - Verify that the LLM provider is configured correctly by making a simple test call. - - Raises: - RuntimeError: If the connection test fails. - """ - try: - logger.info( - f"Verifying LLM: provider={self.provider}, model={self.model}, base_url={self.base_url or 'default'}..." - ) - await self.call( - messages=[{"role": "user", "content": "Say 'ok'"}], - max_completion_tokens=100, - max_retries=2, - initial_backoff=0.5, - max_backoff=2.0, - ) - # If we get here without exception, the connection is working - logger.info(f"LLM verified: {self.provider}/{self.model}") - except Exception as e: - raise RuntimeError(f"LLM connection verification failed for {self.provider}/{self.model}: {e}") from e - - async def call( - self, - messages: list[dict[str, str]], - response_format: Any | None = None, - max_completion_tokens: int | None = None, - temperature: float | None = None, - scope: str = "memory", - max_retries: int = 10, - initial_backoff: float = 1.0, - max_backoff: float = 60.0, - skip_validation: bool = False, - ) -> Any: - """ - Make an LLM API call with retry logic. - - Args: - messages: List of message dicts with 'role' and 'content'. - response_format: Optional Pydantic model for structured output. - max_completion_tokens: Maximum tokens in response. - temperature: Sampling temperature (0.0-2.0). - scope: Scope identifier for tracking. - max_retries: Maximum retry attempts. - initial_backoff: Initial backoff time in seconds. - max_backoff: Maximum backoff time in seconds. - skip_validation: Return raw JSON without Pydantic validation. - - Returns: - Parsed response if response_format is provided, otherwise text content. - - Raises: - OutputTooLongError: If output exceeds token limits. - Exception: Re-raises API errors after retries exhausted. - """ - async with _global_llm_semaphore: - start_time = time.time() - - # Handle Gemini provider separately - if self.provider == "gemini": - return await self._call_gemini( - messages, response_format, max_retries, initial_backoff, max_backoff, skip_validation, start_time - ) - - # Handle Ollama with native API for structured output (better schema enforcement) - if self.provider == "ollama" and response_format is not None: - return await self._call_ollama_native( - messages, - response_format, - max_completion_tokens, - temperature, - max_retries, - initial_backoff, - max_backoff, - skip_validation, - start_time, - ) - - call_params = { - "model": self.model, - "messages": messages, - } - - # Check if model supports reasoning parameter (o1, o3, gpt-5 families) - model_lower = self.model.lower() - is_reasoning_model = any(x in model_lower for x in ["gpt-5", "o1", "o3", "deepseek"]) - - # For GPT-4 and GPT-4.1 models, cap max_completion_tokens to 32000 - # For GPT-4o models, cap to 16384 - is_gpt4_model = any(x in model_lower for x in ["gpt-4.1", "gpt-4-"]) - is_gpt4o_model = "gpt-4o" in model_lower - if max_completion_tokens is not None: - if is_gpt4o_model and max_completion_tokens > 16384: - max_completion_tokens = 16384 - elif is_gpt4_model and max_completion_tokens > 32000: - max_completion_tokens = 32000 - # For reasoning models, max_completion_tokens includes reasoning + output tokens - # Enforce minimum of 16000 to ensure enough space for both - if is_reasoning_model and max_completion_tokens < 16000: - max_completion_tokens = 16000 - call_params["max_completion_tokens"] = max_completion_tokens - - # GPT-5/o1/o3 family doesn't support custom temperature (only default 1) - if temperature is not None and not is_reasoning_model: - call_params["temperature"] = temperature - - # Set reasoning_effort for reasoning models (OpenAI gpt-5, o1, o3) - if is_reasoning_model: - call_params["reasoning_effort"] = self.reasoning_effort - - # Provider-specific parameters - if self.provider == "groq": - call_params["seed"] = DEFAULT_LLM_SEED - extra_body = {"service_tier": "auto"} - # Only add reasoning parameters for reasoning models - if is_reasoning_model: - extra_body["include_reasoning"] = False - call_params["extra_body"] = extra_body - - last_exception = None - - for attempt in range(max_retries + 1): - try: - if response_format is not None: - # Add schema to system message for JSON mode - if hasattr(response_format, "model_json_schema"): - schema = response_format.model_json_schema() - schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}" - - if call_params["messages"] and call_params["messages"][0].get("role") == "system": - call_params["messages"][0]["content"] += schema_msg - elif call_params["messages"]: - call_params["messages"][0]["content"] = ( - schema_msg + "\n\n" + call_params["messages"][0]["content"] - ) - - call_params["response_format"] = {"type": "json_object"} - response = await self._client.chat.completions.create(**call_params) - - content = response.choices[0].message.content - - # Log raw LLM response for debugging JSON parse issues - try: - json_data = json.loads(content) - except json.JSONDecodeError as json_err: - # Truncate content for logging (first 500 and last 200 chars) - content_preview = content[:500] if content else "" - if content and len(content) > 700: - content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}" - logger.warning( - f"JSON parse error from LLM response (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n" - f" Model: {self.provider}/{self.model}\n" - f" Content length: {len(content) if content else 0} chars\n" - f" Content preview: {content_preview!r}\n" - f" Finish reason: {response.choices[0].finish_reason if response.choices else 'unknown'}" - ) - # Retry on JSON parse errors - LLM may return valid JSON on next attempt - if attempt < max_retries: - backoff = min(initial_backoff * (2**attempt), max_backoff) - await asyncio.sleep(backoff) - last_exception = json_err - continue - else: - logger.error(f"JSON parse error after {max_retries + 1} attempts, giving up") - raise - - if skip_validation: - result = json_data - else: - result = response_format.model_validate(json_data) - else: - response = await self._client.chat.completions.create(**call_params) - result = response.choices[0].message.content - - # Log slow calls - duration = time.time() - start_time - usage = response.usage - if duration > 10.0: - ratio = max(1, usage.completion_tokens) / usage.prompt_tokens - cached_tokens = 0 - if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details: - cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0 - cache_info = f", cached_tokens={cached_tokens}" if cached_tokens > 0 else "" - logger.info( - f"slow llm call: model={self.provider}/{self.model}, " - f"input_tokens={usage.prompt_tokens}, output_tokens={usage.completion_tokens}, " - f"total_tokens={usage.total_tokens}{cache_info}, time={duration:.3f}s, ratio out/in={ratio:.2f}" - ) - - return result - - except LengthFinishReasonError as e: - logger.warning(f"LLM output exceeded token limits: {str(e)}") - raise OutputTooLongError( - "LLM output exceeded token limits. Input may need to be split into smaller chunks." - ) from e - - except APIConnectionError as e: - last_exception = e - if attempt < max_retries: - status_code = getattr(e, "status_code", None) or getattr( - getattr(e, "response", None), "status_code", None - ) - logger.warning( - f"Connection error, retrying... (attempt {attempt + 1}/{max_retries + 1}) - status_code={status_code}, message={e}" - ) - backoff = min(initial_backoff * (2**attempt), max_backoff) - await asyncio.sleep(backoff) - continue - else: - logger.error(f"Connection error after {max_retries + 1} attempts: {str(e)}") - raise - - except APIStatusError as e: - # Fast fail only on 401 (unauthorized) and 403 (forbidden) - these won't recover with retries - if e.status_code in (401, 403): - logger.error(f"Auth error (HTTP {e.status_code}), not retrying: {str(e)}") - raise - - last_exception = e - if attempt < max_retries: - backoff = min(initial_backoff * (2**attempt), max_backoff) - jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1) - sleep_time = backoff + jitter - await asyncio.sleep(sleep_time) - else: - logger.error(f"API error after {max_retries + 1} attempts: {str(e)}") - raise - - except Exception as e: - logger.error(f"Unexpected error during LLM call: {type(e).__name__}: {str(e)}") - raise - - if last_exception: - raise last_exception - raise RuntimeError("LLM call failed after all retries with no exception captured") - - async def _call_ollama_native( - self, - messages: list[dict[str, str]], - response_format: Any, - max_completion_tokens: int | None, - temperature: float | None, - max_retries: int, - initial_backoff: float, - max_backoff: float, - skip_validation: bool, - start_time: float, - ) -> Any: - """ - Call Ollama using native API with JSON schema enforcement. - - Ollama's native API supports passing a full JSON schema in the 'format' parameter, - which provides better structured output control than the OpenAI-compatible API. - """ - # Get the JSON schema from the Pydantic model - schema = response_format.model_json_schema() if hasattr(response_format, "model_json_schema") else None - - # Build the base URL for Ollama's native API - # Default OpenAI-compatible URL is http://localhost:11434/v1 - # Native API is at http://localhost:11434/api/chat - base_url = self.base_url or "http://localhost:11434/v1" - if base_url.endswith("/v1"): - native_url = base_url[:-3] + "/api/chat" - else: - native_url = base_url.rstrip("/") + "/api/chat" - - # Build request payload - payload = { - "model": self.model, - "messages": messages, - "stream": False, - } - - # Add schema as format parameter for structured output - if schema: - payload["format"] = schema - - # Add optional parameters with optimized defaults for Ollama - # Benchmarking shows num_ctx=16384 + num_batch=512 is optimal - options = { - "num_ctx": 16384, # 16k context window for larger prompts - "num_batch": 512, # Optimal batch size for prompt processing - } - if max_completion_tokens: - options["num_predict"] = max_completion_tokens - if temperature is not None: - options["temperature"] = temperature - payload["options"] = options - - last_exception = None - - async with httpx.AsyncClient(timeout=300.0) as client: - for attempt in range(max_retries + 1): - try: - response = await client.post(native_url, json=payload) - response.raise_for_status() - - result = response.json() - content = result.get("message", {}).get("content", "") - - # Parse JSON response - try: - json_data = json.loads(content) - except json.JSONDecodeError as json_err: - content_preview = content[:500] if content else "" - if content and len(content) > 700: - content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}" - logger.warning( - f"Ollama JSON parse error (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n" - f" Model: ollama/{self.model}\n" - f" Content length: {len(content) if content else 0} chars\n" - f" Content preview: {content_preview!r}" - ) - if attempt < max_retries: - backoff = min(initial_backoff * (2**attempt), max_backoff) - await asyncio.sleep(backoff) - last_exception = json_err - continue - else: - raise - - # Validate against Pydantic model or return raw JSON - if skip_validation: - return json_data - else: - return response_format.model_validate(json_data) - - except httpx.HTTPStatusError as e: - last_exception = e - if attempt < max_retries: - logger.warning( - f"Ollama HTTP error (attempt {attempt + 1}/{max_retries + 1}): {e.response.status_code}" - ) - backoff = min(initial_backoff * (2**attempt), max_backoff) - await asyncio.sleep(backoff) - continue - else: - logger.error(f"Ollama HTTP error after {max_retries + 1} attempts: {e}") - raise - - except httpx.RequestError as e: - last_exception = e - if attempt < max_retries: - logger.warning(f"Ollama connection error (attempt {attempt + 1}/{max_retries + 1}): {e}") - backoff = min(initial_backoff * (2**attempt), max_backoff) - await asyncio.sleep(backoff) - continue - else: - logger.error(f"Ollama connection error after {max_retries + 1} attempts: {e}") - raise - - except Exception as e: - logger.error(f"Unexpected error during Ollama call: {type(e).__name__}: {e}") - raise - - if last_exception: - raise last_exception - raise RuntimeError("Ollama call failed after all retries") - - async def _call_gemini( - self, - messages: list[dict[str, str]], - response_format: Any | None, - max_retries: int, - initial_backoff: float, - max_backoff: float, - skip_validation: bool, - start_time: float, - ) -> Any: - """Handle Gemini-specific API calls.""" - # Convert OpenAI-style messages to Gemini format - system_instruction = None - gemini_contents = [] - - for msg in messages: - role = msg.get("role", "user") - content = msg.get("content", "") - - if role == "system": - if system_instruction: - system_instruction += "\n\n" + content - else: - system_instruction = content - elif role == "assistant": - gemini_contents.append(genai_types.Content(role="model", parts=[genai_types.Part(text=content)])) - else: - gemini_contents.append(genai_types.Content(role="user", parts=[genai_types.Part(text=content)])) - - # Add JSON schema instruction if response_format is provided - if response_format is not None and hasattr(response_format, "model_json_schema"): - schema = response_format.model_json_schema() - schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}" - if system_instruction: - system_instruction += schema_msg - else: - system_instruction = schema_msg - - # Build generation config - config_kwargs = {} - if system_instruction: - config_kwargs["system_instruction"] = system_instruction - if response_format is not None: - config_kwargs["response_mime_type"] = "application/json" - config_kwargs["response_schema"] = response_format - - generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None - - last_exception = None - - for attempt in range(max_retries + 1): - try: - response = await self._gemini_client.aio.models.generate_content( - model=self.model, - contents=gemini_contents, - config=generation_config, - ) - - content = response.text - - # Handle empty response - if content is None: - block_reason = None - if hasattr(response, "candidates") and response.candidates: - candidate = response.candidates[0] - if hasattr(candidate, "finish_reason"): - block_reason = candidate.finish_reason - - if attempt < max_retries: - logger.warning(f"Gemini returned empty response (reason: {block_reason}), retrying...") - backoff = min(initial_backoff * (2**attempt), max_backoff) - await asyncio.sleep(backoff) - continue - else: - raise RuntimeError(f"Gemini returned empty response after {max_retries + 1} attempts") - - if response_format is not None: - json_data = json.loads(content) - if skip_validation: - result = json_data - else: - result = response_format.model_validate(json_data) - else: - result = content - - # Log slow calls - duration = time.time() - start_time - if duration > 10.0 and hasattr(response, "usage_metadata") and response.usage_metadata: - usage = response.usage_metadata - logger.info( - f"slow llm call: model={self.provider}/{self.model}, " - f"input_tokens={usage.prompt_token_count}, output_tokens={usage.candidates_token_count}, " - f"time={duration:.3f}s" - ) - - return result - - except json.JSONDecodeError as e: - last_exception = e - if attempt < max_retries: - logger.warning("Gemini returned invalid JSON, retrying...") - backoff = min(initial_backoff * (2**attempt), max_backoff) - await asyncio.sleep(backoff) - continue - else: - logger.error(f"Gemini returned invalid JSON after {max_retries + 1} attempts") - raise - - except genai_errors.APIError as e: - # Fast fail only on 401 (unauthorized) and 403 (forbidden) - these won't recover with retries - if e.code in (401, 403): - logger.error(f"Gemini auth error (HTTP {e.code}), not retrying: {str(e)}") - raise - - # Retry on retryable errors (rate limits, server errors, and other client errors like 400) - if e.code in (400, 429, 500, 502, 503, 504) or (e.code and e.code >= 500): - last_exception = e - if attempt < max_retries: - backoff = min(initial_backoff * (2**attempt), max_backoff) - jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1) - await asyncio.sleep(backoff + jitter) - else: - logger.error(f"Gemini API error after {max_retries + 1} attempts: {str(e)}") - raise - else: - logger.error(f"Gemini API error: {type(e).__name__}: {str(e)}") - raise - - except Exception as e: - logger.error(f"Unexpected error during Gemini call: {type(e).__name__}: {str(e)}") - raise - - if last_exception: - raise last_exception - raise RuntimeError("Gemini call failed after all retries") - - @classmethod - def for_memory(cls) -> "LLMProvider": - """Create provider for memory operations from environment variables.""" - provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq") - api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY") - if not api_key: - raise ValueError("HINDSIGHT_API_LLM_API_KEY environment variable is required") - base_url = os.getenv("HINDSIGHT_API_LLM_BASE_URL", "") - model = os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b") - azure_api_version = os.getenv("HINDSIGHT_API_LLM_AZURE_API_VERSION") - - return cls( - provider=provider, - api_key=api_key, - base_url=base_url, - model=model, - reasoning_effort="low", - azure_api_version=azure_api_version, - ) - - @classmethod - def for_answer_generation(cls) -> "LLMProvider": - """Create provider for answer generation. Falls back to memory config if not set.""" - provider = os.getenv("HINDSIGHT_API_ANSWER_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq")) - api_key = os.getenv("HINDSIGHT_API_ANSWER_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY")) - if not api_key: - raise ValueError( - "HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_ANSWER_LLM_API_KEY environment variable is required" - ) - base_url = os.getenv("HINDSIGHT_API_ANSWER_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", "")) - model = os.getenv("HINDSIGHT_API_ANSWER_LLM_MODEL", os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b")) - azure_api_version = os.getenv("HINDSIGHT_API_LLM_AZURE_API_VERSION") - - return cls( - provider=provider, - api_key=api_key, - base_url=base_url, - model=model, - reasoning_effort="high", - azure_api_version=azure_api_version, - ) - - @classmethod - def for_judge(cls) -> "LLMProvider": - """Create provider for judge/evaluator operations. Falls back to memory config if not set.""" - provider = os.getenv("HINDSIGHT_API_JUDGE_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq")) - api_key = os.getenv("HINDSIGHT_API_JUDGE_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY")) - if not api_key: - raise ValueError( - "HINDSIGHT_API_LLM_API_KEY or HINDSIGHT_API_JUDGE_LLM_API_KEY environment variable is required" - ) - base_url = os.getenv("HINDSIGHT_API_JUDGE_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", "")) - model = os.getenv("HINDSIGHT_API_JUDGE_LLM_MODEL", os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b")) - azure_api_version = os.getenv("HINDSIGHT_API_LLM_AZURE_API_VERSION") - - return cls( - provider=provider, - api_key=api_key, - base_url=base_url, - model=model, - reasoning_effort="high", - azure_api_version=azure_api_version, - ) - - -# Backwards compatibility alias -LLMConfig = LLMProvider diff --git a/hindsight-api/hindsight_api/engine/memory_engine.py b/hindsight-api/hindsight_api/engine/memory_engine.py deleted file mode 100644 index 551291a7..00000000 --- a/hindsight-api/hindsight_api/engine/memory_engine.py +++ /dev/null @@ -1,4043 +0,0 @@ -""" -Memory Engine for Memory Banks. - -This implements a sophisticated memory architecture that combines: -1. Temporal links: Memories connected by time proximity -2. Semantic links: Memories connected by meaning/similarity -3. Entity links: Memories connected by shared entities (PERSON, ORG, etc.) -4. Spreading activation: Search through the graph with activation decay -5. Dynamic weighting: Recency and frequency-based importance -""" - -import asyncio -import contextvars -import logging -import time -import uuid -from datetime import UTC, datetime, timedelta -from typing import TYPE_CHECKING, Any - -# Context variable for current schema (async-safe, per-task isolation) -_current_schema: contextvars.ContextVar[str] = contextvars.ContextVar("current_schema", default="public") - - -def get_current_schema() -> str: - """Get the current schema from context (default: 'public').""" - return _current_schema.get() - - -def fq_table(table_name: str) -> str: - """ - Get fully-qualified table name with current schema. - - Example: - fq_table("memory_units") -> "public.memory_units" - fq_table("memory_units") -> "tenant_xyz.memory_units" (if schema is set) - """ - return f"{get_current_schema()}.{table_name}" - - -# Tables that must be schema-qualified (for runtime validation) -_PROTECTED_TABLES = frozenset( - [ - "memory_units", - "memory_links", - "unit_entities", - "entities", - "entity_cooccurrences", - "banks", - "documents", - "chunks", - "async_operations", - ] -) - -# Enable runtime SQL validation (can be disabled in production for performance) -_VALIDATE_SQL_SCHEMAS = True - - -class UnqualifiedTableError(Exception): - """Raised when SQL contains unqualified table references.""" - - pass - - -def validate_sql_schema(sql: str) -> None: - """ - Validate that SQL doesn't contain unqualified table references. - - This is a runtime safety check to prevent cross-tenant data access. - Raises UnqualifiedTableError if any protected table is referenced - without a schema prefix. - - Args: - sql: The SQL query to validate - - Raises: - UnqualifiedTableError: If unqualified table reference found - """ - if not _VALIDATE_SQL_SCHEMAS: - return - - import re - - sql_upper = sql.upper() - - for table in _PROTECTED_TABLES: - table_upper = table.upper() - - # Pattern: SQL keyword followed by unqualified table name - # Matches: FROM memory_units, JOIN memory_units, INTO memory_units, UPDATE memory_units - patterns = [ - rf"FROM\s+{table_upper}(?:\s|$|,|\)|;)", - rf"JOIN\s+{table_upper}(?:\s|$|,|\)|;)", - rf"INTO\s+{table_upper}(?:\s|$|\()", - rf"UPDATE\s+{table_upper}(?:\s|$)", - rf"DELETE\s+FROM\s+{table_upper}(?:\s|$|;)", - ] - - for pattern in patterns: - match = re.search(pattern, sql_upper) - if match: - # Check if it's actually qualified (preceded by schema.) - # Look backwards from match to see if there's a dot - start = match.start() - # Find the table name position in the match - table_pos = sql_upper.find(table_upper, start) - if table_pos > 0: - # Check character before table name (skip whitespace) - prefix = sql[:table_pos].rstrip() - if not prefix.endswith("."): - raise UnqualifiedTableError( - f"Unqualified table reference '{table}' in SQL. " - f"Use fq_table('{table}') for schema safety. " - f"SQL snippet: ...{sql[max(0, start - 10) : start + 50]}..." - ) - - -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.models import RequestContext - - -from enum import Enum - -from ..pg0 import EmbeddedPostgres -from .entity_resolver import EntityResolver -from .llm_wrapper import LLMConfig -from .query_analyzer import QueryAnalyzer -from .response_models import VALID_RECALL_FACT_TYPES, EntityObservation, EntityState, MemoryFact, ReflectResult -from .response_models import RecallResult as RecallResultModel -from .retain import bank_utils, embedding_utils -from .retain.types import RetainContentDict -from .search import observation_utils, think_utils -from .search.reranking import CrossEncoderReranker -from .task_backend import AsyncIOQueueBackend, TaskBackend - - -class Budget(str, Enum): - """Budget levels for recall/reflect operations.""" - - LOW = "low" - MID = "mid" - HIGH = "high" - - -def utcnow(): - """Get current UTC time with timezone info.""" - return datetime.now(UTC) - - -# Logger for memory system -logger = logging.getLogger(__name__) - -import tiktoken - -from .db_utils import acquire_with_retry - -# Cache tiktoken encoding for token budget filtering (module-level singleton) -_TIKTOKEN_ENCODING = None - - -def _get_tiktoken_encoding(): - """Get cached tiktoken encoding (cl100k_base for GPT-4/3.5).""" - global _TIKTOKEN_ENCODING - if _TIKTOKEN_ENCODING is None: - _TIKTOKEN_ENCODING = tiktoken.get_encoding("cl100k_base") - return _TIKTOKEN_ENCODING - - -class MemoryEngine(MemoryEngineInterface): - """ - Advanced memory system using temporal and semantic linking with PostgreSQL. - - This class provides: - - Embedding generation for semantic search - - Entity, temporal, and semantic link creation - - Think operations for formulating answers with opinions - - bank profile and disposition management - """ - - def __init__( - self, - db_url: str | None = None, - memory_llm_provider: str | None = None, - memory_llm_api_key: str | None = None, - memory_llm_model: str | None = None, - memory_llm_base_url: str | None = None, - embeddings: Embeddings | None = None, - cross_encoder: CrossEncoderModel | None = None, - query_analyzer: QueryAnalyzer | None = None, - pool_min_size: int = 5, - pool_max_size: int = 100, - task_backend: TaskBackend | None = None, - run_migrations: bool = True, - operation_validator: "OperationValidatorExtension | None" = None, - tenant_extension: "TenantExtension | None" = None, - skip_llm_verification: bool | None = None, - lazy_reranker: bool | None = None, - ): - """ - Initialize the temporal + semantic memory system. - - All parameters are optional and will be read from environment variables if not provided. - See hindsight_api.config for environment variable names and defaults. - - Args: - db_url: PostgreSQL connection URL. Defaults to HINDSIGHT_API_DATABASE_URL env var or "pg0". - Also supports pg0 URLs: "pg0" or "pg0://instance-name" or "pg0://instance-name:port" - memory_llm_provider: LLM provider. Defaults to HINDSIGHT_API_LLM_PROVIDER env var or "groq". - memory_llm_api_key: API key for the LLM provider. Defaults to HINDSIGHT_API_LLM_API_KEY env var. - memory_llm_model: Model name. Defaults to HINDSIGHT_API_LLM_MODEL env var. - memory_llm_base_url: Base URL for the LLM API. Defaults based on provider. - embeddings: Embeddings implementation. If not provided, created from env vars. - cross_encoder: Cross-encoder model. If not provided, created from env vars. - query_analyzer: Query analyzer implementation. If not provided, uses DateparserQueryAnalyzer. - pool_min_size: Minimum number of connections in the pool (default: 5) - pool_max_size: Maximum number of connections in the pool (default: 100) - task_backend: Custom task backend. If not provided, uses AsyncIOQueueBackend. - run_migrations: Whether to run database migrations during initialize(). Default: True - operation_validator: Optional extension to validate operations before execution. - If provided, retain/recall/reflect operations will be validated. - tenant_extension: Optional extension for multi-tenancy and API key authentication. - If provided, operations require a RequestContext for authentication. - skip_llm_verification: Skip LLM connection verification during initialization. - Defaults to HINDSIGHT_API_SKIP_LLM_VERIFICATION env var or False. - lazy_reranker: Delay reranker initialization until first use. Useful for retain-only - operations that don't need the cross-encoder. Defaults to - HINDSIGHT_API_LAZY_RERANKER env var or False. - """ - # Load config from environment for any missing parameters - from ..config import get_config - - config = get_config() - - # Apply optimization flags from config if not explicitly provided - self._skip_llm_verification = ( - skip_llm_verification if skip_llm_verification is not None else config.skip_llm_verification - ) - self._lazy_reranker = lazy_reranker if lazy_reranker is not None else config.lazy_reranker - - # Apply defaults from config - db_url = db_url or config.database_url - memory_llm_provider = memory_llm_provider or config.llm_provider - memory_llm_api_key = memory_llm_api_key or config.llm_api_key - # Ollama doesn't require an API key - if not memory_llm_api_key and memory_llm_provider != "ollama": - raise ValueError("LLM API key is required. Set HINDSIGHT_API_LLM_API_KEY environment variable.") - memory_llm_model = memory_llm_model or config.llm_model - memory_llm_base_url = memory_llm_base_url or config.get_llm_base_url() or None - memory_llm_azure_api_version = config.llm_azure_api_version - # Track pg0 instance (if used) - self._pg0: EmbeddedPostgres | None = None - self._pg0_instance_name: str | None = None - - # Initialize PostgreSQL connection URL - # The actual URL will be set during initialize() after starting the server - # Supports: "pg0" (default instance), "pg0://instance-name" (named instance), or regular postgresql:// URL - if db_url == "pg0": - self._use_pg0 = True - self._pg0_instance_name = "hindsight" - self._pg0_port = None # Use default port - self.db_url = None - elif db_url.startswith("pg0://"): - self._use_pg0 = True - # Parse instance name and optional port: pg0://instance-name or pg0://instance-name:port - url_part = db_url[6:] # Remove "pg0://" - if ":" in url_part: - self._pg0_instance_name, port_str = url_part.rsplit(":", 1) - self._pg0_port = int(port_str) - else: - self._pg0_instance_name = url_part or "hindsight" - self._pg0_port = None # Use default port - self.db_url = None - else: - self._use_pg0 = False - self._pg0_instance_name = None - self._pg0_port = None - self.db_url = db_url - - # Set default base URL if not provided - if memory_llm_base_url is None: - if memory_llm_provider.lower() == "groq": - memory_llm_base_url = "https://api.groq.com/openai/v1" - elif memory_llm_provider.lower() == "ollama": - memory_llm_base_url = "http://localhost:11434/v1" - else: - memory_llm_base_url = "" - - # Connection pool (will be created in initialize()) - self._pool = None - self._initialized = False - self._pool_min_size = pool_min_size - self._pool_max_size = pool_max_size - self._run_migrations = run_migrations - - # Initialize entity resolver (will be created in initialize()) - self.entity_resolver = None - - # Initialize embeddings (from env vars if not provided) - if embeddings is not None: - self.embeddings = embeddings - else: - self.embeddings = create_embeddings_from_env() - - # Initialize query analyzer - if query_analyzer is not None: - self.query_analyzer = query_analyzer - else: - from .query_analyzer import DateparserQueryAnalyzer - - self.query_analyzer = DateparserQueryAnalyzer() - - # Initialize LLM configuration - self._llm_config = LLMConfig( - provider=memory_llm_provider, - api_key=memory_llm_api_key, - base_url=memory_llm_base_url, - model=memory_llm_model, - azure_api_version=memory_llm_azure_api_version, - ) - - # Store client and model for convenience (deprecated: use _llm_config.call() instead) - self._llm_client = self._llm_config._client - self._llm_model = self._llm_config.model - - # Initialize cross-encoder reranker (cached for performance) - self._cross_encoder_reranker = CrossEncoderReranker(cross_encoder=cross_encoder) - - # Initialize task backend - self._task_backend = task_backend or AsyncIOQueueBackend(batch_size=100, batch_interval=1.0) - - # Backpressure mechanism: limit concurrent searches to prevent overwhelming the database - # Limit concurrent searches to prevent connection pool exhaustion - # Each search can use 2-4 connections, so with 10 concurrent searches - # we use ~20-40 connections max, staying well within pool limits - self._search_semaphore = asyncio.Semaphore(10) - - # Backpressure for put operations: limit concurrent puts to prevent database contention - # Each put_batch holds a connection for the entire transaction, so we limit to 5 - # concurrent puts to avoid connection pool exhaustion and reduce write contention - self._put_semaphore = asyncio.Semaphore(5) - - # initialize encoding eagerly to avoid delaying the first time - _get_tiktoken_encoding() - - # Store operation validator extension (optional) - self._operation_validator = operation_validator - - # Store tenant extension (optional) - self._tenant_extension = tenant_extension - - async def _validate_operation(self, validation_coro) -> None: - """ - Run validation if an operation validator is configured. - - Args: - validation_coro: Coroutine that returns a ValidationResult - - Raises: - OperationValidationError: If validation fails - """ - if self._operation_validator is None: - return - - from hindsight_api.extensions import OperationValidationError - - result = await validation_coro - if not result.allowed: - raise OperationValidationError(result.reason or "Operation not allowed") - - async def _authenticate_tenant(self, request_context: "RequestContext | None") -> str: - """ - Authenticate tenant and set schema in context variable. - - The schema is stored in a contextvar for async-safe, per-task isolation. - Use fq_table(table_name) to get fully-qualified table names. - - Args: - request_context: The request context with API key. Required if tenant_extension is configured. - - Returns: - Schema name that was set in the context. - - Raises: - AuthenticationError: If authentication fails or request_context is missing when required. - """ - if self._tenant_extension is None: - _current_schema.set("public") - return "public" - - from hindsight_api.extensions import AuthenticationError - - if request_context is None: - raise AuthenticationError("RequestContext is required when tenant extension is configured") - - tenant_context = await self._tenant_extension.authenticate(request_context) - _current_schema.set(tenant_context.schema_name) - return tenant_context.schema_name - - async def _handle_access_count_update(self, task_dict: dict[str, Any]): - """ - Handler for access count update tasks. - - Args: - task_dict: Dict with 'node_ids' key containing list of node IDs to update - - Raises: - Exception: Any exception from database operations (propagates to execute_task for retry) - """ - node_ids = task_dict.get("node_ids", []) - if not node_ids: - return - - pool = await self._get_pool() - # Convert string UUIDs to UUID type for faster matching - uuid_list = [uuid.UUID(nid) for nid in node_ids] - async with acquire_with_retry(pool) as conn: - await conn.execute( - f"UPDATE {fq_table('memory_units')} SET access_count = access_count + 1 WHERE id = ANY($1::uuid[])", - uuid_list, - ) - - async def _handle_batch_retain(self, task_dict: dict[str, Any]): - """ - Handler for batch retain tasks. - - Args: - task_dict: Dict with 'bank_id', 'contents' - - Raises: - ValueError: If bank_id is missing - Exception: Any exception from retain_batch_async (propagates to execute_task for retry) - """ - bank_id = task_dict.get("bank_id") - if not bank_id: - raise ValueError("bank_id is required for batch retain task") - contents = task_dict.get("contents", []) - - logger.info( - f"[BATCH_RETAIN_TASK] Starting background batch retain for bank_id={bank_id}, {len(contents)} items" - ) - - # Use internal request context for background tasks - from hindsight_api.models import RequestContext - - internal_context = RequestContext() - await self.retain_batch_async(bank_id=bank_id, contents=contents, request_context=internal_context) - - logger.info(f"[BATCH_RETAIN_TASK] Completed background batch retain for bank_id={bank_id}") - - async def execute_task(self, task_dict: dict[str, Any]): - """ - Execute a task by routing it to the appropriate handler. - - This method is called by the task backend to execute tasks. - It receives a plain dict that can be serialized and sent over the network. - - Args: - task_dict: Task dictionary with 'type' key and other payload data - Example: {'type': 'access_count_update', 'node_ids': [...]} - """ - task_type = task_dict.get("type") - operation_id = task_dict.get("operation_id") - retry_count = task_dict.get("retry_count", 0) - max_retries = 3 - - # Check if operation was cancelled (only for tasks with operation_id) - if operation_id: - try: - pool = await self._get_pool() - async with acquire_with_retry(pool) as conn: - result = await conn.fetchrow( - f"SELECT operation_id FROM {fq_table('async_operations')} WHERE operation_id = $1", - uuid.UUID(operation_id), - ) - if not result: - # Operation was cancelled, skip processing - logger.info(f"Skipping cancelled operation: {operation_id}") - return - except Exception as e: - logger.error(f"Failed to check operation status {operation_id}: {e}") - # Continue with processing if we can't check status - - try: - if task_type == "access_count_update": - await self._handle_access_count_update(task_dict) - elif task_type == "reinforce_opinion": - await self._handle_reinforce_opinion(task_dict) - elif task_type == "form_opinion": - await self._handle_form_opinion(task_dict) - elif task_type == "batch_retain": - await self._handle_batch_retain(task_dict) - elif task_type == "regenerate_observations": - await self._handle_regenerate_observations(task_dict) - else: - logger.error(f"Unknown task type: {task_type}") - # Don't retry unknown task types - if operation_id: - await self._delete_operation_record(operation_id) - return - - # Task succeeded - delete operation record - if operation_id: - await self._delete_operation_record(operation_id) - - except Exception as e: - # Task failed - check if we should retry - logger.error( - f"Task execution failed (attempt {retry_count + 1}/{max_retries + 1}): {task_type}, error: {e}" - ) - import traceback - - error_traceback = traceback.format_exc() - traceback.print_exc() - - if retry_count < max_retries: - # Reschedule with incremented retry count - task_dict["retry_count"] = retry_count + 1 - logger.info(f"Rescheduling task {task_type} (retry {retry_count + 1}/{max_retries})") - await self._task_backend.submit_task(task_dict) - else: - # Max retries exceeded - mark operation as failed - logger.error(f"Max retries exceeded for task {task_type}, marking as failed") - if operation_id: - await self._mark_operation_failed(operation_id, str(e), error_traceback) - - async def _delete_operation_record(self, operation_id: str): - """Helper to delete an operation record from the database.""" - try: - pool = await self._get_pool() - async with acquire_with_retry(pool) as conn: - await conn.execute( - f"DELETE FROM {fq_table('async_operations')} WHERE operation_id = $1", uuid.UUID(operation_id) - ) - except Exception as e: - logger.error(f"Failed to delete async operation record {operation_id}: {e}") - - async def _mark_operation_failed(self, operation_id: str, error_message: str, error_traceback: str): - """Helper to mark an operation as failed in the database.""" - try: - pool = await self._get_pool() - # Truncate error message to avoid extremely long strings - full_error = f"{error_message}\n\nTraceback:\n{error_traceback}" - truncated_error = full_error[:5000] if len(full_error) > 5000 else full_error - - async with acquire_with_retry(pool) as conn: - await conn.execute( - f""" - UPDATE {fq_table("async_operations")} - SET status = 'failed', error_message = $2 - WHERE operation_id = $1 - """, - uuid.UUID(operation_id), - truncated_error, - ) - logger.info(f"Marked async operation as failed: {operation_id}") - except Exception as e: - logger.error(f"Failed to mark operation as failed {operation_id}: {e}") - - async def initialize(self): - """Initialize the connection pool, models, and background workers. - - Loads models (embeddings, cross-encoder) in parallel with pg0 startup - for faster overall initialization. - """ - if self._initialized: - return - - # Run model loading in thread pool (CPU-bound) in parallel with pg0 startup - loop = asyncio.get_event_loop() - - async def start_pg0(): - """Start pg0 if configured.""" - if self._use_pg0: - kwargs = {"name": self._pg0_instance_name} - if self._pg0_port is not None: - kwargs["port"] = self._pg0_port - pg0 = EmbeddedPostgres(**kwargs) # type: ignore[invalid-argument-type] - dict kwargs - # Check if pg0 is already running before we start it - was_already_running = await pg0.is_running() - self.db_url = await pg0.ensure_running() - # Only track pg0 (to stop later) if WE started it - if not was_already_running: - self._pg0 = pg0 - - async def init_embeddings(): - """Initialize embedding model.""" - # For local providers, run in thread pool to avoid blocking event loop - if self.embeddings.provider_name == "local": - await loop.run_in_executor(None, lambda: asyncio.run(self.embeddings.initialize())) - else: - await self.embeddings.initialize() - - async def init_cross_encoder(): - """Initialize cross-encoder model.""" - cross_encoder = self._cross_encoder_reranker.cross_encoder - # For local providers, run in thread pool to avoid blocking event loop - if cross_encoder.provider_name == "local": - await loop.run_in_executor(None, lambda: asyncio.run(cross_encoder.initialize())) - else: - await cross_encoder.initialize() - # Mark reranker as initialized - self._cross_encoder_reranker._initialized = True - - async def init_query_analyzer(): - """Initialize query analyzer model.""" - # Query analyzer load is sync and CPU-bound - await loop.run_in_executor(None, self.query_analyzer.load) - - async def verify_llm(): - """Verify LLM connection is working.""" - if not self._skip_llm_verification: - await self._llm_config.verify_connection() - - # Build list of initialization tasks - init_tasks = [ - start_pg0(), - init_embeddings(), - init_query_analyzer(), - ] - - # Only init cross-encoder eagerly if not using lazy initialization - if not self._lazy_reranker: - init_tasks.append(init_cross_encoder()) - - # Only verify LLM if not skipping - if not self._skip_llm_verification: - init_tasks.append(verify_llm()) - - # Run pg0 and selected model initializations in parallel - await asyncio.gather(*init_tasks) - - # Run database migrations if enabled - if self._run_migrations: - from ..migrations import run_migrations - - if not self.db_url: - raise ValueError("Database URL is required for migrations") - logger.info("Running database migrations...") - run_migrations(self.db_url) - - logger.info(f"Connecting to PostgreSQL at {self.db_url}") - - # Create connection pool - # For read-heavy workloads with many parallel think/search operations, - # we need a larger pool. Read operations don't need strong isolation. - self._pool = await asyncpg.create_pool( - self.db_url, - min_size=self._pool_min_size, - max_size=self._pool_max_size, - command_timeout=60, - statement_cache_size=0, # Disable prepared statement cache - timeout=30, # Connection acquisition timeout (seconds) - ) - - # Initialize entity resolver with pool - self.entity_resolver = EntityResolver(self._pool) - - # Set executor for task backend and initialize - self._task_backend.set_executor(self.execute_task) - await self._task_backend.initialize() - - self._initialized = True - logger.info("Memory system initialized (pool and task backend started)") - - async def _get_pool(self) -> asyncpg.Pool: - """Get the connection pool (must call initialize() first).""" - if not self._initialized: - await self.initialize() - return self._pool - - async def _acquire_connection(self): - """ - Acquire a connection from the pool with retry logic. - - Returns an async context manager that yields a connection. - Retries on transient connection errors with exponential backoff. - """ - pool = await self._get_pool() - - async def acquire(): - return await pool.acquire() - - return await _retry_with_backoff(acquire) - - async def health_check(self) -> dict: - """ - Perform a health check by querying the database. - - Returns: - dict with status and optional error message - """ - try: - pool = await self._get_pool() - async with pool.acquire() as conn: - result = await conn.fetchval("SELECT 1") - if result == 1: - return {"status": "healthy", "database": "connected"} - else: - return {"status": "unhealthy", "database": "unexpected response"} - except Exception as e: - return {"status": "unhealthy", "database": "error", "error": str(e)} - - async def close(self): - """Close the connection pool and shutdown background workers.""" - logger.info("close() started") - - # Shutdown task backend - await self._task_backend.shutdown() - - # Close pool - if self._pool is not None: - self._pool.terminate() - self._pool = None - - self._initialized = False - - # Stop pg0 if we started it - if self._pg0 is not None: - logger.info("Stopping pg0...") - await self._pg0.stop() - self._pg0 = None - logger.info("pg0 stopped") - - async def wait_for_background_tasks(self): - """ - Wait for all pending background tasks to complete. - - This is useful in tests to ensure background tasks (like opinion reinforcement) - complete before making assertions. - """ - if hasattr(self._task_backend, "wait_for_pending_tasks"): - await self._task_backend.wait_for_pending_tasks() - - def _format_readable_date(self, dt: datetime) -> str: - """ - Format a datetime into a readable string for temporal matching. - - Examples: - - June 2024 - - January 15, 2024 - - December 2023 - - This helps queries like "camping in June" match facts that happened in June. - - Args: - dt: datetime object to format - - Returns: - Readable date string - """ - # Format as "Month Year" for most cases - # Could be extended to include day for very specific dates if needed - month_name = dt.strftime("%B") # Full month name (e.g., "June") - year = dt.strftime("%Y") # Year (e.g., "2024") - - # For now, use "Month Year" format - # Could check if day is significant (not 1st or 15th) and include it - return f"{month_name} {year}" - - async def _find_duplicate_facts_batch( - self, - conn, - bank_id: str, - texts: list[str], - embeddings: list[list[float]], - event_date: datetime, - time_window_hours: int = 24, - similarity_threshold: float = 0.95, - ) -> list[bool]: - """ - Check which facts are duplicates using semantic similarity + temporal window. - - For each new fact, checks if a semantically similar fact already exists - within the time window. Uses pgvector cosine similarity for efficiency. - - Args: - conn: Database connection - bank_id: bank IDentifier - texts: List of fact texts to check - embeddings: Corresponding embeddings - event_date: Event date for temporal filtering - time_window_hours: Hours before/after event_date to search (default: 24) - similarity_threshold: Minimum cosine similarity to consider duplicate (default: 0.95) - - Returns: - List of booleans - True if fact is a duplicate (should skip), False if new - """ - if not texts: - return [] - - # Handle edge cases where event_date is at datetime boundaries - try: - time_lower = event_date - timedelta(hours=time_window_hours) - except OverflowError: - time_lower = datetime.min - try: - time_upper = event_date + timedelta(hours=time_window_hours) - except OverflowError: - time_upper = datetime.max - - # Fetch ALL existing facts in time window ONCE (much faster than N queries) - import time as time_mod - - fetch_start = time_mod.time() - existing_facts = await conn.fetch( - f""" - SELECT id, text, embedding - FROM {fq_table("memory_units")} - WHERE bank_id = $1 - AND event_date BETWEEN $2 AND $3 - """, - bank_id, - time_lower, - time_upper, - ) - - # If no existing facts, nothing is duplicate - if not existing_facts: - return [False] * len(texts) - - # Compute similarities in Python (vectorized with numpy) - is_duplicate = [] - - # Convert existing embeddings to numpy for faster computation - embedding_arrays = [] - for row in existing_facts: - raw_emb = row["embedding"] - # Handle different pgvector formats - if isinstance(raw_emb, str): - # Parse string format: "[1.0, 2.0, ...]" - import json - - emb = np.array(json.loads(raw_emb), dtype=np.float32) - elif isinstance(raw_emb, (list, tuple)): - emb = np.array(raw_emb, dtype=np.float32) - else: - # Try direct conversion - emb = np.array(raw_emb, dtype=np.float32) - embedding_arrays.append(emb) - - if not embedding_arrays: - existing_embeddings = np.array([]) - elif len(embedding_arrays) == 1: - # Single embedding: reshape to (1, dim) - existing_embeddings = embedding_arrays[0].reshape(1, -1) - else: - # Multiple embeddings: vstack - existing_embeddings = np.vstack(embedding_arrays) - - comp_start = time_mod.time() - for embedding in embeddings: - # Compute cosine similarity with all existing facts - emb_array = np.array(embedding) - # Cosine similarity = 1 - cosine distance - # For normalized vectors: cosine_sim = dot product - similarities = np.dot(existing_embeddings, emb_array) - - # Check if any existing fact is too similar - max_similarity = np.max(similarities) if len(similarities) > 0 else 0 - is_duplicate.append(max_similarity > similarity_threshold) - - return is_duplicate - - def retain( - self, - bank_id: str, - content: str, - context: str = "", - event_date: datetime | None = None, - request_context: "RequestContext | None" = None, - ) -> list[str]: - """ - Store content as memory units (synchronous wrapper). - - This is a synchronous wrapper around retain_async() for convenience. - For best performance, use retain_async() directly. - - Args: - bank_id: Unique identifier for the bank - content: Text content to store - context: Context about when/why this memory was formed - event_date: When the event occurred (defaults to now) - request_context: Request context for authentication (optional, uses internal context if not provided) - - Returns: - List of created unit IDs - """ - # Run async version synchronously - from hindsight_api.models import RequestContext as RC - - 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)) - - async def retain_async( - self, - bank_id: str, - content: str, - context: str = "", - event_date: datetime | None = None, - document_id: str | None = None, - fact_type_override: str | None = None, - confidence_score: float | None = None, - *, - request_context: "RequestContext", - ) -> list[str]: - """ - Store content as memory units with temporal and semantic links (ASYNC version). - - This is a convenience wrapper around retain_batch_async for a single content item. - - Args: - bank_id: Unique identifier for the bank - content: Text content to store - context: Context about when/why this memory was formed - event_date: When the event occurred (defaults to now) - document_id: Optional document ID for tracking (always upserts if document already exists) - fact_type_override: Override fact type ('world', 'experience', 'opinion') - confidence_score: Confidence score for opinions (0.0 to 1.0) - request_context: Request context for authentication. - - Returns: - List of created unit IDs - """ - # Build content dict - content_dict: RetainContentDict = {"content": content, "context": context} # type: ignore[typeddict-item] - building incrementally - if event_date: - content_dict["event_date"] = event_date - if document_id: - content_dict["document_id"] = document_id - - # Use retain_batch_async with a single item (avoids code duplication) - result = await self.retain_batch_async( - bank_id=bank_id, - contents=[content_dict], - request_context=request_context, - fact_type_override=fact_type_override, - confidence_score=confidence_score, - ) - - # Return the first (and only) list of unit IDs - return result[0] if result else [] - - async def retain_batch_async( - self, - bank_id: str, - contents: list[RetainContentDict], - *, - request_context: "RequestContext", - document_id: str | None = None, - fact_type_override: str | None = None, - confidence_score: float | None = None, - ) -> list[list[str]]: - """ - Store multiple content items as memory units in ONE batch operation. - - This is MUCH more efficient than calling retain_async multiple times: - - Extracts facts from all contents in parallel - - Generates ALL embeddings in ONE batch - - Does ALL database operations in ONE transaction - - Automatically chunks large batches to prevent timeouts - - Args: - bank_id: Unique identifier for the bank - contents: List of dicts with keys: - - "content" (required): Text content to store - - "context" (optional): Context about the memory - - "event_date" (optional): When the event occurred - - "document_id" (optional): Document ID for this specific content item - document_id: **DEPRECATED** - Use "document_id" key in each content dict instead. - Applies the same document_id to ALL content items that don't specify their own. - fact_type_override: Override fact type for all facts ('world', 'experience', 'opinion') - confidence_score: Confidence score for opinions (0.0 to 1.0) - - Returns: - List of lists of unit IDs (one list per content item) - - Example (new style - per-content document_id): - unit_ids = await memory.retain_batch_async( - bank_id="user123", - contents=[ - {"content": "Alice works at Google", "document_id": "doc1"}, - {"content": "Bob loves Python", "document_id": "doc2"}, - {"content": "More about Alice", "document_id": "doc1"}, - ] - ) - # Returns: [["unit-id-1"], ["unit-id-2"], ["unit-id-3"]] - - Example (deprecated style - batch-level document_id): - unit_ids = await memory.retain_batch_async( - bank_id="user123", - contents=[ - {"content": "Alice works at Google"}, - {"content": "Bob loves Python"}, - ], - document_id="meeting-2024-01-15" - ) - # Returns: [["unit-id-1"], ["unit-id-2"]] - """ - start_time = time.time() - - if not contents: - return [] - - # Authenticate tenant and set schema in context (for fq_table()) - await self._authenticate_tenant(request_context) - - # Validate operation if validator is configured - contents_copy = [dict(c) for c in contents] # Convert TypedDict to regular dict for extension - if self._operation_validator: - from hindsight_api.extensions import RetainContext - - ctx = RetainContext( - bank_id=bank_id, - contents=contents_copy, - request_context=request_context, - document_id=document_id, - fact_type_override=fact_type_override, - confidence_score=confidence_score, - ) - await self._validate_operation(self._operation_validator.validate_retain(ctx)) - - # Apply batch-level document_id to contents that don't have their own (backwards compatibility) - if document_id: - for item in contents: - if "document_id" not in item: - item["document_id"] = document_id - - # Auto-chunk large batches by character count to avoid timeouts and memory issues - # Calculate total character count - total_chars = sum(len(item.get("content", "")) for item in contents) - - CHARS_PER_BATCH = 600_000 - - if total_chars > CHARS_PER_BATCH: - # Split into smaller batches based on character count - logger.info( - f"Large batch detected ({total_chars:,} chars from {len(contents)} items). Splitting into sub-batches of ~{CHARS_PER_BATCH:,} chars each..." - ) - - sub_batches = [] - current_batch = [] - current_batch_chars = 0 - - for item in contents: - item_chars = len(item.get("content", "")) - - # If adding this item would exceed the limit, start a new batch - # (unless current batch is empty - then we must include it even if it's large) - if current_batch and current_batch_chars + item_chars > CHARS_PER_BATCH: - sub_batches.append(current_batch) - current_batch = [item] - current_batch_chars = item_chars - else: - current_batch.append(item) - current_batch_chars += item_chars - - # Add the last batch - if current_batch: - sub_batches.append(current_batch) - - logger.info(f"Split into {len(sub_batches)} sub-batches: {[len(b) for b in sub_batches]} items each") - - # Process each sub-batch using internal method (skip chunking check) - all_results = [] - for i, sub_batch in enumerate(sub_batches, 1): - sub_batch_chars = sum(len(item.get("content", "")) for item in sub_batch) - logger.info( - f"Processing sub-batch {i}/{len(sub_batches)}: {len(sub_batch)} items, {sub_batch_chars:,} chars" - ) - - sub_results = await self._retain_batch_async_internal( - bank_id=bank_id, - contents=sub_batch, - document_id=document_id, - is_first_batch=i == 1, # Only upsert on first batch - fact_type_override=fact_type_override, - confidence_score=confidence_score, - ) - all_results.extend(sub_results) - - total_time = time.time() - start_time - logger.info( - f"RETAIN_BATCH_ASYNC (chunked) COMPLETE: {len(all_results)} results from {len(contents)} contents in {total_time:.3f}s" - ) - result = all_results - else: - # Small batch - use internal method directly - result = await self._retain_batch_async_internal( - bank_id=bank_id, - contents=contents, - document_id=document_id, - is_first_batch=True, - fact_type_override=fact_type_override, - confidence_score=confidence_score, - ) - - # Call post-operation hook if validator is configured - if self._operation_validator: - from hindsight_api.extensions import RetainResult - - result_ctx = RetainResult( - bank_id=bank_id, - contents=contents_copy, - request_context=request_context, - document_id=document_id, - fact_type_override=fact_type_override, - confidence_score=confidence_score, - unit_ids=result, - success=True, - error=None, - ) - try: - await self._operation_validator.on_retain_complete(result_ctx) - except Exception as e: - logger.warning(f"Post-retain hook error (non-fatal): {e}") - - return result - - async def _retain_batch_async_internal( - self, - bank_id: str, - contents: list[RetainContentDict], - document_id: str | None = None, - is_first_batch: bool = True, - fact_type_override: str | None = None, - confidence_score: float | None = None, - ) -> list[list[str]]: - """ - Internal method for batch processing without chunking logic. - - Assumes contents are already appropriately sized (< 50k chars). - Called by retain_batch_async after chunking large batches. - - Uses semaphore for backpressure to limit concurrent retains. - - Args: - bank_id: Unique identifier for the bank - contents: List of dicts with content, context, event_date - document_id: Optional document ID (always upserts if exists) - is_first_batch: Whether this is the first batch (for chunked operations, only delete on first batch) - fact_type_override: Override fact type for all facts - confidence_score: Confidence score for opinions - """ - # Backpressure: limit concurrent retains to prevent database contention - async with self._put_semaphore: - # Use the new modular orchestrator - from .retain import orchestrator - - pool = await self._get_pool() - return await orchestrator.retain_batch( - pool=pool, - embeddings_model=self.embeddings, - llm_config=self._llm_config, - entity_resolver=self.entity_resolver, - task_backend=self._task_backend, - format_date_fn=self._format_readable_date, - duplicate_checker_fn=self._find_duplicate_facts_batch, - bank_id=bank_id, - contents_dicts=contents, - document_id=document_id, - is_first_batch=is_first_batch, - fact_type_override=fact_type_override, - confidence_score=confidence_score, - ) - - def recall( - self, - bank_id: str, - query: str, - fact_type: str, - budget: Budget = Budget.MID, - max_tokens: int = 4096, - enable_trace: bool = False, - ) -> tuple[list[dict[str, Any]], Any | None]: - """ - Recall memories using 4-way parallel retrieval (synchronous wrapper). - - This is a synchronous wrapper around recall_async() for convenience. - For best performance, use recall_async() directly. - - Args: - bank_id: bank ID to recall for - query: Recall query - fact_type: Required filter for fact type ('world', 'experience', or 'opinion') - budget: Budget level for graph traversal (low=100, mid=300, high=600 units) - max_tokens: Maximum tokens to return (counts only 'text' field, default 4096) - enable_trace: If True, returns detailed trace object - - Returns: - Tuple of (results, trace) - """ - # Run async version synchronously - deprecated sync method, passing None for request_context - from hindsight_api.models import RequestContext - - return asyncio.run( - self.recall_async( - bank_id, - query, - budget=budget, - max_tokens=max_tokens, - enable_trace=enable_trace, - fact_type=[fact_type], - request_context=RequestContext(), - ) - ) - - async def recall_async( - self, - bank_id: str, - query: str, - *, - budget: Budget | None = None, - max_tokens: int = 4096, - enable_trace: bool = False, - fact_type: list[str] | None = None, - question_date: datetime | None = None, - include_entities: bool = False, - max_entity_tokens: int = 500, - include_chunks: bool = False, - max_chunk_tokens: int = 8192, - request_context: "RequestContext", - ) -> RecallResultModel: - """ - Recall memories using N*4-way parallel retrieval (N fact types × 4 retrieval methods). - - This implements the core RECALL operation: - 1. Retrieval: For each fact type, run 4 parallel retrievals (semantic vector, BM25 keyword, graph activation, temporal graph) - 2. Merge: Combine using Reciprocal Rank Fusion (RRF) - 3. Rerank: Score using selected reranker (heuristic or cross-encoder) - 4. Diversify: Apply MMR for diversity - 5. Token Filter: Return results up to max_tokens budget - - Args: - bank_id: bank ID to recall for - query: Recall query - fact_type: List of fact types to recall (e.g., ['world', 'experience']) - budget: Budget level for graph traversal (low=100, mid=300, high=600 units) - max_tokens: Maximum tokens to return (counts only 'text' field, default 4096) - Results are returned until token budget is reached, stopping before - including a fact that would exceed the limit - enable_trace: Whether to return trace for debugging (deprecated) - question_date: Optional date when question was asked (for temporal filtering) - include_entities: Whether to include entity observations in the response - max_entity_tokens: Maximum tokens for entity observations (default 500) - include_chunks: Whether to include raw chunks in the response - max_chunk_tokens: Maximum tokens for chunks (default 8192) - - Returns: - RecallResultModel containing: - - results: List of MemoryFact objects - - trace: Optional trace information for debugging - - entities: Optional dict of entity states (if include_entities=True) - - chunks: Optional dict of chunks (if include_chunks=True) - """ - # Authenticate tenant and set schema in context (for fq_table()) - await self._authenticate_tenant(request_context) - - # Default to all fact types if not specified - if fact_type is None: - fact_type = list(VALID_RECALL_FACT_TYPES) - - # Validate fact types early - invalid_types = set(fact_type) - VALID_RECALL_FACT_TYPES - if invalid_types: - raise ValueError( - f"Invalid fact type(s): {', '.join(sorted(invalid_types))}. " - f"Must be one of: {', '.join(sorted(VALID_RECALL_FACT_TYPES))}" - ) - - # Validate operation if validator is configured - if self._operation_validator: - from hindsight_api.extensions import RecallContext - - ctx = RecallContext( - bank_id=bank_id, - query=query, - request_context=request_context, - budget=budget, - max_tokens=max_tokens, - enable_trace=enable_trace, - fact_types=list(fact_type), - question_date=question_date, - include_entities=include_entities, - max_entity_tokens=max_entity_tokens, - include_chunks=include_chunks, - max_chunk_tokens=max_chunk_tokens, - ) - await self._validate_operation(self._operation_validator.validate_recall(ctx)) - - # Map budget enum to thinking_budget number (default to MID if None) - budget_mapping = {Budget.LOW: 100, Budget.MID: 300, Budget.HIGH: 1000} - effective_budget = budget if budget is not None else Budget.MID - thinking_budget = budget_mapping[effective_budget] - - # Backpressure: limit concurrent recalls to prevent overwhelming the database - result = None - error_msg = None - async with self._search_semaphore: - # Retry loop for connection errors - max_retries = 3 - for attempt in range(max_retries + 1): - try: - result = await self._search_with_retries( - bank_id, - query, - fact_type, - thinking_budget, - max_tokens, - enable_trace, - question_date, - include_entities, - max_entity_tokens, - include_chunks, - max_chunk_tokens, - request_context, - ) - break # Success - exit retry loop - except Exception as e: - # Check if it's a connection error - is_connection_error = ( - isinstance(e, asyncpg.TooManyConnectionsError) - or isinstance(e, asyncpg.CannotConnectNowError) - or (isinstance(e, asyncpg.PostgresError) and "connection" in str(e).lower()) - ) - - if is_connection_error and attempt < max_retries: - # Wait with exponential backoff before retry - wait_time = 0.5 * (2**attempt) # 0.5s, 1s, 2s - logger.warning( - f"Connection error on search attempt {attempt + 1}/{max_retries + 1}: {str(e)}. " - f"Retrying in {wait_time:.1f}s..." - ) - await asyncio.sleep(wait_time) - else: - # Not a connection error or out of retries - call post-hook and raise - error_msg = str(e) - if self._operation_validator: - from hindsight_api.extensions.operation_validator import RecallResult - - result_ctx = RecallResult( - bank_id=bank_id, - query=query, - request_context=request_context, - budget=budget, - max_tokens=max_tokens, - enable_trace=enable_trace, - fact_types=list(fact_type), - question_date=question_date, - include_entities=include_entities, - max_entity_tokens=max_entity_tokens, - include_chunks=include_chunks, - max_chunk_tokens=max_chunk_tokens, - result=None, - success=False, - error=error_msg, - ) - try: - await self._operation_validator.on_recall_complete(result_ctx) - except Exception as hook_err: - logger.warning(f"Post-recall hook error (non-fatal): {hook_err}") - raise - else: - # Exceeded max retries - error_msg = "Exceeded maximum retries for search due to connection errors." - if self._operation_validator: - from hindsight_api.extensions.operation_validator import RecallResult - - result_ctx = RecallResult( - bank_id=bank_id, - query=query, - request_context=request_context, - budget=budget, - max_tokens=max_tokens, - enable_trace=enable_trace, - fact_types=list(fact_type), - question_date=question_date, - include_entities=include_entities, - max_entity_tokens=max_entity_tokens, - include_chunks=include_chunks, - max_chunk_tokens=max_chunk_tokens, - result=None, - success=False, - error=error_msg, - ) - try: - await self._operation_validator.on_recall_complete(result_ctx) - except Exception as hook_err: - logger.warning(f"Post-recall hook error (non-fatal): {hook_err}") - raise Exception(error_msg) - - # Call post-operation hook for success - if self._operation_validator and result is not None: - from hindsight_api.extensions.operation_validator import RecallResult - - result_ctx = RecallResult( - bank_id=bank_id, - query=query, - request_context=request_context, - budget=budget, - max_tokens=max_tokens, - enable_trace=enable_trace, - fact_types=list(fact_type), - question_date=question_date, - include_entities=include_entities, - max_entity_tokens=max_entity_tokens, - include_chunks=include_chunks, - max_chunk_tokens=max_chunk_tokens, - result=result, - success=True, - error=None, - ) - try: - await self._operation_validator.on_recall_complete(result_ctx) - except Exception as e: - logger.warning(f"Post-recall hook error (non-fatal): {e}") - - return result - - async def _search_with_retries( - self, - bank_id: str, - query: str, - fact_type: list[str], - thinking_budget: int, - max_tokens: int, - enable_trace: bool, - question_date: datetime | None = None, - include_entities: bool = False, - max_entity_tokens: int = 500, - include_chunks: bool = False, - max_chunk_tokens: int = 8192, - request_context: "RequestContext" = None, - ) -> RecallResultModel: - """ - Search implementation with modular retrieval and reranking. - - Architecture: - 1. Retrieval: 4-way parallel (semantic, keyword, graph, temporal graph) - 2. Merge: RRF to combine ranked lists - 3. Reranking: Pluggable strategy (heuristic or cross-encoder) - 4. Diversity: MMR with λ=0.5 - 5. Token Filter: Limit results to max_tokens budget - - Args: - bank_id: bank IDentifier - query: Search query - fact_type: Type of facts to search - thinking_budget: Nodes to explore in graph traversal - max_tokens: Maximum tokens to return (counts only 'text' field) - enable_trace: Whether to return search trace (deprecated) - include_entities: Whether to include entity observations - max_entity_tokens: Maximum tokens for entity observations - include_chunks: Whether to include raw chunks - max_chunk_tokens: Maximum tokens for chunks - - Returns: - RecallResultModel with results, trace, optional entities, and optional chunks - """ - # Initialize tracer if requested - from .search.tracer import SearchTracer - - tracer = SearchTracer(query, thinking_budget, max_tokens) if enable_trace else None - if tracer: - tracer.start() - - pool = await self._get_pool() - recall_start = time.time() - - # Buffer logs for clean output in concurrent scenarios - recall_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}" - log_buffer = [] - log_buffer.append( - f"[RECALL {recall_id}] Query: '{query[:50]}...' (budget={thinking_budget}, max_tokens={max_tokens})" - ) - - try: - # Step 1: Generate query embedding (for semantic search) - step_start = time.time() - query_embedding = embedding_utils.generate_embedding(self.embeddings, query) - step_duration = time.time() - step_start - log_buffer.append(f" [1] Generate query embedding: {step_duration:.3f}s") - - if tracer: - tracer.record_query_embedding(query_embedding) - tracer.add_phase_metric("generate_query_embedding", step_duration) - - # Step 2: N*4-Way Parallel Retrieval (N fact types × 4 retrieval methods) - step_start = time.time() - query_embedding_str = str(query_embedding) - - from .search.retrieval import retrieve_parallel - - # Track each retrieval start time - retrieval_start = time.time() - - # Run retrieval for each fact type in parallel - retrieval_tasks = [ - retrieve_parallel( - pool, query, query_embedding_str, bank_id, ft, thinking_budget, question_date, self.query_analyzer - ) - for ft in fact_type - ] - all_retrievals = await asyncio.gather(*retrieval_tasks) - - # Combine all results from all fact types and aggregate timings - semantic_results = [] - bm25_results = [] - graph_results = [] - temporal_results = [] - aggregated_timings = {"semantic": 0.0, "bm25": 0.0, "graph": 0.0, "temporal": 0.0} - - detected_temporal_constraint = None - for idx, retrieval_result in enumerate(all_retrievals): - # Log fact types in this retrieval batch - ft_name = fact_type[idx] if idx < len(fact_type) else "unknown" - logger.debug( - f"[RECALL {recall_id}] Fact type '{ft_name}': semantic={len(retrieval_result.semantic)}, bm25={len(retrieval_result.bm25)}, graph={len(retrieval_result.graph)}, temporal={len(retrieval_result.temporal) if retrieval_result.temporal else 0}" - ) - - semantic_results.extend(retrieval_result.semantic) - bm25_results.extend(retrieval_result.bm25) - graph_results.extend(retrieval_result.graph) - if retrieval_result.temporal: - temporal_results.extend(retrieval_result.temporal) - # Track max timing for each method (since they run in parallel across fact types) - for method, duration in retrieval_result.timings.items(): - aggregated_timings[method] = max(aggregated_timings.get(method, 0.0), duration) - # Capture temporal constraint (same across all fact types) - if retrieval_result.temporal_constraint: - detected_temporal_constraint = retrieval_result.temporal_constraint - - # If no temporal results from any fact type, set to None - if not temporal_results: - temporal_results = None - - # Sort combined results by score (descending) so higher-scored results - # get better ranks in the trace, regardless of fact type - semantic_results.sort(key=lambda r: r.similarity if hasattr(r, "similarity") else 0, reverse=True) - bm25_results.sort(key=lambda r: r.bm25_score if hasattr(r, "bm25_score") else 0, reverse=True) - graph_results.sort(key=lambda r: r.activation if hasattr(r, "activation") else 0, reverse=True) - if temporal_results: - temporal_results.sort( - key=lambda r: r.combined_score if hasattr(r, "combined_score") else 0, reverse=True - ) - - 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)", - f"bm25={len(bm25_results)}({aggregated_timings['bm25']:.3f}s)", - f"graph={len(graph_results)}({aggregated_timings['graph']:.3f}s)", - ] - temporal_info = "" - if detected_temporal_constraint: - start_dt, end_dt = detected_temporal_constraint - temporal_count = len(temporal_results) if temporal_results else 0 - timing_parts.append(f"temporal={temporal_count}({aggregated_timings['temporal']:.3f}s)") - temporal_info = f" | temporal_range={start_dt.strftime('%Y-%m-%d')} to {end_dt.strftime('%Y-%m-%d')}" - log_buffer.append( - f" [2] {total_retrievals}-way retrieval ({len(fact_type)} fact_types): {', '.join(timing_parts)} in {step_duration:.3f}s{temporal_info}" - ) - - # Record retrieval results for tracer - per fact type - if tracer: - # Convert RetrievalResult to old tuple format for tracer - def to_tuple_format(results): - return [(r.id, r.__dict__) for r in results] - - # Add retrieval results per fact type (to show parallel execution in UI) - for idx, rr in enumerate(all_retrievals): - ft_name = fact_type[idx] if idx < len(fact_type) else "unknown" - - # Add semantic retrieval results for this fact type - tracer.add_retrieval_results( - method_name="semantic", - results=to_tuple_format(rr.semantic), - duration_seconds=rr.timings.get("semantic", 0.0), - score_field="similarity", - metadata={"limit": thinking_budget}, - fact_type=ft_name, - ) - - # Add BM25 retrieval results for this fact type - tracer.add_retrieval_results( - method_name="bm25", - results=to_tuple_format(rr.bm25), - duration_seconds=rr.timings.get("bm25", 0.0), - score_field="bm25_score", - metadata={"limit": thinking_budget}, - fact_type=ft_name, - ) - - # Add graph retrieval results for this fact type - tracer.add_retrieval_results( - method_name="graph", - results=to_tuple_format(rr.graph), - duration_seconds=rr.timings.get("graph", 0.0), - score_field="activation", - metadata={"budget": thinking_budget}, - fact_type=ft_name, - ) - - # Add temporal retrieval results for this fact type (even if empty, to show it ran) - if rr.temporal is not None: - tracer.add_retrieval_results( - method_name="temporal", - results=to_tuple_format(rr.temporal), - duration_seconds=rr.timings.get("temporal", 0.0), - score_field="temporal_score", - metadata={"budget": thinking_budget}, - fact_type=ft_name, - ) - - # Record entry points (from semantic results) for legacy graph view - for rank, retrieval in enumerate(semantic_results[:10], start=1): # Top 10 as entry points - tracer.add_entry_point(retrieval.id, retrieval.text, retrieval.similarity or 0.0, rank) - - tracer.add_phase_metric( - "parallel_retrieval", - step_duration, - { - "semantic_count": len(semantic_results), - "bm25_count": len(bm25_results), - "graph_count": len(graph_results), - "temporal_count": len(temporal_results) if temporal_results else 0, - }, - ) - - # Step 3: Merge with RRF - step_start = time.time() - from .search.fusion import reciprocal_rank_fusion - - # Merge 3 or 4 result lists depending on temporal constraint - if temporal_results: - merged_candidates = reciprocal_rank_fusion( - [semantic_results, bm25_results, graph_results, temporal_results] - ) - else: - merged_candidates = reciprocal_rank_fusion([semantic_results, bm25_results, graph_results]) - - step_duration = time.time() - step_start - log_buffer.append(f" [3] RRF merge: {len(merged_candidates)} unique candidates in {step_duration:.3f}s") - - if tracer: - # Convert MergedCandidate to old tuple format for tracer - tracer_merged = [ - (mc.id, mc.retrieval.__dict__, {"rrf_score": mc.rrf_score, **mc.source_ranks}) - for mc in merged_candidates - ] - tracer.add_rrf_merged(tracer_merged) - tracer.add_phase_metric("rrf_merge", step_duration, {"candidates_merged": len(merged_candidates)}) - - # Step 4: Rerank using cross-encoder (MergedCandidate -> ScoredResult) - step_start = time.time() - reranker_instance = self._cross_encoder_reranker - - # Ensure reranker is initialized (for lazy initialization mode) - await reranker_instance.ensure_initialized() - - # Rerank using cross-encoder - scored_results = reranker_instance.rerank(query, merged_candidates) - - step_duration = time.time() - step_start - log_buffer.append(f" [4] Reranking: {len(scored_results)} candidates scored in {step_duration:.3f}s") - - # Step 4.5: Combine cross-encoder score with retrieval signals - # This preserves retrieval work (RRF, temporal, recency) instead of pure cross-encoder ranking - if scored_results: - # Normalize RRF scores to [0, 1] range using min-max normalization - rrf_scores = [sr.candidate.rrf_score for sr in scored_results] - max_rrf = max(rrf_scores) if rrf_scores else 0.0 - min_rrf = min(rrf_scores) if rrf_scores else 0.0 - rrf_range = max_rrf - min_rrf # Don't force to 1.0, let fallback handle it - - # Calculate recency based on occurred_start (more recent = higher score) - now = utcnow() - for sr in scored_results: - # Normalize RRF score (0-1 range, 0.5 if all same) - if rrf_range > 0: - sr.rrf_normalized = (sr.candidate.rrf_score - min_rrf) / rrf_range - else: - # All RRF scores are the same, use neutral value - sr.rrf_normalized = 0.5 - - # Calculate recency (decay over 365 days, minimum 0.1) - sr.recency = 0.5 # default for missing dates - if sr.retrieval.occurred_start: - occurred = sr.retrieval.occurred_start - if hasattr(occurred, "tzinfo") and occurred.tzinfo is None: - occurred = occurred.replace(tzinfo=UTC) - days_ago = (now - occurred).total_seconds() / 86400 - sr.recency = max(0.1, 1.0 - (days_ago / 365)) # Linear decay over 1 year - - # Get temporal proximity if available (already 0-1) - sr.temporal = ( - sr.retrieval.temporal_proximity if sr.retrieval.temporal_proximity is not None else 0.5 - ) - - # Weighted combination - # Cross-encoder: 60% (semantic relevance) - # RRF: 20% (retrieval consensus) - # Temporal proximity: 10% (time relevance for temporal queries) - # Recency: 10% (prefer recent facts) - sr.combined_score = ( - 0.6 * sr.cross_encoder_score_normalized - + 0.2 * sr.rrf_normalized - + 0.1 * sr.temporal - + 0.1 * sr.recency - ) - sr.weight = sr.combined_score # Update weight for final ranking - - # Re-sort by combined score - scored_results.sort(key=lambda x: x.weight, reverse=True) - log_buffer.append( - " [4.6] Combined scoring: cross_encoder(0.6) + rrf(0.2) + temporal(0.1) + recency(0.1)" - ) - - # Add reranked results to tracer AFTER combined scoring (so normalized values are included) - if tracer: - results_dict = [sr.to_dict() for sr in scored_results] - tracer_merged = [ - (mc.id, mc.retrieval.__dict__, {"rrf_score": mc.rrf_score, **mc.source_ranks}) - for mc in merged_candidates - ] - tracer.add_reranked(results_dict, tracer_merged) - tracer.add_phase_metric( - "reranking", - step_duration, - {"reranker_type": "cross-encoder", "candidates_reranked": len(scored_results)}, - ) - - # Step 5: Truncate to thinking_budget * 2 for token filtering - rerank_limit = thinking_budget * 2 - top_scored = scored_results[:rerank_limit] - log_buffer.append(f" [5] Truncated to top {len(top_scored)} results") - - # Step 6: Token budget filtering - step_start = time.time() - - # Convert to dict for token filtering (backward compatibility) - top_dicts = [sr.to_dict() for sr in top_scored] - filtered_dicts, total_tokens = self._filter_by_token_budget(top_dicts, max_tokens) - - # Convert back to list of IDs and filter scored_results - filtered_ids = {d["id"] for d in filtered_dicts} - top_scored = [sr for sr in top_scored if sr.id in filtered_ids] - - step_duration = time.time() - step_start - log_buffer.append( - f" [6] Token filtering: {len(top_scored)} results, {total_tokens}/{max_tokens} tokens in {step_duration:.3f}s" - ) - - if tracer: - tracer.add_phase_metric( - "token_filtering", - step_duration, - {"results_selected": len(top_scored), "tokens_used": total_tokens, "max_tokens": max_tokens}, - ) - - # Record visits for all retrieved nodes - if tracer: - for sr in scored_results: - tracer.visit_node( - node_id=sr.id, - text=sr.retrieval.text, - context=sr.retrieval.context or "", - event_date=sr.retrieval.occurred_start, - access_count=sr.retrieval.access_count, - is_entry_point=(sr.id in [ep.node_id for ep in tracer.entry_points]), - parent_node_id=None, # In parallel retrieval, there's no clear parent - link_type=None, - link_weight=None, - activation=sr.candidate.rrf_score, # Use RRF score as activation - semantic_similarity=sr.retrieval.similarity or 0.0, - recency=sr.recency, - frequency=0.0, - final_weight=sr.weight, - ) - - # Step 8: Queue access count updates for visited nodes - visited_ids = list(set([sr.id for sr in scored_results[:50]])) # Top 50 - if visited_ids: - await self._task_backend.submit_task({"type": "access_count_update", "node_ids": visited_ids}) - log_buffer.append(f" [7] Queued access count updates for {len(visited_ids)} nodes") - - # Log fact_type distribution in results - fact_type_counts = {} - for sr in top_scored: - ft = sr.retrieval.fact_type - fact_type_counts[ft] = fact_type_counts.get(ft, 0) + 1 - - fact_type_summary = ", ".join([f"{ft}={count}" for ft, count in sorted(fact_type_counts.items())]) - - # Convert ScoredResult to dicts with ISO datetime strings - top_results_dicts = [] - for sr in top_scored: - result_dict = sr.to_dict() - # Convert datetime objects to ISO strings for JSON serialization - if result_dict.get("occurred_start"): - occurred_start = result_dict["occurred_start"] - result_dict["occurred_start"] = ( - occurred_start.isoformat() if hasattr(occurred_start, "isoformat") else occurred_start - ) - if result_dict.get("occurred_end"): - occurred_end = result_dict["occurred_end"] - result_dict["occurred_end"] = ( - occurred_end.isoformat() if hasattr(occurred_end, "isoformat") else occurred_end - ) - if result_dict.get("mentioned_at"): - mentioned_at = result_dict["mentioned_at"] - result_dict["mentioned_at"] = ( - mentioned_at.isoformat() if hasattr(mentioned_at, "isoformat") else mentioned_at - ) - top_results_dicts.append(result_dict) - - # Get entities for each fact if include_entities is requested - fact_entity_map = {} # unit_id -> list of (entity_id, entity_name) - if include_entities and top_scored: - unit_ids = [uuid.UUID(sr.id) for sr in top_scored] - if unit_ids: - async with acquire_with_retry(pool) as entity_conn: - entity_rows = await entity_conn.fetch( - f""" - SELECT ue.unit_id, e.id as entity_id, e.canonical_name - FROM {fq_table("unit_entities")} ue - JOIN {fq_table("entities")} e ON ue.entity_id = e.id - WHERE ue.unit_id = ANY($1::uuid[]) - """, - unit_ids, - ) - for row in entity_rows: - unit_id = str(row["unit_id"]) - if unit_id not in fact_entity_map: - fact_entity_map[unit_id] = [] - fact_entity_map[unit_id].append( - {"entity_id": str(row["entity_id"]), "canonical_name": row["canonical_name"]} - ) - - # Convert results to MemoryFact objects - memory_facts = [] - for result_dict in top_results_dicts: - result_id = str(result_dict.get("id")) - # Get entity names for this fact - entity_names = None - if include_entities and result_id in fact_entity_map: - entity_names = [e["canonical_name"] for e in fact_entity_map[result_id]] - - memory_facts.append( - MemoryFact( - id=result_id, - text=result_dict.get("text"), - fact_type=result_dict.get("fact_type", "world"), - entities=entity_names, - context=result_dict.get("context"), - occurred_start=result_dict.get("occurred_start"), - occurred_end=result_dict.get("occurred_end"), - mentioned_at=result_dict.get("mentioned_at"), - document_id=result_dict.get("document_id"), - chunk_id=result_dict.get("chunk_id"), - ) - ) - - # Fetch entity observations if requested - entities_dict = None - total_entity_tokens = 0 - total_chunk_tokens = 0 - if include_entities and fact_entity_map: - # Collect unique entities in order of fact relevance (preserving order from top_scored) - # Use a list to maintain order, but track seen entities to avoid duplicates - entities_ordered = [] # list of (entity_id, entity_name) tuples - seen_entity_ids = set() - - # Iterate through facts in relevance order - for sr in top_scored: - unit_id = sr.id - if unit_id in fact_entity_map: - for entity in fact_entity_map[unit_id]: - entity_id = entity["entity_id"] - entity_name = entity["canonical_name"] - if entity_id not in seen_entity_ids: - entities_ordered.append((entity_id, entity_name)) - seen_entity_ids.add(entity_id) - - # Fetch observations for each entity (respect token budget, in order) - entities_dict = {} - encoding = _get_tiktoken_encoding() - - for entity_id, entity_name in entities_ordered: - if total_entity_tokens >= max_entity_tokens: - break - - observations = await self.get_entity_observations( - bank_id, entity_id, limit=5, request_context=request_context - ) - - # Calculate tokens for this entity's observations - entity_tokens = 0 - included_observations = [] - for obs in observations: - obs_tokens = len(encoding.encode(obs.text)) - if total_entity_tokens + entity_tokens + obs_tokens <= max_entity_tokens: - included_observations.append(obs) - entity_tokens += obs_tokens - else: - break - - if included_observations: - entities_dict[entity_name] = EntityState( - entity_id=entity_id, canonical_name=entity_name, observations=included_observations - ) - total_entity_tokens += entity_tokens - - # Fetch chunks if requested - chunks_dict = None - if include_chunks and top_scored: - from .response_models import ChunkInfo - - # Collect chunk_ids in order of fact relevance (preserving order from top_scored) - # Use a list to maintain order, but track seen chunks to avoid duplicates - chunk_ids_ordered = [] - seen_chunk_ids = set() - for sr in top_scored: - chunk_id = sr.retrieval.chunk_id - if chunk_id and chunk_id not in seen_chunk_ids: - chunk_ids_ordered.append(chunk_id) - seen_chunk_ids.add(chunk_id) - - if chunk_ids_ordered: - # Fetch chunk data from database using chunk_ids (no ORDER BY to preserve input order) - async with acquire_with_retry(pool) as conn: - chunks_rows = await conn.fetch( - f""" - SELECT chunk_id, chunk_text, chunk_index - FROM {fq_table("chunks")} - WHERE chunk_id = ANY($1::text[]) - """, - chunk_ids_ordered, - ) - - # Create a lookup dict for fast access - chunks_lookup = {row["chunk_id"]: row for row in chunks_rows} - - # Apply token limit and build chunks_dict in the order of chunk_ids_ordered - chunks_dict = {} - encoding = _get_tiktoken_encoding() - - for chunk_id in chunk_ids_ordered: - if chunk_id not in chunks_lookup: - continue - - row = chunks_lookup[chunk_id] - chunk_text = row["chunk_text"] - chunk_tokens = len(encoding.encode(chunk_text)) - - # Check if adding this chunk would exceed the limit - if total_chunk_tokens + chunk_tokens > max_chunk_tokens: - # Truncate the chunk to fit within the remaining budget - remaining_tokens = max_chunk_tokens - total_chunk_tokens - if remaining_tokens > 0: - # Truncate to remaining tokens - truncated_text = encoding.decode(encoding.encode(chunk_text)[:remaining_tokens]) - chunks_dict[chunk_id] = ChunkInfo( - chunk_text=truncated_text, chunk_index=row["chunk_index"], truncated=True - ) - total_chunk_tokens = max_chunk_tokens - # Stop adding more chunks once we hit the limit - break - else: - chunks_dict[chunk_id] = ChunkInfo( - chunk_text=chunk_text, chunk_index=row["chunk_index"], truncated=False - ) - total_chunk_tokens += chunk_tokens - - # Finalize trace if enabled - trace_dict = None - if tracer: - trace = tracer.finalize(top_results_dicts) - trace_dict = trace.to_dict() if trace else None - - # Log final recall stats - total_time = time.time() - recall_start - num_chunks = len(chunks_dict) if chunks_dict else 0 - num_entities = len(entities_dict) if entities_dict else 0 - log_buffer.append( - f"[RECALL {recall_id}] Complete: {len(top_scored)} facts ({total_tokens} tok), {num_chunks} chunks ({total_chunk_tokens} tok), {num_entities} entities ({total_entity_tokens} tok) | {fact_type_summary} | {total_time:.3f}s" - ) - logger.info("\n" + "\n".join(log_buffer)) - - return RecallResultModel(results=memory_facts, trace=trace_dict, entities=entities_dict, chunks=chunks_dict) - - except Exception as e: - log_buffer.append(f"[RECALL {recall_id}] ERROR after {time.time() - recall_start:.3f}s: {str(e)}") - logger.error("\n" + "\n".join(log_buffer)) - raise Exception(f"Failed to search memories: {str(e)}") - - def _filter_by_token_budget( - self, results: list[dict[str, Any]], max_tokens: int - ) -> tuple[list[dict[str, Any]], int]: - """ - Filter results to fit within token budget. - - Counts tokens only for the 'text' field using tiktoken (cl100k_base encoding). - Stops before including a fact that would exceed the budget. - - Args: - results: List of search results - max_tokens: Maximum tokens allowed - - Returns: - Tuple of (filtered_results, total_tokens_used) - """ - encoding = _get_tiktoken_encoding() - - filtered_results = [] - total_tokens = 0 - - for result in results: - text = result.get("text", "") - text_tokens = len(encoding.encode(text)) - - # Check if adding this result would exceed budget - if total_tokens + text_tokens <= max_tokens: - filtered_results.append(result) - total_tokens += text_tokens - else: - # Stop before including a fact that would exceed limit - break - - return filtered_results, total_tokens - - async def get_document( - self, - document_id: str, - bank_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, Any] | None: - """ - Retrieve document metadata and statistics. - - Args: - document_id: Document ID to retrieve - bank_id: bank ID that owns the document - request_context: Request context for authentication. - - Returns: - Dictionary with document info or None if not found - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - async with acquire_with_retry(pool) as conn: - doc = await conn.fetchrow( - f""" - SELECT d.id, d.bank_id, d.original_text, d.content_hash, - d.created_at, d.updated_at, COUNT(mu.id) as unit_count - FROM {fq_table("documents")} d - LEFT JOIN {fq_table("memory_units")} mu ON mu.document_id = d.id - WHERE d.id = $1 AND d.bank_id = $2 - GROUP BY d.id, d.bank_id, d.original_text, d.content_hash, d.created_at, d.updated_at - """, - document_id, - bank_id, - ) - - if not doc: - return None - - return { - "id": doc["id"], - "bank_id": doc["bank_id"], - "original_text": doc["original_text"], - "content_hash": doc["content_hash"], - "memory_unit_count": doc["unit_count"], - "created_at": doc["created_at"].isoformat() if doc["created_at"] else None, - "updated_at": doc["updated_at"].isoformat() if doc["updated_at"] else None, - } - - async def delete_document( - self, - document_id: str, - bank_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, int]: - """ - Delete a document and all its associated memory units and links. - - Args: - document_id: Document ID to delete - bank_id: bank ID that owns the document - request_context: Request context for authentication. - - Returns: - Dictionary with counts of deleted items - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - async with acquire_with_retry(pool) as conn: - async with conn.transaction(): - # Count units before deletion - units_count = await conn.fetchval( - f"SELECT COUNT(*) FROM {fq_table('memory_units')} WHERE document_id = $1", document_id - ) - - # Delete document (cascades to memory_units and all their links) - deleted = await conn.fetchval( - f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING id", - document_id, - bank_id, - ) - - return {"document_deleted": 1 if deleted else 0, "memory_units_deleted": units_count if deleted else 0} - - async def delete_memory_unit( - self, - unit_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - Delete a single memory unit and all its associated links. - - Due to CASCADE DELETE constraints, this will automatically delete: - - All links from this unit (memory_links where from_unit_id = unit_id) - - All links to this unit (memory_links where to_unit_id = unit_id) - - All entity associations (unit_entities where unit_id = unit_id) - - Args: - unit_id: UUID of the memory unit to delete - request_context: Request context for authentication. - - Returns: - Dictionary with deletion result - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - async with acquire_with_retry(pool) as conn: - async with conn.transaction(): - # Delete the memory unit (cascades to links and associations) - deleted = await conn.fetchval( - f"DELETE FROM {fq_table('memory_units')} WHERE id = $1 RETURNING id", unit_id - ) - - return { - "success": deleted is not None, - "unit_id": str(deleted) if deleted else None, - "message": "Memory unit and all its links deleted successfully" - if deleted - else "Memory unit not found", - } - - async def delete_bank( - self, - bank_id: str, - fact_type: str | None = None, - *, - request_context: "RequestContext", - ) -> dict[str, int]: - """ - Delete all data for a specific agent (multi-tenant cleanup). - - This is much more efficient than dropping all tables and allows - multiple agents to coexist in the same database. - - Deletes (with CASCADE): - - All memory units for this bank (optionally filtered by fact_type) - - All entities for this bank (if deleting all memory units) - - All associated links, unit-entity associations, and co-occurrences - - Args: - bank_id: bank ID to delete - fact_type: Optional fact type filter (world, experience, opinion). If provided, only deletes memories of that type. - request_context: Request context for authentication. - - Returns: - Dictionary with counts of deleted items - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - async with acquire_with_retry(pool) as conn: - # Ensure connection is not in read-only mode (can happen with connection poolers) - await conn.execute("SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE") - async with conn.transaction(): - try: - if fact_type: - # Delete only memories of a specific fact type - units_count = await conn.fetchval( - f"SELECT COUNT(*) FROM {fq_table('memory_units')} WHERE bank_id = $1 AND fact_type = $2", - bank_id, - fact_type, - ) - await conn.execute( - f"DELETE FROM {fq_table('memory_units')} WHERE bank_id = $1 AND fact_type = $2", - bank_id, - fact_type, - ) - - # Note: We don't delete entities when fact_type is specified, - # as they may be referenced by other memory units - return {"memory_units_deleted": units_count, "entities_deleted": 0} - else: - # Delete all data for the bank - units_count = await conn.fetchval( - f"SELECT COUNT(*) FROM {fq_table('memory_units')} WHERE bank_id = $1", bank_id - ) - entities_count = await conn.fetchval( - f"SELECT COUNT(*) FROM {fq_table('entities')} WHERE bank_id = $1", bank_id - ) - documents_count = await conn.fetchval( - f"SELECT COUNT(*) FROM {fq_table('documents')} WHERE bank_id = $1", bank_id - ) - - # Delete documents (cascades to chunks) - await conn.execute(f"DELETE FROM {fq_table('documents')} WHERE bank_id = $1", bank_id) - - # 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) - - # 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) - - # Delete the bank profile itself - await conn.execute(f"DELETE FROM {fq_table('banks')} WHERE bank_id = $1", bank_id) - - return { - "memory_units_deleted": units_count, - "entities_deleted": entities_count, - "documents_deleted": documents_count, - "bank_deleted": True, - } - - except Exception as e: - raise Exception(f"Failed to delete agent data: {str(e)}") - - async def get_graph_data( - self, - bank_id: str | None = None, - fact_type: str | None = None, - *, - request_context: "RequestContext", - ): - """ - Get graph data for visualization. - - Args: - bank_id: Filter by bank ID - fact_type: Filter by fact type (world, experience, opinion) - request_context: Request context for authentication. - - Returns: - Dict with nodes, edges, and table_rows - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - async with acquire_with_retry(pool) as conn: - # Get memory units, optionally filtered by bank_id and fact_type - query_conditions = [] - query_params = [] - param_count = 0 - - if bank_id: - param_count += 1 - query_conditions.append(f"bank_id = ${param_count}") - query_params.append(bank_id) - - if fact_type: - param_count += 1 - query_conditions.append(f"fact_type = ${param_count}") - query_params.append(fact_type) - - where_clause = "WHERE " + " AND ".join(query_conditions) if query_conditions else "" - - units = await conn.fetch( - f""" - SELECT id, text, event_date, context, occurred_start, occurred_end, mentioned_at, document_id, chunk_id, fact_type - FROM {fq_table("memory_units")} - {where_clause} - ORDER BY mentioned_at DESC NULLS LAST, event_date DESC - LIMIT 1000 - """, - *query_params, - ) - - # Get links, filtering to only include links between units of the selected agent - # Use DISTINCT ON with LEAST/GREATEST to deduplicate bidirectional links - unit_ids = [row["id"] for row in units] - if unit_ids: - links = await conn.fetch( - f""" - SELECT DISTINCT ON (LEAST(ml.from_unit_id, ml.to_unit_id), GREATEST(ml.from_unit_id, ml.to_unit_id), ml.link_type, COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) - ml.from_unit_id, - ml.to_unit_id, - ml.link_type, - ml.weight, - e.canonical_name as entity_name - FROM {fq_table("memory_links")} ml - LEFT JOIN {fq_table("entities")} e ON ml.entity_id = e.id - WHERE ml.from_unit_id = ANY($1::uuid[]) AND ml.to_unit_id = ANY($1::uuid[]) - ORDER BY LEAST(ml.from_unit_id, ml.to_unit_id), GREATEST(ml.from_unit_id, ml.to_unit_id), ml.link_type, COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid), ml.weight DESC - """, - unit_ids, - ) - else: - links = [] - - # Get entity information - unit_entities = await conn.fetch(f""" - SELECT ue.unit_id, e.canonical_name - FROM {fq_table("unit_entities")} ue - JOIN {fq_table("entities")} e ON ue.entity_id = e.id - ORDER BY ue.unit_id - """) - - # Build entity mapping - entity_map = {} - for row in unit_entities: - unit_id = row["unit_id"] - entity_name = row["canonical_name"] - if unit_id not in entity_map: - entity_map[unit_id] = [] - entity_map[unit_id].append(entity_name) - - # Build nodes - nodes = [] - for row in units: - unit_id = row["id"] - text = row["text"] - event_date = row["event_date"] - context = row["context"] - - entities = entity_map.get(unit_id, []) - entity_count = len(entities) - - # Color by entity count - if entity_count == 0: - color = "#e0e0e0" - elif entity_count == 1: - color = "#90caf9" - else: - color = "#42a5f5" - - nodes.append( - { - "data": { - "id": str(unit_id), - "label": f"{text[:30]}..." if len(text) > 30 else text, - "text": text, - "date": event_date.isoformat() if event_date else "", - "context": context if context else "", - "entities": ", ".join(entities) if entities else "None", - "color": color, - } - } - ) - - # Build edges - edges = [] - for row in links: - from_id = str(row["from_unit_id"]) - to_id = str(row["to_unit_id"]) - link_type = row["link_type"] - weight = row["weight"] - entity_name = row["entity_name"] - - # Color by link type - if link_type == "temporal": - color = "#00bcd4" - line_style = "dashed" - elif link_type == "semantic": - color = "#ff69b4" - line_style = "solid" - elif link_type == "entity": - color = "#ffd700" - line_style = "solid" - else: - color = "#999999" - line_style = "solid" - - edges.append( - { - "data": { - "id": f"{from_id}-{to_id}-{link_type}", - "source": from_id, - "target": to_id, - "linkType": link_type, - "weight": weight, - "entityName": entity_name if entity_name else "", - "color": color, - "lineStyle": line_style, - } - } - ) - - # Build table rows - table_rows = [] - for row in units: - unit_id = row["id"] - entities = entity_map.get(unit_id, []) - - table_rows.append( - { - "id": str(unit_id), - "text": row["text"], - "context": row["context"] if row["context"] else "N/A", - "occurred_start": row["occurred_start"].isoformat() if row["occurred_start"] else None, - "occurred_end": row["occurred_end"].isoformat() if row["occurred_end"] else None, - "mentioned_at": row["mentioned_at"].isoformat() if row["mentioned_at"] else None, - "date": row["event_date"].strftime("%Y-%m-%d %H:%M") - if row["event_date"] - else "N/A", # Deprecated, kept for backwards compatibility - "entities": ", ".join(entities) if entities else "None", - "document_id": row["document_id"], - "chunk_id": row["chunk_id"] if row["chunk_id"] else None, - "fact_type": row["fact_type"], - } - ) - - return {"nodes": nodes, "edges": edges, "table_rows": table_rows, "total_units": len(units)} - - async def list_memory_units( - self, - bank_id: str, - *, - fact_type: str | None = None, - search_query: str | None = None, - limit: int = 100, - offset: int = 0, - request_context: "RequestContext", - ): - """ - List memory units for table view with optional full-text search. - - Args: - bank_id: Filter by bank ID - fact_type: Filter by fact type (world, experience, opinion) - search_query: Full-text search query (searches text and context fields) - limit: Maximum number of results to return - offset: Offset for pagination - request_context: Request context for authentication. - - Returns: - Dict with items (list of memory units) and total count - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - async with acquire_with_retry(pool) as conn: - # Build query conditions - query_conditions = [] - query_params = [] - param_count = 0 - - if bank_id: - param_count += 1 - query_conditions.append(f"bank_id = ${param_count}") - query_params.append(bank_id) - - if fact_type: - param_count += 1 - query_conditions.append(f"fact_type = ${param_count}") - query_params.append(fact_type) - - if search_query: - # Full-text search on text and context fields using ILIKE - param_count += 1 - query_conditions.append(f"(text ILIKE ${param_count} OR context ILIKE ${param_count})") - query_params.append(f"%{search_query}%") - - where_clause = "WHERE " + " AND ".join(query_conditions) if query_conditions else "" - - # Get total count - count_query = f""" - SELECT COUNT(*) as total - FROM {fq_table("memory_units")} - {where_clause} - """ - count_result = await conn.fetchrow(count_query, *query_params) - total = count_result["total"] - - # Get units with limit and offset - param_count += 1 - limit_param = f"${param_count}" - query_params.append(limit) - - param_count += 1 - offset_param = f"${param_count}" - query_params.append(offset) - - units = await conn.fetch( - f""" - SELECT id, text, event_date, context, fact_type, mentioned_at, occurred_start, occurred_end, chunk_id - FROM {fq_table("memory_units")} - {where_clause} - ORDER BY mentioned_at DESC NULLS LAST, created_at DESC - LIMIT {limit_param} OFFSET {offset_param} - """, - *query_params, - ) - - # Get entity information for these units - if units: - unit_ids = [row["id"] for row in units] - unit_entities = await conn.fetch( - f""" - SELECT ue.unit_id, e.canonical_name - FROM {fq_table("unit_entities")} ue - JOIN {fq_table("entities")} e ON ue.entity_id = e.id - WHERE ue.unit_id = ANY($1::uuid[]) - ORDER BY ue.unit_id - """, - unit_ids, - ) - else: - unit_entities = [] - - # Build entity mapping - entity_map = {} - for row in unit_entities: - unit_id = row["unit_id"] - entity_name = row["canonical_name"] - if unit_id not in entity_map: - entity_map[unit_id] = [] - entity_map[unit_id].append(entity_name) - - # Build result items - items = [] - for row in units: - unit_id = row["id"] - entities = entity_map.get(unit_id, []) - - items.append( - { - "id": str(unit_id), - "text": row["text"], - "context": row["context"] if row["context"] else "", - "date": row["event_date"].isoformat() if row["event_date"] else "", - "fact_type": row["fact_type"], - "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, - "entities": ", ".join(entities) if entities else "", - "chunk_id": row["chunk_id"] if row["chunk_id"] else None, - } - ) - - return {"items": items, "total": total, "limit": limit, "offset": offset} - - async def list_documents( - self, - bank_id: str, - *, - search_query: str | None = None, - limit: int = 100, - offset: int = 0, - request_context: "RequestContext", - ): - """ - List documents with optional search and pagination. - - Args: - bank_id: bank ID (required) - search_query: Search in document ID - limit: Maximum number of results - offset: Offset for pagination - request_context: Request context for authentication. - - Returns: - Dict with items (list of documents without original_text) and total count - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - async with acquire_with_retry(pool) as conn: - # Build query conditions - query_conditions = [] - query_params = [] - param_count = 0 - - param_count += 1 - query_conditions.append(f"bank_id = ${param_count}") - query_params.append(bank_id) - - if search_query: - # Search in document ID - param_count += 1 - query_conditions.append(f"id ILIKE ${param_count}") - query_params.append(f"%{search_query}%") - - where_clause = "WHERE " + " AND ".join(query_conditions) if query_conditions else "" - - # Get total count - count_query = f""" - SELECT COUNT(*) as total - FROM {fq_table("documents")} - {where_clause} - """ - count_result = await conn.fetchrow(count_query, *query_params) - total = count_result["total"] - - # Get documents with limit and offset (without original_text for performance) - param_count += 1 - limit_param = f"${param_count}" - query_params.append(limit) - - param_count += 1 - offset_param = f"${param_count}" - query_params.append(offset) - - documents = await conn.fetch( - f""" - SELECT - id, - bank_id, - content_hash, - created_at, - updated_at, - LENGTH(original_text) as text_length, - retain_params - FROM {fq_table("documents")} - {where_clause} - ORDER BY created_at DESC - LIMIT {limit_param} OFFSET {offset_param} - """, - *query_params, - ) - - # Get memory unit count for each document - if documents: - doc_ids = [(row["id"], row["bank_id"]) for row in documents] - - # Create placeholders for the query - placeholders = [] - params_for_count = [] - for i, (doc_id, bank_id_val) in enumerate(doc_ids): - idx_doc = i * 2 + 1 - idx_agent = i * 2 + 2 - placeholders.append(f"(document_id = ${idx_doc} AND bank_id = ${idx_agent})") - params_for_count.extend([doc_id, bank_id_val]) - - where_clause_count = " OR ".join(placeholders) - - unit_counts = await conn.fetch( - f""" - SELECT document_id, bank_id, COUNT(*) as unit_count - FROM {fq_table("memory_units")} - WHERE {where_clause_count} - GROUP BY document_id, bank_id - """, - *params_for_count, - ) - else: - unit_counts = [] - - # Build count mapping - count_map = {(row["document_id"], row["bank_id"]): row["unit_count"] for row in unit_counts} - - # Build result items - items = [] - for row in documents: - doc_id = row["id"] - bank_id_val = row["bank_id"] - unit_count = count_map.get((doc_id, bank_id_val), 0) - - items.append( - { - "id": doc_id, - "bank_id": bank_id_val, - "content_hash": row["content_hash"], - "created_at": row["created_at"].isoformat() if row["created_at"] else "", - "updated_at": row["updated_at"].isoformat() if row["updated_at"] else "", - "text_length": row["text_length"] or 0, - "memory_unit_count": unit_count, - "retain_params": row["retain_params"] if row["retain_params"] else None, - } - ) - - return {"items": items, "total": total, "limit": limit, "offset": offset} - - async def get_chunk( - self, - chunk_id: str, - *, - request_context: "RequestContext", - ): - """ - Get a specific chunk by its ID. - - Args: - chunk_id: Chunk ID (format: bank_id_document_id_chunk_index) - request_context: Request context for authentication. - - Returns: - Dict with chunk details including chunk_text, or None if not found - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - async with acquire_with_retry(pool) as conn: - chunk = await conn.fetchrow( - f""" - SELECT - chunk_id, - document_id, - bank_id, - chunk_index, - chunk_text, - created_at - FROM {fq_table("chunks")} - WHERE chunk_id = $1 - """, - chunk_id, - ) - - if not chunk: - return None - - return { - "chunk_id": chunk["chunk_id"], - "document_id": chunk["document_id"], - "bank_id": chunk["bank_id"], - "chunk_index": chunk["chunk_index"], - "chunk_text": chunk["chunk_text"], - "created_at": chunk["created_at"].isoformat() if chunk["created_at"] else "", - } - - async def _evaluate_opinion_update_async( - self, - opinion_text: str, - opinion_confidence: float, - new_event_text: str, - entity_name: str, - ) -> dict[str, Any] | None: - """ - Evaluate if an opinion should be updated based on a new event. - - Args: - opinion_text: Current opinion text (includes reasons) - opinion_confidence: Current confidence score (0.0-1.0) - new_event_text: Text of the new event - entity_name: Name of the entity this opinion is about - - Returns: - Dict with 'action' ('keep'|'update'), 'new_confidence', 'new_text' (if action=='update') - or None if no changes needed - """ - - class OpinionEvaluation(BaseModel): - """Evaluation of whether an opinion should be updated.""" - - action: str = Field(description="Action to take: 'keep' (no change) or 'update' (modify opinion)") - reasoning: str = Field(description="Brief explanation of why this action was chosen") - new_confidence: float = Field( - description="New confidence score (0.0-1.0). Can be higher, lower, or same as before." - ) - new_opinion_text: str | None = Field( - default=None, - description="If action is 'update', the revised opinion text that acknowledges the previous view. Otherwise None.", - ) - - evaluation_prompt = f"""You are evaluating whether an existing opinion should be updated based on new information. - -ENTITY: {entity_name} - -EXISTING OPINION: -{opinion_text} -Current confidence: {opinion_confidence:.2f} - -NEW EVENT: -{new_event_text} - -Evaluate whether this new event: -1. REINFORCES the opinion (increase confidence, keep text) -2. WEAKENS the opinion (decrease confidence, keep text) -3. CHANGES the opinion (update both text and confidence, noting "Previously I thought X, but now Y...") -4. IRRELEVANT (keep everything as is) - -Guidelines: -- Only suggest 'update' action if the new event genuinely contradicts or significantly modifies the opinion -- If updating the text, acknowledge the previous opinion and explain the change -- Confidence should reflect accumulated evidence (0.0 = no confidence, 1.0 = very confident) -- Small changes in confidence are normal; large jumps should be rare""" - - try: - result = await self._llm_config.call( - messages=[ - {"role": "system", "content": "You evaluate and update opinions based on new information."}, - {"role": "user", "content": evaluation_prompt}, - ], - response_format=OpinionEvaluation, - scope="memory_evaluate_opinion", - temperature=0.3, # Lower temperature for more consistent evaluation - ) - - # Only return updates if something actually changed - if result.action == "keep" and abs(result.new_confidence - opinion_confidence) < 0.01: - return None - - return { - "action": result.action, - "reasoning": result.reasoning, - "new_confidence": result.new_confidence, - "new_text": result.new_opinion_text if result.action == "update" else None, - } - - except Exception as e: - logger.warning(f"Failed to evaluate opinion update: {str(e)}") - return None - - async def _handle_form_opinion(self, task_dict: dict[str, Any]): - """ - Handler for form opinion tasks. - - Args: - task_dict: Dict with keys: 'bank_id', 'answer_text', 'query' - """ - bank_id = task_dict["bank_id"] - answer_text = task_dict["answer_text"] - query = task_dict["query"] - - await self._extract_and_store_opinions_async(bank_id=bank_id, answer_text=answer_text, query=query) - - async def _handle_reinforce_opinion(self, task_dict: dict[str, Any]): - """ - Handler for reinforce opinion tasks. - - Args: - task_dict: Dict with keys: 'bank_id', 'created_unit_ids', 'unit_texts', 'unit_entities' - """ - bank_id = task_dict["bank_id"] - created_unit_ids = task_dict["created_unit_ids"] - unit_texts = task_dict["unit_texts"] - unit_entities = task_dict["unit_entities"] - - await self._reinforce_opinions_async( - bank_id=bank_id, created_unit_ids=created_unit_ids, unit_texts=unit_texts, unit_entities=unit_entities - ) - - async def _reinforce_opinions_async( - self, - bank_id: str, - created_unit_ids: list[str], - unit_texts: list[str], - unit_entities: list[list[dict[str, str]]], - ): - """ - Background task to reinforce opinions based on newly ingested events. - - This runs asynchronously and does not block the put operation. - - Args: - bank_id: bank ID - created_unit_ids: List of newly created memory unit IDs - unit_texts: Texts of the newly created units - unit_entities: Entities extracted from each unit - """ - try: - # Extract all unique entity names from the new units - entity_names = set() - for entities_list in unit_entities: - for entity in entities_list: - # Handle both Entity objects and dicts - if hasattr(entity, "text"): - entity_names.add(entity.text) - elif isinstance(entity, dict): - entity_names.add(entity["text"]) - - if not entity_names: - return - - pool = await self._get_pool() - async with acquire_with_retry(pool) as conn: - # Find all opinions related to these entities - opinions = await conn.fetch( - f""" - SELECT DISTINCT mu.id, mu.text, mu.confidence_score, e.canonical_name - FROM {fq_table("memory_units")} mu - JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id - JOIN {fq_table("entities")} e ON ue.entity_id = e.id - WHERE mu.bank_id = $1 - AND mu.fact_type = 'opinion' - AND e.canonical_name = ANY($2::text[]) - """, - bank_id, - list(entity_names), - ) - - if not opinions: - return - - # Use cached LLM config - if self._llm_config is None: - logger.error("[REINFORCE] LLM config not available, skipping opinion reinforcement") - return - - # Evaluate each opinion against the new events - updates_to_apply = [] - for opinion in opinions: - opinion_id = str(opinion["id"]) - opinion_text = opinion["text"] - opinion_confidence = opinion["confidence_score"] - entity_name = opinion["canonical_name"] - - # Find all new events mentioning this entity - relevant_events = [] - for unit_text, entities_list in zip(unit_texts, unit_entities): - if any(e["text"] == entity_name for e in entities_list): - relevant_events.append(unit_text) - - if not relevant_events: - continue - - # Combine all relevant events - combined_events = "\n".join(relevant_events) - - # Evaluate if opinion should be updated - evaluation = await self._evaluate_opinion_update_async( - opinion_text, opinion_confidence, combined_events, entity_name - ) - - if evaluation: - updates_to_apply.append({"opinion_id": opinion_id, "evaluation": evaluation}) - - # Apply all updates in a single transaction - if updates_to_apply: - async with conn.transaction(): - for update in updates_to_apply: - opinion_id = update["opinion_id"] - evaluation = update["evaluation"] - - if evaluation["action"] == "update" and evaluation["new_text"]: - # Update both text and confidence - await conn.execute( - f""" - UPDATE {fq_table("memory_units")} - SET text = $1, confidence_score = $2, updated_at = NOW() - WHERE id = $3 - """, - evaluation["new_text"], - evaluation["new_confidence"], - uuid.UUID(opinion_id), - ) - else: - # Only update confidence - await conn.execute( - f""" - UPDATE {fq_table("memory_units")} - SET confidence_score = $1, updated_at = NOW() - WHERE id = $2 - """, - evaluation["new_confidence"], - uuid.UUID(opinion_id), - ) - - else: - pass # No opinions to update - - except Exception as e: - logger.error(f"[REINFORCE] Error during opinion reinforcement: {str(e)}") - import traceback - - traceback.print_exc() - - # ==================== bank profile Methods ==================== - - async def get_bank_profile( - self, - bank_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - Get bank profile (name, disposition + background). - Auto-creates agent with default values if not exists. - - Args: - bank_id: bank IDentifier - request_context: Request context for authentication. - - Returns: - Dict with name, disposition traits, and background - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - profile = await bank_utils.get_bank_profile(pool, bank_id) - disposition = profile["disposition"] - return { - "bank_id": bank_id, - "name": profile["name"], - "disposition": disposition, - "background": profile["background"], - } - - async def update_bank_disposition( - self, - bank_id: str, - disposition: dict[str, int], - *, - request_context: "RequestContext", - ) -> None: - """ - Update bank disposition traits. - - Args: - bank_id: bank IDentifier - disposition: Dict with skepticism, literalism, empathy (all 1-5) - request_context: Request context for authentication. - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - await bank_utils.update_bank_disposition(pool, bank_id, disposition) - - async def merge_bank_background( - self, - bank_id: str, - new_info: str, - *, - update_disposition: bool = True, - request_context: "RequestContext", - ) -> dict[str, Any]: - """ - Merge new background information with existing background using LLM. - Normalizes to first person ("I") and resolves conflicts. - Optionally infers disposition traits from the merged background. - - Args: - bank_id: bank IDentifier - new_info: New background information to add/merge - update_disposition: If True, infer Big Five traits from background (default: True) - request_context: Request context for authentication. - - Returns: - Dict with 'background' (str) and optionally 'disposition' (dict) keys - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - return await bank_utils.merge_bank_background(pool, self._llm_config, bank_id, new_info, update_disposition) - - async def list_banks( - self, - *, - request_context: "RequestContext", - ) -> list[dict[str, Any]]: - """ - List all agents in the system. - - Args: - request_context: Request context for authentication. - - Returns: - List of dicts with bank_id, name, disposition, background, created_at, updated_at - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - return await bank_utils.list_banks(pool) - - # ==================== Reflect Methods ==================== - - async def reflect_async( - self, - bank_id: str, - query: str, - *, - budget: Budget | None = None, - context: str | None = None, - request_context: "RequestContext", - ) -> ReflectResult: - """ - Reflect and formulate an answer using bank identity, world facts, and opinions. - - This method: - 1. Retrieves experience (conversations and events) - 2. Retrieves world facts (general knowledge) - 3. Retrieves existing opinions (bank's formed perspectives) - 4. Uses LLM to formulate an answer - 5. Extracts and stores any new opinions formed during reflection - 6. Returns plain text answer and the facts used - - Args: - bank_id: bank identifier - query: Question to answer - budget: Budget level for memory exploration (low=100, mid=300, high=600 units) - context: Additional context string to include in LLM prompt (not used in recall) - - Returns: - ReflectResult containing: - - text: Plain text answer (no markdown) - - based_on: Dict with 'world', 'experience', and 'opinion' fact lists (MemoryFact objects) - - new_opinions: List of newly formed opinions - """ - # Use cached LLM config - if self._llm_config is None: - raise ValueError("Memory LLM API key not set. Set HINDSIGHT_API_LLM_API_KEY environment variable.") - - # Authenticate tenant and set schema in context (for fq_table()) - await self._authenticate_tenant(request_context) - - # Validate operation if validator is configured - if self._operation_validator: - from hindsight_api.extensions import ReflectContext - - ctx = ReflectContext( - bank_id=bank_id, - query=query, - request_context=request_context, - budget=budget, - context=context, - ) - await self._validate_operation(self._operation_validator.validate_reflect(ctx)) - - reflect_start = time.time() - reflect_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}" - log_buffer = [] - log_buffer.append(f"[REFLECT {reflect_id}] Query: '{query[:50]}...'") - - # Steps 1-3: Run multi-fact-type search (12-way retrieval: 4 methods × 3 fact types) - recall_start = time.time() - search_result = await self.recall_async( - bank_id=bank_id, - query=query, - budget=budget, - max_tokens=4096, - enable_trace=False, - fact_type=["experience", "world", "opinion"], - include_entities=True, - request_context=request_context, - ) - recall_time = time.time() - recall_start - - all_results = search_result.results - - # Split results by fact type for structured response - agent_results = [r for r in all_results if r.fact_type == "experience"] - world_results = [r for r in all_results if r.fact_type == "world"] - opinion_results = [r for r in all_results if r.fact_type == "opinion"] - - log_buffer.append( - f"[REFLECT {reflect_id}] Recall: {len(all_results)} facts (experience={len(agent_results)}, world={len(world_results)}, opinion={len(opinion_results)}) in {recall_time:.3f}s" - ) - - # Format facts for LLM - agent_facts_text = think_utils.format_facts_for_prompt(agent_results) - world_facts_text = think_utils.format_facts_for_prompt(world_results) - opinion_facts_text = think_utils.format_facts_for_prompt(opinion_results) - - # Get bank profile (name, disposition + background) - profile = await self.get_bank_profile(bank_id, request_context=request_context) - name = profile["name"] - disposition = profile["disposition"] # Typed as DispositionTraits - background = profile["background"] - - # Build the prompt - prompt = think_utils.build_think_prompt( - agent_facts_text=agent_facts_text, - world_facts_text=world_facts_text, - opinion_facts_text=opinion_facts_text, - query=query, - name=name, - disposition=disposition, - background=background, - context=context, - ) - - log_buffer.append(f"[REFLECT {reflect_id}] Prompt: {len(prompt)} chars") - - system_message = think_utils.get_system_message(disposition) - - llm_start = time.time() - answer_text = await self._llm_config.call( - messages=[{"role": "system", "content": system_message}, {"role": "user", "content": prompt}], - scope="memory_think", - temperature=0.9, - max_completion_tokens=1000, - ) - llm_time = time.time() - llm_start - - answer_text = answer_text.strip() - - # Submit form_opinion task for background processing - await self._task_backend.submit_task( - {"type": "form_opinion", "bank_id": bank_id, "answer_text": answer_text, "query": query} - ) - - total_time = time.time() - reflect_start - log_buffer.append( - f"[REFLECT {reflect_id}] Complete: {len(answer_text)} chars response, LLM {llm_time:.3f}s, total {total_time:.3f}s" - ) - logger.info("\n" + "\n".join(log_buffer)) - - # Return response with facts split by type - result = ReflectResult( - text=answer_text, - based_on={"world": world_results, "experience": agent_results, "opinion": opinion_results}, - new_opinions=[], # Opinions are being extracted asynchronously - ) - - # Call post-operation hook if validator is configured - if self._operation_validator: - from hindsight_api.extensions.operation_validator import ReflectResultContext - - result_ctx = ReflectResultContext( - bank_id=bank_id, - query=query, - request_context=request_context, - budget=budget, - context=context, - result=result, - success=True, - error=None, - ) - try: - await self._operation_validator.on_reflect_complete(result_ctx) - except Exception as e: - logger.warning(f"Post-reflect hook error (non-fatal): {e}") - - return result - - async def _extract_and_store_opinions_async(self, bank_id: str, answer_text: str, query: str): - """ - Background task to extract and store opinions from think response. - - This runs asynchronously and does not block the think response. - - Args: - bank_id: bank IDentifier - answer_text: The generated answer text - query: The original query - """ - try: - # Extract opinions from the answer - new_opinions = await think_utils.extract_opinions_from_text(self._llm_config, text=answer_text, query=query) - - # Store new opinions - if new_opinions: - from datetime import datetime - - current_time = datetime.now(UTC) - # Use internal request context for background tasks - from hindsight_api.models import RequestContext - - internal_context = RequestContext() - for opinion in new_opinions: - await self.retain_async( - bank_id=bank_id, - content=opinion.opinion, - context=f"formed during thinking about: {query}", - event_date=current_time, - fact_type_override="opinion", - confidence_score=opinion.confidence, - request_context=internal_context, - ) - - except Exception as e: - logger.warning(f"[REFLECT] Failed to extract/store opinions: {str(e)}") - - async def get_entity_observations( - self, - bank_id: str, - entity_id: str, - *, - limit: int = 10, - request_context: "RequestContext", - ) -> list[Any]: - """ - Get observations linked to an entity. - - Args: - bank_id: bank IDentifier - entity_id: Entity UUID to get observations for - limit: Maximum number of observations to return - request_context: Request context for authentication. - - Returns: - List of EntityObservation objects - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - async with acquire_with_retry(pool) as conn: - rows = await conn.fetch( - f""" - SELECT mu.text, mu.mentioned_at - FROM {fq_table("memory_units")} mu - JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id - WHERE mu.bank_id = $1 - AND mu.fact_type = 'observation' - AND ue.entity_id = $2 - ORDER BY mu.mentioned_at DESC - LIMIT $3 - """, - bank_id, - uuid.UUID(entity_id), - limit, - ) - - observations = [] - for row in rows: - mentioned_at = row["mentioned_at"].isoformat() if row["mentioned_at"] else None - observations.append(EntityObservation(text=row["text"], mentioned_at=mentioned_at)) - return observations - - async def list_entities( - self, - bank_id: str, - *, - limit: int = 100, - request_context: "RequestContext", - ) -> list[dict[str, Any]]: - """ - List all entities for a bank. - - Args: - bank_id: bank IDentifier - limit: Maximum number of entities to return - request_context: Request context for authentication. - - Returns: - List of entity dicts with id, canonical_name, mention_count, first_seen, last_seen - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - async with acquire_with_retry(pool) as conn: - rows = await conn.fetch( - f""" - SELECT id, canonical_name, mention_count, first_seen, last_seen, metadata - FROM {fq_table("entities")} - WHERE bank_id = $1 - ORDER BY mention_count DESC, last_seen DESC - LIMIT $2 - """, - bank_id, - limit, - ) - - entities = [] - for row in rows: - # Handle metadata - may be dict, JSON string, or None - metadata = row["metadata"] - if metadata is None: - metadata = {} - elif isinstance(metadata, str): - import json - - try: - metadata = json.loads(metadata) - except json.JSONDecodeError: - metadata = {} - - entities.append( - { - "id": str(row["id"]), - "canonical_name": row["canonical_name"], - "mention_count": row["mention_count"], - "first_seen": row["first_seen"].isoformat() if row["first_seen"] else None, - "last_seen": row["last_seen"].isoformat() if row["last_seen"] else None, - "metadata": metadata, - } - ) - return entities - - async def get_entity_state( - self, - bank_id: str, - entity_id: str, - entity_name: str, - *, - limit: int = 10, - request_context: "RequestContext", - ) -> EntityState: - """ - Get the current state (mental model) of an entity. - - Args: - bank_id: bank IDentifier - entity_id: Entity UUID - entity_name: Canonical name of the entity - limit: Maximum number of observations to include - request_context: Request context for authentication. - - Returns: - EntityState with observations - """ - observations = await self.get_entity_observations( - bank_id, entity_id, limit=limit, request_context=request_context - ) - return EntityState(entity_id=entity_id, canonical_name=entity_name, observations=observations) - - async def regenerate_entity_observations( - self, - bank_id: str, - entity_id: str, - entity_name: str, - *, - version: str | None = None, - conn=None, - request_context: "RequestContext", - ) -> None: - """ - Regenerate observations for an entity by: - 1. Checking version for deduplication (if provided) - 2. Searching all facts mentioning the entity - 3. Using LLM to synthesize observations (no personality) - 4. Deleting old observations for this entity - 5. Storing new observations linked to the entity - - Args: - bank_id: bank IDentifier - entity_id: Entity UUID - entity_name: Canonical name of the entity - version: Entity's last_seen timestamp when task was created (for deduplication) - conn: Optional database connection (for transactional atomicity with caller) - request_context: Request context for authentication. - """ - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - entity_uuid = uuid.UUID(entity_id) - - # Helper to run a query with provided conn or acquire one - async def fetch_with_conn(query, *args): - if conn is not None: - return await conn.fetch(query, *args) - else: - async with acquire_with_retry(pool) as acquired_conn: - return await acquired_conn.fetch(query, *args) - - async def fetchval_with_conn(query, *args): - if conn is not None: - return await conn.fetchval(query, *args) - else: - async with acquire_with_retry(pool) as acquired_conn: - return await acquired_conn.fetchval(query, *args) - - # Step 1: Check version for deduplication - if version: - current_last_seen = await fetchval_with_conn( - f""" - SELECT last_seen - FROM {fq_table("entities")} - WHERE id = $1 AND bank_id = $2 - """, - entity_uuid, - bank_id, - ) - - if current_last_seen and current_last_seen.isoformat() != version: - return [] - - # Step 2: Get all facts mentioning this entity (exclude observations themselves) - rows = await fetch_with_conn( - f""" - SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.fact_type - FROM {fq_table("memory_units")} mu - JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id - WHERE mu.bank_id = $1 - AND ue.entity_id = $2 - AND mu.fact_type IN ('world', 'experience') - ORDER BY mu.occurred_start DESC - LIMIT 50 - """, - bank_id, - entity_uuid, - ) - - if not rows: - return [] - - # Convert to MemoryFact objects for the observation extraction - facts = [] - for row in rows: - occurred_start = row["occurred_start"].isoformat() if row["occurred_start"] else None - facts.append( - MemoryFact( - id=str(row["id"]), - text=row["text"], - fact_type=row["fact_type"], - context=row["context"], - occurred_start=occurred_start, - ) - ) - - # Step 3: Extract observations using LLM (no personality) - observations = await observation_utils.extract_observations_from_facts(self._llm_config, entity_name, facts) - - if not observations: - return [] - - # Step 4: Delete old observations and insert new ones - # If conn provided, we're already in a transaction - don't start another - # If conn is None, acquire one and start a transaction - async def do_db_operations(db_conn): - # Delete old observations for this entity - await db_conn.execute( - f""" - DELETE FROM {fq_table("memory_units")} - WHERE id IN ( - SELECT mu.id - FROM {fq_table("memory_units")} mu - JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id - WHERE mu.bank_id = $1 - AND mu.fact_type = 'observation' - AND ue.entity_id = $2 - ) - """, - bank_id, - entity_uuid, - ) - - # Generate embeddings for new observations - embeddings = await embedding_utils.generate_embeddings_batch(self.embeddings, observations) - - # Insert new observations - current_time = utcnow() - created_ids = [] - - for obs_text, embedding in zip(observations, embeddings): - result = await db_conn.fetchrow( - f""" - INSERT INTO {fq_table("memory_units")} ( - bank_id, text, embedding, context, event_date, - occurred_start, occurred_end, mentioned_at, - fact_type, access_count - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'observation', 0) - RETURNING id - """, - bank_id, - obs_text, - str(embedding), - f"observation about {entity_name}", - current_time, - current_time, - current_time, - current_time, - ) - obs_id = str(result["id"]) - created_ids.append(obs_id) - - # Link observation to entity - await db_conn.execute( - f""" - INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id) - VALUES ($1, $2) - """, - uuid.UUID(obs_id), - entity_uuid, - ) - - return created_ids - - if conn is not None: - # Use provided connection (already in a transaction) - return await do_db_operations(conn) - else: - # Acquire connection and start our own transaction - async with acquire_with_retry(pool) as acquired_conn: - async with acquired_conn.transaction(): - return await do_db_operations(acquired_conn) - - async def _regenerate_observations_sync( - self, - bank_id: str, - entity_ids: list[str], - min_facts: int = 5, - conn=None, - request_context: "RequestContext | None" = None, - ) -> None: - """ - Regenerate observations for entities synchronously (called during retain). - - Processes entities in PARALLEL for faster execution. - - Args: - bank_id: Bank identifier - entity_ids: List of entity IDs to process - min_facts: Minimum facts required to regenerate observations - conn: Optional database connection (for transactional atomicity) - """ - if not bank_id or not entity_ids: - return - - # Convert to UUIDs - entity_uuids = [uuid.UUID(eid) if isinstance(eid, str) else eid for eid in entity_ids] - - # Use provided connection or acquire a new one - if conn is not None: - # Use the provided connection (transactional with caller) - entity_rows = await conn.fetch( - f""" - SELECT id, canonical_name FROM {fq_table("entities")} - WHERE id = ANY($1) AND bank_id = $2 - """, - entity_uuids, - bank_id, - ) - entity_names = {row["id"]: row["canonical_name"] for row in entity_rows} - - fact_counts = await conn.fetch( - f""" - SELECT ue.entity_id, COUNT(*) as cnt - FROM {fq_table("unit_entities")} ue - JOIN {fq_table("memory_units")} mu ON ue.unit_id = mu.id - WHERE ue.entity_id = ANY($1) AND mu.bank_id = $2 - GROUP BY ue.entity_id - """, - entity_uuids, - bank_id, - ) - entity_fact_counts = {row["entity_id"]: row["cnt"] for row in fact_counts} - else: - # Acquire a new connection (standalone call) - pool = await self._get_pool() - async with pool.acquire() as acquired_conn: - entity_rows = await acquired_conn.fetch( - f""" - SELECT id, canonical_name FROM {fq_table("entities")} - WHERE id = ANY($1) AND bank_id = $2 - """, - entity_uuids, - bank_id, - ) - entity_names = {row["id"]: row["canonical_name"] for row in entity_rows} - - fact_counts = await acquired_conn.fetch( - f""" - SELECT ue.entity_id, COUNT(*) as cnt - FROM {fq_table("unit_entities")} ue - JOIN {fq_table("memory_units")} mu ON ue.unit_id = mu.id - WHERE ue.entity_id = ANY($1) AND mu.bank_id = $2 - GROUP BY ue.entity_id - """, - entity_uuids, - bank_id, - ) - entity_fact_counts = {row["entity_id"]: row["cnt"] for row in fact_counts} - - # Filter entities that meet the threshold - entities_to_process = [] - for entity_id in entity_ids: - entity_uuid = uuid.UUID(entity_id) if isinstance(entity_id, str) else entity_id - if entity_uuid not in entity_names: - continue - fact_count = entity_fact_counts.get(entity_uuid, 0) - if fact_count >= min_facts: - entities_to_process.append((entity_id, entity_names[entity_uuid])) - - if not entities_to_process: - return - - # Use internal context if not provided (for internal/background calls) - from hindsight_api.models import RequestContext as RC - - ctx = request_context if request_context is not None else RC() - - # Process all entities in PARALLEL (LLM calls are the bottleneck) - async def process_entity(entity_id: str, entity_name: str): - try: - await self.regenerate_entity_observations( - bank_id, entity_id, entity_name, version=None, conn=conn, request_context=ctx - ) - except Exception as e: - logger.error(f"[OBSERVATIONS] Error processing entity {entity_id}: {e}") - - await asyncio.gather(*[process_entity(eid, name) for eid, name in entities_to_process]) - - async def _handle_regenerate_observations(self, task_dict: dict[str, Any]): - """ - Handler for regenerate_observations tasks. - - Args: - task_dict: Dict with 'bank_id' and either: - - 'entity_ids' (list): Process multiple entities - - 'entity_id', 'entity_name': Process single entity (legacy) - - Raises: - ValueError: If required fields are missing - Exception: Any exception from regenerate_entity_observations (propagates to execute_task for retry) - """ - bank_id = task_dict.get("bank_id") - # Use internal request context for background tasks - from hindsight_api.models import RequestContext - - internal_context = RequestContext() - - # New format: multiple entity_ids - if "entity_ids" in task_dict: - entity_ids = task_dict.get("entity_ids", []) - min_facts = task_dict.get("min_facts", 5) - - if not bank_id or not entity_ids: - raise ValueError(f"[OBSERVATIONS] Missing required fields in task: {task_dict}") - - # Process each entity - pool = await self._get_pool() - async with pool.acquire() as conn: - for entity_id in entity_ids: - try: - # Fetch entity name and check fact count - import uuid as uuid_module - - entity_uuid = uuid_module.UUID(entity_id) if isinstance(entity_id, str) else entity_id - - # First check if entity exists - entity_exists = await conn.fetchrow( - f"SELECT canonical_name FROM {fq_table('entities')} WHERE id = $1 AND bank_id = $2", - entity_uuid, - bank_id, - ) - - if not entity_exists: - logger.debug(f"[OBSERVATIONS] Entity {entity_id} not yet in bank {bank_id}, skipping") - continue - - entity_name = entity_exists["canonical_name"] - - # Count facts linked to this entity - fact_count = ( - await conn.fetchval( - f"SELECT COUNT(*) FROM {fq_table('unit_entities')} WHERE entity_id = $1", - entity_uuid, - ) - or 0 - ) - - # Only regenerate if entity has enough facts - if fact_count >= min_facts: - await self.regenerate_entity_observations( - bank_id, entity_id, entity_name, version=None, request_context=internal_context - ) - else: - logger.debug( - f"[OBSERVATIONS] Skipping {entity_name} ({fact_count} facts < {min_facts} threshold)" - ) - - except Exception as e: - # Log but continue processing other entities - individual entity failures - # shouldn't fail the whole batch - logger.error(f"[OBSERVATIONS] Error processing entity {entity_id}: {e}") - continue - - # Legacy format: single entity - else: - entity_id = task_dict.get("entity_id") - entity_name = task_dict.get("entity_name") - version = task_dict.get("version") - - if not all([bank_id, entity_id, entity_name]): - raise ValueError(f"[OBSERVATIONS] Missing required fields in task: {task_dict}") - - # Type assertions after validation - assert isinstance(bank_id, str) and isinstance(entity_id, str) and isinstance(entity_name, str) - await self.regenerate_entity_observations( - bank_id, entity_id, entity_name, version=version, request_context=internal_context - ) - - # ========================================================================= - # Statistics & Operations (for HTTP API layer) - # ========================================================================= - - async def get_bank_stats( - self, - bank_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, Any]: - """Get statistics about memory nodes and links for a bank.""" - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - - async with acquire_with_retry(pool) as conn: - # Get node counts by fact_type - node_stats = await conn.fetch( - f""" - SELECT fact_type, COUNT(*) as count - FROM {fq_table("memory_units")} - WHERE bank_id = $1 - GROUP BY fact_type - """, - bank_id, - ) - - # Get link counts by link_type - link_stats = await conn.fetch( - f""" - SELECT ml.link_type, COUNT(*) as count - FROM {fq_table("memory_links")} ml - JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id - WHERE mu.bank_id = $1 - GROUP BY ml.link_type - """, - bank_id, - ) - - # Get link counts by fact_type (from nodes) - link_fact_type_stats = await conn.fetch( - f""" - SELECT mu.fact_type, COUNT(*) as count - FROM {fq_table("memory_links")} ml - JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id - WHERE mu.bank_id = $1 - GROUP BY mu.fact_type - """, - bank_id, - ) - - # Get link counts by fact_type AND link_type - link_breakdown_stats = await conn.fetch( - f""" - SELECT mu.fact_type, ml.link_type, COUNT(*) as count - FROM {fq_table("memory_links")} ml - JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id - WHERE mu.bank_id = $1 - GROUP BY mu.fact_type, ml.link_type - """, - bank_id, - ) - - # Get pending and failed operations counts - ops_stats = await conn.fetch( - f""" - SELECT status, COUNT(*) as count - FROM {fq_table("async_operations")} - WHERE bank_id = $1 - GROUP BY status - """, - bank_id, - ) - - return { - "bank_id": bank_id, - "node_counts": {row["fact_type"]: row["count"] for row in node_stats}, - "link_counts": {row["link_type"]: row["count"] for row in link_stats}, - "link_counts_by_fact_type": {row["fact_type"]: row["count"] for row in link_fact_type_stats}, - "link_breakdown": [ - {"fact_type": row["fact_type"], "link_type": row["link_type"], "count": row["count"]} - for row in link_breakdown_stats - ], - "operations": {row["status"]: row["count"] for row in ops_stats}, - } - - async def get_entity( - self, - bank_id: str, - entity_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, Any] | None: - """Get entity details including metadata and observations.""" - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - - async with acquire_with_retry(pool) as conn: - entity_row = await conn.fetchrow( - f""" - SELECT id, canonical_name, mention_count, first_seen, last_seen, metadata - FROM {fq_table("entities")} - WHERE bank_id = $1 AND id = $2 - """, - bank_id, - uuid.UUID(entity_id), - ) - - if not entity_row: - return None - - # Get observations for the entity - observations = await self.get_entity_observations(bank_id, entity_id, limit=20, request_context=request_context) - - return { - "id": str(entity_row["id"]), - "canonical_name": entity_row["canonical_name"], - "mention_count": entity_row["mention_count"], - "first_seen": entity_row["first_seen"].isoformat() if entity_row["first_seen"] else None, - "last_seen": entity_row["last_seen"].isoformat() if entity_row["last_seen"] else None, - "metadata": entity_row["metadata"] or {}, - "observations": observations, - } - - async def list_operations( - self, - bank_id: str, - *, - request_context: "RequestContext", - ) -> list[dict[str, Any]]: - """List async operations for a bank.""" - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - - async with acquire_with_retry(pool) as conn: - operations = await conn.fetch( - f""" - SELECT operation_id, bank_id, operation_type, created_at, status, error_message, result_metadata - FROM {fq_table("async_operations")} - WHERE bank_id = $1 - ORDER BY created_at DESC - """, - bank_id, - ) - - def parse_metadata(metadata): - if metadata is None: - return {} - if isinstance(metadata, str): - import json - - return json.loads(metadata) - return metadata - - return [ - { - "id": str(row["operation_id"]), - "task_type": row["operation_type"], - "items_count": parse_metadata(row["result_metadata"]).get("items_count", 0), - "document_id": parse_metadata(row["result_metadata"]).get("document_id"), - "created_at": row["created_at"].isoformat(), - "status": row["status"], - "error_message": row["error_message"], - } - for row in operations - ] - - async def cancel_operation( - self, - bank_id: str, - operation_id: str, - *, - request_context: "RequestContext", - ) -> dict[str, Any]: - """Cancel a pending async operation.""" - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - - op_uuid = uuid.UUID(operation_id) - - async with acquire_with_retry(pool) as conn: - # Check if operation exists and belongs to this memory bank - result = await conn.fetchrow( - f"SELECT bank_id FROM {fq_table('async_operations')} WHERE operation_id = $1 AND bank_id = $2", - op_uuid, - bank_id, - ) - - if not result: - raise ValueError(f"Operation {operation_id} not found for bank {bank_id}") - - # Delete the operation - await conn.execute(f"DELETE FROM {fq_table('async_operations')} WHERE operation_id = $1", op_uuid) - - return { - "success": True, - "message": f"Operation {operation_id} cancelled", - "operation_id": operation_id, - "bank_id": bank_id, - } - - async def update_bank( - self, - bank_id: str, - *, - name: str | None = None, - background: str | None = None, - request_context: "RequestContext", - ) -> dict[str, Any]: - """Update bank name and/or background.""" - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - - async with acquire_with_retry(pool) as conn: - if name is not None: - await conn.execute( - f""" - UPDATE {fq_table("banks")} - SET name = $2, updated_at = NOW() - WHERE bank_id = $1 - """, - bank_id, - name, - ) - - if background is not None: - await conn.execute( - f""" - UPDATE {fq_table("banks")} - SET background = $2, updated_at = NOW() - WHERE bank_id = $1 - """, - bank_id, - background, - ) - - # Return updated profile - return await self.get_bank_profile(bank_id, request_context=request_context) - - async def submit_async_retain( - self, - bank_id: str, - contents: list[dict[str, Any]], - *, - request_context: "RequestContext", - ) -> dict[str, Any]: - """Submit a batch retain operation to run asynchronously.""" - await self._authenticate_tenant(request_context) - pool = await self._get_pool() - - import json - - operation_id = uuid.uuid4() - - # Insert operation record into database - async with acquire_with_retry(pool) as conn: - await conn.execute( - f""" - INSERT INTO {fq_table("async_operations")} (operation_id, bank_id, operation_type, result_metadata) - VALUES ($1, $2, $3, $4) - """, - operation_id, - bank_id, - "retain", - json.dumps({"items_count": len(contents)}), - ) - - # Submit task to background queue - await self._task_backend.submit_task( - { - "type": "batch_retain", - "operation_id": str(operation_id), - "bank_id": bank_id, - "contents": contents, - } - ) - - logger.info(f"Retain task queued for bank_id={bank_id}, {len(contents)} items, operation_id={operation_id}") - - return { - "operation_id": str(operation_id), - "items_count": len(contents), - } diff --git a/hindsight-api/hindsight_api/engine/query_analyzer.py b/hindsight-api/hindsight_api/engine/query_analyzer.py deleted file mode 100644 index a6e6a303..00000000 --- a/hindsight-api/hindsight_api/engine/query_analyzer.py +++ /dev/null @@ -1,522 +0,0 @@ -""" -Query analysis abstraction for the memory system. - -Provides an interface for analyzing natural language queries to extract -structured information like temporal constraints. -""" - -import logging -import re -from abc import ABC, abstractmethod -from datetime import datetime, timedelta - -from pydantic import BaseModel, Field - -logger = logging.getLogger(__name__) - - -class TemporalConstraint(BaseModel): - """ - Temporal constraint extracted from a query. - - Represents a time range with start and end dates. - """ - - start_date: datetime = Field(description="Start of the time range (inclusive)") - end_date: datetime = Field(description="End of the time range (inclusive)") - - def __str__(self) -> str: - return f"{self.start_date.strftime('%Y-%m-%d')} to {self.end_date.strftime('%Y-%m-%d')}" - - -class QueryAnalysis(BaseModel): - """ - Result of analyzing a natural language query. - - Contains extracted structured information like temporal constraints. - """ - - temporal_constraint: TemporalConstraint | None = Field( - default=None, description="Extracted temporal constraint, if any" - ) - - -class QueryAnalyzer(ABC): - """ - Abstract base class for query analysis. - - Implementations analyze natural language queries to extract structured - information like temporal constraints, entities, etc. - """ - - @abstractmethod - def load(self) -> None: - """ - Load the query analyzer model. - - This should be called during initialization to load the model - and avoid cold start latency on first analyze() call. - """ - pass - - @abstractmethod - def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAnalysis: - """ - Analyze a natural language query. - - Args: - query: Natural language query to analyze - reference_date: Reference date for relative terms (defaults to now) - - Returns: - QueryAnalysis containing extracted information - """ - pass - - -class DateparserQueryAnalyzer(QueryAnalyzer): - """ - Query analyzer using dateparser library. - - Uses dateparser to extract temporal expressions from natural language - queries. Supports 200+ languages including English, Spanish, Italian, - French, German, etc. - - Performance: - - ~10-50ms per query - - No model loading required - """ - - def __init__(self): - """Initialize dateparser query analyzer.""" - self._search_dates = None - - def load(self) -> None: - """Load dateparser (lazy import).""" - if self._search_dates is None: - from dateparser.search import search_dates - - self._search_dates = search_dates - - def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAnalysis: - """ - Analyze query using dateparser. - - Extracts temporal expressions from the query text. Supports multiple - languages automatically. - - Args: - query: Natural language query (any language) - reference_date: Reference date for relative terms (defaults to now) - - Returns: - QueryAnalysis with temporal_constraint if found - """ - self.load() - - if reference_date is None: - reference_date = datetime.now() - - # Check for period expressions first (these need special handling) - query_lower = query.lower() - period_result = self._extract_period(query_lower, reference_date) - if period_result is not None: - return QueryAnalysis(temporal_constraint=period_result) - - # Use dateparser's search_dates to find temporal expressions - settings = { - "RELATIVE_BASE": reference_date, - "PREFER_DATES_FROM": "past", - "RETURN_AS_TIMEZONE_AWARE": False, - } - - results = self._search_dates(query, settings=settings) - - if not results: - return QueryAnalysis(temporal_constraint=None) - - # Filter out false positives (common words parsed as dates) - false_positives = {"do", "may", "march", "will", "can", "sat", "sun", "mon", "tue", "wed", "thu", "fri"} - valid_results = [(text, date) for text, date in results if text.lower() not in false_positives or len(text) > 3] - - if not valid_results: - return QueryAnalysis(temporal_constraint=None) - - # Use the first valid date found - _, parsed_date = valid_results[0] - - # Create constraint for single day - start_date = parsed_date.replace(hour=0, minute=0, second=0, microsecond=0) - end_date = parsed_date.replace(hour=23, minute=59, second=59, microsecond=999999) - - return QueryAnalysis(temporal_constraint=TemporalConstraint(start_date=start_date, end_date=end_date)) - - def _extract_period(self, query: str, reference_date: datetime) -> TemporalConstraint | None: - """ - Extract period-based temporal expressions (week, month, year, weekend). - - These need special handling as they represent date ranges, not single dates. - Supports multiple languages. - """ - - def constraint(start: datetime, end: datetime) -> TemporalConstraint: - return TemporalConstraint( - start_date=start.replace(hour=0, minute=0, second=0, microsecond=0), - end_date=end.replace(hour=23, minute=59, second=59, microsecond=999999), - ) - - # Yesterday patterns (English, Spanish, Italian, French, German) - if re.search(r"\b(yesterday|ayer|ieri|hier|gestern)\b", query, re.IGNORECASE): - d = reference_date - timedelta(days=1) - return constraint(d, d) - - # Today patterns - if re.search(r"\b(today|hoy|oggi|aujourd\'?hui|heute)\b", query, re.IGNORECASE): - return constraint(reference_date, reference_date) - - # "a couple of days ago" / "a few days ago" patterns - # These are imprecise so we create a range - if re.search(r"\b(a\s+)?couple\s+(of\s+)?days?\s+ago\b", query, re.IGNORECASE): - # "a couple of days" = approximately 2 days, give range of 1-3 days - return constraint(reference_date - timedelta(days=3), reference_date - timedelta(days=1)) - - if re.search(r"\b(a\s+)?few\s+days?\s+ago\b", query, re.IGNORECASE): - # "a few days" = approximately 3-4 days, give range of 2-5 days - return constraint(reference_date - timedelta(days=5), reference_date - timedelta(days=2)) - - # "a couple of weeks ago" / "a few weeks ago" patterns - if re.search(r"\b(a\s+)?couple\s+(of\s+)?weeks?\s+ago\b", query, re.IGNORECASE): - # "a couple of weeks" = approximately 2 weeks, give range of 1-3 weeks - return constraint(reference_date - timedelta(weeks=3), reference_date - timedelta(weeks=1)) - - if re.search(r"\b(a\s+)?few\s+weeks?\s+ago\b", query, re.IGNORECASE): - # "a few weeks" = approximately 3-4 weeks, give range of 2-5 weeks - return constraint(reference_date - timedelta(weeks=5), reference_date - timedelta(weeks=2)) - - # "a couple of months ago" / "a few months ago" patterns - if re.search(r"\b(a\s+)?couple\s+(of\s+)?months?\s+ago\b", query, re.IGNORECASE): - # "a couple of months" = approximately 2 months, give range of 1-3 months - return constraint(reference_date - timedelta(days=90), reference_date - timedelta(days=30)) - - if re.search(r"\b(a\s+)?few\s+months?\s+ago\b", query, re.IGNORECASE): - # "a few months" = approximately 3-4 months, give range of 2-5 months - return constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60)) - - # Last week patterns (English, Spanish, Italian, French, German) - if re.search( - r"\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b", - query, - re.IGNORECASE, - ): - start = reference_date - timedelta(days=reference_date.weekday() + 7) - return constraint(start, start + timedelta(days=6)) - - # Last month patterns - if re.search( - r"\b(last\s+month|el\s+mes\s+pasado|il\s+mese\s+scorso|le\s+mois\s+dernier|letzten?\s+monat)\b", - query, - re.IGNORECASE, - ): - first = reference_date.replace(day=1) - end = first - timedelta(days=1) - start = end.replace(day=1) - return constraint(start, end) - - # Last year patterns - if re.search( - r"\b(last\s+year|el\s+a[ñn]o\s+pasado|l\'anno\s+scorso|l\'ann[ée]e\s+derni[eè]re|letztes?\s+jahr)\b", - query, - re.IGNORECASE, - ): - year = reference_date.year - 1 - return constraint(datetime(year, 1, 1), datetime(year, 12, 31)) - - # Last weekend patterns - if re.search( - r"\b(last\s+weekend|el\s+fin\s+de\s+semana\s+pasado|lo\s+scorso\s+fine\s+settimana|le\s+week-?end\s+dernier|letztes?\s+wochenende)\b", - query, - re.IGNORECASE, - ): - days_since_sat = (reference_date.weekday() + 2) % 7 - if days_since_sat == 0: - days_since_sat = 7 - sat = reference_date - timedelta(days=days_since_sat) - return constraint(sat, sat + timedelta(days=1)) - - # Month + Year patterns (e.g., "June 2024", "junio 2024", "giugno 2024") - month_patterns = { - "january|enero|gennaio|janvier|januar": 1, - "february|febrero|febbraio|f[ée]vrier|februar": 2, - "march|marzo|mars|m[äa]rz": 3, - "april|abril|aprile|avril": 4, - "may|mayo|maggio|mai": 5, - "june|junio|giugno|juin|juni": 6, - "july|julio|luglio|juillet|juli": 7, - "august|agosto|ao[uû]t": 8, - "september|septiembre|settembre|septembre": 9, - "october|octubre|ottobre|octobre|oktober": 10, - "november|noviembre|novembre": 11, - "december|diciembre|dicembre|d[ée]cembre|dezember": 12, - } - - for pattern, month_num in month_patterns.items(): - match = re.search(rf"\b({pattern})\s+(\d{{4}})\b", query, re.IGNORECASE) - if match: - year = int(match.group(2)) - start = datetime(year, month_num, 1) - if month_num == 12: - end = datetime(year, 12, 31) - else: - end = datetime(year, month_num + 1, 1) - timedelta(days=1) - return constraint(start, end) - - return None - - -class TransformerQueryAnalyzer(QueryAnalyzer): - """ - Query analyzer using T5-based generative models. - - Uses T5 to convert natural language temporal expressions into structured - date ranges without pattern matching or regex. - - Performance: - - ~30-80ms on CPU, ~5-15ms on GPU - - Model size: ~80M params (~300MB download) - """ - - def __init__(self, model_name: str = "google/flan-t5-small", device: str = "cpu"): - """ - Initialize T5 query analyzer. - - Args: - model_name: Name of the HuggingFace T5 model to use. - Default: google/flan-t5-small (~80M params, ~300MB download) - Alternative: google/flan-t5-base (~1GB, more accurate) - device: Device to run model on ("cpu" or "cuda") - """ - self.model_name = model_name - self.device = device - self._model = None - self._tokenizer = None - - def load(self) -> None: - """Load the T5 model for temporal extraction.""" - if self._model is not None: - return - - try: - from transformers import AutoModelForSeq2SeqLM, AutoTokenizer - except ImportError: - raise ImportError( - "transformers is required for TransformerQueryAnalyzer. Install it with: pip install transformers" - ) - - logger.info(f"Loading query analyzer model: {self.model_name}...") - self._tokenizer = AutoTokenizer.from_pretrained(self.model_name) - self._model = AutoModelForSeq2SeqLM.from_pretrained(self.model_name) - self._model.to(self.device) - self._model.eval() - logger.info("Query analyzer model loaded") - - def _load_model(self): - """Lazy load the T5 model for temporal extraction (calls load()).""" - self.load() - - def _extract_with_rules(self, query: str, reference_date: datetime) -> TemporalConstraint | None: - """ - Extract temporal expressions using rule-based patterns. - - Handles common patterns reliably and fast. Returns None for - patterns that need model-based extraction. - """ - import re - - query_lower = query.lower() - - def get_last_weekday(weekday: int) -> datetime: - days_ago = (reference_date.weekday() - weekday) % 7 - if days_ago == 0: - days_ago = 7 - return reference_date - timedelta(days=days_ago) - - def constraint(start: datetime, end: datetime) -> TemporalConstraint: - return TemporalConstraint( - start_date=start.replace(hour=0, minute=0, second=0, microsecond=0), - end_date=end.replace(hour=23, minute=59, second=59, microsecond=999999), - ) - - # Yesterday - if re.search(r"\byesterday\b", query_lower): - d = reference_date - timedelta(days=1) - return constraint(d, d) - - # Last week - if re.search(r"\blast\s+week\b", query_lower): - start = reference_date - timedelta(days=reference_date.weekday() + 7) - return constraint(start, start + timedelta(days=6)) - - # Last month - if re.search(r"\blast\s+month\b", query_lower): - first = reference_date.replace(day=1) - end = first - timedelta(days=1) - start = end.replace(day=1) - return constraint(start, end) - - # Last year - if re.search(r"\blast\s+year\b", query_lower): - y = reference_date.year - 1 - return constraint(datetime(y, 1, 1), datetime(y, 12, 31)) - - # Last weekend - if re.search(r"\blast\s+weekend\b", query_lower): - sat = get_last_weekday(5) - return constraint(sat, sat + timedelta(days=1)) - - # Last - weekdays = {"monday": 0, "tuesday": 1, "wednesday": 2, "thursday": 3, "friday": 4, "saturday": 5, "sunday": 6} - for name, num in weekdays.items(): - if re.search(rf"\blast\s+{name}\b", query_lower): - d = get_last_weekday(num) - return constraint(d, d) - - # Month + Year: "June 2024", "in March 2023" - months = { - "january": 1, - "february": 2, - "march": 3, - "april": 4, - "may": 5, - "june": 6, - "july": 7, - "august": 8, - "september": 9, - "october": 10, - "november": 11, - "december": 12, - } - for name, num in months.items(): - match = re.search(rf"\b{name}\s+(\d{{4}})\b", query_lower) - if match: - year = int(match.group(1)) - if num == 12: - last_day = 31 - else: - last_day = (datetime(year, num + 1, 1) - timedelta(days=1)).day - return constraint(datetime(year, num, 1), datetime(year, num, last_day)) - - return None - - def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAnalysis: - """ - Analyze query for temporal expressions. - - Uses rule-based extraction for common patterns (fast & reliable), - falls back to T5 model for complex/unusual patterns. - - Args: - query: Natural language query - reference_date: Reference date for relative terms (defaults to now) - - Returns: - QueryAnalysis with temporal_constraint if found - """ - if reference_date is None: - reference_date = datetime.now() - - # Try rule-based extraction first (handles 90%+ of cases) - result = self._extract_with_rules(query, reference_date) - if result is not None: - return QueryAnalysis(temporal_constraint=result) - - # Fall back to T5 model for unusual patterns - self._load_model() - - # Helper to calculate example dates - def get_last_weekday(weekday: int) -> datetime: - days_ago = (reference_date.weekday() - weekday) % 7 - if days_ago == 0: - days_ago = 7 - return reference_date - timedelta(days=days_ago) - - yesterday = reference_date - timedelta(days=1) - last_saturday = get_last_weekday(5) - - # Build prompt for T5 - prompt = f"""Today is {reference_date.strftime("%Y-%m-%d")}. Extract date range or "none". - -June 2024 = 2024-06-01 to 2024-06-30 -yesterday = {yesterday.strftime("%Y-%m-%d")} to {yesterday.strftime("%Y-%m-%d")} -last Saturday = {last_saturday.strftime("%Y-%m-%d")} to {last_saturday.strftime("%Y-%m-%d")} -what is the weather = none -{query} =""" - - # Tokenize and generate - inputs = self._tokenizer(prompt, return_tensors="pt", max_length=512, truncation=True) - inputs = {k: v.to(self.device) for k, v in inputs.items()} - - with self._no_grad(): - outputs = self._model.generate(**inputs, max_new_tokens=30, num_beams=3, do_sample=False, temperature=1.0) - - result = self._tokenizer.decode(outputs[0], skip_special_tokens=True).strip() - - # Parse the generated output - temporal = self._parse_generated_output(result, reference_date) - return QueryAnalysis(temporal_constraint=temporal) - - def _no_grad(self): - """Get torch.no_grad context manager.""" - try: - import torch - - return torch.no_grad() - except ImportError: - from contextlib import nullcontext - - return nullcontext() - - def _parse_generated_output(self, result: str, reference_date: datetime) -> TemporalConstraint | None: - """ - Parse T5 generated output into TemporalConstraint. - - Expected format: "YYYY-MM-DD to YYYY-MM-DD" - - Args: - result: Generated text from T5 - reference_date: Reference date for validation - - Returns: - TemporalConstraint if valid output, else None - """ - if not result or result.lower().strip() in ("none", "null", "no"): - return None - - try: - # Parse "YYYY-MM-DD to YYYY-MM-DD" - import re - - pattern = r"(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})" - match = re.search(pattern, result, re.IGNORECASE) - - if match: - start_str = match.group(1) - end_str = match.group(2) - - start_date = datetime.strptime(start_str, "%Y-%m-%d") - end_date = datetime.strptime(end_str, "%Y-%m-%d") - - # Set time boundaries - start_date = start_date.replace(hour=0, minute=0, second=0, microsecond=0) - end_date = end_date.replace(hour=23, minute=59, second=59, microsecond=999999) - - # Validation - if end_date < start_date: - logger.warning(f"Invalid date range: {start_date} to {end_date}") - return None - - return TemporalConstraint(start_date=start_date, end_date=end_date) - - except (ValueError, AttributeError): - return None - - return None diff --git a/hindsight-api/hindsight_api/engine/response_models.py b/hindsight-api/hindsight_api/engine/response_models.py deleted file mode 100644 index 452228ad..00000000 --- a/hindsight-api/hindsight_api/engine/response_models.py +++ /dev/null @@ -1,219 +0,0 @@ -""" -Core response models for Hindsight memory system. - -These models define the structure of data returned by the core MemoryEngine class. -API response models should be kept separate and convert from these core models to maintain -API stability even if internal models change. -""" - -from typing import Any - -from pydantic import BaseModel, ConfigDict, Field - -# Valid fact types for recall operations (excludes 'observation' which is internal) -VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "opinion"]) - - -class DispositionTraits(BaseModel): - """ - Disposition traits for a memory bank. - - All traits are scored 1-5 where: - - skepticism: 1=trusting, 5=skeptical (how much to doubt or question information) - - literalism: 1=flexible interpretation, 5=literal interpretation (how strictly to interpret information) - - empathy: 1=detached, 5=empathetic (how much to consider emotional context) - """ - - skepticism: int = Field(ge=1, le=5, description="How skeptical vs trusting (1=trusting, 5=skeptical)") - literalism: int = Field(ge=1, le=5, description="How literally to interpret information (1=flexible, 5=literal)") - empathy: int = Field(ge=1, le=5, description="How much to consider emotional context (1=detached, 5=empathetic)") - - model_config = ConfigDict(json_schema_extra={"example": {"skepticism": 3, "literalism": 3, "empathy": 3}}) - - -class MemoryFact(BaseModel): - """ - A single memory fact returned by search or think operations. - - This represents a unit of information stored in the memory system, - including both the content and metadata. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "id": "123e4567-e89b-12d3-a456-426614174000", - "text": "Alice works at Google on the AI team", - "fact_type": "world", - "entities": ["Alice", "Google"], - "context": "work info", - "occurred_start": "2024-01-15T10:30:00Z", - "occurred_end": "2024-01-15T10:30:00Z", - "mentioned_at": "2024-01-15T10:30:00Z", - "document_id": "session_abc123", - "metadata": {"source": "slack"}, - "chunk_id": "bank123_session_abc123_0", - "activation": 0.95, - } - } - ) - - id: str = Field(description="Unique identifier for the memory fact") - text: str = Field(description="The actual text content of the memory") - fact_type: str = Field(description="Type of fact: 'world', 'experience', 'opinion', or 'observation'") - entities: list[str] | None = Field(None, description="Entity names mentioned in this fact") - context: str | None = Field(None, description="Additional context for the memory") - occurred_start: str | None = Field(None, description="ISO format date when the event started occurring") - occurred_end: str | None = Field(None, description="ISO format date when the event ended occurring") - mentioned_at: str | None = Field(None, description="ISO format date when the fact was mentioned/learned") - document_id: str | None = Field(None, description="ID of the document this memory belongs to") - metadata: dict[str, str] | None = Field(None, description="User-defined metadata") - chunk_id: str | None = Field( - None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)" - ) - - -class ChunkInfo(BaseModel): - """Information about a chunk.""" - - chunk_text: str = Field(description="The raw chunk text") - chunk_index: int = Field(description="Index of the chunk within the document") - truncated: bool = Field(default=False, description="Whether the chunk was truncated due to token limits") - - -class RecallResult(BaseModel): - """ - Result from a recall operation. - - Contains a list of matching memory facts and optional trace information - for debugging and transparency. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "results": [ - { - "id": "123e4567-e89b-12d3-a456-426614174000", - "text": "Alice works at Google on the AI team", - "fact_type": "world", - "context": "work info", - "occurred_start": "2024-01-15T10:30:00Z", - "occurred_end": "2024-01-15T10:30:00Z", - "activation": 0.95, - } - ], - "trace": {"query": "What did Alice say about machine learning?", "num_results": 1}, - } - } - ) - - results: list[MemoryFact] = Field(description="List of memory facts matching the query") - trace: dict[str, Any] | None = Field(None, description="Trace information for debugging") - entities: dict[str, "EntityState"] | None = Field( - None, description="Entity states for entities mentioned in results (keyed by canonical name)" - ) - chunks: dict[str, ChunkInfo] | None = Field( - None, description="Chunks for facts, keyed by '{document_id}_{chunk_index}'" - ) - - -class ReflectResult(BaseModel): - """ - Result from a reflect operation. - - Contains the formulated answer, the facts it was based on (organized by type), - and any new opinions that were formed during the reflection process. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "text": "Based on my knowledge, machine learning is being actively used in healthcare...", - "based_on": { - "world": [ - { - "id": "123e4567-e89b-12d3-a456-426614174000", - "text": "Machine learning is used in medical diagnosis", - "fact_type": "world", - "context": "healthcare", - "occurred_start": "2024-01-15T10:30:00Z", - "occurred_end": "2024-01-15T10:30:00Z", - } - ], - "experience": [], - "opinion": [], - }, - "new_opinions": ["Machine learning has great potential in healthcare"], - } - } - ) - - text: str = Field(description="The formulated answer text") - based_on: dict[str, list[MemoryFact]] = Field( - description="Facts used to formulate the answer, organized by type (world, experience, opinion)" - ) - new_opinions: list[str] = Field(default_factory=list, description="List of newly formed opinions during reflection") - - -class Opinion(BaseModel): - """ - An opinion with confidence score. - - Opinions represent the bank's formed perspectives on topics, - with a confidence level indicating strength of belief. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": {"text": "Machine learning has great potential in healthcare", "confidence": 0.85} - } - ) - - text: str = Field(description="The opinion text") - confidence: float = Field(description="Confidence score between 0.0 and 1.0") - - -class EntityObservation(BaseModel): - """ - An observation about an entity. - - Observations are objective facts synthesized from multiple memory facts - about an entity, without personality influence. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": {"text": "John is detail-oriented and works at Google", "mentioned_at": "2024-01-15T10:30:00Z"} - } - ) - - text: str = Field(description="The observation text") - mentioned_at: str | None = Field(None, description="ISO format date when this observation was created") - - -class EntityState(BaseModel): - """ - Current mental model of an entity. - - Contains observations synthesized from facts about the entity. - """ - - model_config = ConfigDict( - json_schema_extra={ - "example": { - "entity_id": "123e4567-e89b-12d3-a456-426614174000", - "canonical_name": "John", - "observations": [ - {"text": "John is detail-oriented", "mentioned_at": "2024-01-15T10:30:00Z"}, - {"text": "John works at Google on the AI team", "mentioned_at": "2024-01-14T09:00:00Z"}, - ], - } - } - ) - - entity_id: str = Field(description="Unique identifier for the entity") - canonical_name: str = Field(description="Canonical name of the entity") - observations: list[EntityObservation] = Field( - default_factory=list, description="List of observations about this entity" - ) diff --git a/hindsight-api/hindsight_api/engine/retain/__init__.py b/hindsight-api/hindsight_api/engine/retain/__init__.py deleted file mode 100644 index 6fd95827..00000000 --- a/hindsight-api/hindsight_api/engine/retain/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -""" -Retain pipeline modules for storing memories. - -This package contains modular components for the retain operation: -- types: Type definitions for retain pipeline -- fact_extraction: Extract facts from content -- embedding_processing: Augment texts and generate embeddings -- deduplication: Check for duplicate facts -- entity_processing: Process and resolve entities -- link_creation: Create temporal, semantic, entity, and causal links -- chunk_storage: Handle chunk storage -- fact_storage: Handle fact insertion into database -""" - -from . import ( - chunk_storage, - deduplication, - embedding_processing, - entity_processing, - fact_extraction, - fact_storage, - link_creation, -) -from .types import CausalRelation, ChunkMetadata, EntityRef, ExtractedFact, ProcessedFact, RetainBatch, RetainContent - -__all__ = [ - # Types - "RetainContent", - "ExtractedFact", - "ProcessedFact", - "ChunkMetadata", - "EntityRef", - "CausalRelation", - "RetainBatch", - # Modules - "fact_extraction", - "embedding_processing", - "deduplication", - "entity_processing", - "link_creation", - "chunk_storage", - "fact_storage", -] diff --git a/hindsight-api/hindsight_api/engine/retain/bank_utils.py b/hindsight-api/hindsight_api/engine/retain/bank_utils.py deleted file mode 100644 index f9cac1f9..00000000 --- a/hindsight-api/hindsight_api/engine/retain/bank_utils.py +++ /dev/null @@ -1,390 +0,0 @@ -""" -bank profile utilities for disposition and background management. -""" - -import json -import logging -import re -from typing import TypedDict - -from pydantic import BaseModel, Field - -from ..db_utils import acquire_with_retry -from ..memory_engine import fq_table -from ..response_models import DispositionTraits - -logger = logging.getLogger(__name__) - -DEFAULT_DISPOSITION = { - "skepticism": 3, - "literalism": 3, - "empathy": 3, -} - - -class BankProfile(TypedDict): - """Type for bank profile data.""" - - name: str - disposition: DispositionTraits - background: str - - -class BackgroundMergeResponse(BaseModel): - """LLM response for background merge with disposition inference.""" - - background: str = Field(description="Merged background in first person perspective") - disposition: DispositionTraits = Field(description="Inferred disposition traits (skepticism, literalism, empathy)") - - -async def get_bank_profile(pool, bank_id: str) -> BankProfile: - """ - Get bank profile (name, disposition + background). - Auto-creates bank with default values if not exists. - - Args: - pool: Database connection pool - bank_id: bank IDentifier - - Returns: - BankProfile with name, typed DispositionTraits, and background - """ - async with acquire_with_retry(pool) as conn: - # Try to get existing bank - row = await conn.fetchrow( - f""" - SELECT name, disposition, background - FROM {fq_table("banks")} WHERE bank_id = $1 - """, - bank_id, - ) - - if row: - # asyncpg returns JSONB as a string, so parse it - disposition_data = row["disposition"] - if isinstance(disposition_data, str): - disposition_data = json.loads(disposition_data) - - return BankProfile( - name=row["name"], disposition=DispositionTraits(**disposition_data), background=row["background"] - ) - - # Bank doesn't exist, create with defaults - await conn.execute( - f""" - INSERT INTO {fq_table("banks")} (bank_id, name, disposition, background) - VALUES ($1, $2, $3::jsonb, $4) - ON CONFLICT (bank_id) DO NOTHING - """, - bank_id, - bank_id, # Default name is the bank_id - json.dumps(DEFAULT_DISPOSITION), - "", - ) - - return BankProfile(name=bank_id, disposition=DispositionTraits(**DEFAULT_DISPOSITION), background="") - - -async def update_bank_disposition(pool, bank_id: str, disposition: dict[str, int]) -> None: - """ - Update bank disposition traits. - - Args: - pool: Database connection pool - bank_id: bank IDentifier - disposition: Dict with skepticism, literalism, empathy (all 1-5) - """ - # Ensure bank exists first - await get_bank_profile(pool, bank_id) - - async with acquire_with_retry(pool) as conn: - await conn.execute( - f""" - UPDATE {fq_table("banks")} - SET disposition = $2::jsonb, - updated_at = NOW() - WHERE bank_id = $1 - """, - bank_id, - json.dumps(disposition), - ) - - -async def merge_bank_background(pool, llm_config, bank_id: str, new_info: str, update_disposition: bool = True) -> dict: - """ - Merge new background information with existing background using LLM. - Normalizes to first person ("I") and resolves conflicts. - Optionally infers disposition traits from the merged background. - - Args: - pool: Database connection pool - llm_config: LLM configuration for background merging - bank_id: bank IDentifier - new_info: New background information to add/merge - update_disposition: If True, infer Big Five traits from background (default: True) - - Returns: - Dict with 'background' (str) and optionally 'disposition' (dict) keys - """ - # Get current profile - profile = await get_bank_profile(pool, bank_id) - current_background = profile["background"] - - # Use LLM to merge backgrounds and optionally infer disposition - result = await _llm_merge_background(llm_config, current_background, new_info, infer_disposition=update_disposition) - - merged_background = result["background"] - inferred_disposition = result.get("disposition") - - # Update in database - async with acquire_with_retry(pool) as conn: - if inferred_disposition: - # Update both background and disposition - await conn.execute( - f""" - UPDATE {fq_table("banks")} - SET background = $2, - disposition = $3::jsonb, - updated_at = NOW() - WHERE bank_id = $1 - """, - bank_id, - merged_background, - json.dumps(inferred_disposition), - ) - else: - # Update only background - await conn.execute( - f""" - UPDATE {fq_table("banks")} - SET background = $2, - updated_at = NOW() - WHERE bank_id = $1 - """, - bank_id, - merged_background, - ) - - response = {"background": merged_background} - if inferred_disposition: - response["disposition"] = inferred_disposition - - return response - - -async def _llm_merge_background(llm_config, current: str, new_info: str, infer_disposition: bool = False) -> dict: - """ - Use LLM to intelligently merge background information. - Optionally infer Big Five disposition traits from the merged background. - - Args: - llm_config: LLM configuration to use - current: Current background text - new_info: New information to merge - infer_disposition: If True, also infer disposition traits - - Returns: - Dict with 'background' (str) and optionally 'disposition' (dict) keys - """ - if infer_disposition: - prompt = f"""You are helping maintain a memory bank's background/profile and infer their disposition. You MUST respond with ONLY valid JSON. - -Current background: {current if current else "(empty)"} - -New information to add: {new_info} - -Instructions: -1. Merge the new information with the current background -2. If there are conflicts (e.g., different birthplaces), the NEW information overwrites the old -3. Keep additions that don't conflict -4. Output in FIRST PERSON ("I") perspective -5. Be concise - keep merged background under 500 characters -6. Infer disposition traits from the merged background (each 1-5 integer): - - Skepticism: 1-5 (1=trusting, takes things at face value; 5=skeptical, questions everything) - - Literalism: 1-5 (1=flexible interpretation, reads between lines; 5=literal, exact interpretation) - - Empathy: 1-5 (1=detached, focuses on facts; 5=empathetic, considers emotional context) - -CRITICAL: You MUST respond with ONLY a valid JSON object. No markdown, no code blocks, no explanations. Just the JSON. - -Format: -{{ - "background": "the merged background text in first person", - "disposition": {{ - "skepticism": 3, - "literalism": 3, - "empathy": 3 - }} -}} - -Trait inference examples: -- "I'm a lawyer" → skepticism: 4, literalism: 5, empathy: 2 -- "I'm a therapist" → skepticism: 2, literalism: 2, empathy: 5 -- "I'm an engineer" → skepticism: 3, literalism: 4, empathy: 3 -- "I've been burned before by trusting people" → skepticism: 5, literalism: 3, empathy: 3 -- "I try to understand what people really mean" → skepticism: 3, literalism: 2, empathy: 4 -- "I take contracts very seriously" → skepticism: 4, literalism: 5, empathy: 2""" - else: - prompt = f"""You are helping maintain a memory bank's background/profile. - -Current background: {current if current else "(empty)"} - -New information to add: {new_info} - -Instructions: -1. Merge the new information with the current background -2. If there are conflicts (e.g., different birthplaces), the NEW information overwrites the old -3. Keep additions that don't conflict -4. Output in FIRST PERSON ("I") perspective -5. Be concise - keep it under 500 characters -6. Return ONLY the merged background text, no explanations - -Merged background:""" - - try: - # Prepare messages - messages = [{"role": "user", "content": prompt}] - - if infer_disposition: - # Use structured output with Pydantic model for disposition inference - try: - parsed = await llm_config.call( - messages=messages, - response_format=BackgroundMergeResponse, - scope="bank_background", - temperature=0.3, - max_completion_tokens=8192, - ) - logger.info(f"Successfully got structured response: background={parsed.background[:100]}") - - # Convert Pydantic model to dict format - return {"background": parsed.background, "disposition": parsed.disposition.model_dump()} - except Exception as e: - logger.warning(f"Structured output failed, falling back to manual parsing: {e}") - # Fall through to manual parsing below - - # Manual parsing fallback or non-disposition merge - content = await llm_config.call( - messages=messages, scope="bank_background", temperature=0.3, max_completion_tokens=8192 - ) - - logger.info(f"LLM response for background merge (first 500 chars): {content[:500]}") - - if infer_disposition: - # Parse JSON response - try multiple extraction methods - result = None - - # Method 1: Direct parse - try: - result = json.loads(content) - logger.info("Successfully parsed JSON directly") - except json.JSONDecodeError: - pass - - # Method 2: Extract from markdown code blocks - if result is None: - # Remove markdown code blocks - code_block_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", content, re.DOTALL) - if code_block_match: - try: - result = json.loads(code_block_match.group(1)) - logger.info("Successfully extracted JSON from markdown code block") - except json.JSONDecodeError: - pass - - # Method 3: Find nested JSON structure - if result is None: - # Look for JSON object with nested structure - json_match = re.search( - r'\{[^{}]*"background"[^{}]*"disposition"[^{}]*\{[^{}]*\}[^{}]*\}', content, re.DOTALL - ) - if json_match: - try: - result = json.loads(json_match.group()) - logger.info("Successfully extracted JSON using nested pattern") - except json.JSONDecodeError: - pass - - # All parsing methods failed - use fallback - if result is None: - logger.warning(f"Failed to extract JSON from LLM response. Raw content: {content[:200]}") - # Fallback: use new_info as background with default disposition - return { - "background": new_info if new_info else current if current else "", - "disposition": DEFAULT_DISPOSITION.copy(), - } - - # Validate disposition values - disposition = result.get("disposition", {}) - for key in ["skepticism", "literalism", "empathy"]: - if key not in disposition: - disposition[key] = 3 # Default to neutral - else: - # Clamp to [1, 5] and convert to int - disposition[key] = max(1, min(5, int(disposition[key]))) - - result["disposition"] = disposition - - # Ensure background exists - if "background" not in result or not result["background"]: - result["background"] = new_info if new_info else "" - - return result - else: - # Just background merge - merged = content - if not merged or merged.lower() in ["(empty)", "none", "n/a"]: - merged = new_info if new_info else "" - return {"background": merged} - - except Exception as e: - logger.error(f"Error merging background with LLM: {e}") - # Fallback: just append new info - if current: - merged = f"{current} {new_info}".strip() - else: - merged = new_info - - result = {"background": merged} - if infer_disposition: - result["disposition"] = DEFAULT_DISPOSITION.copy() - return result - - -async def list_banks(pool) -> list: - """ - List all banks in the system. - - Args: - pool: Database connection pool - - Returns: - List of dicts with bank_id, name, disposition, background, created_at, updated_at - """ - async with acquire_with_retry(pool) as conn: - rows = await conn.fetch( - f""" - SELECT bank_id, name, disposition, background, created_at, updated_at - FROM {fq_table("banks")} - ORDER BY updated_at DESC - """ - ) - - result = [] - for row in rows: - # asyncpg returns JSONB as a string, so parse it - disposition_data = row["disposition"] - if isinstance(disposition_data, str): - disposition_data = json.loads(disposition_data) - - result.append( - { - "bank_id": row["bank_id"], - "name": row["name"], - "disposition": disposition_data, - "background": row["background"], - "created_at": row["created_at"].isoformat() if row["created_at"] else None, - "updated_at": row["updated_at"].isoformat() if row["updated_at"] else None, - } - ) - - return result diff --git a/hindsight-api/hindsight_api/engine/retain/chunk_storage.py b/hindsight-api/hindsight_api/engine/retain/chunk_storage.py deleted file mode 100644 index 34941a10..00000000 --- a/hindsight-api/hindsight_api/engine/retain/chunk_storage.py +++ /dev/null @@ -1,75 +0,0 @@ -""" -Chunk storage for retain pipeline. - -Handles storage of document chunks in the database. -""" - -import logging - -from ..memory_engine import fq_table -from .types import ChunkMetadata - -logger = logging.getLogger(__name__) - - -async def store_chunks_batch(conn, bank_id: str, document_id: str, chunks: list[ChunkMetadata]) -> dict[int, str]: - """ - Store document chunks in the database. - - Args: - conn: Database connection - bank_id: Bank identifier - document_id: Document identifier - chunks: List of ChunkMetadata objects - - Returns: - Dictionary mapping global chunk index to chunk_id - """ - if not chunks: - return {} - - # Prepare chunk data for batch insert - chunk_ids = [] - chunk_texts = [] - chunk_indices = [] - chunk_id_map = {} - - 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_indices.append(chunk.chunk_index) - chunk_id_map[chunk.chunk_index] = chunk_id - - # Batch insert all chunks - await conn.execute( - f""" - INSERT INTO {fq_table("chunks")} (chunk_id, document_id, bank_id, chunk_text, chunk_index) - SELECT * FROM unnest($1::text[], $2::text[], $3::text[], $4::text[], $5::integer[]) - """, - chunk_ids, - [document_id] * len(chunk_texts), - [bank_id] * len(chunk_texts), - chunk_texts, - chunk_indices, - ) - - return chunk_id_map - - -def map_facts_to_chunks(facts_chunk_indices: list[int], chunk_id_map: dict[int, str]) -> list[str | None]: - """ - Map fact chunk indices to chunk IDs. - - Args: - facts_chunk_indices: List of chunk indices for each fact - chunk_id_map: Dictionary mapping chunk index to chunk_id - - Returns: - List of chunk_ids (same length as facts_chunk_indices) - """ - chunk_ids = [] - for chunk_idx in facts_chunk_indices: - chunk_id = chunk_id_map.get(chunk_idx) - chunk_ids.append(chunk_id) - return chunk_ids diff --git a/hindsight-api/hindsight_api/engine/retain/deduplication.py b/hindsight-api/hindsight_api/engine/retain/deduplication.py deleted file mode 100644 index ead97001..00000000 --- a/hindsight-api/hindsight_api/engine/retain/deduplication.py +++ /dev/null @@ -1,85 +0,0 @@ -""" -Deduplication logic for retain pipeline. - -Checks for duplicate facts using semantic similarity and temporal proximity. -""" - -import logging -from collections import defaultdict -from datetime import UTC - -from .types import ProcessedFact - -logger = logging.getLogger(__name__) - - -async def check_duplicates_batch(conn, bank_id: str, facts: list[ProcessedFact], duplicate_checker_fn) -> list[bool]: - """ - Check which facts are duplicates using batched time-window queries. - - Groups facts by 12-hour time buckets to efficiently check for duplicates - within a 24-hour window. - - Args: - conn: Database connection - bank_id: Bank identifier - facts: List of ProcessedFact objects to check - duplicate_checker_fn: Async function(conn, bank_id, texts, embeddings, date, time_window_hours) - that returns List[bool] indicating duplicates - - Returns: - List of boolean flags (same length as facts) indicating if each fact is a duplicate - """ - if not facts: - return [] - - # Group facts by event_date (rounded to 12-hour buckets) for efficient batching - time_buckets = defaultdict(list) - for idx, fact in enumerate(facts): - # Use occurred_start if available, otherwise use mentioned_at - # For deduplication purposes, we need a time reference - fact_date = fact.occurred_start if fact.occurred_start is not None else fact.mentioned_at - - # Defensive: if both are None (shouldn't happen), use now() - if fact_date is None: - from datetime import datetime - - fact_date = datetime.now(UTC) - - # Round to 12-hour bucket to group similar times - bucket_key = fact_date.replace(hour=(fact_date.hour // 12) * 12, minute=0, second=0, microsecond=0) - time_buckets[bucket_key].append((idx, fact)) - - # Process each bucket in batch - all_is_duplicate = [False] * len(facts) - - for bucket_date, bucket_items in time_buckets.items(): - indices = [item[0] for item in bucket_items] - texts = [item[1].fact_text for item in bucket_items] - embeddings = [item[1].embedding for item in bucket_items] - - # Check duplicates for this time bucket - dup_flags = await duplicate_checker_fn(conn, bank_id, texts, embeddings, bucket_date, time_window_hours=24) - - # Map results back to original indices - for idx, is_dup in zip(indices, dup_flags): - all_is_duplicate[idx] = is_dup - - return all_is_duplicate - - -def filter_duplicates(facts: list[ProcessedFact], is_duplicate_flags: list[bool]) -> list[ProcessedFact]: - """ - Filter out duplicate facts based on duplicate flags. - - Args: - facts: List of ProcessedFact objects - is_duplicate_flags: Boolean flags indicating which facts are duplicates - - Returns: - List of non-duplicate facts - """ - if len(facts) != len(is_duplicate_flags): - raise ValueError(f"Mismatch between facts ({len(facts)}) and flags ({len(is_duplicate_flags)})") - - return [fact for fact, is_dup in zip(facts, is_duplicate_flags) if not is_dup] diff --git a/hindsight-api/hindsight_api/engine/retain/embedding_processing.py b/hindsight-api/hindsight_api/engine/retain/embedding_processing.py deleted file mode 100644 index 0ee63f6b..00000000 --- a/hindsight-api/hindsight_api/engine/retain/embedding_processing.py +++ /dev/null @@ -1,55 +0,0 @@ -""" -Embedding processing for retain pipeline. - -Handles augmenting fact texts with temporal information and generating embeddings. -""" - -import logging - -from . import embedding_utils -from .types import ExtractedFact - -logger = logging.getLogger(__name__) - - -def augment_texts_with_dates(facts: list[ExtractedFact], format_date_fn) -> list[str]: - """ - Augment fact texts with readable dates for better temporal matching. - - This allows queries like "camping in June" to match facts that happened in June. - - Args: - facts: List of ExtractedFact objects - format_date_fn: Function to format datetime to readable string - - Returns: - List of augmented text strings (same length as facts) - """ - augmented_texts = [] - for fact in facts: - # Use occurred_start as the representative date - fact_date = fact.occurred_start or fact.mentioned_at - readable_date = format_date_fn(fact_date) - # Augment text with date for embedding (but store original text in DB) - augmented_text = f"{fact.fact_text} (happened in {readable_date})" - augmented_texts.append(augmented_text) - return augmented_texts - - -async def generate_embeddings_batch(embeddings_model, texts: list[str]) -> list[list[float]]: - """ - Generate embeddings for a batch of texts. - - Args: - embeddings_model: Embeddings model instance - texts: List of text strings to embed - - Returns: - List of embedding vectors (same length as texts) - """ - if not texts: - return [] - - embeddings = await embedding_utils.generate_embeddings_batch(embeddings_model, texts) - - return embeddings diff --git a/hindsight-api/hindsight_api/engine/retain/embedding_utils.py b/hindsight-api/hindsight_api/engine/retain/embedding_utils.py deleted file mode 100644 index 53ebc762..00000000 --- a/hindsight-api/hindsight_api/engine/retain/embedding_utils.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -Embedding generation utilities for memory units. -""" - -import asyncio -import logging - -logger = logging.getLogger(__name__) - - -def generate_embedding(embeddings_backend, text: str) -> list[float]: - """ - Generate embedding for text using the provided embeddings backend. - - Args: - embeddings_backend: Embeddings instance to use for encoding - text: Text to embed - - Returns: - Embedding vector (dimension depends on embeddings backend) - """ - try: - embeddings = embeddings_backend.encode([text]) - return embeddings[0] - except Exception as e: - raise Exception(f"Failed to generate embedding: {str(e)}") - - -async def generate_embeddings_batch(embeddings_backend, texts: list[str]) -> list[list[float]]: - """ - Generate embeddings for multiple texts using the provided embeddings backend. - - Runs the embedding generation in a thread pool to avoid blocking the event loop - for CPU-bound operations. - - Args: - embeddings_backend: Embeddings instance to use for encoding - texts: List of texts to embed - - Returns: - List of embeddings in same order as input texts - """ - try: - # Run embeddings in thread pool to avoid blocking event loop - loop = asyncio.get_event_loop() - embeddings = await loop.run_in_executor( - None, # Use default thread pool - embeddings_backend.encode, - texts, - ) - return embeddings - except Exception as e: - raise Exception(f"Failed to generate batch embeddings: {str(e)}") diff --git a/hindsight-api/hindsight_api/engine/retain/entity_processing.py b/hindsight-api/hindsight_api/engine/retain/entity_processing.py deleted file mode 100644 index 12e6409a..00000000 --- a/hindsight-api/hindsight_api/engine/retain/entity_processing.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Entity processing for retain pipeline. - -Handles entity extraction, resolution, and link creation for stored facts. -""" - -import logging - -from . import link_utils -from .types import EntityLink, ProcessedFact - -logger = logging.getLogger(__name__) - - -async def process_entities_batch( - entity_resolver, conn, bank_id: str, unit_ids: list[str], facts: list[ProcessedFact], log_buffer: list[str] = None -) -> list[EntityLink]: - """ - Process entities for all facts and create entity links. - - This function: - 1. Extracts entity mentions from fact texts - 2. Resolves entity names to canonical entities - 3. Creates entity records in the database - 4. Returns entity links ready for insertion - - Args: - entity_resolver: EntityResolver instance for entity resolution - conn: Database connection - bank_id: Bank identifier - unit_ids: List of unit IDs (same length as facts) - facts: List of ProcessedFact objects - log_buffer: Optional buffer for detailed logging - - Returns: - List of EntityLink objects for batch insertion - """ - if not unit_ids or not facts: - return [] - - if len(unit_ids) != len(facts): - raise ValueError(f"Mismatch between unit_ids ({len(unit_ids)}) and facts ({len(facts)})") - - # Extract data for link_utils function - fact_texts = [fact.fact_text for fact in facts] - # Use occurred_start if available, otherwise use mentioned_at for entity timestamps - fact_dates = [fact.occurred_start if fact.occurred_start is not None else fact.mentioned_at for fact in facts] - # Convert EntityRef objects to dict format expected by link_utils - entities_per_fact = [ - [{"text": entity.name, "type": "CONCEPT"} for entity in (fact.entities or [])] for fact in facts - ] - - # Use existing link_utils function for entity processing - entity_links = await link_utils.extract_entities_batch_optimized( - entity_resolver, - conn, - bank_id, - unit_ids, - fact_texts, - "", # context (not used in current implementation) - fact_dates, - entities_per_fact, - log_buffer, # Pass log_buffer for detailed logging - ) - - return entity_links - - -async def insert_entity_links_batch(conn, entity_links: list[EntityLink]) -> None: - """ - Insert entity links in batch. - - Args: - conn: Database connection - entity_links: List of EntityLink objects - """ - if not entity_links: - return - - await link_utils.insert_entity_links_batch(conn, entity_links) diff --git a/hindsight-api/hindsight_api/engine/retain/fact_extraction.py b/hindsight-api/hindsight_api/engine/retain/fact_extraction.py deleted file mode 100644 index 07227dba..00000000 --- a/hindsight-api/hindsight_api/engine/retain/fact_extraction.py +++ /dev/null @@ -1,1101 +0,0 @@ -""" -Fact extraction from text using LLM. - -Extracts semantic facts, entities, and temporal information from text. -Uses the LLMConfig wrapper for all LLM calls. -""" - -import asyncio -import json -import logging -import re -from datetime import datetime, timedelta -from typing import Literal - -from pydantic import BaseModel, ConfigDict, Field, field_validator - -from ..llm_wrapper import LLMConfig, OutputTooLongError - - -def _sanitize_text(text: str) -> str: - """ - Sanitize text by removing invalid Unicode surrogate characters. - - Surrogate characters (U+D800 to U+DFFF) are used in UTF-16 encoding - but cannot be encoded in UTF-8. They can appear in Python strings - from improperly decoded data (e.g., from JavaScript or broken files). - - This function removes unpaired surrogates to prevent UnicodeEncodeError - when the text is sent to the LLM API. - """ - if not text: - return text - # Remove surrogate characters (U+D800 to U+DFFF) using regex - # These are invalid in UTF-8 and cause encoding errors - return re.sub(r"[\ud800-\udfff]", "", text) - - -class Entity(BaseModel): - """An entity extracted from text.""" - - text: str = Field( - description="The specific, named entity as it appears in the fact. Must be a proper noun or specific identifier." - ) - - -class Fact(BaseModel): - """ - Final fact model for storage - built from lenient parsing of LLM response. - - This is what fact_extraction returns and what the rest of the pipeline expects. - Combined fact text format: "what | when | where | who | why" - """ - - # Required fields - fact: str = Field(description="Combined fact text: what | when | where | who | why") - fact_type: Literal["world", "experience", "opinion"] = Field(description="Perspective: world/experience/opinion") - - # Optional temporal fields - occurred_start: str | None = None - occurred_end: str | None = None - mentioned_at: str | None = None - - # Optional location field - where: str | None = Field( - None, description="WHERE the fact occurred or is about (specific location, place, or area)" - ) - - # Optional structured data - entities: list[Entity] | None = None - causal_relations: list["CausalRelation"] | None = None - - -class CausalRelation(BaseModel): - """Causal relationship between facts.""" - - target_fact_index: int = Field( - description="Index of the related fact in the facts array (0-based). " - "This creates a directed causal link to another fact in the extraction." - ) - relation_type: Literal["causes", "caused_by", "enables", "prevents"] = Field( - description="Type of causal relationship: " - "'causes' = this fact directly causes the target fact, " - "'caused_by' = this fact was caused by the target fact, " - "'enables' = this fact enables/allows the target fact, " - "'prevents' = this fact prevents/blocks the target fact" - ) - strength: float = Field( - description="Strength of causal relationship (0.0 to 1.0). " - "1.0 = direct/strong causation, 0.5 = moderate, 0.3 = weak/indirect", - ge=0.0, - le=1.0, - default=1.0, - ) - - -class ExtractedFact(BaseModel): - """A single extracted fact with 5 required dimensions for comprehensive capture.""" - - model_config = ConfigDict( - json_schema_mode="validation", - json_schema_extra={"required": ["what", "when", "where", "who", "why", "fact_type"]}, - ) - - # ========================================================================== - # FIVE REQUIRED DIMENSIONS - LLM must think about each one - # ========================================================================== - - what: str = Field( - description="WHAT happened - COMPLETE, DETAILED description with ALL specifics. " - "NEVER summarize or omit details. Include: exact actions, objects, quantities, specifics. " - "BE VERBOSE - capture every detail that was mentioned. " - "Example: 'Emily got married to Sarah at a rooftop garden ceremony with 50 guests attending and a live jazz band playing' " - "NOT: 'A wedding happened' or 'Emily got married'" - ) - - when: str = Field( - description="WHEN it happened - ALWAYS include temporal information if mentioned. " - "Include: specific dates, times, durations, relative time references. " - "Examples: 'on June 15th, 2024 at 3pm', 'last weekend', 'for the past 3 years', 'every morning at 6am'. " - "Write 'N/A' ONLY if absolutely no temporal context exists. Prefer converting to absolute dates when possible." - ) - - where: str = Field( - description="WHERE it happened or is about - SPECIFIC locations, places, areas, regions if applicable. " - "Include: cities, neighborhoods, venues, buildings, countries, specific addresses when mentioned. " - "Examples: 'downtown San Francisco at a rooftop garden venue', 'at the user's home in Brooklyn', 'online via Zoom', 'Paris, France'. " - "Write 'N/A' ONLY if absolutely no location context exists or if the fact is completely location-agnostic." - ) - - who: str = Field( - description="WHO is involved - ALL people/entities with FULL context and relationships. " - "Include: names, roles, relationships to user, background details. " - "Resolve coreferences (if 'my roommate' is later named 'Emily', write 'Emily, the user's college roommate'). " - "BE DETAILED about relationships and roles. " - "Example: 'Emily (user's college roommate from Stanford, now works at Google), Sarah (Emily's partner of 5 years, software engineer)' " - "NOT: 'my friend' or 'Emily and Sarah'" - ) - - why: str = Field( - description="WHY it matters - ALL emotional, contextual, and motivational details. " - "Include EVERYTHING: feelings, preferences, motivations, observations, context, background, significance. " - "BE VERBOSE - capture all the nuance and meaning. " - "FOR ASSISTANT FACTS: MUST include what the user asked/requested that led to this interaction! " - "Example (world): 'The user felt thrilled and inspired, has always dreamed of an outdoor ceremony, mentioned wanting a similar garden venue, was particularly moved by the intimate atmosphere and personal vows' " - "Example (assistant): 'User asked how to fix slow API performance with 1000+ concurrent users, expected 70-80% reduction in database load' " - "NOT: 'User liked it' or 'To help user'" - ) - - # ========================================================================== - # CLASSIFICATION - # ========================================================================== - - fact_kind: str = Field( - default="conversation", - description="'event' = specific datable occurrence (set occurred dates), 'conversation' = general info (no occurred dates)", - ) - - # Temporal fields - optional - occurred_start: str | None = Field( - default=None, - description="WHEN the event happened (ISO timestamp). Only for fact_kind='event'. Leave null for conversations.", - ) - occurred_end: str | None = Field( - default=None, - description="WHEN the event ended (ISO timestamp). Only for events with duration. Leave null for conversations.", - ) - - # Classification (CRITICAL - required) - # Note: LLM uses "assistant" but we convert to "bank" for storage - fact_type: Literal["world", "assistant"] = Field( - description="'world' = about the user/others (background, experiences). 'assistant' = experience with the assistant." - ) - - # Entities - extracted from fact content - entities: list[Entity] | None = Field( - default=None, - description="Named entities, objects, AND abstract concepts from the fact. Include: people names, organizations, places, significant objects (e.g., 'coffee maker', 'car'), AND abstract concepts/themes (e.g., 'friendship', 'career growth', 'loss', 'celebration'). Extract anything that could help link related facts together.", - ) - causal_relations: list[CausalRelation] | None = Field( - default=None, description="Causal links to other facts. Can be null." - ) - - @field_validator("entities", mode="before") - @classmethod - def ensure_entities_list(cls, v): - """Ensure entities is always a list (convert None to empty list).""" - if v is None: - return [] - return v - - @field_validator("causal_relations", mode="before") - @classmethod - def ensure_causal_relations_list(cls, v): - """Ensure causal_relations is always a list (convert None to empty list).""" - if v is None: - return [] - return v - - def build_fact_text(self) -> str: - """Combine all dimensions into a single comprehensive fact string.""" - parts = [self.what] - - # Add 'who' if not N/A - if self.who and self.who.upper() != "N/A": - parts.append(f"Involving: {self.who}") - - # Add 'why' if not N/A - if self.why and self.why.upper() != "N/A": - parts.append(self.why) - - if len(parts) == 1: - return parts[0] - - return " | ".join(parts) - - -class FactExtractionResponse(BaseModel): - """Response containing all extracted facts.""" - - facts: list[ExtractedFact] = Field(description="List of extracted factual statements") - - -def chunk_text(text: str, max_chars: int) -> 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. - - Args: - text: Input text to chunk (plain text or JSON conversation) - max_chars: Maximum characters per chunk (default 120k ≈ 30k tokens) - - 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] - - # 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) - 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) - ], - ) - - return splitter.split_text(text) - - -def _chunk_conversation(turns: list[dict], max_chars: 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 - - Returns: - List of JSON-serialized chunks, each containing complete turns - """ - - chunks = [] - current_chunk = [] - current_size = 2 # Account for "[]" - - 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 - - # 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 "[]" - - # 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)) - - return chunks if chunks else [json.dumps(turns, ensure_ascii=False)] - - -async def _extract_facts_from_chunk( - chunk: str, - chunk_index: int, - total_chunks: int, - event_date: datetime, - context: str, - llm_config: "LLMConfig", - agent_name: str = None, - extract_opinions: bool = False, -) -> list[dict[str, str]]: - """ - Extract facts from a single chunk (internal helper for parallel processing). - - Note: event_date parameter is kept for backward compatibility but not used in prompt. - The LLM extracts temporal information from the context string instead. - """ - memory_bank_context = f"\n- Your name: {agent_name}" if agent_name and extract_opinions else "" - - # Determine which fact types to extract based on the flag - # Note: We use "assistant" in the prompt but convert to "bank" for storage - if extract_opinions: - # Opinion extraction uses a separate prompt (not this one) - fact_types_instruction = "Extract ONLY 'opinion' type facts (formed opinions, beliefs, and perspectives). DO NOT extract 'world' or 'assistant' facts." - else: - fact_types_instruction = ( - "Extract ONLY 'world' and 'assistant' type facts. DO NOT extract opinions - those are extracted separately." - ) - - prompt = f"""Extract facts from text into structured format with FOUR required dimensions - BE EXTREMELY DETAILED. - -{fact_types_instruction} - - - -══════════════════════════════════════════════════════════════════════════ -FACT FORMAT - ALL FIVE DIMENSIONS REQUIRED - MAXIMUM VERBOSITY -══════════════════════════════════════════════════════════════════════════ - -For EACH fact, CAPTURE ALL DETAILS - NEVER SUMMARIZE OR OMIT: - -1. **what**: WHAT happened - COMPLETE description with ALL specifics (objects, actions, quantities, details) -2. **when**: WHEN it happened - ALWAYS include temporal info with DAY OF WEEK (e.g., "Monday, June 10, 2024") - - Always include the day name: Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday - - Format: "day_name, month day, year" (e.g., "Saturday, June 9, 2024") -3. **where**: WHERE it happened or is about - SPECIFIC locations, places, areas, regions (if applicable) -4. **who**: WHO is involved - ALL people/entities with FULL relationships and background -5. **why**: WHY it matters - ALL emotions, preferences, motivations, significance, nuance - - For assistant facts: MUST include what the user asked/requested that triggered this! - -Plus: fact_type, fact_kind, entities, occurred_start/end (for structured dates), where (structured location) - -VERBOSITY REQUIREMENT: Include EVERY detail mentioned. More detail is ALWAYS better than less. - -══════════════════════════════════════════════════════════════════════════ -COREFERENCE RESOLUTION (CRITICAL) -══════════════════════════════════════════════════════════════════════════ - -When text uses BOTH a generic relation AND a name for the same person → LINK THEM! - -Example input: "I went to my college roommate's wedding last June. Emily finally married Sarah after 5 years together." - -CORRECT output: -- what: "Emily got married to Sarah at a rooftop garden ceremony" -- when: "Saturday, June 8, 2024, after dating for 5 years" -- where: "downtown San Francisco, at a rooftop garden venue" -- who: "Emily (user's college roommate), Sarah (Emily's partner of 5 years)" -- why: "User found it romantic and beautiful, dreams of similar outdoor ceremony" -- where (structured): "San Francisco" - -WRONG output: -- what: "User's roommate got married" ← LOSES THE NAME! -- who: "the roommate" ← WRONG - use the actual name! -- where: (missing) ← WRONG - include the location! - -══════════════════════════════════════════════════════════════════════════ -FACT_KIND CLASSIFICATION (CRITICAL FOR TEMPORAL HANDLING) -══════════════════════════════════════════════════════════════════════════ - -⚠️ MUST set fact_kind correctly - this determines whether occurred_start/end are set! - -fact_kind="event" - USE FOR: -- Actions that happened at a specific time: "went to", "attended", "visited", "bought", "made" -- Past events: "yesterday I...", "last week...", "in March 2020..." -- Future plans with dates: "will go to", "scheduled for" -- Examples: "I went to a pottery workshop" → event - "Alice visited Paris in February" → event - "I bought a new car yesterday" → event - "The user graduated from MIT in March 2020" → event - -fact_kind="conversation" - USE FOR: -- Ongoing states: "works as", "lives in", "is married to" -- Preferences: "loves", "prefers", "enjoys" -- Traits/abilities: "speaks fluent French", "knows Python" -- Examples: "I love Italian food" → conversation - "Alice works at Google" → conversation - "I prefer outdoor dining" → conversation - -══════════════════════════════════════════════════════════════════════════ -TEMPORAL HANDLING (CRITICAL - USE EVENT DATE AS REFERENCE) -══════════════════════════════════════════════════════════════════════════ - -⚠️ IMPORTANT: Use the "Event Date" provided in the input as your reference point! -All relative dates ("yesterday", "last week", "recently") must be resolved relative to the Event Date, NOT today's date. - -For EVENTS (fact_kind="event") - MUST SET BOTH occurred_start AND occurred_end: -- Convert relative dates → absolute using Event Date as reference -- If Event Date is "Saturday, March 15, 2020", then "yesterday" = Friday, March 14, 2020 -- Dates mentioned in text (e.g., "in March 2020") should use THAT year, not current year -- Always include the day name (Monday, Tuesday, etc.) in the 'when' field -- Set occurred_start AND occurred_end to WHEN IT HAPPENED (not when mentioned) -- For single-day/point events: set occurred_end = occurred_start (same timestamp) - -For CONVERSATIONS (fact_kind="conversation"): -- General info, preferences, ongoing states → NO occurred dates -- Examples: "loves coffee", "works as engineer" - -══════════════════════════════════════════════════════════════════════════ -FACT TYPE -══════════════════════════════════════════════════════════════════════════ - -- **world**: User's life, other people, events (would exist without this conversation) -- **assistant**: Interactions with assistant (requests, recommendations, help) - ⚠️ CRITICAL for assistant facts: ALWAYS capture the user's request/question in the fact! - Include: what the user asked, what problem they wanted solved, what context they provided - -══════════════════════════════════════════════════════════════════════════ -USER PREFERENCES (CRITICAL) -══════════════════════════════════════════════════════════════════════════ - -ALWAYS extract user preferences as separate facts! Watch for these keywords: -- "enjoy", "like", "love", "prefer", "hate", "dislike", "favorite", "ideal", "dream", "want" - -Example: "I love Italian food and prefer outdoor dining" -→ Fact 1: what="User loves Italian food", who="user", why="This is a food preference", entities=["user"] -→ Fact 2: what="User prefers outdoor dining", who="user", why="This is a dining preference", entities=["user"] - -══════════════════════════════════════════════════════════════════════════ -ENTITIES - INCLUDE PEOPLE, PLACES, OBJECTS, AND CONCEPTS (CRITICAL) -══════════════════════════════════════════════════════════════════════════ - -Extract entities that help link related facts together. Include: -1. "user" - when the fact is about the user -2. People names - Emily, Dr. Smith, etc. -3. Organizations/Places - IKEA, Goodwill, New York, etc. -4. Specific objects - coffee maker, toaster, car, laptop, kitchen, etc. -5. Abstract concepts - themes, values, emotions, or ideas that capture the essence of the fact: - - "friendship" for facts about friends helping each other, bonding, loyalty - - "career growth" for facts about promotions, learning new skills, job changes - - "loss" or "grief" for facts about death, endings, saying goodbye - - "celebration" for facts about parties, achievements, milestones - - "trust" or "betrayal" for facts involving those themes - -✅ CORRECT: entities=["user", "coffee maker", "Goodwill", "kitchen"] for "User donated their coffee maker to Goodwill" -✅ CORRECT: entities=["user", "Emily", "friendship"] for "Emily helped user move to a new apartment" -✅ CORRECT: entities=["user", "promotion", "career growth"] for "User got promoted to senior engineer" -✅ CORRECT: entities=["user", "grandmother", "loss", "grief"] for "User's grandmother passed away last week" -❌ WRONG: entities=["user", "Emily"] only - missing the "friendship" concept that links to other friendship facts! - -══════════════════════════════════════════════════════════════════════════ -EXAMPLES -══════════════════════════════════════════════════════════════════════════ - -Example 1 - World Facts (Event Date: Tuesday, June 10, 2024): -Input: "I'm planning my wedding and want a small outdoor ceremony. I just got back from my college roommate Emily's wedding - she married Sarah at a rooftop garden, it was so romantic!" - -Output facts: - -1. User's wedding preference - - what: "User wants a small outdoor ceremony for their wedding" - - who: "user" - - why: "User prefers intimate outdoor settings" - - fact_type: "world", fact_kind: "conversation" - - entities: ["user", "wedding", "outdoor ceremony"] - -2. User planning wedding - - what: "User is planning their own wedding" - - who: "user" - - why: "Inspired by Emily's ceremony" - - fact_type: "world", fact_kind: "conversation" - - entities: ["user", "wedding"] - -3. Emily's wedding (THE EVENT - note occurred_start AND occurred_end both set) - - what: "Emily got married to Sarah at a rooftop garden ceremony in the city" - - who: "Emily (user's college roommate), Sarah (Emily's partner)" - - why: "User found it romantic and beautiful" - - fact_type: "world", fact_kind: "event" - - occurred_start: "2024-06-09T00:00:00Z" (recently, user "just got back" - relative to Event Date June 10, 2024) - - occurred_end: "2024-06-09T23:59:59Z" (same day - point event) - - entities: ["user", "Emily", "Sarah", "wedding", "rooftop garden"] - -Example 2 - Assistant Facts (Context: March 5, 2024): -Input: "User: My API is really slow when we have 1000+ concurrent users. What can I do? -Assistant: I'd recommend implementing Redis for caching frequently-accessed data, which should reduce your database load by 70-80%." - -Output fact: - - what: "Assistant recommended implementing Redis for caching frequently-accessed data to improve API performance" - - when: "March 5, 2024 during conversation" - - who: "user, assistant" - - why: "User asked how to fix slow API performance with 1000+ concurrent users, expected 70-80% reduction in database load" - - fact_type: "assistant", fact_kind: "conversation" - - entities: ["user", "API", "Redis"] - -Example 3 - Kitchen Items with Concept Inference (Event Date: Thursday, May 30, 2024): -Input: "I finally donated my old coffee maker to Goodwill. I upgraded to that new espresso machine last month and the old one was just taking up counter space." - -Output fact: - - what: "User donated their old coffee maker to Goodwill after upgrading to a new espresso machine" - - when: "Thursday, May 30, 2024" - - who: "user" - - why: "The old coffee maker was taking up counter space after the upgrade" - - fact_type: "world", fact_kind: "event" - - occurred_start: "2024-05-30T00:00:00Z" (uses Event Date year) - - occurred_end: "2024-05-30T23:59:59Z" (same day - point event) - - entities: ["user", "coffee maker", "Goodwill", "espresso machine", "kitchen"] - -Note: "kitchen" is inferred as a concept because coffee makers and espresso machines are kitchen appliances. -This links the fact to other kitchen-related facts (toaster, faucet, kitchen mat, etc.) via the shared "kitchen" entity. - -Note how the "why" field captures the FULL STORY: what the user asked AND what outcome was expected! - -══════════════════════════════════════════════════════════════════════════ -WHAT TO EXTRACT vs SKIP -══════════════════════════════════════════════════════════════════════════ - -✅ EXTRACT: User preferences (ALWAYS as separate facts!), feelings, plans, events, relationships, achievements -❌ SKIP: Greetings, filler ("thanks", "cool"), purely structural statements""" - - import logging - - from openai import BadRequestError - - logger = logging.getLogger(__name__) - - # Retry logic for JSON validation errors - max_retries = 2 - last_error = None - - # Sanitize input text to prevent Unicode encoding errors (e.g., unpaired surrogates) - sanitized_chunk = _sanitize_text(chunk) - sanitized_context = _sanitize_text(context) if context else "none" - - # Build user message with metadata and chunk content in a clear format - # Format event_date with day of week for better temporal reasoning - event_date_formatted = event_date.strftime("%A, %B %d, %Y") # e.g., "Monday, June 10, 2024" - user_message = f"""Extract facts from the following text chunk. -{memory_bank_context} - -Chunk: {chunk_index + 1}/{total_chunks} -Event Date: {event_date_formatted} ({event_date.isoformat()}) -Context: {sanitized_context} - -Text: -{sanitized_chunk}""" - - for attempt in range(max_retries): - try: - extraction_response_json = await llm_config.call( - messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}], - response_format=FactExtractionResponse, - scope="memory_extract_facts", - temperature=0.1, - max_completion_tokens=65000, - skip_validation=True, # Get raw JSON, we'll validate leniently - ) - - # Lenient parsing of facts from raw JSON - chunk_facts = [] - has_malformed_facts = False - - # Handle malformed LLM responses - if not isinstance(extraction_response_json, dict): - if attempt < max_retries - 1: - logger.warning( - f"LLM returned non-dict JSON on attempt {attempt + 1}/{max_retries}: {type(extraction_response_json).__name__}. Retrying..." - ) - continue - else: - logger.warning( - f"LLM returned non-dict JSON after {max_retries} attempts: {type(extraction_response_json).__name__}. " - f"Raw: {str(extraction_response_json)[:500]}" - ) - return [] - - raw_facts = extraction_response_json.get("facts", []) - if not raw_facts: - logger.debug( - f"LLM response missing 'facts' field or returned empty list. " - f"Response: {extraction_response_json}. " - f"Input: " - f"date: {event_date.isoformat()}, " - f"context: {context if context else 'none'}, " - f"text: {chunk}" - ) - - for i, llm_fact in enumerate(raw_facts): - # Skip non-dict entries but track them for retry - if not isinstance(llm_fact, dict): - logger.warning(f"Skipping non-dict fact at index {i}") - has_malformed_facts = True - continue - - # Helper to get non-empty value - def get_value(field_name): - value = llm_fact.get(field_name) - if value and value != "" and value != [] and value != {} and str(value).upper() != "N/A": - return value - return None - - # NEW FORMAT: what, when, who, why (all required) - what = get_value("what") - when = get_value("when") - who = get_value("who") - why = get_value("why") - - # Fallback to old format if new fields not present - if not what: - what = get_value("factual_core") - if not what: - logger.warning(f"Skipping fact {i}: missing 'what' field") - continue - - # Critical field: fact_type - # LLM uses "assistant" but we convert to "experience" for storage - fact_type = llm_fact.get("fact_type") - - # Convert "assistant" → "experience" for storage - if fact_type == "assistant": - fact_type = "experience" - - # Validate fact_type (after conversion) - if fact_type not in ["world", "experience", "opinion"]: - # Try to fix common mistakes - check if they swapped fact_type and fact_kind - fact_kind = llm_fact.get("fact_kind") - if fact_kind == "assistant": - fact_type = "experience" - elif fact_kind in ["world", "experience", "opinion"]: - fact_type = fact_kind - else: - # Default to 'world' if we can't determine - fact_type = "world" - logger.warning(f"Fact {i}: defaulting to fact_type='world'") - - # Get fact_kind for temporal handling (but don't store it) - fact_kind = llm_fact.get("fact_kind", "conversation") - if fact_kind not in ["conversation", "event", "other"]: - fact_kind = "conversation" - - # Build combined fact text from the 4 dimensions: what | when | who | why - fact_data = {} - combined_parts = [what] - - if when: - combined_parts.append(f"When: {when}") - - if who: - combined_parts.append(f"Involving: {who}") - - if why: - combined_parts.append(why) - - combined_text = " | ".join(combined_parts) - - # Add temporal fields - # For events: occurred_start/occurred_end (when the event happened) - if fact_kind == "event": - occurred_start = get_value("occurred_start") - occurred_end = get_value("occurred_end") - if occurred_start: - fact_data["occurred_start"] = occurred_start - # For point events: if occurred_end not set, default to occurred_start - if occurred_end: - fact_data["occurred_end"] = occurred_end - else: - fact_data["occurred_end"] = occurred_start - - # Add entities if present (validate as Entity objects) - # LLM sometimes returns strings instead of {"text": "..."} format - entities = get_value("entities") - if entities: - # Validate and normalize each entity - validated_entities = [] - for ent in entities: - if isinstance(ent, str): - # Normalize string to Entity object - validated_entities.append(Entity(text=ent)) - elif isinstance(ent, dict) and "text" in ent: - try: - validated_entities.append(Entity.model_validate(ent)) - except Exception as e: - logger.warning(f"Invalid entity {ent}: {e}") - if validated_entities: - fact_data["entities"] = validated_entities - - # Add causal relations if present (validate as CausalRelation objects) - # Filter out invalid relations (missing required fields) - causal_relations = get_value("causal_relations") - if causal_relations: - validated_relations = [] - for rel in causal_relations: - if isinstance(rel, dict) and "target_fact_index" in rel and "relation_type" in rel: - try: - validated_relations.append(CausalRelation.model_validate(rel)) - except Exception as e: - logger.warning(f"Invalid causal relation {rel}: {e}") - if validated_relations: - fact_data["causal_relations"] = validated_relations - - # Always set mentioned_at to the event_date (when the conversation/document occurred) - fact_data["mentioned_at"] = event_date.isoformat() - - # Build Fact model instance - try: - 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}") - has_malformed_facts = True - continue - - # If we got malformed facts and haven't exhausted retries, try again - if has_malformed_facts and len(chunk_facts) < len(raw_facts) * 0.8 and attempt < max_retries - 1: - logger.warning( - f"Got {len(raw_facts) - len(chunk_facts)} malformed facts out of {len(raw_facts)} on attempt {attempt + 1}/{max_retries}. Retrying..." - ) - continue - - return chunk_facts - - except BadRequestError as e: - last_error = e - if "json_validate_failed" in str(e): - logger.warning( - f" [1.3.{chunk_index + 1}] Attempt {attempt + 1}/{max_retries} failed with JSON validation error: {e}" - ) - if attempt < max_retries - 1: - logger.info(f" [1.3.{chunk_index + 1}] Retrying...") - continue - # If it's not a JSON validation error or we're out of retries, re-raise - raise - - # If we exhausted all retries, raise the last error - raise last_error - - -async def _extract_facts_with_auto_split( - chunk: str, - chunk_index: int, - total_chunks: int, - event_date: datetime, - context: str, - llm_config: LLMConfig, - agent_name: str = None, - extract_opinions: bool = False, -) -> list[dict[str, str]]: - """ - Extract facts from a chunk with automatic splitting if output exceeds token limits. - - If the LLM output is too long (OutputTooLongError), this function automatically - splits the chunk in half and processes each half recursively. - - Args: - chunk: Text chunk to process - chunk_index: Index of this chunk in the original list - total_chunks: Total number of original chunks - event_date: Reference date for temporal information - context: Context about the conversation/document - llm_config: LLM configuration to use - agent_name: Optional agent name (memory owner) - extract_opinions: If True, extract ONLY opinions. If False, extract world and agent facts (no opinions) - - Returns: - List of fact dictionaries extracted from the chunk (possibly from sub-chunks) - """ - import logging - - logger = logging.getLogger(__name__) - - try: - # Try to extract facts from the full chunk - return await _extract_facts_from_chunk( - chunk=chunk, - chunk_index=chunk_index, - total_chunks=total_chunks, - event_date=event_date, - context=context, - llm_config=llm_config, - agent_name=agent_name, - extract_opinions=extract_opinions, - ) - except OutputTooLongError: - # Output exceeded token limits - split the chunk in half and retry - logger.warning( - f"Output too long for chunk {chunk_index + 1}/{total_chunks} " - f"({len(chunk)} chars). Splitting in half and retrying..." - ) - - # Split at the midpoint, preferring sentence boundaries - mid_point = len(chunk) // 2 - - # Try to find a sentence boundary near the midpoint - # Look for ". ", "! ", "? " within 20% of midpoint - search_range = int(len(chunk) * 0.2) - search_start = max(0, mid_point - search_range) - search_end = min(len(chunk), mid_point + search_range) - - sentence_endings = [". ", "! ", "? ", "\n\n"] - best_split = mid_point - - for ending in sentence_endings: - pos = chunk.rfind(ending, search_start, search_end) - if pos != -1: - best_split = pos + len(ending) - break - - # Split the chunk - first_half = chunk[:best_split].strip() - second_half = chunk[best_split:].strip() - - logger.info( - f"Split chunk {chunk_index + 1} into two sub-chunks: {len(first_half)} chars and {len(second_half)} chars" - ) - - # Process both halves recursively (in parallel) - sub_tasks = [ - _extract_facts_with_auto_split( - chunk=first_half, - chunk_index=chunk_index, - total_chunks=total_chunks, - event_date=event_date, - context=context, - llm_config=llm_config, - agent_name=agent_name, - extract_opinions=extract_opinions, - ), - _extract_facts_with_auto_split( - chunk=second_half, - chunk_index=chunk_index, - total_chunks=total_chunks, - event_date=event_date, - context=context, - llm_config=llm_config, - agent_name=agent_name, - extract_opinions=extract_opinions, - ), - ] - - sub_results = await asyncio.gather(*sub_tasks) - - # Combine results from both halves - all_facts = [] - for sub_result in sub_results: - all_facts.extend(sub_result) - - logger.info(f"Successfully extracted {len(all_facts)} facts from split chunk {chunk_index + 1}") - - return all_facts - - -async def extract_facts_from_text( - text: str, - event_date: datetime, - llm_config: LLMConfig, - agent_name: str, - context: str = "", - extract_opinions: bool = False, -) -> tuple[list[Fact], list[tuple[str, int]]]: - """ - Extract semantic facts from conversational or narrative text using LLM. - - For large texts (>3000 chars), automatically chunks at sentence boundaries - to avoid hitting output token limits. Processes ALL chunks in PARALLEL for speed. - - If a chunk produces output that exceeds token limits (OutputTooLongError), it is - automatically split in half and retried recursively until successful. - - Args: - text: Input text (conversation, article, etc.) - event_date: Reference date for resolving relative times - context: Context about the conversation/document - llm_config: LLM configuration to use - agent_name: Agent name (memory owner) - extract_opinions: If True, extract ONLY opinions. If False, extract world and bank facts (no opinions) - - Returns: - Tuple of (facts, chunks) where: - - facts: List of Fact model instances - - chunks: List of tuples (chunk_text, fact_count) for each chunk - """ - chunks = chunk_text(text, max_chars=3000) - tasks = [ - _extract_facts_with_auto_split( - chunk=chunk, - chunk_index=i, - total_chunks=len(chunks), - event_date=event_date, - context=context, - llm_config=llm_config, - agent_name=agent_name, - extract_opinions=extract_opinions, - ) - for i, chunk in enumerate(chunks) - ] - chunk_results = await asyncio.gather(*tasks) - all_facts = [] - chunk_metadata = [] # [(chunk_text, fact_count), ...] - for chunk, chunk_facts in zip(chunks, chunk_results): - all_facts.extend(chunk_facts) - chunk_metadata.append((chunk, len(chunk_facts))) - return all_facts, chunk_metadata - - -# ============================================================================ -# ORCHESTRATION LAYER -# ============================================================================ - -# Import types for the orchestration layer (note: ExtractedFact here is different from the Pydantic model above) - -from .types import CausalRelation as CausalRelationType -from .types import ChunkMetadata, RetainContent -from .types import ExtractedFact as ExtractedFactType - -logger = logging.getLogger(__name__) - -# Each fact gets 10 seconds offset to preserve ordering within a document -SECONDS_PER_FACT = 10 - - -async def extract_facts_from_contents( - contents: list[RetainContent], llm_config, agent_name: str, extract_opinions: bool = False -) -> tuple[list[ExtractedFactType], list[ChunkMetadata]]: - """ - Extract facts from multiple content items in parallel. - - This function: - 1. Extracts facts from all contents in parallel using the LLM - 2. Tracks which facts came from which chunks - 3. Adds time offsets to preserve fact ordering within each content - 4. Returns typed ExtractedFact and ChunkMetadata objects - - Args: - contents: List of RetainContent objects to process - llm_config: LLM configuration for fact extraction - agent_name: Name of the agent (for agent-related fact detection) - extract_opinions: If True, extract only opinions; otherwise world/bank facts - - Returns: - Tuple of (extracted_facts, chunks_metadata) - """ - if not contents: - return [], [] - - # Step 1: Create parallel fact extraction tasks - fact_extraction_tasks = [] - for item in contents: - # Call extract_facts_from_text directly (defined earlier in this file) - # to avoid circular import with utils.extract_facts - task = extract_facts_from_text( - text=item.content, - event_date=item.event_date, - context=item.context, - llm_config=llm_config, - agent_name=agent_name, - extract_opinions=extract_opinions, - ) - fact_extraction_tasks.append(task) - - # Step 2: Wait for all fact extractions to complete - all_fact_results = await asyncio.gather(*fact_extraction_tasks) - - # Step 3: Flatten and convert to typed objects - extracted_facts: list[ExtractedFactType] = [] - chunks_metadata: list[ChunkMetadata] = [] - - global_chunk_idx = 0 - global_fact_idx = 0 - - for content_index, (content, (facts_from_llm, chunks_from_llm)) in enumerate(zip(contents, all_fact_results)): - chunk_start_idx = global_chunk_idx - - # Convert chunk tuples to ChunkMetadata objects - for chunk_index_in_content, (chunk_text, chunk_fact_count) in enumerate(chunks_from_llm): - chunk_metadata = ChunkMetadata( - chunk_text=chunk_text, - fact_count=chunk_fact_count, - content_index=content_index, - chunk_index=global_chunk_idx, - ) - chunks_metadata.append(chunk_metadata) - global_chunk_idx += 1 - - # Convert facts to ExtractedFact objects with proper indexing - fact_idx_in_content = 0 - for chunk_idx_in_content, (chunk_text, chunk_fact_count) in enumerate(chunks_from_llm): - chunk_global_idx = chunk_start_idx + chunk_idx_in_content - - for _ in range(chunk_fact_count): - if fact_idx_in_content < len(facts_from_llm): - fact_from_llm = facts_from_llm[fact_idx_in_content] - - # Convert Fact model from LLM to ExtractedFactType dataclass - # mentioned_at is always the event_date (when the conversation/document occurred) - extracted_fact = ExtractedFactType( - fact_text=fact_from_llm.fact, - fact_type=fact_from_llm.fact_type, - entities=[e.text for e in (fact_from_llm.entities or [])], - # occurred_start/end: from LLM only, leave None if not provided - occurred_start=_parse_datetime(fact_from_llm.occurred_start) - if fact_from_llm.occurred_start - else None, - occurred_end=_parse_datetime(fact_from_llm.occurred_end) - if fact_from_llm.occurred_end - else None, - causal_relations=_convert_causal_relations( - fact_from_llm.causal_relations or [], global_fact_idx - ), - content_index=content_index, - chunk_index=chunk_global_idx, - context=content.context, - # mentioned_at: always the event_date (when the conversation/document occurred) - mentioned_at=content.event_date, - metadata=content.metadata, - ) - - extracted_facts.append(extracted_fact) - global_fact_idx += 1 - fact_idx_in_content += 1 - - # Step 4: Add time offsets to preserve ordering within each content - _add_temporal_offsets(extracted_facts, contents) - - return extracted_facts, chunks_metadata - - -def _parse_datetime(date_str: str): - """Parse ISO datetime string.""" - from dateutil import parser as date_parser - - try: - return date_parser.isoparse(date_str) - except Exception: - return None - - -def _convert_causal_relations(relations_from_llm, fact_start_idx: int) -> list[CausalRelationType]: - """ - Convert causal relations from LLM format to ExtractedFact format. - - Adjusts target_fact_index from content-relative to global indices. - """ - causal_relations = [] - for rel in relations_from_llm: - causal_relation = CausalRelationType( - relation_type=rel.relation_type, - target_fact_index=fact_start_idx + rel.target_fact_index, - strength=rel.strength, - ) - causal_relations.append(causal_relation) - return causal_relations - - -def _add_temporal_offsets(facts: list[ExtractedFactType], contents: list[RetainContent]) -> None: - """ - Add time offsets to preserve fact ordering within each content. - - This allows retrieval to distinguish between facts that happened earlier vs later - in the same conversation, even when the base event_date is the same. - - Modifies facts in place. - """ - # Group facts by content_index - current_content_idx = 0 - content_fact_start = 0 - - for i, fact in enumerate(facts): - if fact.content_index != current_content_idx: - # Moved to next content - current_content_idx = fact.content_index - content_fact_start = i - - # Calculate position within this content - fact_position = i - content_fact_start - offset = timedelta(seconds=fact_position * SECONDS_PER_FACT) - - # Apply offset to all temporal fields - if fact.occurred_start: - fact.occurred_start = fact.occurred_start + offset - if fact.occurred_end: - fact.occurred_end = fact.occurred_end + offset - if fact.mentioned_at: - fact.mentioned_at = fact.mentioned_at + offset diff --git a/hindsight-api/hindsight_api/engine/retain/fact_storage.py b/hindsight-api/hindsight_api/engine/retain/fact_storage.py deleted file mode 100644 index 9122204b..00000000 --- a/hindsight-api/hindsight_api/engine/retain/fact_storage.py +++ /dev/null @@ -1,167 +0,0 @@ -""" -Fact storage for retain pipeline. - -Handles insertion of facts into the database. -""" - -import json -import logging - -from ..memory_engine import fq_table -from .types import ProcessedFact - -logger = logging.getLogger(__name__) - - -async def insert_facts_batch( - conn, bank_id: str, facts: list[ProcessedFact], document_id: str | None = None -) -> list[str]: - """ - Insert facts into the database in batch. - - Args: - conn: Database connection - bank_id: Bank identifier - facts: List of ProcessedFact objects to insert - document_id: Optional document ID to associate with facts - - Returns: - List of unit IDs (UUIDs as strings) for the inserted facts - """ - if not facts: - return [] - - # Prepare data for batch insert - fact_texts = [] - embeddings = [] - event_dates = [] - occurred_starts = [] - occurred_ends = [] - mentioned_ats = [] - contexts = [] - fact_types = [] - confidence_scores = [] - access_counts = [] - metadata_jsons = [] - chunk_ids = [] - document_ids = [] - - for fact in facts: - fact_texts.append(fact.fact_text) - # Convert embedding to string for asyncpg vector type - embeddings.append(str(fact.embedding)) - # event_date: Use occurred_start if available, otherwise use mentioned_at - # This maintains backward compatibility while handling None occurred_start - event_dates.append(fact.occurred_start if fact.occurred_start is not None else fact.mentioned_at) - occurred_starts.append(fact.occurred_start) - occurred_ends.append(fact.occurred_end) - mentioned_ats.append(fact.mentioned_at) - contexts.append(fact.context) - fact_types.append(fact.fact_type) - # confidence_score is only for opinion facts - confidence_scores.append(1.0 if fact.fact_type == "opinion" else None) - access_counts.append(0) # Initial access count - metadata_jsons.append(json.dumps(fact.metadata)) - chunk_ids.append(fact.chunk_id) - # Use per-fact document_id if available, otherwise fallback to batch-level document_id - document_ids.append(fact.document_id if fact.document_id else document_id) - - # Batch insert all facts - results = await conn.fetch( - f""" - INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at, - context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id) - SELECT $1, * FROM unnest( - $2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[], - $8::text[], $9::text[], $10::float[], $11::int[], $12::jsonb[], $13::text[], $14::text[] - ) - RETURNING id - """, - bank_id, - fact_texts, - embeddings, - event_dates, # event_date: occurred_start if available, else mentioned_at - occurred_starts, - occurred_ends, - mentioned_ats, - contexts, - fact_types, - confidence_scores, - access_counts, - metadata_jsons, - chunk_ids, - document_ids, - ) - - unit_ids = [str(row["id"]) for row in results] - return unit_ids - - -async def ensure_bank_exists(conn, bank_id: str) -> None: - """ - Ensure bank exists in the database. - - Creates bank with default values if it doesn't exist. - - Args: - conn: Database connection - bank_id: Bank identifier - """ - await conn.execute( - f""" - INSERT INTO {fq_table("banks")} (bank_id, disposition, background) - VALUES ($1, $2::jsonb, $3) - ON CONFLICT (bank_id) DO UPDATE - SET updated_at = NOW() - """, - bank_id, - '{"skepticism": 3, "literalism": 3, "empathy": 3}', - "", - ) - - -async def handle_document_tracking( - conn, bank_id: str, document_id: str, combined_content: str, is_first_batch: bool, retain_params: dict | None = None -) -> None: - """ - Handle document tracking in the database. - - Args: - conn: Database connection - bank_id: Bank identifier - document_id: Document identifier - combined_content: Combined content text from all content items - is_first_batch: Whether this is the first batch (for chunked operations) - retain_params: Optional parameters passed during retain (context, event_date, etc.) - """ - import hashlib - - # Calculate content hash - content_hash = hashlib.sha256(combined_content.encode()).hexdigest() - - # Always delete old document first if it exists (cascades to units and links) - # Only delete on the first batch to avoid deleting data we just inserted - if is_first_batch: - await conn.fetchval( - f"DELETE FROM {fq_table('documents')} WHERE id = $1 AND bank_id = $2 RETURNING id", document_id, bank_id - ) - - # Insert document (or update if exists from concurrent operations) - await conn.execute( - f""" - INSERT INTO {fq_table("documents")} (id, bank_id, original_text, content_hash, metadata, retain_params) - VALUES ($1, $2, $3, $4, $5, $6) - ON CONFLICT (id, bank_id) DO UPDATE - SET original_text = EXCLUDED.original_text, - content_hash = EXCLUDED.content_hash, - metadata = EXCLUDED.metadata, - retain_params = EXCLUDED.retain_params, - updated_at = NOW() - """, - document_id, - bank_id, - combined_content, - content_hash, - json.dumps({}), # Empty metadata dict - json.dumps(retain_params) if retain_params else None, - ) diff --git a/hindsight-api/hindsight_api/engine/retain/link_creation.py b/hindsight-api/hindsight_api/engine/retain/link_creation.py deleted file mode 100644 index b4f453f6..00000000 --- a/hindsight-api/hindsight_api/engine/retain/link_creation.py +++ /dev/null @@ -1,99 +0,0 @@ -""" -Link creation for retain pipeline. - -Handles creation of temporal, semantic, and causal links between facts. -""" - -import logging - -from . import link_utils -from .types import ProcessedFact - -logger = logging.getLogger(__name__) - - -async def create_temporal_links_batch(conn, bank_id: str, unit_ids: list[str]) -> int: - """ - Create temporal links between facts. - - Links facts that occurred close in time to each other. - - Args: - conn: Database connection - bank_id: Bank identifier - unit_ids: List of unit IDs to create links for - - Returns: - Number of temporal links created - """ - if not unit_ids: - return 0 - - return await link_utils.create_temporal_links_batch_per_fact(conn, bank_id, unit_ids, log_buffer=[]) - - -async def create_semantic_links_batch(conn, bank_id: str, unit_ids: list[str], embeddings: list[list[float]]) -> int: - """ - Create semantic links between facts. - - Links facts that are semantically similar based on embeddings. - - Args: - conn: Database connection - bank_id: Bank identifier - unit_ids: List of unit IDs to create links for - embeddings: List of embedding vectors (same length as unit_ids) - - Returns: - Number of semantic links created - """ - if not unit_ids or not embeddings: - return 0 - - if len(unit_ids) != len(embeddings): - raise ValueError(f"Mismatch between unit_ids ({len(unit_ids)}) and embeddings ({len(embeddings)})") - - return await link_utils.create_semantic_links_batch(conn, bank_id, unit_ids, embeddings, log_buffer=[]) - - -async def create_causal_links_batch(conn, unit_ids: list[str], facts: list[ProcessedFact]) -> int: - """ - Create causal links between facts. - - Links facts that have causal relationships (causes, enables, prevents). - - Args: - conn: Database connection - unit_ids: List of unit IDs (same length as facts) - facts: List of ProcessedFact objects with causal_relations - - Returns: - Number of causal links created - """ - if not unit_ids or not facts: - return 0 - - if len(unit_ids) != len(facts): - raise ValueError(f"Mismatch between unit_ids ({len(unit_ids)}) and facts ({len(facts)})") - - # Extract causal relations in the format expected by link_utils - # Format: List of lists, where each inner list is the causal relations for that fact - causal_relations_per_fact = [] - for fact in facts: - if fact.causal_relations: - # Convert CausalRelation objects to dicts - relations_dicts = [ - { - "relation_type": rel.relation_type, - "target_fact_index": rel.target_fact_index, - "strength": rel.strength, - } - for rel in fact.causal_relations - ] - causal_relations_per_fact.append(relations_dicts) - else: - causal_relations_per_fact.append([]) - - link_count = await link_utils.create_causal_links_batch(conn, unit_ids, causal_relations_per_fact) - - return link_count diff --git a/hindsight-api/hindsight_api/engine/retain/link_utils.py b/hindsight-api/hindsight_api/engine/retain/link_utils.py deleted file mode 100644 index 19aa310e..00000000 --- a/hindsight-api/hindsight_api/engine/retain/link_utils.py +++ /dev/null @@ -1,836 +0,0 @@ -""" -Link creation utilities for temporal, semantic, and entity links. -""" - -import logging -import time -from datetime import UTC, datetime, timedelta -from uuid import UUID - -from ..memory_engine import fq_table -from .types import EntityLink - -logger = logging.getLogger(__name__) - - -def _normalize_datetime(dt): - """Normalize datetime to be timezone-aware (UTC) for consistent comparison.""" - if dt is None: - return None - if dt.tzinfo is None: - # Naive datetime - assume UTC - return dt.replace(tzinfo=UTC) - return dt - - -def compute_temporal_links( - new_units: dict, - candidates: list, - time_window_hours: int = 24, -) -> list: - """ - Compute temporal links between new units and candidate neighbors. - - This is a pure function that takes query results and returns link tuples, - making it easy to test without database access. - - Args: - new_units: Dict mapping unit_id (str) to event_date (datetime) - candidates: List of dicts with 'id' and 'event_date' keys (candidate neighbors) - time_window_hours: Time window in hours for temporal links - - Returns: - List of tuples: (from_unit_id, to_unit_id, 'temporal', weight, None) - """ - if not new_units: - return [] - - links = [] - for unit_id, unit_event_date in new_units.items(): - # Normalize unit_event_date for consistent comparison - unit_event_date_norm = _normalize_datetime(unit_event_date) - - # Calculate time window bounds with overflow protection - try: - time_lower = unit_event_date_norm - timedelta(hours=time_window_hours) - except OverflowError: - time_lower = datetime.min.replace(tzinfo=UTC) - try: - time_upper = unit_event_date_norm + timedelta(hours=time_window_hours) - except OverflowError: - time_upper = datetime.max.replace(tzinfo=UTC) - - # Filter candidates within this unit's time window - matching_neighbors = [ - (row["id"], row["event_date"]) - for row in candidates - if time_lower <= _normalize_datetime(row["event_date"]) <= time_upper - ][:10] # Limit to top 10 - - for recent_id, recent_event_date in matching_neighbors: - # Calculate temporal proximity weight - time_diff_hours = abs( - (unit_event_date_norm - _normalize_datetime(recent_event_date)).total_seconds() / 3600 - ) - weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours)) - links.append((unit_id, str(recent_id), "temporal", weight, None)) - - return links - - -def compute_temporal_query_bounds( - new_units: dict, - time_window_hours: int = 24, -) -> tuple: - """ - Compute the min/max date bounds for querying temporal neighbors. - - Args: - new_units: Dict mapping unit_id (str) to event_date (datetime) - time_window_hours: Time window in hours - - Returns: - Tuple of (min_date, max_date) with overflow protection - """ - if not new_units: - return None, None - - # Normalize all dates to be timezone-aware to avoid comparison issues - all_dates = [_normalize_datetime(d) for d in new_units.values()] - - try: - min_date = min(all_dates) - timedelta(hours=time_window_hours) - except OverflowError: - min_date = datetime.min.replace(tzinfo=UTC) - - try: - max_date = max(all_dates) + timedelta(hours=time_window_hours) - except OverflowError: - max_date = datetime.max.replace(tzinfo=UTC) - - return min_date, max_date - - -def _log(log_buffer, message, level="info"): - """Helper to log to buffer if available, otherwise use logger. - - Args: - log_buffer: Buffer to append messages to (for main output) - message: The log message - level: 'info', 'debug', 'warning', or 'error'. Debug messages are not added to buffer. - """ - if level == "debug": - # Debug messages only go to logger, not to buffer - logger.debug(message) - return - - if log_buffer is not None: - log_buffer.append(message) - else: - if level == "info": - logger.info(message) - else: - logger.log(logging.WARNING if level == "warning" else logging.ERROR, message) - - -async def extract_entities_batch_optimized( - entity_resolver, - conn, - bank_id: str, - unit_ids: list[str], - sentences: list[str], - context: str, - fact_dates: list, - llm_entities: list[list[dict]], - log_buffer: list[str] = None, -) -> list[tuple]: - """ - Process LLM-extracted entities for ALL facts in batch. - - Uses entities provided by the LLM (no spaCy needed), then resolves - and links them in bulk. - - Args: - entity_resolver: EntityResolver instance for entity resolution - conn: Database connection - agent_id: bank IDentifier - unit_ids: List of unit IDs - sentences: List of fact sentences - context: Context string - fact_dates: List of fact dates - llm_entities: List of entity lists from LLM extraction - log_buffer: Optional buffer for logging - - Returns: - List of tuples for batch insertion: (from_unit_id, to_unit_id, link_type, weight, entity_id) - """ - try: - # Step 1: Convert LLM entities to the format expected by entity resolver - substep_start = time.time() - all_entities = [] - for entity_list in llm_entities: - # Convert List[Entity] or List[dict] to List[Dict] format - formatted_entities = [] - for ent in entity_list: - # Handle both Entity objects and dicts - if hasattr(ent, "text"): - # Entity objects only have 'text', default type to 'CONCEPT' - formatted_entities.append({"text": ent.text, "type": "CONCEPT"}) - elif isinstance(ent, dict): - formatted_entities.append({"text": ent.get("text", ""), "type": ent.get("type", "CONCEPT")}) - all_entities.append(formatted_entities) - - total_entities = sum(len(ents) for ents in all_entities) - _log( - log_buffer, - f" [6.1] Process LLM entities: {total_entities} entities from {len(sentences)} facts in {time.time() - substep_start:.3f}s", - level="debug", - ) - - # Step 2: Resolve entities in BATCH (much faster!) - substep_start = time.time() - step_6_2_start = time.time() - - # [6.2.1] Prepare all entities for batch resolution - substep_6_2_1_start = time.time() - all_entities_flat = [] - entity_to_unit = [] # Maps flat index to (unit_id, local_index) - - for unit_id, entities, fact_date in zip(unit_ids, all_entities, fact_dates): - if not entities: - continue - - for local_idx, entity in enumerate(entities): - all_entities_flat.append( - { - "text": entity["text"], - "type": entity["type"], - "nearby_entities": entities, - } - ) - entity_to_unit.append((unit_id, local_idx, fact_date)) - _log( - log_buffer, - f" [6.2.1] Prepare entities: {len(all_entities_flat)} entities in {time.time() - substep_6_2_1_start:.3f}s", - level="debug", - ) - - # Resolve ALL entities in one batch call - if all_entities_flat: - # [6.2.2] Batch resolve entities - single call with per-entity dates - substep_6_2_2_start = time.time() - - # Add per-entity dates to entity data for batch resolution - for idx, (unit_id, local_idx, fact_date) in enumerate(entity_to_unit): - all_entities_flat[idx]["event_date"] = fact_date - - # Resolve ALL entities in ONE batch call (much faster than sequential buckets) - # INSERT ... ON CONFLICT handles any race conditions at the DB level - resolved_entity_ids = await entity_resolver.resolve_entities_batch( - bank_id=bank_id, - entities_data=all_entities_flat, - context=context, - unit_event_date=None, # Not used when per-entity dates provided - conn=conn, # Use main transaction connection - ) - - _log( - log_buffer, - f" [6.2.2] Resolve entities: {len(all_entities_flat)} entities in single batch in {time.time() - substep_6_2_2_start:.3f}s", - level="debug", - ) - - # [6.2.3] Create unit-entity links in BATCH - substep_6_2_3_start = time.time() - # Map resolved entities back to units and collect all (unit, entity) pairs - unit_to_entity_ids = {} - unit_entity_pairs = [] - for idx, (unit_id, local_idx, fact_date) in enumerate(entity_to_unit): - if unit_id not in unit_to_entity_ids: - unit_to_entity_ids[unit_id] = [] - - entity_id = resolved_entity_ids[idx] - unit_to_entity_ids[unit_id].append(entity_id) - unit_entity_pairs.append((unit_id, entity_id)) - - # Batch insert all unit-entity links (MUCH faster!) - await entity_resolver.link_units_to_entities_batch(unit_entity_pairs, conn=conn) - _log( - log_buffer, - f" [6.2.3] Create unit-entity links (batched): {len(unit_entity_pairs)} links in {time.time() - substep_6_2_3_start:.3f}s", - level="debug", - ) - - _log( - log_buffer, - f" [6.2] Entity resolution (batched): {len(all_entities_flat)} entities resolved in {time.time() - step_6_2_start:.3f}s", - level="debug", - ) - else: - unit_to_entity_ids = {} - _log( - log_buffer, - f" [6.2] Entity resolution (batched): 0 entities in {time.time() - step_6_2_start:.3f}s", - level="debug", - ) - - # Step 3: Create entity links between units that share entities - substep_start = time.time() - # Collect all unique entity IDs - all_entity_ids = set() - for entity_ids in unit_to_entity_ids.values(): - all_entity_ids.update(entity_ids) - - _log(log_buffer, f" [6.3] Creating entity links for {len(all_entity_ids)} unique entities...", level="debug") - - # Find all units that reference these entities (ONE batched query) - entity_to_units = {} - if all_entity_ids: - query_start = time.time() - import uuid - - entity_id_list = [uuid.UUID(eid) if isinstance(eid, str) else eid for eid in all_entity_ids] - rows = await conn.fetch( - f""" - SELECT entity_id, unit_id - FROM {fq_table("unit_entities")} - WHERE entity_id = ANY($1::uuid[]) - """, - entity_id_list, - ) - _log( - log_buffer, - f" [6.3.1] Query unit_entities: {len(rows)} rows in {time.time() - query_start:.3f}s", - level="debug", - ) - - # Group by entity_id - group_start = time.time() - for row in rows: - entity_id = row["entity_id"] - if entity_id not in entity_to_units: - entity_to_units[entity_id] = [] - entity_to_units[entity_id].append(row["unit_id"]) - _log(log_buffer, f" [6.3.2] Group by entity_id: {time.time() - group_start:.3f}s", level="debug") - - # Create bidirectional links between units that share entities - # OPTIMIZATION: Limit links per entity to avoid N² explosion - # Only link each new unit to the most recent MAX_LINKS_PER_ENTITY units - MAX_LINKS_PER_ENTITY = 50 # Limit to prevent explosion when entity appears in many facts - link_gen_start = time.time() - links: list[EntityLink] = [] - new_unit_set = set(unit_ids) # Units from this batch - - def to_uuid(val) -> UUID: - return UUID(val) if isinstance(val, str) else val - - for entity_id, units_with_entity in entity_to_units.items(): - entity_uuid = to_uuid(entity_id) - # Separate new units (from this batch) and existing units - new_units = [u for u in units_with_entity if str(u) in new_unit_set or u in new_unit_set] - existing_units = [u for u in units_with_entity if str(u) not in new_unit_set and u not in new_unit_set] - - # Link new units to each other (within batch) - also limited - # For very common entities, limit within-batch links too - new_units_to_link = ( - new_units[-MAX_LINKS_PER_ENTITY:] if len(new_units) > MAX_LINKS_PER_ENTITY else new_units - ) - for i, unit_id_1 in enumerate(new_units_to_link): - for unit_id_2 in new_units_to_link[i + 1 :]: - links.append( - EntityLink( - from_unit_id=to_uuid(unit_id_1), to_unit_id=to_uuid(unit_id_2), entity_id=entity_uuid - ) - ) - links.append( - EntityLink( - from_unit_id=to_uuid(unit_id_2), to_unit_id=to_uuid(unit_id_1), entity_id=entity_uuid - ) - ) - - # Link new units to LIMITED existing units (most recent) - existing_to_link = existing_units[-MAX_LINKS_PER_ENTITY:] # Take most recent - for new_unit in new_units: - for existing_unit in existing_to_link: - links.append( - EntityLink( - from_unit_id=to_uuid(new_unit), to_unit_id=to_uuid(existing_unit), entity_id=entity_uuid - ) - ) - links.append( - EntityLink( - from_unit_id=to_uuid(existing_unit), to_unit_id=to_uuid(new_unit), entity_id=entity_uuid - ) - ) - - _log( - log_buffer, f" [6.3.3] Generate {len(links)} links: {time.time() - link_gen_start:.3f}s", level="debug" - ) - _log( - log_buffer, - f" [6.3] Entity link creation: {len(links)} links for {len(all_entity_ids)} unique entities in {time.time() - substep_start:.3f}s", - level="debug", - ) - - return links - - except Exception as e: - logger.error(f"Failed to extract entities in batch: {str(e)}") - import traceback - - traceback.print_exc() - raise - - -async def create_temporal_links_batch_per_fact( - conn, - bank_id: str, - unit_ids: list[str], - time_window_hours: int = 24, - log_buffer: list[str] = None, -) -> int: - """ - Create temporal links for multiple units, each with their own event_date. - - Queries the event_date for each unit from the database and creates temporal - links based on individual dates (supports per-fact dating). - - Args: - conn: Database connection - agent_id: bank IDentifier - unit_ids: List of unit IDs - time_window_hours: Time window in hours for temporal links - log_buffer: Optional buffer for logging - - Returns: - Number of temporal links created - """ - if not unit_ids: - return 0 - - try: - import time as time_mod - - # Get the event_date for each new unit - fetch_dates_start = time_mod.time() - rows = await conn.fetch( - f""" - SELECT id, event_date - FROM {fq_table("memory_units")} - WHERE id::text = ANY($1) - """, - unit_ids, - ) - new_units = {str(row["id"]): row["event_date"] for row in rows} - _log( - log_buffer, - f" [7.1] Fetch event_dates for {len(unit_ids)} units: {time_mod.time() - fetch_dates_start:.3f}s", - ) - - # Fetch ALL potential temporal neighbors in ONE query (much faster!) - # Get time range across all units with overflow protection - min_date, max_date = compute_temporal_query_bounds(new_units, time_window_hours) - - fetch_neighbors_start = time_mod.time() - all_candidates = await conn.fetch( - f""" - SELECT id, event_date - FROM {fq_table("memory_units")} - WHERE bank_id = $1 - AND event_date BETWEEN $2 AND $3 - AND id::text != ALL($4) - ORDER BY event_date DESC - """, - bank_id, - min_date, - max_date, - unit_ids, - ) - _log( - log_buffer, - f" [7.2] Fetch {len(all_candidates)} candidate neighbors (1 query): {time_mod.time() - fetch_neighbors_start:.3f}s", - ) - - # Filter and create links in memory (much faster than N queries) - link_gen_start = time_mod.time() - links = compute_temporal_links(new_units, all_candidates, time_window_hours) - - # Also compute temporal links WITHIN the new batch (new units to each other) - if len(new_units) > 1: - # Convert new_units dict to candidate format for within-batch linking - new_unit_items = list(new_units.items()) - for i, (unit_id, event_date) in enumerate(new_unit_items): - unit_event_date_norm = _normalize_datetime(event_date) - - # Compare with other new units (only those after this one to avoid duplicates) - for j in range(i + 1, len(new_unit_items)): - other_id, other_event_date = new_unit_items[j] - other_event_date_norm = _normalize_datetime(other_event_date) - - # Check if within time window - time_diff_hours = abs((unit_event_date_norm - other_event_date_norm).total_seconds() / 3600) - if time_diff_hours <= time_window_hours: - weight = max(0.3, 1.0 - (time_diff_hours / time_window_hours)) - # Create bidirectional links - links.append((unit_id, other_id, "temporal", weight, None)) - links.append((other_id, unit_id, "temporal", weight, None)) - - _log(log_buffer, f" [7.3] Generate {len(links)} temporal links: {time_mod.time() - link_gen_start:.3f}s") - - if links: - insert_start = time_mod.time() - await conn.executemany( - f""" - INSERT INTO {fq_table("memory_links")} (from_unit_id, to_unit_id, link_type, weight, entity_id) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING - """, - links, - ) - _log(log_buffer, f" [7.4] Insert {len(links)} temporal links: {time_mod.time() - insert_start:.3f}s") - - return len(links) - - except Exception as e: - logger.error(f"Failed to create temporal links: {str(e)}") - import traceback - - traceback.print_exc() - raise - - -async def create_semantic_links_batch( - conn, - bank_id: str, - unit_ids: list[str], - embeddings: list[list[float]], - top_k: int = 5, - threshold: float = 0.7, - log_buffer: list[str] = None, -) -> int: - """ - Create semantic links for multiple units efficiently. - - For each unit, finds similar units and creates links. - - Args: - conn: Database connection - agent_id: bank IDentifier - unit_ids: List of unit IDs - embeddings: List of embedding vectors - top_k: Number of top similar units to link - threshold: Minimum similarity threshold - log_buffer: Optional buffer for logging - - Returns: - Number of semantic links created - """ - if not unit_ids or not embeddings: - return 0 - - try: - import time as time_mod - - import numpy as np - - # Fetch ALL existing units with embeddings in ONE query - fetch_start = time_mod.time() - all_existing = await conn.fetch( - f""" - SELECT id, embedding - FROM {fq_table("memory_units")} - WHERE bank_id = $1 - AND embedding IS NOT NULL - AND id::text != ALL($2) - """, - bank_id, - unit_ids, - ) - _log( - log_buffer, - f" [8.1] Fetch {len(all_existing)} existing embeddings (1 query): {time_mod.time() - fetch_start:.3f}s", - ) - - # Convert to numpy for vectorized similarity computation - compute_start = time_mod.time() - all_links = [] - - if all_existing: - # Convert existing embeddings to numpy array - existing_ids = [str(row["id"]) for row in all_existing] - # Stack embeddings as 2D array: (num_embeddings, embedding_dim) - embedding_arrays = [] - for row in all_existing: - raw_emb = row["embedding"] - # Handle different pgvector formats - if isinstance(raw_emb, str): - # Parse string format: "[1.0, 2.0, ...]" - import json - - emb = np.array(json.loads(raw_emb), dtype=np.float32) - elif isinstance(raw_emb, (list, tuple)): - emb = np.array(raw_emb, dtype=np.float32) - else: - # Try direct conversion (works for numpy arrays, pgvector objects, etc.) - emb = np.array(raw_emb, dtype=np.float32) - - # Ensure it's 1D - if emb.ndim != 1: - raise ValueError(f"Expected 1D embedding, got shape {emb.shape}") - embedding_arrays.append(emb) - - if not embedding_arrays: - existing_embeddings = np.array([]) - elif len(embedding_arrays) == 1: - # Single embedding: reshape to (1, dim) - existing_embeddings = embedding_arrays[0].reshape(1, -1) - else: - # Multiple embeddings: vstack - existing_embeddings = np.vstack(embedding_arrays) - - # For each new unit, compute similarities with ALL existing units - for unit_id, new_embedding in zip(unit_ids, embeddings): - new_emb_array = np.array(new_embedding) - - # Compute cosine similarities (dot product for normalized vectors) - similarities = np.dot(existing_embeddings, new_emb_array) - - # Find top-k above threshold - # Get indices of similarities above threshold - above_threshold = np.where(similarities >= threshold)[0] - - if len(above_threshold) > 0: - # Sort by similarity (descending) and take top-k - sorted_indices = above_threshold[np.argsort(-similarities[above_threshold])][:top_k] - - for idx in sorted_indices: - similar_id = existing_ids[idx] - # Clamp to [0, 1] to handle floating point precision issues - similarity = float(min(1.0, max(0.0, similarities[idx]))) - all_links.append((unit_id, similar_id, "semantic", similarity, None)) - - # Also compute similarities WITHIN the new batch (new units to each other) - # Apply the same top_k limit per unit as we do for existing units - if len(unit_ids) > 1: - new_embeddings_matrix = np.array(embeddings) - - for i, unit_id in enumerate(unit_ids): - # Compute similarities with all OTHER new units - other_indices = [j for j in range(len(unit_ids)) if j != i] - if not other_indices: - continue - - other_embeddings = new_embeddings_matrix[other_indices] - similarities = np.dot(other_embeddings, new_embeddings_matrix[i]) - - # Find top-k above threshold (same logic as existing units) - above_threshold = np.where(similarities >= threshold)[0] - - if len(above_threshold) > 0: - # Sort by similarity (descending) and take top-k - sorted_local_indices = above_threshold[np.argsort(-similarities[above_threshold])][:top_k] - - for local_idx in sorted_local_indices: - other_idx = other_indices[local_idx] - other_id = unit_ids[other_idx] - # Clamp to [0, 1] to handle floating point precision issues - similarity = float(min(1.0, max(0.0, similarities[local_idx]))) - all_links.append((unit_id, other_id, "semantic", similarity, None)) - - _log( - log_buffer, - f" [8.2] Compute similarities & generate {len(all_links)} semantic links: {time_mod.time() - compute_start:.3f}s", - ) - - if all_links: - insert_start = time_mod.time() - await conn.executemany( - f""" - INSERT INTO {fq_table("memory_links")} (from_unit_id, to_unit_id, link_type, weight, entity_id) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING - """, - all_links, - ) - _log( - log_buffer, f" [8.3] Insert {len(all_links)} semantic links: {time_mod.time() - insert_start:.3f}s" - ) - - return len(all_links) - - except Exception as e: - logger.error(f"Failed to create semantic links: {str(e)}") - import traceback - - traceback.print_exc() - raise - - -async def insert_entity_links_batch(conn, links: list[EntityLink], chunk_size: int = 50000): - """ - Insert all entity links using COPY to temp table + INSERT for maximum speed. - - Uses PostgreSQL COPY (via copy_records_to_table) for bulk loading, - then INSERT ... ON CONFLICT from temp table. This is the fastest - method for bulk inserts with conflict handling. - - Args: - conn: Database connection - links: List of EntityLink objects - chunk_size: Number of rows per batch (default 50000) - """ - if not links: - return - - import time as time_mod - - total_start = time_mod.time() - - # Create temp table for bulk loading - create_start = time_mod.time() - await conn.execute(""" - CREATE TEMP TABLE IF NOT EXISTS _temp_entity_links ( - from_unit_id uuid, - to_unit_id uuid, - link_type text, - weight float, - entity_id uuid - ) ON COMMIT DROP - """) - logger.debug(f" [9.1] Create temp table: {time_mod.time() - create_start:.3f}s") - - # Clear any existing data in temp table - truncate_start = time_mod.time() - await conn.execute("TRUNCATE _temp_entity_links") - logger.debug(f" [9.2] Truncate temp table: {time_mod.time() - truncate_start:.3f}s") - - # Convert EntityLink objects to tuples for COPY - convert_start = time_mod.time() - records = [] - for link in links: - records.append((link.from_unit_id, link.to_unit_id, link.link_type, link.weight, link.entity_id)) - logger.debug(f" [9.3] Convert {len(records)} records: {time_mod.time() - convert_start:.3f}s") - - # Bulk load using COPY (fastest method) - copy_start = time_mod.time() - await conn.copy_records_to_table( - "_temp_entity_links", - records=records, - columns=["from_unit_id", "to_unit_id", "link_type", "weight", "entity_id"], - ) - logger.debug(f" [9.4] COPY {len(records)} records to temp table: {time_mod.time() - copy_start:.3f}s") - - # Insert from temp table with ON CONFLICT (single query for all rows) - insert_start = time_mod.time() - await conn.execute(f""" - INSERT INTO {fq_table("memory_links")} (from_unit_id, to_unit_id, link_type, weight, entity_id) - SELECT from_unit_id, to_unit_id, link_type, weight, entity_id - FROM _temp_entity_links - ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING - """) - logger.debug(f" [9.5] INSERT from temp table: {time_mod.time() - insert_start:.3f}s") - logger.debug(f" [9.TOTAL] Entity links batch insert: {time_mod.time() - total_start:.3f}s") - - -async def create_causal_links_batch( - conn, - unit_ids: list[str], - causal_relations_per_fact: list[list[dict]], -) -> int: - """ - Create causal links between facts based on LLM-extracted causal relationships. - - Args: - conn: Database connection - unit_ids: List of unit IDs (in same order as causal_relations_per_fact) - causal_relations_per_fact: List of causal relations for each fact. - Each element is a list of dicts with: - - target_fact_index: Index into unit_ids for the target fact - - relation_type: "causes", "caused_by", "enables", or "prevents" - - strength: Float in [0.0, 1.0] representing relationship strength - - Returns: - Number of causal links created - - Causal link types: - - "causes": This fact directly causes the target fact (forward causation) - - "caused_by": This fact was caused by the target fact (backward causation) - - "enables": This fact enables/allows the target fact (enablement) - - "prevents": This fact prevents/blocks the target fact (prevention) - """ - if not unit_ids or not causal_relations_per_fact: - return 0 - - 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): - if not causal_relations: - continue - - from_unit_id = unit_ids[fact_idx] - - for relation in causal_relations: - target_idx = relation["target_fact_index"] - relation_type = relation["relation_type"] - strength = relation.get("strength", 1.0) - - # Validate relation_type - must match database constraint - valid_types = {"causes", "caused_by", "enables", "prevents"} - if relation_type not in valid_types: - logger.error( - f"Invalid relation_type '{relation_type}' (type: {type(relation_type).__name__}) " - f"from fact {fact_idx}. Must be one of: {valid_types}. " - f"Relation data: {relation}" - ) - continue - - # Validate target index - if target_idx < 0 or target_idx >= len(unit_ids): - logger.warning(f"Invalid target_fact_index {target_idx} in causal relation from fact {fact_idx}") - continue - - to_unit_id = unit_ids[target_idx] - - # Don't create self-links - if from_unit_id == to_unit_id: - continue - - # Add the causal link - # link_type is the relation_type (e.g., "causes", "caused_by") - # weight is the strength of the relationship - links.append((from_unit_id, to_unit_id, relation_type, strength, None)) - - if links: - insert_start = time_mod.time() - try: - await conn.executemany( - f""" - INSERT INTO {fq_table("memory_links")} (from_unit_id, to_unit_id, link_type, weight, entity_id) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING - """, - links, - ) - except Exception as db_error: - # Log the actual data being inserted for debugging - logger.error(f"Database insert failed for causal links. Error: {db_error}") - logger.error(f"Attempted to insert {len(links)} links. First few:") - for i, link in enumerate(links[:3]): - logger.error( - f" Link {i}: from={link[0]}, to={link[1]}, type='{link[2]}' (repr={repr(link[2])}), weight={link[3]}, entity={link[4]}" - ) - raise - - return len(links) - - except Exception as e: - logger.error(f"Failed to create causal links: {str(e)}") - import traceback - - traceback.print_exc() - raise diff --git a/hindsight-api/hindsight_api/engine/retain/observation_regeneration.py b/hindsight-api/hindsight_api/engine/retain/observation_regeneration.py deleted file mode 100644 index 2e829723..00000000 --- a/hindsight-api/hindsight_api/engine/retain/observation_regeneration.py +++ /dev/null @@ -1,252 +0,0 @@ -""" -Observation regeneration for retain pipeline. - -Regenerates entity observations as part of the retain transaction. -""" - -import logging -import time -import uuid -from datetime import UTC, datetime - -from ..memory_engine import fq_table -from ..search import observation_utils -from . import embedding_utils -from .types import EntityLink - -logger = logging.getLogger(__name__) - - -def utcnow(): - """Get current UTC time.""" - return datetime.now(UTC) - - -# Simple dataclass-like container for facts (avoid importing from memory_engine) -class MemoryFactForObservation: - def __init__(self, id: str, text: str, fact_type: str, context: str, occurred_start: str | None): - self.id = id - self.text = text - self.fact_type = fact_type - self.context = context - self.occurred_start = occurred_start - - -async def regenerate_observations_batch( - conn, embeddings_model, llm_config, bank_id: str, entity_links: list[EntityLink], log_buffer: list[str] = None -) -> None: - """ - Regenerate observations for top entities in this batch. - - Called INSIDE the retain transaction for atomicity - if observations - fail, the entire retain batch is rolled back. - - Args: - conn: Database connection (from the retain transaction) - embeddings_model: Embeddings model for generating observation embeddings - llm_config: LLM configuration for observation extraction - bank_id: Bank identifier - entity_links: Entity links from this batch - log_buffer: Optional log buffer for timing - """ - TOP_N_ENTITIES = 5 - MIN_FACTS_THRESHOLD = 5 - - if not entity_links: - return - - # Count mentions per entity in this batch - entity_mention_counts: dict[str, int] = {} - for link in entity_links: - if link.entity_id: - entity_id = str(link.entity_id) - entity_mention_counts[entity_id] = entity_mention_counts.get(entity_id, 0) + 1 - - if not entity_mention_counts: - return - - # Sort by mention count descending and take top N - sorted_entities = sorted(entity_mention_counts.items(), key=lambda x: x[1], reverse=True) - entities_to_process = [e[0] for e in sorted_entities[:TOP_N_ENTITIES]] - - obs_start = time.time() - - # Convert to UUIDs - entity_uuids = [uuid.UUID(eid) if isinstance(eid, str) else eid for eid in entities_to_process] - - # Batch query for entity names - entity_rows = await conn.fetch( - f""" - SELECT id, canonical_name FROM {fq_table("entities")} - WHERE id = ANY($1) AND bank_id = $2 - """, - entity_uuids, - bank_id, - ) - entity_names = {row["id"]: row["canonical_name"] for row in entity_rows} - - # Batch query for fact counts - fact_counts = await conn.fetch( - f""" - SELECT ue.entity_id, COUNT(*) as cnt - FROM {fq_table("unit_entities")} ue - JOIN {fq_table("memory_units")} mu ON ue.unit_id = mu.id - WHERE ue.entity_id = ANY($1) AND mu.bank_id = $2 - GROUP BY ue.entity_id - """, - entity_uuids, - bank_id, - ) - entity_fact_counts = {row["entity_id"]: row["cnt"] for row in fact_counts} - - # Filter entities that meet the threshold - entities_with_names = [] - for entity_id in entities_to_process: - entity_uuid = uuid.UUID(entity_id) if isinstance(entity_id, str) else entity_id - if entity_uuid not in entity_names: - continue - fact_count = entity_fact_counts.get(entity_uuid, 0) - if fact_count >= MIN_FACTS_THRESHOLD: - entities_with_names.append((entity_id, entity_names[entity_uuid])) - - if not entities_with_names: - return - - # Process entities SEQUENTIALLY (asyncpg doesn't allow concurrent queries on same connection) - # We must use the same connection to stay in the retain transaction - total_observations = 0 - - for entity_id, entity_name in entities_with_names: - try: - obs_ids = await _regenerate_entity_observations( - conn, embeddings_model, llm_config, bank_id, entity_id, entity_name - ) - total_observations += len(obs_ids) - except Exception as e: - logger.error(f"[OBSERVATIONS] Error processing entity {entity_id}: {e}") - - obs_time = time.time() - obs_start - if log_buffer is not None: - log_buffer.append( - f"[11] Observations: {total_observations} observations for {len(entities_with_names)} entities in {obs_time:.3f}s" - ) - - -async def _regenerate_entity_observations( - conn, embeddings_model, llm_config, bank_id: str, entity_id: str, entity_name: str -) -> list[str]: - """ - Regenerate observations for a single entity. - - Uses the provided connection (part of retain transaction). - - Args: - conn: Database connection (from the retain transaction) - embeddings_model: Embeddings model - llm_config: LLM configuration - bank_id: Bank identifier - entity_id: Entity UUID - entity_name: Canonical name of the entity - - Returns: - List of created observation IDs - """ - entity_uuid = uuid.UUID(entity_id) if isinstance(entity_id, str) else entity_id - - # Get all facts mentioning this entity (exclude observations themselves) - rows = await conn.fetch( - f""" - SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.fact_type - FROM {fq_table("memory_units")} mu - JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id - WHERE mu.bank_id = $1 - AND ue.entity_id = $2 - AND mu.fact_type IN ('world', 'experience') - ORDER BY mu.occurred_start DESC - LIMIT 50 - """, - bank_id, - entity_uuid, - ) - - if not rows: - return [] - - # Convert to fact objects for observation extraction - facts = [] - for row in rows: - occurred_start = row["occurred_start"].isoformat() if row["occurred_start"] else None - facts.append( - MemoryFactForObservation( - id=str(row["id"]), - text=row["text"], - fact_type=row["fact_type"], - context=row["context"], - occurred_start=occurred_start, - ) - ) - - # Extract observations using LLM - observations = await observation_utils.extract_observations_from_facts(llm_config, entity_name, facts) - - if not observations: - return [] - - # Delete old observations for this entity - await conn.execute( - f""" - DELETE FROM {fq_table("memory_units")} - WHERE id IN ( - SELECT mu.id - FROM {fq_table("memory_units")} mu - JOIN {fq_table("unit_entities")} ue ON mu.id = ue.unit_id - WHERE mu.bank_id = $1 - AND mu.fact_type = 'observation' - AND ue.entity_id = $2 - ) - """, - bank_id, - entity_uuid, - ) - - # Generate embeddings for new observations - embeddings = await embedding_utils.generate_embeddings_batch(embeddings_model, observations) - - # Insert new observations - current_time = utcnow() - created_ids = [] - - for obs_text, embedding in zip(observations, embeddings): - result = await conn.fetchrow( - f""" - INSERT INTO {fq_table("memory_units")} ( - bank_id, text, embedding, context, event_date, - occurred_start, occurred_end, mentioned_at, - fact_type, access_count - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'observation', 0) - RETURNING id - """, - bank_id, - obs_text, - str(embedding), - f"observation about {entity_name}", - current_time, - current_time, - current_time, - current_time, - ) - obs_id = str(result["id"]) - created_ids.append(obs_id) - - # Link observation to entity - await conn.execute( - f""" - INSERT INTO {fq_table("unit_entities")} (unit_id, entity_id) - VALUES ($1, $2) - """, - uuid.UUID(obs_id), - entity_uuid, - ) - - return created_ids diff --git a/hindsight-api/hindsight_api/engine/retain/orchestrator.py b/hindsight-api/hindsight_api/engine/retain/orchestrator.py deleted file mode 100644 index 26ccbd4f..00000000 --- a/hindsight-api/hindsight_api/engine/retain/orchestrator.py +++ /dev/null @@ -1,405 +0,0 @@ -""" -Main orchestrator for the retain pipeline. - -Coordinates all retain pipeline modules to store memories efficiently. -""" - -import logging -import time -import uuid -from datetime import UTC, datetime - -from ..db_utils import acquire_with_retry -from . import bank_utils - - -def utcnow(): - """Get current UTC time.""" - return datetime.now(UTC) - - -from . import ( - chunk_storage, - deduplication, - embedding_processing, - entity_processing, - fact_extraction, - fact_storage, - link_creation, - observation_regeneration, -) -from .types import ExtractedFact, ProcessedFact, RetainContent, RetainContentDict - -logger = logging.getLogger(__name__) - - -async def retain_batch( - pool, - embeddings_model, - llm_config, - entity_resolver, - task_backend, - format_date_fn, - duplicate_checker_fn, - bank_id: str, - contents_dicts: list[RetainContentDict], - document_id: str | None = None, - is_first_batch: bool = True, - fact_type_override: str | None = None, - confidence_score: float | None = None, -) -> list[list[str]]: - """ - Process a batch of content through the retain pipeline. - - Args: - pool: Database connection pool - embeddings_model: Embeddings model for generating embeddings - llm_config: LLM configuration for fact extraction - entity_resolver: Entity resolver for entity processing - task_backend: Task backend for background jobs - format_date_fn: Function to format datetime to readable string - duplicate_checker_fn: Function to check for duplicate facts - bank_id: Bank identifier - contents_dicts: List of content dictionaries - document_id: Optional document ID - is_first_batch: Whether this is the first batch - fact_type_override: Override fact type for all facts - confidence_score: Confidence score for opinions - - Returns: - List of unit ID lists (one list per content item) - """ - start_time = time.time() - total_chars = sum(len(item.get("content", "")) for item in contents_dicts) - - # Buffer all logs - log_buffer = [] - log_buffer.append(f"{'=' * 60}") - log_buffer.append(f"RETAIN_BATCH START: {bank_id}") - log_buffer.append(f"Batch size: {len(contents_dicts)} content items, {total_chars:,} chars") - log_buffer.append(f"{'=' * 60}") - - # Get bank profile - profile = await bank_utils.get_bank_profile(pool, bank_id) - agent_name = profile["name"] - - # Convert dicts to RetainContent objects - contents = [] - for item in contents_dicts: - content = RetainContent( - content=item["content"], - context=item.get("context", ""), - event_date=item.get("event_date") or utcnow(), - metadata=item.get("metadata", {}), - ) - contents.append(content) - - # Step 1: Extract facts from all contents - step_start = time.time() - extract_opinions = fact_type_override == "opinion" - - extracted_facts, chunks = await fact_extraction.extract_facts_from_contents( - contents, llm_config, agent_name, extract_opinions - ) - log_buffer.append( - f"[1] Extract facts: {len(extracted_facts)} facts, {len(chunks)} chunks from {len(contents)} contents in {time.time() - step_start:.3f}s" - ) - - if not extracted_facts: - total_time = time.time() - start_time - logger.info( - f"RETAIN_BATCH COMPLETE: 0 facts extracted from {len(contents)} contents in {total_time:.3f}s (nothing to store)" - ) - return [[] for _ in contents] - - # Apply fact_type_override if provided - if fact_type_override: - for fact in extracted_facts: - fact.fact_type = fact_type_override - - # Step 2: Augment texts and generate embeddings - step_start = time.time() - augmented_texts = embedding_processing.augment_texts_with_dates(extracted_facts, format_date_fn) - embeddings = await embedding_processing.generate_embeddings_batch(embeddings_model, augmented_texts) - log_buffer.append(f"[2] Generate embeddings: {len(embeddings)} embeddings in {time.time() - step_start:.3f}s") - - # Step 3: Convert to ProcessedFact objects (without chunk_ids yet) - processed_facts = [ - ProcessedFact.from_extracted_fact(extracted_fact, embedding) - for extracted_fact, embedding in zip(extracted_facts, embeddings) - ] - - # Track document IDs for logging - document_ids_added = [] - - # Group contents by document_id for document tracking and chunk storage - from collections import defaultdict - - contents_by_doc = defaultdict(list) - for idx, content_dict in enumerate(contents_dicts): - doc_id = content_dict.get("document_id") - contents_by_doc[doc_id].append((idx, content_dict)) - - # Step 4: Database transaction - async with acquire_with_retry(pool) as conn: - async with conn.transaction(): - # Ensure bank exists - await fact_storage.ensure_bank_exists(conn, bank_id) - - # Handle document tracking for all documents - step_start = time.time() - # Map None document_id to generated UUIDs - doc_id_mapping = {} # Maps original doc_id (including None) to actual doc_id used - - if document_id: - # Legacy: single document_id parameter - combined_content = "\n".join([c.get("content", "") for c in contents_dicts]) - retain_params = {} - if contents_dicts: - first_item = contents_dicts[0] - if first_item.get("context"): - retain_params["context"] = first_item["context"] - if first_item.get("event_date"): - retain_params["event_date"] = ( - first_item["event_date"].isoformat() - if hasattr(first_item["event_date"], "isoformat") - else str(first_item["event_date"]) - ) - if first_item.get("metadata"): - retain_params["metadata"] = first_item["metadata"] - - await fact_storage.handle_document_tracking( - conn, bank_id, document_id, combined_content, is_first_batch, retain_params - ) - document_ids_added.append(document_id) - doc_id_mapping[None] = document_id # For backwards compatibility - else: - # Handle per-item document_ids (create documents if any item has document_id or if chunks exist) - has_any_doc_ids = any(item.get("document_id") for item in contents_dicts) - - if has_any_doc_ids or chunks: - for original_doc_id, doc_contents in contents_by_doc.items(): - actual_doc_id = original_doc_id - - # Only create document record if: - # 1. Item has explicit document_id, OR - # 2. There are chunks (need document for chunk storage) - should_create_doc = (original_doc_id is not None) or chunks - - if should_create_doc: - if actual_doc_id is None: - # No document_id but have chunks - generate one - actual_doc_id = str(uuid.uuid4()) - - # Store mapping for later use - doc_id_mapping[original_doc_id] = actual_doc_id - - # Combine content for this document - combined_content = "\n".join([c.get("content", "") for _, c in doc_contents]) - - # Extract retain params from first content item - retain_params = {} - if doc_contents: - first_item = doc_contents[0][1] - if first_item.get("context"): - retain_params["context"] = first_item["context"] - if first_item.get("event_date"): - retain_params["event_date"] = ( - first_item["event_date"].isoformat() - if hasattr(first_item["event_date"], "isoformat") - else str(first_item["event_date"]) - ) - if first_item.get("metadata"): - retain_params["metadata"] = first_item["metadata"] - - await fact_storage.handle_document_tracking( - conn, bank_id, actual_doc_id, combined_content, is_first_batch, retain_params - ) - document_ids_added.append(actual_doc_id) - - if document_ids_added: - log_buffer.append( - f"[2.5] Document tracking: {len(document_ids_added)} documents in {time.time() - step_start:.3f}s" - ) - - # Store chunks and map to facts for all documents - step_start = time.time() - chunk_id_map_by_doc = {} # Maps (doc_id, chunk_index) -> chunk_id - - if chunks: - # Group chunks by their source document - chunks_by_doc = defaultdict(list) - for chunk in chunks: - # chunk.content_index tells us which content this chunk came from - original_doc_id = contents_dicts[chunk.content_index].get("document_id") - # Map to actual document_id (handles None -> generated UUID mapping) - actual_doc_id = doc_id_mapping.get(original_doc_id, original_doc_id) - if actual_doc_id is None and document_id: - actual_doc_id = document_id - chunks_by_doc[actual_doc_id].append(chunk) - - # Store chunks for each document - for doc_id, doc_chunks in chunks_by_doc.items(): - chunk_id_map = await chunk_storage.store_chunks_batch(conn, bank_id, doc_id, doc_chunks) - # Store mapping with document context - for chunk_idx, chunk_id in chunk_id_map.items(): - chunk_id_map_by_doc[(doc_id, chunk_idx)] = chunk_id - - log_buffer.append( - f"[3] Store chunks: {len(chunks)} chunks for {len(chunks_by_doc)} documents in {time.time() - step_start:.3f}s" - ) - - # Map chunk_ids and document_ids to facts - for fact, processed_fact in zip(extracted_facts, processed_facts): - # Get the original document_id for this fact's source content - original_doc_id = contents_dicts[fact.content_index].get("document_id") - # Map to actual document_id (handles None -> generated UUID mapping) - actual_doc_id = doc_id_mapping.get(original_doc_id, original_doc_id) - if actual_doc_id is None and document_id: - actual_doc_id = document_id - - # Set document_id on the fact - processed_fact.document_id = actual_doc_id - - # Map chunk_id if this fact came from a chunk - if fact.chunk_index is not None: - # Look up chunk_id using (doc_id, chunk_index) - chunk_id = chunk_id_map_by_doc.get((actual_doc_id, fact.chunk_index)) - if chunk_id: - processed_fact.chunk_id = chunk_id - else: - # No chunks - still need to set document_id on facts - for fact, processed_fact in zip(extracted_facts, processed_facts): - original_doc_id = contents_dicts[fact.content_index].get("document_id") - # Map to actual document_id (handles None -> generated UUID mapping) - actual_doc_id = doc_id_mapping.get(original_doc_id, original_doc_id) - if actual_doc_id is None and document_id: - actual_doc_id = document_id - processed_fact.document_id = actual_doc_id - - # Deduplication - step_start = time.time() - is_duplicate_flags = await deduplication.check_duplicates_batch( - conn, bank_id, processed_facts, duplicate_checker_fn - ) - log_buffer.append( - f"[4] Deduplication: {sum(is_duplicate_flags)} duplicates in {time.time() - step_start:.3f}s" - ) - - # Filter out duplicates - non_duplicate_facts = deduplication.filter_duplicates(processed_facts, is_duplicate_flags) - - if not non_duplicate_facts: - return [[] for _ in contents] - - # Insert facts (document_id is now stored per-fact) - step_start = time.time() - unit_ids = await fact_storage.insert_facts_batch(conn, bank_id, non_duplicate_facts) - log_buffer.append(f"[5] Insert facts: {len(unit_ids)} units in {time.time() - step_start:.3f}s") - - # Process entities - step_start = time.time() - entity_links = await entity_processing.process_entities_batch( - entity_resolver, conn, bank_id, unit_ids, non_duplicate_facts, log_buffer - ) - log_buffer.append(f"[6] Process entities: {len(entity_links)} links in {time.time() - step_start:.3f}s") - - # Create temporal links - step_start = time.time() - temporal_link_count = await link_creation.create_temporal_links_batch(conn, bank_id, unit_ids) - log_buffer.append(f"[7] Temporal links: {temporal_link_count} links in {time.time() - step_start:.3f}s") - - # Create semantic links - step_start = time.time() - embeddings_for_links = [fact.embedding for fact in non_duplicate_facts] - semantic_link_count = await link_creation.create_semantic_links_batch( - conn, bank_id, unit_ids, embeddings_for_links - ) - log_buffer.append(f"[8] Semantic links: {semantic_link_count} links in {time.time() - step_start:.3f}s") - - # Insert entity links - step_start = time.time() - if entity_links: - await entity_processing.insert_entity_links_batch(conn, entity_links) - log_buffer.append( - f"[9] Entity links: {len(entity_links) if entity_links else 0} links in {time.time() - step_start:.3f}s" - ) - - # Create causal links - step_start = time.time() - causal_link_count = await link_creation.create_causal_links_batch(conn, unit_ids, non_duplicate_facts) - log_buffer.append(f"[10] Causal links: {causal_link_count} links in {time.time() - step_start:.3f}s") - - # Regenerate observations INSIDE transaction for atomicity - await observation_regeneration.regenerate_observations_batch( - conn, embeddings_model, llm_config, bank_id, entity_links, log_buffer - ) - - # Map results back to original content items - result_unit_ids = _map_results_to_contents(contents, extracted_facts, is_duplicate_flags, unit_ids) - - # Trigger background tasks AFTER transaction commits (opinion reinforcement only) - await _trigger_background_tasks(task_backend, bank_id, unit_ids, non_duplicate_facts) - - # Log final summary - total_time = time.time() - start_time - log_buffer.append(f"{'=' * 60}") - log_buffer.append(f"RETAIN_BATCH COMPLETE: {len(unit_ids)} units in {total_time:.3f}s") - if document_ids_added: - log_buffer.append(f"Documents: {', '.join(document_ids_added)}") - log_buffer.append(f"{'=' * 60}") - - logger.info("\n" + "\n".join(log_buffer) + "\n") - - return result_unit_ids - - -def _map_results_to_contents( - contents: list[RetainContent], - extracted_facts: list[ExtractedFact], - is_duplicate_flags: list[bool], - unit_ids: list[str], -) -> list[list[str]]: - """ - Map created unit IDs back to original content items. - - Accounts for duplicates when mapping back. - """ - result_unit_ids = [] - filtered_idx = 0 - - # Group facts by content_index - facts_by_content = {i: [] for i in range(len(contents))} - for i, fact in enumerate(extracted_facts): - facts_by_content[fact.content_index].append(i) - - for content_index in range(len(contents)): - content_unit_ids = [] - for fact_idx in facts_by_content[content_index]: - if not is_duplicate_flags[fact_idx]: - content_unit_ids.append(unit_ids[filtered_idx]) - filtered_idx += 1 - result_unit_ids.append(content_unit_ids) - - return result_unit_ids - - -async def _trigger_background_tasks( - task_backend, - bank_id: str, - unit_ids: list[str], - facts: list[ProcessedFact], -) -> None: - """Trigger opinion reinforcement as background task (after transaction commits).""" - # Trigger opinion reinforcement if there are entities - fact_entities = [[e.name for e in fact.entities] for fact in facts] - if any(fact_entities): - await task_backend.submit_task( - { - "type": "reinforce_opinion", - "bank_id": bank_id, - "created_unit_ids": unit_ids, - "unit_texts": [fact.fact_text for fact in facts], - "unit_entities": fact_entities, - } - ) diff --git a/hindsight-api/hindsight_api/engine/retain/types.py b/hindsight-api/hindsight_api/engine/retain/types.py deleted file mode 100644 index d6b5df1c..00000000 --- a/hindsight-api/hindsight_api/engine/retain/types.py +++ /dev/null @@ -1,243 +0,0 @@ -""" -Type definitions for the retain pipeline. - -These dataclasses provide type safety throughout the retain operation, -from content input to fact storage. -""" - -from dataclasses import dataclass, field -from datetime import UTC, datetime -from typing import TypedDict -from uuid import UUID - - -class RetainContentDict(TypedDict, total=False): - """Type definition for content items in retain_batch_async. - - Fields: - content: Text content to store (required) - context: Context about the content (optional) - event_date: When the content occurred (optional, defaults to now) - metadata: Custom key-value metadata (optional) - document_id: Document ID for this content item (optional) - """ - - content: str # Required - context: str - event_date: datetime - metadata: dict[str, str] - document_id: str - - -def _now_utc() -> datetime: - """Factory function for default event_date.""" - return datetime.now(UTC) - - -@dataclass -class RetainContent: - """ - Input content item to be retained as memories. - - Represents a single piece of content to extract facts from. - """ - - content: str - context: str = "" - event_date: datetime = field(default_factory=_now_utc) - metadata: dict[str, str] = field(default_factory=dict) - - -@dataclass -class ChunkMetadata: - """ - Metadata about a text chunk. - - Used to track which facts were extracted from which chunks. - """ - - chunk_text: str - fact_count: int - content_index: int # Index of the source content - chunk_index: int # Global chunk index across all contents - - -@dataclass -class EntityRef: - """ - Reference to an entity mentioned in a fact. - - Entities are extracted by the LLM during fact extraction. - """ - - name: str - canonical_name: str | None = None # Resolved canonical name - entity_id: UUID | None = None # Resolved entity ID - - -@dataclass -class CausalRelation: - """ - Causal relationship between facts. - - Represents how one fact causes, enables, or prevents another. - """ - - relation_type: str # "causes", "enables", "prevents", "caused_by" - target_fact_index: int # Index of the target fact in the batch - strength: float = 1.0 # Strength of the causal relationship - - -@dataclass -class ExtractedFact: - """ - Fact extracted from content by the LLM. - - This is the raw output from fact extraction before processing. - """ - - fact_text: str - fact_type: str # "world", "experience", "opinion", "observation" - entities: list[str] = field(default_factory=list) - occurred_start: datetime | None = None - occurred_end: datetime | None = None - where: str | None = None # WHERE the fact occurred or is about - causal_relations: list[CausalRelation] = field(default_factory=list) - - # Context from the content item - content_index: int = 0 # Which content this fact came from - chunk_index: int = 0 # Which chunk this fact came from - context: str = "" - mentioned_at: datetime | None = None - metadata: dict[str, str] = field(default_factory=dict) - - -@dataclass -class ProcessedFact: - """ - Fact after processing and ready for storage. - - Includes resolved entities, embeddings, and all necessary fields. - """ - - # Core fact data - fact_text: str - fact_type: str - embedding: list[float] - - # Temporal data - occurred_start: datetime | None - occurred_end: datetime | None - mentioned_at: datetime - - # Context and metadata - context: str - metadata: dict[str, str] - - # Location data - where: str | None = None - - # Entities - entities: list[EntityRef] = field(default_factory=list) - - # Causal relations - causal_relations: list[CausalRelation] = field(default_factory=list) - - # Chunk reference - chunk_id: str | None = None - - # Document reference (denormalized for query performance) - document_id: str | None = None - - # DB fields (set after insertion) - unit_id: UUID | None = None - - @property - def is_duplicate(self) -> bool: - """Check if this fact was marked as a duplicate.""" - return self.unit_id is None - - @staticmethod - def from_extracted_fact( - extracted_fact: "ExtractedFact", embedding: list[float], chunk_id: str | None = None - ) -> "ProcessedFact": - """ - Create ProcessedFact from ExtractedFact. - - Args: - extracted_fact: Source ExtractedFact - embedding: Generated embedding vector - chunk_id: Optional chunk ID - - Returns: - ProcessedFact ready for storage - """ - from datetime import datetime - - # Use occurred dates only if explicitly provided by LLM - occurred_start = extracted_fact.occurred_start - occurred_end = extracted_fact.occurred_end - mentioned_at = extracted_fact.mentioned_at or datetime.now(UTC) - - # Convert entity strings to EntityRef objects - entities = [EntityRef(name=name) for name in extracted_fact.entities] - - return ProcessedFact( - fact_text=extracted_fact.fact_text, - fact_type=extracted_fact.fact_type, - embedding=embedding, - occurred_start=occurred_start, - occurred_end=occurred_end, - mentioned_at=mentioned_at, - context=extracted_fact.context, - metadata=extracted_fact.metadata, - entities=entities, - causal_relations=extracted_fact.causal_relations, - chunk_id=chunk_id, - ) - - -@dataclass -class EntityLink: - """ - Link between two memory units through a shared entity. - - Used for entity-based graph connections in the memory graph. - """ - - from_unit_id: UUID - to_unit_id: UUID - entity_id: UUID - link_type: str = "entity" - weight: float = 1.0 - - -@dataclass -class RetainBatch: - """ - A batch of content to retain. - - Tracks all facts, chunks, and metadata for a batch operation. - """ - - bank_id: str - contents: list[RetainContent] - document_id: str | None = None - fact_type_override: str | None = None - confidence_score: float | None = None - - # Extracted data (populated during processing) - extracted_facts: list[ExtractedFact] = field(default_factory=list) - processed_facts: list[ProcessedFact] = field(default_factory=list) - chunks: list[ChunkMetadata] = field(default_factory=list) - - # Results (populated after storage) - unit_ids_by_content: list[list[str]] = field(default_factory=list) - - def get_facts_for_content(self, content_index: int) -> list[ExtractedFact]: - """Get all extracted facts for a specific content item.""" - return [f for f in self.extracted_facts if f.content_index == content_index] - - def get_chunks_for_content(self, content_index: int) -> list[ChunkMetadata]: - """Get all chunks for a specific content item.""" - return [c for c in self.chunks if c.content_index == content_index] diff --git a/hindsight-api/hindsight_api/engine/search/__init__.py b/hindsight-api/hindsight_api/engine/search/__init__.py deleted file mode 100644 index efdb1fd2..00000000 --- a/hindsight-api/hindsight_api/engine/search/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Search module for memory retrieval. - -Provides modular search architecture: -- Retrieval: 4-way parallel (semantic + BM25 + graph + temporal) -- Graph retrieval: Pluggable strategies (BFS, PPR) -- Reranking: Pluggable strategies (heuristic, cross-encoder) -""" - -from .graph_retrieval import BFSGraphRetriever, GraphRetriever -from .mpfp_retrieval import MPFPGraphRetriever -from .reranking import CrossEncoderReranker -from .retrieval import ( - ParallelRetrievalResult, - get_default_graph_retriever, - retrieve_parallel, - set_default_graph_retriever, -) - -__all__ = [ - "retrieve_parallel", - "get_default_graph_retriever", - "set_default_graph_retriever", - "ParallelRetrievalResult", - "GraphRetriever", - "BFSGraphRetriever", - "MPFPGraphRetriever", - "CrossEncoderReranker", -] diff --git a/hindsight-api/hindsight_api/engine/search/fusion.py b/hindsight-api/hindsight_api/engine/search/fusion.py deleted file mode 100644 index b9bff304..00000000 --- a/hindsight-api/hindsight_api/engine/search/fusion.py +++ /dev/null @@ -1,113 +0,0 @@ -""" -Helper functions for hybrid search (semantic + BM25 + graph). -""" - -from typing import Any - -from .types import MergedCandidate, RetrievalResult - - -def reciprocal_rank_fusion(result_lists: list[list[RetrievalResult]], k: int = 60) -> list[MergedCandidate]: - """ - Merge multiple ranked result lists using Reciprocal Rank Fusion. - - RRF formula: score(d) = sum_over_lists(1 / (k + rank(d))) - - Args: - result_lists: List of result lists, each containing RetrievalResult objects - k: Constant for RRF formula (default: 60) - - Returns: - Merged list of MergedCandidate objects, sorted by RRF score - - Example: - semantic_results = [RetrievalResult(...), RetrievalResult(...), ...] - bm25_results = [RetrievalResult(...), RetrievalResult(...), ...] - graph_results = [RetrievalResult(...), RetrievalResult(...), ...] - - merged = reciprocal_rank_fusion([semantic_results, bm25_results, graph_results]) - # Returns: [MergedCandidate(...), MergedCandidate(...), ...] - """ - # Track scores from each list - rrf_scores = {} - source_ranks = {} # Track rank from each source for each doc_id - all_retrievals = {} # Store the actual RetrievalResult (use first occurrence) - - source_names = ["semantic", "bm25", "graph", "temporal"] - - for source_idx, results in enumerate(result_lists): - source_name = source_names[source_idx] if source_idx < len(source_names) else f"source_{source_idx}" - - for rank, retrieval in enumerate(results, start=1): - # Type check to catch tuple issues - if isinstance(retrieval, tuple): - raise TypeError( - f"Expected RetrievalResult but got tuple in {source_name} results at rank {rank}. " - f"Tuple value: {retrieval[:2] if len(retrieval) >= 2 else retrieval}. " - f"This suggests the retrieval function returned tuples instead of RetrievalResult objects." - ) - if not isinstance(retrieval, RetrievalResult): - raise TypeError( - f"Expected RetrievalResult but got {type(retrieval).__name__} in {source_name} results at rank {rank}" - ) - doc_id = retrieval.id - - # Store retrieval result (use first occurrence) - if doc_id not in all_retrievals: - all_retrievals[doc_id] = retrieval - - # Calculate RRF score contribution - if doc_id not in rrf_scores: - rrf_scores[doc_id] = 0.0 - source_ranks[doc_id] = {} - - rrf_scores[doc_id] += 1.0 / (k + rank) - source_ranks[doc_id][f"{source_name}_rank"] = rank - - # Combine into final results with metadata - merged_results = [] - for rrf_rank, (doc_id, rrf_score) in enumerate( - sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True), start=1 - ): - merged_candidate = MergedCandidate( - retrieval=all_retrievals[doc_id], rrf_score=rrf_score, rrf_rank=rrf_rank, source_ranks=source_ranks[doc_id] - ) - merged_results.append(merged_candidate) - - return merged_results - - -def normalize_scores_on_deltas(results: list[dict[str, Any]], score_keys: list[str]) -> list[dict[str, Any]]: - """ - 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/hindsight_api/engine/search/graph_retrieval.py b/hindsight-api/hindsight_api/engine/search/graph_retrieval.py deleted file mode 100644 index 88e94b3a..00000000 --- a/hindsight-api/hindsight_api/engine/search/graph_retrieval.py +++ /dev/null @@ -1,235 +0,0 @@ -""" -Graph retrieval strategies for memory recall. - -This module provides an abstraction for graph-based memory retrieval, -allowing different algorithms (BFS spreading activation, PPR, etc.) to be -swapped without changing the rest of the recall pipeline. -""" - -import logging -from abc import ABC, abstractmethod - -from ..db_utils import acquire_with_retry -from ..memory_engine import fq_table -from .types import RetrievalResult - -logger = logging.getLogger(__name__) - - -class GraphRetriever(ABC): - """ - Abstract base class for graph-based memory retrieval. - - Implementations traverse the memory graph (entity links, temporal links, - causal links) to find relevant facts that might not be found by - semantic or keyword search alone. - """ - - @property - @abstractmethod - def name(self) -> str: - """Return identifier for this retrieval strategy (e.g., 'bfs', 'mpfp').""" - pass - - @abstractmethod - async def retrieve( - self, - pool, - query_embedding_str: str, - bank_id: str, - fact_type: str, - budget: int, - query_text: str | None = None, - semantic_seeds: list[RetrievalResult] | None = None, - temporal_seeds: list[RetrievalResult] | None = None, - ) -> list[RetrievalResult]: - """ - Retrieve relevant facts via graph traversal. - - Args: - pool: Database connection pool - query_embedding_str: Query embedding as string (for finding entry points) - bank_id: Memory bank identifier - fact_type: Fact type to filter ('world', 'experience', 'opinion', 'observation') - budget: Maximum number of nodes to explore/return - query_text: Original query text (optional, for some strategies) - semantic_seeds: Pre-computed semantic entry points (from semantic retrieval) - temporal_seeds: Pre-computed temporal entry points (from temporal retrieval) - - Returns: - List of RetrievalResult objects with activation scores set - """ - pass - - -class BFSGraphRetriever(GraphRetriever): - """ - Graph retrieval using BFS-style spreading activation. - - Starting from semantic entry points, spreads activation through - the memory graph (entity, temporal, causal links) using breadth-first - traversal with decaying activation. - - This is the original Hindsight graph retrieval algorithm. - """ - - def __init__( - self, - entry_point_limit: int = 5, - entry_point_threshold: float = 0.5, - activation_decay: float = 0.8, - min_activation: float = 0.1, - batch_size: int = 20, - ): - """ - Initialize BFS graph retriever. - - Args: - entry_point_limit: Maximum number of entry points to start from - entry_point_threshold: Minimum semantic similarity for entry points - activation_decay: Decay factor per hop (activation *= decay) - min_activation: Minimum activation to continue spreading - batch_size: Number of nodes to process per batch (for neighbor fetching) - """ - self.entry_point_limit = entry_point_limit - self.entry_point_threshold = entry_point_threshold - self.activation_decay = activation_decay - self.min_activation = min_activation - self.batch_size = batch_size - - @property - def name(self) -> str: - return "bfs" - - async def retrieve( - self, - pool, - query_embedding_str: str, - bank_id: str, - fact_type: str, - budget: int, - query_text: str | None = None, - semantic_seeds: list[RetrievalResult] | None = None, - temporal_seeds: list[RetrievalResult] | None = None, - ) -> list[RetrievalResult]: - """ - Retrieve facts using BFS spreading activation. - - Algorithm: - 1. Find entry points (top semantic matches above threshold) - 2. BFS traversal: visit neighbors, propagate decaying activation - 3. Boost causal links (causes, enables, prevents) - 4. Return visited nodes up to budget - - Note: BFS finds its own entry points via embedding search. - The semantic_seeds and temporal_seeds parameters are accepted - for interface compatibility but not used. - """ - async with acquire_with_retry(pool) as conn: - return await self._retrieve_with_conn(conn, query_embedding_str, bank_id, fact_type, budget) - - async def _retrieve_with_conn( - self, - conn, - query_embedding_str: str, - bank_id: str, - fact_type: str, - budget: int, - ) -> list[RetrievalResult]: - """Internal implementation with connection.""" - - # Step 1: Find entry points - entry_points = await conn.fetch( - f""" - SELECT id, text, context, event_date, occurred_start, occurred_end, - mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, - 1 - (embedding <=> $1::vector) AS similarity - FROM {fq_table("memory_units")} - WHERE bank_id = $2 - AND embedding IS NOT NULL - AND fact_type = $3 - AND (1 - (embedding <=> $1::vector)) >= $4 - ORDER BY embedding <=> $1::vector - LIMIT $5 - """, - query_embedding_str, - bank_id, - fact_type, - self.entry_point_threshold, - self.entry_point_limit, - ) - - if not entry_points: - return [] - - # Step 2: BFS spreading activation - visited = set() - results = [] - queue = [(RetrievalResult.from_db_row(dict(r)), r["similarity"]) for r in entry_points] - budget_remaining = budget - - while queue and budget_remaining > 0: - # Collect a batch of nodes to process - batch_nodes = [] - batch_activations = {} - - while queue and len(batch_nodes) < self.batch_size and budget_remaining > 0: - current, activation = queue.pop(0) - unit_id = current.id - - if unit_id not in visited: - visited.add(unit_id) - budget_remaining -= 1 - current.activation = activation - results.append(current) - batch_nodes.append(current.id) - batch_activations[unit_id] = activation - - # Batch fetch neighbors - if batch_nodes and budget_remaining > 0: - max_neighbors = len(batch_nodes) * 20 - neighbors = await conn.fetch( - f""" - SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end, - mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type, - mu.document_id, mu.chunk_id, - ml.weight, ml.link_type, ml.from_unit_id - FROM {fq_table("memory_links")} ml - JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id - WHERE ml.from_unit_id = ANY($1::uuid[]) - AND ml.weight >= $2 - AND mu.fact_type = $3 - ORDER BY ml.weight DESC - LIMIT $4 - """, - batch_nodes, - self.min_activation, - fact_type, - max_neighbors, - ) - - for n in neighbors: - neighbor_id = str(n["id"]) - if neighbor_id not in visited: - parent_id = str(n["from_unit_id"]) - parent_activation = batch_activations.get(parent_id, 0.5) - - # Boost causal links - link_type = n["link_type"] - base_weight = n["weight"] - - if link_type in ("causes", "caused_by"): - causal_boost = 2.0 - elif link_type in ("enables", "prevents"): - causal_boost = 1.5 - else: - causal_boost = 1.0 - - effective_weight = base_weight * causal_boost - new_activation = parent_activation * effective_weight * self.activation_decay - - if new_activation > self.min_activation: - neighbor_result = RetrievalResult.from_db_row(dict(n)) - queue.append((neighbor_result, new_activation)) - - return results diff --git a/hindsight-api/hindsight_api/engine/search/mpfp_retrieval.py b/hindsight-api/hindsight_api/engine/search/mpfp_retrieval.py deleted file mode 100644 index cb717f2e..00000000 --- a/hindsight-api/hindsight_api/engine/search/mpfp_retrieval.py +++ /dev/null @@ -1,439 +0,0 @@ -""" -Meta-Path Forward Push (MPFP) graph retrieval. - -A sublinear graph traversal algorithm for memory retrieval over heterogeneous -graphs with multiple edge types (semantic, temporal, causal, entity). - -Combines meta-path patterns from HIN literature with Forward Push local -propagation from Approximate PPR. - -Key properties: -- Sublinear in graph size (threshold pruning bounds active nodes) -- Predefined patterns capture different retrieval intents -- All patterns run in parallel, results fused via RRF -- No LLM in the loop during traversal -""" - -import asyncio -import logging -from collections import defaultdict -from dataclasses import dataclass, field - -from ..db_utils import acquire_with_retry -from ..memory_engine import fq_table -from .graph_retrieval import GraphRetriever -from .types import RetrievalResult - -logger = logging.getLogger(__name__) - - -# ----------------------------------------------------------------------------- -# Data Classes -# ----------------------------------------------------------------------------- - - -@dataclass -class EdgeTarget: - """A neighbor node with its edge weight.""" - - node_id: str - weight: float - - -@dataclass -class TypedAdjacency: - """Adjacency lists split by edge type.""" - - # edge_type -> from_node_id -> list of (to_node_id, weight) - graphs: dict[str, dict[str, list[EdgeTarget]]] = field(default_factory=dict) - - def get_neighbors(self, edge_type: str, node_id: str) -> list[EdgeTarget]: - """Get neighbors for a node via a specific edge type.""" - return self.graphs.get(edge_type, {}).get(node_id, []) - - def get_normalized_neighbors(self, edge_type: str, node_id: str, top_k: int) -> list[EdgeTarget]: - """Get top-k neighbors with weights normalized to sum to 1.""" - neighbors = self.get_neighbors(edge_type, node_id)[:top_k] - if not neighbors: - return [] - - total = sum(n.weight for n in neighbors) - if total == 0: - return [] - - return [EdgeTarget(node_id=n.node_id, weight=n.weight / total) for n in neighbors] - - -@dataclass -class PatternResult: - """Result from a single pattern traversal.""" - - pattern: list[str] - scores: dict[str, float] # node_id -> accumulated mass - - -@dataclass -class MPFPConfig: - """Configuration for MPFP algorithm.""" - - alpha: float = 0.15 # teleport/keep probability - threshold: float = 1e-6 # mass pruning threshold (lower = explore more) - top_k_neighbors: int = 20 # fan-out limit per node - - # Patterns from semantic seeds - patterns_semantic: list[list[str]] = field( - default_factory=lambda: [ - ["semantic", "semantic"], # topic expansion - ["entity", "temporal"], # entity timeline - ["semantic", "causes"], # reasoning chains (forward) - ["semantic", "caused_by"], # reasoning chains (backward) - ["entity", "semantic"], # entity context - ] - ) - - # Patterns from temporal seeds - patterns_temporal: list[list[str]] = field( - default_factory=lambda: [ - ["temporal", "semantic"], # what was happening then - ["temporal", "entity"], # who was involved then - ] - ) - - -@dataclass -class SeedNode: - """An entry point node with its initial score.""" - - node_id: str - score: float # initial mass (e.g., similarity score) - - -# ----------------------------------------------------------------------------- -# Core Algorithm -# ----------------------------------------------------------------------------- - - -def mpfp_traverse( - seeds: list[SeedNode], - pattern: list[str], - adjacency: TypedAdjacency, - config: MPFPConfig, -) -> PatternResult: - """ - Forward Push traversal following a meta-path pattern. - - Args: - seeds: Entry point nodes with initial scores - pattern: Sequence of edge types to follow - adjacency: Typed adjacency structure - config: Algorithm parameters - - Returns: - PatternResult with accumulated scores per node - """ - if not seeds: - return PatternResult(pattern=pattern, scores={}) - - scores: dict[str, float] = {} - - # Initialize frontier with seed masses (normalized) - total_seed_score = sum(s.score for s in seeds) - if total_seed_score == 0: - total_seed_score = len(seeds) # fallback to uniform - - frontier: dict[str, float] = {s.node_id: s.score / total_seed_score for s in seeds} - - # Follow pattern hop by hop - for edge_type in pattern: - next_frontier: dict[str, float] = {} - - for node_id, mass in frontier.items(): - if mass < config.threshold: - continue - - # Keep α portion for this node - scores[node_id] = scores.get(node_id, 0) + config.alpha * mass - - # Push (1-α) to neighbors - push_mass = (1 - config.alpha) * mass - neighbors = adjacency.get_normalized_neighbors(edge_type, node_id, config.top_k_neighbors) - - for neighbor in neighbors: - next_frontier[neighbor.node_id] = next_frontier.get(neighbor.node_id, 0) + push_mass * neighbor.weight - - frontier = next_frontier - - # Final frontier nodes get their remaining mass - for node_id, mass in frontier.items(): - if mass >= config.threshold: - scores[node_id] = scores.get(node_id, 0) + mass - - return PatternResult(pattern=pattern, scores=scores) - - -def rrf_fusion( - results: list[PatternResult], - k: int = 60, - top_k: int = 50, -) -> list[tuple[str, float]]: - """ - Reciprocal Rank Fusion to combine pattern results. - - Args: - results: List of pattern results - k: RRF constant (higher = more uniform weighting) - top_k: Number of results to return - - Returns: - List of (node_id, fused_score) tuples, sorted by score descending - """ - fused: dict[str, float] = {} - - for result in results: - if not result.scores: - continue - - # Rank nodes by their score in this pattern - ranked = sorted(result.scores.keys(), key=lambda n: result.scores[n], reverse=True) - - for rank, node_id in enumerate(ranked): - fused[node_id] = fused.get(node_id, 0) + 1.0 / (k + rank + 1) - - # Sort by fused score and return top-k - sorted_results = sorted(fused.items(), key=lambda x: x[1], reverse=True) - - return sorted_results[:top_k] - - -# ----------------------------------------------------------------------------- -# Database Loading -# ----------------------------------------------------------------------------- - - -async def load_typed_adjacency(pool, bank_id: str) -> TypedAdjacency: - """ - Load all edges for a bank, split by edge type. - - Single query, then organize in-memory for fast traversal. - """ - async with acquire_with_retry(pool) as conn: - rows = await conn.fetch( - f""" - SELECT ml.from_unit_id, ml.to_unit_id, ml.link_type, ml.weight - FROM {fq_table("memory_links")} ml - JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id - WHERE mu.bank_id = $1 - AND ml.weight >= 0.1 - ORDER BY ml.from_unit_id, ml.weight DESC - """, - bank_id, - ) - - graphs: dict[str, dict[str, list[EdgeTarget]]] = defaultdict(lambda: defaultdict(list)) - - for row in rows: - from_id = str(row["from_unit_id"]) - to_id = str(row["to_unit_id"]) - link_type = row["link_type"] - weight = row["weight"] - - graphs[link_type][from_id].append(EdgeTarget(node_id=to_id, weight=weight)) - - return TypedAdjacency(graphs=dict(graphs)) - - -async def fetch_memory_units_by_ids( - pool, - node_ids: list[str], - fact_type: str, -) -> list[RetrievalResult]: - """Fetch full memory unit details for a list of node IDs.""" - if not node_ids: - return [] - - async with acquire_with_retry(pool) as conn: - rows = await conn.fetch( - f""" - SELECT id, text, context, event_date, occurred_start, occurred_end, - mentioned_at, access_count, embedding, fact_type, document_id, chunk_id - FROM {fq_table("memory_units")} - WHERE id = ANY($1::uuid[]) - AND fact_type = $2 - """, - node_ids, - fact_type, - ) - - return [RetrievalResult.from_db_row(dict(r)) for r in rows] - - -# ----------------------------------------------------------------------------- -# Graph Retriever Implementation -# ----------------------------------------------------------------------------- - - -class MPFPGraphRetriever(GraphRetriever): - """ - Graph retrieval using Meta-Path Forward Push. - - Runs predefined patterns in parallel from semantic and temporal seeds, - then fuses results via RRF. - """ - - def __init__(self, config: MPFPConfig | None = None): - """ - Initialize MPFP retriever. - - Args: - config: Algorithm configuration (uses defaults if None) - """ - self.config = config or MPFPConfig() - self._adjacency_cache: dict[str, TypedAdjacency] = {} - - @property - def name(self) -> str: - return "mpfp" - - async def retrieve( - self, - pool, - query_embedding_str: str, - bank_id: str, - fact_type: str, - budget: int, - query_text: str | None = None, - semantic_seeds: list[RetrievalResult] | None = None, - temporal_seeds: list[RetrievalResult] | None = None, - ) -> list[RetrievalResult]: - """ - Retrieve facts using MPFP algorithm. - - Args: - pool: Database connection pool - query_embedding_str: Query embedding (used for fallback seed finding) - bank_id: Memory bank ID - fact_type: Fact type to filter - budget: Maximum results to return - query_text: Original query text (optional) - semantic_seeds: Pre-computed semantic entry points - temporal_seeds: Pre-computed temporal entry points - - Returns: - List of RetrievalResult with activation scores - """ - # Load typed adjacency (could cache per bank_id with TTL) - adjacency = await load_typed_adjacency(pool, bank_id) - - # Convert seeds to SeedNode format - semantic_seed_nodes = self._convert_seeds(semantic_seeds, "similarity") - temporal_seed_nodes = self._convert_seeds(temporal_seeds, "temporal_score") - - # If no semantic seeds provided, fall back to finding our own - if not semantic_seed_nodes: - semantic_seed_nodes = await self._find_semantic_seeds(pool, query_embedding_str, bank_id, fact_type) - - # Run all patterns in parallel - tasks = [] - - # Patterns from semantic seeds - for pattern in self.config.patterns_semantic: - if semantic_seed_nodes: - tasks.append( - asyncio.to_thread( - mpfp_traverse, - semantic_seed_nodes, - pattern, - adjacency, - self.config, - ) - ) - - # Patterns from temporal seeds - for pattern in self.config.patterns_temporal: - if temporal_seed_nodes: - tasks.append( - asyncio.to_thread( - mpfp_traverse, - temporal_seed_nodes, - pattern, - adjacency, - self.config, - ) - ) - - if not tasks: - return [] - - # Gather pattern results - pattern_results = await asyncio.gather(*tasks) - - # Fuse results - fused = rrf_fusion(pattern_results, top_k=budget) - - if not fused: - return [] - - # Get top result IDs (don't exclude seeds - they may be highly relevant) - result_ids = [node_id for node_id, score in fused][:budget] - - # Fetch full details - results = await fetch_memory_units_by_ids(pool, result_ids, fact_type) - - # Add activation scores from fusion - score_map = {node_id: score for node_id, score in fused} - for result in results: - result.activation = score_map.get(result.id, 0.0) - - # Sort by activation - results.sort(key=lambda r: r.activation or 0, reverse=True) - - return results - - def _convert_seeds( - self, - seeds: list[RetrievalResult] | None, - score_attr: str, - ) -> list[SeedNode]: - """Convert RetrievalResult seeds to SeedNode format.""" - if not seeds: - return [] - - result = [] - for seed in seeds: - score = getattr(seed, score_attr, None) - if score is None: - score = seed.activation or seed.similarity or 1.0 - result.append(SeedNode(node_id=seed.id, score=score)) - - return result - - async def _find_semantic_seeds( - self, - pool, - query_embedding_str: str, - bank_id: str, - fact_type: str, - limit: int = 20, - threshold: float = 0.3, - ) -> list[SeedNode]: - """Fallback: find semantic seeds via embedding search.""" - async with acquire_with_retry(pool) as conn: - rows = await conn.fetch( - f""" - SELECT id, 1 - (embedding <=> $1::vector) AS similarity - FROM {fq_table("memory_units")} - WHERE bank_id = $2 - AND embedding IS NOT NULL - AND fact_type = $3 - AND (1 - (embedding <=> $1::vector)) >= $4 - ORDER BY embedding <=> $1::vector - LIMIT $5 - """, - query_embedding_str, - bank_id, - fact_type, - threshold, - limit, - ) - - return [SeedNode(node_id=str(r["id"]), score=r["similarity"]) for r in rows] diff --git a/hindsight-api/hindsight_api/engine/search/observation_utils.py b/hindsight-api/hindsight_api/engine/search/observation_utils.py deleted file mode 100644 index 626b8174..00000000 --- a/hindsight-api/hindsight_api/engine/search/observation_utils.py +++ /dev/null @@ -1,125 +0,0 @@ -""" -Observation utilities for generating entity observations from facts. - -Observations are objective facts synthesized from multiple memory facts -about an entity, without personality influence. -""" - -import logging - -from pydantic import BaseModel, Field - -from ..response_models import MemoryFact - -logger = logging.getLogger(__name__) - - -class Observation(BaseModel): - """An observation about an entity.""" - - observation: str = Field(description="The observation text - a factual statement about the entity") - - -class ObservationExtractionResponse(BaseModel): - """Response containing extracted observations.""" - - observations: list[Observation] = Field(default_factory=list, description="List of observations about the entity") - - -def format_facts_for_observation_prompt(facts: list[MemoryFact]) -> str: - """Format facts as text for observation extraction prompt.""" - import json - - if not facts: - return "[]" - formatted = [] - for fact in facts: - fact_obj = {"text": fact.text} - - # Add context if available - if fact.context: - fact_obj["context"] = fact.context - - # Add occurred_start if available - if fact.occurred_start: - fact_obj["occurred_at"] = fact.occurred_start - - formatted.append(fact_obj) - - return json.dumps(formatted, indent=2) - - -def build_observation_prompt( - entity_name: str, - facts_text: str, -) -> str: - """Build the observation extraction prompt for the LLM.""" - return f"""Based on the following facts about "{entity_name}", generate a list of key observations. - -FACTS ABOUT {entity_name.upper()}: -{facts_text} - -Your task: Synthesize the facts into clear, objective observations about {entity_name}. - -GUIDELINES: -1. Each observation should be a factual statement about {entity_name} -2. Combine related facts into single observations where appropriate -3. Be objective - do not add opinions, judgments, or interpretations -4. Focus on what we KNOW about {entity_name}, not what we assume -5. Include observations about: identity, characteristics, roles, relationships, activities -6. Write in third person (e.g., "John is..." not "I think John is...") -7. If there are conflicting facts, note the most recent or most supported one - -EXAMPLES of good observations: -- "John works at Google as a software engineer" -- "John is detail-oriented and methodical in his approach" -- "John collaborates frequently with Sarah on the AI project" -- "John joined the company in 2023" - -EXAMPLES of bad observations (avoid these): -- "John seems like a good person" (opinion/judgment) -- "John probably likes his job" (assumption) -- "I believe John is reliable" (first-person opinion) - -Generate 3-7 observations based on the available facts. If there are very few facts, generate fewer observations.""" - - -def get_observation_system_message() -> str: - """Get the system message for observation extraction.""" - return "You are an objective observer synthesizing facts about an entity. Generate clear, factual observations without opinions or personality influence. Be concise and accurate." - - -async def extract_observations_from_facts(llm_config, entity_name: str, facts: list[MemoryFact]) -> list[str]: - """ - Extract observations from facts about an entity using LLM. - - Args: - llm_config: LLM configuration to use - entity_name: Name of the entity to generate observations about - facts: List of facts mentioning the entity - - Returns: - List of observation strings - """ - if not facts: - return [] - - facts_text = format_facts_for_observation_prompt(facts) - prompt = build_observation_prompt(entity_name, facts_text) - - try: - result = await llm_config.call( - messages=[ - {"role": "system", "content": get_observation_system_message()}, - {"role": "user", "content": prompt}, - ], - response_format=ObservationExtractionResponse, - scope="memory_extract_observation", - ) - - observations = [op.observation for op in result.observations] - return observations - - except Exception as e: - logger.warning(f"Failed to extract observations for {entity_name}: {str(e)}") - return [] diff --git a/hindsight-api/hindsight_api/engine/search/reranking.py b/hindsight-api/hindsight_api/engine/search/reranking.py deleted file mode 100644 index 073052f4..00000000 --- a/hindsight-api/hindsight_api/engine/search/reranking.py +++ /dev/null @@ -1,113 +0,0 @@ -""" -Cross-encoder neural reranking for search results. -""" - -from .types import MergedCandidate, ScoredResult - - -class CrossEncoderReranker: - """ - Neural reranking using a cross-encoder model. - - Configured via environment variables (see cross_encoder.py). - Default local model is cross-encoder/ms-marco-MiniLM-L-6-v2. - """ - - def __init__(self, cross_encoder=None): - """ - Initialize cross-encoder reranker. - - Args: - cross_encoder: CrossEncoderModel instance. If None, creates one from - environment variables (defaults to local provider) - """ - if cross_encoder is None: - from hindsight_api.engine.cross_encoder import create_cross_encoder_from_env - - cross_encoder = create_cross_encoder_from_env() - self.cross_encoder = cross_encoder - self._initialized = False - - async def ensure_initialized(self): - """Ensure the cross-encoder model is initialized (for lazy initialization).""" - if self._initialized: - return - - import asyncio - - cross_encoder = self.cross_encoder - # For local providers, run in thread pool to avoid blocking event loop - if cross_encoder.provider_name == "local": - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, lambda: asyncio.run(cross_encoder.initialize())) - else: - await cross_encoder.initialize() - self._initialized = True - - def rerank(self, query: str, candidates: list[MergedCandidate]) -> list[ScoredResult]: - """ - Rerank candidates using cross-encoder scores. - - Args: - query: Search query - candidates: Merged candidates from RRF - - Returns: - List of ScoredResult objects sorted by cross-encoder score - """ - if not candidates: - return [] - - # Prepare query-document pairs with date information - pairs = [] - for candidate in candidates: - retrieval = candidate.retrieval - - # Use text + context for better ranking - doc_text = retrieval.text - if retrieval.context: - doc_text = f"{retrieval.context}: {doc_text}" - - # Add formatted date information for temporal awareness - if retrieval.occurred_start: - occurred_start = retrieval.occurred_start - - # Format in two styles for better model understanding - # 1. ISO format: YYYY-MM-DD - date_iso = occurred_start.strftime("%Y-%m-%d") - - # 2. Human-readable: "June 5, 2022" - date_readable = occurred_start.strftime("%B %d, %Y") - - # Prepend date to document text - doc_text = f"[Date: {date_readable} ({date_iso})] {doc_text}" - - pairs.append([query, doc_text]) - - # Get cross-encoder scores - scores = self.cross_encoder.predict(pairs) - - # Normalize scores using sigmoid to [0, 1] range - # Cross-encoder returns logits which can be negative - import numpy as np - - def sigmoid(x): - return 1 / (1 + np.exp(-x)) - - normalized_scores = [sigmoid(score) for score in scores] - - # Create ScoredResult objects with cross-encoder scores - scored_results = [] - for candidate, raw_score, norm_score in zip(candidates, scores, normalized_scores): - scored_result = ScoredResult( - candidate=candidate, - cross_encoder_score=float(raw_score), - cross_encoder_score_normalized=float(norm_score), - weight=float(norm_score), # Initial weight is just cross-encoder score - ) - scored_results.append(scored_result) - - # Sort by cross-encoder score - scored_results.sort(key=lambda x: x.weight, reverse=True) - - return scored_results diff --git a/hindsight-api/hindsight_api/engine/search/retrieval.py b/hindsight-api/hindsight_api/engine/search/retrieval.py deleted file mode 100644 index dc75f3f3..00000000 --- a/hindsight-api/hindsight_api/engine/search/retrieval.py +++ /dev/null @@ -1,699 +0,0 @@ -""" -Retrieval module for 4-way parallel search. - -Implements: -1. Semantic retrieval (vector similarity) -2. BM25 retrieval (keyword/full-text search) -3. Graph retrieval (via pluggable GraphRetriever interface) -4. Temporal retrieval (time-aware search with spreading) -""" - -import asyncio -import logging -from dataclasses import dataclass, field -from datetime import UTC, datetime -from typing import Optional - -from ...config import get_config -from ..db_utils import acquire_with_retry -from ..memory_engine import fq_table -from .graph_retrieval import BFSGraphRetriever, GraphRetriever -from .mpfp_retrieval import MPFPGraphRetriever -from .types import RetrievalResult - -logger = logging.getLogger(__name__) - - -@dataclass -class ParallelRetrievalResult: - """Result from parallel retrieval across all methods.""" - - semantic: list[RetrievalResult] - bm25: list[RetrievalResult] - graph: list[RetrievalResult] - temporal: list[RetrievalResult] | None - timings: dict[str, float] = field(default_factory=dict) - temporal_constraint: tuple | None = None # (start_date, end_date) - - -# Default graph retriever instance (can be overridden) -_default_graph_retriever: GraphRetriever | None = None - - -def get_default_graph_retriever() -> GraphRetriever: - """Get or create the default graph retriever based on config.""" - global _default_graph_retriever - if _default_graph_retriever is None: - config = get_config() - retriever_type = config.graph_retriever.lower() - if retriever_type == "mpfp": - _default_graph_retriever = MPFPGraphRetriever() - logger.info("Using MPFP graph retriever") - elif retriever_type == "bfs": - _default_graph_retriever = BFSGraphRetriever() - logger.info("Using BFS graph retriever") - else: - logger.warning(f"Unknown graph retriever '{retriever_type}', falling back to MPFP") - _default_graph_retriever = MPFPGraphRetriever() - return _default_graph_retriever - - -def set_default_graph_retriever(retriever: GraphRetriever) -> None: - """Set the default graph retriever (for configuration/testing).""" - global _default_graph_retriever - _default_graph_retriever = retriever - - -async def retrieve_semantic( - conn, query_emb_str: str, bank_id: str, fact_type: str, limit: int -) -> list[RetrievalResult]: - """ - Semantic retrieval via vector similarity. - - Args: - conn: Database connection - query_emb_str: Query embedding as string - agent_id: bank ID - fact_type: Fact type to filter - limit: Maximum results to return - - Returns: - List of RetrievalResult objects - """ - results = await conn.fetch( - f""" - SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, - 1 - (embedding <=> $1::vector) AS similarity - FROM {fq_table("memory_units")} - WHERE bank_id = $2 - AND embedding IS NOT NULL - AND fact_type = $3 - AND (1 - (embedding <=> $1::vector)) >= 0.3 - ORDER BY embedding <=> $1::vector - LIMIT $4 - """, - query_emb_str, - bank_id, - fact_type, - limit, - ) - return [RetrievalResult.from_db_row(dict(r)) for r in results] - - -async def retrieve_bm25(conn, query_text: str, bank_id: str, fact_type: str, limit: int) -> list[RetrievalResult]: - """ - BM25 keyword retrieval via full-text search. - - Args: - conn: Database connection - query_text: Query text - agent_id: bank ID - fact_type: Fact type to filter - limit: Maximum results to return - - Returns: - List of RetrievalResult objects - """ - import re - - # Sanitize query text: remove special characters that have meaning in tsquery - # Keep only alphanumeric characters and spaces - sanitized_text = re.sub(r"[^\w\s]", " ", query_text.lower()) - - # Split and filter empty strings - tokens = [token for token in sanitized_text.split() if token] - - if not tokens: - # If no valid tokens, return empty results - return [] - - # Convert query to tsquery using OR for more flexible matching - # This prevents empty results when some terms are missing - query_tsquery = " | ".join(tokens) - - results = await conn.fetch( - f""" - SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, - ts_rank_cd(search_vector, to_tsquery('english', $1)) AS bm25_score - FROM {fq_table("memory_units")} - WHERE bank_id = $2 - AND fact_type = $3 - AND search_vector @@ to_tsquery('english', $1) - ORDER BY bm25_score DESC - LIMIT $4 - """, - query_tsquery, - bank_id, - fact_type, - limit, - ) - return [RetrievalResult.from_db_row(dict(r)) for r in results] - - -async def retrieve_temporal( - conn, - query_emb_str: str, - bank_id: str, - fact_type: str, - start_date: datetime, - end_date: datetime, - budget: int, - semantic_threshold: float = 0.1, -) -> list[RetrievalResult]: - """ - Temporal retrieval with spreading activation. - - Strategy: - 1. Find entry points (facts in date range with semantic relevance) - 2. Spread through temporal links to related facts - 3. Score by temporal proximity + semantic similarity + link weight - - Args: - conn: Database connection - query_emb_str: Query embedding as string - agent_id: bank ID - fact_type: Fact type to filter - start_date: Start of time range - end_date: End of time range - budget: Node budget for spreading - semantic_threshold: Minimum semantic similarity to include - - Returns: - List of RetrievalResult objects with temporal scores - """ - - # Ensure start_date and end_date are timezone-aware (UTC) to match database datetimes - if start_date.tzinfo is None: - start_date = start_date.replace(tzinfo=UTC) - if end_date.tzinfo is None: - end_date = end_date.replace(tzinfo=UTC) - - entry_points = await conn.fetch( - f""" - SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, - 1 - (embedding <=> $1::vector) AS similarity - FROM {fq_table("memory_units")} - WHERE bank_id = $2 - AND fact_type = $3 - AND embedding IS NOT NULL - AND ( - -- Match if occurred range overlaps with query range - (occurred_start IS NOT NULL AND occurred_end IS NOT NULL - AND occurred_start <= $5 AND occurred_end >= $4) - OR - -- Match if mentioned_at falls within query range - (mentioned_at IS NOT NULL AND mentioned_at BETWEEN $4 AND $5) - OR - -- Match if any occurred date is set and overlaps (even if only start or end is set) - (occurred_start IS NOT NULL AND occurred_start BETWEEN $4 AND $5) - OR - (occurred_end IS NOT NULL AND occurred_end BETWEEN $4 AND $5) - ) - AND (1 - (embedding <=> $1::vector)) >= $6 - ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC, (embedding <=> $1::vector) ASC - LIMIT 10 - """, - query_emb_str, - bank_id, - fact_type, - start_date, - end_date, - semantic_threshold, - ) - - if not entry_points: - return [] - - # Calculate temporal scores for entry points - total_days = (end_date - start_date).total_seconds() / 86400 - mid_date = start_date + (end_date - start_date) / 2 # Calculate once for all comparisons - results = [] - visited = set() - - for ep in entry_points: - unit_id = str(ep["id"]) - visited.add(unit_id) - - # Calculate temporal proximity using the most relevant date - # Priority: occurred_start/end (event time) > mentioned_at (mention time) - best_date = None - if ep["occurred_start"] is not None and ep["occurred_end"] is not None: - # Use midpoint of occurred range - best_date = ep["occurred_start"] + (ep["occurred_end"] - ep["occurred_start"]) / 2 - elif ep["occurred_start"] is not None: - best_date = ep["occurred_start"] - elif ep["occurred_end"] is not None: - best_date = ep["occurred_end"] - elif ep["mentioned_at"] is not None: - best_date = ep["mentioned_at"] - - # Temporal proximity score (closer to range center = higher score) - if best_date: - days_from_mid = abs((best_date - mid_date).total_seconds() / 86400) - temporal_proximity = 1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0 - else: - temporal_proximity = 0.5 # Fallback if no dates (shouldn't happen due to WHERE clause) - - # Create RetrievalResult with temporal scores - ep_result = RetrievalResult.from_db_row(dict(ep)) - ep_result.temporal_score = temporal_proximity - ep_result.temporal_proximity = temporal_proximity - results.append(ep_result) - - # Spread through temporal links - queue = [ - (RetrievalResult.from_db_row(dict(ep)), ep["similarity"], 1.0) for ep in entry_points - ] # (unit, semantic_sim, temporal_score) - budget_remaining = budget - len(entry_points) - - while queue and budget_remaining > 0: - current, semantic_sim, temporal_score = queue.pop(0) - current_id = current.id - - # Get neighbors via temporal and causal links - if budget_remaining > 0: - neighbors = await conn.fetch( - f""" - SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id, - ml.weight, ml.link_type, - 1 - (mu.embedding <=> $1::vector) AS similarity - FROM {fq_table("memory_links")} ml - JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id - WHERE ml.from_unit_id = $2 - AND ml.link_type IN ('temporal', 'causes', 'caused_by', 'enables', 'prevents') - AND ml.weight >= 0.1 - AND mu.fact_type = $3 - AND mu.embedding IS NOT NULL - AND (1 - (mu.embedding <=> $1::vector)) >= $4 - ORDER BY ml.weight DESC - LIMIT 10 - """, - query_emb_str, - current.id, - fact_type, - semantic_threshold, - ) - - for n in neighbors: - neighbor_id = str(n["id"]) - if neighbor_id in visited: - continue - - visited.add(neighbor_id) - budget_remaining -= 1 - - # Calculate temporal score for neighbor using best available date - neighbor_best_date = None - if n["occurred_start"] is not None and n["occurred_end"] is not None: - neighbor_best_date = n["occurred_start"] + (n["occurred_end"] - n["occurred_start"]) / 2 - elif n["occurred_start"] is not None: - neighbor_best_date = n["occurred_start"] - elif n["occurred_end"] is not None: - neighbor_best_date = n["occurred_end"] - elif n["mentioned_at"] is not None: - neighbor_best_date = n["mentioned_at"] - - if neighbor_best_date: - days_from_mid = abs((neighbor_best_date - mid_date).total_seconds() / 86400) - neighbor_temporal_proximity = ( - 1.0 - min(days_from_mid / (total_days / 2), 1.0) if total_days > 0 else 1.0 - ) - else: - neighbor_temporal_proximity = 0.3 # Lower score if no temporal data - - # Boost causal links (same as graph retrieval) - link_type = n["link_type"] - if link_type in ("causes", "caused_by"): - causal_boost = 2.0 - elif link_type in ("enables", "prevents"): - causal_boost = 1.5 - else: - causal_boost = 1.0 - - # Propagate temporal score through links (decay, with causal boost) - propagated_temporal = temporal_score * n["weight"] * causal_boost * 0.7 - - # Combined temporal score - combined_temporal = max(neighbor_temporal_proximity, propagated_temporal) - - # Create RetrievalResult with temporal scores - neighbor_result = RetrievalResult.from_db_row(dict(n)) - neighbor_result.temporal_score = combined_temporal - neighbor_result.temporal_proximity = neighbor_temporal_proximity - results.append(neighbor_result) - - # Add to queue for further spreading - if budget_remaining > 0 and combined_temporal > 0.2: - queue.append((neighbor_result, n["similarity"], combined_temporal)) - - if budget_remaining <= 0: - break - - return results - - -async def retrieve_parallel( - pool, - query_text: str, - query_embedding_str: str, - bank_id: str, - fact_type: str, - thinking_budget: int, - question_date: datetime | None = None, - query_analyzer: Optional["QueryAnalyzer"] = None, - graph_retriever: GraphRetriever | None = None, -) -> ParallelRetrievalResult: - """ - Run 3-way or 4-way parallel retrieval (adds temporal if detected). - - Args: - pool: Database connection pool - query_text: Query text - query_embedding_str: Query embedding as string - bank_id: Bank ID - fact_type: Fact type to filter - thinking_budget: Budget for graph traversal and retrieval limits - question_date: Optional date when question was asked (for temporal filtering) - query_analyzer: Query analyzer to use (defaults to TransformerQueryAnalyzer) - graph_retriever: Graph retrieval strategy (defaults to configured retriever) - - Returns: - ParallelRetrievalResult with semantic, bm25, graph, temporal results and timings - """ - from .temporal_extraction import extract_temporal_constraint - - temporal_constraint = extract_temporal_constraint(query_text, reference_date=question_date, analyzer=query_analyzer) - - retriever = graph_retriever or get_default_graph_retriever() - - if retriever.name == "mpfp": - return await _retrieve_parallel_mpfp( - pool, query_text, query_embedding_str, bank_id, fact_type, thinking_budget, temporal_constraint, retriever - ) - else: - return await _retrieve_parallel_bfs( - pool, query_text, query_embedding_str, bank_id, fact_type, thinking_budget, temporal_constraint, retriever - ) - - -@dataclass -class _SemanticGraphResult: - """Internal result from semantic→graph chain.""" - - semantic: list[RetrievalResult] - graph: list[RetrievalResult] - semantic_time: float - graph_time: float - - -@dataclass -class _TimedResult: - """Internal result with timing.""" - - results: list[RetrievalResult] - time: float - - -async def _retrieve_parallel_mpfp( - pool, - query_text: str, - query_embedding_str: str, - bank_id: str, - fact_type: str, - thinking_budget: int, - temporal_constraint: tuple | None, - retriever: GraphRetriever, -) -> ParallelRetrievalResult: - """ - MPFP retrieval with optimized parallelization. - - Runs 2-3 parallel task chains: - - Task 1: Semantic → Graph (chained, graph uses semantic seeds) - - Task 2: BM25 (independent) - - Task 3: Temporal (if constraint detected) - """ - import time - - async def run_semantic_then_graph() -> _SemanticGraphResult: - """Chain: semantic retrieval → graph retrieval (using semantic as seeds).""" - start = time.time() - async with acquire_with_retry(pool) as conn: - semantic = await retrieve_semantic(conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget) - semantic_time = time.time() - start - - # Get temporal seeds if needed (quick query, part of this chain) - temporal_seeds = None - if temporal_constraint: - tc_start, tc_end = temporal_constraint - async with acquire_with_retry(pool) as conn: - temporal_seeds = await _get_temporal_entry_points( - conn, query_embedding_str, bank_id, fact_type, tc_start, tc_end, limit=20 - ) - - # Run graph with seeds - start = time.time() - graph = await retriever.retrieve( - pool=pool, - query_embedding_str=query_embedding_str, - bank_id=bank_id, - fact_type=fact_type, - budget=thinking_budget, - query_text=query_text, - semantic_seeds=semantic, - temporal_seeds=temporal_seeds, - ) - graph_time = time.time() - start - - return _SemanticGraphResult(semantic, graph, semantic_time, graph_time) - - async def run_bm25() -> _TimedResult: - """Independent BM25 retrieval.""" - start = time.time() - async with acquire_with_retry(pool) as conn: - results = await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget) - return _TimedResult(results, time.time() - start) - - async def run_temporal(tc_start, tc_end) -> _TimedResult: - """Temporal retrieval (uses its own entry point finding).""" - start = time.time() - async with acquire_with_retry(pool) as conn: - results = await retrieve_temporal( - conn, - query_embedding_str, - bank_id, - fact_type, - tc_start, - tc_end, - budget=thinking_budget, - semantic_threshold=0.1, - ) - return _TimedResult(results, time.time() - start) - - # Run parallel task chains - if temporal_constraint: - tc_start, tc_end = temporal_constraint - sg_result, bm25_result, temporal_result = await asyncio.gather( - run_semantic_then_graph(), - run_bm25(), - run_temporal(tc_start, tc_end), - ) - return ParallelRetrievalResult( - semantic=sg_result.semantic, - bm25=bm25_result.results, - graph=sg_result.graph, - temporal=temporal_result.results, - timings={ - "semantic": sg_result.semantic_time, - "graph": sg_result.graph_time, - "bm25": bm25_result.time, - "temporal": temporal_result.time, - }, - temporal_constraint=temporal_constraint, - ) - else: - sg_result, bm25_result = await asyncio.gather( - run_semantic_then_graph(), - run_bm25(), - ) - return ParallelRetrievalResult( - semantic=sg_result.semantic, - bm25=bm25_result.results, - graph=sg_result.graph, - temporal=None, - timings={ - "semantic": sg_result.semantic_time, - "graph": sg_result.graph_time, - "bm25": bm25_result.time, - }, - temporal_constraint=None, - ) - - -async def _get_temporal_entry_points( - conn, - query_embedding_str: str, - bank_id: str, - fact_type: str, - start_date: datetime, - end_date: datetime, - limit: int = 20, - semantic_threshold: float = 0.1, -) -> list[RetrievalResult]: - """Get temporal entry points (facts in date range with semantic relevance).""" - - if start_date.tzinfo is None: - start_date = start_date.replace(tzinfo=UTC) - if end_date.tzinfo is None: - end_date = end_date.replace(tzinfo=UTC) - - rows = await conn.fetch( - f""" - SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, - access_count, embedding, fact_type, document_id, chunk_id, - 1 - (embedding <=> $1::vector) AS similarity - FROM {fq_table("memory_units")} - WHERE bank_id = $2 - AND fact_type = $3 - AND embedding IS NOT NULL - AND ( - (occurred_start IS NOT NULL AND occurred_end IS NOT NULL - AND occurred_start <= $5 AND occurred_end >= $4) - OR (mentioned_at IS NOT NULL AND mentioned_at BETWEEN $4 AND $5) - OR (occurred_start IS NOT NULL AND occurred_start BETWEEN $4 AND $5) - OR (occurred_end IS NOT NULL AND occurred_end BETWEEN $4 AND $5) - ) - AND (1 - (embedding <=> $1::vector)) >= $6 - ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC, - (embedding <=> $1::vector) ASC - LIMIT $7 - """, - query_embedding_str, - bank_id, - fact_type, - start_date, - end_date, - semantic_threshold, - limit, - ) - - results = [] - total_days = max((end_date - start_date).total_seconds() / 86400, 1) - mid_date = start_date + (end_date - start_date) / 2 - - for row in rows: - result = RetrievalResult.from_db_row(dict(row)) - - # Calculate temporal proximity score - best_date = None - if row["occurred_start"] and row["occurred_end"]: - best_date = row["occurred_start"] + (row["occurred_end"] - row["occurred_start"]) / 2 - elif row["occurred_start"]: - best_date = row["occurred_start"] - elif row["occurred_end"]: - best_date = row["occurred_end"] - elif row["mentioned_at"]: - best_date = row["mentioned_at"] - - if best_date: - days_from_mid = abs((best_date - mid_date).total_seconds() / 86400) - result.temporal_proximity = 1.0 - min(days_from_mid / (total_days / 2), 1.0) - else: - result.temporal_proximity = 0.5 - - result.temporal_score = result.temporal_proximity - results.append(result) - - return results - - -async def _retrieve_parallel_bfs( - pool, - query_text: str, - query_embedding_str: str, - bank_id: str, - fact_type: str, - thinking_budget: int, - temporal_constraint: tuple | None, - retriever: GraphRetriever, -) -> ParallelRetrievalResult: - """BFS retrieval: all methods run in parallel (original behavior).""" - import time - - async def run_semantic() -> _TimedResult: - start = time.time() - async with acquire_with_retry(pool) as conn: - results = await retrieve_semantic(conn, query_embedding_str, bank_id, fact_type, limit=thinking_budget) - return _TimedResult(results, time.time() - start) - - async def run_bm25() -> _TimedResult: - start = time.time() - async with acquire_with_retry(pool) as conn: - results = await retrieve_bm25(conn, query_text, bank_id, fact_type, limit=thinking_budget) - return _TimedResult(results, time.time() - start) - - async def run_graph() -> _TimedResult: - start = time.time() - results = await retriever.retrieve( - pool=pool, - query_embedding_str=query_embedding_str, - bank_id=bank_id, - fact_type=fact_type, - budget=thinking_budget, - query_text=query_text, - ) - return _TimedResult(results, time.time() - start) - - async def run_temporal(tc_start, tc_end) -> _TimedResult: - start = time.time() - async with acquire_with_retry(pool) as conn: - results = await retrieve_temporal( - conn, - query_embedding_str, - bank_id, - fact_type, - tc_start, - tc_end, - budget=thinking_budget, - semantic_threshold=0.1, - ) - return _TimedResult(results, time.time() - start) - - if temporal_constraint: - tc_start, tc_end = temporal_constraint - semantic_r, bm25_r, graph_r, temporal_r = await asyncio.gather( - run_semantic(), - run_bm25(), - run_graph(), - run_temporal(tc_start, tc_end), - ) - return ParallelRetrievalResult( - semantic=semantic_r.results, - bm25=bm25_r.results, - graph=graph_r.results, - temporal=temporal_r.results, - timings={ - "semantic": semantic_r.time, - "bm25": bm25_r.time, - "graph": graph_r.time, - "temporal": temporal_r.time, - }, - temporal_constraint=temporal_constraint, - ) - else: - semantic_r, bm25_r, graph_r = await asyncio.gather( - run_semantic(), - run_bm25(), - run_graph(), - ) - return ParallelRetrievalResult( - semantic=semantic_r.results, - bm25=bm25_r.results, - graph=graph_r.results, - temporal=None, - timings={ - "semantic": semantic_r.time, - "bm25": bm25_r.time, - "graph": graph_r.time, - }, - temporal_constraint=None, - ) diff --git a/hindsight-api/hindsight_api/engine/search/scoring.py b/hindsight-api/hindsight_api/engine/search/scoring.py deleted file mode 100644 index d0258175..00000000 --- a/hindsight-api/hindsight_api/engine/search/scoring.py +++ /dev/null @@ -1,159 +0,0 @@ -""" -Scoring functions for memory search and retrieval. - -Includes recency weighting, frequency weighting, temporal proximity, -and similarity calculations used in memory activation and ranking. -""" - -from datetime import datetime - - -def cosine_similarity(vec1: list[float], vec2: list[float]) -> float: - """ - Calculate cosine similarity between two vectors. - - Args: - vec1: First vector - vec2: Second vector - - Returns: - Similarity score between 0 and 1 - """ - if len(vec1) != len(vec2): - raise ValueError("Vectors must have same dimension") - - dot_product = sum(a * b for a, b in zip(vec1, vec2)) - magnitude1 = sum(a * a for a in vec1) ** 0.5 - magnitude2 = sum(b * b for b in vec2) ** 0.5 - - if magnitude1 == 0 or magnitude2 == 0: - return 0.0 - - return dot_product / (magnitude1 * magnitude2) - - -def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -> float: - """ - Calculate recency weight using logarithmic decay. - - This provides much better differentiation over long time periods compared to - exponential decay. Uses a log-based decay where the half-life parameter controls - when memories reach 50% weight. - - Examples: - - Today (0 days): 1.0 - - 1 year (365 days): ~0.5 (with default half_life=365) - - 2 years (730 days): ~0.33 - - 5 years (1825 days): ~0.17 - - 10 years (3650 days): ~0.09 - - This ensures that 2-year-old and 5-year-old memories have meaningfully - different weights, unlike exponential decay which makes them both ~0. - - Args: - days_since: Number of days since the memory was created - half_life_days: Number of days for weight to reach 0.5 (default: 1 year) - - Returns: - Weight between 0 and 1 - """ - import math - - # Logarithmic decay: 1 / (1 + log(1 + days_since/half_life)) - # This decays much slower than exponential, giving better long-term differentiation - normalized_age = days_since / half_life_days - return 1.0 / (1.0 + math.log1p(normalized_age)) - - -def calculate_frequency_weight(access_count: int, max_boost: float = 2.0) -> float: - """ - Calculate frequency weight based on access count. - - Frequently accessed memories are weighted higher. - Uses logarithmic scaling to avoid over-weighting. - - Args: - access_count: Number of times the memory was accessed - max_boost: Maximum multiplier for frequently accessed memories - - Returns: - Weight between 1.0 and max_boost - """ - import math - - if access_count <= 0: - return 1.0 - - # Logarithmic scaling: log(access_count + 1) / log(10) - # This gives: 0 accesses = 1.0, 9 accesses ~= 1.5, 99 accesses ~= 2.0 - normalized = math.log(access_count + 1) / math.log(10) - return 1.0 + min(normalized, max_boost - 1.0) - - -def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime) -> datetime: - """ - Calculate a single temporal anchor point from a temporal range. - - Used for spreading activation - we need a single representative date - to calculate temporal proximity between facts. This simplifies the - range-to-range distance problem. - - Strategy: Use midpoint of the range for balanced representation. - - Args: - occurred_start: Start of temporal range - occurred_end: End of temporal range - - Returns: - Single datetime representing the temporal anchor (midpoint) - - Examples: - - Point event (July 14): start=July 14, end=July 14 → anchor=July 14 - - Month range (February): start=Feb 1, end=Feb 28 → anchor=Feb 14 - - Year range (2023): start=Jan 1, end=Dec 31 → anchor=July 1 - """ - # Calculate midpoint - time_delta = occurred_end - occurred_start - midpoint = occurred_start + (time_delta / 2) - return midpoint - - -def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float: - """ - Calculate temporal proximity between two temporal anchors. - - Used for spreading activation to determine how "close" two facts are - in time. Uses logarithmic decay so that temporal similarity doesn't - drop off too quickly. - - Args: - anchor_a: Temporal anchor of first fact - anchor_b: Temporal anchor of second fact - half_life_days: Number of days for proximity to reach 0.5 - (default: 30 days = 1 month) - - Returns: - Proximity score in [0, 1] where: - - 1.0 = same day - - 0.5 = ~half_life days apart - - 0.0 = very distant in time - - Examples: - - Same day: 1.0 - - 1 week apart (half_life=30): ~0.7 - - 1 month apart (half_life=30): ~0.5 - - 1 year apart (half_life=30): ~0.2 - """ - import math - - days_apart = abs((anchor_a - anchor_b).days) - - if days_apart == 0: - return 1.0 - - # Logarithmic decay: 1 / (1 + log(1 + days_apart/half_life)) - # Similar to calculate_recency_weight but for proximity between events - normalized_distance = days_apart / half_life_days - proximity = 1.0 / (1.0 + math.log1p(normalized_distance)) - - return proximity diff --git a/hindsight-api/hindsight_api/engine/search/temporal_extraction.py b/hindsight-api/hindsight_api/engine/search/temporal_extraction.py deleted file mode 100644 index 71f02d02..00000000 --- a/hindsight-api/hindsight_api/engine/search/temporal_extraction.py +++ /dev/null @@ -1,61 +0,0 @@ -""" -Temporal extraction for time-aware search queries. - -Handles natural language temporal expressions using transformer-based query analysis. -""" - -import logging -from datetime import datetime - -from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer, QueryAnalyzer - -logger = logging.getLogger(__name__) - -# Global default analyzer instance -# Can be overridden by passing a custom analyzer to extract_temporal_constraint -_default_analyzer: QueryAnalyzer | None = None - - -def get_default_analyzer() -> QueryAnalyzer: - """ - Get or create the default query analyzer. - - Uses lazy initialization to avoid loading at import time. - - Returns: - Default DateparserQueryAnalyzer instance - """ - global _default_analyzer - if _default_analyzer is None: - _default_analyzer = DateparserQueryAnalyzer() - return _default_analyzer - - -def extract_temporal_constraint( - query: str, - reference_date: datetime | None = None, - analyzer: QueryAnalyzer | None = None, -) -> tuple[datetime, datetime] | None: - """ - Extract temporal constraint from query. - - Returns (start_date, end_date) tuple if temporal constraint found, else None. - - Args: - query: Search query - reference_date: Reference date for relative terms (defaults to now) - analyzer: Custom query analyzer (defaults to DateparserQueryAnalyzer) - - Returns: - (start_date, end_date) tuple or None - """ - if analyzer is None: - analyzer = get_default_analyzer() - - analysis = analyzer.analyze(query, reference_date) - - if analysis.temporal_constraint: - result = (analysis.temporal_constraint.start_date, analysis.temporal_constraint.end_date) - return result - - return None diff --git a/hindsight-api/hindsight_api/engine/search/think_utils.py b/hindsight-api/hindsight_api/engine/search/think_utils.py deleted file mode 100644 index f8722605..00000000 --- a/hindsight-api/hindsight_api/engine/search/think_utils.py +++ /dev/null @@ -1,360 +0,0 @@ -""" -Think operation utilities for formulating answers based on agent and world facts. -""" - -import logging -import re -from datetime import datetime - -from pydantic import BaseModel, Field - -from ..response_models import DispositionTraits, MemoryFact - -logger = logging.getLogger(__name__) - - -class Opinion(BaseModel): - """An opinion formed by the bank.""" - - opinion: str = Field(description="The opinion or perspective with reasoning included") - confidence: float = Field(description="Confidence score for this opinion (0.0 to 1.0, where 1.0 is very confident)") - - -class OpinionExtractionResponse(BaseModel): - """Response containing extracted opinions.""" - - opinions: list[Opinion] = Field( - default_factory=list, description="List of opinions formed with their supporting reasons and confidence scores" - ) - - -def describe_trait_level(value: int) -> str: - """Convert trait value (1-5) to descriptive text.""" - levels = {1: "very low", 2: "low", 3: "moderate", 4: "high", 5: "very high"} - return levels.get(value, "moderate") - - -def build_disposition_description(disposition: DispositionTraits) -> str: - """Build a disposition description string from disposition traits.""" - skepticism_desc = { - 1: "You are very trusting and tend to take information at face value.", - 2: "You tend to trust information but may question obvious inconsistencies.", - 3: "You have a balanced approach to information, neither too trusting nor too skeptical.", - 4: "You are somewhat skeptical and often question the reliability of information.", - 5: "You are highly skeptical and critically examine all information for accuracy and hidden motives.", - } - - literalism_desc = { - 1: "You interpret information very flexibly, reading between the lines and inferring intent.", - 2: "You tend to consider context and implied meaning alongside literal statements.", - 3: "You balance literal interpretation with contextual understanding.", - 4: "You prefer to interpret information more literally and precisely.", - 5: "You interpret information very literally and focus on exact wording and commitments.", - } - - empathy_desc = { - 1: "You focus primarily on facts and data, setting aside emotional context.", - 2: "You consider facts first but acknowledge emotional factors exist.", - 3: "You balance factual analysis with emotional understanding.", - 4: "You give significant weight to emotional context and human factors.", - 5: "You strongly consider the emotional state and circumstances of others when forming memories.", - } - - return f"""Your disposition traits: -- Skepticism ({describe_trait_level(disposition.skepticism)}): {skepticism_desc.get(disposition.skepticism, skepticism_desc[3])} -- Literalism ({describe_trait_level(disposition.literalism)}): {literalism_desc.get(disposition.literalism, literalism_desc[3])} -- Empathy ({describe_trait_level(disposition.empathy)}): {empathy_desc.get(disposition.empathy, empathy_desc[3])}""" - - -def format_facts_for_prompt(facts: list[MemoryFact]) -> str: - """Format facts as JSON for LLM prompt.""" - import json - - if not facts: - return "[]" - formatted = [] - for fact in facts: - fact_obj = {"text": fact.text} - - # Add context if available - if fact.context: - fact_obj["context"] = fact.context - - # Add occurred_start if available (when the fact occurred) - if fact.occurred_start: - occurred_start = fact.occurred_start - if isinstance(occurred_start, str): - fact_obj["occurred_start"] = occurred_start - elif isinstance(occurred_start, datetime): - fact_obj["occurred_start"] = occurred_start.strftime("%Y-%m-%d %H:%M:%S") - - formatted.append(fact_obj) - - return json.dumps(formatted, indent=2) - - -def build_think_prompt( - agent_facts_text: str, - world_facts_text: str, - opinion_facts_text: str, - query: str, - name: str, - disposition: DispositionTraits, - background: str, - context: str | None = None, -) -> str: - """Build the think prompt for the LLM.""" - disposition_desc = build_disposition_description(disposition) - - name_section = f""" - -Your name: {name} -""" - - background_section = "" - if background: - background_section = f""" - -Your background: -{background} -""" - - context_section = "" - if context: - context_section = f""" -ADDITIONAL CONTEXT: -{context} - -""" - - return f"""Here's what I know and have experienced: - -MY IDENTITY & EXPERIENCES: -{agent_facts_text} - -WHAT I KNOW ABOUT THE WORLD: -{world_facts_text} - -MY EXISTING OPINIONS & BELIEFS: -{opinion_facts_text} - -{context_section}{name_section}{disposition_desc}{background_section} - -QUESTION: {query} - -Based on everything I know, believe, and who I am (including my name, disposition and background), here's what I genuinely think about this question. I'll draw on my experiences, knowledge, opinions, and personal traits to give you my honest perspective.""" - - -def get_system_message(disposition: DispositionTraits) -> str: - """Get the system message for the think LLM call.""" - # Build disposition-specific instructions based on trait values - instructions = [] - - # Skepticism influences how much to question/doubt information - if disposition.skepticism >= 4: - instructions.append("Be skeptical of claims and look for potential issues or inconsistencies.") - elif disposition.skepticism <= 2: - instructions.append("Trust the information provided and take statements at face value.") - - # Literalism influences interpretation style - if disposition.literalism >= 4: - instructions.append("Interpret information literally and focus on exact commitments and wording.") - elif disposition.literalism <= 2: - instructions.append("Read between the lines and consider implied meaning and context.") - - # Empathy influences consideration of emotional factors - if disposition.empathy >= 4: - instructions.append("Consider the emotional state and circumstances behind the information.") - elif disposition.empathy <= 2: - instructions.append("Focus on facts and outcomes rather than emotional context.") - - disposition_instruction = ( - " ".join(instructions) if instructions else "Balance your disposition traits when interpreting information." - ) - - return f"You are a person with your own thoughts, experiences, opinions, and disposition. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {disposition_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting." - - -async def extract_opinions_from_text(llm_config, text: str, query: str) -> list[Opinion]: - """ - Extract opinions with reasons and confidence from text using LLM. - - Args: - llm_config: LLM configuration to use - text: Text to extract opinions from - query: The original query that prompted this response - - Returns: - List of Opinion objects with text and confidence - """ - extraction_prompt = f"""Extract any NEW opinions or perspectives from the answer below and rewrite them in FIRST-PERSON as if YOU are stating the opinion directly. - -ORIGINAL QUESTION: -{query} - -ANSWER PROVIDED: -{text} - -Your task: Find opinions in the answer and rewrite them AS IF YOU ARE THE ONE SAYING THEM. - -An opinion is a judgment, viewpoint, or conclusion that goes beyond just stating facts. - -IMPORTANT: Do NOT extract statements like: -- "I don't have enough information" -- "The facts don't contain information about X" -- "I cannot answer because..." - -ONLY extract actual opinions about substantive topics. - -CRITICAL FORMAT REQUIREMENTS: -1. **ALWAYS start with first-person phrases**: "I think...", "I believe...", "In my view...", "I've come to believe...", "Previously I thought... but now..." -2. **NEVER use third-person**: Do NOT say "The speaker thinks..." or "They believe..." - always use "I" -3. Include the reasoning naturally within the statement -4. Provide a confidence score (0.0 to 1.0) - -CORRECT Examples (✓ FIRST-PERSON): -- "I think Alice is more reliable because she consistently delivers on time and writes clean code" -- "Previously I thought all engineers were equal, but now I feel that experience and track record really matter" -- "I believe reliability is best measured by consistent output over time" -- "I've come to believe that track records are more important than potential" - -WRONG Examples (✗ THIRD-PERSON - DO NOT USE): -- "The speaker thinks Alice is more reliable" -- "They believe reliability matters" -- "It is believed that Alice is better" - -If no genuine opinions are expressed (e.g., the response just says "I don't know"), return an empty list.""" - - try: - result = await llm_config.call( - messages=[ - { - "role": "system", - "content": "You are converting opinions from text into first-person statements. Always use 'I think', 'I believe', 'I feel', etc. NEVER use third-person like 'The speaker' or 'They'.", - }, - {"role": "user", "content": extraction_prompt}, - ], - response_format=OpinionExtractionResponse, - scope="memory_extract_opinion", - ) - - # Format opinions with confidence score and convert to first-person - formatted_opinions = [] - for op in result.opinions: - # Convert third-person to first-person if needed - opinion_text = op.opinion - - # Replace common third-person patterns with first-person - def singularize_verb(verb): - if verb.endswith("es"): - return verb[:-1] # believes -> believe - elif verb.endswith("s"): - return verb[:-1] # thinks -> think - return verb - - # Pattern: "The speaker/user [verb]..." -> "I [verb]..." - match = re.match( - r"^(The speaker|The user|They|It is believed) (believes?|thinks?|feels?|says|asserts?|considers?)(\s+that)?(.*)$", - opinion_text, - re.IGNORECASE, - ) - if match: - verb = singularize_verb(match.group(2)) - that_part = match.group(3) or "" # Keep " that" if present - rest = match.group(4) - opinion_text = f"I {verb}{that_part}{rest}" - - # If still doesn't start with first-person, prepend "I believe that " - first_person_starters = [ - "I think", - "I believe", - "I feel", - "In my view", - "I've come to believe", - "Previously I", - ] - if not any(opinion_text.startswith(starter) for starter in first_person_starters): - opinion_text = "I believe that " + opinion_text[0].lower() + opinion_text[1:] - - formatted_opinions.append(Opinion(opinion=opinion_text, confidence=op.confidence)) - - return formatted_opinions - - except Exception as e: - logger.warning(f"Failed to extract opinions: {str(e)}") - return [] - - -async def reflect( - llm_config, - query: str, - experience_facts: list[str] = None, - world_facts: list[str] = None, - opinion_facts: list[str] = None, - name: str = "Assistant", - disposition: DispositionTraits = None, - background: str = "", - context: str = None, -) -> str: - """ - Standalone reflect function for generating answers based on facts. - - This is a static version of the reflect operation that can be called - without a MemoryEngine instance, useful for testing. - - Args: - llm_config: LLM provider instance - query: Question to answer - experience_facts: List of experience/agent fact strings - world_facts: List of world fact strings - opinion_facts: List of opinion fact strings - name: Name of the agent/persona - disposition: Disposition traits (defaults to neutral) - background: Background information - context: Additional context for the prompt - - Returns: - Generated answer text - """ - # Default disposition if not provided - if disposition is None: - disposition = DispositionTraits(skepticism=3, literalism=3, empathy=3) - - # Convert string lists to MemoryFact format for formatting - def to_memory_facts(facts: list[str], fact_type: str) -> list[MemoryFact]: - if not facts: - return [] - return [MemoryFact(id=f"test-{i}", text=f, fact_type=fact_type) for i, f in enumerate(facts)] - - agent_results = to_memory_facts(experience_facts or [], "experience") - world_results = to_memory_facts(world_facts or [], "world") - opinion_results = to_memory_facts(opinion_facts or [], "opinion") - - # Format facts for prompt - agent_facts_text = format_facts_for_prompt(agent_results) - world_facts_text = format_facts_for_prompt(world_results) - opinion_facts_text = format_facts_for_prompt(opinion_results) - - # Build prompt - prompt = build_think_prompt( - agent_facts_text=agent_facts_text, - world_facts_text=world_facts_text, - opinion_facts_text=opinion_facts_text, - query=query, - name=name, - disposition=disposition, - background=background, - context=context, - ) - - system_message = get_system_message(disposition) - - # Call LLM - answer_text = await llm_config.call( - messages=[{"role": "system", "content": system_message}, {"role": "user", "content": prompt}], - scope="memory_think", - temperature=0.9, - max_completion_tokens=1000, - ) - - return answer_text.strip() diff --git a/hindsight-api/hindsight_api/engine/search/trace.py b/hindsight-api/hindsight_api/engine/search/trace.py deleted file mode 100644 index 19c80638..00000000 --- a/hindsight-api/hindsight_api/engine/search/trace.py +++ /dev/null @@ -1,245 +0,0 @@ -""" -Search trace models for debugging and visualization. - -These Pydantic models define the structure of search traces, capturing -every step of the spreading activation search process for analysis. -""" - -from datetime import datetime -from typing import Any, Literal - -from pydantic import BaseModel, Field - - -class QueryInfo(BaseModel): - """Information about the search query.""" - - query_text: str = Field(description="Original query text") - query_embedding: list[float] = Field(description="Generated query embedding vector") - timestamp: datetime = Field(description="When the query was executed") - budget: int = Field(description="Maximum nodes to explore") - max_tokens: int = Field(description="Maximum tokens to return in results") - - -class EntryPoint(BaseModel): - """An entry point node selected for search.""" - - node_id: str = Field(description="Memory unit ID") - text: str = Field(description="Memory unit text content") - similarity_score: float = Field(description="Cosine similarity to query", ge=0.0, le=1.0) - rank: int = Field(description="Rank among entry points (1-based)") - - -class WeightComponents(BaseModel): - """Breakdown of weight calculation components.""" - - activation: float = Field(description="Activation from spreading (can exceed 1.0 through accumulation)", ge=0.0) - semantic_similarity: float = Field(description="Semantic similarity to query", ge=0.0, le=1.0) - recency: float = Field(description="Recency weight", ge=0.0, le=1.0) - frequency: float = Field(description="Normalized frequency weight", ge=0.0, le=1.0) - final_weight: float = Field(description="Combined final weight") - - # Weight formula components (for transparency) - activation_contribution: float = Field(description="0.3 * activation") - semantic_contribution: float = Field(description="0.3 * semantic_similarity") - recency_contribution: float = Field(description="0.25 * recency") - frequency_contribution: float = Field(description="0.15 * frequency") - - -class LinkInfo(BaseModel): - """Information about a link to a neighbor.""" - - to_node_id: str = Field(description="Target node ID") - link_type: Literal["temporal", "semantic", "entity"] = Field(description="Type of link") - link_weight: float = Field( - description="Weight of the link (can exceed 1.0 when aggregating multiple connections)", ge=0.0 - ) - entity_id: str | None = Field(default=None, description="Entity ID if link_type is 'entity'") - new_activation: float | None = Field( - default=None, description="Activation that would be passed to neighbor (None for supplementary links)" - ) - followed: bool = Field(description="Whether this link was followed (or pruned)") - prune_reason: str | None = Field(default=None, description="Why link was not followed (if not followed)") - is_supplementary: bool = Field( - default=False, description="Whether this is a supplementary link (multiple connections to same node)" - ) - - -class NodeVisit(BaseModel): - """Information about visiting a node during search.""" - - step: int = Field(description="Step number in search (1-based)") - node_id: str = Field(description="Memory unit ID") - text: str = Field(description="Memory unit text content") - context: str = Field(description="Memory unit context") - event_date: datetime | None = Field(default=None, description="When the memory occurred") - access_count: int = Field(description="Number of times accessed before this search") - - # How this node was reached - is_entry_point: bool = Field(description="Whether this is an entry point") - parent_node_id: str | None = Field(default=None, description="Node that led to this one") - link_type: Literal["temporal", "semantic", "entity"] | None = Field( - default=None, description="Type of link from parent" - ) - link_weight: float | None = Field(default=None, description="Weight of link from parent") - - # Weights - weights: WeightComponents = Field(description="Weight calculation breakdown") - - # Neighbors discovered from this node - neighbors_explored: list[LinkInfo] = Field(default_factory=list, description="Links explored from this node") - - # Ranking - final_rank: int | None = Field(default=None, description="Final rank in results (1-based, None if not in top-k)") - - -class PruningDecision(BaseModel): - """Records when a node was considered but not visited.""" - - node_id: str = Field(description="Node that was pruned") - reason: Literal["already_visited", "activation_too_low", "budget_exhausted"] = Field( - description="Why it was pruned" - ) - activation: float = Field(description="Activation value when pruned") - would_have_been_step: int = Field(description="What step it would have been if visited") - - -class SearchPhaseMetrics(BaseModel): - """Performance metrics for a search phase.""" - - phase_name: str = Field(description="Name of the phase") - duration_seconds: float = Field(description="Time taken in seconds") - details: dict[str, Any] = Field(default_factory=dict, description="Additional phase-specific metrics") - - -class RetrievalResult(BaseModel): - """A single result from a retrieval method.""" - - rank: int = Field(description="Rank in this retrieval method (1-based)") - node_id: str = Field(description="Memory unit ID") - text: str = Field(description="Memory unit text content") - context: str = Field(default="", description="Memory unit context") - event_date: datetime | None = Field(default=None, description="When the memory occurred") - fact_type: str | None = Field(default=None, description="Fact type (world, experience, opinion)") - score: float = Field(description="Score from this retrieval method") - score_name: str = Field(description="Name of the score (e.g., 'similarity', 'bm25_score', 'activation')") - - -class RetrievalMethodResults(BaseModel): - """Results from a single retrieval method.""" - - method_name: Literal["semantic", "bm25", "graph", "temporal"] = Field(description="Name of retrieval method") - fact_type: str | None = Field( - default=None, description="Fact type this retrieval was for (world, experience, opinion)" - ) - results: list[RetrievalResult] = Field(description="Retrieved results with ranks") - duration_seconds: float = Field(description="Time taken for this retrieval") - metadata: dict[str, Any] = Field(default_factory=dict, description="Method-specific metadata") - - -class RRFMergeResult(BaseModel): - """A result after RRF merging.""" - - node_id: str = Field(description="Memory unit ID") - text: str = Field(description="Memory unit text content") - rrf_score: float = Field(description="Reciprocal Rank Fusion score") - source_ranks: dict[str, int] = Field(description="Rank in each source that contributed (method_name -> rank)") - final_rrf_rank: int = Field(description="Rank after RRF merge (1-based)") - - -class RerankedResult(BaseModel): - """A result after reranking.""" - - node_id: str = Field(description="Memory unit ID") - text: str = Field(description="Memory unit text content") - rerank_score: float = Field(description="Final reranking score") - rerank_rank: int = Field(description="Rank after reranking (1-based)") - rrf_rank: int = Field(description="Original RRF rank before reranking") - rank_change: int = Field(description="Change in rank (positive = moved up)") - score_components: dict[str, float] = Field(default_factory=dict, description="Score breakdown") - - -class SearchSummary(BaseModel): - """Summary statistics about the search.""" - - total_nodes_visited: int = Field(description="Total nodes visited") - total_nodes_pruned: int = Field(description="Total nodes pruned") - entry_points_found: int = Field(description="Number of entry points") - budget_used: int = Field(description="How much budget was used") - budget_remaining: int = Field(description="How much budget remained") - total_duration_seconds: float = Field(description="Total search duration") - results_returned: int = Field(description="Number of results returned") - - # Link statistics - temporal_links_followed: int = Field(default=0, description="Temporal links followed") - semantic_links_followed: int = Field(default=0, description="Semantic links followed") - entity_links_followed: int = Field(default=0, description="Entity links followed") - - # Phase timings - phase_metrics: list[SearchPhaseMetrics] = Field(default_factory=list, description="Metrics for each phase") - - -class SearchTrace(BaseModel): - """Complete trace of a search operation.""" - - query: QueryInfo = Field(description="Query information") - - # New 4-way retrieval architecture - retrieval_results: list[RetrievalMethodResults] = Field( - default_factory=list, description="Results from each retrieval method" - ) - rrf_merged: list[RRFMergeResult] = Field(default_factory=list, description="Results after RRF merging") - reranked: list[RerankedResult] = Field(default_factory=list, description="Results after reranking") - - # Legacy fields (kept for backward compatibility with graph/temporal visualizations) - entry_points: list[EntryPoint] = Field( - default_factory=list, description="Entry points selected for search (legacy)" - ) - visits: list[NodeVisit] = Field( - default_factory=list, description="All nodes visited during search (legacy, for graph viz)" - ) - pruned: list[PruningDecision] = Field(default_factory=list, description="Nodes that were pruned (legacy)") - - summary: SearchSummary = Field(description="Summary statistics") - - # Final results (for comparison with visits) - final_results: list[dict[str, Any]] = Field(description="Final ranked results returned to user") - - model_config = {"json_encoders": {datetime: lambda v: v.isoformat()}} - - def to_json(self, **kwargs) -> str: - """Export trace as JSON string.""" - return self.model_dump_json(indent=2, **kwargs) - - def to_dict(self) -> dict: - """Export trace as dictionary.""" - return self.model_dump() - - def get_visit_by_node_id(self, node_id: str) -> NodeVisit | None: - """Find a visit by node ID.""" - for visit in self.visits: - if visit.node_id == node_id: - return visit - return None - - def get_search_path_to_node(self, node_id: str) -> list[NodeVisit]: - """Get the path from entry point to a specific node.""" - path = [] - current_visit = self.get_visit_by_node_id(node_id) - - while current_visit: - path.insert(0, current_visit) - if current_visit.parent_node_id: - current_visit = self.get_visit_by_node_id(current_visit.parent_node_id) - else: - break - - return path - - def get_nodes_by_link_type(self, link_type: Literal["temporal", "semantic", "entity"]) -> list[NodeVisit]: - """Get all nodes reached via a specific link type.""" - return [v for v in self.visits if v.link_type == link_type] - - def get_entry_point_nodes(self) -> list[NodeVisit]: - """Get all entry point visits.""" - return [v for v in self.visits if v.is_entry_point] diff --git a/hindsight-api/hindsight_api/engine/search/tracer.py b/hindsight-api/hindsight_api/engine/search/tracer.py deleted file mode 100644 index ef695ad0..00000000 --- a/hindsight-api/hindsight_api/engine/search/tracer.py +++ /dev/null @@ -1,461 +0,0 @@ -""" -Search tracer for collecting detailed search execution traces. - -The SearchTracer collects comprehensive information about each step -of the spreading activation search process for debugging and visualization. -""" - -import time -from datetime import UTC, datetime -from typing import Any, Literal - -from .trace import ( - EntryPoint, - LinkInfo, - NodeVisit, - PruningDecision, - QueryInfo, - RerankedResult, - RetrievalMethodResults, - RetrievalResult, - RRFMergeResult, - SearchPhaseMetrics, - SearchSummary, - SearchTrace, - WeightComponents, -) - - -class SearchTracer: - """ - Tracer for collecting detailed search execution information. - - Usage: - tracer = SearchTracer(query="Who is Alice?", budget=50, max_tokens=4096) - tracer.start() - - # During search... - tracer.record_query_embedding(embedding) - tracer.add_entry_point(node_id, text, similarity, rank) - tracer.visit_node(...) - tracer.prune_node(...) - - # After search... - trace = tracer.finalize(final_results) - json_output = trace.to_json() - """ - - def __init__(self, query: str, budget: int, max_tokens: int): - """ - Initialize tracer. - - Args: - query: Search query text - budget: Maximum nodes to explore - max_tokens: Maximum tokens to return in results - """ - self.query_text = query - self.budget = budget - self.max_tokens = max_tokens - - # Trace data - self.query_embedding: list[float] | None = None - self.start_time: float | None = None - self.entry_points: list[EntryPoint] = [] - self.visits: list[NodeVisit] = [] - self.pruned: list[PruningDecision] = [] - self.phase_metrics: list[SearchPhaseMetrics] = [] - - # New 4-way retrieval tracking - self.retrieval_results: list[RetrievalMethodResults] = [] - self.rrf_merged: list[RRFMergeResult] = [] - self.reranked: list[RerankedResult] = [] - - # Tracking state - self.current_step = 0 - self.nodes_visited_set = set() # For quick lookups - - # Link statistics - self.temporal_links_followed = 0 - self.semantic_links_followed = 0 - self.entity_links_followed = 0 - - def start(self): - """Start timing the search.""" - self.start_time = time.time() - - def record_query_embedding(self, embedding: list[float]): - """Record the query embedding.""" - self.query_embedding = embedding - - def add_entry_point(self, node_id: str, text: str, similarity: float, rank: int): - """ - Record an entry point. - - Args: - node_id: Memory unit ID - text: Memory unit text - similarity: Cosine similarity to query - rank: Rank among entry points (1-based) - """ - # Clamp similarity to [0.0, 1.0] to handle floating-point precision - similarity = min(1.0, max(0.0, similarity)) - - self.entry_points.append( - EntryPoint( - node_id=node_id, - text=text, - similarity_score=similarity, - rank=rank, - ) - ) - - def visit_node( - self, - node_id: str, - text: str, - context: str, - event_date: datetime | None, - access_count: int, - is_entry_point: bool, - parent_node_id: str | None, - link_type: Literal["temporal", "semantic", "entity"] | None, - link_weight: float | None, - activation: float, - semantic_similarity: float, - recency: float, - frequency: float, - final_weight: float, - ): - """ - Record visiting a node. - - Args: - node_id: Memory unit ID - text: Memory unit text - context: Memory unit context - event_date: When the memory occurred - access_count: Access count before this search - is_entry_point: Whether this is an entry point - parent_node_id: Node that led here (None for entry points) - link_type: Type of link from parent - link_weight: Weight of link from parent - activation: Activation score - semantic_similarity: Semantic similarity to query - recency: Recency weight - frequency: Frequency weight - final_weight: Combined final weight - """ - self.current_step += 1 - self.nodes_visited_set.add(node_id) - - # Clamp values to handle floating-point precision issues - # (sometimes normalization produces values like 1.0000005 instead of 1.0) - semantic_similarity = min(1.0, max(0.0, semantic_similarity)) - recency = min(1.0, max(0.0, recency)) - frequency = min(1.0, max(0.0, frequency)) - - # Calculate weight contributions for transparency - weights = WeightComponents( - activation=activation, - semantic_similarity=semantic_similarity, - recency=recency, - frequency=frequency, - final_weight=final_weight, - activation_contribution=0.3 * activation, - semantic_contribution=0.3 * semantic_similarity, - recency_contribution=0.25 * recency, - frequency_contribution=0.15 * frequency, - ) - - visit = NodeVisit( - step=self.current_step, - node_id=node_id, - text=text, - context=context, - event_date=event_date, - access_count=access_count, - is_entry_point=is_entry_point, - parent_node_id=parent_node_id, - link_type=link_type, - link_weight=link_weight, - weights=weights, - neighbors_explored=[], - final_rank=None, # Will be set later - ) - - self.visits.append(visit) - - # Track link statistics - if link_type == "temporal": - self.temporal_links_followed += 1 - elif link_type == "semantic": - self.semantic_links_followed += 1 - elif link_type == "entity": - self.entity_links_followed += 1 - - def add_neighbor_link( - self, - from_node_id: str, - to_node_id: str, - link_type: Literal["temporal", "semantic", "entity"], - link_weight: float, - entity_id: str | None, - new_activation: float | None, - followed: bool, - prune_reason: str | None = None, - is_supplementary: bool = False, - ): - """ - Record a link to a neighbor (whether followed or not). - - Args: - from_node_id: Source node - to_node_id: Target node - link_type: Type of link - link_weight: Weight of link - entity_id: Entity ID if link is entity-based - new_activation: Activation passed to neighbor (None for supplementary links) - followed: Whether link was followed - prune_reason: Why link was not followed (if not followed) - is_supplementary: Whether this is a supplementary link (multiple connections) - """ - # Find the visit for the source node - visit = None - for v in self.visits: - if v.node_id == from_node_id: - visit = v - break - - if visit is None: - # Node not found, skip - return - - link_info = LinkInfo( - to_node_id=to_node_id, - link_type=link_type, - link_weight=link_weight, - entity_id=entity_id, - new_activation=new_activation, - followed=followed, - prune_reason=prune_reason, - is_supplementary=is_supplementary, - ) - - visit.neighbors_explored.append(link_info) - - def prune_node( - self, - node_id: str, - reason: Literal["already_visited", "activation_too_low", "budget_exhausted"], - activation: float, - ): - """ - Record a node being pruned (not visited). - - Args: - node_id: Node that was pruned - reason: Why it was pruned - activation: Activation value when pruned - """ - self.pruned.append( - PruningDecision( - node_id=node_id, - reason=reason, - activation=activation, - would_have_been_step=self.current_step + 1, - ) - ) - - def add_phase_metric(self, phase_name: str, duration_seconds: float, details: dict[str, Any] | None = None): - """ - Record metrics for a search phase. - - Args: - phase_name: Name of the phase - duration_seconds: Time taken - details: Additional phase-specific details - """ - self.phase_metrics.append( - SearchPhaseMetrics( - phase_name=phase_name, - duration_seconds=duration_seconds, - details=details or {}, - ) - ) - - def add_retrieval_results( - self, - method_name: Literal["semantic", "bm25", "graph", "temporal"], - results: list[tuple], # List of (doc_id, data) tuples - duration_seconds: float, - score_field: str, # e.g., "similarity", "bm25_score" - metadata: dict[str, Any] | None = None, - fact_type: str | None = None, - ): - """ - Record results from a single retrieval method. - - Args: - method_name: Name of the retrieval method - results: List of (doc_id, data) tuples from retrieval - duration_seconds: Time taken for this retrieval - score_field: Field name containing the score in data dict - metadata: Optional metadata about this retrieval method - fact_type: Fact type this retrieval was for (world, experience, opinion) - """ - retrieval_results = [] - for rank, (doc_id, data) in enumerate(results, start=1): - score = data.get(score_field) - if score is None: - score = 0.0 - retrieval_results.append( - RetrievalResult( - rank=rank, - node_id=doc_id, - text=data.get("text", ""), - context=data.get("context", ""), - event_date=data.get("event_date"), - fact_type=data.get("fact_type") or fact_type, - score=score, - score_name=score_field, - ) - ) - - self.retrieval_results.append( - RetrievalMethodResults( - method_name=method_name, - fact_type=fact_type, - results=retrieval_results, - duration_seconds=duration_seconds, - metadata=metadata or {}, - ) - ) - - def add_rrf_merged(self, merged_results: list[tuple]): - """ - Record RRF merged results. - - Args: - merged_results: List of (doc_id, data, rrf_meta) tuples from RRF merge - """ - self.rrf_merged = [] - for rank, (doc_id, data, rrf_meta) in enumerate(merged_results, start=1): - 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", {}), - final_rrf_rank=rank, - ) - ) - - def add_reranked(self, reranked_results: list[dict[str, Any]], rrf_merged: list): - """ - Record reranked results. - - Args: - reranked_results: List of result dicts after reranking - rrf_merged: Original RRF merged results for comparison - """ - # Build map of node_id -> rrf_rank - rrf_rank_map = {} - for item in self.rrf_merged: - rrf_rank_map[item.node_id] = item.final_rrf_rank - - self.reranked = [] - for rank, result in enumerate(reranked_results, start=1): - node_id = result["id"] - rrf_rank = rrf_rank_map.get(node_id, len(rrf_merged) + 1) - rank_change = rrf_rank - rank # Positive = moved up - - # Extract score components (only include non-None values) - # Keys from ScoredResult.to_dict(): cross_encoder_score, cross_encoder_score_normalized, - # rrf_normalized, temporal, recency, combined_score, weight - score_components = {} - for key in [ - "cross_encoder_score", - "cross_encoder_score_normalized", - "rrf_score", - "rrf_normalized", - "temporal", - "recency", - "combined_score", - ]: - if key in result and result[key] is not None: - score_components[key] = result[key] - - self.reranked.append( - RerankedResult( - node_id=node_id, - text=result.get("text", ""), - rerank_score=result.get("weight", 0.0), - rerank_rank=rank, - rrf_rank=rrf_rank, - rank_change=rank_change, - score_components=score_components, - ) - ) - - def finalize(self, final_results: list[dict[str, Any]]) -> SearchTrace: - """ - Finalize the trace and return the complete SearchTrace object. - - Args: - final_results: Final ranked results returned to user - - Returns: - Complete SearchTrace object - """ - if self.start_time is None: - raise ValueError("Tracer not started - call start() first") - - total_duration = time.time() - self.start_time - - # Set final ranks on visits based on results - for rank, result in enumerate(final_results, 1): - result_node_id = result["id"] - for visit in self.visits: - if visit.node_id == result_node_id: - visit.final_rank = rank - break - - # Create query info - query_info = QueryInfo( - query_text=self.query_text, - query_embedding=self.query_embedding or [], - timestamp=datetime.now(UTC), - budget=self.budget, - max_tokens=self.max_tokens, - ) - - # Create summary - summary = SearchSummary( - total_nodes_visited=len(self.visits), - total_nodes_pruned=len(self.pruned), - entry_points_found=len(self.entry_points), - budget_used=len(self.visits), - budget_remaining=self.budget - len(self.visits), - total_duration_seconds=total_duration, - results_returned=len(final_results), - temporal_links_followed=self.temporal_links_followed, - semantic_links_followed=self.semantic_links_followed, - entity_links_followed=self.entity_links_followed, - phase_metrics=self.phase_metrics, - ) - - # Create complete trace - trace = SearchTrace( - query=query_info, - retrieval_results=self.retrieval_results, - rrf_merged=self.rrf_merged, - reranked=self.reranked, - entry_points=self.entry_points, - visits=self.visits, - pruned=self.pruned, - summary=summary, - final_results=final_results, - ) - - return trace diff --git a/hindsight-api/hindsight_api/engine/search/types.py b/hindsight-api/hindsight_api/engine/search/types.py deleted file mode 100644 index 630ee5db..00000000 --- a/hindsight-api/hindsight_api/engine/search/types.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -Type definitions for the recall pipeline. - -These dataclasses replace Dict[str, Any] types throughout the recall pipeline, -providing type safety and making data flow explicit. -""" - -from dataclasses import dataclass, field -from datetime import datetime -from typing import Any - - -@dataclass -class RetrievalResult: - """ - Result from a single retrieval method (semantic, BM25, graph, or temporal). - - This represents a raw result from the database query, before merging or reranking. - """ - - id: str - text: str - fact_type: str - context: str | None = None - event_date: datetime | None = None - occurred_start: datetime | None = None - occurred_end: datetime | None = None - mentioned_at: datetime | None = None - document_id: str | None = None - chunk_id: str | None = None - access_count: int = 0 - embedding: list[float] | None = None - - # Retrieval-specific scores (only one will be set depending on retrieval method) - similarity: float | None = None # Semantic retrieval - bm25_score: float | None = None # BM25 retrieval - activation: float | None = None # Graph retrieval (spreading activation) - temporal_score: float | None = None # Temporal retrieval - temporal_proximity: float | None = None # Temporal retrieval - - @classmethod - def from_db_row(cls, row: dict[str, Any]) -> "RetrievalResult": - """Create from a database row (asyncpg Record converted to dict).""" - return cls( - id=str(row["id"]), - text=row["text"], - fact_type=row["fact_type"], - context=row.get("context"), - event_date=row.get("event_date"), - occurred_start=row.get("occurred_start"), - occurred_end=row.get("occurred_end"), - mentioned_at=row.get("mentioned_at"), - document_id=row.get("document_id"), - chunk_id=row.get("chunk_id"), - access_count=row.get("access_count", 0), - embedding=row.get("embedding"), - similarity=row.get("similarity"), - bm25_score=row.get("bm25_score"), - activation=row.get("activation"), - temporal_score=row.get("temporal_score"), - temporal_proximity=row.get("temporal_proximity"), - ) - - -@dataclass -class MergedCandidate: - """ - Candidate after RRF merge of multiple retrieval results. - - Contains the original retrieval data plus RRF metadata. - """ - - # Original retrieval data - retrieval: RetrievalResult - - # RRF metadata - rrf_score: float - rrf_rank: int = 0 - source_ranks: dict[str, int] = field(default_factory=dict) # method_name -> rank - - @property - def id(self) -> str: - """Convenience property to access ID.""" - return self.retrieval.id - - -@dataclass -class ScoredResult: - """ - Result after reranking and scoring. - - Contains all retrieval/merge data plus reranking scores and combined score. - """ - - # Original merged candidate - candidate: MergedCandidate - - # Reranking scores - cross_encoder_score: float = 0.0 - cross_encoder_score_normalized: float = 0.0 - - # Normalized component scores - rrf_normalized: float = 0.0 - recency: float = 0.5 - temporal: float = 0.5 - - # Final combined score - combined_score: float = 0.0 - weight: float = 0.0 # Final weight used for ranking - - @property - def id(self) -> str: - """Convenience property to access ID.""" - return self.candidate.id - - @property - def retrieval(self) -> RetrievalResult: - """Convenience property to access retrieval data.""" - return self.candidate.retrieval - - def to_dict(self) -> dict[str, Any]: - """ - Convert to dict for backwards compatibility. - - This is used during the transition period and for serialization. - """ - # Start with retrieval data - result = { - "id": self.retrieval.id, - "text": self.retrieval.text, - "fact_type": self.retrieval.fact_type, - "context": self.retrieval.context, - "event_date": self.retrieval.event_date, - "occurred_start": self.retrieval.occurred_start, - "occurred_end": self.retrieval.occurred_end, - "mentioned_at": self.retrieval.mentioned_at, - "document_id": self.retrieval.document_id, - "chunk_id": self.retrieval.chunk_id, - "access_count": self.retrieval.access_count, - "embedding": self.retrieval.embedding, - "semantic_similarity": self.retrieval.similarity, - "bm25_score": self.retrieval.bm25_score, - } - - # Add temporal scores if present - if self.retrieval.temporal_score is not None: - result["temporal_score"] = self.retrieval.temporal_score - if self.retrieval.temporal_proximity is not None: - result["temporal_proximity"] = self.retrieval.temporal_proximity - - # Add RRF metadata - result["rrf_score"] = self.candidate.rrf_score - result["rrf_rank"] = self.candidate.rrf_rank - result.update(self.candidate.source_ranks) - - # Add reranking scores - result["cross_encoder_score"] = self.cross_encoder_score - result["cross_encoder_score_normalized"] = self.cross_encoder_score_normalized - result["rrf_normalized"] = self.rrf_normalized - result["temporal"] = self.temporal - result["recency"] = self.recency - result["combined_score"] = self.combined_score - result["weight"] = self.weight - result["activation"] = self.weight # Legacy field - - return result diff --git a/hindsight-api/hindsight_api/engine/task_backend.py b/hindsight-api/hindsight_api/engine/task_backend.py deleted file mode 100644 index d34139f7..00000000 --- a/hindsight-api/hindsight_api/engine/task_backend.py +++ /dev/null @@ -1,250 +0,0 @@ -""" -Abstract task backend for running async tasks. - -This provides an abstraction that can be adapted to different execution models: -- AsyncIO queue (default implementation) -- Pub/Sub architectures (future) -- Message brokers (future) -""" - -import asyncio -import logging -from abc import ABC, abstractmethod -from collections.abc import Awaitable, Callable -from typing import Any - -logger = logging.getLogger(__name__) - - -class TaskBackend(ABC): - """ - Abstract base class for task execution backends. - - Implementations must: - 1. Store/publish task events (as serializable dicts) - 2. Execute tasks through a provided executor callback - - The backend treats tasks as pure dictionaries that can be serialized - and sent over the network. The executor (typically MemoryEngine.execute_task) - receives the dict and routes it to the appropriate handler. - """ - - def __init__(self): - """Initialize the task backend.""" - self._executor: Callable[[dict[str, Any]], Awaitable[None]] | None = None - self._initialized = False - - def set_executor(self, executor: Callable[[dict[str, Any]], Awaitable[None]]): - """ - Set the executor callback for processing tasks. - - Args: - executor: Async function that takes a task dict and executes it - """ - self._executor = executor - - @abstractmethod - async def initialize(self): - """ - Initialize the backend (e.g., start workers, connect to broker). - """ - pass - - @abstractmethod - async def submit_task(self, task_dict: dict[str, Any]): - """ - Submit a task for execution. - - Args: - task_dict: Task as a dictionary (must be serializable) - """ - pass - - @abstractmethod - async def shutdown(self): - """ - Shutdown the backend gracefully (e.g., stop workers, close connections). - """ - pass - - async def _execute_task(self, task_dict: dict[str, Any]): - """ - Execute a task through the registered executor. - - Args: - task_dict: Task dictionary to execute - """ - if self._executor is None: - task_type = task_dict.get("type", "unknown") - logger.warning(f"No executor registered, skipping task {task_type}") - return - - try: - await self._executor(task_dict) - except Exception as e: - task_type = task_dict.get("type", "unknown") - logger.error(f"Error executing task {task_type}: {e}") - import traceback - - traceback.print_exc() - - -class SyncTaskBackend(TaskBackend): - """ - Synchronous task backend that executes tasks immediately. - - This is useful for embedded/CLI usage where we don't want background - workers that prevent clean exit. Tasks are executed inline rather than - being queued. - """ - - async def initialize(self): - """No-op for sync backend.""" - self._initialized = True - logger.debug("SyncTaskBackend initialized") - - async def submit_task(self, task_dict: dict[str, Any]): - """ - Execute the task immediately (synchronously). - - Args: - task_dict: Task dictionary to execute - """ - if not self._initialized: - await self.initialize() - - await self._execute_task(task_dict) - - async def shutdown(self): - """No-op for sync backend.""" - self._initialized = False - logger.debug("SyncTaskBackend shutdown") - - -class AsyncIOQueueBackend(TaskBackend): - """ - Task backend implementation using asyncio queues. - - This is the default implementation that uses in-process asyncio queues - and a periodic consumer worker. - """ - - def __init__(self, batch_size: int = 100, batch_interval: float = 1.0): - """ - Initialize AsyncIO queue backend. - - Args: - batch_size: Maximum number of tasks to process in one batch - batch_interval: Maximum time (seconds) to wait before processing batch - """ - super().__init__() - self._queue: asyncio.Queue | None = None - self._worker_task: asyncio.Task | None = None - self._shutdown_event: asyncio.Event | None = None - self._batch_size = batch_size - self._batch_interval = batch_interval - - async def initialize(self): - """Initialize the queue and start the worker.""" - if self._initialized: - return - - self._queue = asyncio.Queue() - self._shutdown_event = asyncio.Event() - self._worker_task = asyncio.create_task(self._worker()) - self._initialized = True - logger.info("AsyncIOQueueBackend initialized") - - async def submit_task(self, task_dict: dict[str, Any]): - """ - Submit a task by putting it in the queue. - - Args: - task_dict: Task dictionary to execute - """ - if not self._initialized: - await self.initialize() - - await self._queue.put(task_dict) - task_type = task_dict.get("type", "unknown") - task_id = task_dict.get("id") - - async def wait_for_pending_tasks(self, timeout: float = 5.0): - """ - Wait for all pending tasks in the queue to be processed. - - This is useful in tests to ensure background tasks complete before assertions. - - Args: - timeout: Maximum time to wait in seconds - """ - if not self._initialized or self._queue is None: - return - - # Wait for queue to be empty and give worker time to process - start_time = asyncio.get_event_loop().time() - while asyncio.get_event_loop().time() - start_time < timeout: - if self._queue.empty(): - # Queue is empty, give worker a bit more time to finish any in-flight task - await asyncio.sleep(0.3) - # Check again - if still empty, we're done - if self._queue.empty(): - return - else: - # Queue not empty, wait a bit - await asyncio.sleep(0.1) - - async def shutdown(self): - """Shutdown the worker and drain the queue.""" - if not self._initialized: - return - - logger.info("Shutting down AsyncIOQueueBackend...") - - # Signal shutdown - self._shutdown_event.set() - - # Cancel worker - if self._worker_task is not None: - self._worker_task.cancel() - try: - await self._worker_task - except asyncio.CancelledError: - pass # Worker cancelled successfully - - self._initialized = False - logger.info("AsyncIOQueueBackend shutdown complete") - - async def _worker(self): - """ - Background worker that processes tasks in batches. - - Collects tasks for up to batch_interval seconds or batch_size items, - then processes them. - """ - while not self._shutdown_event.is_set(): - try: - # Collect tasks for batching - tasks = [] - deadline = asyncio.get_event_loop().time() + self._batch_interval - - while len(tasks) < self._batch_size and asyncio.get_event_loop().time() < deadline: - try: - remaining_time = max(0.1, deadline - asyncio.get_event_loop().time()) - task_dict = await asyncio.wait_for(self._queue.get(), timeout=remaining_time) - tasks.append(task_dict) - except TimeoutError: - break - - # Process batch - if tasks: - # Execute tasks concurrently - await asyncio.gather( - *[self._execute_task(task_dict) for task_dict in tasks], return_exceptions=True - ) - - except asyncio.CancelledError: - break - except Exception as e: - logger.error(f"Worker error: {e}") - await asyncio.sleep(1) # Backoff on error diff --git a/hindsight-api/hindsight_api/engine/utils.py b/hindsight-api/hindsight_api/engine/utils.py deleted file mode 100644 index 1d1a132b..00000000 --- a/hindsight-api/hindsight_api/engine/utils.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -Utility functions for memory system. -""" - -import logging -from datetime import datetime -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from .llm_wrapper import LLMConfig - from .retain.fact_extraction import Fact - -from .retain.fact_extraction import extract_facts_from_text - - -async def extract_facts( - text: str, - event_date: datetime, - context: str = "", - llm_config: "LLMConfig" = None, - agent_name: str = None, - extract_opinions: bool = False, -) -> tuple[list["Fact"], list[tuple[str, int]]]: - """ - Extract semantic facts from text using LLM. - - Uses LLM for intelligent fact extraction that: - - Filters out social pleasantries and filler words - - Creates self-contained statements with absolute dates - - Handles conversational text well - - Resolves relative time expressions to absolute dates - - Args: - text: Input text (conversation, article, etc.) - event_date: Reference date for resolving relative times - context: Context about the conversation/document - llm_config: LLM configuration to use - agent_name: Optional agent name to help identify agent-related facts - extract_opinions: If True, extract ONLY opinions. If False, extract world and agent facts (no opinions) - - Returns: - Tuple of (facts, chunks) where: - - facts: List of Fact model instances - - chunks: List of tuples (chunk_text, fact_count) for each chunk - - Raises: - Exception: If LLM fact extraction fails - """ - if not text or not text.strip(): - return [], [] - - facts, chunks = await extract_facts_from_text( - text, - event_date, - context=context, - llm_config=llm_config, - agent_name=agent_name, - extract_opinions=extract_opinions, - ) - - if not facts: - logging.warning( - f"LLM extracted 0 facts from text of length {len(text)}. This may indicate the text contains no meaningful information, or the LLM failed to extract facts. Full text: {text}" - ) - return [], chunks - - return facts, chunks - - -def cosine_similarity(vec1: list[float], vec2: list[float]) -> float: - """ - Calculate cosine similarity between two vectors. - - Args: - vec1: First vector - vec2: Second vector - - Returns: - Similarity score between 0 and 1 - """ - if len(vec1) != len(vec2): - raise ValueError("Vectors must have same dimension") - - dot_product = sum(a * b for a, b in zip(vec1, vec2)) - magnitude1 = sum(a * a for a in vec1) ** 0.5 - magnitude2 = sum(b * b for b in vec2) ** 0.5 - - if magnitude1 == 0 or magnitude2 == 0: - return 0.0 - - return dot_product / (magnitude1 * magnitude2) - - -def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -> float: - """ - Calculate recency weight using logarithmic decay. - - This provides much better differentiation over long time periods compared to - exponential decay. Uses a log-based decay where the half-life parameter controls - when memories reach 50% weight. - - Examples: - - Today (0 days): 1.0 - - 1 year (365 days): ~0.5 (with default half_life=365) - - 2 years (730 days): ~0.33 - - 5 years (1825 days): ~0.17 - - 10 years (3650 days): ~0.09 - - This ensures that 2-year-old and 5-year-old memories have meaningfully - different weights, unlike exponential decay which makes them both ~0. - - Args: - days_since: Number of days since the memory was created - half_life_days: Number of days for weight to reach 0.5 (default: 1 year) - - Returns: - Weight between 0 and 1 - """ - import math - - # Logarithmic decay: 1 / (1 + log(1 + days_since/half_life)) - # This decays much slower than exponential, giving better long-term differentiation - normalized_age = days_since / half_life_days - return 1.0 / (1.0 + math.log1p(normalized_age)) - - -def calculate_frequency_weight(access_count: int, max_boost: float = 2.0) -> float: - """ - Calculate frequency weight based on access count. - - Frequently accessed memories are weighted higher. - Uses logarithmic scaling to avoid over-weighting. - - Args: - access_count: Number of times the memory was accessed - max_boost: Maximum multiplier for frequently accessed memories - - Returns: - Weight between 1.0 and max_boost - """ - import math - - if access_count <= 0: - return 1.0 - - # Logarithmic scaling: log(access_count + 1) / log(10) - # This gives: 0 accesses = 1.0, 9 accesses ~= 1.5, 99 accesses ~= 2.0 - normalized = math.log(access_count + 1) / math.log(10) - return 1.0 + min(normalized, max_boost - 1.0) - - -def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime) -> datetime: - """ - Calculate a single temporal anchor point from a temporal range. - - Used for spreading activation - we need a single representative date - to calculate temporal proximity between facts. This simplifies the - range-to-range distance problem. - - Strategy: Use midpoint of the range for balanced representation. - - Args: - occurred_start: Start of temporal range - occurred_end: End of temporal range - - Returns: - Single datetime representing the temporal anchor (midpoint) - - Examples: - - Point event (July 14): start=July 14, end=July 14 → anchor=July 14 - - Month range (February): start=Feb 1, end=Feb 28 → anchor=Feb 14 - - Year range (2023): start=Jan 1, end=Dec 31 → anchor=July 1 - """ - # Calculate midpoint - time_delta = occurred_end - occurred_start - midpoint = occurred_start + (time_delta / 2) - return midpoint - - -def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float: - """ - Calculate temporal proximity between two temporal anchors. - - Used for spreading activation to determine how "close" two facts are - in time. Uses logarithmic decay so that temporal similarity doesn't - drop off too quickly. - - Args: - anchor_a: Temporal anchor of first fact - anchor_b: Temporal anchor of second fact - half_life_days: Number of days for proximity to reach 0.5 - (default: 30 days = 1 month) - - Returns: - Proximity score in [0, 1] where: - - 1.0 = same day - - 0.5 = ~half_life days apart - - 0.0 = very distant in time - - Examples: - - Same day: 1.0 - - 1 week apart (half_life=30): ~0.7 - - 1 month apart (half_life=30): ~0.5 - - 1 year apart (half_life=30): ~0.2 - """ - import math - - days_apart = abs((anchor_a - anchor_b).days) - - if days_apart == 0: - return 1.0 - - # Logarithmic decay: 1 / (1 + log(1 + days_apart/half_life)) - # Similar to calculate_recency_weight but for proximity between events - normalized_distance = days_apart / half_life_days - proximity = 1.0 / (1.0 + math.log1p(normalized_distance)) - - return proximity diff --git a/hindsight-api/hindsight_api/extensions/__init__.py b/hindsight-api/hindsight_api/extensions/__init__.py deleted file mode 100644 index fd84a272..00000000 --- a/hindsight-api/hindsight_api/extensions/__init__.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -Hindsight Extensions System. - -Extensions allow customizing and extending Hindsight behavior without modifying core code. -Extensions are loaded via environment variables pointing to implementation classes. - -Example: - HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator - HINDSIGHT_API_OPERATION_VALIDATOR_MAX_RETRIES=3 - - HINDSIGHT_API_HTTP_EXTENSION=mypackage.http:MyHttpExtension - HINDSIGHT_API_HTTP_SOME_CONFIG=value - -Extensions receive an ExtensionContext that provides a controlled API for interacting -with the system (e.g., running migrations for tenant schemas). -""" - -from hindsight_api.extensions.base import Extension -from hindsight_api.extensions.builtin import ApiKeyTenantExtension -from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionContext -from hindsight_api.extensions.http import HttpExtension -from hindsight_api.extensions.loader import load_extension -from hindsight_api.extensions.operation_validator import ( - OperationValidationError, - OperationValidatorExtension, - RecallContext, - RecallResult, - ReflectContext, - ReflectResultContext, - RetainContext, - RetainResult, - ValidationResult, -) -from hindsight_api.extensions.tenant import ( - AuthenticationError, - TenantContext, - TenantExtension, -) -from hindsight_api.models import RequestContext - -__all__ = [ - # Base - "Extension", - "load_extension", - # Context - "ExtensionContext", - "DefaultExtensionContext", - # HTTP Extension - "HttpExtension", - # Operation Validator - "OperationValidationError", - "OperationValidatorExtension", - "RecallContext", - "RecallResult", - "ReflectContext", - "ReflectResultContext", - "RetainContext", - "RetainResult", - "ValidationResult", - # Tenant/Auth - "ApiKeyTenantExtension", - "AuthenticationError", - "RequestContext", - "TenantContext", - "TenantExtension", -] diff --git a/hindsight-api/hindsight_api/extensions/base.py b/hindsight-api/hindsight_api/extensions/base.py deleted file mode 100644 index 76e0df6a..00000000 --- a/hindsight-api/hindsight_api/extensions/base.py +++ /dev/null @@ -1,81 +0,0 @@ -"""Base Extension class for all Hindsight extensions.""" - -from abc import ABC -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from hindsight_api.extensions.context import ExtensionContext - - -class Extension(ABC): - """ - Base class for all Hindsight extensions. - - Extensions are loaded via environment variables and receive configuration - from prefixed environment variables. - - Example: - HINDSIGHT_API_MY_EXTENSION=mypackage.ext:MyExtension - HINDSIGHT_API_MY_SOME_CONFIG=value - - The extension receives: {"some_config": "value"} - - Extensions also receive an ExtensionContext that provides a controlled API - for interacting with the system (e.g., running migrations for tenant schemas). - """ - - def __init__(self, config: dict[str, str]): - """ - Initialize the extension with configuration. - - Args: - config: Dictionary of configuration values from environment variables. - Keys are lowercased with the prefix stripped. - """ - self.config = config - self._context: "ExtensionContext | None" = None - - def set_context(self, context: "ExtensionContext") -> None: - """ - Set the extension context. - - Called by the extension loader after instantiation. - Extensions should not call this directly. - - Args: - context: The ExtensionContext providing system APIs. - """ - self._context = context - - @property - def context(self) -> "ExtensionContext": - """ - Get the extension context. - - Returns: - The ExtensionContext providing system APIs. - - Raises: - RuntimeError: If context has not been set yet. - """ - if self._context is None: - raise RuntimeError( - "Extension context not set. Context is available after the extension is loaded by the system." - ) - return self._context - - async def on_startup(self) -> None: - """ - Called when the application starts. - - Override to perform initialization tasks like connecting to external services. - """ - pass - - async def on_shutdown(self) -> None: - """ - Called when the application shuts down. - - Override to perform cleanup tasks like closing connections. - """ - pass diff --git a/hindsight-api/hindsight_api/extensions/builtin/__init__.py b/hindsight-api/hindsight_api/extensions/builtin/__init__.py deleted file mode 100644 index 703f531c..00000000 --- a/hindsight-api/hindsight_api/extensions/builtin/__init__.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -Built-in extension implementations. - -These are ready-to-use implementations of the extension interfaces. -They can be used directly or serve as examples for custom implementations. - -Available built-in extensions: - - ApiKeyTenantExtension: Simple API key validation with public schema - -Example usage: - HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension -""" - -from hindsight_api.extensions.builtin.tenant import ApiKeyTenantExtension - -__all__ = [ - "ApiKeyTenantExtension", -] diff --git a/hindsight-api/hindsight_api/extensions/builtin/tenant.py b/hindsight-api/hindsight_api/extensions/builtin/tenant.py deleted file mode 100644 index a5a1b80a..00000000 --- a/hindsight-api/hindsight_api/extensions/builtin/tenant.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Built-in tenant extension implementations.""" - -from hindsight_api.extensions.tenant import AuthenticationError, TenantContext, TenantExtension -from hindsight_api.models import RequestContext - - -class ApiKeyTenantExtension(TenantExtension): - """ - Built-in tenant extension that validates API key against an environment variable. - - This is a simple implementation that: - 1. Validates the API key matches HINDSIGHT_API_TENANT_API_KEY - 2. Returns 'public' as the schema for all authenticated requests - - Configuration: - HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension - HINDSIGHT_API_TENANT_API_KEY=your-secret-key - - For multi-tenant setups with separate schemas per tenant, implement a custom - TenantExtension that looks up the schema based on the API key or token claims. - """ - - def __init__(self, config: dict[str, str]): - super().__init__(config) - self.expected_api_key = config.get("api_key") - if not self.expected_api_key: - raise ValueError("HINDSIGHT_API_TENANT_API_KEY is required when using ApiKeyTenantExtension") - - async def authenticate(self, context: RequestContext) -> TenantContext: - """Validate API key and return public schema context.""" - if context.api_key != self.expected_api_key: - raise AuthenticationError("Invalid API key") - return TenantContext(schema_name="public") diff --git a/hindsight-api/hindsight_api/extensions/context.py b/hindsight-api/hindsight_api/extensions/context.py deleted file mode 100644 index 03e5726a..00000000 --- a/hindsight-api/hindsight_api/extensions/context.py +++ /dev/null @@ -1,110 +0,0 @@ -"""Extension context providing a controlled API for extensions to interact with the system.""" - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from hindsight_api.engine.interface import MemoryEngineInterface - - -class ExtensionContext(ABC): - """ - Abstract context providing a controlled API for extensions. - - Extensions receive this context instead of direct access to internal - components like MemoryEngine or database connections. This provides: - - A stable API that won't break when internals change - - Security by limiting what extensions can access - - Clear documentation of what extensions can do - - Built-in implementation: - hindsight_api.extensions.builtin.context.DefaultExtensionContext - - Example usage in an extension: - class MyTenantExtension(TenantExtension): - async def on_startup(self) -> None: - # Run migrations for a new tenant schema - await self.context.run_migration("tenant_acme") - - class MyHttpExtension(HttpExtension): - def get_router(self, memory): - # Use memory engine for custom endpoints - engine = self.context.get_memory_engine() - ... - """ - - @abstractmethod - async def run_migration(self, schema: str) -> None: - """ - Run database migrations for a specific schema. - - This creates the schema if it doesn't exist and runs all pending - migrations. Uses advisory locks to coordinate between distributed workers. - - Args: - schema: PostgreSQL schema name (e.g., "tenant_acme"). - The schema will be created if it doesn't exist. - - Raises: - RuntimeError: If migrations fail to complete. - - Example: - # Provision a new tenant schema - await context.run_migration("tenant_acme") - """ - ... - - @abstractmethod - def get_memory_engine(self) -> "MemoryEngineInterface": - """ - Get the memory engine interface. - - Returns the MemoryEngineInterface for performing memory operations - like retain, recall, reflect, and entity/document management. - - Returns: - MemoryEngineInterface instance. - - Example: - engine = context.get_memory_engine() - result = await engine.recall_async(bank_id, query) - """ - ... - - -class DefaultExtensionContext(ExtensionContext): - """ - Default implementation of ExtensionContext. - - Uses the system's database URL and migration infrastructure. - """ - - def __init__( - self, - database_url: str, - memory_engine: "MemoryEngineInterface | None" = None, - ): - """ - Initialize the context. - - Args: - database_url: SQLAlchemy database URL for migrations. - memory_engine: Optional MemoryEngine instance for memory operations. - """ - self._database_url = database_url - self._memory_engine = memory_engine - - async def run_migration(self, schema: str) -> None: - """Run migrations for a specific schema.""" - from hindsight_api.migrations import run_migrations - - run_migrations(self._database_url, schema=schema) - - def get_memory_engine(self) -> "MemoryEngineInterface": - """Get the memory engine interface.""" - if self._memory_engine is None: - raise RuntimeError( - "Memory engine not configured in ExtensionContext. " - "Ensure the context was created with a memory_engine parameter." - ) - return self._memory_engine diff --git a/hindsight-api/hindsight_api/extensions/http.py b/hindsight-api/hindsight_api/extensions/http.py deleted file mode 100644 index a64816f7..00000000 --- a/hindsight-api/hindsight_api/extensions/http.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -HTTP Extension for adding custom endpoints to the Hindsight API. - -This extension allows adding custom HTTP endpoints under the /ext/ path prefix. -The extension provides a FastAPI router that is mounted on the main application. -""" - -from abc import ABC, abstractmethod -from typing import TYPE_CHECKING - -from fastapi import APIRouter - -from hindsight_api.extensions.base import Extension - -if TYPE_CHECKING: - from hindsight_api import MemoryEngine - - -class HttpExtension(Extension, ABC): - """ - Base class for HTTP extensions that add custom API endpoints. - - HTTP extensions provide a FastAPI router that gets mounted under /ext/. - The extension has full control over the routes, request/response models, and handlers. - - Example: - ```python - from fastapi import APIRouter - from hindsight_api.extensions import HttpExtension - - class MyHttpExtension(HttpExtension): - def get_router(self, memory: MemoryEngine) -> APIRouter: - router = APIRouter() - - @router.get("/hello") - async def hello(): - return {"message": "Hello from extension!"} - - @router.post("/custom/{bank_id}/action") - async def custom_action(bank_id: str): - # Access memory engine for database operations - pool = await memory._get_pool() - # ... custom logic - return {"status": "ok"} - - return router - ``` - - The routes will be available at: - - GET /ext/hello - - POST /ext/custom/{bank_id}/action - - Configuration via environment variables: - HINDSIGHT_API_HTTP_EXTENSION=mypackage.ext:MyHttpExtension - HINDSIGHT_API_HTTP_SOME_CONFIG=value - - The extension receives config: {"some_config": "value"} - """ - - @abstractmethod - def get_router(self, memory: "MemoryEngine") -> APIRouter: - """ - Return a FastAPI router with custom endpoints. - - The router will be mounted at /ext/ on the main application. - All routes defined in the router will be prefixed with /ext/. - - Args: - memory: The MemoryEngine instance for database access and core operations. - Use this to access the connection pool, run queries, or call - memory operations like retain, recall, etc. - - Returns: - A FastAPI APIRouter with the custom endpoints defined. - - Example: - ```python - def get_router(self, memory: MemoryEngine) -> APIRouter: - router = APIRouter(tags=["My Extension"]) - - @router.get("/status") - async def status(): - health = await memory.health_check() - return {"extension": "healthy", "memory": health} - - return router - ``` - """ - pass diff --git a/hindsight-api/hindsight_api/extensions/loader.py b/hindsight-api/hindsight_api/extensions/loader.py deleted file mode 100644 index 67595314..00000000 --- a/hindsight-api/hindsight_api/extensions/loader.py +++ /dev/null @@ -1,125 +0,0 @@ -"""Extension loader utilities.""" - -import importlib -import logging -import os -from typing import TYPE_CHECKING, TypeVar - -from hindsight_api.extensions.base import Extension - -if TYPE_CHECKING: - from hindsight_api.extensions.context import ExtensionContext - -logger = logging.getLogger(__name__) - -T = TypeVar("T", bound=Extension) - - -class ExtensionLoadError(Exception): - """Raised when an extension fails to load.""" - - pass - - -def load_extension( - prefix: str, - base_class: type[T], - env_prefix: str = "HINDSIGHT_API", - context: "ExtensionContext | None" = None, -) -> T | None: - """ - Load an extension from environment variable configuration. - - The extension class is specified via {env_prefix}_{prefix}_EXTENSION environment - variable in the format "module.path:ClassName". - - Configuration for the extension is collected from all environment variables - matching {env_prefix}_{prefix}_* (excluding the EXTENSION variable itself). - - Args: - prefix: The extension prefix (e.g., "OPERATION_VALIDATOR"). - base_class: The base class that the extension must inherit from. - env_prefix: The environment variable prefix (default: "HINDSIGHT_API"). - context: Optional ExtensionContext to provide system APIs to the extension. - - Returns: - An instance of the extension, or None if not configured. - - Raises: - ExtensionLoadError: If the extension fails to load or validate. - - Example: - HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator - HINDSIGHT_API_OPERATION_VALIDATOR_MAX_REQUESTS=100 - - ext = load_extension("OPERATION_VALIDATOR", OperationValidatorExtension) - # ext.config == {"max_requests": "100"} - """ - env_var = f"{env_prefix}_{prefix}_EXTENSION" - ext_path = os.getenv(env_var) - - if not ext_path: - logger.debug(f"No extension configured for {env_var}") - return None - - logger.info(f"Loading extension from {env_var}={ext_path}") - - # Parse "module.path:ClassName" - if ":" not in ext_path: - raise ExtensionLoadError(f"Invalid extension path '{ext_path}'. Expected format: 'module.path:ClassName'") - - module_path, class_name = ext_path.rsplit(":", 1) - - # Import the module - try: - module = importlib.import_module(module_path) - except ImportError as e: - raise ExtensionLoadError(f"Failed to import extension module '{module_path}': {e}") from e - - # Get the class - try: - ext_class = getattr(module, class_name) - except AttributeError as e: - raise ExtensionLoadError(f"Extension class '{class_name}' not found in module '{module_path}'") from e - - # Validate inheritance - if not isinstance(ext_class, type) or not issubclass(ext_class, base_class): - raise ExtensionLoadError(f"Extension class '{ext_class.__name__}' must inherit from '{base_class.__name__}'") - - # Collect configuration from environment variables - config = _collect_config(env_prefix, prefix) - - logger.info(f"Loaded extension {ext_class.__name__} with config keys: {list(config.keys())}") - - # Instantiate the extension - try: - extension = ext_class(config) - except Exception as e: - raise ExtensionLoadError(f"Failed to instantiate extension '{ext_class.__name__}': {e}") from e - - # Set the context if provided - if context is not None: - extension.set_context(context) - logger.debug(f"Set context on extension {ext_class.__name__}") - - return extension - - -def _collect_config(env_prefix: str, prefix: str) -> dict[str, str]: - """ - Collect configuration from environment variables. - - Collects all variables matching {env_prefix}_{prefix}_* except for - {env_prefix}_{prefix}_EXTENSION, strips the prefix, and lowercases keys. - """ - config = {} - full_prefix = f"{env_prefix}_{prefix}_" - extension_var = f"{full_prefix}EXTENSION" - - for key, value in os.environ.items(): - if key.startswith(full_prefix) and key != extension_var: - # Strip prefix and lowercase the key - config_key = key[len(full_prefix) :].lower() - config[config_key] = value - - return config diff --git a/hindsight-api/hindsight_api/extensions/operation_validator.py b/hindsight-api/hindsight_api/extensions/operation_validator.py deleted file mode 100644 index cd862ce5..00000000 --- a/hindsight-api/hindsight_api/extensions/operation_validator.py +++ /dev/null @@ -1,325 +0,0 @@ -"""Operation Validator Extension for validating retain/recall/reflect operations.""" - -from abc import ABC, abstractmethod -from dataclasses import dataclass, field -from datetime import datetime -from typing import TYPE_CHECKING, Any - -from hindsight_api.extensions.base import Extension - -if TYPE_CHECKING: - from hindsight_api.engine.memory_engine import Budget - from hindsight_api.engine.response_models import RecallResult as RecallResultModel - from hindsight_api.engine.response_models import ReflectResult - from hindsight_api.models import RequestContext - - -class OperationValidationError(Exception): - """Raised when an operation fails validation.""" - - def __init__(self, reason: str): - self.reason = reason - super().__init__(f"Operation validation failed: {reason}") - - -@dataclass -class ValidationResult: - """Result of an operation validation.""" - - allowed: bool - reason: str | None = None - - @classmethod - def accept(cls) -> "ValidationResult": - """Create an accepted validation result.""" - return cls(allowed=True) - - @classmethod - def reject(cls, reason: str) -> "ValidationResult": - """Create a rejected validation result with a reason.""" - return cls(allowed=False, reason=reason) - - -# ============================================================================= -# Pre-operation Contexts (all user-provided parameters) -# ============================================================================= - - -@dataclass -class RetainContext: - """Context for a retain operation validation (pre-operation). - - Contains ALL user-provided parameters for the retain operation. - """ - - bank_id: str - contents: list[dict] # List of {content, context, event_date, document_id} - request_context: "RequestContext" - document_id: str | None = None - fact_type_override: str | None = None - confidence_score: float | None = None - - -@dataclass -class RecallContext: - """Context for a recall operation validation (pre-operation). - - Contains ALL user-provided parameters for the recall operation. - """ - - bank_id: str - query: str - request_context: "RequestContext" - budget: "Budget | None" = None - max_tokens: int = 4096 - enable_trace: bool = False - fact_types: list[str] = field(default_factory=list) - question_date: datetime | None = None - include_entities: bool = False - max_entity_tokens: int = 500 - include_chunks: bool = False - max_chunk_tokens: int = 8192 - - -@dataclass -class ReflectContext: - """Context for a reflect operation validation (pre-operation). - - Contains ALL user-provided parameters for the reflect operation. - """ - - bank_id: str - query: str - request_context: "RequestContext" - budget: "Budget | None" = None - context: str | None = None - - -# ============================================================================= -# Post-operation Contexts (includes results) -# ============================================================================= - - -@dataclass -class RetainResult: - """Result context for post-retain hook. - - Contains the operation parameters and the result. - """ - - bank_id: str - contents: list[dict] - request_context: "RequestContext" - document_id: str | None - fact_type_override: str | None - confidence_score: float | None - # Result - unit_ids: list[list[str]] # List of unit IDs per content item - success: bool = True - error: str | None = None - - -@dataclass -class RecallResult: - """Result context for post-recall hook. - - Contains the operation parameters and the result. - """ - - bank_id: str - query: str - request_context: "RequestContext" - budget: "Budget | None" - max_tokens: int - enable_trace: bool - fact_types: list[str] - question_date: datetime | None - include_entities: bool - max_entity_tokens: int - include_chunks: bool - max_chunk_tokens: int - # Result - result: "RecallResultModel | None" = None - success: bool = True - error: str | None = None - - -@dataclass -class ReflectResultContext: - """Result context for post-reflect hook. - - Contains the operation parameters and the result. - """ - - bank_id: str - query: str - request_context: "RequestContext" - budget: "Budget | None" - context: str | None - # Result - result: "ReflectResult | None" = None - success: bool = True - error: str | None = None - - -class OperationValidatorExtension(Extension, ABC): - """ - Validates and hooks into retain/recall/reflect operations. - - This extension allows implementing custom logic such as: - - Rate limiting (pre-operation) - - Quota enforcement (pre-operation) - - Permission checks (pre-operation) - - Content filtering (pre-operation) - - Usage tracking (post-operation) - - Audit logging (post-operation) - - Metrics collection (post-operation) - - Enable via environment variable: - HINDSIGHT_API_OPERATION_VALIDATOR_EXTENSION=mypackage.validators:MyValidator - - Configuration is passed from prefixed environment variables: - HINDSIGHT_API_OPERATION_VALIDATOR_MAX_REQUESTS=100 - -> config = {"max_requests": "100"} - - Hook execution order: - 1. validate_retain/validate_recall/validate_reflect (pre-operation) - 2. [operation executes] - 3. on_retain_complete/on_recall_complete/on_reflect_complete (post-operation) - """ - - # ========================================================================= - # Pre-operation validation hooks (abstract - must be implemented) - # ========================================================================= - - @abstractmethod - async def validate_retain(self, ctx: RetainContext) -> ValidationResult: - """ - Validate a retain operation before execution. - - Called before the retain operation is processed. Return ValidationResult.reject() - to prevent the operation from executing. - - Args: - ctx: Context containing all user-provided parameters: - - bank_id: Bank identifier - - contents: List of content dicts - - request_context: Request context with auth info - - document_id: Optional document ID - - fact_type_override: Optional fact type override - - confidence_score: Optional confidence score - - Returns: - ValidationResult indicating whether the operation is allowed. - """ - ... - - @abstractmethod - async def validate_recall(self, ctx: RecallContext) -> ValidationResult: - """ - Validate a recall operation before execution. - - Called before the recall operation is processed. Return ValidationResult.reject() - to prevent the operation from executing. - - Args: - ctx: Context containing all user-provided parameters: - - bank_id: Bank identifier - - query: Search query - - request_context: Request context with auth info - - budget: Budget level - - max_tokens: Maximum tokens to return - - enable_trace: Whether to include trace info - - fact_types: List of fact types to search - - question_date: Optional date context for query - - include_entities: Whether to include entity data - - max_entity_tokens: Max tokens for entities - - include_chunks: Whether to include chunks - - max_chunk_tokens: Max tokens for chunks - - Returns: - ValidationResult indicating whether the operation is allowed. - """ - ... - - @abstractmethod - async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult: - """ - Validate a reflect operation before execution. - - Called before the reflect operation is processed. Return ValidationResult.reject() - to prevent the operation from executing. - - Args: - ctx: Context containing all user-provided parameters: - - bank_id: Bank identifier - - query: Question to answer - - request_context: Request context with auth info - - budget: Budget level - - context: Optional additional context - - Returns: - ValidationResult indicating whether the operation is allowed. - """ - ... - - # ========================================================================= - # Post-operation hooks (optional - override to implement) - # ========================================================================= - - async def on_retain_complete(self, result: RetainResult) -> None: - """ - Called after a retain operation completes (success or failure). - - Override this method to implement post-operation logic such as: - - Usage tracking - - Audit logging - - Metrics collection - - Notifications - - Args: - result: Result context containing: - - All original operation parameters - - unit_ids: List of created unit IDs (if success) - - success: Whether the operation succeeded - - error: Error message (if failed) - """ - pass - - async def on_recall_complete(self, result: RecallResult) -> None: - """ - Called after a recall operation completes (success or failure). - - Override this method to implement post-operation logic such as: - - Usage tracking - - Audit logging - - Metrics collection - - Query analytics - - Args: - result: Result context containing: - - All original operation parameters - - result: RecallResultModel (if success) - - success: Whether the operation succeeded - - error: Error message (if failed) - """ - pass - - async def on_reflect_complete(self, result: ReflectResultContext) -> None: - """ - Called after a reflect operation completes (success or failure). - - Override this method to implement post-operation logic such as: - - Usage tracking - - Audit logging - - Metrics collection - - Response analytics - - Args: - result: Result context containing: - - All original operation parameters - - result: ReflectResult (if success) - - success: Whether the operation succeeded - - error: Error message (if failed) - """ - pass diff --git a/hindsight-api/hindsight_api/extensions/tenant.py b/hindsight-api/hindsight_api/extensions/tenant.py deleted file mode 100644 index 6b178a32..00000000 --- a/hindsight-api/hindsight_api/extensions/tenant.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Tenant Extension for multi-tenancy and API key authentication.""" - -from abc import ABC, abstractmethod -from dataclasses import dataclass - -from hindsight_api.extensions.base import Extension -from hindsight_api.models import RequestContext - - -class AuthenticationError(Exception): - """Raised when authentication fails.""" - - def __init__(self, reason: str): - self.reason = reason - super().__init__(f"Authentication failed: {reason}") - - -@dataclass -class TenantContext: - """ - Tenant context returned by authentication. - - Contains the PostgreSQL schema name for tenant isolation. - All database queries will use fully-qualified table names - with this schema (e.g., schema_name.memory_units). - """ - - schema_name: str - - -class TenantExtension(Extension, ABC): - """ - Extension for multi-tenancy and API key authentication. - - This extension validates incoming requests and returns the tenant context - including the PostgreSQL schema to use for database operations. - - Built-in implementation: - hindsight_api.extensions.builtin.tenant.ApiKeyTenantExtension - - Enable via environment variable: - HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension - HINDSIGHT_API_TENANT_API_KEY=your-secret-key - - The returned schema_name is used for fully-qualified table names in queries, - enabling tenant isolation at the database level. - """ - - @abstractmethod - async def authenticate(self, context: RequestContext) -> TenantContext: - """ - Authenticate the action context and return tenant context. - - Args: - context: The action context containing API key and other auth data. - - Returns: - TenantContext with the schema_name for database operations. - - Raises: - AuthenticationError: If authentication fails. - """ - ... diff --git a/hindsight-api/hindsight_api/main.py b/hindsight-api/hindsight_api/main.py deleted file mode 100644 index b2bccee8..00000000 --- a/hindsight-api/hindsight_api/main.py +++ /dev/null @@ -1,273 +0,0 @@ -""" -Command-line interface for Hindsight API. - -Run the server with: - hindsight-api - -Run as background daemon: - hindsight-api --daemon - -Stop with Ctrl+C. -""" - -import argparse -import asyncio -import atexit -import dataclasses -import os -import signal -import sys -import warnings - -import uvicorn - -from . import MemoryEngine -from .api import create_app -from .banner import print_banner -from .config import HindsightConfig, get_config -from .daemon import ( - DEFAULT_DAEMON_PORT, - DEFAULT_IDLE_TIMEOUT, - DaemonLock, - IdleTimeoutMiddleware, - daemonize, -) - -# Filter deprecation warnings from third-party libraries -warnings.filterwarnings("ignore", message="websockets.legacy is deprecated") -warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProtocol is deprecated") - -# Disable tokenizers parallelism to avoid warnings -os.environ["TOKENIZERS_PARALLELISM"] = "false" - -# Global reference for cleanup -_memory: MemoryEngine | None = None - - -def _cleanup(): - """Synchronous cleanup function to stop resources on exit.""" - global _memory - if _memory is not None and _memory._pg0 is not None: - try: - loop = asyncio.new_event_loop() - loop.run_until_complete(_memory._pg0.stop()) - loop.close() - print("\npg0 stopped.") - except Exception as e: - print(f"\nError stopping pg0: {e}") - - -def _signal_handler(signum, frame): - """Handle SIGINT/SIGTERM to ensure cleanup.""" - print(f"\nReceived signal {signum}, shutting down...") - _cleanup() - sys.exit(0) - - -def main(): - """Main entry point for the CLI.""" - global _memory - - # Load configuration from environment (for CLI args defaults) - config = get_config() - - parser = argparse.ArgumentParser( - prog="hindsight-api", - description="Hindsight API Server", - ) - - # Server options - parser.add_argument( - "--host", default=config.host, help=f"Host to bind to (default: {config.host}, env: HINDSIGHT_API_HOST)" - ) - parser.add_argument( - "--port", - type=int, - default=config.port, - help=f"Port to bind to (default: {config.port}, env: HINDSIGHT_API_PORT)", - ) - parser.add_argument( - "--log-level", - default=config.log_level, - choices=["critical", "error", "warning", "info", "debug", "trace"], - help=f"Log level (default: {config.log_level}, env: HINDSIGHT_API_LOG_LEVEL)", - ) - - # Development options - parser.add_argument("--reload", action="store_true", help="Enable auto-reload on code changes (development only)") - parser.add_argument("--workers", type=int, default=1, help="Number of worker processes (default: 1)") - - # Access log options - parser.add_argument("--access-log", action="store_true", help="Enable access log") - parser.add_argument("--no-access-log", dest="access_log", action="store_false", help="Disable access log (default)") - parser.set_defaults(access_log=False) - - # Proxy options - parser.add_argument( - "--proxy-headers", action="store_true", help="Enable X-Forwarded-Proto, X-Forwarded-For headers" - ) - parser.add_argument( - "--forwarded-allow-ips", default=None, help="Comma separated list of IPs to trust with proxy headers" - ) - - # SSL options - parser.add_argument("--ssl-keyfile", default=None, help="SSL key file") - parser.add_argument("--ssl-certfile", default=None, help="SSL certificate file") - - # Daemon mode options - parser.add_argument( - "--daemon", - action="store_true", - help=f"Run as background daemon (uses port {DEFAULT_DAEMON_PORT}, auto-exits after idle)", - ) - parser.add_argument( - "--idle-timeout", - type=int, - default=DEFAULT_IDLE_TIMEOUT, - help=f"Idle timeout in seconds before auto-exit in daemon mode (default: {DEFAULT_IDLE_TIMEOUT})", - ) - - args = parser.parse_args() - - # Daemon mode handling - if args.daemon: - # Use fixed daemon port - args.port = DEFAULT_DAEMON_PORT - args.host = "127.0.0.1" # Only bind to localhost for security - - # Check if another daemon is already running - daemon_lock = DaemonLock() - if not daemon_lock.acquire(): - print(f"Daemon already running (PID: {daemon_lock.get_pid()})", file=sys.stderr) - sys.exit(1) - - # Fork into background - daemonize() - - # Re-acquire lock in child process - daemon_lock = DaemonLock() - if not daemon_lock.acquire(): - sys.exit(1) - - # Register cleanup to release lock - def release_lock(): - daemon_lock.release() - - atexit.register(release_lock) - - # Print banner (not in daemon mode) - if not args.daemon: - print() - print_banner() - - # Configure Python logging based on log level - # Update config with CLI override if provided - if args.log_level != config.log_level: - config = HindsightConfig( - database_url=config.database_url, - llm_provider=config.llm_provider, - llm_api_key=config.llm_api_key, - llm_model=config.llm_model, - llm_base_url=config.llm_base_url, - embeddings_provider=config.embeddings_provider, - embeddings_local_model=config.embeddings_local_model, - embeddings_tei_url=config.embeddings_tei_url, - reranker_provider=config.reranker_provider, - reranker_local_model=config.reranker_local_model, - reranker_tei_url=config.reranker_tei_url, - host=args.host, - port=args.port, - log_level=args.log_level, - mcp_enabled=config.mcp_enabled, - graph_retriever=config.graph_retriever, - skip_llm_verification=config.skip_llm_verification, - lazy_reranker=config.lazy_reranker, - ) - config.configure_logging() - if not args.daemon: - config.log_config() - - # Register cleanup handlers - atexit.register(_cleanup) - signal.signal(signal.SIGINT, _signal_handler) - signal.signal(signal.SIGTERM, _signal_handler) - - # Create MemoryEngine (reads configuration from environment) - _memory = MemoryEngine() - - # Create FastAPI app - app = create_app( - memory=_memory, - http_api_enabled=True, - mcp_api_enabled=config.mcp_enabled, - mcp_mount_path="/mcp", - initialize_memory=True, - ) - - # Wrap with idle timeout middleware in daemon mode - idle_middleware = None - if args.daemon: - idle_middleware = IdleTimeoutMiddleware(app, idle_timeout=args.idle_timeout) - app = idle_middleware - - # Prepare uvicorn config - uvicorn_config = { - "app": app, - "host": args.host, - "port": args.port, - "log_level": args.log_level, - "access_log": args.access_log, - "proxy_headers": args.proxy_headers, - "ws": "wsproto", # Use wsproto instead of websockets to avoid deprecation warnings - } - - # Add optional parameters if provided - if args.reload: - uvicorn_config["reload"] = True - if args.workers > 1: - uvicorn_config["workers"] = args.workers - if args.forwarded_allow_ips: - uvicorn_config["forwarded_allow_ips"] = args.forwarded_allow_ips - if args.ssl_keyfile: - uvicorn_config["ssl_keyfile"] = args.ssl_keyfile - if args.ssl_certfile: - uvicorn_config["ssl_certfile"] = args.ssl_certfile - - # Print startup info (not in daemon mode) - if not args.daemon: - from .banner import print_startup_info - - print_startup_info( - host=args.host, - port=args.port, - database_url=config.database_url, - llm_provider=config.llm_provider, - llm_model=config.llm_model, - embeddings_provider=config.embeddings_provider, - reranker_provider=config.reranker_provider, - mcp_enabled=config.mcp_enabled, - ) - - # Start idle checker in daemon mode - if idle_middleware is not None: - # Start the idle checker in a background thread with its own event loop - import threading - - def run_idle_checker(): - import time - - time.sleep(2) # Wait for uvicorn to start - try: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - loop.run_until_complete(idle_middleware._check_idle()) - except Exception: - pass - - threading.Thread(target=run_idle_checker, daemon=True).start() - - uvicorn.run(**uvicorn_config) # type: ignore[invalid-argument-type] - dict kwargs - - -if __name__ == "__main__": - main() diff --git a/hindsight-api/hindsight_api/mcp_local.py b/hindsight-api/hindsight_api/mcp_local.py deleted file mode 100644 index 7205a5e5..00000000 --- a/hindsight-api/hindsight_api/mcp_local.py +++ /dev/null @@ -1,199 +0,0 @@ -""" -Local MCP server for use with Claude Code (stdio transport). - -This runs a fully local Hindsight instance with embedded PostgreSQL (pg0). -No external database or server required. - -Run with: - hindsight-local-mcp - -Or with uvx: - uvx hindsight-api@latest hindsight-local-mcp - -Configure in Claude Code's MCP settings: - { - "mcpServers": { - "hindsight": { - "command": "uvx", - "args": ["hindsight-api@latest", "hindsight-local-mcp"], - "env": { - "HINDSIGHT_API_LLM_API_KEY": "your-openai-key" - } - } - } - } - -Environment variables: - HINDSIGHT_API_LLM_API_KEY: Required. API key for LLM provider. - HINDSIGHT_API_LLM_PROVIDER: Optional. LLM provider (default: "openai"). - HINDSIGHT_API_LLM_MODEL: Optional. LLM model (default: "gpt-4o-mini"). - HINDSIGHT_API_MCP_LOCAL_BANK_ID: Optional. Memory bank ID (default: "mcp"). - HINDSIGHT_API_LOG_LEVEL: Optional. Log level (default: "warning"). - HINDSIGHT_API_MCP_INSTRUCTIONS: Optional. Additional instructions appended to both retain and recall tools. - -Example custom instructions (these are ADDED to the default behavior): - To also store assistant actions: - HINDSIGHT_API_MCP_INSTRUCTIONS="Also store every action you take, including tool calls, code written, and decisions made." - - To also store conversation summaries: - HINDSIGHT_API_MCP_INSTRUCTIONS="Also store summaries of important conversations and their outcomes." -""" - -import logging -import os -import sys - -from mcp.server.fastmcp import FastMCP -from mcp.types import Icon - -from hindsight_api.config import ( - DEFAULT_MCP_LOCAL_BANK_ID, - DEFAULT_MCP_RECALL_DESCRIPTION, - DEFAULT_MCP_RETAIN_DESCRIPTION, - ENV_MCP_INSTRUCTIONS, - ENV_MCP_LOCAL_BANK_ID, -) - -# Configure logging - default to warning to avoid polluting stderr during MCP init -# MCP clients interpret stderr output as errors, so we suppress INFO logs by default -_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "warning").lower() -_log_level_map = { - "critical": logging.CRITICAL, - "error": logging.ERROR, - "warning": logging.WARNING, - "info": logging.INFO, - "debug": logging.DEBUG, -} -logging.basicConfig( - level=_log_level_map.get(_log_level_str, logging.WARNING), - format="%(asctime)s - %(levelname)s - %(name)s - %(message)s", - stream=sys.stderr, # MCP uses stdout for protocol, logs go to stderr -) -logger = logging.getLogger(__name__) - - -def create_local_mcp_server(bank_id: str, memory=None) -> FastMCP: - """ - Create a stdio MCP server with retain/recall tools. - - Args: - bank_id: The memory bank ID to use for all operations. - memory: Optional MemoryEngine instance. If not provided, creates one with pg0. - - Returns: - Configured FastMCP server instance. - """ - # Import here to avoid slow startup if just checking --help - from hindsight_api import MemoryEngine - from hindsight_api.engine.memory_engine import Budget - from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES - from hindsight_api.models import RequestContext - - # Create memory engine with pg0 embedded database if not provided - if memory is None: - memory = MemoryEngine(db_url="pg0://hindsight-mcp") - - # Get custom instructions from environment variable (appended to both tools) - extra_instructions = os.environ.get(ENV_MCP_INSTRUCTIONS, "") - - retain_description = DEFAULT_MCP_RETAIN_DESCRIPTION - recall_description = DEFAULT_MCP_RECALL_DESCRIPTION - - if extra_instructions: - retain_description = f"{DEFAULT_MCP_RETAIN_DESCRIPTION}\n\nAdditional instructions: {extra_instructions}" - recall_description = f"{DEFAULT_MCP_RECALL_DESCRIPTION}\n\nAdditional instructions: {extra_instructions}" - - mcp = FastMCP("hindsight") - - @mcp.tool(description=retain_description) - async def retain(content: str, context: str = "general") -> dict: - """ - Args: - content: The fact/memory to store (be specific and include relevant details) - context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general' - """ - import asyncio - - async def _retain(): - try: - await memory.retain_batch_async( - bank_id=bank_id, - contents=[{"content": content, "context": context}], - request_context=RequestContext(), - ) - except Exception as e: - logger.error(f"Error storing memory: {e}", exc_info=True) - - # Fire and forget - don't block on memory storage - asyncio.create_task(_retain()) - return {"status": "accepted", "message": "Memory storage initiated"} - - @mcp.tool(description=recall_description) - async def recall(query: str, max_tokens: int = 4096, budget: str = "low") -> dict: - """ - Args: - query: Natural language search query (e.g., "user's food preferences", "what projects is user working on") - max_tokens: Maximum tokens to return in results (default: 4096) - budget: Search budget level - "low", "mid", or "high" (default: "low") - """ - try: - # Map string budget to enum - budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH} - budget_enum = budget_map.get(budget.lower(), Budget.LOW) - - search_result = await memory.recall_async( - bank_id=bank_id, - query=query, - fact_type=list(VALID_RECALL_FACT_TYPES), - budget=budget_enum, - max_tokens=max_tokens, - request_context=RequestContext(), - ) - - return search_result.model_dump() - except Exception as e: - logger.error(f"Error searching: {e}", exc_info=True) - return {"error": str(e), "results": []} - - return mcp - - -async def _initialize_and_run(bank_id: str): - """Initialize memory and run the MCP server.""" - from hindsight_api import MemoryEngine - - # Create and initialize memory engine with pg0 embedded database - # Note: We avoid printing to stderr during init as MCP clients show it as "errors" - memory = MemoryEngine(db_url="pg0://hindsight-mcp") - await memory.initialize() - - # Create and run the server - mcp = create_local_mcp_server(bank_id, memory=memory) - await mcp.run_stdio_async() - - -def main(): - """Main entry point for the stdio MCP server.""" - import asyncio - - from hindsight_api.config import ENV_LLM_API_KEY, get_config - - # Check for required environment variables - config = get_config() - if not config.llm_api_key: - print(f"Error: {ENV_LLM_API_KEY} environment variable is required", file=sys.stderr) - print("Set it in your MCP configuration or shell environment", file=sys.stderr) - sys.exit(1) - - # Get bank ID from environment, default to "mcp" - bank_id = os.environ.get(ENV_MCP_LOCAL_BANK_ID, DEFAULT_MCP_LOCAL_BANK_ID) - - # Note: We don't print to stderr as MCP clients display it as "error output" - # Use HINDSIGHT_API_LOG_LEVEL=debug for verbose startup logging - - # Run the async initialization and server - asyncio.run(_initialize_and_run(bank_id)) - - -if __name__ == "__main__": - main() diff --git a/hindsight-api/hindsight_api/metrics.py b/hindsight-api/hindsight_api/metrics.py deleted file mode 100644 index d8f16cf6..00000000 --- a/hindsight-api/hindsight_api/metrics.py +++ /dev/null @@ -1,241 +0,0 @@ -""" -OpenTelemetry metrics instrumentation for Hindsight API. - -This module provides metrics for: -- Operation latency (retain, recall, reflect) with percentiles -- Token usage (input/output) per operation -- Per-bank granularity via labels -""" - -import logging -import time -from contextlib import contextmanager - -from opentelemetry import metrics -from opentelemetry.exporter.prometheus import PrometheusMetricReader -from opentelemetry.sdk.metrics import MeterProvider -from opentelemetry.sdk.resources import Resource - -logger = logging.getLogger(__name__) - -# Global meter instance -_meter = None - - -def initialize_metrics(service_name: str = "hindsight-api", service_version: str = "1.0.0"): - """ - Initialize OpenTelemetry metrics with Prometheus exporter. - - This should be called once during application startup. - - Args: - service_name: Name of the service for resource attributes - service_version: Version of the service - - Returns: - PrometheusMetricReader instance (for accessing metrics endpoint) - """ - global _meter - - # Create resource with service information - resource = Resource.create( - { - "service.name": service_name, - "service.version": service_version, - } - ) - - # Create Prometheus metric reader - prometheus_reader = PrometheusMetricReader() - - # Create meter provider with Prometheus exporter - provider = MeterProvider(resource=resource, metric_readers=[prometheus_reader]) - - # Set the global meter provider - metrics.set_meter_provider(provider) - - # Get meter for this application - _meter = metrics.get_meter(__name__) - - return prometheus_reader - - -def get_meter(): - """Get the global meter instance.""" - if _meter is None: - raise RuntimeError("Metrics not initialized. Call initialize_metrics() first.") - return _meter - - -class MetricsCollectorBase: - """Base class for metrics collectors.""" - - @contextmanager - def record_operation(self, operation: str, bank_id: str, budget: str | None = None, max_tokens: int | None = None): - """Context manager to record operation duration and status.""" - raise NotImplementedError - - def record_tokens( - self, - operation: str, - bank_id: str, - input_tokens: int = 0, - output_tokens: int = 0, - budget: str | None = None, - max_tokens: int | None = None, - ): - """Record token usage for an operation.""" - raise NotImplementedError - - -class NoOpMetricsCollector(MetricsCollectorBase): - """No-op metrics collector that does nothing. Used when metrics are disabled.""" - - @contextmanager - def record_operation(self, operation: str, bank_id: str, budget: str | None = None, max_tokens: int | None = None): - """No-op context manager.""" - yield - - def record_tokens( - self, - operation: str, - bank_id: str, - input_tokens: int = 0, - output_tokens: int = 0, - budget: str | None = None, - max_tokens: int | None = None, - ): - """No-op token recording.""" - pass - - -class MetricsCollector(MetricsCollectorBase): - """ - Collector for Hindsight API metrics. - - Provides methods to record latency and token usage for operations. - """ - - def __init__(self): - self.meter = get_meter() - - # Operation latency histogram (in seconds) - # Records duration of retain, recall, reflect operations - self.operation_duration = self.meter.create_histogram( - name="hindsight.operation.duration", description="Duration of Hindsight operations in seconds", unit="s" - ) - - # Token usage counters - self.tokens_input = self.meter.create_counter( - name="hindsight.tokens.input", description="Number of input tokens consumed", unit="tokens" - ) - - self.tokens_output = self.meter.create_counter( - name="hindsight.tokens.output", description="Number of output tokens generated", unit="tokens" - ) - - # Operation counter (success/failure) - self.operation_total = self.meter.create_counter( - name="hindsight.operation.total", description="Total number of operations executed", unit="operations" - ) - - @contextmanager - def record_operation(self, operation: str, bank_id: str, budget: str | None = None, max_tokens: int | None = None): - """ - Context manager to record operation duration and status. - - Usage: - with metrics.record_operation("recall", bank_id="user123", budget="mid", max_tokens=4096): - # ... perform operation - pass - - Args: - operation: Operation name (retain, recall, reflect) - bank_id: Memory bank ID - budget: Optional budget level (low, mid, high) - max_tokens: Optional max tokens for the operation - """ - start_time = time.time() - attributes = { - "operation": operation, - "bank_id": bank_id, - } - if budget: - attributes["budget"] = budget - if max_tokens: - attributes["max_tokens"] = str(max_tokens) - - 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 operation count - self.operation_total.add(1, attributes) - - def record_tokens( - self, - operation: str, - bank_id: str, - input_tokens: int = 0, - output_tokens: int = 0, - budget: str | None = None, - max_tokens: int | None = None, - ): - """ - Record token usage for an operation. - - Args: - operation: Operation name (retain, recall, reflect) - bank_id: Memory bank ID - input_tokens: Number of input tokens - output_tokens: Number of output tokens - budget: Optional budget level - max_tokens: Optional max tokens for the operation - """ - attributes = { - "operation": operation, - "bank_id": bank_id, - } - if budget: - attributes["budget"] = budget - if max_tokens: - attributes["max_tokens"] = str(max_tokens) - - if input_tokens > 0: - self.tokens_input.add(input_tokens, attributes) - - if output_tokens > 0: - self.tokens_output.add(output_tokens, attributes) - - -# Global metrics collector instance (defaults to no-op) -_metrics_collector: MetricsCollectorBase = NoOpMetricsCollector() - - -def get_metrics_collector() -> MetricsCollectorBase: - """ - Get the global metrics collector instance. - - Returns a no-op collector if metrics are not initialized. - """ - return _metrics_collector - - -def create_metrics_collector() -> MetricsCollector: - """ - Create and set the global metrics collector. - - Should be called after initialize_metrics(). - """ - global _metrics_collector - _metrics_collector = MetricsCollector() - return _metrics_collector diff --git a/hindsight-api/hindsight_api/migrations.py b/hindsight-api/hindsight_api/migrations.py deleted file mode 100644 index 91022980..00000000 --- a/hindsight-api/hindsight_api/migrations.py +++ /dev/null @@ -1,231 +0,0 @@ -""" -Database migration management using Alembic. - -This module provides programmatic access to run database migrations -on application startup. It is designed to be safe for concurrent -execution using PostgreSQL advisory locks to coordinate between -distributed workers. - -Supports multi-tenant schema isolation: migrations can target a specific -PostgreSQL schema, allowing each tenant to have isolated tables. - -Important: All migrations must be backward-compatible to allow -safe rolling deployments. - -No alembic.ini required - all configuration is done programmatically. -""" - -import hashlib -import logging -import os -from pathlib import Path - -from alembic import command -from alembic.config import Config -from sqlalchemy import create_engine, text - -logger = logging.getLogger(__name__) - -# Advisory lock ID for migrations (arbitrary unique number) -MIGRATION_LOCK_ID = 123456789 - - -def _get_schema_lock_id(schema: str) -> int: - """ - Generate a unique advisory lock ID for a schema. - - Uses hash of schema name to create a deterministic lock ID. - """ - # Use hash to create a unique lock ID per schema - # Keep within PostgreSQL's bigint range - hash_bytes = hashlib.sha256(schema.encode()).digest()[:8] - return int.from_bytes(hash_bytes, byteorder="big") % (2**31) - - -def _run_migrations_internal(database_url: str, script_location: str, schema: str | None = None) -> None: - """ - Internal function to run migrations without locking. - - Args: - database_url: SQLAlchemy database URL - script_location: Path to alembic scripts - schema: Target schema (None for default/public) - """ - schema_name = schema or "public" - logger.info(f"Running database migrations to head for schema '{schema_name}'...") - logger.info(f"Database URL: {database_url}") - logger.info(f"Script location: {script_location}") - - # Create Alembic configuration programmatically (no alembic.ini needed) - alembic_cfg = Config() - - # Set the script location (where alembic versions are stored) - alembic_cfg.set_main_option("script_location", script_location) - - # Set the database URL - alembic_cfg.set_main_option("sqlalchemy.url", database_url) - - # Configure logging (optional, but helps with debugging) - # Uses Python's logging system instead of alembic.ini - alembic_cfg.set_main_option("prepend_sys_path", ".") - - # Set path_separator to avoid deprecation warning - alembic_cfg.set_main_option("path_separator", "os") - - # If targeting a specific schema, pass it to env.py via config - # env.py will handle setting search_path and version_table_schema - if schema: - alembic_cfg.set_main_option("target_schema", schema) - - # Run migrations - command.upgrade(alembic_cfg, "head") - - logger.info(f"Database migrations completed successfully for schema '{schema_name}'") - - -def run_migrations( - database_url: str, - script_location: str | None = None, - schema: str | None = None, -) -> None: - """ - Run database migrations to the latest version using programmatic Alembic configuration. - - This function is safe to call from multiple distributed workers simultaneously: - - Uses PostgreSQL advisory lock to ensure only one worker runs migrations at a time - - Other workers wait for the lock, then verify migrations are complete - - If schema is already up-to-date, this is a fast no-op - - Supports multi-tenant schema isolation: when a schema is specified, migrations - run in that schema instead of public. This allows tenant extensions to provision - new tenant schemas with their own isolated tables. - - Args: - database_url: SQLAlchemy database URL (e.g., "postgresql://user:pass@host/db") - script_location: Path to alembic migrations directory (e.g., "/path/to/alembic"). - If None, defaults to hindsight-api/alembic directory. - schema: Target PostgreSQL schema name. If None, uses default (public). - When specified, creates the schema if needed and runs migrations there. - - Raises: - RuntimeError: If migrations fail to complete - FileNotFoundError: If script_location doesn't exist - - Example: - # Using default location and public schema - run_migrations("postgresql://user:pass@host/db") - - # Run migrations for a specific tenant schema - run_migrations("postgresql://user:pass@host/db", schema="tenant_acme") - - # Using custom location (when importing from another project) - run_migrations( - "postgresql://user:pass@host/db", - script_location="/path/to/copied/_alembic" - ) - """ - try: - # Determine script location - if script_location is None: - # Default: use the alembic directory inside the hindsight_api package - # This file is in: hindsight_api/migrations.py - # Alembic is in: hindsight_api/alembic/ - package_dir = Path(__file__).parent - script_location = str(package_dir / "alembic") - - script_path = Path(script_location) - if not script_path.exists(): - raise FileNotFoundError( - f"Alembic script location not found at {script_location}. Database migrations cannot be run." - ) - - # Use schema-specific lock ID for multi-tenant isolation - lock_id = _get_schema_lock_id(schema) if schema else MIGRATION_LOCK_ID - schema_name = schema or "public" - - # Use PostgreSQL advisory lock to coordinate between distributed workers - engine = create_engine(database_url) - with engine.connect() as conn: - # pg_advisory_lock blocks until the lock is acquired - # The lock is automatically released when the connection closes - logger.debug(f"Acquiring migration advisory lock for schema '{schema_name}' (id={lock_id})...") - conn.execute(text(f"SELECT pg_advisory_lock({lock_id})")) - logger.debug("Migration advisory lock acquired") - - try: - # Run migrations while holding the lock - _run_migrations_internal(database_url, script_location, schema=schema) - finally: - # Explicitly release the lock (also released on connection close) - conn.execute(text(f"SELECT pg_advisory_unlock({lock_id})")) - logger.debug("Migration advisory lock released") - - except FileNotFoundError: - logger.error(f"Alembic script location not found at {script_location}") - raise - except SystemExit as e: - # Catch sys.exit() calls from Alembic - logger.error(f"Alembic called sys.exit() with code: {e.code}", exc_info=True) - raise RuntimeError(f"Database migration failed with exit code {e.code}") from e - except Exception as e: - logger.error(f"Failed to run database migrations: {e}", exc_info=True) - raise RuntimeError("Database migration failed") from e - - -def check_migration_status( - database_url: str | None = None, script_location: str | None = None -) -> tuple[str | None, str | None]: - """ - Check current database schema version and latest available version. - - Args: - database_url: SQLAlchemy database URL. If None, uses HINDSIGHT_API_DATABASE_URL env var. - script_location: Path to alembic migrations directory. If None, uses default location. - - Returns: - Tuple of (current_revision, head_revision) - Returns (None, None) if unable to determine versions - """ - try: - from alembic.runtime.migration import MigrationContext - from alembic.script import ScriptDirectory - from sqlalchemy import create_engine - - # Get database URL - if database_url is None: - database_url = os.getenv("HINDSIGHT_API_DATABASE_URL") - if not database_url: - logger.warning( - "Database URL not provided and HINDSIGHT_API_DATABASE_URL not set, cannot check migration status" - ) - return None, None - - # Get current revision from database - engine = create_engine(database_url) - with engine.connect() as connection: - context = MigrationContext.configure(connection) - current_rev = context.get_current_revision() - - # Get head revision from migration scripts - if script_location is None: - package_dir = Path(__file__).parent - script_location = str(package_dir / "alembic") - - script_path = Path(script_location) - if not script_path.exists(): - logger.warning(f"Script location not found at {script_location}") - return current_rev, None - - # Create config programmatically - alembic_cfg = Config() - alembic_cfg.set_main_option("script_location", script_location) - alembic_cfg.set_main_option("path_separator", "os") - - script = ScriptDirectory.from_config(alembic_cfg) - head_rev = script.get_current_head() - - return current_rev, head_rev - - except Exception as e: - logger.warning(f"Unable to check migration status: {e}") - return None, None diff --git a/hindsight-api/hindsight_api/models.py b/hindsight-api/hindsight_api/models.py deleted file mode 100644 index de5eddc4..00000000 --- a/hindsight-api/hindsight_api/models.py +++ /dev/null @@ -1,311 +0,0 @@ -""" -SQLAlchemy models for the memory system. -""" - -from dataclasses import dataclass -from datetime import datetime -from uuid import UUID as PyUUID - - -@dataclass -class RequestContext: - """ - Context for request authentication and authorization. - - This dataclass carries authentication data from HTTP requests to the - memory engine operations. It can be extended to include additional - context like headers, tokens, user info, etc. - """ - - api_key: str | None = None - - -from pgvector.sqlalchemy import Vector -from sqlalchemy import ( - CheckConstraint, - Float, - ForeignKey, - ForeignKeyConstraint, - Index, - Integer, - Text, - func, -) -from sqlalchemy import ( - text as sql_text, -) -from sqlalchemy.dialects.postgresql import JSONB, TIMESTAMP, UUID -from sqlalchemy.ext.asyncio import AsyncAttrs -from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship - - -class Base(AsyncAttrs, DeclarativeBase): - """Base class for all models.""" - - pass - - -class Document(Base): - """Source documents for memory units.""" - - __tablename__ = "documents" - - id: Mapped[str] = mapped_column(Text, primary_key=True) - bank_id: Mapped[str] = mapped_column(Text, primary_key=True) - original_text: Mapped[str | None] = mapped_column(Text) - content_hash: Mapped[str | None] = mapped_column(Text) - doc_metadata: Mapped[dict] = mapped_column("metadata", JSONB, server_default=sql_text("'{}'::jsonb")) - created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now()) - - # Relationships - memory_units = relationship("MemoryUnit", back_populates="document", cascade="all, delete-orphan") - - __table_args__ = ( - Index("idx_documents_bank_id", "bank_id"), - Index("idx_documents_content_hash", "content_hash"), - ) - - -class MemoryUnit(Base): - """Individual sentence-level memories.""" - - __tablename__ = "memory_units" - - id: Mapped[PyUUID] = mapped_column( - UUID(as_uuid=True), primary_key=True, server_default=sql_text("gen_random_uuid()") - ) - bank_id: Mapped[str] = mapped_column(Text, nullable=False) - document_id: Mapped[str | None] = mapped_column(Text) - text: Mapped[str] = mapped_column(Text, nullable=False) - embedding = mapped_column(Vector(384)) # pgvector type - context: Mapped[str | None] = mapped_column(Text) - event_date: Mapped[datetime] = mapped_column( - TIMESTAMP(timezone=True), nullable=False - ) # Kept for backward compatibility - occurred_start: Mapped[datetime | None] = mapped_column( - TIMESTAMP(timezone=True) - ) # When fact occurred (range start) - occurred_end: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) # When fact occurred (range end) - mentioned_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) # When fact was mentioned - fact_type: Mapped[str] = mapped_column(Text, nullable=False, server_default="world") - confidence_score: Mapped[float | None] = mapped_column(Float) - access_count: Mapped[int] = mapped_column(Integer, server_default="0") - unit_metadata: Mapped[dict] = mapped_column( - "metadata", JSONB, server_default=sql_text("'{}'::jsonb") - ) # User-defined metadata (str->str) - created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now()) - - # Relationships - document = relationship("Document", back_populates="memory_units") - unit_entities = relationship("UnitEntity", back_populates="memory_unit", cascade="all, delete-orphan") - outgoing_links = relationship( - "MemoryLink", foreign_keys="MemoryLink.from_unit_id", back_populates="from_unit", cascade="all, delete-orphan" - ) - incoming_links = relationship( - "MemoryLink", foreign_keys="MemoryLink.to_unit_id", back_populates="to_unit", cascade="all, delete-orphan" - ) - - __table_args__ = ( - ForeignKeyConstraint( - ["document_id", "bank_id"], - ["documents.id", "documents.bank_id"], - name="memory_units_document_fkey", - ondelete="CASCADE", - ), - CheckConstraint("fact_type IN ('world', 'experience', 'opinion', 'observation')"), - CheckConstraint("confidence_score IS NULL OR (confidence_score >= 0.0 AND confidence_score <= 1.0)"), - CheckConstraint( - "(fact_type = 'opinion' AND confidence_score IS NOT NULL) OR " - "(fact_type = 'observation') OR " - "(fact_type NOT IN ('opinion', 'observation') AND confidence_score IS NULL)", - name="confidence_score_fact_type_check", - ), - Index("idx_memory_units_bank_id", "bank_id"), - Index("idx_memory_units_document_id", "document_id"), - Index("idx_memory_units_event_date", "event_date", postgresql_ops={"event_date": "DESC"}), - Index("idx_memory_units_bank_date", "bank_id", "event_date", postgresql_ops={"event_date": "DESC"}), - Index("idx_memory_units_access_count", "access_count", postgresql_ops={"access_count": "DESC"}), - Index("idx_memory_units_fact_type", "fact_type"), - Index("idx_memory_units_bank_fact_type", "bank_id", "fact_type"), - Index( - "idx_memory_units_bank_type_date", - "bank_id", - "fact_type", - "event_date", - postgresql_ops={"event_date": "DESC"}, - ), - Index( - "idx_memory_units_opinion_confidence", - "bank_id", - "confidence_score", - postgresql_where=sql_text("fact_type = 'opinion'"), - postgresql_ops={"confidence_score": "DESC"}, - ), - Index( - "idx_memory_units_opinion_date", - "bank_id", - "event_date", - postgresql_where=sql_text("fact_type = 'opinion'"), - postgresql_ops={"event_date": "DESC"}, - ), - Index( - "idx_memory_units_observation_date", - "bank_id", - "event_date", - postgresql_where=sql_text("fact_type = 'observation'"), - postgresql_ops={"event_date": "DESC"}, - ), - Index( - "idx_memory_units_embedding", - "embedding", - postgresql_using="hnsw", - postgresql_ops={"embedding": "vector_cosine_ops"}, - ), - ) - - -class Entity(Base): - """Resolved entities (people, organizations, locations, etc.).""" - - __tablename__ = "entities" - - id: Mapped[PyUUID] = mapped_column( - UUID(as_uuid=True), primary_key=True, server_default=sql_text("gen_random_uuid()") - ) - canonical_name: Mapped[str] = mapped_column(Text, nullable=False) - bank_id: Mapped[str] = mapped_column(Text, nullable=False) - entity_metadata: Mapped[dict] = mapped_column("metadata", JSONB, server_default=sql_text("'{}'::jsonb")) - first_seen: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now()) - last_seen: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now()) - mention_count: Mapped[int] = mapped_column(Integer, server_default="1") - - # Relationships - unit_entities = relationship("UnitEntity", back_populates="entity", cascade="all, delete-orphan") - memory_links = relationship("MemoryLink", back_populates="entity", cascade="all, delete-orphan") - cooccurrences_1 = relationship( - "EntityCooccurrence", - foreign_keys="EntityCooccurrence.entity_id_1", - back_populates="entity_1", - cascade="all, delete-orphan", - ) - cooccurrences_2 = relationship( - "EntityCooccurrence", - foreign_keys="EntityCooccurrence.entity_id_2", - back_populates="entity_2", - cascade="all, delete-orphan", - ) - - __table_args__ = ( - Index("idx_entities_bank_id", "bank_id"), - Index("idx_entities_canonical_name", "canonical_name"), - Index("idx_entities_bank_name", "bank_id", "canonical_name"), - ) - - -class UnitEntity(Base): - """Association between memory units and entities.""" - - __tablename__ = "unit_entities" - - unit_id: Mapped[PyUUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("memory_units.id", ondelete="CASCADE"), primary_key=True - ) - entity_id: Mapped[PyUUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("entities.id", ondelete="CASCADE"), primary_key=True - ) - - # Relationships - memory_unit = relationship("MemoryUnit", back_populates="unit_entities") - entity = relationship("Entity", back_populates="unit_entities") - - __table_args__ = ( - Index("idx_unit_entities_unit", "unit_id"), - Index("idx_unit_entities_entity", "entity_id"), - ) - - -class EntityCooccurrence(Base): - """Materialized cache of entity co-occurrences.""" - - __tablename__ = "entity_cooccurrences" - - entity_id_1: Mapped[PyUUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("entities.id", ondelete="CASCADE"), primary_key=True - ) - entity_id_2: Mapped[PyUUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("entities.id", ondelete="CASCADE"), primary_key=True - ) - cooccurrence_count: Mapped[int] = mapped_column(Integer, server_default="1") - last_cooccurred: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now()) - - # Relationships - entity_1 = relationship("Entity", foreign_keys=[entity_id_1], back_populates="cooccurrences_1") - entity_2 = relationship("Entity", foreign_keys=[entity_id_2], back_populates="cooccurrences_2") - - __table_args__ = ( - CheckConstraint("entity_id_1 < entity_id_2", name="entity_cooccurrence_order_check"), - Index("idx_entity_cooccurrences_entity1", "entity_id_1"), - Index("idx_entity_cooccurrences_entity2", "entity_id_2"), - Index("idx_entity_cooccurrences_count", "cooccurrence_count", postgresql_ops={"cooccurrence_count": "DESC"}), - ) - - -class MemoryLink(Base): - """Links between memory units (temporal, semantic, entity).""" - - __tablename__ = "memory_links" - - from_unit_id: Mapped[PyUUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("memory_units.id", ondelete="CASCADE"), primary_key=True - ) - to_unit_id: Mapped[PyUUID] = mapped_column( - UUID(as_uuid=True), ForeignKey("memory_units.id", ondelete="CASCADE"), primary_key=True - ) - link_type: Mapped[str] = mapped_column(Text, primary_key=True) - entity_id: Mapped[PyUUID | None] = mapped_column( - UUID(as_uuid=True), ForeignKey("entities.id", ondelete="CASCADE"), primary_key=True - ) - weight: Mapped[float] = mapped_column(Float, nullable=False, server_default="1.0") - created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now()) - - # Relationships - from_unit = relationship("MemoryUnit", foreign_keys=[from_unit_id], back_populates="outgoing_links") - to_unit = relationship("MemoryUnit", foreign_keys=[to_unit_id], back_populates="incoming_links") - entity = relationship("Entity", back_populates="memory_links") - - __table_args__ = ( - CheckConstraint( - "link_type IN ('temporal', 'semantic', 'entity', 'causes', 'caused_by', 'enables', 'prevents')", - name="memory_links_link_type_check", - ), - CheckConstraint("weight >= 0.0 AND weight <= 1.0", name="memory_links_weight_check"), - Index("idx_memory_links_from", "from_unit_id"), - Index("idx_memory_links_to", "to_unit_id"), - Index("idx_memory_links_type", "link_type"), - Index("idx_memory_links_entity", "entity_id", postgresql_where=sql_text("entity_id IS NOT NULL")), - Index( - "idx_memory_links_from_weight", - "from_unit_id", - "weight", - postgresql_where=sql_text("weight >= 0.1"), - postgresql_ops={"weight": "DESC"}, - ), - ) - - -class Bank(Base): - """Memory bank profiles with disposition traits and background.""" - - __tablename__ = "banks" - - bank_id: Mapped[str] = mapped_column(Text, primary_key=True) - disposition: Mapped[dict] = mapped_column( - JSONB, nullable=False, server_default=sql_text('\'{"skepticism": 3, "literalism": 3, "empathy": 3}\'::jsonb') - ) - background: Mapped[str] = mapped_column(Text, nullable=False, server_default="") - created_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now()) - updated_at: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), server_default=func.now()) - - __table_args__ = (Index("idx_banks_bank_id", "bank_id"),) diff --git a/hindsight-api/hindsight_api/pg0.py b/hindsight-api/hindsight_api/pg0.py deleted file mode 100644 index 797d9465..00000000 --- a/hindsight-api/hindsight_api/pg0.py +++ /dev/null @@ -1,134 +0,0 @@ -import asyncio -import logging - -from pg0 import Pg0 - -logger = logging.getLogger(__name__) - -DEFAULT_USERNAME = "hindsight" -DEFAULT_PASSWORD = "hindsight" -DEFAULT_DATABASE = "hindsight" - - -class EmbeddedPostgres: - """Manages an embedded PostgreSQL server instance using pg0-embedded.""" - - def __init__( - self, - port: int | None = None, - username: str = DEFAULT_USERNAME, - password: str = DEFAULT_PASSWORD, - database: str = DEFAULT_DATABASE, - name: str = "hindsight", - **kwargs, - ): - self.port = port # None means pg0 will auto-assign - self.username = username - self.password = password - self.database = database - self.name = name - self._pg0: Pg0 | None = None - - def _get_pg0(self) -> Pg0: - if self._pg0 is None: - kwargs = { - "name": self.name, - "username": self.username, - "password": self.password, - "database": self.database, - } - # Only set port if explicitly specified - if self.port is not None: - kwargs["port"] = self.port - self._pg0 = Pg0(**kwargs) # type: ignore[invalid-argument-type] - dict kwargs - return self._pg0 - - async def start(self, max_retries: int = 5, retry_delay: float = 4.0) -> str: - """Start the PostgreSQL server with retry logic.""" - port_info = f"port={self.port}" if self.port else "port=auto" - logger.info(f"Starting embedded PostgreSQL (name={self.name}, {port_info})...") - - pg0 = self._get_pg0() - last_error = None - - for attempt in range(1, max_retries + 1): - try: - loop = asyncio.get_event_loop() - info = await loop.run_in_executor(None, pg0.start) - # Get URI from pg0 (includes auto-assigned port) - uri = info.uri - logger.info(f"PostgreSQL started: {uri}") - return uri - except Exception as e: - last_error = str(e) - if attempt < max_retries: - delay = retry_delay * (2 ** (attempt - 1)) - logger.debug(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error}") - logger.debug(f"Retrying in {delay:.1f}s...") - await asyncio.sleep(delay) - else: - logger.debug(f"pg0 start attempt {attempt}/{max_retries} failed: {last_error}") - - raise RuntimeError( - f"Failed to start embedded PostgreSQL after {max_retries} attempts. Last error: {last_error}" - ) - - async def stop(self) -> None: - """Stop the PostgreSQL server.""" - pg0 = self._get_pg0() - logger.info(f"Stopping embedded PostgreSQL (name: {self.name})...") - - try: - loop = asyncio.get_event_loop() - await loop.run_in_executor(None, pg0.stop) - logger.info("Embedded PostgreSQL stopped") - except Exception as e: - if "not running" in str(e).lower(): - return - raise RuntimeError(f"Failed to stop PostgreSQL: {e}") - - async def get_uri(self) -> str: - """Get the connection URI for the PostgreSQL server.""" - pg0 = self._get_pg0() - loop = asyncio.get_event_loop() - info = await loop.run_in_executor(None, pg0.info) - return info.uri - - async def is_running(self) -> bool: - """Check if the PostgreSQL server is currently running.""" - try: - pg0 = self._get_pg0() - loop = asyncio.get_event_loop() - info = await loop.run_in_executor(None, pg0.info) - return info is not None and info.running - except Exception: - return False - - async def ensure_running(self) -> str: - """Ensure the PostgreSQL server is running, starting it if needed.""" - if await self.is_running(): - return await self.get_uri() - return await self.start() - - -_default_instance: EmbeddedPostgres | None = None - - -def get_embedded_postgres() -> EmbeddedPostgres: - """Get or create the default EmbeddedPostgres instance.""" - global _default_instance - if _default_instance is None: - _default_instance = EmbeddedPostgres() - return _default_instance - - -async def start_embedded_postgres() -> str: - """Quick start function for embedded PostgreSQL.""" - return await get_embedded_postgres().ensure_running() - - -async def stop_embedded_postgres() -> None: - """Stop the default embedded PostgreSQL instance.""" - global _default_instance - if _default_instance: - await _default_instance.stop() diff --git a/hindsight-api/hindsight_api/server.py b/hindsight-api/hindsight_api/server.py deleted file mode 100644 index 8a4f7496..00000000 --- a/hindsight-api/hindsight_api/server.py +++ /dev/null @@ -1,40 +0,0 @@ -""" -FastAPI server for Hindsight API. - -This module provides the ASGI app for uvicorn import string usage: - uvicorn hindsight_api.server:app - -For CLI usage, use the hindsight-api command instead. -""" - -import os -import warnings - -# Filter deprecation warnings from third-party libraries -warnings.filterwarnings("ignore", message="websockets.legacy is deprecated") -warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProtocol is deprecated") - -from hindsight_api import MemoryEngine -from hindsight_api.api import create_app -from hindsight_api.config import get_config - -# Disable tokenizers parallelism to avoid warnings -os.environ["TOKENIZERS_PARALLELISM"] = "false" - -# Load configuration and configure logging -config = get_config() -config.configure_logging() - -# Create app at module level (required for uvicorn import string) -# MemoryEngine reads configuration from environment variables automatically -_memory = MemoryEngine() - -# Create unified app with both HTTP and optionally MCP -app = create_app(memory=_memory, http_api_enabled=True, mcp_api_enabled=config.mcp_enabled, mcp_mount_path="/mcp") - - -if __name__ == "__main__": - # When run directly, delegate to the CLI - from hindsight_api.main import main - - main() diff --git a/hindsight-api/pyproject.toml b/hindsight-api/pyproject.toml deleted file mode 100644 index bb78360f..00000000 --- a/hindsight-api/pyproject.toml +++ /dev/null @@ -1,153 +0,0 @@ -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[project] -name = "hindsight-api" -version = "0.1.13" -description = "Hindsight: Agent Memory That Works Like Human Memory" -readme = "README.md" -requires-python = ">=3.11" -# Core dependencies (no heavy ML libs - those are in 'local' extras) -dependencies = [ - "httpx>=0.28.0", - "asyncpg>=0.29.0", - "python-dotenv>=1.0.0", - "openai>=1.0.0", - "pydantic>=2.0.0", - "rich>=13.0.0", - "langchain-text-splitters>=0.3.0", - "fastapi[standard]>=0.120.3", - "uvicorn>=0.38.0", - "wsproto>=1.0.0", - "sqlalchemy>=2.0.44", - "alembic>=1.17.1", - "pgvector>=0.4.1", - "greenlet>=3.2.4", - "psycopg2-binary>=2.9.11", - "tiktoken>=0.12.0", - "fastmcp>=2.3.0", - "pg0-embedded>=0.11.0", - "python-dateutil>=2.8.0", - "opentelemetry-api>=1.20.0", - "opentelemetry-sdk>=1.20.0", - "opentelemetry-instrumentation-fastapi>=0.41b0", - "opentelemetry-exporter-prometheus>=0.41b0", - "dateparser>=1.2.2", - "google-genai>=1.0.0", -] - -[project.optional-dependencies] -# Local ML models (sentence-transformers, torch) - not needed for cloud API providers -local = [ - "sentence-transformers>=3.0.0,<3.3.0", - "transformers>=4.30.0,<4.46.0", - "torch>=2.0.0", -] -test = [ - "pytest>=7.0.0", - "pytest-asyncio>=0.21.0", - "pytest-timeout>=2.4.0", - "pytest-xdist>=3.0.0", - "filelock>=3.0.0", -] - -[project.scripts] -hindsight-api = "hindsight_api.main:main" -hindsight-local-mcp = "hindsight_api.mcp_local:main" - -[tool.hatch.build.targets.wheel] -packages = ["hindsight_api"] - -[tool.hatch.build.targets.wheel.sources] -"hindsight_api" = "hindsight_api" - -[tool.hatch.build.targets.sdist] -include = [ - "hindsight_api/**/*", -] - -[tool.hatch.build] -include = [ - "hindsight_api/**/*.py", - "hindsight_api/alembic/**/*", -] - -[tool.pytest.ini_options] -log_cli = true -log_cli_level = "INFO" -log_cli_format = "%(asctime)s - %(levelname)s - %(name)s - %(message)s" -log_cli_date_format = "%Y-%m-%d %H:%M:%S" -addopts = "--timeout 120 -n 8 --durations=10 -v" -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" -log_auto_indent = true -filterwarnings = [ - "ignore:The @wait_container_is_ready decorator is deprecated:DeprecationWarning", - "ignore::RuntimeWarning:asyncio", -] - -[dependency-groups] -dev = [ - "pytest>=9.0.0", - "pytest-asyncio>=1.3.0", - "pytest-timeout>=2.4.0", - "pytest-xdist>=3.8.0", - "python-dotenv>=1.2.1", - "filelock>=3.0.0", - "ruff>=0.8.0", - "ty>=0.0.1", -] - -[tool.ruff] -line-length = 120 -target-version = "py311" -exclude = [ - "tests/", - "**/tests/", -] - -[tool.ruff.lint] -select = [ - "E", # pycodestyle errors - "W", # pycodestyle warnings - "F", # Pyflakes - "I", # isort -] -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) -] - -[tool.ruff.format] -quote-style = "double" -indent-style = "space" - -[tool.ty] -# Type checking configuration -# ty is an extremely fast Python type checker from Astral (same team as ruff/uv) - -[tool.ty.environment] -python-version = "3.11" - -[tool.ty.src] -exclude = [ - "tests/", - "hindsight_api/alembic/", -] - -[tool.ty.rules] -# Disable noisy rules while keeping important ones -invalid-argument-type = "ignore" # False positives with **kwargs patterns -invalid-return-type = "ignore" # Often intentional in async code -invalid-parameter-default = "ignore" # Optional params with None default -possibly-missing-attribute = "ignore" # Common with Optional types -invalid-raise = "ignore" # False positives with exception tracking -call-non-callable = "ignore" # False positives with Optional types -invalid-key = "ignore" # Pydantic ConfigDict not understood -invalid-method-override = "ignore" # Intentional signature differences -unresolved-reference = "ignore" # Forward references not always resolved diff --git a/hindsight-api/tests/__init__.py b/hindsight-api/tests/__init__.py deleted file mode 100644 index 3ce0d332..00000000 --- a/hindsight-api/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for the memory system.""" diff --git a/hindsight-api/tests/conftest.py b/hindsight-api/tests/conftest.py deleted file mode 100644 index d2db8dde..00000000 --- a/hindsight-api/tests/conftest.py +++ /dev/null @@ -1,170 +0,0 @@ -""" -Pytest configuration and shared fixtures. -""" -import pytest -import pytest_asyncio -import asyncio -import os -import filelock -from pathlib import Path -from dotenv import load_dotenv -from hindsight_api import MemoryEngine, LLMConfig, LocalSTEmbeddings, RequestContext - -from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder -from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer -from hindsight_api.pg0 import EmbeddedPostgres - -# Default pg0 instance configuration for tests -DEFAULT_PG0_INSTANCE_NAME = "hindsight-test" -DEFAULT_PG0_PORT = 5556 - - -# Load environment variables from .env at the start of test session -def pytest_configure(config): - """Load environment variables before running tests.""" - # Look for .env in the workspace root (two levels up from tests dir) - env_file = Path(__file__).parent.parent.parent / ".env" - if env_file.exists(): - load_dotenv(env_file) - else: - print(f"Warning: {env_file} not found, tests may fail without proper configuration") - - -@pytest.fixture(scope="session") -def db_url(): - """ - Provide a PostgreSQL connection URL for tests. - - If HINDSIGHT_API_DATABASE_URL is set, use it directly. - Otherwise, return None to indicate pg0 should be used (managed by pg0_instance fixture). - """ - return os.getenv("HINDSIGHT_API_DATABASE_URL") - - -@pytest.fixture(scope="session") -def pg0_db_url(db_url, tmp_path_factory, worker_id): - """ - Session-scoped fixture that ensures pg0 is running, migrations are applied, - and returns the database URL. - - If HINDSIGHT_API_DATABASE_URL is set, uses that directly (no pg0 management). - Otherwise, starts pg0 once for the entire test session. - - Uses filelock to ensure only one pytest-xdist worker starts pg0. - Migrations use PostgreSQL advisory locks internally, so they're safe to call - from multiple workers - only one will actually run migrations. - - Note: We don't stop pg0 at the end because pytest-xdist runs workers in separate - processes that share the same pg0 instance. pg0 will persist for the next test run. - """ - if db_url: - # Use provided database URL directly - return db_url - - # Get shared temp dir for coordination between xdist workers - if worker_id == "master": - # Running without xdist (-n 0 or no -n flag) - root_tmp_dir = tmp_path_factory.getbasetemp() - else: - # Running with xdist - use parent dir shared by all workers - root_tmp_dir = tmp_path_factory.getbasetemp().parent - - # Use a lock file to ensure only one worker starts pg0 - lock_file = root_tmp_dir / "pg0_setup.lock" - url_file = root_tmp_dir / "pg0_url.txt" - - with filelock.FileLock(str(lock_file)): - if url_file.exists(): - # Another worker already started pg0 - url = url_file.read_text().strip() - else: - # First worker - start pg0 - pg0 = EmbeddedPostgres(name=DEFAULT_PG0_INSTANCE_NAME, port=DEFAULT_PG0_PORT) - - # Run ensure_running in a new event loop - loop = asyncio.new_event_loop() - try: - url = loop.run_until_complete(pg0.ensure_running()) - finally: - loop.close() - - # Save URL for other workers - url_file.write_text(url) - - # 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) - - return url - - -@pytest.fixture(scope="function") -def request_context(): - """Provide a default RequestContext for tests.""" - return RequestContext() - - -@pytest.fixture(scope="session") -def llm_config(): - """ - Provide LLM configuration for tests. - This can be used by tests that need to call LLM directly without memory system. - """ - return LLMConfig.for_memory() - - -@pytest.fixture(scope="session") -def embeddings(): - - return LocalSTEmbeddings() - - - -@pytest.fixture(scope="session") -def cross_encoder(): - - return LocalSTCrossEncoder() - -@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): - """ - Provide a MemoryEngine instance for each test. - - Must be function-scoped because: - 1. pytest-xdist runs tests in separate processes with different event loops - 2. asyncpg pools are bound to the event loop that created them - 3. Each test needs its own pool in its own event loop - - Uses small pool sizes since tests run in parallel. - Uses pg0_db_url (a postgresql:// URL) directly, so MemoryEngine won't try to - manage pg0 lifecycle - that's handled by the session-scoped pg0_db_url fixture. - Migrations are disabled here since they're run once at session scope in pg0_db_url. - """ - mem = MemoryEngine( - db_url=pg0_db_url, # Direct postgresql:// URL, not pg0:// - memory_llm_provider=os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"), - memory_llm_api_key=os.getenv("HINDSIGHT_API_LLM_API_KEY"), - memory_llm_model=os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"), - memory_llm_base_url=os.getenv("HINDSIGHT_API_LLM_BASE_URL") or None, - embeddings=embeddings, - cross_encoder=cross_encoder, - query_analyzer=query_analyzer, - pool_min_size=1, - pool_max_size=5, - run_migrations=False, # Migrations already run at session scope - ) - await mem.initialize() - yield mem - try: - if mem._pool and not mem._pool._closing: - await mem.close() - except Exception: - pass diff --git a/hindsight-api/tests/fixtures/README.md b/hindsight-api/tests/fixtures/README.md deleted file mode 100644 index 6eef9e59..00000000 --- a/hindsight-api/tests/fixtures/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Test Fixtures - -## locomo_conversation_sample.json - -Sample conversation from the LoComo benchmark (conv-26) used for performance tuning tests. - -**Stats:** -- Sample ID: conv-26 -- Sessions: 19 -- Total dialogues: 419 -- Questions: 199 - -**Usage:** -Used by `test_performance_tuning.py` to measure: -- Batch ingestion performance -- Search performance -- Entity resolution performance - -This is a realistic long-form conversation for stress testing the memory system. diff --git a/hindsight-api/tests/fixtures/locomo_conversation_sample.json b/hindsight-api/tests/fixtures/locomo_conversation_sample.json deleted file mode 100644 index 9ffa2694..00000000 --- a/hindsight-api/tests/fixtures/locomo_conversation_sample.json +++ /dev/null @@ -1,5271 +0,0 @@ -{ - "qa": [ - { - "question": "When did Caroline go to the LGBTQ support group?", - "answer": "7 May 2023", - "evidence": [ - "D1:3" - ], - "category": 2 - }, - { - "question": "When did Melanie paint a sunrise?", - "answer": 2022, - "evidence": [ - "D1:12" - ], - "category": 2 - }, - { - "question": "What fields would Caroline be likely to pursue in her educaton?", - "answer": "Psychology, counseling certification", - "evidence": [ - "D1:9", - "D1:11" - ], - "category": 3 - }, - { - "question": "What did Caroline research?", - "answer": "Adoption agencies", - "evidence": [ - "D2:8" - ], - "category": 1 - }, - { - "question": "What is Caroline's identity?", - "answer": "Transgender woman", - "evidence": [ - "D1:5" - ], - "category": 1 - }, - { - "question": "When did Melanie run a charity race?", - "answer": "The sunday before 25 May 2023", - "evidence": [ - "D2:1" - ], - "category": 2 - }, - { - "question": "When is Melanie planning on going camping?", - "answer": "June 2023", - "evidence": [ - "D2:7" - ], - "category": 2 - }, - { - "question": "What is Caroline's relationship status?", - "answer": "Single", - "evidence": [ - "D3:13", - "D2:14" - ], - "category": 1 - }, - { - "question": "When did Caroline give a speech at a school?", - "answer": "The week before 9 June 2023", - "evidence": [ - "D3:1" - ], - "category": 2 - }, - { - "question": "When did Caroline meet up with her friends, family, and mentors?", - "answer": "The week before 9 June 2023", - "evidence": [ - "D3:11" - ], - "category": 2 - }, - { - "question": "How long has Caroline had her current group of friends for?", - "answer": "4 years", - "evidence": [ - "D3:13" - ], - "category": 2 - }, - { - "question": "Where did Caroline move from 4 years ago?", - "answer": "Sweden", - "evidence": [ - "D3:13", - "D4:3" - ], - "category": 1 - }, - { - "question": "How long ago was Caroline's 18th birthday?", - "answer": "10 years ago", - "evidence": [ - "D4:5" - ], - "category": 2 - }, - { - "question": "What career path has Caroline decided to persue?", - "answer": "counseling or mental health for Transgender people", - "evidence": [ - "D4:13", - "D1:11" - ], - "category": 1 - }, - { - "question": "Would Caroline still want to pursue counseling as a career if she hadn't received support growing up?", - "answer": "Likely no", - "evidence": [ - "D4:15", - "D3:5" - ], - "category": 3 - }, - { - "question": "What activities does Melanie partake in?", - "answer": "pottery, camping, painting, swimming", - "evidence": [ - "D5:4", - "D9:1", - "D1:12", - "D1:18" - ], - "category": 1 - }, - { - "question": "When did Melanie sign up for a pottery class?", - "answer": "2 July 2023", - "evidence": [ - "D5:4" - ], - "category": 2 - }, - { - "question": "When is Caroline going to the transgender conference?", - "answer": "July 2023", - "evidence": [ - "D5:13" - ], - "category": 2 - }, - { - "question": "Where has Melanie camped?", - "answer": "beach, mountains, forest", - "evidence": [ - "D6:16", - "D4:6", - "D8:32" - ], - "category": 1 - }, - { - "question": "What do Melanie's kids like?", - "answer": "dinosaurs, nature", - "evidence": [ - "D6:6", - "D4:8" - ], - "category": 1 - }, - { - "question": "When did Melanie go to the museum?", - "answer": "5 July 2023", - "evidence": [ - "D6:4" - ], - "category": 2 - }, - { - "question": "When did Caroline have a picnic?", - "answer": "The week before 6 July 2023", - "evidence": [ - "D6:11" - ], - "category": 2 - }, - { - "question": "Would Caroline likely have Dr. Seuss books on her bookshelf?", - "answer": "Yes, since she collects classic children's books", - "evidence": [ - "D6:9" - ], - "category": 3 - }, - { - "question": "What books has Melanie read?", - "answer": "\"Nothing is Impossible\", \"Charlotte's Web\"", - "evidence": [ - "D7:8", - "D6:10" - ], - "category": 1 - }, - { - "question": "What does Melanie do to destress?", - "answer": "Running, pottery", - "evidence": [ - "D7:22", - "D5:4" - ], - "category": 1 - }, - { - "question": "When did Caroline go to the LGBTQ conference?", - "answer": "10 July 2023", - "evidence": [ - "D7:1" - ], - "category": 2 - }, - { - "question": "When did Melanie read the book \"nothing is impossible\"?", - "answer": 2022, - "evidence": [ - "D7:8" - ], - "category": 2 - }, - { - "question": "Would Caroline pursue writing as a career option?", - "answer": "LIkely no; though she likes reading, she wants to be a counselor", - "evidence": [ - "D7:5", - "D7:9" - ], - "category": 3 - }, - { - "question": "When did Caroline go to the adoption meeting?", - "answer": "The friday before 15 July 2023", - "evidence": [ - "D8:9" - ], - "category": 2 - }, - { - "question": "When did Melanie go to the pottery workshop?", - "answer": "The Friday before 15 July 2023", - "evidence": [ - "D8:2" - ], - "category": 2 - }, - { - "question": "Would Melanie be considered a member of the LGBTQ community?", - "answer": "Likely no, she does not refer to herself as part of it", - "evidence": [], - "category": 3 - }, - { - "question": "When did Melanie go camping in June?", - "answer": "The week before 27 June 2023", - "evidence": [ - "D4:8" - ], - "category": 2 - }, - { - "question": "What LGBTQ+ events has Caroline participated in?", - "answer": "Pride parade, school speech, support group", - "evidence": [ - "D5:1", - "D8:17", - "D3:1", - "D1:3" - ], - "category": 1 - }, - { - "question": "When did Caroline go to a pride parade during the summer?", - "answer": "The week before 3 July 2023", - "evidence": [ - "D5:1" - ], - "category": 2 - }, - { - "question": "What events has Caroline participated in to help children?", - "answer": "Mentoring program, school speech", - "evidence": [ - "D9:2", - "D3:3" - ], - "category": 1 - }, - { - "question": "When did Melanie go camping in July?", - "answer": "two weekends before 17 July 2023", - "evidence": [ - "D9:1" - ], - "category": 2 - }, - { - "question": "When did Caroline join a mentorship program?", - "answer": "The weekend before 17 July 2023", - "evidence": [ - "D9:2" - ], - "category": 2 - }, - { - "question": "What did Melanie paint recently?", - "answer": "sunset", - "evidence": [ - "D8:6; D9:17" - ], - "category": 1 - }, - { - "question": "What activities has Melanie done with her family?", - "answer": "Pottery, painting, camping, museum, swimming, hiking", - "evidence": [ - "D8:4", - "D8:6", - "D9:1", - "D6:4", - "D1:18", - "D3:14" - ], - "category": 1 - }, - { - "question": "In what ways is Caroline participating in the LGBTQ community?", - "answer": "Joining activist group, going to pride parades, participating in an art show, mentoring program", - "evidence": [ - "D10:3", - "D5:1", - "D9:12", - "D9:2" - ], - "category": 1 - }, - { - "question": "How many times has Melanie gone to the beach in 2023?", - "answer": 2, - "evidence": [ - "D10:8", - "D6:16" - ], - "category": 1 - }, - { - "question": "When did Caroline join a new activist group?", - "answer": "The Tuesday before 20 July 2023", - "evidence": [ - "D10:3" - ], - "category": 2 - }, - { - "question": "Would Melanie be more interested in going to a national park or a theme park?", - "answer": "National park; she likes the outdoors", - "evidence": [ - "D10:12", - "D10:14" - ], - "category": 3 - }, - { - "question": "What kind of art does Caroline make?", - "answer": "abstract art", - "evidence": [ - "D11:12", - "D11:8", - "D9:14" - ], - "category": 1 - }, - { - "question": "When is Melanie's daughter's birthday?", - "answer": "13 August", - "evidence": [ - "D11:1" - ], - "category": 2 - }, - { - "question": "When did Caroline attend a pride parade in August?", - "answer": "The Friday before 14 August 2023", - "evidence": [ - "D11:4" - ], - "category": 2 - }, - { - "question": "Would Melanie be considered an ally to the transgender community?", - "answer": "Yes, she is supportive", - "evidence": [], - "category": 3 - }, - { - "question": "Who supports Caroline when she has a negative experience?", - "answer": "Her mentors, family, and friends", - "evidence": [ - "D12:1", - "D3:11" - ], - "category": 1 - }, - { - "question": "What types of pottery have Melanie and her kids made?", - "answer": "bowls, cup", - "evidence": [ - "D12:14", - "D8:4", - "D5:6" - ], - "category": 1 - }, - { - "question": "When did Caroline and Melanie go to a pride fesetival together?", - "answer": 2022, - "evidence": [ - "D12:15" - ], - "category": 2 - }, - { - "question": "What would Caroline's political leaning likely be?", - "answer": "Liberal", - "evidence": [ - "D12:1" - ], - "category": 3 - }, - { - "question": "What has Melanie painted?", - "answer": "Horse, sunset, sunrise", - "evidence": [ - "D13:8", - "D8:6", - "D1:12" - ], - "category": 1 - }, - { - "question": "What are Melanie's pets' names?", - "answer": "Oliver, Luna, Bailey", - "evidence": [ - "D13:4", - "D7:18" - ], - "category": 1 - }, - { - "question": "When did Caroline apply to adoption agencies?", - "answer": "The week of 23 August 2023", - "evidence": [ - "D13:1" - ], - "category": 2 - }, - { - "question": "When did Caroline draw a self-portrait?", - "answer": "The week before 23 August 2023", - "evidence": [ - "D13:11" - ], - "category": 2 - }, - { - "question": "What subject have Caroline and Melanie both painted?", - "answer": "Sunsets", - "evidence": [ - "D14:5", - "D8:6" - ], - "category": 1 - }, - { - "question": "What symbols are important to Caroline?", - "answer": "Rainbow flag, transgender symbol", - "evidence": [ - "D14:15", - "D4:1" - ], - "category": 1 - }, - { - "question": "When did Caroline encounter people on a hike and have a negative experience?", - "answer": "The week before 25 August 2023", - "evidence": [ - "D14:1" - ], - "category": 2 - }, - { - "question": "When did Melanie make a plate in pottery class?", - "answer": "24 August 2023", - "evidence": [ - "D14:4" - ], - "category": 2 - }, - { - "question": "Would Caroline be considered religious?", - "answer": "Somewhat, but not extremely religious", - "evidence": [ - "D14:19", - "D12:1" - ], - "category": 3 - }, - { - "question": "What instruments does Melanie play?", - "answer": "clarinet and violin", - "evidence": [ - "D15:26", - "D2:5" - ], - "category": 1 - }, - { - "question": "What musical artists/bands has Melanie seen?", - "answer": "Summer Sounds, Matt Patterson", - "evidence": [ - "D15:16", - "D11:3" - ], - "category": 1 - }, - { - "question": "When did Melanie go to the park?", - "answer": "27 August 2023", - "evidence": [ - "D15:2" - ], - "category": 2 - }, - { - "question": "When is Caroline's youth center putting on a talent show?", - "answer": "September 2023", - "evidence": [ - "D15:11" - ], - "category": 2 - }, - { - "question": "Would Melanie likely enjoy the song \"The Four Seasons\" by Vivaldi?", - "answer": "Yes; it's classical music", - "evidence": [ - "D15:28" - ], - "category": 3 - }, - { - "question": "What are some changes Caroline has faced during her transition journey?", - "answer": "Changes to her body, losing unsupportive friends", - "evidence": [ - "D16:15", - "D11:14" - ], - "category": 1 - }, - { - "question": "What does Melanie do with her family on hikes?", - "answer": "Roast marshmallows, tell stories", - "evidence": [ - "D16:4", - "D10:12" - ], - "category": 1 - }, - { - "question": "When did Caroline go biking with friends?", - "answer": "The weekend before 13 September 2023", - "evidence": [ - "D16:1" - ], - "category": 2 - }, - { - "question": "How long has Melanie been practicing art?", - "answer": "Since 2016", - "evidence": [ - "D16:8" - ], - "category": 2 - }, - { - "question": "What personality traits might Melanie say Caroline has?", - "answer": "Thoughtful, authentic, driven", - "evidence": [ - "D16:18", - "D13:16", - "D7:4" - ], - "category": 3 - }, - { - "question": "What transgender-specific events has Caroline attended?", - "answer": "Poetry reading, conference", - "evidence": [ - "D17:19", - "D15:13" - ], - "category": 1 - }, - { - "question": "What book did Melanie read from Caroline's suggestion?", - "answer": "\"Becoming Nicole\"", - "evidence": [ - "D7:11", - "D17:10" - ], - "category": 1 - }, - { - "question": "When did Melanie's friend adopt a child?", - "answer": 2022, - "evidence": [ - "D17:3" - ], - "category": 2 - }, - { - "question": "When did Melanie get hurt?", - "answer": "September 2023", - "evidence": [ - "D17:8" - ], - "category": 2 - }, - { - "question": "When did Melanie's family go on a roadtrip?", - "answer": "The weekend before 20 October 2023", - "evidence": [ - "D18:1" - ], - "category": 2 - }, - { - "question": "How many children does Melanie have?", - "answer": 3, - "evidence": [ - "D18:1", - "D18:7" - ], - "category": 1 - }, - { - "question": "When did Melanie go on a hike after the roadtrip?", - "answer": "19 October 2023", - "evidence": [ - "D18:17" - ], - "category": 1 - }, - { - "question": "Would Melanie go on another roadtrip soon?", - "answer": "Likely no; since this one went badly", - "evidence": [ - "D18:3", - "D18:1" - ], - "category": 3 - }, - { - "question": "What items has Melanie bought?", - "answer": "Figurines, shoes", - "evidence": [ - "D19:2", - "D7:18" - ], - "category": 1 - }, - { - "question": "When did Caroline pass the adoption interview?", - "answer": "The Friday before 22 October 2023", - "evidence": [ - "D19:1" - ], - "category": 2 - }, - { - "question": "When did Melanie buy the figurines?", - "answer": "21 October 2023", - "evidence": [ - "D19:2" - ], - "category": 2 - }, - { - "question": "Would Caroline want to move back to her home country soon?", - "answer": "No; she's in the process of adopting children.", - "evidence": [ - "D19:1", - "D19:3" - ], - "category": 3 - }, - { - "question": "What did the charity race raise awareness for?", - "answer": "mental health", - "evidence": [ - "D2:2" - ], - "category": 4 - }, - { - "question": "What did Melanie realize after the charity race?", - "answer": "self-care is important", - "evidence": [ - "D2:3" - ], - "category": 4 - }, - { - "question": "How does Melanie prioritize self-care?", - "answer": "by carving out some me-time each day for activities like running, reading, or playing the violin", - "evidence": [ - "D2:5" - ], - "category": 4 - }, - { - "question": "What are Caroline's plans for the summer?", - "answer": "researching adoption agencies", - "evidence": [ - "D2:8" - ], - "category": 4 - }, - { - "question": "What type of individuals does the adoption agency Caroline is considering support?", - "answer": "LGBTQ+ individuals", - "evidence": [ - "D2:12" - ], - "category": 4 - }, - { - "question": "Why did Caroline choose the adoption agency?", - "answer": "because of their inclusivity and support for LGBTQ+ individuals", - "evidence": [ - "D2:12" - ], - "category": 4 - }, - { - "question": "What is Caroline excited about in the adoption process?", - "answer": "creating a family for kids who need one", - "evidence": [ - "D2:14" - ], - "category": 4 - }, - { - "question": "What does Melanie think about Caroline's decision to adopt?", - "answer": "she thinks Caroline is doing something amazing and will be an awesome mom", - "evidence": [ - "D2:15" - ], - "category": 4 - }, - { - "question": "How long have Mel and her husband been married?", - "answer": "Mel and her husband have been married for 5 years.", - "evidence": [ - "D3:16" - ], - "category": 4 - }, - { - "question": "What does Caroline's necklace symbolize?", - "answer": "love, faith, and strength", - "evidence": [ - "D4:3" - ], - "category": 4 - }, - { - "question": "What country is Caroline's grandma from?", - "answer": "Sweden", - "evidence": [ - "D4:3" - ], - "category": 4 - }, - { - "question": "What was grandma's gift to Caroline?", - "answer": "necklace", - "evidence": [ - "D4:3" - ], - "category": 4 - }, - { - "question": "What is Melanie's hand-painted bowl a reminder of?", - "answer": "art and self-expression", - "evidence": [ - "D4:5" - ], - "category": 4 - }, - { - "question": "What did Melanie and her family do while camping?", - "answer": "explored nature, roasted marshmallows, and went on a hike", - "evidence": [ - "D4:8" - ], - "category": 4 - }, - { - "question": "What kind of counseling and mental health services is Caroline interested in pursuing?", - "answer": "working with trans people, helping them accept themselves and supporting their mental health", - "evidence": [ - "D4:13" - ], - "category": 4 - }, - { - "question": "What workshop did Caroline attend recently?", - "answer": "LGBTQ+ counseling workshop", - "evidence": [ - "D4:13" - ], - "category": 4 - }, - { - "question": "What was discussed in the LGBTQ+ counseling workshop?", - "answer": "therapeutic methods and how to best work with trans people", - "evidence": [ - "D4:13" - ], - "category": 4 - }, - { - "question": "What motivated Caroline to pursue counseling?", - "answer": "her own journey and the support she received, and how counseling improved her life", - "evidence": [ - "D4:15" - ], - "category": 4 - }, - { - "question": "What kind of place does Caroline want to create for people?", - "answer": "a safe and inviting place for people to grow", - "evidence": [ - "D4:15" - ], - "category": 4 - }, - { - "question": "Did Melanie make the black and white bowl in the photo?", - "answer": "Yes", - "evidence": [ - "D5:8" - ], - "category": 4 - }, - { - "question": "What kind of books does Caroline have in her library?", - "answer": "kids' books - classics, stories from different cultures, educational books", - "evidence": [ - "D6:9" - ], - "category": 4 - }, - { - "question": "What was Melanie's favorite book from her childhood?", - "answer": "\"Charlotte's Web\"", - "evidence": [ - "D6:10" - ], - "category": 4 - }, - { - "question": "What book did Caroline recommend to Melanie?", - "answer": "\"Becoming Nicole\"", - "evidence": [ - "D7:11" - ], - "category": 4 - }, - { - "question": "What did Caroline take away from the book \"Becoming Nicole\"?", - "answer": "Lessons on self-acceptance and finding support", - "evidence": [ - "D7:13" - ], - "category": 4 - }, - { - "question": "What are the new shoes that Melanie got used for?", - "answer": "Running", - "evidence": [ - "D7:19" - ], - "category": 4 - }, - { - "question": "What is Melanie's reason for getting into running?", - "answer": "To de-stress and clear her mind", - "evidence": [ - "D7:21" - ], - "category": 4 - }, - { - "question": "What does Melanie say running has been great for?", - "answer": "Her mental health", - "evidence": [ - "D7:24" - ], - "category": 4 - }, - { - "question": "What did Mel and her kids make during the pottery workshop?", - "answer": "pots", - "evidence": [ - "D8:2" - ], - "category": 4 - }, - { - "question": "What kind of pot did Mel and her kids make with clay?", - "answer": "a cup with a dog face on it", - "evidence": [ - "D8:4" - ], - "category": 4 - }, - { - "question": "What creative project do Mel and her kids do together besides pottery?", - "answer": "painting", - "evidence": [ - "D8:5" - ], - "category": 4 - }, - { - "question": "What did Mel and her kids paint in their latest project in July 2023?", - "answer": "a sunset with a palm tree", - "evidence": [ - "D8:6" - ], - "category": 4 - }, - { - "question": "What did Caroline see at the council meeting for adoption?", - "answer": "many people wanting to create loving homes for children in need", - "evidence": [ - "D8:9" - ], - "category": 4 - }, - { - "question": "What do sunflowers represent according to Caroline?", - "answer": "warmth and happiness", - "evidence": [ - "D8:11" - ], - "category": 4 - }, - { - "question": "Why are flowers important to Melanie?", - "answer": "They remind her to appreciate the small moments and were a part of her wedding decor", - "evidence": [ - "D8:12" - ], - "category": 4 - }, - { - "question": "What inspired Caroline's painting for the art show?", - "answer": "visiting an LGBTQ center and wanting to capture unity and strength", - "evidence": [ - "D9:16" - ], - "category": 4 - }, - { - "question": "How often does Melanie go to the beach with her kids?", - "answer": "once or twice a year", - "evidence": [ - "D10:10" - ], - "category": 4 - }, - { - "question": "What did Melanie and her family see during their camping trip last year?", - "answer": "Perseid meteor shower", - "evidence": [ - "D10:14" - ], - "category": 4 - }, - { - "question": "How did Melanie feel while watching the meteor shower?", - "answer": "in awe of the universe", - "evidence": [ - "D10:18" - ], - "category": 4 - }, - { - "question": "Whose birthday did Melanie celebrate recently?", - "answer": "Melanie's daughter", - "evidence": [ - "D11:1" - ], - "category": 4 - }, - { - "question": "Who performed at the concert at Melanie's daughter's birthday?", - "answer": "Matt Patterson", - "evidence": [ - "D11:3" - ], - "category": 4 - }, - { - "question": "Why did Melanie choose to use colors and patterns in her pottery project?", - "answer": "She wanted to catch the eye and make people smile.", - "evidence": [ - "D12:6" - ], - "category": 4 - }, - { - "question": "What pet does Caroline have?", - "answer": "guinea pig", - "evidence": [ - "D13:3" - ], - "category": 4 - }, - { - "question": "What pets does Melanie have?", - "answer": "Two cats and a dog", - "evidence": [ - "D13:4" - ], - "category": 4 - }, - { - "question": "Where did Oliver hide his bone once?", - "answer": "In Melanie's slipper", - "evidence": [ - "D13:6" - ], - "category": 4 - }, - { - "question": "What activity did Caroline used to do with her dad?", - "answer": "Horseback riding", - "evidence": [ - "D13:7" - ], - "category": 4 - }, - { - "question": "What did Caroline make for a local church?", - "answer": "a stained glass window", - "evidence": [ - "D14:17" - ], - "category": 4 - }, - { - "question": "What did Caroline find in her neighborhood during her walk?", - "answer": "a rainbow sidewalk", - "evidence": [ - "D14:23" - ], - "category": 4 - }, - { - "question": "Which song motivates Caroline to be courageous?", - "answer": "Brave by Sara Bareilles", - "evidence": [ - "D15:23" - ], - "category": 4 - }, - { - "question": "Which classical musicians does Melanie enjoy listening to?", - "answer": "Bach and Mozart", - "evidence": [ - "D15:28" - ], - "category": 4 - }, - { - "question": "Who is Melanie a fan of in terms of modern music?", - "answer": "Ed Sheeran", - "evidence": [ - "D15:28" - ], - "category": 4 - }, - { - "question": "How long has Melanie been creating art?", - "answer": "7 years", - "evidence": [ - "D16:7" - ], - "category": 4 - }, - { - "question": "What precautionary sign did Melanie see at the caf\u00e9?", - "answer": "A sign stating that someone is not being able to leave", - "evidence": [ - "D16:16" - ], - "category": 4 - }, - { - "question": "What advice does Caroline give for getting started with adoption?", - "answer": "Do research, find an adoption agency or lawyer, gather necessary documents, and prepare emotionally.", - "evidence": [ - "D17:7" - ], - "category": 4 - }, - { - "question": "What setback did Melanie face in October 2023?", - "answer": "She got hurt and had to take a break from pottery.", - "evidence": [ - "D17:8" - ], - "category": 4 - }, - { - "question": "What does Melanie do to keep herself busy during her pottery break?", - "answer": "Read a book and paint.", - "evidence": [ - "D17:10" - ], - "category": 4 - }, - { - "question": "What painting did Melanie show to Caroline on October 13, 2023?", - "answer": "A painting inspired by sunsets with a pink sky.", - "evidence": [ - "D17:12" - ], - "category": 4 - }, - { - "question": "What kind of painting did Caroline share with Melanie on October 13, 2023?", - "answer": "An abstract painting with blue streaks on a wall.", - "evidence": [ - "D17:14" - ], - "category": 4 - }, - { - "question": "What was the poetry reading that Caroline attended about?", - "answer": "It was a transgender poetry reading where transgender people shared their stories.", - "evidence": [ - "D17:18" - ], - "category": 4 - }, - { - "question": "What did the posters at the poetry reading say?", - "answer": "\"Trans Lives Matter\"", - "evidence": [ - "D17:19" - ], - "category": 4 - }, - { - "question": "What does Caroline's drawing symbolize for her?", - "answer": "Freedom and being true to herself.", - "evidence": [ - "D17:23" - ], - "category": 4 - }, - { - "question": "How do Melanie and Caroline describe their journey through life together?", - "answer": "An ongoing adventure of learning and growing.", - "evidence": [ - "D17:25" - ], - "category": 4 - }, - { - "question": "What happened to Melanie's son on their road trip?", - "answer": "He got into an accident", - "evidence": [ - "D18:1" - ], - "category": 4 - }, - { - "question": "How did Melanie's son handle the accident?", - "answer": "He was scared but reassured by his family", - "evidence": [ - "D18:6", - "D18:7" - ], - "category": 4 - }, - { - "question": "How did Melanie feel about her family after the accident?", - "answer": "They are important and mean the world to her", - "evidence": [ - "D18:5" - ], - "category": 4 - }, - { - "question": "How did Melanie's children handle the accident?", - "answer": "They were scared but resilient", - "evidence": [ - "D18:7" - ], - "category": 4 - }, - { - "question": "How did Melanie feel after the accident?", - "answer": "Grateful and thankful for her family", - "evidence": [ - "D18:5" - ], - "category": 4 - }, - { - "question": "What was Melanie's reaction to her children enjoying the Grand Canyon?", - "answer": "She was happy and thankful", - "evidence": [ - "D18:5" - ], - "category": 4 - }, - { - "question": "What do Melanie's family give her?", - "answer": "Strength and motivation", - "evidence": [ - "D18:9" - ], - "category": 4 - }, - { - "question": "How did Melanie feel about her family supporting her?", - "answer": "She appreciated them a lot", - "evidence": [ - "D18:13" - ], - "category": 4 - }, - { - "question": "What did Melanie do after the road trip to relax?", - "answer": "Went on a nature walk or hike", - "evidence": [ - "D18:17" - ], - "category": 4 - }, - { - "question": "What did Caroline realize after her charity race?", - "evidence": [ - "D2:3" - ], - "category": 5, - "adversarial_answer": "self-care is important" - }, - { - "question": "What are Melanie's plans for the summer with respect to adoption?", - "evidence": [ - "D2:8" - ], - "category": 5, - "adversarial_answer": "researching adoption agencies" - }, - { - "question": "What type of individuals does the adoption agency Melanie is considering support?", - "evidence": [ - "D2:12" - ], - "category": 5, - "adversarial_answer": "LGBTQ+ individuals" - }, - { - "question": "Why did Melanie choose the adoption agency?", - "evidence": [ - "D2:12" - ], - "category": 5, - "adversarial_answer": "because of their inclusivity and support for LGBTQ+ individuals" - }, - { - "question": "What is Melanie excited about in her adoption process?", - "evidence": [ - "D2:14" - ], - "category": 5, - "adversarial_answer": "creating a family for kids who need one" - }, - { - "question": "What does Melanie's necklace symbolize?", - "evidence": [ - "D4:3" - ], - "category": 5, - "adversarial_answer": "love, faith, and strength" - }, - { - "question": "What country is Melanie's grandma from?", - "evidence": [ - "D4:3" - ], - "category": 5, - "adversarial_answer": "Sweden" - }, - { - "question": "What was grandma's gift to Melanie?", - "evidence": [ - "D4:3" - ], - "category": 5, - "adversarial_answer": "necklace" - }, - { - "question": "What was grandpa's gift to Caroline?", - "evidence": [ - "D4:3" - ], - "category": 5, - "adversarial_answer": "necklace" - }, - { - "question": "What is Caroline's hand-painted bowl a reminder of?", - "evidence": [ - "D4:5" - ], - "category": 5, - "adversarial_answer": "art and self-expression" - }, - { - "question": "What did Caroline and her family do while camping?", - "evidence": [ - "D4:8" - ], - "category": 5, - "adversarial_answer": "explored nature, roasted marshmallows, and went on a hike" - }, - { - "question": "What kind of counseling and mental health services is Melanie interested in pursuing?", - "evidence": [ - "D4:13" - ], - "category": 5, - "adversarial_answer": "working with trans people, helping them accept themselves and supporting their mental health" - }, - { - "question": "What kind of counseling workshop did Melanie attend recently?", - "evidence": [ - "D4:13" - ], - "category": 5, - "adversarial_answer": "LGBTQ+ counseling workshop" - }, - { - "question": "What motivated Melanie to pursue counseling?", - "evidence": [ - "D4:15" - ], - "category": 5, - "adversarial_answer": "her own journey and the support she received, and how counseling improved her life" - }, - { - "question": "What kind of place does Melanie want to create for people?", - "evidence": [ - "D4:15" - ], - "category": 5, - "adversarial_answer": "a safe and inviting place for people to grow" - }, - { - "question": "Did Caroline make the black and white bowl in the photo?", - "adversarial_answer": "Yes", - "answer": "No", - "evidence": [ - "D5:8" - ], - "category": 5 - }, - { - "question": "What are the new shoes that Caroline got used for?", - "evidence": [ - "D7:19" - ], - "category": 5, - "adversarial_answer": "Running" - }, - { - "question": "What is Caroline's reason for getting into running?", - "evidence": [ - "D7:21" - ], - "category": 5, - "adversarial_answer": "To de-stress and clear her mind" - }, - { - "question": "What does Caroline say running has been great for?", - "evidence": [ - "D7:24" - ], - "category": 5, - "adversarial_answer": "Her mental health" - }, - { - "question": "What did Melanie see at the council meeting for adoption?", - "evidence": [ - "D8:9" - ], - "category": 5, - "adversarial_answer": "many people wanting to create loving homes for children in need" - }, - { - "question": "What inspired Melanie's painting for the art show?", - "evidence": [ - "D9:16" - ], - "category": 5, - "adversarial_answer": "visiting an LGBTQ center and wanting to capture unity and strength" - }, - { - "question": "What inspired Caroline's sculpture for the art show?", - "evidence": [ - "D9:16" - ], - "category": 5, - "adversarial_answer": "visiting an LGBTQ center and wanting to capture unity and strength" - }, - { - "question": "How often does Caroline go to the beach with her kids?", - "evidence": [ - "D10:10" - ], - "category": 5, - "adversarial_answer": "once or twice a year" - }, - { - "question": "What did Caroline and her family see during their camping trip last year?", - "evidence": [ - "D10:14" - ], - "category": 5, - "adversarial_answer": "Perseid meteor shower" - }, - { - "question": "How did Caroline feel while watching the meteor shower?", - "evidence": [ - "D10:18" - ], - "category": 5, - "adversarial_answer": "in awe of the universe" - }, - { - "question": "Why did Caroline choose to use colors and patterns in her pottery project?", - "evidence": [ - "D12:6" - ], - "category": 5, - "adversarial_answer": "She wanted to catch the eye and make people smile." - }, - { - "question": "Is Oscar Melanie's pet?", - "adversarial_answer": "Yes", - "answer": "No", - "evidence": [ - "D13:3" - ], - "category": 5 - }, - { - "question": "Where did Oscar hide his bone once?", - "evidence": [ - "D13:6" - ], - "category": 5, - "adversarial_answer": "In Melanie's slipper" - }, - { - "question": "What activity did Melanie used to do with her dad?", - "evidence": [ - "D13:7" - ], - "category": 5, - "adversarial_answer": "Horseback riding" - }, - { - "question": "What did Melanie make for a local church?", - "evidence": [ - "D14:17" - ], - "category": 5, - "adversarial_answer": "a stained glass window" - }, - { - "question": "What did Melanie find in her neighborhood during her walk?", - "evidence": [ - "D14:23" - ], - "category": 5, - "adversarial_answer": "a rainbow sidewalk" - }, - { - "question": "Which song motivates Melanie to be courageous?", - "evidence": [ - "D15:23" - ], - "category": 5, - "adversarial_answer": "Brave by Sara Bareilles" - }, - { - "question": "What type of instrument does Caroline play?", - "evidence": [ - "D15:26" - ], - "category": 5, - "adversarial_answer": "clarinet and violin" - }, - { - "question": "Which classical musicians does Caroline enjoy listening to?", - "evidence": [ - "D15:28" - ], - "category": 5, - "adversarial_answer": "Bach and Mozart" - }, - { - "question": "Who is Caroline a fan of in terms of modern music?", - "evidence": [ - "D15:28" - ], - "category": 5, - "adversarial_answer": "Ed Sheeran" - }, - { - "question": "What precautionary sign did Caroline see at the caf\u00e9?", - "evidence": [ - "D16:16" - ], - "category": 5, - "adversarial_answer": "A sign stating that someone is not being able to leave" - }, - { - "question": "What setback did Caroline face recently?", - "evidence": [ - "D17:8" - ], - "category": 5, - "adversarial_answer": "She got hurt and had to take a break from pottery." - }, - { - "question": "What does Caroline do to keep herself busy during her pottery break?", - "evidence": [ - "D17:10" - ], - "category": 5, - "adversarial_answer": "Read a book and paint." - }, - { - "question": "What was the poetry reading that Melanie attended about?", - "evidence": [ - "D17:18" - ], - "category": 5, - "adversarial_answer": "It was a transgender poetry reading where transgender people shared their stories." - }, - { - "question": "What happened to Caroline's son on their road trip?", - "evidence": [ - "D18:1" - ], - "category": 5, - "adversarial_answer": "He got into an accident" - }, - { - "question": "How did Caroline's son handle the accident?", - "evidence": [ - "D18:6", - "D18:7" - ], - "category": 5, - "adversarial_answer": "He was scared but reassured by his family" - }, - { - "question": "How did Caroline feel about her family after the accident?", - "evidence": [ - "D18:5" - ], - "category": 5, - "adversarial_answer": "They are important and mean the world to her" - }, - { - "question": "How did Caroline's children handle the accident?", - "evidence": [ - "D18:7" - ], - "category": 5, - "adversarial_answer": "They were scared but resilient" - }, - { - "question": "How did Caroline feel after the accident?", - "evidence": [ - "D18:5" - ], - "category": 5, - "adversarial_answer": "Grateful and thankful for her family" - }, - { - "question": "What was Caroline's reaction to her children enjoying the Grand Canyon?", - "evidence": [ - "D18:5" - ], - "category": 5, - "adversarial_answer": "She was happy and thankful" - }, - { - "question": "What did Caroline do after the road trip to relax?", - "evidence": [ - "D18:17" - ], - "category": 5, - "adversarial_answer": "Went on a nature walk or hike" - }, - { - "question": "What does Caroline love most about camping with her family?", - "evidence": [ - "D18:21" - ], - "category": 5, - "adversarial_answer": "Being present and bonding with her family" - } - ], - "conversation": { - "speaker_a": "Caroline", - "speaker_b": "Melanie", - "session_1_date_time": "1:56 pm on 8 May, 2023", - "session_1": [ - { - "speaker": "Caroline", - "dia_id": "D1:1", - "text": "Hey Mel! Good to see you! How have you been?" - }, - { - "speaker": "Melanie", - "dia_id": "D1:2", - "text": "Hey Caroline! Good to see you! I'm swamped with the kids & work. What's up with you? Anything new?" - }, - { - "speaker": "Caroline", - "dia_id": "D1:3", - "text": "I went to a LGBTQ support group yesterday and it was so powerful." - }, - { - "speaker": "Melanie", - "dia_id": "D1:4", - "text": "Wow, that's cool, Caroline! What happened that was so awesome? Did you hear any inspiring stories?" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://i.redd.it/l7hozpetnhlb1.jpg" - ], - "blip_caption": "a photo of a dog walking past a wall with a painting of a woman", - "query": "transgender pride flag mural", - "dia_id": "D1:5", - "text": "The transgender stories were so inspiring! I was so happy and thankful for all the support." - }, - { - "speaker": "Melanie", - "dia_id": "D1:6", - "text": "Wow, love that painting! So cool you found such a helpful group. What's it done for you?" - }, - { - "speaker": "Caroline", - "dia_id": "D1:7", - "text": "The support group has made me feel accepted and given me courage to embrace myself." - }, - { - "speaker": "Melanie", - "dia_id": "D1:8", - "text": "That's really cool. You've got guts. What now?" - }, - { - "speaker": "Caroline", - "dia_id": "D1:9", - "text": "Gonna continue my edu and check out career options, which is pretty exciting!" - }, - { - "speaker": "Melanie", - "dia_id": "D1:10", - "text": "Wow, Caroline! What kinda jobs are you thinkin' of? Anything that stands out?" - }, - { - "speaker": "Caroline", - "dia_id": "D1:11", - "text": "I'm keen on counseling or working in mental health - I'd love to support those with similar issues." - }, - { - "speaker": "Melanie", - "img_url": [ - "http://candicealexander.com/cdn/shop/products/IMG_7269_a49d5af8-c76c-4ecd-ae20-48c08cb11dec.jpg" - ], - "blip_caption": "a photo of a painting of a sunset over a lake", - "query": "painting sunrise", - "dia_id": "D1:12", - "text": "You'd be a great counselor! Your empathy and understanding will really help the people you work with. By the way, take a look at this." - }, - { - "speaker": "Caroline", - "dia_id": "D1:13", - "text": "Thanks, Melanie! That's really sweet. Is this your own painting?" - }, - { - "speaker": "Melanie", - "dia_id": "D1:14", - "text": "Yeah, I painted that lake sunrise last year! It's special to me." - }, - { - "speaker": "Caroline", - "dia_id": "D1:15", - "text": "Wow, Melanie! The colors really blend nicely. Painting looks like a great outlet for expressing yourself." - }, - { - "speaker": "Melanie", - "dia_id": "D1:16", - "text": "Thanks, Caroline! Painting's a fun way to express my feelings and get creative. It's a great way to relax after a long day." - }, - { - "speaker": "Caroline", - "dia_id": "D1:17", - "text": "Totally agree, Mel. Relaxing and expressing ourselves is key. Well, I'm off to go do some research." - }, - { - "speaker": "Melanie", - "dia_id": "D1:18", - "text": "Yep, Caroline. Taking care of ourselves is vital. I'm off to go swimming with the kids. Talk to you soon!" - } - ], - "session_2_date_time": "1:14 pm on 25 May, 2023", - "session_2": [ - { - "speaker": "Melanie", - "dia_id": "D2:1", - "text": "Hey Caroline, since we last chatted, I've had a lot of things happening to me. I ran a charity race for mental health last Saturday \u2013 it was really rewarding. Really made me think about taking care of our minds." - }, - { - "speaker": "Caroline", - "dia_id": "D2:2", - "text": "That charity race sounds great, Mel! Making a difference & raising awareness for mental health is super rewarding - I'm really proud of you for taking part!" - }, - { - "speaker": "Melanie", - "dia_id": "D2:3", - "text": "Thanks, Caroline! The event was really thought-provoking. I'm starting to realize that self-care is really important. It's a journey for me, but when I look after myself, I'm able to better look after my family." - }, - { - "speaker": "Caroline", - "dia_id": "D2:4", - "text": "I totally agree, Melanie. Taking care of ourselves is so important - even if it's not always easy. Great that you're prioritizing self-care." - }, - { - "speaker": "Melanie", - "dia_id": "D2:5", - "text": "Yeah, it's tough. So I'm carving out some me-time each day - running, reading, or playing my violin - which refreshes me and helps me stay present for my fam!" - }, - { - "speaker": "Caroline", - "dia_id": "D2:6", - "text": "That's great, Mel! Taking time for yourself is so important. You're doing an awesome job looking after yourself and your family!" - }, - { - "speaker": "Melanie", - "dia_id": "D2:7", - "text": "Thanks, Caroline. It's still a work in progress, but I'm doing my best. My kids are so excited about summer break! We're thinking about going camping next month. Any fun plans for the summer?" - }, - { - "speaker": "Caroline", - "dia_id": "D2:8", - "text": "Researching adoption agencies \u2014 it's been a dream to have a family and give a loving home to kids who need it." - }, - { - "speaker": "Melanie", - "dia_id": "D2:9", - "text": "Wow, Caroline! That's awesome! Taking in kids in need - you're so kind. Your future family is gonna be so lucky to have you!" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://live.staticflickr.com/3437/3935231341_b2955b00dd_b.jpg" - ], - "blip_caption": "a photography of a sign for a new arrival and an information and domestic building", - "query": "adoption agency brochure", - "dia_id": "D2:10", - "re-download": true, - "text": "Thanks, Mel! My goal is to give kids a loving home. I'm truly grateful for all the support I've got from friends and mentors. Now the hard work starts to turn my dream into a reality. And here's one of the adoption agencies I'm looking into. It's a lot to take in, but I'm feeling hopeful and optimistic." - }, - { - "speaker": "Melanie", - "dia_id": "D2:11", - "text": "Wow, that agency looks great! What made you pick it?" - }, - { - "speaker": "Caroline", - "dia_id": "D2:12", - "text": "I chose them 'cause they help LGBTQ+ folks with adoption. Their inclusivity and support really spoke to me." - }, - { - "speaker": "Melanie", - "dia_id": "D2:13", - "text": "That's great, Caroline! Loving the inclusivity and support. Anything you're excited for in the adoption process?" - }, - { - "speaker": "Caroline", - "dia_id": "D2:14", - "text": "I'm thrilled to make a family for kids who need one. It'll be tough as a single parent, but I'm up for the challenge!" - }, - { - "speaker": "Melanie", - "dia_id": "D2:15", - "text": "You're doing something amazing! Creating a family for those kids is so lovely. You'll be an awesome mom! Good luck!" - }, - { - "speaker": "Caroline", - "dia_id": "D2:16", - "text": "Thanks, Melanie! Your kind words really mean a lot. I'll do my best to make sure these kids have a safe and loving home." - }, - { - "speaker": "Melanie", - "dia_id": "D2:17", - "text": "No doubts, Caroline. You have such a caring heart - they'll get all the love and stability they need! Excited for this new chapter!" - } - ], - "session_3_date_time": "7:55 pm on 9 June, 2023", - "session_3": [ - { - "speaker": "Caroline", - "dia_id": "D3:1", - "text": "Hey Melanie! How's it going? I wanted to tell you about my school event last week. It was awesome! I talked about my transgender journey and encouraged students to get involved in the LGBTQ community. It was great to see their reactions. It made me reflect on how far I've come since I started transitioning three years ago." - }, - { - "speaker": "Melanie", - "dia_id": "D3:2", - "text": "Hey Caroline! Great to hear from you. Sounds like your event was amazing! I'm so proud of you for spreading awareness and getting others involved in the LGBTQ community. You've come a long way since your transition - keep on inspiring people with your strength and courage!" - }, - { - "speaker": "Caroline", - "dia_id": "D3:3", - "text": "Thanks, Mel! Your backing really means a lot. I felt super powerful giving my talk. I shared my own journey, the struggles I had and how much I've developed since coming out. It was wonderful to see how the audience related to what I said and how it inspired them to be better allies. Conversations about gender identity and inclusion are so necessary and I'm thankful for being able to give a voice to the trans community." - }, - { - "speaker": "Melanie", - "dia_id": "D3:4", - "text": "Wow, Caroline, you're doing an awesome job of inspiring others with your journey. It's great to be part of it and see how you're positively affecting so many. Talking about inclusivity and acceptance is crucial, and you're so brave to speak up for the trans community. Keep up the great work!" - }, - { - "speaker": "Caroline", - "dia_id": "D3:5", - "text": "Thanks Mel! Your kind words mean a lot. Sharing our experiences isn't always easy, but I feel it's important to help promote understanding and acceptance. I've been blessed with loads of love and support throughout this journey, and I want to pass it on to others. By sharing our stories, we can build a strong, supportive community of hope." - }, - { - "speaker": "Melanie", - "dia_id": "D3:6", - "text": "Yeah, Caroline! It takes courage to talk about our own stories. But it's in these vulnerable moments that we bond and understand each other. We all have our different paths, but if we share them, we show people that they're not alone. Our stories can be so inspiring and encouraging to others who are facing the same challenges. Thank you for using your voice to create love, acceptance, and hope. You're doing amazing!" - }, - { - "speaker": "Caroline", - "dia_id": "D3:7", - "text": "Your words mean a lot to me. I'm grateful for the chance to share my story and give others hope. We all have unique paths, and by working together we can build a more inclusive and understanding world. I'm going to keep using my voice to make a change and lift others up. And you're part of that!" - }, - { - "speaker": "Melanie", - "dia_id": "D3:8", - "text": "Thanks, Caroline, for letting me join your journey. I'm so proud to be part of the difference you're making. Let's keep motivating and helping each other out as we journey through life. We can make a real impact together!" - }, - { - "speaker": "Caroline", - "dia_id": "D3:9", - "text": "Yeah Mel, let's spread love and understanding! Thanks for the support and encouragement. We can tackle life's challenges together! We got this!" - }, - { - "speaker": "Melanie", - "dia_id": "D3:10", - "text": "Yes, Caroline! We can do it. Your courage is inspiring. I want to be couragous for my family- they motivate me and give me love. What motivates you?" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://fox2now.com/wp-content/uploads/sites/14/2023/08/that-tall-family.jpg" - ], - "blip_caption": "a photo of a family posing for a picture in a yard", - "query": "group of friends and family", - "dia_id": "D3:11", - "text": "Thanks, Mel! My friends, family and mentors are my rocks \u2013 they motivate me and give me the strength to push on. Here's a pic from when we met up last week!" - }, - { - "speaker": "Melanie", - "dia_id": "D3:12", - "text": "Wow, that photo is great! How long have you had such a great support system?" - }, - { - "speaker": "Caroline", - "dia_id": "D3:13", - "text": "Yeah, I'm really lucky to have them. They've been there through everything, I've known these friends for 4 years, since I moved from my home country. Their love and help have been so important especially after that tough breakup. I'm super thankful. Who supports you, Mel?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://mrswebersneighborhood.com/wp-content/uploads/2022/07/Cedar-Falls-Hocking-Hills.jpg" - ], - "blip_caption": "a photo of a man and a little girl standing in front of a waterfall", - "query": "husband kids hiking nature", - "dia_id": "D3:14", - "text": "I'm lucky to have my husband and kids; they keep me motivated." - }, - { - "speaker": "Caroline", - "dia_id": "D3:15", - "text": "Wow, what an amazing family pic! How long have you been married?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://i.redd.it/8o28nfllf3eb1.jpg" - ], - "blip_caption": "a photo of a bride in a wedding dress holding a bouquet", - "query": "wedding day", - "dia_id": "D3:16", - "text": "5 years already! Time flies- feels like just yesterday I put this dress on! Thanks, Caroline!" - }, - { - "speaker": "Caroline", - "dia_id": "D3:17", - "text": "Congrats, Melanie! You both looked so great on your wedding day! Wishing you many happy years together!" - }, - { - "speaker": "Melanie", - "img_url": [ - "http://shirleyswardrobe.com/wp-content/uploads/2017/07/LF-Picnic-6.jpg" - ], - "blip_caption": "a photo of a man and woman sitting on a blanket eating food", - "query": "family picnic park laughing", - "dia_id": "D3:18", - "text": "Thanks, Caroline! Appreciate your kind words. Looking forward to more happy years. Our family and moments make it all worth it." - }, - { - "speaker": "Caroline", - "dia_id": "D3:19", - "text": "Looks like you had a great day! How was it? You all look so happy!" - }, - { - "speaker": "Melanie", - "dia_id": "D3:20", - "text": "It so fun! We played games, ate good food, and just hung out together. Family moments make life awesome." - }, - { - "speaker": "Caroline", - "dia_id": "D3:21", - "text": "Sounds great, Mel! Glad you had a great time. Cherish the moments - they're the best!" - }, - { - "speaker": "Melanie", - "dia_id": "D3:22", - "text": "Absolutely, Caroline! I cherish time with family. It's when I really feel alive and happy." - }, - { - "speaker": "Caroline", - "dia_id": "D3:23", - "text": "I 100% agree, Mel. Hanging with loved ones is amazing and brings so much happiness. Those moments really make me thankful. Family is everything." - } - ], - "session_4_date_time": "10:37 am on 27 June, 2023", - "session_4": [ - { - "speaker": "Caroline", - "img_url": [ - "https://i.redd.it/67uas3gnmz7b1.jpg" - ], - "blip_caption": "a photo of a person holding a necklace with a cross and a heart", - "query": "pendant transgender symbol", - "dia_id": "D4:1", - "text": "Hey Melanie! Long time no talk! A lot's been going on in my life! Take a look at this." - }, - { - "speaker": "Melanie", - "dia_id": "D4:2", - "text": "Hey, Caroline! Nice to hear from you! Love the necklace, any special meaning to it?" - }, - { - "speaker": "Caroline", - "dia_id": "D4:3", - "text": "Thanks, Melanie! This necklace is super special to me - a gift from my grandma in my home country, Sweden. She gave it to me when I was young, and it stands for love, faith and strength. It's like a reminder of my roots and all the love and support I get from my family." - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a stack of bowls with different designs on them", - "dia_id": "D4:4", - "text": "That's gorgeous, Caroline! It's awesome what items can mean so much to us, right? Got any other objects that you treasure, like that necklace?" - }, - { - "speaker": "Caroline", - "dia_id": "D4:5", - "text": "Yep, Melanie! I've got some other stuff with sentimental value, like my hand-painted bowl. A friend made it for my 18th birthday ten years ago. The pattern and colors are awesome-- it reminds me of art and self-expression." - }, - { - "speaker": "Melanie", - "dia_id": "D4:6", - "text": "That sounds great, Caroline! It's awesome having stuff around that make us think of good connections and times. Actually, I just took my fam camping in the mountains last week - it was a really nice time together!" - }, - { - "speaker": "Caroline", - "dia_id": "D4:7", - "text": "Sounds great, Mel. Glad you made some new family mems. How was it? Anything fun?" - }, - { - "speaker": "Melanie", - "dia_id": "D4:8", - "text": "It was an awesome time, Caroline! We explored nature, roasted marshmallows around the campfire and even went on a hike. The view from the top was amazing! The 2 younger kids love nature. It was so special having these moments together as a family - I'll never forget it!" - }, - { - "speaker": "Caroline", - "dia_id": "D4:9", - "text": "That's awesome, Melanie! Family moments like that are so special. Glad y'all had such a great time." - }, - { - "speaker": "Melanie", - "dia_id": "D4:10", - "text": "Thanks, Caroline! Family time matters to me. What's up with you lately?" - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a book shelf with many books on it", - "dia_id": "D4:11", - "text": "Lately, I've been looking into counseling and mental health as a career. I want to help people who have gone through the same things as me." - }, - { - "speaker": "Melanie", - "dia_id": "D4:12", - "text": "Sounds great! What kind of counseling and mental health services do you want to persue?" - }, - { - "speaker": "Caroline", - "dia_id": "D4:13", - "text": "I'm still figuring out the details, but I'm thinking of working with trans people, helping them accept themselves and supporting their mental health. Last Friday, I went to an LGBTQ+ counseling workshop and it was really enlightening. They talked about different therapeutic methods and how to best work with trans people. Seeing how passionate these pros were about making a safe space for people like me was amazing." - }, - { - "speaker": "Melanie", - "dia_id": "D4:14", - "text": "Woah, Caroline, it sounds like you're doing some impressive work. It's inspiring to see your dedication to helping others. What motivated you to pursue counseling?" - }, - { - "speaker": "Caroline", - "dia_id": "D4:15", - "text": "Thanks, Melanie. It really mattered. My own journey and the support I got made a huge difference. Now I want to help people go through it too. I saw how counseling and support groups improved my life, so I started caring more about mental health and understanding myself. Now I'm passionate about creating a safe, inviting place for people to grow." - }, - { - "speaker": "Melanie", - "dia_id": "D4:16", - "text": "Wow, Caroline! You've gained so much from your own experience. Your passion and hard work to help others is awesome. Keep it up, you're making a big impact!" - }, - { - "speaker": "Caroline", - "dia_id": "D4:17", - "text": "Thanks, Melanie! Your kind words mean a lot." - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a book shelf filled with books in a room", - "dia_id": "D4:18", - "text": "Congrats Caroline! Good on you for going after what you really care about." - } - ], - "session_5_date_time": "1:36 pm on 3 July, 2023", - "session_5": [ - { - "speaker": "Caroline", - "dia_id": "D5:1", - "text": "Since we last spoke, some big things have happened. Last week I went to an LGBTQ+ pride parade. Everyone was so happy and it made me feel like I belonged. It showed me how much our community has grown, it was amazing!" - }, - { - "speaker": "Melanie", - "dia_id": "D5:2", - "text": "Wow, Caroline, sounds like the parade was an awesome experience! It's great to see the love and support for the LGBTQ+ community. Congrats! Has this experience influenced your goals at all?" - }, - { - "speaker": "Caroline", - "dia_id": "D5:3", - "text": "Thanks, Mel! It really motivated me for sure. Talking to the community made me want to use my story to help others too - I'm still thinking that counseling and mental health is the way to go. I'm super excited to give back. " - }, - { - "speaker": "Melanie", - "img_url": [ - "https://m.media-amazon.com/images/I/A1uELSr5rgL.jpg" - ], - "blip_caption": "a photo of a person holding a frisbee in their hand", - "query": "family frisbee game", - "dia_id": "D5:4", - "text": "Wow, Caroline! That's great! I just signed up for a pottery class yesterday. It's like therapy for me, letting me express myself and get creative. Have you found any activities that make you feel the same way?" - }, - { - "speaker": "Caroline", - "dia_id": "D5:5", - "text": "Wow, Melanie! I'm getting creative too, just learning the piano. What made you try pottery?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://therusticbarnct.com/cdn/shop/files/image_05483f46-4845-433b-a4cf-0fc61fe1aa79.jpg" - ], - "blip_caption": "a photo of a bowl with a black and white flower design", - "query": "pottery painted bowl intricate design", - "dia_id": "D5:6", - "text": "I'm a big fan of pottery - the creativity and skill is awesome. Plus, making it is so calming. Look at this!" - }, - { - "speaker": "Caroline", - "dia_id": "D5:7", - "text": "That bowl is gorgeous! The black and white design looks so fancy. Did you make it?" - }, - { - "speaker": "Melanie", - "dia_id": "D5:8", - "text": "Thanks, Caroline! Yeah, I made this bowl in my class. It took some work, but I'm pretty proud of it." - }, - { - "speaker": "Caroline", - "dia_id": "D5:9", - "text": "Nice job! You really put in the work and it definitely shows. Your creativity looks great!" - }, - { - "speaker": "Melanie", - "dia_id": "D5:10", - "text": "Thanks, Caroline! Your kind words mean a lot. Pottery is a huge part of my life, not just a hobby - it helps me express my emotions. Clay is incredible, it brings me so much joy!" - }, - { - "speaker": "Caroline", - "dia_id": "D5:11", - "text": "Wow, Mel, I'm so stoked for you that art is helping you express yourself and bring you joy! Keep it up!" - }, - { - "speaker": "Melanie", - "dia_id": "D5:12", - "text": "Thanks, Caroline! I'm excited to see where pottery takes me. Anything coming up you're looking forward to?" - }, - { - "speaker": "Caroline", - "dia_id": "D5:13", - "text": "Thanks Mel! I'm going to a transgender conference this month. I'm so excited to meet other people in the community and learn more about advocacy. It's gonna be great!" - }, - { - "speaker": "Melanie", - "dia_id": "D5:14", - "text": "Sounds awesome, Caroline! Have a great time and learn a lot. Have fun!" - }, - { - "speaker": "Caroline", - "dia_id": "D5:15", - "text": "Cool, thanks Mel! Can't wait. I'll keep ya posted. Bye!" - }, - { - "speaker": "Melanie", - "dia_id": "D5:16", - "text": "Bye, Caroline! Can't wait to hear about it. Have fun and stay safe!" - } - ], - "session_6_date_time": "8:18 pm on 6 July, 2023", - "session_6": [ - { - "speaker": "Caroline", - "dia_id": "D6:1", - "text": "Hey Mel! Long time no talk. Lots has been going on since then!" - }, - { - "speaker": "Melanie", - "dia_id": "D6:2", - "text": "Hey Caroline! Missed you. Anything new? Spill the beans!" - }, - { - "speaker": "Caroline", - "dia_id": "D6:3", - "text": "Since our last chat, I've been looking into counseling or mental health work more. I'm passionate about helping people and making a positive impact. It's tough, but really rewarding too. Anything new happening with you?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://live.staticflickr.com/3201/2867258131_2d8bc22859_b.jpg" - ], - "blip_caption": "a photography of two children playing in a water play area", - "query": "kids laughing dinosaur exhibit museum", - "dia_id": "D6:4", - "re-download": true, - "text": "That's awesome, Caroline! Congrats on following your dreams. Yesterday I took the kids to the museum - it was so cool spending time with them and seeing their eyes light up!" - }, - { - "speaker": "Caroline", - "dia_id": "D6:5", - "text": "Melanie, that's a great pic! That must have been awesome. What were they so stoked about?" - }, - { - "speaker": "Melanie", - "dia_id": "D6:6", - "text": "They were stoked for the dinosaur exhibit! They love learning about animals and the bones were so cool. It reminds me why I love being a mom." - }, - { - "speaker": "Caroline", - "img_url": [ - "https://i.pinimg.com/originals/02/94/c3/0294c3460b66d1fd50530e4bd5a2e1f5.jpg" - ], - "blip_caption": "a photo of a bookcase filled with books and toys", - "query": "bookshelf childrens books library", - "dia_id": "D6:7", - "text": "Being a mom is awesome. I'm creating a library for when I have kids. I'm really looking forward to reading to them and opening up their minds." - }, - { - "speaker": "Melanie", - "dia_id": "D6:8", - "text": "Sounds great! What kind of books you got in your library?" - }, - { - "speaker": "Caroline", - "dia_id": "D6:9", - "text": "I've got lots of kids' books- classics, stories from different cultures, educational books, all of that. What's a favorite book you remember from your childhood?" - }, - { - "speaker": "Melanie", - "img_url": [ - "http://bookworm-detective.myshopify.com/cdn/shop/products/PXL_20210428_222022427.jpg" - ], - "blip_caption": "a photo of a book cover with a picture of a girl and a cat", - "query": "charlotte's web book", - "dia_id": "D6:10", - "text": "I loved reading \"Charlotte's Web\" as a kid. It was so cool seeing how friendship and compassion can make a difference." - }, - { - "speaker": "Caroline", - "img_url": [ - "https://i.pinimg.com/originals/41/d5/60/41d5601e4ab0959ce5e29683a2660938.jpg" - ], - "blip_caption": "a photo of a group of women sitting on a blanket in a park", - "query": "group friends picnic", - "dia_id": "D6:11", - "text": "Wow, that's great! It sure shows how important friendship and compassion are. It's made me appreciate how lucky I am to have my friends and family helping with my transition. They make all the difference. We even had a picnic last week!" - }, - { - "speaker": "Melanie", - "dia_id": "D6:12", - "text": "That's a gorgeous photo, Caroline! Wow, the love around you is awesome. How have your friends and fam been helping you out with your transition?" - }, - { - "speaker": "Caroline", - "dia_id": "D6:13", - "text": "Thanks, Melanie! This support network has been amazing. They've been there for me every step of the way giving me love, guidance, and acceptance. I couldn't have done it without them." - }, - { - "speaker": "Melanie", - "dia_id": "D6:14", - "text": "Wow, Caroline! It's great you have people to support you, that's really awesome!" - }, - { - "speaker": "Caroline", - "dia_id": "D6:15", - "text": "I'm so lucky to have such a great support system around me. Their love and encouragement has really helped me accept and grow into my true self. They've been instrumental in my transition." - }, - { - "speaker": "Melanie", - "img_url": [ - "https://i.redd.it/ye1cp24b18w01.jpg" - ], - "blip_caption": "a photo of a family sitting around a campfire on the beach", - "query": "family campfire", - "dia_id": "D6:16", - "text": "Glad you have support, Caroline! Unconditional love is so important. Here's a pic of my family camping at the beach. We love it, it brings us closer!" - } - ], - "session_7_date_time": "4:33 pm on 12 July, 2023", - "session_7": [ - { - "speaker": "Caroline", - "dia_id": "D7:1", - "text": "Hey Mel, great to chat with you again! So much has happened since we last spoke - I went to an LGBTQ conference two days ago and it was really special. I got the chance to meet and connect with people who've gone through similar journeys. It was such a welcoming environment and I felt totally accepted. I'm really thankful for this amazing community - it's shown me how important it is to fight for trans rights and spread awareness." - }, - { - "speaker": "Melanie", - "dia_id": "D7:2", - "text": "Wow, Caroline, that sounds awesome! So glad you felt accepted and supported. Events like these are great for reminding us of how strong community can be!" - }, - { - "speaker": "Caroline", - "dia_id": "D7:3", - "text": "Yeah, it's true! Having people who back you makes such a huge difference. It's great to see how far LGBTQ rights have come, but there's still plenty of progress to be made. I wanna help make a difference." - }, - { - "speaker": "Melanie", - "dia_id": "D7:4", - "text": "Wow, Caroline. We've come so far, but there's more to do. Your drive to help is awesome! What's your plan to pitch in?" - }, - { - "speaker": "Caroline", - "dia_id": "D7:5", - "text": "Thanks, Mell! I'm still looking into counseling and mental health jobs. It's important to me that people have someone to talk to, and I want to help make that happen." - }, - { - "speaker": "Melanie", - "dia_id": "D7:6", - "text": "Wow, Caroline! You're so inspiring for wanting to help others with their mental health. What's pushing you to keep going forward with it?" - }, - { - "speaker": "Caroline", - "dia_id": "D7:7", - "text": "I struggled with mental health, and support I got was really helpful. It made me realize how important it is for others to have a support system. So, I started looking into counseling and mental health career options, so I could help other people on their own journeys like I was helped." - }, - { - "speaker": "Melanie", - "img_url": [ - "https://www.speakers.co.uk/microsites/tom-oliver/wp-content/uploads/2014/11/Book-Cover-3D1.jpg" - ], - "blip_caption": "a photography of a book cover with a gold coin on it called 'Nothing is Impossible'", - "query": "painted canvas follow your dreams", - "dia_id": "D7:8", - "re-download": true, - "text": "Caroline, so glad you got the support! Your experience really brought you to where you need to be. You're gonna make a huge difference! This book I read last year reminds me to always pursue my dreams, just like you are doing!\ud83c\udf1f" - }, - { - "speaker": "Caroline", - "dia_id": "D7:9", - "text": "Thanks so much, Mel! Seeing this pic just made me appreciate my love of reading even more. Books guide me, motivate me and help me discover who I am. They're a huge part of my journey, and this one's reminding me to keep going and never give up!" - }, - { - "speaker": "Melanie", - "dia_id": "D7:10", - "text": "Wow, Caroline! Books have such an awesome power! Which one has been your favorite guide?" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://m.media-amazon.com/images/I/A1CPpaLFR2L.jpg" - ], - "blip_caption": "a photo of a dog sitting in a boat on the water", - "query": "becoming nicole book amy ellis nutt", - "dia_id": "D7:11", - "text": "I loved \"Becoming Nicole\" by Amy Ellis Nutt. It's a real inspiring true story about a trans girl and her family. It made me feel connected and gave me a lot of hope for my own path. Highly recommend it for sure!" - }, - { - "speaker": "Melanie", - "dia_id": "D7:12", - "text": "That sounds awesome! What did you take away from it to use in your life?" - }, - { - "speaker": "Caroline", - "dia_id": "D7:13", - "text": "It taught me self-acceptance and how to find support. It also showed me that tough times don't last - hope and love exist. Pets bring so much joy too, though." - }, - { - "speaker": "Melanie", - "img_url": [ - "https://st3.depositphotos.com/12674628/16006/i/1600/depositphotos_160060676-stock-photo-multiethnic-girls-with-puppy.jpg" - ], - "blip_caption": "a photography of two little girls sitting on the steps with a dog", - "query": "daughters playing with pet dog backyard", - "dia_id": "D7:14", - "re-download": true, - "text": "Caroline, those lessons are great - self-acceptance and finding support are key. Plus pets are awesome for joy and comfort, can't agree more! " - }, - { - "speaker": "Caroline", - "dia_id": "D7:15", - "text": "That's so nice! What pet do you have?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://i.redd.it/u26t78f0idd91.jpg" - ], - "blip_caption": "a photo of a cat laying on the floor with its head on the floor", - "query": "dog cat kids playing joy", - "dia_id": "D7:16", - "text": "We've got a pup and a kitty. That's the dog, and here's our cat! They brighten up our day and always make us smile." - }, - { - "speaker": "Caroline", - "dia_id": "D7:17", - "text": "Ah, they're adorable! What are their names? Pets sure do bring so much joy to us!" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://blogger.googleusercontent.com/img/b/R29vZ2xl/AVvXsEhwGRNI2jDkrALJHgL2LWfW2rUGhN-GA4OL_gXU2fHPyxtst2MPrv9hkyOMdpj5SppLNYiQrcXUUq90vv5es8ueswy2tuu0Lqa2lh2vKOfDZ5SXSdLVMVvBrfLbFJG19QiqDbv1xs38fv-atd4MYOesJ4c89sQTzv6k93PDQ5T0dwVJV9O2FF95woyP3Q/s4032/IMG_9747.jpg" - ], - "blip_caption": "a photo of a person wearing pink sneakers on a white rug", - "query": "purple running shoe", - "dia_id": "D7:18", - "text": "Luna and Oliver! They are so sweet and playful - they really liven up the house! Just got some new shoes, too!" - }, - { - "speaker": "Caroline", - "dia_id": "D7:19", - "text": "Love that purple color! For walking or running?" - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a pair of pink sneakers in a box", - "dia_id": "D7:20", - "text": "Thanks, Caroline! These are for running. Been running longer since our last chat - a great way to destress and clear my mind." - }, - { - "speaker": "Caroline", - "dia_id": "D7:21", - "text": "Wow! What got you into running?" - }, - { - "speaker": "Melanie", - "dia_id": "D7:22", - "text": "I've been running farther to de-stress, which has been great for my headspace." - }, - { - "speaker": "Caroline", - "dia_id": "D7:23", - "text": "Cool, Melanie! Running can really boost your mood. Keep it up!" - }, - { - "speaker": "Melanie", - "dia_id": "D7:24", - "text": "Thanks, Caroline! This has been great for my mental health. I'm gonna keep it up." - }, - { - "speaker": "Caroline", - "dia_id": "D7:25", - "text": "Awesome, Melanie! Mental health's a priority, so make sure you take care of yourself." - }, - { - "speaker": "Melanie", - "dia_id": "D7:26", - "text": "Caroline, thanks! Mental health is important to me, and it's made such an improvement!" - }, - { - "speaker": "Caroline", - "dia_id": "D7:27", - "text": "Glad it helped ya, Melanie!" - } - ], - "session_8_date_time": "1:51 pm on 15 July, 2023", - "session_8": [ - { - "speaker": "Caroline", - "dia_id": "D8:1", - "text": "Hey Mel, what's up? Been a busy week since we talked." - }, - { - "speaker": "Melanie", - "img_url": [ - "https://images.rawpixel.com/image_social_landscape/cHJpdmF0ZS9sci9pbWFnZXMvd2Vic2l0ZS8yMDIyLTExL2ZsNDg2NDgxOTYyMDMtaW1hZ2UuanBn.jpg" - ], - "blip_caption": "a photography of a group of children making clay sculptures in a classroom", - "query": "pottery workshop family making clay pots", - "dia_id": "D8:2", - "re-download": true, - "text": "Hey Caroline, it's been super busy here. So much since we talked! Last Fri I finally took my kids to a pottery workshop. We all made our own pots, it was fun and therapeutic!" - }, - { - "speaker": "Caroline", - "dia_id": "D8:3", - "text": "Wow, Mel! Sounds like you and the kids had a blast. How'd they like it?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://monstermonster.shop/cdn/shop/products/mug-class_5000x.jpg" - ], - "blip_caption": "a photo of a cup with a dog face on it", - "query": "kids pottery finished pieces", - "dia_id": "D8:4", - "text": "The kids loved it! They were so excited to get their hands dirty and make something with clay. It was special to watch their creativity and imagination come to life, they made this!" - }, - { - "speaker": "Caroline", - "dia_id": "D8:5", - "text": "Aww, that's so sweet! That cup is so cute. It's awesome to see how kids show their personalities through art. What other creative projects do you do with them, besides pottery?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://i.pinimg.com/originals/ea/d9/d7/ead9d79b58ca80a38a744b5ab70482db.jpg" - ], - "blip_caption": "a photo of a painting of a sunset with a palm tree", - "query": "painting vibrant flowers sunset sky", - "dia_id": "D8:6", - "text": "We love painting together lately, especially nature-inspired ones. Here's our latest work from last weekend." - }, - { - "speaker": "Caroline", - "dia_id": "D8:7", - "text": "Wow Mel, that painting's amazing! The colors are so bold and it really highlights the beauty of nature. Y'all work on it together?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://karengimson.files.wordpress.com/2017/06/img_7222.jpg" - ], - "blip_caption": "a photo of a field of purple flowers with green leaves", - "query": "path lined purple flowers nature", - "dia_id": "D8:8", - "text": "Thanks, Caroline! We both helped with the painting - it was great bonding over it and chatting about nature. We found these lovely flowers. Appreciating the small things in life, too." - }, - { - "speaker": "Caroline", - "dia_id": "D8:9", - "text": "That photo is stunning! So glad you bonded over our love of nature. Last Friday I went to a council meeting for adoption. It was inspiring and emotional - so many people wanted to create loving homes for children in need. It made me even more determined to adopt." - }, - { - "speaker": "Melanie", - "img_url": [ - "https://assets.eflorist.com/assets/products/PHR_/TEV57-5A.jpg" - ], - "blip_caption": "a photo of a blue vase with a bouquet of sunflowers and roses", - "query": "sunflower bouquet", - "dia_id": "D8:10", - "text": "Wow, Caroline, way to go! Your future fam will get a kick out of having you. What do you think of these?" - }, - { - "speaker": "Caroline", - "dia_id": "D8:11", - "text": "Thanks Melanie - love the blue vase in the pic! Blue's my fave, it makes me feel relaxed. Sunflowers mean warmth and happiness, right? While roses stand for love and beauty? That's neat. What do flowers mean to you?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://blueblossomrentals.com/cdn/shop/products/image_909fb96b-4208-429b-9a6f-59dffa3cb546.jpg" - ], - "blip_caption": "a photo of a row of white chairs with flowers on them", - "query": "garden full of flowers wedding decorations", - "dia_id": "D8:12", - "text": "Flowers bring joy. They represent growth, beauty and reminding us to appreciate the small moments. They were an important part of my wedding decor and always remind me of that day." - }, - { - "speaker": "Caroline", - "dia_id": "D8:13", - "text": "It must have been special at your wedding. I wish I had known you back then!" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://platinumnotary.files.wordpress.com/2023/03/img-6679.jpg" - ], - "blip_caption": "a photo of a wedding ceremony in a greenhouse with people taking pictures", - "query": "wedding ceremony", - "dia_id": "D8:14", - "text": "It was amazing, Caroline. The day was full of love and joy. Everyone we love was there to celebrate us - it was really special." - }, - { - "speaker": "Caroline", - "dia_id": "D8:15", - "text": "Wow, what a great day! Glad everyone could make it. What was your favorite part?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://s3-us-west-2.amazonaws.com/amm-prod/wedding_photos/photos/000/024/198/original/4B873921-0596-4A6B-8CD8-C6E5C2B024AF.png" - ], - "blip_caption": "a photo of a man and woman standing on a beach", - "query": "vows partner holding hands ceremony", - "dia_id": "D8:16", - "text": "Marrying my partner and promising to be together forever was the best part." - }, - { - "speaker": "Caroline", - "img_url": [ - "https://dynaimage.cdn.cnn.com/cnn/digital-images/org/dfc95f14-b325-431c-b977-5b6dc2d35f9c.jpg" - ], - "blip_caption": "a photo of a parade with people walking down the street", - "query": "rainbow flag pride march", - "dia_id": "D8:17", - "text": "Wow, nice pic! You both looked amazing. One special memory for me was this pride parade I went to a few weeks ago." - }, - { - "speaker": "Melanie", - "dia_id": "D8:18", - "text": "Wow, looks awesome! Did you join in?" - }, - { - "speaker": "Caroline", - "img_url": [ - "http://ninalemsparty.com/cdn/shop/collections/iStock-1292280203.jpg" - ], - "blip_caption": "a photo of a group of people holding up signs and smiling", - "query": "lgbtq+ pride parade vibrant flags smiling faces", - "dia_id": "D8:19", - "text": "Yes, I did. It was amazing! I felt so accepted and happy, just being around people who accepted and celebrated me. It's definitely a top memory." - }, - { - "speaker": "Melanie", - "dia_id": "D8:20", - "text": "Wow, what an experience! How did it make you feel?" - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a rainbow flag on a pole on a carpet", - "dia_id": "D8:21", - "text": "I felt so proud and grateful - the vibes were amazing and it was comforting to know I'm not alone and have a great community around me." - }, - { - "speaker": "Melanie", - "dia_id": "D8:22", - "text": "Wow, Caroline! That's huge! How did it feel to be around so much love and acceptance?" - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a group of people sitting on the ground with a dog", - "dia_id": "D8:23", - "text": "It was awesome, Melanie! Being around people who embrace and back me up is beyond words. It really inspired me." - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a girl sitting in a teepee with stuffed animals", - "dia_id": "D8:24", - "text": "Wow, that sounds awesome! Your friends and community really have your back. What's been the best part of it?" - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a teepee with a teddy bear and pillows", - "dia_id": "D8:25", - "text": "Realizing I can be me without fear and having the courage to transition was the best part. It's so freeing to express myself authentically and have people back me up." - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a buddha statue and a candle on a table", - "dia_id": "D8:26", - "text": "That's awesome, Caro! You've found the courage to be yourself - that's important for our mental health and finding peace." - }, - { - "speaker": "Caroline", - "dia_id": "D8:27", - "text": "Thanks, Melanie! Been a long road, but I'm proud of how far I've come. How're you doing finding peace?" - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a man holding a frisbee in front of a frisbee golf basket", - "dia_id": "D8:28", - "text": "I'm getting there, Caroline. Creativity and family keep me at peace." - }, - { - "speaker": "Caroline", - "dia_id": "D8:29", - "text": "That's awesome, Melanie! How have your family been supportive during your move?" - }, - { - "speaker": "Melanie", - "dia_id": "D8:30", - "text": "My fam's been awesome - they helped out and showed lots of love and support." - }, - { - "speaker": "Caroline", - "dia_id": "D8:31", - "text": "Wow, Mel, family love and support is the best!" - }, - { - "speaker": "Melanie", - "img_url": [ - "http://cragmama.com/wp-content/uploads//2016/10/IMG_4568.jpg" - ], - "blip_caption": "a photo of a man and two children sitting around a campfire", - "query": "family camping trip roasting marshmallows campfire", - "dia_id": "D8:32", - "text": "Yeah, Caroline, my family's been great - their love and support really helped me through tough times. It's awesome! We even went on another camping trip in the forest." - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a family walking through a forest with a toddler", - "dia_id": "D8:33", - "text": "Awesome, Mel! Family support's huge. What else do you guys like doing together?" - }, - { - "speaker": "Melanie", - "dia_id": "D8:34", - "text": "We enjoy hiking in the mountains and exploring forests. It's a cool way to connect with nature and each other." - }, - { - "speaker": "Caroline", - "dia_id": "D8:35", - "text": "Wow, Mel, that sounds awesome! Exploring nature and family time is so special." - }, - { - "speaker": "Melanie", - "dia_id": "D8:36", - "text": "Yeah, Caroline, they're some of my fave memories. It brings us together and brings us happiness. Glad you're here to share in it." - }, - { - "speaker": "Caroline", - "dia_id": "D8:37", - "text": "Thanks, Melanie! Really glad to have you as a friend to share my journey. You're awesome!" - }, - { - "speaker": "Melanie", - "dia_id": "D8:38", - "text": "Thanks, Caroline! Appreciate your friendship. It's great to have a supporter!" - }, - { - "speaker": "Caroline", - "dia_id": "D8:39", - "text": "No worries, Mel! Your friendship means so much to me. Enjoy your day!" - } - ], - "session_9_date_time": "2:31 pm on 17 July, 2023", - "session_9": [ - { - "speaker": "Melanie", - "dia_id": "D9:1", - "text": "Hey Caroline, hope all's good! I had a quiet weekend after we went camping with my fam two weekends ago. It was great to unplug and hang with the kids. What've you been up to? Anything fun over the weekend?" - }, - { - "speaker": "Caroline", - "dia_id": "D9:2", - "text": "Hey Melanie! That sounds great! Last weekend I joined a mentorship program for LGBTQ youth - it's really rewarding to help the community." - }, - { - "speaker": "Melanie", - "dia_id": "D9:3", - "text": "Wow, Caroline! It's great that you're helping out. How's it going? Got any cool experiences you can share?" - }, - { - "speaker": "Caroline", - "dia_id": "D9:4", - "text": "The mentoring is going great! I've met some amazing young folks and supported them along the way. It's inspiring to see how resilient and strong they are." - }, - { - "speaker": "Melanie", - "dia_id": "D9:5", - "text": "Wow, Caroline, that sounds super rewarding! Young people's resilience is amazing. Care to share some stories?" - }, - { - "speaker": "Caroline", - "dia_id": "D9:6", - "text": "I mentor a transgender teen just like me. We've been working on building up confidence and finding positive strategies, and it's really been paying off! We had a great time at the LGBT pride event last month." - }, - { - "speaker": "Melanie", - "dia_id": "D9:7", - "text": "Caroline, awesome news that you two are getting along! What was it like for you both? Care to fill me in?" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://res.cloudinary.com/dragonspell/images/w_1440,h_864,c_fill,dpr_auto,fl_progressive:steep,f_auto/w_1440,h_864/v1571420662/www.travelportland.com/Portland-Pride-Parade-Downtown/Portland-Pride-Parade-Downtown.jpg" - ], - "blip_caption": "a photo of a woman holding a rainbow umbrella in the air", - "query": "lgbt pride event", - "dia_id": "D9:8", - "text": "The pride event was awesome! It was so encouraging to be surrounded by so much love and acceptance." - }, - { - "speaker": "Melanie", - "dia_id": "D9:9", - "text": "Wow! What's the best part you remember from it?" - }, - { - "speaker": "Caroline", - "dia_id": "D9:10", - "text": "Seeing my mentee's face light up when they saw the support was the best! Such a special moment." - }, - { - "speaker": "Melanie", - "dia_id": "D9:11", - "text": "Wow, Caroline! They must have felt so appreciated. It's awesome to see the difference we can make in each other's lives. Any other exciting LGBTQ advocacy stuff coming up?" - }, - { - "speaker": "Caroline", - "dia_id": "D9:12", - "text": "Yay! Next month I'm having an LGBTQ art show with my paintings - can't wait!" - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a painting with a blue and yellow design", - "dia_id": "D9:13", - "text": "Wow, Caroline, that sounds awesome! Can't wait to see your art - got any previews?" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://images.fineartamerica.com/images/artworkimages/mediumlarge/1/abstract-landscape-bold-colorful-painting-megan-duncanson.jpg" - ], - "blip_caption": "a photography of a painting of a tree with a bright sun in the background", - "query": "preview painting art show", - "dia_id": "D9:14", - "re-download": true, - "text": "Check out my painting for the art show! Hope you like it." - }, - { - "speaker": "Melanie", - "dia_id": "D9:15", - "text": "Wow, Caroline, that painting is awesome! Those colors are so vivid and the whole thing looks really unified. What inspired you?" - }, - { - "speaker": "Caroline", - "dia_id": "D9:16", - "text": "Thanks, Melanie! I painted this after I visited a LGBTQ center. I wanted to capture everyone's unity and strength." - }, - { - "speaker": "Melanie", - "dia_id": "D9:17", - "text": "Wow, Caroline! It really conveys unity and strength - such a gorgeous piece! My kids and I just finished another painting like our last one." - } - ], - "session_10_date_time": "8:56 pm on 20 July, 2023", - "session_10": [ - { - "speaker": "Caroline", - "dia_id": "D10:1", - "text": "Hey Melanie! Just wanted to say hi!" - }, - { - "speaker": "Melanie", - "dia_id": "D10:2", - "text": "Hey Caroline! Good to talk to you again. What's up? Anything new since last time?" - }, - { - "speaker": "Caroline", - "dia_id": "D10:3", - "text": "Hey Mel! A lot's happened since we last chatted - I just joined a new LGBTQ activist group last Tues. I'm meeting so many cool people who are as passionate as I am about rights and community support. I'm giving my voice and making a real difference, plus it's fulfilling in so many ways. It's just great, you know?" - }, - { - "speaker": "Melanie", - "dia_id": "D10:4", - "text": "That's awesome, Caroline! Glad to hear you found a great group where you can have an impact. Bet it feels great to be able to speak your truth and stand up for what's right. Want to tell me a bit more about it?" - }, - { - "speaker": "Caroline", - "dia_id": "D10:5", - "text": "Thanks, Melanie! It's awesome to have our own platform to be ourselves and support others' rights. Our group, 'Connected LGBTQ Activists', is made of all kinds of people investing in positive changes. We have regular meetings, plan events and campaigns, to get together and support each other." - }, - { - "speaker": "Melanie", - "dia_id": "D10:6", - "text": "Wow, Caroline, your group sounds awesome! Supporting each other and making good things happen - that's so inspiring! Have you been part of any events or campaigns lately?" - }, - { - "speaker": "Caroline", - "dia_id": "D10:7", - "text": "Last weekend our city held a pride parade! So many people marched through the streets waving flags, holding signs and celebrating love and diversity. I missed it but it was a powerful reminder that we are not alone in this fight for equality and inclusivity. Change is possible!" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://mdkidadventures.files.wordpress.com/2023/06/img_2130.jpg" - ], - "blip_caption": "a photo of three children playing on the beach with a kite", - "query": "beach family playing frisbee sandy shore", - "dia_id": "D10:8", - "text": "Wow, fantastic, Caroline! Bet the atmosphere was incredible. Oh yeah, we went to the beach recently. It was awesome! The kids had such a blast." - }, - { - "speaker": "Caroline", - "dia_id": "D10:9", - "text": "Sounds fun! What was the best part? Do you do it often with the kids?" - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a sand castle on the beach with a blue sky", - "dia_id": "D10:10", - "text": "Seeing my kids' faces so happy at the beach was the best! We don't go often, usually only once or twice a year. But those times are always special to spend time together and chill." - }, - { - "speaker": "Caroline", - "dia_id": "D10:11", - "text": "Sounds special, those beach trips! Do you have any other summer traditions you all do together? Create those memories!" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://i.redd.it/hjh0wp8s721a1.jpg" - ], - "blip_caption": "a photo of a fire pit with a lot of fire and sparks", - "query": "family camping trip campfire night", - "dia_id": "D10:12", - "text": "We always look forward to our family camping trip. We roast marshmallows, tell stories around the campfire and just enjoy each other's company. It's the highlight of our summer!" - }, - { - "speaker": "Caroline", - "dia_id": "D10:13", - "text": "Wow, Mel, that's awesome! What's your best camping memory?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://i.redd.it/ms0tvo85cto91.jpg" - ], - "blip_caption": "a photo of a plane flying in the sky with a star filled sky", - "query": "shooting star night sky", - "dia_id": "D10:14", - "text": "I'll always remember our camping trip last year when we saw the Perseid meteor shower. It was so amazing lying there and watching the sky light up with streaks of light. We all made wishes and felt so at one with the universe. That's a memory I'll never forget." - }, - { - "speaker": "Caroline", - "dia_id": "D10:15", - "text": "Cool! What did it look like?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://i.redd.it/eqtu6adwcrfb1.jpg" - ], - "blip_caption": "a photo of a plane flying in the sky with a trail of smoke coming out of it", - "query": "night sky stars meteor shower", - "dia_id": "D10:16", - "text": "The sky was so clear and filled with stars, and the meteor shower was amazing - it felt like we were part of something huge and awe-inspiring." - }, - { - "speaker": "Caroline", - "dia_id": "D10:17", - "text": "Wow, Mel. That must've been breathtaking!" - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a beach with footprints in the sand and a blue sky", - "dia_id": "D10:18", - "text": "It was one of those moments where I felt tiny and in awe of the universe. Reminds me how awesome life is - so many little moments like that." - }, - { - "speaker": "Caroline", - "dia_id": "D10:19", - "text": "That's great, Mel! What other good memories do you have that make you feel thankful for life?" - }, - { - "speaker": "Melanie", - "dia_id": "D10:20", - "text": "I'll never forget the day my youngest took her first steps. Seeing her wobble as she took those initial steps really put into perspective how fleeting life is and how lucky I am to be able to share these moments." - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a baby in a white crib with a blue blanket", - "dia_id": "D10:21", - "text": "Aw, that's sweet, Mel! Those milestones are great reminders of how special our bonds are." - }, - { - "speaker": "Melanie", - "img_url": [ - "https://freerangestock.com/sample/134391/happy-family-holding-hands-with-ocean-and-sunset-in-the-background.jpg" - ], - "blip_caption": "a photography of a family standing on the beach at sunset", - "query": "children playing and laughing", - "dia_id": "D10:22", - "re-download": true, - "text": "Yeah, they sure are. It's special moments like these that make me appreciate life and how lucky I am to be with my family and have our love." - }, - { - "speaker": "Caroline", - "dia_id": "D10:23", - "text": "Wow, Melanie, what a beautiful moment! Lucky you to have such an awesome family!" - }, - { - "speaker": "Melanie", - "dia_id": "D10:24", - "text": "Thanks, Caroline! I'm really lucky to have my family; they bring so much joy and love." - } - ], - "session_11_date_time": "2:24 pm on 14 August, 2023", - "session_11": [ - { - "speaker": "Melanie", - "dia_id": "D11:1", - "text": "Hey Caroline! Last night was amazing! We celebrated my daughter's birthday with a concert surrounded by music, joy and the warm summer breeze. Seeing my kids' smiles was so awesome, and I'm so thankful for our special moments together." - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a poster for a concert with a picture of a man", - "dia_id": "D11:2", - "text": "Wow, sounds wonderful! Your love for your kids is so awesome. What concert was it? The advocacy event was a cool experience - so much love and support, amazing!" - }, - { - "speaker": "Melanie", - "dia_id": "D11:3", - "text": "Thanks, Caroline! It was Matt Patterson, he is so talented! His voice and songs were amazing. What's up with you? Anything interesting going on?" - }, - { - "speaker": "Caroline", - "dia_id": "D11:4", - "text": "Wow, Mel, glad you had a blast at the concert. A lot's happened since we talked. I went to a pride parade last Friday and it was awesome - so much energy and love everywhere. Really made me proud and reminded me how important it is to keep standing up for equality." - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a band performing on stage with a sign that says all are welcome", - "dia_id": "D11:5", - "text": "Wow, that's awesome! How did it feel being part of that community?" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://cloudfront-us-east-1.images.arcpublishing.com/opb/35SV3NIC4ZBRTLDGHUJ5QWU5WY.jpg" - ], - "blip_caption": "a photo of a group of people walking down a street with balloons", - "query": "pride parade crowd", - "dia_id": "D11:6", - "text": "It was so inspiring, Mel! Check out the crowd. People of all kinds celebrating love and acceptance - it really pushed me to keep fighting for LGBTQ rights." - }, - { - "speaker": "Melanie", - "img_url": [ - "https://livingmividaloca.com/wp-content/uploads/2023/06/anaheim-town-square-concert.jpg" - ], - "blip_caption": "a photo of a group of people sitting on chairs watching a band", - "query": "outdoor concert family loving accepting environment", - "dia_id": "D11:7", - "text": "Wow, Caroline! That sounds awesome. This pic's from last night - looks like everyone was having a blast! Reminds me it's important to cultivate a loving and accepting environment for our kids. How do you stay inclusive in your work as an artist?" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://www.dawnsilerart.com/wp-content/uploads/sites/3130/2020/11/YCNHTMC-CU9.jpg" - ], - "blip_caption": "a photo of a painting with a painting brush and paint on it", - "query": "painting vibrant colors diverse representation", - "dia_id": "D11:8", - "text": "That pic is cool! Representing inclusivity and diversity in my art is important to me. I also use it to speak up for the LGBTQ+ community and push for acceptance. Here's a recent painting!" - }, - { - "speaker": "Melanie", - "dia_id": "D11:9", - "text": "Wow, that rocks! What's the main idea of your art?" - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a painting of a woman with a cow in her lap", - "dia_id": "D11:10", - "text": "My art is about expressing my trans experience. It's my way of showing my story and helping people understand the trans community." - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a person holding a purple bowl in their hand", - "dia_id": "D11:11", - "text": "Your art's amazing, Caroline. I love how you use it to tell your stories and teach people about trans folks. I'd love to see another painting of yours!" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://media.artsper.com/artwork/2013795_1_l.jpg" - ], - "blip_caption": "a photo of a painting of a woman with a red shirt", - "query": "painting embracing identity purple blue", - "dia_id": "D11:12", - "text": "Thanks, Melanie. Here's one- 'Embracing Identity' is all about finding comfort and love in being yourself. The woman in the painting stands for the journey of acceptance. My aim was to show warmth, love and self-acceptance." - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a woman is making a vase on a wheel", - "dia_id": "D11:13", - "text": "Wow, Caroline, that's gorgeous! I love the self-acceptance and love theme. How does art help you with your self-discovery and acceptance journey?" - }, - { - "speaker": "Caroline", - "dia_id": "D11:14", - "text": "Art's allowed me to explore my transition and my changing body. It's been a great way to work through stuff I'm going through. I love that it teaches me to accept the beauty of imperfections." - }, - { - "speaker": "Melanie", - "dia_id": "D11:15", - "text": "Wow, Caroline, that's so cool! Art can be so healing and a way to really connect with who you are. It's awesome that beauty can be found in the imperfections. We're all individual and wonderfully imperfect. Thanks for sharing it with me!" - }, - { - "speaker": "Caroline", - "dia_id": "D11:16", - "text": "Thanks, Melanie. It means a lot to share this with you." - }, - { - "speaker": "Melanie", - "dia_id": "D11:17", - "text": "Great chatting with you! Feel free to reach out any time." - } - ], - "session_12_date_time": "1:50 pm on 17 August, 2023", - "session_12": [ - { - "speaker": "Caroline", - "dia_id": "D12:1", - "text": "Hey Mel! How're ya doin'? Recently, I had a not-so-great experience on a hike. I ran into a group of religious conservatives who said something that really upset me. It made me think how much work we still have to do for LGBTQ rights. It's been so helpful to have people around me who accept and support me, so I know I'll be ok!" - }, - { - "speaker": "Melanie", - "dia_id": "D12:2", - "text": "Hey Caroline, sorry about the hike. It sucks when people are so closed-minded. Strong support really helps. FYI, I finished another pottery project - want to see a pic?" - }, - { - "speaker": "Caroline", - "dia_id": "D12:3", - "text": "Sure thing, Melanie! Can't wait to see your pottery project. I'm happy you found something that makes you happy. Show me when you can!" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://omceramic.com/cdn/shop/products/IMG_0022.jpg" - ], - "blip_caption": "a photo of a bowl with a colorful design on it", - "query": "pottery project ceramic bowl", - "dia_id": "D12:4", - "text": "Here it is. Pretty proud of it! It was a great experience. Thoughts?" - }, - { - "speaker": "Caroline", - "dia_id": "D12:5", - "text": "That bowl is awesome, Mel! What gave you the idea for all the colors and patterns?" - }, - { - "speaker": "Melanie", - "dia_id": "D12:6", - "text": "Thanks, Caroline! I'm obsessed with those, so I made something to catch the eye and make people smile. Plus, painting helps me express my feelings and be creative. Each stroke carries a part of me." - }, - { - "speaker": "Caroline", - "dia_id": "D12:7", - "text": "That's amazing! You put so much effort and passion into it. Your creativity really shines. Seeing how art can be a source of self-expression and growth is truly inspiring. You're killing it!" - }, - { - "speaker": "Melanie", - "dia_id": "D12:8", - "text": "Thanks, Caroline! Your words really mean a lot. I've always felt a strong connection to art, and it's been a huge learning experience. It's both a sanctuary and a source of comfort. I'm so glad to have something that brings me so much happiness and fulfillment." - }, - { - "speaker": "Caroline", - "dia_id": "D12:9", - "text": "Glad you found something that makes you so happy! Surrounding ourselves with things that bring us joy is important. Life's too short to do anything else!" - }, - { - "speaker": "Melanie", - "dia_id": "D12:10", - "text": "Agreed, Caroline. Life's tough but it's worth it when we have things that make us happy." - }, - { - "speaker": "Caroline", - "dia_id": "D12:11", - "text": "Definitely, Mel! Finding those happy moments and clinging to them is key. It's what keeps us going, even when life's hard. I'm lucky to have people like you to remind me." - }, - { - "speaker": "Melanie", - "dia_id": "D12:12", - "text": "Yeah, same here Caroline. You make life's struggles more bearable." - }, - { - "speaker": "Caroline", - "dia_id": "D12:13", - "text": "Thanks, Melanie! It means a lot having you in my corner. Appreciate our friendship!" - }, - { - "speaker": "Melanie", - "dia_id": "D12:14", - "text": "I appreciate our friendship too, Caroline. You've always been there for me." - }, - { - "speaker": "Caroline", - "img_url": [ - "https://media2.fdncms.com/portmerc/imager/u/large/46577490/pride2022-2-jankowski.jpg" - ], - "blip_caption": "a photo of a group of people walking down a street with balloons", - "query": "friends pride festival", - "dia_id": "D12:15", - "text": "I'm always here for you, Mel! We had a blast last year at the Pride fest. Those supportive friends definitely make everything worth it!" - }, - { - "speaker": "Melanie", - "dia_id": "D12:16", - "text": "That was a blast! So much fun with the whole gang! Wanna do a family outing this summer?" - }, - { - "speaker": "Caroline", - "dia_id": "D12:17", - "text": "Right, it was so much fun! We could do a family outting, or wanna plan something special for this summer, just us two? It'd be a great chance to catch up and explore nature! What do you think?" - }, - { - "speaker": "Melanie", - "dia_id": "D12:18", - "text": "Sounds great, Caroline! Let's plan something special!" - }, - { - "speaker": "Caroline", - "dia_id": "D12:19", - "text": "Sounds great, Mel! We'll make some awesome memories!" - }, - { - "speaker": "Melanie", - "dia_id": "D12:20", - "text": "Yeah, Caroline! I'll start thinking about what we can do." - }, - { - "speaker": "Caroline", - "dia_id": "D12:21", - "text": "Yeah, Mel! Life's all about creating memories. Can't wait for the trip!" - } - ], - "session_13_date_time": "3:31 pm on 23 August, 2023", - "session_13": [ - { - "speaker": "Caroline", - "img_url": [ - "https://i.redd.it/pyq31v7eh6ra1.jpg" - ], - "blip_caption": "a photo of a sign with a picture of a guinea pig", - "query": "adoption brochures application forms external adoption advice assistance group", - "dia_id": "D13:1", - "text": "Hi Melanie! Hope you're doing good. Guess what I did this week? I took the first step towards becoming a mom - I applied to adoption agencies! It's a big decision, but I think I'm ready to give all my love to a child. I got lots of help from this adoption advice/assistance group I attended. It was great!" - }, - { - "speaker": "Melanie", - "dia_id": "D13:2", - "text": "Caroline, congrats! So proud of you for taking this step. How does it feel? Also, do you have any pets?" - }, - { - "speaker": "Caroline", - "dia_id": "D13:3", - "text": "Thanks, Mel! Exciting but kinda nerve-wracking. Parenting's such a big responsibility. And yup, I do- Oscar, my guinea pig. He's been great. How are your pets?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://i.redd.it/kgggim1gom951.jpg" - ], - "blip_caption": "a photo of a black dog laying in the grass with a frisbee", - "query": "pets Luna Oliver playing frisbee backyard", - "dia_id": "D13:4", - "text": "Yeah, it's normal to be both excited and nervous with a big decision. And thanks for asking, they're good- we got another cat named Bailey too. Here's a pic of Oliver. Can you show me one of Oscar?" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://cdn.i-scmp.com/sites/default/files/styles/landscape/public/d8/yp/images/shutterstock533807500.jpg" - ], - "blip_caption": "a photography of a guinea in a cage with hay and hay", - "query": "oscar munching parsley playpen", - "dia_id": "D13:5", - "re-download": true, - "text": "He's so cute! What\u2019s the funniest thing Oliver's done? And sure, check out this pic of him eating parsley! Veggies are his fave!" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://i.redd.it/fgv0i3nzo7541.jpg" - ], - "blip_caption": "a photo of a person holding a carrot in front of a horse", - "query": "oscar carrot in mouth", - "dia_id": "D13:6", - "text": "Oliver's hilarious! He hid his bone in my slipper once! Cute, right? Almost as silly as when I got to feed a horse a carrot. " - }, - { - "speaker": "Caroline", - "dia_id": "D13:7", - "text": "That's so funny! I used to go horseback riding with my dad when I was a kid, we'd go through the fields, feeling the wind. It was so special. I've always had a love for horses!" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://warpedtable.com/cdn/shop/products/F331B563-AB73-430A-A6DF-3C5E0F91A4D8.jpg" - ], - "blip_caption": "a photo of a horse painted on a wooden wall", - "query": "horse painting", - "dia_id": "D13:8", - "text": "Wow, that sounds great - I agree, they're awesome. Here's a photo of my horse painting I did recently." - }, - { - "speaker": "Caroline", - "dia_id": "D13:9", - "text": "Wow, Melanie, that's amazing! Love all the details and how you got the horse's grace and strength. Do you like painting animals?" - }, - { - "speaker": "Melanie", - "dia_id": "D13:10", - "text": "Thanks, Caroline! Glad you like it. Yeah, I love to. It's peaceful and special. Horses have such grace! Do you like to paint too?" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://cdn.shopify.com/s/files/1/0302/3968/6755/files/IMG_8385_a145b124-53ab-4b3c-8f1a-497fa2d39a49.jpg" - ], - "blip_caption": "a photo of a painting of a woman with a blue face", - "query": "self-portrait painting vibrant colors", - "dia_id": "D13:11", - "text": "Painting's great for expressing myself. I love creating art! Here's a recent self-portrait I made last week." - }, - { - "speaker": "Melanie", - "dia_id": "D13:12", - "text": "Caroline, that's great! The blue's really powerful, huh? How'd you feel while painting it?" - }, - { - "speaker": "Caroline", - "dia_id": "D13:13", - "text": "Thanks, Mel! I felt liberated and empowered doing it. Painting helps me explore my identity and be true to myself. It's definitely therapeutic." - }, - { - "speaker": "Melanie", - "dia_id": "D13:14", - "text": "Wow, Caroline, that's great! Art's awesome for showing us who we really are and getting in touch with ourselves. What else helps you out?" - }, - { - "speaker": "Caroline", - "dia_id": "D13:15", - "text": "Thanks, Melanie. Art gives me a sense of freedom, but so does having supportive people around, promoting LGBTQ rights and being true to myself. I want to live authentically and help others to do the same." - }, - { - "speaker": "Melanie", - "dia_id": "D13:16", - "text": "Wow, Caroline! That's amazing. You really care about being real and helping others. Wishing you the best on your adoption journey!" - }, - { - "speaker": "Caroline", - "dia_id": "D13:17", - "text": "Thanks, Melanie! I really appreciate it. Excited for the future! Bye!" - }, - { - "speaker": "Melanie", - "dia_id": "D13:18", - "text": "Bye Caroline. I'm here for you. Take care of yourself." - } - ], - "session_14_date_time": "1:33 pm on 25 August, 2023", - "session_14": [ - { - "speaker": "Caroline", - "img_url": [ - "https://photos.thetrek.co/wp-content/uploads/2017/11/IMG_1742-e1509796327550.jpg" - ], - "blip_caption": "a photo of a woman sitting on a sign on top of a mountain", - "query": "letter apology hike encounter", - "dia_id": "D14:1", - "text": "Hey, Mel! How's it going? There's something I want to tell you. I went hiking last week and got into a bad spot with some people. It really bugged me, so I tried to apologize to them." - }, - { - "speaker": "Melanie", - "img_url": [ - "https://i0.wp.com/bardith.com/wp-content/uploads/2022/05/IMG_4371-1.jpg" - ], - "blip_caption": "a photo of a plate with a bunch of flowers on it", - "query": "pottery purple bowl floral patterns", - "dia_id": "D14:2", - "text": "Wow, Caroline! Sorry that happened to you. It's tough when those things happen, but it's great you apologized. Takes a lot of courage and maturity! What do you think of this?" - }, - { - "speaker": "Caroline", - "dia_id": "D14:3", - "text": "Thanks, Melanie! That plate is awesome! Did you make it?" - }, - { - "speaker": "Melanie", - "dia_id": "D14:4", - "text": "Yeah, I made it in pottery class yesterday. I love it! Pottery's so relaxing and creative. Have you tried it yet?" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://i0.wp.com/makesomethingmondays.com/wp-content/uploads/2017/07/mini-beach-sunset-painting-diy.jpg" - ], - "blip_caption": "a photo of a painting of a sunset on a small easel", - "query": "vibrant sunset beach painting", - "dia_id": "D14:5", - "text": "Nah, I haven't. I've been busy painting - here's something I just finished." - }, - { - "speaker": "Melanie", - "dia_id": "D14:6", - "text": "Wow Caroline, that looks amazing! Those colors are so vivid, it really looks like a real sunset. What gave you the idea to paint it?" - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a painting of a sunset over the ocean", - "dia_id": "D14:7", - "text": "Thanks, Melanie! I painted it after I visited the beach last week. Just seeing the sun dip below the horizon, all the amazing colors - it was amazing and calming. So I just had to try to capture that feeling in my painting." - }, - { - "speaker": "Melanie", - "dia_id": "D14:8", - "text": "Wow, the beach really inspired you. The art really took me to that moment and I can feel the serenity. You captured the sunset perfectly, so peaceful!" - }, - { - "speaker": "Caroline", - "dia_id": "D14:9", - "text": "Thanks Mel, really appreciate your kind words. It means a lot to me that you can feel the sense of peace and serenity. Makes me feel connected." - }, - { - "speaker": "Melanie", - "dia_id": "D14:10", - "text": "I feel the same way! Art is so cool like that - it connects us and helps us understand each other. I was actually just remembering yesterday, spending the day with my fam volunteering at a homeless shelter. It was hard to see how neglected some people are, but it was great to feel like we could make a difference." - }, - { - "speaker": "Caroline", - "img_url": [ - "https://images.squarespace-cdn.com/content/v1/64cda0c3f2719a0e6e707684/a08e6e1f-f0e0-4f1a-b567-1f1f92b80aab/35970846_829192503937065_1026209343625756672_o_829192493937066.jpg" - ], - "blip_caption": "a photo of a crowd of people walking down a street with a rainbow flag", - "query": "volunteering pride event", - "dia_id": "D14:11", - "text": "Wow, Mel, you're amazing! Volunteering and making a difference- it's so heartwarming. You're an inspiration to us all!" - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a bulletin board with a rainbow flag and a don't ever be afraid to", - "dia_id": "D14:12", - "text": "Thanks, Caroline! I really appreciate your help and motivation. What made you decide to transition and join the transgender community?" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://npr.brightspotcdn.com/legacy/sites/wuwm/files/201811/20181029_095916.jpg" - ], - "blip_caption": "a photo of a building with a large eagle painted on it", - "query": "rainbow flag painting unity acceptance", - "dia_id": "D14:13", - "text": "Finding a community where I'm accepted, loved and supported has really meant a lot to me. It's made a huge difference to have people who get what I'm going through. Stuff like this mural are really special to me!" - }, - { - "speaker": "Melanie", - "dia_id": "D14:14", - "text": "Caroline, glad you found a supportive community! Can you tell me more about why it's special to you?" - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a stained glass window with a picture of a person on a horse", - "dia_id": "D14:15", - "text": "The rainbow flag mural is important to me as it reflects the courage and strength of the trans community. The eagle symbolizes freedom and pride, representing my own resilience and that of others." - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a stained glass window with a person holding a key", - "dia_id": "D14:16", - "text": "I'm in awe of your courage as a trans person. Have you made any more art lately?" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://projects.history.qmul.ac.uk/thehistorian/wp-content/uploads/sites/24/2017/10/IMG_20170922_072615_165.jpg" - ], - "blip_caption": "a photo of three stained glass windows in a church with a clock", - "query": "stained glass window letter", - "dia_id": "D14:17", - "text": "Thanks, Mel! I made this stained glass window to remind myself and others that within us all is the key to discovering our true potential and living our best life." - }, - { - "speaker": "Melanie", - "dia_id": "D14:18", - "text": "Wow, Caroline, that looks amazing! What inspired it?" - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a large stained glass window in a church", - "dia_id": "D14:19", - "text": "Thanks! It was made for a local church and shows time changing our lives. I made it to show my own journey as a transgender woman and how we should accept growth and change." - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a door with a stained glass window and a coat rack", - "dia_id": "D14:20", - "text": "Wow, Caroline! All those colors are incredible and the story it tells is so inspiring." - }, - { - "speaker": "Caroline", - "img_url": [ - "https://i0.wp.com/marbleheadcurrent.org/wp-content/uploads/2023/07/rainbow.jpg" - ], - "blip_caption": "a photo of a painted sidewalk with a rainbow design on it", - "query": "painting rainbow flag unity acceptance", - "dia_id": "D14:21", - "text": "Thanks, Mel! Glad you like it. It's a symbol of togetherness, to celebrate differences and be that much closer. I'd love to make something like this next!" - }, - { - "speaker": "Melanie", - "dia_id": "D14:22", - "text": "Wow, that's gorgeous! Where did you find it?" - }, - { - "speaker": "Caroline", - "dia_id": "D14:23", - "text": "I was out walking in my neighborhood when I came across this cool rainbow sidewalk for Pride Month. It was so vibrant and welcoming, I had to take a picture! It reminds us that love and acceptance are everywhere\u2014even where we least expect it." - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a person drawing a flower on the ground", - "dia_id": "D14:24", - "text": "That's so nice, Caroline! Art can be in the most unlikely places. Love and acceptance really can be found everywhere." - }, - { - "speaker": "Caroline", - "img_url": [ - "https://static.skillshare.com/uploads/project/281358/cover_full_592aef91cce2432e71c739804161e0fb.jpg" - ], - "blip_caption": "a photo of a painting of flowers and a watercolor palette", - "query": "drawing flower ground colored chalk simple act creativity", - "dia_id": "D14:25", - "text": "Agreed, Mel! Art can be a real mood-booster - I saw someone drawing on the ground the other day and it made me so happy. Creativity sure can lighten someone's day." - }, - { - "speaker": "Melanie", - "dia_id": "D14:26", - "text": "Wow, Caroline, that's so nice! The colors are so bright and the flowers are so pretty. Art is such a source of joy." - }, - { - "speaker": "Caroline", - "img_url": [ - "https://i.pinimg.com/originals/3d/e3/b8/3de3b8a013be3eec63cc454cb0c63536.jpg" - ], - "blip_caption": "a photo of a drawing of a bunch of flowers on a table", - "query": "bouquet wildflowers art", - "dia_id": "D14:27", - "text": "Thanks, Mel! Art gives me so much joy. It helps me show my feelings and freeze gorgeous moments, like a bouquet of flowers. " - }, - { - "speaker": "Melanie", - "dia_id": "D14:28", - "text": "Wow, did you make that? It looks so real!" - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a drawing of a flower bouquet with a person holding it", - "dia_id": "D14:29", - "text": "Yeah, definitely! Drawing flowers is one of my faves. Appreciating nature and sharing it is great. What about you, Mel? What type of art do you love?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://i.redd.it/k9wcp85ledi91.jpg" - ], - "blip_caption": "a photo of a painting of a sunflower on a canvas", - "query": "painting field sunflowers", - "dia_id": "D14:30", - "text": "Painting landscapes and still life is my favorite! Nature's amazing, here's a painting I did recently." - }, - { - "speaker": "Caroline", - "dia_id": "D14:31", - "text": "Wow, Mel! Any more paintings coming up?" - }, - { - "speaker": "Melanie", - "dia_id": "D14:32", - "text": "I'm feeling inspired by autumn so I'm planning a few. You got any cool art projects coming up?" - }, - { - "speaker": "Caroline", - "dia_id": "D14:33", - "text": "I'm putting together an LGBTQ art show next month and I'm gonna show my paintings. Super stoked!" - }, - { - "speaker": "Melanie", - "dia_id": "D14:34", - "text": "Wow, Caroline, that's awesome! Can't wait to see your show - the LGBTQ community needs more platforms like this!" - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a poster for a concert with a man in a cowboy hat", - "dia_id": "D14:35", - "text": "Yeah Mel, stoked! Gonna be a great night featuring LGBTQ artists and their awesome talents. We want it to spread understanding and acceptance - let's make it happen!" - } - ], - "session_15_date_time": "3:19 pm on 28 August, 2023", - "session_15": [ - { - "speaker": "Caroline", - "dia_id": "D15:1", - "text": "Hey Melanie, great to hear from you. What's been up since we talked?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://img-aws.ehowcdn.com/1280x/www.onlyinyourstate.com/wp-content/uploads/2022/12/gym8.jpg" - ], - "blip_caption": "a photo of a playground with a climbing net and a slide", - "query": "kids climbing jungle gym park", - "dia_id": "D15:2", - "text": "Hey Caroline! Since we last spoke, I took my kids to a park yesterday. They had fun exploring and playing. It was nice seeing them have a good time outdoors. Time flies, huh? What's new with you?" - }, - { - "speaker": "Caroline", - "dia_id": "D15:3", - "text": "Wow, your kids had so much fun at the park! Being outdoors can be really enjoyable. A lot happened since our last chat. I've been chasing my ambitions and had the chance to volunteer at an LGBTQ+ youth center. It was so gratifying to talk to similar young people. It made me remember how essential it is to be kind and show support." - }, - { - "speaker": "Melanie", - "dia_id": "D15:4", - "text": "That sounds great, Caroline. Volunteering is a great way to meet people. Creating community and supporting each other, especially for kids, is really important. How did you feel about your time there? Anything that sticks out to you?" - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a table with a black table cloth and a group of people", - "dia_id": "D15:5", - "text": "I loved it. It was awesome to see how strong the young people were, with all the challenges they face. I felt fulfilled guiding and supporting them. I even got to let them know they're not alone by sharing my story. Such a powerful, emotional experience." - }, - { - "speaker": "Melanie", - "dia_id": "D15:6", - "text": "Was connecting with those young folks meaningful for you? " - }, - { - "speaker": "Caroline", - "dia_id": "D15:7", - "text": "It was so special to me. It reminded me of my own struggles in the past and how I felt alone. I was glad I could share my story and offer them support - it felt like I could make a difference." - }, - { - "speaker": "Melanie", - "dia_id": "D15:8", - "text": "That's great. Sharing your story and support might make a difference for a long time. What do you hope to do next time?" - }, - { - "speaker": "Caroline", - "dia_id": "D15:9", - "text": "I'm definitely carrying on volunteering at the youth center. It's an important part of my life and I've made strong connections with people there. I really believe in community and supporting each other. So I wanna keep making a difference." - }, - { - "speaker": "Melanie", - "dia_id": "D15:10", - "text": "That's great news, Caroline! Love seeing your dedication to helping others. Any specific projects or activities you're looking forward to there?" - }, - { - "speaker": "Caroline", - "dia_id": "D15:11", - "text": "We're putting together a talent show for the kids next month. I'm looking forward to seeing how much fun everyone has and how proud they'll feel of their talents!" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://www.stomplight.com/cdn/shop/products/DavidAguilar.jpg" - ], - "blip_caption": "a photo of a band playing on a stage in a park", - "query": "talent show stage colorful lights microphone", - "dia_id": "D15:12", - "text": "That's so cool, Caroline! That's a great way to show off and be proud of everyone's skills. You know I love live music. Can't wait to hear about it!" - }, - { - "speaker": "Caroline", - "dia_id": "D15:13", - "text": "Wow! Did you see that band?" - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a crowd of people at a concert with their hands in the air", - "dia_id": "D15:14", - "text": "Yeah, that pic was from a show I went to. It was so much fun and reminded me of how music brings us together." - }, - { - "speaker": "Caroline", - "dia_id": "D15:15", - "text": "Wow, what a fun moment! What's the band?" - }, - { - "speaker": "Melanie", - "dia_id": "D15:16", - "text": "\"Summer Sounds\"- The playing an awesome pop song that got everyone dancing and singing. It was so fun and lively!" - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a man playing a guitar in a recording studio", - "dia_id": "D15:17", - "text": "That sounds great! Music brings us together and brings joy. Playing and singing let me express myself and connect with others - love it! So cathartic and uplifting." - }, - { - "speaker": "Melanie", - "dia_id": "D15:18", - "text": "Cool! What type of music do you play?" - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a guitar on display in a store", - "dia_id": "D15:19", - "text": "Guitar's mostly my thing. Playing it helps me get my emotions out." - }, - { - "speaker": "Melanie", - "dia_id": "D15:20", - "text": "That's awesome! What type of guitar? Been playing long?" - }, - { - "speaker": "Caroline", - "dia_id": "D15:21", - "text": "I started playing acoustic guitar about five years ago; it's been a great way to express myself and escape into my emotions." - }, - { - "speaker": "Melanie", - "dia_id": "D15:22", - "text": "Music's amazing, isn't it? Any songs that have deep meaning for you?" - }, - { - "speaker": "Caroline", - "dia_id": "D15:23", - "text": "Yeah totally! \"Brave\" by Sara Bareilles has a lot of significance for me. It's about being courageous and fighting for what's right. Whenever I hear this jam, I think about the paths I've taken and the progress I've made." - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a piece of paper with a drawing of a man playing a piano", - "dia_id": "D15:24", - "text": "That's a gorgeous song, Caroline. It really fits with your journey and your determination to make a difference. Music can be so inspiring and uplifting." - }, - { - "speaker": "Caroline", - "dia_id": "D15:25", - "text": "Thanks, Melanie! Appreciate it. You play any instruments?" - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a sheet music with notes and a pencil", - "dia_id": "D15:26", - "text": "Yeah, I play clarinet! Started when I was young and it's been great. Expression of myself and a way to relax." - }, - { - "speaker": "Caroline", - "dia_id": "D15:27", - "text": "Cool! Got any fav tunes?" - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a laptop computer with a graph on it", - "dia_id": "D15:28", - "text": "I'm a fan of both classical like Bach and Mozart, as well as modern music like Ed Sheeran's \"Perfect\"." - } - ], - "session_16_date_time": "12:09 am on 13 September, 2023", - "session_16": [ - { - "speaker": "Caroline", - "img_url": [ - "https://assets.simpleviewinc.com/simpleview/image/upload/c_fill,f_jpg,h_371,q_75,w_640/v1/crm/corpuschristitx/Sunset-Lake-Park_38118D81-5056-A36F-23E62D2F41525FF4-38118cb45056a36_381194bb-5056-a36f-23c599c63a3950d7.jpg" - ], - "blip_caption": "a photo of a beach with a fence and a sunset", - "query": "sunset lake", - "dia_id": "D16:1", - "text": "Hey Mel, long time no chat! I had a wicked day out with the gang last weekend - we went biking and saw some pretty cool stuff. It was so refreshing, and the pic I'm sending is just stunning, eh?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://exploringtheprime.com/wp-content/uploads/2019/10/IMG_6705-2.jpg" - ], - "blip_caption": "a photo of a dirt road surrounded by trees with yellow leaves", - "query": "family hiking trail vibrant autumn colors", - "dia_id": "D16:2", - "text": "Hey Caroline! It's so good to hear from you! That pic is so beautiful, the colors really pop. Biking sounds like a great way to get out in nature. We went camping with the kids a few weeks ago, had a blast exploring the forest and hiking. Nature can be so refreshing for your soul. Any plans coming up?" - }, - { - "speaker": "Caroline", - "dia_id": "D16:3", - "text": "Melanie, that photo's amazing! I love all the yellow leaves, it looks so cozy. That sounds like fun! Seeing how excited they get for the little things is awesome, it's so contagious." - }, - { - "speaker": "Melanie", - "dia_id": "D16:4", - "text": "Thanks, Caroline! It's awesome seeing the kids get excited learning something new about nature. Those moments make being a parent worth it. We roasted marshmallows and shared stories around the campfire. Those simple moments make the best memories. What inspires you with your volunteering?" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://i.pinimg.com/originals/34/2e/72/342e72b194865e01a38af86c307e95c7.jpg" - ], - "blip_caption": "a photo of a painting of a heart on a table", - "query": "canvas painting rainbow colors", - "dia_id": "D16:5", - "text": "I'm inspired seeing my work make a difference for the LGBTQ+ community. Knowing I'm helping create a more loving world is amazing. I'm really thankful for my friends, family and mentors' support. It inspires me to keep making art, too." - }, - { - "speaker": "Melanie", - "dia_id": "D16:6", - "text": "Wow, Caroline, that looks awesome! I love how it shows the togetherness and power you were talking about. How long have you been creating art?" - }, - { - "speaker": "Caroline", - "dia_id": "D16:7", - "text": "Since I was 17 or so. I find it soempowering and cathartic. It's amazing how art can show things that are hard to put into words. How long have you been into art?" - }, - { - "speaker": "Melanie", - "img_url": [ - "https://www.1hotpieceofglass.com/cdn/shop/files/image_93ad5985-ff65-4b93-877b-3ee948ac5641_5000x.jpg" - ], - "blip_caption": "a photo of a group of bowls and a starfish on a white surface", - "query": "pottery bowl intricate patterns purple glaze", - "dia_id": "D16:8", - "text": "Seven years now, and I've finally found my real muses: painting and pottery. It's so calming and satisfying. Check out my pottery creation in the pic!" - }, - { - "speaker": "Caroline", - "dia_id": "D16:9", - "text": "Melanie, those bowls are amazing! They each have such cool designs. I love that you chose pottery for your art. Painting and drawing have helped me express my feelings and explore my gender identity. Creating art was really important to me during my transition - it helped me understand and accept myself. I'm so grateful." - }, - { - "speaker": "Melanie", - "dia_id": "D16:10", - "text": "Thanks, Caroline! It has really helped me out. I love how it's both a creative outlet and a form of therapy. Have you ever thought about trying it or another art form?" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://i.redd.it/z8zsh53ycfvb1.jpg" - ], - "blip_caption": "a photo of a painting on a easel with a red and blue background", - "query": "canvas colourful brush strokes", - "dia_id": "D16:11", - "text": "I haven't done pottery yet, but I'm game for trying new art. I might try it sometime! Check out this piece I made!" - }, - { - "speaker": "Melanie", - "dia_id": "D16:12", - "text": "Wow, Caroline! This painting is awesome. Love the red and blue. What gave you the idea?" - }, - { - "speaker": "Caroline", - "dia_id": "D16:13", - "text": "Thanks, Melanie! I made this painting to show my path as a trans woman. The red and blue are for the binary gender system, and the mix of colors means smashing that rigid thinking. It's a reminder to love my authentic self - it's taken a while to get here but I'm finally proud of who I am." - }, - { - "speaker": "Melanie", - "dia_id": "D16:14", - "text": "Wow, Caro, that painting is amazing! You've made so much progress. I'm super proud of you for being your true self. What effect has the journey had on your relationships?" - }, - { - "speaker": "Caroline", - "dia_id": "D16:15", - "text": "Thanks, Melanie. It's definitely changed them. Some close friends kept supporting me, but a few weren't able to handle it. It wasn't easy, but I'm much happier being around those who accept and love me. Now my relationships feel more genuine." - }, - { - "speaker": "Melanie", - "img_url": [ - "https://i.redd.it/epuj1xq8eaga1.jpg" - ], - "blip_caption": "a photo of a sign posted on a door stating that someone is not being able to leave", - "query": "me kids park joy love happiness", - "dia_id": "D16:16", - "text": "Caroline, it's got to be tough dealing with those changes. Glad you've found people who uplift and accept you! Here's to a good time at the caf\u00e9 last weekend - they even had thoughtful signs like this! It brings me so much happiness." - }, - { - "speaker": "Caroline", - "dia_id": "D16:17", - "text": "Whoa, Mel, that sign looks serious. Did anything happen?" - }, - { - "speaker": "Melanie", - "dia_id": "D16:18", - "text": "The sign was just a precaution, I had a great time. But thank you for your concern, you're so thoughtful!" - }, - { - "speaker": "Caroline", - "dia_id": "D16:19", - "text": "Phew! Glad it all worked out and you had a good time at the park!" - }, - { - "speaker": "Melanie", - "dia_id": "D16:20", - "text": "Yeah, it was so much fun! Those joyful moments definitely show us life's beauty." - } - ], - "session_17_date_time": "10:31 am on 13 October, 2023", - "session_17": [ - { - "speaker": "Caroline", - "dia_id": "D17:1", - "text": "Hey Mel, what's up? Long time no see! I just contacted my mentor for adoption advice. I'm ready to be a mom and share my love and family. It's a great feeling. Anything new with you? Anything exciting going on?" - }, - { - "speaker": "Melanie", - "dia_id": "D17:2", - "text": "Hey Caroline! Great to hear from you! Wow, what an amazing journey. Congrats!" - }, - { - "speaker": "Caroline", - "dia_id": "D17:3", - "text": "Thanks, Melanie! I'm stoked to start this new chapter. It's been a dream to adopt and provide a safe, loving home for kids who need it. Do you have any experience with adoption, or know anyone who's gone through the process?" - }, - { - "speaker": "Melanie", - "dia_id": "D17:4", - "text": "Yeah, a buddy of mine adopted last year. It was a long process, but now they're super happy with their new kid. Makes me feel like maybe I should do it too!" - }, - { - "speaker": "Caroline", - "dia_id": "D17:5", - "text": "That's great news about your friend! It can be tough, but so worth it. It's a great way to add to your family and show your love. If you ever do it, let me know \u2014 I'd love to help in any way I can." - }, - { - "speaker": "Melanie", - "dia_id": "D17:6", - "text": "Thanks, Caroline! Appreciate your help. Got any tips for getting started on it?" - }, - { - "speaker": "Caroline", - "dia_id": "D17:7", - "text": "Yep! Do your research and find an adoption agency or lawyer. They'll help with the process and provide all the info. Gather documents like references, financial info and medical checks. Don't forget to prepare emotionally, since the wait can be hard. It's all worth it in the end though." - }, - { - "speaker": "Melanie", - "dia_id": "D17:8", - "text": "Thanks for the tip, Caroline. Doing research and readying myself emotionally makes sense. I'll do that. BTW, recently I had a setback. Last month I got hurt and had to take a break from pottery, which I use for self-expression and peace." - }, - { - "speaker": "Caroline", - "dia_id": "D17:9", - "text": "Oh man, sorry to hear that, Melanie. I hope you're okay. Pottery's a great way to relax, so it must have been tough taking a break. Need any help?" - }, - { - "speaker": "Melanie", - "dia_id": "D17:10", - "text": "Thanks, Caroline. It was tough, but I'm doing ok. Been reading that book you recommended a while ago and painting to keep busy." - }, - { - "speaker": "Caroline", - "dia_id": "D17:11", - "text": "Cool that you have creative outlets. Got any paintings to show? I'd love to check them out." - }, - { - "speaker": "Melanie", - "img_url": [ - "https://trendgallery.art/cdn/shop/files/IMG_2355.jpg" - ], - "blip_caption": "a photo of a painting of a sunset with a pink sky", - "query": "landscape painting vibrant purple sunset autumn", - "dia_id": "D17:12", - "text": "Yeah, Here's one I did last week. It's inspired by the sunsets. The colors make me feel calm. What have you been up to lately, artistically?" - }, - { - "speaker": "Caroline", - "dia_id": "D17:13", - "text": "Wow Mel, that's stunning! Love the colors and the chilled-out sunset vibe. What made you paint it? I've been trying out abstract stuff recently. It's kinda freeing, just putting my feelings on the canvas without too much of a plan. It's like a cool form of self-expression." - }, - { - "speaker": "Melanie", - "img_url": [ - "https://theartwerks.com/cdn/shop/products/image_4c8aee8a-5395-4037-a1d4-f6db3a3b0302.jpg" - ], - "blip_caption": "a photo of a painting on a wall with a blue background", - "query": "abstract painting vibrant colors", - "dia_id": "D17:14", - "text": "Thanks, Caroline! I painted it because it was calming. I've done an abstract painting too, take a look! I love how art lets us get our emotions out." - }, - { - "speaker": "Caroline", - "dia_id": "D17:15", - "text": "Wow, that looks great! The blue adds so much to it. What feelings were you hoping to portray?" - }, - { - "speaker": "Melanie", - "dia_id": "D17:16", - "text": "I wanted a peaceful blue streaks to show tranquility. Blue calms me, so I wanted the painting to have a serene vibe while still having lots of vibrant colors." - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a poster on a wall in a classroom", - "dia_id": "D17:17", - "text": "Yeah, it's very calming. It's awesome how art can show emotions. By the way, I went to a poetry reading last Fri - it was really powerful! Ever been to one?" - }, - { - "speaker": "Melanie", - "dia_id": "D17:18", - "text": "Nope, never been to something like that. What was it about? What made it so special?" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://hips.hearstapps.com/hmg-prod/images/gettyimages-1675780954.jpg" - ], - "blip_caption": "a photography of a sign that says trans lives matter", - "query": "transgender poetry reading trans pride flags", - "dia_id": "D17:19", - "re-download": true, - "text": "It was a transgender poetry reading where transgender people shared their stories through poetry. It was extra special 'cause it was a safe place for self-expression and it was really empowering to hear others share and celebrate their identities." - }, - { - "speaker": "Melanie", - "dia_id": "D17:20", - "text": "Wow, sounds amazing! What was the event like? Those posters are great!" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://i.redd.it/50qvgfuva33b1.jpg" - ], - "blip_caption": "a photo of a drawing of a woman in a dress", - "query": "transgender flag drawing", - "dia_id": "D17:21", - "text": "The room was electric with energy and support! The posters were amazing, so much pride and strength! It inspired me to make some art." - }, - { - "speaker": "Melanie", - "dia_id": "D17:22", - "text": "That's awesome, Caroline! You drew it? What does it mean to you?" - }, - { - "speaker": "Caroline", - "dia_id": "D17:23", - "text": "Thanks, Melanie! Yeah, I drew it. It stands for freedom and being real. It's like a nudge to always stay true to myself and embrace my womanhood." - }, - { - "speaker": "Melanie", - "dia_id": "D17:24", - "text": "I love it. Showing off our true selves is the best thing ever." - }, - { - "speaker": "Caroline", - "dia_id": "D17:25", - "text": "Yep, Melanie! Being ourselves is such a great feeling. It's an ongoing adventure of learning and growing." - }, - { - "speaker": "Melanie", - "dia_id": "D17:26", - "text": "Yep, Caroline. Life's about learning and exploring. Glad we can be on this trip together." - } - ], - "session_18_date_time": "6:55 pm on 20 October, 2023", - "session_18": [ - { - "speaker": "Melanie", - "img_url": [ - "https://i.redd.it/dl8dki2hm3k81.jpg" - ], - "blip_caption": "a photo of a car dashboard with a white cloth and a steering wheel", - "query": "car accident damaged car airbags deployed roadtrip", - "dia_id": "D18:1", - "text": "Hey Caroline, that roadtrip this past weekend was insane! We were all freaked when my son got into an accident. We were so lucky he was okay. It was a real scary experience. Thankfully it's over now. What's been up since we last talked?" - }, - { - "speaker": "Caroline", - "dia_id": "D18:2", - "text": "Oops, sorry 'bout the accident! Must have been traumatizing for you guys. Thank goodness your son's okay. Life sure can be a roller coaster." - }, - { - "speaker": "Melanie", - "dia_id": "D18:3", - "text": "Yeah, our trip got off to a bad start. I was really scared when we got into the accident. Thankfully, my son's ok and that was a reminder that life is precious and to cherish our family." - }, - { - "speaker": "Caroline", - "dia_id": "D18:4", - "text": "Glad your son is okay, Melanie. Life's unpredictable, but moments like these remind us how important our loved ones are. Family's everything." - }, - { - "speaker": "Melanie", - "img_url": [ - "https://familyadventuresva.files.wordpress.com/2022/03/img_5030.jpg" - ], - "blip_caption": "a photo of two children standing on a rocky cliff overlooking a canyon", - "query": "grand canyon family photo", - "dia_id": "D18:5", - "text": "Yeah, you're right, Caroline. Family's super important to me. Especially after the accident, I've thought a lot about how much I need them. They mean the world to me and I'm so thankful to have them. Thankfully, they enjoyed the Grand Canyon a lot!" - }, - { - "speaker": "Caroline", - "dia_id": "D18:6", - "text": "The kids look so cute, Mel! I bet they bring lots of joy. How did they handle the accident?" - }, - { - "speaker": "Melanie", - "dia_id": "D18:7", - "text": "Thanks! They were scared but we reassured them and explained their brother would be OK. They're tough kids." - }, - { - "speaker": "Caroline", - "dia_id": "D18:8", - "text": "Kids are amazingly resilient in tough situations. They have an amazing ability to bounce back." - }, - { - "speaker": "Melanie", - "dia_id": "D18:9", - "text": "They're really amazing. Wish I was that resilient too. But they give me the strength to keep going." - }, - { - "speaker": "Caroline", - "dia_id": "D18:10", - "text": "Our loved ones give us strength to tackle any challenge - it's amazing!" - }, - { - "speaker": "Melanie", - "dia_id": "D18:11", - "text": "Yeah, Caroline. Totally agree. They're my biggest motivation and support." - }, - { - "speaker": "Caroline", - "dia_id": "D18:12", - "text": "It's so sweet to see your love for your family, Melanie. They really are your rock." - }, - { - "speaker": "Melanie", - "dia_id": "D18:13", - "text": "Thanks, Caroline. They're a real support. Appreciate them a lot." - }, - { - "speaker": "Caroline", - "dia_id": "D18:14", - "text": "Glad you've got people to lean on, Melanie. It helps during tougher times." - }, - { - "speaker": "Melanie", - "img_url": [ - "https://live.staticflickr.com/8358/29211988243_82023c5524_b.jpg" - ], - "blip_caption": "a photography of a woman and a child walking on a trail", - "query": "family hiking mountains", - "dia_id": "D18:15", - "re-download": true, - "text": "Yeah for sure. Having my fam around helps a lot. It makes hard times easier." - }, - { - "speaker": "Caroline", - "dia_id": "D18:16", - "text": "Wow, great pic! Is that recent? Looks like you all had fun!" - }, - { - "speaker": "Melanie", - "dia_id": "D18:17", - "text": "Thanks, Caroline! Yup, we just did it yesterday! The kids loved it and it was a nice way to relax after the road trip." - }, - { - "speaker": "Caroline", - "dia_id": "D18:18", - "text": "Glad you got some R&R after the drive. Nature sure seems to refresh us, huh?" - }, - { - "speaker": "Melanie", - "blip_caption": "a photo of a sunset over a body of water", - "dia_id": "D18:19", - "text": "Absolutely! It really helps me reset and recharge. I love camping trips with my fam, 'cause nature brings such peace and serenity." - }, - { - "speaker": "Caroline", - "dia_id": "D18:20", - "text": "Wow, that's awesome! What do you love most about camping with your fam?" - }, - { - "speaker": "Melanie", - "dia_id": "D18:21", - "text": "It's a chance to be present and together. We bond over stories, campfires and nature. It's so peaceful waking up to the sound of birds and the smell of fresh air - it always refreshes my soul." - }, - { - "speaker": "Caroline", - "dia_id": "D18:22", - "text": "That's so peaceful and calming, Melanie! I can picture waking up to nature. It's great that you get to spend quality, tranquil time with your family." - }, - { - "speaker": "Melanie", - "dia_id": "D18:23", - "text": "Thanks, Caroline! This is a great time. Nature and quality time, can't beat it!" - }, - { - "speaker": "Caroline", - "dia_id": "D18:24", - "text": "Yeah totally! They're priceless. Lucky you!" - } - ], - "session_19_date_time": "9:55 am on 22 October, 2023", - "session_19": [ - { - "speaker": "Caroline", - "dia_id": "D19:1", - "text": "Woohoo Melanie! I passed the adoption agency interviews last Friday! I'm so excited and thankful. This is a big move towards my goal of having a family." - }, - { - "speaker": "Melanie", - "img_url": [ - "https://imgur.com/oGlhL5J.jpg" - ], - "blip_caption": "a photo of a couple of wooden dolls sitting on top of a table", - "query": "painted ceramic family figurine", - "dia_id": "D19:2", - "text": "Congrats, Caroline! Adoption sounds awesome. I'm so happy for you. These figurines I bought yesterday remind me of family love. Tell me, what's your vision for the future?" - }, - { - "speaker": "Caroline", - "dia_id": "D19:3", - "text": "Thanks so much, Melanie! It's beautiful! It really brings home how much love's in families - both blood and the ones we choose. I hope to build my own family and put a roof over kids who haven't had that before. For me, adoption is a way of giving back and showing love and acceptance." - }, - { - "speaker": "Melanie", - "dia_id": "D19:4", - "text": "Wow, Caroline, that's awesome. Giving a home to needy kids is such a loving way to build a family. Those kids will be so supported and happy in their new home." - }, - { - "speaker": "Caroline", - "dia_id": "D19:5", - "text": "Thanks, Melanie. My dream is to create a safe and loving home for these kids. Love and acceptance should be everyone's right, and I want them to experience it." - }, - { - "speaker": "Melanie", - "dia_id": "D19:6", - "text": "I totally agree, Caroline. Everyone deserves that. It's awesome to see how passionate you are about helping these kids." - }, - { - "speaker": "Caroline", - "dia_id": "D19:7", - "text": "Thanks, Mel. Finding self-acceptance was a long process, but now I'm ready to offer love and support to those who need it. It's empowering to make a positive difference in someone's life." - }, - { - "speaker": "Melanie", - "dia_id": "D19:8", - "text": "That must have been tough for you, Caroline. Respect for finding acceptance and helping others with what you've been through. You're so strong and inspiring." - }, - { - "speaker": "Caroline", - "dia_id": "D19:9", - "text": "Thanks, Melanie. Transitioning wasn't easy and acceptance wasn't either, but the help I got from friends, family and people I looked up to was invaluable. They boosted me through tough times and helped me find out who I really am. That's why I want to pass that same support to anyone who needs it. Bringing others comfort and helping them grow brings me such joy." - }, - { - "speaker": "Melanie", - "dia_id": "D19:10", - "text": "I'm so happy for you, Caroline. You found your true self and now you're helping others. You're so inspiring!" - }, - { - "speaker": "Caroline", - "blip_caption": "a photo of a clock with a green and yellow design on it", - "dia_id": "D19:11", - "text": "Thanks, Melanie. Your support really means a lot. This journey has been amazing and I'm grateful I get to share it and help others with theirs. It's a real gift." - }, - { - "speaker": "Melanie", - "dia_id": "D19:12", - "text": "Absolutely! I'm so glad we can always be there for each other." - }, - { - "speaker": "Caroline", - "dia_id": "D19:13", - "text": "Glad you agree, Caroline. Appreciate the support of those close to me. Their encouragement made me who I am." - }, - { - "speaker": "Melanie", - "dia_id": "D19:14", - "text": "Glad you had support. Being yourself is great!" - }, - { - "speaker": "Caroline", - "img_url": [ - "https://trendgallery.art/cdn/shop/products/IMG_4482.jpg" - ], - "blip_caption": "a photo of a painting with the words happiness painted on it", - "query": "painting vibrant colors happiness self-expression", - "dia_id": "D19:15", - "text": "Yeah, that's true! It's so freeing to just be yourself and live honestly. We can really accept who we are and be content." - } - ], - "session_20_date_time": "4:10 pm on 26 October, 2023", - "session_21_date_time": "9:35 am on 31 October, 2023", - "session_22_date_time": "12:28 am on 8 November, 2023", - "session_23_date_time": "5:15 pm on 11 November, 2023", - "session_24_date_time": "2:46 pm on 16 November, 2023", - "session_25_date_time": "1:18 pm on 21 November, 2023", - "session_26_date_time": "4:39 pm on 24 November, 2023", - "session_27_date_time": "6:25 pm on 26 November, 2023", - "session_28_date_time": "8:52 pm on 5 December, 2023", - "session_29_date_time": "12:20 am on 8 December, 2023", - "session_30_date_time": "4:37 pm on 10 December, 2023", - "session_31_date_time": "3:24 pm on 16 December, 2023", - "session_32_date_time": "3:43 pm on 20 December, 2023", - "session_33_date_time": "8:32 pm on 27 December, 2023", - "session_34_date_time": "1:08 pm on 30 December, 2023", - "session_35_date_time": "12:19 am on 4 January, 2024" - }, - "event_summary": { - "events_session_1": { - "Caroline": [ - "Caroline attends an LGBTQ support group for the first time." - ], - "Melanie": [], - "date": "8 May, 2023" - }, - "events_session_2": { - "Caroline": [ - "Caroline is inspired by her supportive friends and mentors to start researching adoption agencies." - ], - "Melanie": [], - "date": "25 May, 2023" - }, - "events_session_3": { - "Caroline": [ - "Caroline speaks at her school and encourages students to get involved in the LGBTQ community." - ], - "Melanie": [], - "date": "9 June, 2023" - }, - "events_session_4": { - "Caroline": [], - "Melanie": [ - "Melanie takes her family camping for a weekend to bond." - ], - "date": "27 June, 2023" - }, - "events_session_5": { - "Caroline": [], - "Melanie": [ - "Melanie registers for a pottery class." - ], - "date": "3 July, 2023" - }, - "events_session_6": { - "Caroline": [], - "Melanie": [ - "Melanie takes her kids to the local musuem for a day of fun." - ], - "date": "6 July, 2023" - }, - "events_session_7": { - "Caroline": [], - "Melanie": [ - "Melanie begins running longer distances to destress." - ], - "date": "12 July, 2023" - }, - "events_session_8": { - "Caroline": [ - "Caroline attends an adoption council meeting." - ], - "Melanie": [], - "date": "15 July, 2023" - }, - "events_session_9": { - "Caroline": [ - "Caroline joins a mentorship program for LGBTQ youth." - ], - "Melanie": [], - "date": "17 July, 2023" - }, - "events_session_10": { - "Caroline": [ - "Caroline joins a group of connected LGBTQ activists." - ], - "Melanie": [ - "Melanie and her family takes a trip to the beach" - ], - "date": "20 July, 2023" - }, - "events_session_11": { - "Caroline": [], - "Melanie": [ - "Melanie and her family attend an outdoor concert to celebrate her daughter's birthday." - ], - "date": "14 August, 2023" - }, - "events_session_12": { - "Caroline": [ - "Caroline meets a group of religious conservatives on a hike, and they make an unwelcoming comment about her transition." - ], - "Melanie": [ - "Melanie finishes her first pottery project." - ], - "date": "17 August, 2023" - }, - "events_session_13": { - "Caroline": [ - "Caroline begins the adoption process by applying to multiple agencies.", - "Caroline attends a meeting to receive special adoption advice and assistance from the supportive group." - ], - "Melanie": [], - "date": "23 August, 2023" - }, - "events_session_14": { - "Caroline": [ - "Caroline writes a letter to the people she encountered on her hike to apologize for the negative experience they had." - ], - "Melanie": [ - "Melanie and her family volunteer at a local homeless shelter." - ], - "date": "25 August, 2023" - }, - "events_session_15": { - "Caroline": [], - "Melanie": [ - "Melanie takes her kids to a local park" - ], - "date": "28 August, 2023" - }, - "events_session_16": { - "Caroline": [ - "Caroline spends a day out outdoors bike riding and sight seeing with her friends." - ], - "Melanie": [], - "date": "13 September, 2023" - }, - "events_session_17": { - "Caroline": [ - "Caroline calls on her mentor for adoption advice." - ], - "Melanie": [], - "date": "13 October, 2023" - }, - "events_session_18": { - "Caroline": [], - "Melanie": [ - "Melanie's family takes a roadtrip to the Grand Canyon.", - "Melanie's son gets in a car accident while on the roadtrip.", - "Melanie and her family take a roadtrip to visit a nearby national park." - ], - "date": "20 October, 2023" - }, - "events_session_19": { - "Caroline": [ - "Caroline passes the adoption agency interviews." - ], - "Melanie": [], - "date": "22 October, 2023" - } - }, - "observation": { - "session_1_observation": { - "Caroline": [ - [ - "Caroline attended an LGBTQ support group recently and found the transgender stories inspiring.", - "D1:3" - ], - [ - "The support group has made Caroline feel accepted and given her courage to embrace herself.", - "D1:7" - ], - [ - "Caroline is planning to continue her education and explore career options in counseling or mental health to support those with similar issues.", - "D1:9" - ] - ], - "Melanie": [ - [ - "Melanie is currently managing kids and work and finds it overwhelming.", - "D1:2" - ], - [ - "Melanie painted a lake sunrise last year which holds special meaning to her.", - "D1:14" - ], - [ - "Painting is a fun way for Melanie to express her feelings and get creative, helping her relax after a long day.", - "D1:16" - ], - [ - "Melanie is going swimming with the kids after the conversation.", - "D1:18" - ] - ] - }, - "session_2_observation": { - "Melanie": [ - [ - "Melanie ran a charity race for mental health last Saturday.", - "D2:1" - ], - [ - "Melanie is realizing the importance of self-care and its impact on her family.", - "D2:3" - ], - [ - "Melanie carves out me-time each day for activities like running, reading, or playing the violin.", - "D2:5" - ], - [ - "Melanie's kids are excited about summer break and they are considering going camping next month.", - "D2:7" - ] - ], - "Caroline": [ - [ - "Caroline is researching adoption agencies with the dream of having a family and providing a loving home to kids in need.", - "D2:8" - ], - [ - "Caroline chose an adoption agency that helps LGBTQ+ folks with adoption due to their inclusivity and support.", - "D2:12" - ], - [ - "Caroline is excited to create a family for kids who need one, even though she anticipates challenges as a single parent.", - "D2:14" - ] - ] - }, - "session_3_observation": { - "Caroline": [ - [ - "Caroline started transitioning three years ago.", - "D3:1" - ], - [ - "Caroline gave a talk at a school event about her transgender journey and encouraged students to get involved in the LGBTQ community.", - "D3:1" - ], - [ - "Caroline believes conversations about gender identity and inclusion are necessary and is thankful for being able to give a voice to the trans community.", - "D3:3" - ], - [ - "Caroline feels sharing experiences is important to help promote understanding and acceptance.", - "D3:5" - ], - [ - "Caroline feels blessed with a lot of love and support throughout her journey.", - "D3:5" - ], - [ - "Caroline aims to pass on the love and support she has received by sharing stories to build a strong and supportive community of hope.", - "D3:5" - ], - [ - "Caroline's friends, family, and mentors are her rocks, motivating her and giving her strength to push on.", - "D3:11" - ], - [ - "Caroline has known her friends for 4 years, since moving from her home country, and values their love and help, especially after a tough breakup.", - "D3:13" - ] - ], - "Melanie": [ - [ - "Melanie is supportive of Caroline and proud of her for spreading awareness and inspiring others in the LGBTQ community.", - "D3:2" - ], - [ - "Melanie believes talking about inclusivity and acceptance is crucial.", - "D3:4" - ], - [ - "Melanie values family moments and feels they make life awesome, alive, and happy.", - "D3:20" - ], - [ - "Melanie has a husband and kids who keep her motivated.", - "D3:14" - ], - [ - "Melanie has been married for 5 years.", - "D3:16" - ], - [ - "Melanie cherishes time with family and feels most alive and happy during those moments.", - "D3:22" - ] - ] - }, - "session_4_observation": { - "Caroline": [ - [ - "Caroline received a special necklace as a gift from her grandmother in Sweden, symbolizing love, faith, and strength.", - "D4:3" - ], - [ - "Caroline treasures a hand-painted bowl made by a friend for her 18th birthday, which reminds her of art and self-expression.", - "D4:5" - ], - [ - "Caroline is considering a career in counseling and mental health, particularly working with trans people to help them accept themselves and support their mental health.", - "D4:11" - ], - [ - "Caroline attended an LGBTQ+ counseling workshop focused on therapeutic methods and supporting trans individuals, finding it enlightening and inspiring.", - "D4:13" - ], - [ - "Caroline's motivation to pursue counseling comes from her own journey, the support she received, and the positive impact counseling had on her life.", - "D4:15" - ] - ], - "Melanie": [ - [ - "Melanie went camping with her family in the mountains last week and had a great time exploring nature, roasting marshmallows, and hiking.", - "D4:8" - ], - [ - "Melanie values family time and finds it to be special and important.", - "D4:10" - ] - ] - }, - "session_5_observation": { - "Caroline": [ - [ - "Caroline attended an LGBTQ+ pride parade last week and felt a sense of belonging and happiness.", - "D5:1" - ], - [ - "Caroline is considering a career in counseling and mental health to help others.", - "D5:3" - ], - [ - "Caroline is currently learning the piano to get creative.", - "D5:5" - ], - [ - "Caroline is looking forward to attending a transgender conference this month to meet others in the community and learn about advocacy.", - "D5:13" - ] - ], - "Melanie": [ - [ - "Melanie signed up for a pottery class and finds it therapeutic for self-expression and creativity.", - "D5:4" - ], - [ - "Melanie is a big fan of pottery and finds it calming and creative.", - "D5:6" - ], - [ - "Melanie made a black and white bowl in her pottery class which she is proud of.", - "D5:8" - ], - [ - "Pottery is a significant part of Melanie's life as it helps her express her emotions and brings her joy.", - "D5:10" - ] - ] - }, - "session_6_observation": { - "Caroline": [ - [ - "Caroline has been looking into counseling or mental health work and is passionate about helping people and making a positive impact.", - "D6:3" - ], - [ - "Caroline is creating a library for when she has kids, as she looks forward to reading to them and opening up their minds.", - "D6:7" - ], - [ - "Caroline has a collection of kids' books in her library including classics, stories from different cultures, and educational books.", - "D6:9" - ], - [ - "Caroline appreciates the importance of friendship and compassion in her life and is lucky to have friends and family helping with her transition.", - "D6:11" - ], - [ - "Caroline's friends and family have been there for her every step of the way, providing love, guidance, and acceptance during her transition.", - "D6:13" - ] - ], - "Melanie": [ - [ - "Melanie took her kids to the museum recently and enjoyed seeing their excitement at the dinosaur exhibit.", - "D6:4" - ], - [ - "Melanie loves spending time with her kids and seeing the joy in their eyes when exploring new things.", - "D6:4" - ], - [ - "Melanie and her family enjoy camping at the beach as it brings them closer together.", - "D6:16" - ] - ] - }, - "session_7_observation": { - "Caroline": [ - [ - "Caroline attended an LGBTQ conference recently and felt accepted and supported, emphasizing the importance of fighting for trans rights and spreading awareness.", - "D7:1" - ], - [ - "Caroline is looking into counseling and mental health jobs to provide support to others, motivated by her own struggles with mental health and the help she received.", - "D7:5" - ], - [ - "Caroline's favorite guiding book is 'Becoming Nicole' by Amy Ellis Nutt, a true story about a trans girl and her family that gave her hope and connection.", - "D7:11" - ], - [ - "According to 'Becoming Nicole,' Caroline learned the importance of self-acceptance, finding support, and the existence of hope and love.", - "D7:13" - ], - [ - "Caroline values the role of pets in bringing joy and comfort.", - "D7:13" - ] - ], - "Melanie": [ - [ - "Melanie finds LGBTQ events like the conference Caroline attended to be reminding of the strength of community.", - "D7:2" - ], - [ - "Melanie supports Caroline's drive to make a difference in LGBTQ rights.", - "D7:4" - ], - [ - "Melanie reminds Caroline to pursue her dreams and appreciates the power of books in guiding and motivating her.", - "D7:8" - ], - [ - "Melanie has a dog named Luna and a cat named Oliver that bring joy and liveliness to her home.", - "D7:18" - ], - [ - "Melanie got new running shoes for running, which she finds great for destressing and clearing her mind.", - "D7:20" - ], - [ - "Running has been great for Melanie's mental health and mood.", - "D7:24" - ] - ] - }, - "session_8_observation": { - "Caroline": [ - [ - "Caroline attended a council meeting for adoption last Friday and found it inspiring and emotional.", - "D8:9" - ], - [ - "Caroline went to a pride parade a few weeks ago and felt accepted and happy being around people who celebrated her.", - "D8:19" - ], - [ - "Caroline felt proud and grateful at the pride parade feeling accepted by the community.", - "D8:21" - ], - [ - "Caroline expressed feeling comforted by being around accepting and loving people.", - "D8:21" - ], - [ - "Caroline mentioned the importance of finding peace and mental health through expressions of authentic self.", - "D8:25" - ], - [ - "Caroline expressed pride in the courage to transition, finding freedom in expressing herself authentically.", - "D8:25" - ] - ], - "Melanie": [ - [ - "Melanie took her kids to a pottery workshop last Friday where they made their own pots.", - "D8:2" - ], - [ - "Melanie and her kids enjoy nature-inspired painting projects.", - "D8:6" - ], - [ - "Melanie's favorite part of her wedding was marrying her partner and promising to be together forever.", - "D8:16" - ], - [ - "Melanie finds joy in flowers which represent growth, beauty, and appreciating small moments.", - "D8:12" - ], - [ - "Melanie shared that family and creativity keep her at peace.", - "D8:28" - ], - [ - "Melanie's family has been supportive and loving during tough times and helped her through.", - "D8:32" - ] - ] - }, - "session_9_observation": { - "Melanie": [ - [ - "Melanie went camping with her family two weekends ago.", - "D9:1" - ], - [ - "Melanie enjoys unplugging and hanging out with her kids.", - "D9:1" - ], - [ - "Melanie and her kids finished a painting before the conversation.", - "D9:17" - ] - ], - "Caroline": [ - [ - "Caroline joined a mentorship program for LGBTQ youth over the weekend.", - "D9:2" - ], - [ - "Caroline mentors a transgender teen and they work on building confidence and positive strategies.", - "D9:6" - ], - [ - "Caroline and her mentee had a great time at the LGBT pride event the previous month.", - "D9:6" - ], - [ - "Caroline is planning an LGBTQ art show with her paintings for next month.", - "D9:12" - ], - [ - "Caroline painted a piece inspired by a visit to an LGBTQ center, aiming to capture unity and strength.", - "D9:16" - ] - ] - }, - "session_10_observation": { - "Caroline": [ - [ - "Caroline joined a new LGBTQ activist group called 'Connected LGBTQ Activists' last Tuesday.", - "D10:3" - ], - [ - "Caroline and her LGBTQ activist group plan events and campaigns to support each other and positive changes.", - "D10:5" - ], - [ - "Caroline and her activist group participated in a pride parade last weekend to celebrate love and diversity.", - "D10:7" - ] - ], - "Melanie": [ - [ - "Melanie enjoys family beach trips with her kids once or twice a year.", - "D10:8" - ], - [ - "Melanie's family tradition includes a camping trip where they roast marshmallows and tell stories around the campfire.", - "D10:12" - ], - [ - "Melanie and her family watched the Perseid meteor shower during a camping trip last year and it was a memorable experience.", - "D10:14" - ], - [ - "Melanie treasures the memory of her youngest child taking her first steps.", - "D10:20" - ] - ] - }, - "session_11_observation": { - "Melanie": [ - [ - "Melanie celebrated her daughter's birthday with a concert featuring Matt Patterson.", - "D11:1" - ], - [ - "Melanie values special moments with her kids and is grateful for them.", - "D11:1" - ], - [ - "Melanie appreciates cultivating a loving and accepting environment for her kids.", - "D11:7" - ], - [ - "Melanie values inclusivity in her interactions and work as an artist.", - "D11:7" - ], - [ - "Melanie admires Caroline's art and appreciates the themes of self-acceptance and love.", - "D11:13" - ] - ], - "Caroline": [ - [ - "Caroline attended a pride parade recently and felt inspired by the community's energy and support for LGBTQ rights.", - "D11:4" - ], - [ - "Caroline represents inclusivity and diversity in her art and uses it to advocate for the LGBTQ+ community.", - "D11:8" - ], - [ - "Caroline's art focuses on expressing her trans experience and educating others about the trans community.", - "D11:10" - ], - [ - "Caroline's painting 'Embracing Identity' symbolizes self-acceptance, love, and the journey to being oneself.", - "D11:12" - ], - [ - "Caroline finds art to be healing and a way to connect with her self-discovery and acceptance journey.", - "D11:14" - ], - [ - "Caroline values sharing her art and experiences with others, such as Melanie.", - "D11:16" - ] - ] - }, - "session_12_observation": { - "Caroline": [ - [ - "Caroline had a not-so-great experience on a hike where she ran into a group of religious conservatives who upset her.", - "D12:1" - ], - [ - "Caroline values having people around her who accept and support her.", - "D12:1" - ], - [ - "Caroline expresses that surrounding herself with things that bring joy is important because life is too short.", - "D12:9" - ], - [ - "Caroline values happy moments and believes they are essential to keep going, especially during tough times.", - "D12:11" - ], - [ - "Caroline expresses appreciation for her friendship with Melanie.", - "D12:13" - ], - [ - "Caroline had a great time with the whole gang at the Pride fest last year and values supportive friends.", - "D12:15" - ] - ], - "Melanie": [ - [ - "Melanie finished another pottery project and expresses pride in her work.", - "D12:2" - ], - [ - "Melanie's pottery project was a source of happiness and fulfillment for her.", - "D12:8" - ], - [ - "Melanie has a strong connection to art, considering it both a sanctuary and a source of comfort.", - "D12:8" - ], - [ - "Melanie values friendship with Caroline and expresses appreciation for it.", - "D12:14" - ], - [ - "Melanie suggests doing a family outing or planning something special for the summer with Caroline to make awesome memories.", - "D12:16" - ] - ] - }, - "session_13_observation": { - "Caroline": [ - [ - "Caroline took the first step towards becoming a mom by applying to adoption agencies.", - "D13:1" - ], - [ - "Caroline attended an adoption advice/assistance group to help with her decision.", - "D13:1" - ], - [ - "Caroline has a guinea pig named Oscar.", - "D13:3" - ], - [ - "Caroline used to go horseback riding with her dad when she was a kid.", - "D13:7" - ], - [ - "Caroline loves horses and has a love for them.", - "D13:7" - ], - [ - "Caroline expresses herself through painting and values art for exploring identity and being therapeutic.", - "D13:13" - ], - [ - "Caroline values supportive people, promotes LGBTQ rights, and aims to live authentically.", - "D13:15" - ] - ], - "Melanie": [ - [ - "Melanie has pets including another cat named Bailey.", - "D13:4" - ], - [ - "Melanie shared a photo of her horse painting that she recently did.", - "D13:8" - ], - [ - "Melanie enjoys painting animals and finds it peaceful and special.", - "D13:10" - ], - [ - "Melanie expresses herself through painting and values art for showing who we really are and getting in touch with ourselves.", - "D13:14" - ] - ] - }, - "session_14_observation": { - "Caroline": [ - [ - "Caroline went hiking last week and got into a bad spot with some people but tried to apologize.", - "D14:1" - ], - [ - "Caroline painted a vivid sunset inspired by a beach visit.", - "D14:7" - ], - [ - "Caroline transitioned and joined the transgender community seeking acceptance and support.", - "D14:13" - ], - [ - "Caroline created a rainbow flag mural symbolizing courage and strength of the trans community.", - "D14:15" - ], - [ - "Caroline made a stained glass window showcasing personal journey as a transgender woman and the acceptance of growth and change.", - "D14:19" - ], - [ - "Caroline found a vibrant rainbow sidewalk during Pride Month, which reminded her of love and acceptance.", - "D14:23" - ], - [ - "Caroline is organizing an LGBTQ art show next month to showcase paintings and talents of LGBTQ artists aimed at spreading understanding and acceptance.", - "D14:33" - ] - ], - "Melanie": [ - [ - "Melanie made a plate in pottery class and finds pottery relaxing and creative.", - "D14:4" - ], - [ - "Melanie loves painting landscapes and still life.", - "D14:30" - ], - [ - "Melanie volunteered with her family at a homeless shelter to make a difference.", - "D14:10" - ], - [ - "Melanie appreciates and admires Caroline's courage as a trans person.", - "D14:16" - ], - [ - "Melanie created a painting inspired by autumn.", - "D14:32" - ] - ] - }, - "session_15_observation": { - "Caroline": [ - [ - "Caroline had the opportunity to volunteer at an LGBTQ+ youth center and found it gratifying to support and guide the young people there.", - "D15:3" - ], - [ - "Caroline shared her story with the young people at the LGBTQ+ youth center and felt fulfilled by the experience.", - "D15:5" - ], - [ - "Caroline plans to continue volunteering at the youth center as she believes in community and supporting others.", - "D15:9" - ], - [ - "Caroline is involved in organizing a talent show for the kids at the youth center.", - "D15:11" - ], - [ - "Caroline mentioned that playing the guitar helps her express her emotions.", - "D15:19" - ], - [ - "Caroline started playing acoustic guitar about five years ago as a way to express herself and escape in her emotions.", - "D15:21" - ], - [ - "Caroline finds the song \"Brave\" by Sara Bareilles significant and inspiring as it resonates with her journey and determination to make a difference.", - "D15:23" - ] - ], - "Melanie": [ - [ - "Melanie took her kids to a park and enjoyed seeing them have fun exploring and playing.", - "D15:2" - ], - [ - "Melanie plays the clarinet as a way to express herself and relax.", - "D15:26" - ], - [ - "Melanie enjoys classical music like Bach and Mozart, as well as modern music like Ed Sheeran's \"Perfect\".", - "D15:28" - ] - ] - }, - "session_16_observation": { - "Caroline": [ - [ - "Caroline spends time with friends biking and exploring nature.", - "D16:1" - ], - [ - "Caroline is very focused on making a difference for the LGBTQ+ community through her work.", - "D16:5" - ], - [ - "Caroline has been creating art since the age of 17.", - "D16:7" - ], - [ - "Caroline uses art to express her feelings and explore her gender identity.", - "D16:9" - ], - [ - "Caroline made a painting representing her journey as a trans woman.", - "D16:13" - ], - [ - "Caroline's relationships have changed due to her journey, some friends were not able to handle the changes.", - "D16:15" - ] - ], - "Melanie": [ - [ - "Melanie enjoys camping with her kids, exploring the forest, and hiking.", - "D16:2" - ], - [ - "Melanie finds inspiration in seeing her kids excited about learning new things about nature.", - "D16:4" - ], - [ - "Melanie has been into art for seven years, finding a passion for painting and pottery.", - "D16:8" - ], - [ - "Melanie uses painting and pottery as a calming and satisfying creative outlet.", - "D16:8" - ] - ] - }, - "session_17_observation": { - "Caroline": [ - [ - "Caroline is looking into adoption and contacted her mentor for advice.", - "D17:1" - ], - [ - "Caroline sees adoption as a way to share her love and provide a safe, loving home for kids in need.", - "D17:3" - ], - [ - "Caroline recommends doing research, preparing emotionally, and gathering necessary documents when starting the adoption process.", - "D17:7" - ], - [ - "Caroline recently went to a transgender poetry reading event that was empowering and celebrated self-expression.", - "D17:19" - ], - [ - "Caroline is inspired by freedom and being true to oneself.", - "D17:23" - ] - ], - "Melanie": [ - [ - "Melanie had a setback due to an injury that led her to take a break from pottery, which she uses for self-expression and peace.", - "D17:8" - ], - [ - "Melanie continued expressing herself through reading and painting during her break from pottery.", - "D17:10" - ], - [ - "Melanie enjoys expressing emotions through art, like painting inspired by sunsets and abstract art.", - "D17:13" - ], - [ - "Melanie finds blue a calming color and uses it to convey tranquility in her art.", - "D17:16" - ] - ] - }, - "session_18_observation": { - "Melanie": [ - [ - "Melanie went on a road trip with her family which started off with an accident involving her son.", - "D18:1" - ], - [ - "Melanie's son got into an accident during the road trip.", - "D18:1" - ], - [ - "Melanie's family visited the Grand Canyon and enjoyed it.", - "D18:5" - ], - [ - "Melanie finds peace and serenity in nature, particularly during camping trips with her family.", - "D18:19" - ], - [ - "Melanie believes that being in nature refreshes her soul and helps her reset and recharge.", - "D18:21" - ] - ], - "Caroline": [ - [ - "Caroline acknowledged the traumatic experience of Melanie's family being in an accident during the road trip.", - "D18:2" - ], - [ - "Caroline believes that loved ones give strength to tackle challenges.", - "D18:10" - ], - [ - "Caroline appreciates seeing Melanie's love for her family and acknowledges that they are her rock.", - "D18:12" - ], - [ - "Caroline finds nature refreshing and discussed how it can bring peace.", - "D18:18" - ], - [ - "Caroline appreciates the peaceful and calming nature of spending quality time with family in nature.", - "D18:22" - ] - ] - }, - "session_19_observation": { - "Caroline": [ - [ - "Caroline passed the adoption agency interviews last Friday and is excited about building her own family through adoption.", - "D19:1" - ], - [ - "Caroline's vision for the future includes creating a safe and loving home for needy kids to experience love and acceptance.", - "D19:3" - ], - [ - "Caroline finds empowerment in making a positive difference in someone's life by offering love and support.", - "D19:7" - ], - [ - "Caroline went through a tough process of finding self-acceptance but is now ready to help others who need support.", - "D19:7" - ], - [ - "Caroline received invaluable help from friends, family, and role models during the process of finding acceptance.", - "D19:9" - ], - [ - "Caroline's journey of self-discovery has been amazing and she finds joy in bringing comfort and support to others.", - "D19:9" - ] - ], - "Melanie": [ - [ - "Melanie bought figurines that remind her of family love.", - "D19:2" - ], - [ - "Melanie appreciates Caroline's passion for helping kids and finds her inspiring.", - "D19:6" - ], - [ - "Melanie respects Caroline's journey of finding acceptance and admires her strength and inspiration to help others.", - "D19:8" - ], - [ - "Melanie is supportive and expresses happiness for Caroline finding her true self and helping others.", - "D19:10" - ], - [ - "Melanie values the mutual support they provide to each other and appreciates the encouragement of close ones.", - "D19:13" - ] - ] - } - }, - "session_summary": { - "session_1_summary": "Caroline and Melanie had a conversation on 8 May 2023 at 1:56 pm. Caroline mentioned that she attended an LGBTQ support group and was inspired by the transgender stories she heard. The support group made her feel accepted and gave her the courage to embrace herself. Caroline plans to continue her education and explore career options, particularly in counseling or working in mental health. Melanie praised Caroline's empathy and mentioned that she painted a lake sunrise last year as a way of expressing herself. Caroline complimented Melanie's painting and agreed that painting is a great outlet for relaxation and self-expression. They both emphasized the importance of taking care of oneself. Caroline was going to do some research, while Melanie planned to go swimming with her kids.", - "session_2_summary": "On May 25, 2023 at 1:14 pm, Melanie tells Caroline about her recent experience running a charity race for mental health. Caroline expresses pride and agrees that taking care of oneself is important. Melanie shares her struggle with self-care but mentions that she is carving out time each day for activities that refresh her. Caroline encourages Melanie and praises her efforts. Melanie then asks Caroline about her plans for the summer, to which Caroline replies that she is researching adoption agencies as she wants to give a loving home to children in need. Melanie praises Caroline's decision and expresses excitement for her future family. Caroline explains that she chose an adoption agency that supports the LGBTQ+ community because of its inclusivity and support. Melanie commends Caroline's choice and asks what she is looking forward to in the adoption process. Caroline says she is thrilled to create a family for kids who need one, despite the challenges of being a single parent. Melanie encourages Caroline and expresses confidence in her ability to provide a safe and loving home. The conversation ends with Melanie expressing her excitement for Caroline's new chapter.", - "session_3_summary": "Caroline and Melanie had a conversation at 7:55 pm on 9 June, 2023. Caroline shared about her school event last week where she talked about her transgender journey and encouraged students to get involved in the LGBTQ community. Melanie praised Caroline for spreading awareness and inspiring others with her strength and courage. They discussed the importance of conversations about gender identity and inclusion. Caroline expressed gratitude for the support she has received and the opportunity to give a voice to the trans community. Melanie commended Caroline for using her voice to create love, acceptance, and hope. They talked about the power of sharing personal stories and the impact it can have on others. They both expressed a desire to make a positive difference and support each other. Melanie mentioned that her family motivates her, while Caroline mentioned that her friends, family, and mentors are her support system. They shared photos of their loved ones and talked about the length of their relationships. Melanie mentioned being married for 5 years and Caroline expressed congratulations and well-wishes. They discussed the importance of cherishing family moments and finding happiness in them. They both agreed that family is everything.", - "session_4_summary": "Caroline and Melanie catch up after a long time. Caroline shows Melanie her special necklace, which was a gift from her grandmother in Sweden and represents love, faith, and strength. Melanie admires it and asks if Caroline has any other treasured items. Caroline mentions a hand-painted bowl made by a friend on her 18th birthday, which reminds her of art and self-expression. Melanie shares that she recently went camping with her family and had a great time exploring nature and bonding with her kids. They discuss the importance of family moments. Caroline reveals that she is looking into a career in counseling and mental health, specifically wanting to work with trans people. She attended an LGBTQ+ counseling workshop and found it enlightening. Melanie praises Caroline for her dedication and asks about her motivation to pursue counseling. Caroline shares how her own journey and the support she received inspired her to help others. Melanie commends Caroline's hard work and passion. Caroline expresses gratitude for Melanie's kind words, and Melanie congratulates Caroline for pursuing what she cares about.", - "session_5_summary": "Caroline had recently attended an LGBTQ+ pride parade and felt a sense of belonging and community. This experience inspired her to use her own story to help others, possibly through counseling or mental health work. Melanie, in turn, shared that she had recently signed up for a pottery class as a way to express herself and find calmness. The two discussed their creative endeavors, with Melanie showing Caroline a bowl she had made in her class. Caroline praised Melanie's work and expressed her excitement for the upcoming transgender conference she would be attending. Melanie wished her a great time at the conference and encouraged her to have fun and stay safe.", - "session_6_summary": "Caroline and Melanie caught up with each other at 8:18 pm on 6 July, 2023. Caroline shared that since their last chat, she has been exploring counseling or mental health work because she is passionate about helping people. Melanie praised Caroline for following her dreams. Melanie mentioned that she recently took her kids to the museum and enjoyed watching their excitement. Caroline was curious about what had them so excited, and Melanie explained that they loved the dinosaur exhibit. Caroline mentioned that she is creating a library for future kids and looks forward to reading to them. Melanie asked about the books she has in her library, and Caroline mentioned classics, stories from different cultures, and educational books. Melanie shared that her favorite book from childhood was \"Charlotte's Web,\" and Caroline agreed that it showed the importance of friendship and compassion. Caroline mentioned that her friends and family have been a great support system during her transition. Melanie praised Caroline for having people who support her and shared a photo of her family camping at the beach.", - "session_7_summary": "Caroline and Melanie had a conversation at 4:33 pm on 12 July, 2023. Caroline talked about attending an LGBTQ conference recently, where she felt accepted and connected with others who have similar experiences. She expressed her gratitude for the LGBTQ community and her desire to fight for trans rights. Melanie praised Caroline for her drive to make a difference and asked about her plan to contribute. Caroline mentioned that she is looking into counseling and mental health jobs, as she wants to provide support for others. Melanie commended Caroline for her inspiring goal and mentioned a book she read that reminds her to pursue her dreams. They discussed the power of books, and Caroline recommended \"Becoming Nicole\" by Amy Ellis Nutt, which had a positive impact on her own life. She mentioned that the book taught her about self-acceptance and finding support. Melanie agreed and added that pets also bring joy and comfort. They talked about their own pets and shared pictures. Melanie mentioned that she has been running more to destress and clear her mind. Caroline encouraged her to keep it up and take care of her mental health. Melanie expressed her gratitude for the improvements in her mental health.", - "session_8_summary": "Caroline and Melanie spoke at 1:51 pm on 15 July, 2023. Melanie mentioned that she took her kids to a pottery workshop and they all made their own pots. Caroline commented on how cute the cup that the kids made was and how she loved seeing kids express their personalities through art. Melanie also mentioned that she and the kids enjoy painting together, particularly nature-inspired paintings. Caroline admired their latest painting and Melanie mentioned that they found lovely flowers to paint. Caroline then shared that she attended a council meeting for adoption, which inspired her to adopt in the future. Melanie complimented a photo of a blue vase that Caroline shared and they discussed the meanings of flowers. Melanie mentioned that flowers remind her of her wedding and Caroline expressed regret for not knowing Melanie back then. Melanie said that her wedding day was full of love and joy and her favorite part was marrying her partner. Caroline then shared a special memory of attending a pride parade and how accepting and happy she felt. They discussed the importance of a supportive community. Caroline mentioned that the best part was realizing she could be herself without fear and having the courage to transition. Melanie expressed admiration for Caroline's courage and the importance of finding peace. Melanie mentioned that her family has been supportive during her move. Caroline commented on", - "session_9_summary": "Caroline has joined a mentorship program for LGBTQ youth, which she finds rewarding. She has been supporting a transgender teen and they had a great time at an LGBT pride event. Caroline is also preparing for an LGBTQ art show next month. Melanie thinks Caroline's painting for the art show is awesome and asks what inspired her. Caroline explains that she painted it after visiting an LGBTQ center and wanted to capture unity and strength. Meanwhile, Melanie and her kids have finished another painting.", - "session_10_summary": "Caroline and Melanie had a conversation at 8:56 pm on 20 July, 2023. Caroline told Melanie that she recently joined a new LGBTQ activist group and is enjoying making a difference. Melanie expressed her happiness for Caroline and wanted to know more about the group. Caroline explained that the group, \"Connected LGBTQ Activists,\" is focused on positive changes and supporting each other. Melanie praised the group and asked if Caroline has participated in any events or campaigns. Caroline mentioned a recent pride parade in their city and how it was a powerful reminder of the fight for equality. Melanie shared that she recently went to the beach with her kids, which they thoroughly enjoyed. Caroline inquired about other summer traditions, and Melanie mentioned their family camping trip as the highlight of their summer. She recalled witnessing the Perseid meteor shower and how it made her feel in awe of the universe. Caroline asked about the experience, and Melanie described it as breathtaking, making her appreciate life. Melanie then shared another special memory of her youngest child taking her first steps. Caroline found it sweet and mentioned that such milestones remind us of the special bonds we have. Melanie agreed and expressed gratitude for her family. Caroline praised Melanie for having an awesome family. Melanie thanked Caroline and expressed her happiness for having a", - "session_11_summary": "On August 14, 2023, at 2:24 pm, Melanie and Caroline had a conversation. Melanie shared that she had a great time at a concert celebrating her daughter's birthday, while Caroline attended an advocacy event that focused on love and support. Melanie asked Caroline about her experience at the pride parade, to which Caroline responded by expressing her pride in being part of the LGBTQ community and fighting for equality. Melanie then shared a picture from the concert and discussed the importance of creating a loving and inclusive environment for their kids. Caroline mentioned that she incorporates inclusivity and diversity in her artwork and uses it to advocate for acceptance of the LGBTQ+ community. Melanie praised Caroline's art and asked about its main message, to which Caroline replied that her art is about expressing her trans experience and helping people understand the trans community. Melanie requested to see another painting, and Caroline shared one called \"Embracing Identity,\" which represents self-acceptance and love. Caroline explained that art has helped her in her own self-discovery and acceptance journey. Melanie acknowledged the healing power of art and thanked Caroline for sharing her work. They ended the conversation by inviting each other to reach out anytime.", - "session_12_summary": "Caroline tells Melanie about a negative experience she had with religious conservatives while hiking, which reminds her of the work still needed for LGBTQ rights. She expresses gratitude for the support and acceptance she has from those around her. Melanie sympathizes with Caroline and shows her a picture of a pottery project she recently finished. Caroline expresses interest in seeing the picture and compliments Melanie's work. Melanie explains that the colors and patterns were inspired by her love for them and how painting helps her express her feelings. Caroline praises Melanie's creativity and passion. Melanie expresses her deep connection to art and how it brings her happiness and fulfillment. Caroline agrees that surrounding oneself with things that bring joy is important. They both agree that finding happiness is key in life. They express appreciation for each other's friendship and support. They reminisce about a fun time at a Pride fest and discuss plans for a family outing or a special trip just for the two of them. They agree to plan something special and look forward to making more memories together.", - "session_13_summary": "Caroline shared with Melanie that she applied to adoption agencies and received help from an adoption assistance group. Melanie congratulated Caroline and asked about her pets. Caroline mentioned her guinea pig named Oscar. Melanie shared that she had another cat named Bailey and showed Caroline a picture of her cat Oliver. Caroline shared a picture of Oscar eating parsley. Melanie mentioned that Oliver hid his bone in her slipper. Caroline reminisced about horseback riding with her dad and shared that she loves horses. Melanie showed Caroline a horse painting she did. Caroline shared a self-portrait she recently made, mentioning how painting helps her explore her identity. Melanie agreed and asked what else helps her. Caroline mentioned having supportive people and promoting LGBTQ rights. Melanie commended Caroline for her care and wished her the best on her adoption journey. They said goodbye and Melanie offered her support.", - "session_14_summary": "Caroline tells Melanie that she went hiking last week and got into a bad situation with some people. She tried to apologize to them. Melanie is supportive and says that it takes a lot of courage and maturity to apologize. Melanie shows Caroline a pottery plate she made and Caroline compliments her on it. Melanie says that pottery is relaxing and creative. Caroline says that she has been busy painting and shows Melanie a painting of a sunset that she recently finished. Melanie compliments the painting and Caroline explains that she was inspired by a visit to the beach. Melanie says that she can feel the serenity of the beach in the painting. They discuss how art can connect people and Melanie mentions a volunteering experience at a homeless shelter. Caroline praises Melanie for her volunteering efforts. Melanie asks Caroline about her decision to transition and join the transgender community. Caroline explains that finding a supportive community has meant a lot to her and shows Melanie a mural that she created, explaining its symbolism. Melanie praises Caroline's courage as a trans person. Melanie asks Caroline if she has made any more art and Caroline shows her a stained glass window that she made for a local church. They discuss the inspiration behind the window and Melanie compliments Caroline on her artistry. Caroline shows Melanie a picture of a rainbow sidewalk that she found in her neighborhood", - "session_15_summary": "Caroline and Melanie had a conversation at 3:19 pm on 28 August, 2023. Melanie had taken her kids to a park and enjoyed seeing them have fun outdoors. Caroline had been volunteering at an LGBTQ+ youth center and found it gratifying and fulfilling to support and guide the young people there. Melanie asked Caroline about her experience at the youth center and Caroline shared that connecting with the young folks and sharing her story had been meaningful and made her feel like she could make a difference. Caroline expressed her dedication to continuing volunteering at the youth center and mentioned that they were planning a talent show for the kids. Melanie expressed her excitement and support for Caroline's dedication to helping others. They also briefly discussed a band Melanie saw and Caroline spoke about her love for music and playing the guitar. They shared their favorite songs and agreed on the power of music to inspire and uplift.", - "session_16_summary": "Caroline and Melanie were chatting at 12:09 am on 13 September, 2023. Caroline told Melanie about her biking trip with friends and sent her a stunning picture. Melanie complimented the picture and shared her own experience of camping with her kids. They both agreed that being in nature was refreshing for the soul. Melanie asked Caroline about her upcoming plans. Caroline expressed how excited she was about her work volunteering for the LGBTQ+ community and how it inspired her to create art. Melanie admired Caroline's art and shared her own love for painting and pottery. They talked about the therapeutic aspect of art and how it helped them express their feelings. Caroline showed Melanie a painting she made about her journey as a trans woman. Melanie was impressed and proud of Caroline's progress. Caroline spoke about the changes in her relationships and how she was happier being around accepting and loving people. Melanie shared a picture from a caf\u00e9 they visited and assured Caroline that everything was fine despite the serious sign. They ended their conversation by celebrating the joyful moments in life.", - "session_17_summary": "Caroline reached out to her friend Melanie to share her excitement about her decision to adopt and become a mother. Melanie mentioned that she knew someone who had successfully adopted. Caroline gave Melanie some advice on how to get started with the adoption process, emphasizing the importance of research and emotional preparation. Melanie mentioned that she had recently experienced a setback due to an injury, but had found solace in reading and painting. Caroline showed interest in Melanie's paintings, and shared her own recent venture into abstract art. They discussed the emotions behind their artwork and the therapeutic nature of self-expression. Caroline also mentioned attending a poetry reading that celebrated transgender identities, which inspired her to create her own art. Melanie praised Caroline's artwork and they affirmed the importance of staying true to oneself and embracing personal growth and exploration.", - "session_18_summary": "Melanie and Caroline are discussing a recent road trip on October 20, 2023. Melanie mentions that her son got into an accident, but fortunately, he is okay. She reflects on the importance of cherishing family and how they enjoyed their time at the Grand Canyon. Caroline acknowledges the resilience of children and the support that loved ones provide during tough times. Melanie expresses her gratitude for her family, who are her motivation and support. They also discuss the benefits of spending time in nature and how it helps them reset and recharge. Melanie shares that camping with her family brings peace and serenity and allows them to bond. Caroline compliments Melanie on the quality time she spends with her family and remarks on the priceless nature of these experiences.", - "session_19_summary": "Caroline tells Melanie that she passed the adoption agency interviews last Friday and is excited about the progress she's making towards her goal of having a family. Melanie congratulates her and shows her some figurines that remind her of family love. Caroline explains that she wants to build her own family and provide a home for children in need, as a way of giving back and showing love and acceptance. Melanie agrees that everyone deserves love and acceptance and admires Caroline's passion for helping these kids. Caroline shares that finding self-acceptance was a long process for her, but now she's ready to offer love and support to those who need it. Melanie praises Caroline for her strength and inspiration. Caroline credits her friends, family, and role models for helping her find acceptance and wants to pass that same support to others. Melanie tells Caroline that she is happy for her and finds her journey inspiring. Caroline expresses gratitude for the support she's received and considers it a gift to be able to share her journey and help others. Melanie agrees that it's important to be there for each other. Caroline emphasizes the importance of being oneself and living honestly, as it brings freedom and contentment. Both friends express their agreement and appreciation for the support they've received in being true to themselves." - }, - "sample_id": "conv-26" -} \ No newline at end of file diff --git a/hindsight-api/tests/test_agents_api.py b/hindsight-api/tests/test_agents_api.py deleted file mode 100644 index ca66a734..00000000 --- a/hindsight-api/tests/test_agents_api.py +++ /dev/null @@ -1,252 +0,0 @@ -""" -Tests for agent management API (profile, disposition, background). -""" -import pytest -import uuid -from hindsight_api import MemoryEngine, RequestContext -from hindsight_api.api import CreateBankRequest, DispositionTraits -from hindsight_api.engine.memory_engine import Budget - - -def unique_agent_id(prefix: str) -> str: - """Generate a unique agent ID for testing.""" - return f"{prefix}_{uuid.uuid4().hex[:8]}" - - -class TestAgentProfile: - """Tests for agent profile management.""" - - @pytest.mark.asyncio - async def test_get_agent_profile_creates_default(self, memory: MemoryEngine, request_context): - """Test that getting a profile for a new agent creates default disposition.""" - bank_id = unique_agent_id("test_profile_default") - - profile = await memory.get_bank_profile(bank_id, request_context=request_context) - - assert profile is not None - assert "disposition" in profile - assert "background" in profile - - disposition = profile["disposition"] - assert disposition.skepticism == 3 - assert disposition.literalism == 3 - assert disposition.empathy == 3 - - assert profile["background"] == "" - - @pytest.mark.asyncio - async def test_update_agent_disposition(self, memory: MemoryEngine, request_context): - """Test updating agent disposition traits.""" - bank_id = unique_agent_id("test_profile_update") - - profile = await memory.get_bank_profile(bank_id, request_context=request_context) - assert profile["disposition"].skepticism == 3 - - new_disposition = { - "skepticism": 5, - "literalism": 4, - "empathy": 2, - } - await memory.update_bank_disposition(bank_id, new_disposition, request_context=request_context) - - updated_profile = await memory.get_bank_profile(bank_id, request_context=request_context) - disposition = updated_profile["disposition"] - assert disposition.skepticism == new_disposition["skepticism"] - assert disposition.literalism == new_disposition["literalism"] - assert disposition.empathy == new_disposition["empathy"] - - @pytest.mark.asyncio - async def test_list_agents(self, memory: MemoryEngine, request_context): - """Test listing all agents.""" - agent_id_1 = unique_agent_id("test_list") - agent_id_2 = unique_agent_id("test_list") - agent_id_3 = unique_agent_id("test_list") - - await memory.get_bank_profile(agent_id_1, request_context=request_context) - await memory.get_bank_profile(agent_id_2, request_context=request_context) - await memory.get_bank_profile(agent_id_3, request_context=request_context) - - agents = await memory.list_banks(request_context=request_context) - - agent_ids = [a["bank_id"] for a in agents] - assert agent_id_1 in agent_ids - assert agent_id_2 in agent_ids - assert agent_id_3 in agent_ids - - for agent in agents: - assert "bank_id" in agent - assert "disposition" in agent - assert "background" in agent - assert "created_at" in agent - assert "updated_at" in agent - - -class TestAgentBackground: - """Tests for agent background management.""" - - @pytest.mark.asyncio - async def test_merge_agent_background(self, memory: MemoryEngine, request_context): - """Test merging agent background information.""" - bank_id = unique_agent_id("test_profile_merge") - - profile = await memory.get_bank_profile(bank_id, request_context=request_context) - assert profile["background"] == "" - - result1 = await memory.merge_bank_background( - bank_id, - "I was born in Texas", - update_disposition=False, - request_context=request_context, - ) - assert "Texas" in result1["background"] - - result2 = await memory.merge_bank_background( - bank_id, - "I have 10 years of startup experience", - update_disposition=False, - request_context=request_context, - ) - assert "Texas" in result2["background"] or "startup" in result2["background"] - - final_profile = await memory.get_bank_profile(bank_id, request_context=request_context) - assert final_profile["background"] != "" - - @pytest.mark.asyncio - async def test_merge_background_handles_conflicts(self, memory: MemoryEngine, request_context): - """Test that merging background handles conflicts (new overwrites old).""" - bank_id = unique_agent_id("test_profile_conflict") - - result1 = await memory.merge_bank_background( - bank_id, - "I was born in Colorado", - update_disposition=False, - request_context=request_context, - ) - assert "Colorado" in result1["background"] - - result2 = await memory.merge_bank_background( - bank_id, - "You were born in Texas", - update_disposition=False, - request_context=request_context, - ) - assert "Texas" in result2["background"] - - -class TestAgentEndpoint: - """Tests for agent PUT endpoint logic.""" - - @pytest.mark.asyncio - async def test_put_agent_create(self, memory: MemoryEngine, request_context): - """Test creating an agent via PUT endpoint.""" - bank_id = unique_agent_id("test_put_create") - - request = CreateBankRequest( - disposition=DispositionTraits( - skepticism=4, - literalism=5, - empathy=2 - ), - background="I am a creative software engineer" - ) - - profile = await memory.get_bank_profile(bank_id, request_context=request_context) - - if request.disposition is not None: - await memory.update_bank_disposition( - bank_id, - request.disposition.model_dump(), - request_context=request_context, - ) - - if request.background is not None: - pool = await memory._get_pool() - async with pool.acquire() as conn: - await conn.execute( - """ - UPDATE banks - SET background = $2, - updated_at = NOW() - WHERE bank_id = $1 - """, - bank_id, - request.background - ) - - final_profile = await memory.get_bank_profile(bank_id, request_context=request_context) - - assert final_profile["disposition"].skepticism == 4 - assert final_profile["disposition"].literalism == 5 - assert final_profile["background"] == "I am a creative software engineer" - - @pytest.mark.asyncio - async def test_put_agent_partial_update(self, memory: MemoryEngine, request_context): - """Test updating only background.""" - bank_id = unique_agent_id("test_put_partial") - - request = CreateBankRequest( - background="I am a data scientist" - ) - - profile = await memory.get_bank_profile(bank_id, request_context=request_context) - - if request.background is not None: - pool = await memory._get_pool() - async with pool.acquire() as conn: - await conn.execute( - """ - UPDATE banks - SET background = $2, - updated_at = NOW() - WHERE bank_id = $1 - """, - bank_id, - request.background - ) - - final_profile = await memory.get_bank_profile(bank_id, request_context=request_context) - - assert final_profile["disposition"].skepticism == 3 # Default - assert final_profile["background"] == "I am a data scientist" - - -class TestAgentDispositionIntegration: - """Tests for disposition integration with other features.""" - - @pytest.mark.asyncio - async def test_think_uses_disposition(self, memory: MemoryEngine, request_context): - """Test that THINK operation uses agent disposition.""" - bank_id = unique_agent_id("test_think") - - disposition = { - "skepticism": 5, # Very skeptical - "literalism": 4, # High literalism - "empathy": 2, # Low empathy - } - await memory.update_bank_disposition(bank_id, disposition, request_context=request_context) - - await memory.merge_bank_background( - bank_id, - "I am a creative artist who values innovation over tradition", - update_disposition=False, - request_context=request_context, - ) - - await memory.retain_batch_async( - bank_id=bank_id, - contents=[ - {"content": "Traditional painting techniques have been used for centuries"}, - {"content": "Modern digital art is changing the art world"} - ], - request_context=request_context, - ) - - result = await memory.reflect_async( - bank_id=bank_id, - query="What do you think about traditional vs modern art?", - budget=Budget.LOW, - request_context=request_context, - ) - - assert result.text is not None - assert len(result.text) > 0 diff --git a/hindsight-api/tests/test_batch_chunking.py b/hindsight-api/tests/test_batch_chunking.py deleted file mode 100644 index e1329875..00000000 --- a/hindsight-api/tests/test_batch_chunking.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Test automatic batch chunking based on character count.""" -import asyncio -import pytest -from hindsight_api import MemoryEngine -import os - - -@pytest.mark.asyncio -async def test_large_batch_auto_chunks(memory, request_context): - bank_id = "test_chunking_agent" - # Create a large batch that should trigger chunking - # Each item is ~2000 chars, so 30 items = 60k chars (exceeds 50k threshold) - large_content = "Alice met with Bob at the coffee shop. " * 50 # ~2000 chars - contents = [ - {"content": large_content, "context": f"conversation_{i}"} - for i in range(30) - ] - - # Calculate total chars - total_chars = sum(len(item["content"]) for item in contents) - print(f"\nTotal characters: {total_chars:,}") - print(f"Should trigger chunking: {total_chars > 50_000}") - - # Ingest the large batch (should auto-chunk) - result = await memory.retain_batch_async( - bank_id=bank_id, - contents=contents, - request_context=request_context, - ) - - # Verify we got results back - assert len(result) == 30, f"Expected 30 results, got {len(result)}" - print(f"Successfully ingested {len(result)} items (auto-chunked)") - - -@pytest.mark.asyncio -async def test_small_batch_no_chunking(memory, request_context): - bank_id = "test_no_chunking_agent" - - # Create a small batch that should NOT trigger chunking - contents = [ - {"content": "Alice works at Google", "context": "conversation_1"}, - {"content": "Bob loves Python", "context": "conversation_2"} - ] - - # Calculate total chars - total_chars = sum(len(item["content"]) for item in contents) - print(f"\nTotal characters: {total_chars:,}") - print(f"Should NOT trigger chunking: {total_chars <= 50_000}") - - # Ingest the small batch (should NOT auto-chunk) - result = await memory.retain_batch_async( - bank_id=bank_id, - contents=contents, - request_context=request_context, - ) - - # Verify we got results back - assert len(result) == 2, f"Expected 2 results, got {len(result)}" - print(f"Successfully ingested {len(result)} items (no chunking)") diff --git a/hindsight-api/tests/test_chunking.py b/hindsight-api/tests/test_chunking.py deleted file mode 100644 index 929beb7b..00000000 --- a/hindsight-api/tests/test_chunking.py +++ /dev/null @@ -1,56 +0,0 @@ -""" -Test chunking functionality for large documents. -""" -import pytest -from hindsight_api.engine.retain.fact_extraction import chunk_text - - -def test_chunk_text_small(): - """Test that small text is not chunked.""" - text = "This is a short text. It should not be chunked." - chunks = chunk_text(text, max_chars=1000) - - assert len(chunks) == 1, "Small text should not be chunked" - assert chunks[0] == text - - -def test_chunk_text_large(): - """Test that large text is chunked at sentence boundaries.""" - # Create a text with 10 sentences of ~100 chars each - sentences = [f"This is sentence number {i}. " + "x" * 80 for i in range(10)] - text = " ".join(sentences) - - # Chunk with max 300 chars - should create multiple chunks - chunks = chunk_text(text, max_chars=300) - - assert len(chunks) > 1, "Large text should be chunked" - - # Verify all chunks are under the limit - for chunk in chunks: - assert len(chunk) <= 300, f"Chunk exceeds max_chars: {len(chunk)}" - - # Verify we didn't lose any content - combined = " ".join(chunks) - # Account for possible whitespace differences - assert len(combined.replace(" ", "")) >= len(text.replace(" ", "")) * 0.95 - - -def test_chunk_text_64k(): - """Test chunking a 64k character text (like a podcast transcript).""" - # Create a 64k character text - sentence = "This is a typical podcast conversation sentence. " - text = sentence * (64000 // len(sentence)) - - chunks = chunk_text(text, max_chars=120000) - - # Should create at least 1 chunk (if text fits) or more - assert len(chunks) >= 1 - - # All chunks should be under the limit - for chunk in chunks: - assert len(chunk) <= 120000, f"Chunk exceeds max_chars: {len(chunk)}" - - # Verify we didn't lose content - combined_length = sum(len(chunk) for chunk in chunks) - assert combined_length >= len(text) * 0.95, "Lost too much content during chunking" - diff --git a/hindsight-api/tests/test_combined_scoring.py b/hindsight-api/tests/test_combined_scoring.py deleted file mode 100644 index 4a12e135..00000000 --- a/hindsight-api/tests/test_combined_scoring.py +++ /dev/null @@ -1,334 +0,0 @@ -""" -Tests for combined scoring functionality. - -Verifies that: -1. RRF scores are properly normalized to [0, 1] range -2. Combined scoring formula is applied correctly -3. Tracer captures normalized values (not raw values) -""" -import pytest -from datetime import datetime, timezone -from hindsight_api.engine.search.types import RetrievalResult, MergedCandidate, ScoredResult -from hindsight_api.engine.memory_engine import Budget -from hindsight_api import RequestContext - - -class TestRRFNormalization: - """Test that RRF scores are properly normalized.""" - - def test_rrf_normalized_range(self): - """RRF normalized values should be in [0, 1] range, not raw [0.04, 0.06].""" - # Simulate RRF scores like what we get from actual retrieval - raw_rrf_scores = [0.0607, 0.0550, 0.0480, 0.0390] - - max_rrf = max(raw_rrf_scores) - min_rrf = min(raw_rrf_scores) - rrf_range = max_rrf - min_rrf - - normalized = [] - for score in raw_rrf_scores: - if rrf_range > 0: - norm = (score - min_rrf) / rrf_range - else: - norm = 0.5 - normalized.append(norm) - - # Verify normalized values are in [0, 1] - for i, norm in enumerate(normalized): - assert 0.0 <= norm <= 1.0, f"Normalized RRF {norm} not in [0, 1] for raw {raw_rrf_scores[i]}" - - # Highest raw should be 1.0 - assert normalized[0] == 1.0, f"Highest RRF should normalize to 1.0, got {normalized[0]}" - - # Lowest raw should be 0.0 - assert normalized[-1] == 0.0, f"Lowest RRF should normalize to 0.0, got {normalized[-1]}" - - def test_rrf_all_same_scores(self): - """When all RRF scores are the same, normalized should be 0.5 (neutral).""" - raw_rrf_scores = [0.0500, 0.0500, 0.0500] - - max_rrf = max(raw_rrf_scores) - min_rrf = min(raw_rrf_scores) - rrf_range = max_rrf - min_rrf - - normalized = [] - for score in raw_rrf_scores: - if rrf_range > 0: - norm = (score - min_rrf) / rrf_range - else: - norm = 0.5 # Neutral value when all same - normalized.append(norm) - - # All should be 0.5 when scores are identical - for norm in normalized: - assert norm == 0.5, f"Expected 0.5 for identical scores, got {norm}" - - -class TestCombinedScoringFormula: - """Test that the combined scoring formula is applied correctly.""" - - def test_combined_score_calculation(self): - """Verify the weighted combination: 0.6*CE + 0.2*RRF + 0.1*temporal + 0.1*recency.""" - # Test case 1: All components at 1.0 - ce_norm = 1.0 - rrf_norm = 1.0 - temporal = 1.0 - recency = 1.0 - - expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency - assert expected == 1.0, f"All 1.0 should give 1.0, got {expected}" - - # Test case 2: All components at 0.0 - ce_norm = 0.0 - rrf_norm = 0.0 - temporal = 0.0 - recency = 0.0 - - expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency - assert expected == 0.0, f"All 0.0 should give 0.0, got {expected}" - - # Test case 3: High CE, low RRF (cross-encoder finds something retrieval missed) - ce_norm = 0.999 - rrf_norm = 0.0 # Lowest in set - temporal = 0.5 - recency = 0.5 - - expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency - # 0.5994 + 0.0 + 0.05 + 0.05 = 0.6994 - assert abs(expected - 0.6994) < 0.001, f"Expected ~0.6994, got {expected}" - - # Test case 4: Medium CE, high RRF (retrieval consensus) - ce_norm = 0.8 - rrf_norm = 1.0 # Highest in set - temporal = 0.5 - recency = 0.5 - - expected = 0.6 * ce_norm + 0.2 * rrf_norm + 0.1 * temporal + 0.1 * recency - # 0.48 + 0.2 + 0.05 + 0.05 = 0.78 - assert abs(expected - 0.78) < 0.001, f"Expected ~0.78, got {expected}" - - def test_rrf_contribution_is_significant(self): - """Verify RRF actually contributes to the final score (not negligible).""" - # Same CE, different RRF - ce_norm = 0.8 - temporal = 0.5 - recency = 0.5 - - # Low RRF - score_low_rrf = 0.6 * ce_norm + 0.2 * 0.0 + 0.1 * temporal + 0.1 * recency - - # High RRF - score_high_rrf = 0.6 * ce_norm + 0.2 * 1.0 + 0.1 * temporal + 0.1 * recency - - # Difference should be 0.2 (20% contribution) - diff = score_high_rrf - score_low_rrf - assert abs(diff - 0.2) < 0.001, f"RRF should contribute 0.2 difference, got {diff}" - - -@pytest.mark.asyncio -async def test_trace_has_normalized_rrf(memory, request_context): - """Integration test: verify trace contains normalized RRF values, not raw.""" - bank_id = f"test_scoring_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store multiple memories to ensure different RRF scores - await memory.retain_async( - bank_id=bank_id, - content="Python is a programming language created by Guido van Rossum", - context="tech facts", - request_context=request_context, - ) - await memory.retain_async( - bank_id=bank_id, - content="JavaScript was created by Brendan Eich at Netscape", - context="tech facts", - request_context=request_context, - ) - await memory.retain_async( - bank_id=bank_id, - content="The Eiffel Tower is located in Paris, France", - context="geography facts", - request_context=request_context, - ) - await memory.retain_async( - bank_id=bank_id, - content="Mount Everest is the tallest mountain on Earth", - context="geography facts", - request_context=request_context, - ) - - # Search with tracing - result = await memory.recall_async( - bank_id=bank_id, - query="programming languages", - fact_type=["world"], - budget=Budget.LOW, - max_tokens=1024, - enable_trace=True, - request_context=request_context, - ) - - assert result.trace is not None, "Trace should be present" - trace = result.trace - - # Check reranked results have proper score_components - assert "reranked" in trace, "Trace should have reranked results" - assert len(trace["reranked"]) > 0, "Should have reranked results" - - has_valid_rrf = False - has_valid_temporal = False - has_valid_recency = False - - for r in trace["reranked"]: - sc = r.get("score_components", {}) - - # Check RRF normalized is present and in valid range - if "rrf_normalized" in sc: - rrf_norm = sc["rrf_normalized"] - assert 0.0 <= rrf_norm <= 1.0, f"rrf_normalized {rrf_norm} should be in [0, 1]" - # Should NOT be raw RRF score (which would be ~0.04-0.06) - # A normalized value of exactly 0.0 or 1.0 is valid (min/max of set) - # But raw scores like 0.0607 should never appear as normalized - if rrf_norm > 0.1: # Any value > 0.1 is likely properly normalized - has_valid_rrf = True - - # Check temporal is present and in valid range - if "temporal" in sc: - temporal = sc["temporal"] - assert 0.0 <= temporal <= 1.0, f"temporal {temporal} should be in [0, 1]" - has_valid_temporal = True - - # Check recency is present and in valid range - if "recency" in sc: - recency = sc["recency"] - assert 0.0 <= recency <= 1.0, f"recency {recency} should be in [0, 1]" - has_valid_recency = True - - # At least some results should have these components - # (might not have rrf > 0.1 if all scores are same, which is fine) - assert has_valid_temporal, "Should have temporal scores in trace" - assert has_valid_recency, "Should have recency scores in trace" - - print("\n✓ Combined scoring trace test passed!") - print(f" - Reranked results: {len(trace['reranked'])}") - if trace["reranked"]: - sc = trace["reranked"][0].get("score_components", {}) - print(f" - First result score components: {sc}") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_rrf_normalized_not_raw_in_trace(memory, request_context): - """Verify that raw RRF scores (0.04-0.06 range) don't appear as normalized values.""" - bank_id = f"test_rrf_raw_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store enough memories to get varied RRF scores - for i in range(5): - await memory.retain_async( - bank_id=bank_id, - content=f"Test fact number {i} about various topics", - context="test context", - request_context=request_context, - ) - - result = await memory.recall_async( - bank_id=bank_id, - query="test fact", - fact_type=["world"], - budget=Budget.LOW, - max_tokens=512, - enable_trace=True, - request_context=request_context, - ) - - trace = result.trace - assert trace is not None - - # Check that rrf_normalized values are NOT in the raw range - raw_rrf_range = (0.01, 0.08) # Raw RRF scores are typically in this range - - for r in trace.get("reranked", []): - sc = r.get("score_components", {}) - - if "rrf_normalized" in sc and "rrf_score" in sc: - rrf_norm = sc["rrf_normalized"] - rrf_raw = sc["rrf_score"] - - # Raw should be in the typical range - assert raw_rrf_range[0] <= rrf_raw <= raw_rrf_range[1], \ - f"Raw RRF {rrf_raw} should be in typical range {raw_rrf_range}" - - # Normalized should either be: - # - 0.0 (min in set) - # - 1.0 (max in set) - # - 0.5 (all same) - # - Something in between (0.0 to 1.0) - # But NOT the same as raw (which would indicate no normalization) - if len(trace["reranked"]) > 1: - # If we have multiple results, normalized should differ from raw - # (unless by coincidence, which is very unlikely) - assert rrf_norm != rrf_raw, \ - f"Normalized RRF ({rrf_norm}) should differ from raw ({rrf_raw})" - - print("\n✓ RRF raw vs normalized test passed!") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_combined_score_matches_components(memory, request_context): - """Verify the final score actually equals the weighted sum of components.""" - bank_id = f"test_combined_{datetime.now(timezone.utc).timestamp()}" - - try: - await memory.retain_async( - bank_id=bank_id, - content="The quick brown fox jumps over the lazy dog", - context="test", - request_context=request_context, - ) - await memory.retain_async( - bank_id=bank_id, - content="A quick test of the emergency broadcast system", - context="test", - request_context=request_context, - ) - - result = await memory.recall_async( - bank_id=bank_id, - query="quick test", - fact_type=["world"], - budget=Budget.LOW, - max_tokens=512, - enable_trace=True, - request_context=request_context, - ) - - trace = result.trace - assert trace is not None - - for r in trace.get("reranked", []): - sc = r.get("score_components", {}) - final_score = r.get("rerank_score", 0) - - # Get components (use defaults if missing) - ce = sc.get("cross_encoder_score_normalized", 0) - rrf = sc.get("rrf_normalized", 0.5) - tmp = sc.get("temporal", 0.5) - rec = sc.get("recency", 0.5) - - # Calculate expected score - expected = 0.6 * ce + 0.2 * rrf + 0.1 * tmp + 0.1 * rec - - # Allow small floating point difference - assert abs(final_score - expected) < 0.01, \ - f"Final score {final_score} doesn't match expected {expected} from components" - - print("\n✓ Combined score verification test passed!") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api/tests/test_document_tracking.py b/hindsight-api/tests/test_document_tracking.py deleted file mode 100644 index 521c3c4b..00000000 --- a/hindsight-api/tests/test_document_tracking.py +++ /dev/null @@ -1,137 +0,0 @@ -""" -Tests for document tracking and upsert functionality. -""" -import logging -import pytest -from datetime import datetime, timezone -from hindsight_api import RequestContext - - -@pytest.mark.asyncio -async def test_document_creation_and_retrieval(memory, request_context): - """Test that documents are created and can be retrieved.""" - bank_id = f"test_doc_{datetime.now(timezone.utc).timestamp()}" - - try: - document_id = "meeting-001" - - # Store memory with document tracking - await memory.retain_async( - bank_id=bank_id, - content="Alice works at Google. Bob works at Microsoft.", - context="Team meeting", - document_id=document_id, - request_context=request_context, - ) - - # Retrieve document - doc = await memory.get_document(document_id, bank_id, request_context=request_context) - - assert doc is not None - assert doc["id"] == document_id - assert doc["bank_id"] == bank_id - assert "Alice works at Google" in doc["original_text"] - assert doc["memory_unit_count"] > 0 - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_document_upsert(memory, request_context): - """Test that providing the same document_id automatically upserts (deletes old units and creates new ones).""" - bank_id = f"test_upsert_{datetime.now(timezone.utc).timestamp()}" - - try: - document_id = "meeting-002" - - # First version - units_v1 = await memory.retain_async( - bank_id=bank_id, - content="Alice works at Google.", - context="Initial", - document_id=document_id, - request_context=request_context, - ) - - # Get document stats - doc_v1 = await memory.get_document(document_id, bank_id, request_context=request_context) - count_v1 = doc_v1["memory_unit_count"] - - # Update with different content (automatic upsert when same document_id is provided) - units_v2 = await memory.retain_async( - bank_id=bank_id, - content="Alice works at Microsoft. Bob works at Apple.", - context="Updated", - document_id=document_id, - request_context=request_context, - ) - - # Get updated document stats - doc_v2 = await memory.get_document(document_id, bank_id, request_context=request_context) - count_v2 = doc_v2["memory_unit_count"] - - # Verify old units were replaced - assert "Microsoft" in doc_v2["original_text"] - assert doc_v2["updated_at"] > doc_v1["created_at"] - - # Different unit IDs (old ones deleted, new ones created) - assert set(units_v1).isdisjoint(set(units_v2)) - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_document_deletion(memory, request_context): - """Test that deleting a document cascades to memory units.""" - bank_id = f"test_delete_{datetime.now(timezone.utc).timestamp()}" - - try: - document_id = "meeting-003" - - # Create document - await memory.retain_async( - bank_id=bank_id, - content="Alice works at Google.", - context="Test", - document_id=document_id, - request_context=request_context, - ) - - # Verify it exists - doc = await memory.get_document(document_id, bank_id, request_context=request_context) - assert doc is not None - assert doc["memory_unit_count"] > 0 - - # Delete document - result = await memory.delete_document(document_id, bank_id, request_context=request_context) - assert result["document_deleted"] == 1 - assert result["memory_units_deleted"] > 0 - - # Verify it's gone - doc_after = await memory.get_document(document_id, bank_id, request_context=request_context) - assert doc_after is None - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_memory_without_document(memory, request_context): - """Test that memories can still be created without document tracking.""" - bank_id = f"test_no_doc_{datetime.now(timezone.utc).timestamp()}" - - try: - # Create memory without document_id (backward compatibility) - units = await memory.retain_async( - bank_id=bank_id, - content="Alice works at Google.", - context="Test", - request_context=request_context, - ) - - assert len(units) > 0 - - finally: - await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api/tests/test_embeddings_provider_openai.py b/hindsight-api/tests/test_embeddings_provider_openai.py deleted file mode 100644 index 95ada663..00000000 --- a/hindsight-api/tests/test_embeddings_provider_openai.py +++ /dev/null @@ -1,45 +0,0 @@ -import os - -import pytest - -from hindsight_api.engine.embeddings import OpenAIEmbeddings, create_embeddings_from_env - - -def test_create_embeddings_from_env_openai_provider(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_PROVIDER", "openai") - emb = create_embeddings_from_env() - assert isinstance(emb, OpenAIEmbeddings) - - -@pytest.mark.asyncio -async def test_openai_embeddings_requires_api_key(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_PROVIDER", "openai") - monkeypatch.delenv("HINDSIGHT_API_EMBEDDINGS_API_KEY", raising=False) - - emb = create_embeddings_from_env() - with pytest.raises(ValueError, match="HINDSIGHT_API_EMBEDDINGS_API_KEY"): - await emb.initialize() - - -@pytest.mark.asyncio -async def test_openai_embeddings_dimension_mismatch_raises(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_PROVIDER", "openai") - monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_API_KEY", "dummy") - monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_DIMENSIONS", "3072") - - emb = create_embeddings_from_env() - with pytest.raises(ValueError, match="dimensions mismatch"): - await emb.initialize() - - -@pytest.mark.asyncio -async def test_openai_embeddings_azure_requires_api_version(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_PROVIDER", "openai") - monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_API_KEY", "dummy") - monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_BASE_URL", "https://example.openai.azure.com") - monkeypatch.setenv("HINDSIGHT_API_EMBEDDINGS_AZURE_DEPLOYMENT", "my-embeddings") - monkeypatch.delenv("HINDSIGHT_API_EMBEDDINGS_AZURE_API_VERSION", raising=False) - - emb = create_embeddings_from_env() - with pytest.raises(ValueError, match="AZURE_API_VERSION"): - await emb.initialize() diff --git a/hindsight-api/tests/test_extensions.py b/hindsight-api/tests/test_extensions.py deleted file mode 100644 index 88a2f5d3..00000000 --- a/hindsight-api/tests/test_extensions.py +++ /dev/null @@ -1,796 +0,0 @@ -"""Tests for the Hindsight extensions system.""" - -from collections import defaultdict - -import pytest -from fastapi import APIRouter -from fastapi.testclient import TestClient - -from hindsight_api.extensions import ( - ApiKeyTenantExtension, - AuthenticationError, - Extension, - HttpExtension, - OperationValidationError, - OperationValidatorExtension, - RecallContext, - RecallResult, - ReflectContext, - ReflectResultContext, - RequestContext, - RetainContext, - RetainResult, - TenantContext, - TenantExtension, - ValidationResult, - load_extension, -) - - -class TestExtensionLoader: - """Tests for extension loading and lifecycle.""" - - def test_load_extension_with_config(self, monkeypatch): - """Extension receives config from prefixed env vars and supports lifecycle.""" - monkeypatch.setenv( - "HINDSIGHT_API_TEST_EXTENSION", - "tests.test_extensions:LifecycleTestExtension", - ) - monkeypatch.setenv("HINDSIGHT_API_TEST_API_URL", "https://example.com") - monkeypatch.setenv("HINDSIGHT_API_TEST_MAX_RETRIES", "5") - - ext = load_extension("TEST", Extension) - - assert ext is not None - assert ext.config["api_url"] == "https://example.com" - assert ext.config["max_retries"] == "5" - - @pytest.mark.asyncio - async def test_extension_lifecycle(self, monkeypatch): - """Extension on_startup and on_shutdown are called.""" - monkeypatch.setenv( - "HINDSIGHT_API_TEST_EXTENSION", - "tests.test_extensions:LifecycleTestExtension", - ) - - ext = load_extension("TEST", Extension) - - assert not ext.started - assert not ext.stopped - - await ext.on_startup() - assert ext.started - - await ext.on_shutdown() - assert ext.stopped - - -class LifecycleTestExtension(Extension): - """Test extension for config and lifecycle tests.""" - - def __init__(self, config): - super().__init__(config) - self.started = False - self.stopped = False - - async def on_startup(self): - self.started = True - - async def on_shutdown(self): - self.stopped = True - - -class RateLimitingValidator(OperationValidatorExtension): - """ - Mock validator that blocks after N attempts per bank_id. - - Used for testing the extension integration with MemoryEngine. - """ - - def __init__(self, config: dict): - super().__init__(config) - self.max_attempts = int(config.get("max_attempts", "2")) - self.retain_counts: dict[str, int] = defaultdict(int) - self.recall_counts: dict[str, int] = defaultdict(int) - self.reflect_counts: dict[str, int] = defaultdict(int) - - 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.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.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.accept() - - -class TrackingValidator(OperationValidatorExtension): - """ - Mock validator that tracks all pre and post hook calls with full parameters. - - Used for testing that hooks receive all user-provided parameters. - """ - - def __init__(self, config: dict): - super().__init__(config) - # Pre-hook tracking - self.pre_retain_calls: list[RetainContext] = [] - self.pre_recall_calls: list[RecallContext] = [] - self.pre_reflect_calls: list[ReflectContext] = [] - # Post-hook tracking - self.post_retain_calls: list[RetainResult] = [] - self.post_recall_calls: list[RecallResult] = [] - self.post_reflect_calls: list[ReflectResultContext] = [] - - async def validate_retain(self, ctx: RetainContext) -> ValidationResult: - self.pre_retain_calls.append(ctx) - return ValidationResult.accept() - - async def validate_recall(self, ctx: RecallContext) -> ValidationResult: - self.pre_recall_calls.append(ctx) - return ValidationResult.accept() - - async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult: - self.pre_reflect_calls.append(ctx) - return ValidationResult.accept() - - async def on_retain_complete(self, result: RetainResult) -> None: - self.post_retain_calls.append(result) - - async def on_recall_complete(self, result: RecallResult) -> None: - self.post_recall_calls.append(result) - - async def on_reflect_complete(self, result: ReflectResultContext) -> None: - self.post_reflect_calls.append(result) - - -class TestMemoryEngineValidation: - """Tests for validation integration with MemoryEngine. - - The OperationValidatorExtension is integrated at the MemoryEngine level, - so all interfaces (HTTP API, MCP, SDK) get the same validation behavior. - - For retain, the batch is validated as a whole (all or nothing) using - retain_batch_async which is the public method used by the HTTP API. - """ - - @pytest.mark.asyncio - async def test_retain_batch_validation(self, memory_with_validator): - """Retain batch is validated as a whole - accepts or rejects entire batch.""" - memory = memory_with_validator - bank_id = "test-retain-batch" - ctx = RequestContext() - - # First batch should succeed - await memory.retain_batch_async( - bank_id=bank_id, - contents=[ - {"content": "First item"}, - {"content": "Second item"}, - ], - request_context=ctx, - ) - - # Second batch should succeed (2nd attempt) - await memory.retain_batch_async( - bank_id=bank_id, - contents=[{"content": "Third item"}], - request_context=ctx, - ) - - # Third batch should be blocked entirely (exceeds limit) - with pytest.raises(OperationValidationError) as exc_info: - await memory.retain_batch_async( - bank_id=bank_id, - contents=[ - {"content": "Should not be stored"}, - {"content": "Neither should this"}, - ], - request_context=ctx, - ) - - assert "limit exceeded" in str(exc_info.value).lower() - - @pytest.mark.asyncio - async def test_recall_validation(self, memory_with_validator): - """Recall is validated before execution.""" - memory = memory_with_validator - bank_id = "test-recall-validation" - ctx = RequestContext() - - # First recall should pass validation - await memory.recall_async(bank_id, "test query", fact_type=["world"], request_context=ctx) - - # Second recall should pass validation - await memory.recall_async(bank_id, "another query", fact_type=["world"], request_context=ctx) - - # Third recall should be blocked by validator - with pytest.raises(OperationValidationError) as exc_info: - await memory.recall_async(bank_id, "blocked query", fact_type=["world"], request_context=ctx) - - assert "limit exceeded" in str(exc_info.value).lower() - - @pytest.mark.asyncio - async def test_reflect_validation(self, memory_with_validator): - """Reflect is validated before execution.""" - memory = memory_with_validator - bank_id = "test-reflect-validation" - ctx = RequestContext() - - # First reflect should pass validation (may fail internally but validation passes) - try: - await memory.reflect_async(bank_id, "test question", request_context=ctx) - except OperationValidationError: - raise # Re-raise validation errors - except Exception: - pass # Other errors are fine (e.g., no data) - - # Second reflect should pass validation - try: - await memory.reflect_async(bank_id, "another question", request_context=ctx) - except OperationValidationError: - raise - except Exception: - pass - - # Third reflect should be blocked by validator - with pytest.raises(OperationValidationError) as exc_info: - await memory.reflect_async(bank_id, "blocked question", request_context=ctx) - - assert "limit exceeded" in str(exc_info.value).lower() - - -@pytest.fixture -def memory_with_validator(memory): - """Memory engine with a rate-limiting validator (max 2 attempts per bank).""" - validator = RateLimitingValidator({"max_attempts": "2"}) - memory._operation_validator = validator - return memory - - -@pytest.fixture -def memory_with_tracking_validator(memory): - """Memory engine with a tracking validator that records all hook calls.""" - validator = TrackingValidator({}) - memory._operation_validator = validator - return memory, validator - - -class TestOperationHooksParameters: - """Tests for pre and post operation hooks receiving all user-provided parameters.""" - - @pytest.mark.asyncio - async def test_retain_pre_hook_receives_all_parameters(self, memory_with_tracking_validator): - """Pre-retain hook receives all user-provided parameters.""" - memory, validator = memory_with_tracking_validator - bank_id = "test-retain-params" - ctx = RequestContext(api_key="test-key") - contents = [{"content": "Test content", "context": "test context"}] - document_id = "doc-123" - - await memory.retain_batch_async( - bank_id=bank_id, - contents=contents, - document_id=document_id, - fact_type_override="world", - confidence_score=0.9, - request_context=ctx, - ) - - assert len(validator.pre_retain_calls) == 1 - pre_ctx = validator.pre_retain_calls[0] - - # Verify all parameters are present - assert pre_ctx.bank_id == bank_id - # Note: contents is copied before document_id is applied to individual items - assert len(pre_ctx.contents) == len(contents) - assert pre_ctx.contents[0]["content"] == contents[0]["content"] - assert pre_ctx.document_id == document_id - assert pre_ctx.fact_type_override == "world" - assert pre_ctx.confidence_score == 0.9 - assert pre_ctx.request_context == ctx - - @pytest.mark.asyncio - async def test_retain_post_hook_receives_all_parameters_and_result(self, memory_with_tracking_validator): - """Post-retain hook receives all parameters plus the result.""" - memory, validator = memory_with_tracking_validator - bank_id = "test-retain-post" - ctx = RequestContext(api_key="test-key") - contents = [{"content": "Test content for post hook"}] - document_id = "doc-456" - - result = await memory.retain_batch_async( - bank_id=bank_id, - contents=contents, - document_id=document_id, - fact_type_override="experience", - confidence_score=0.8, - request_context=ctx, - ) - - assert len(validator.post_retain_calls) == 1 - post_result = validator.post_retain_calls[0] - - # Verify all parameters are present - assert post_result.bank_id == bank_id - assert post_result.document_id == document_id - assert post_result.fact_type_override == "experience" - assert post_result.confidence_score == 0.8 - assert post_result.request_context == ctx - - # Verify result data - assert post_result.success is True - assert post_result.error is None - assert post_result.unit_ids == result # Should match the return value - - @pytest.mark.asyncio - async def test_recall_pre_hook_receives_all_parameters(self, memory_with_tracking_validator): - """Pre-recall hook receives all user-provided parameters.""" - from datetime import datetime, timezone - from hindsight_api.engine.memory_engine import Budget - - memory, validator = memory_with_tracking_validator - bank_id = "test-recall-params" - ctx = RequestContext(api_key="test-key") - query = "test query" - question_date = datetime(2024, 1, 15, tzinfo=timezone.utc) - - await memory.recall_async( - bank_id=bank_id, - query=query, - budget=Budget.HIGH, - max_tokens=2048, - enable_trace=True, - fact_type=["world", "experience"], - question_date=question_date, - include_entities=True, - max_entity_tokens=300, - include_chunks=True, - max_chunk_tokens=4096, - request_context=ctx, - ) - - assert len(validator.pre_recall_calls) == 1 - pre_ctx = validator.pre_recall_calls[0] - - # Verify all parameters are present - assert pre_ctx.bank_id == bank_id - assert pre_ctx.query == query - assert pre_ctx.budget == Budget.HIGH - assert pre_ctx.max_tokens == 2048 - assert pre_ctx.enable_trace is True - assert pre_ctx.fact_types == ["world", "experience"] - assert pre_ctx.question_date == question_date - assert pre_ctx.include_entities is True - assert pre_ctx.max_entity_tokens == 300 - assert pre_ctx.include_chunks is True - assert pre_ctx.max_chunk_tokens == 4096 - assert pre_ctx.request_context == ctx - - @pytest.mark.asyncio - async def test_recall_post_hook_receives_all_parameters_and_result(self, memory_with_tracking_validator): - """Post-recall hook receives all parameters plus the result.""" - from hindsight_api.engine.memory_engine import Budget - - memory, validator = memory_with_tracking_validator - bank_id = "test-recall-post" - ctx = RequestContext(api_key="test-key") - - result = await memory.recall_async( - bank_id=bank_id, - query="test query for post", - budget=Budget.LOW, - max_tokens=1024, - fact_type=["world"], - request_context=ctx, - ) - - assert len(validator.post_recall_calls) == 1 - post_result = validator.post_recall_calls[0] - - # Verify all parameters are present - assert post_result.bank_id == bank_id - assert post_result.query == "test query for post" - assert post_result.budget == Budget.LOW - assert post_result.max_tokens == 1024 - assert post_result.fact_types == ["world"] - assert post_result.request_context == ctx - - # Verify result data - assert post_result.success is True - assert post_result.error is None - assert post_result.result == result # Should match the return value - - @pytest.mark.asyncio - async def test_reflect_pre_hook_receives_all_parameters(self, memory_with_tracking_validator): - """Pre-reflect hook receives all user-provided parameters.""" - from hindsight_api.engine.memory_engine import Budget - - memory, validator = memory_with_tracking_validator - bank_id = "test-reflect-params" - ctx = RequestContext(api_key="test-key") - - try: - await memory.reflect_async( - bank_id=bank_id, - query="test question", - budget=Budget.MID, - context="additional context", - request_context=ctx, - ) - except Exception: - pass # May fail if no data, but pre-hook should still be called - - assert len(validator.pre_reflect_calls) == 1 - pre_ctx = validator.pre_reflect_calls[0] - - # Verify all parameters are present - assert pre_ctx.bank_id == bank_id - assert pre_ctx.query == "test question" - assert pre_ctx.budget == Budget.MID - assert pre_ctx.context == "additional context" - assert pre_ctx.request_context == ctx - - @pytest.mark.asyncio - async def test_reflect_post_hook_receives_all_parameters_and_result(self, memory_with_tracking_validator): - """Post-reflect hook receives all parameters plus the result on success.""" - from hindsight_api.engine.memory_engine import Budget - - memory, validator = memory_with_tracking_validator - bank_id = "test-reflect-post" - ctx = RequestContext(api_key="test-key") - - # Store some content first so reflect has something to work with - await memory.retain_batch_async( - bank_id=bank_id, - contents=[{"content": "Alice is a software engineer at Google."}], - request_context=ctx, - ) - - result = await memory.reflect_async( - bank_id=bank_id, - query="What does Alice do?", - budget=Budget.LOW, - context="work context", - request_context=ctx, - ) - - assert len(validator.post_reflect_calls) == 1 - post_result = validator.post_reflect_calls[0] - - # Verify all parameters are present - assert post_result.bank_id == bank_id - assert post_result.query == "What does Alice do?" - assert post_result.budget == Budget.LOW - assert post_result.context == "work context" - assert post_result.request_context == ctx - - # Verify result data - assert post_result.success is True - assert post_result.error is None - assert post_result.result == result # Should match the return value - assert post_result.result.text is not None - - @pytest.mark.asyncio - async def test_post_hooks_called_in_order_after_pre_hooks(self, memory_with_tracking_validator): - """Post hooks are called after pre hooks and after operation completes.""" - memory, validator = memory_with_tracking_validator - bank_id = "test-hook-order" - ctx = RequestContext() - - # Retain operation - await memory.retain_batch_async( - bank_id=bank_id, - contents=[{"content": "Test content"}], - request_context=ctx, - ) - - # Pre-hook should be called before post-hook - assert len(validator.pre_retain_calls) == 1 - assert len(validator.post_retain_calls) == 1 - - # Recall operation - await memory.recall_async( - bank_id=bank_id, - query="test", - fact_type=["world"], - request_context=ctx, - ) - - assert len(validator.pre_recall_calls) == 1 - assert len(validator.post_recall_calls) == 1 - - -class TestTenantExtension: - """Tests for TenantExtension and ApiKeyTenantExtension.""" - - @pytest.mark.asyncio - async def test_api_key_tenant_extension_valid_key(self): - """ApiKeyTenantExtension accepts valid API key.""" - ext = ApiKeyTenantExtension({"api_key": "secret-key-123"}) - - result = await ext.authenticate(RequestContext(api_key="secret-key-123")) - - assert result.schema_name == "public" - - @pytest.mark.asyncio - async def test_api_key_tenant_extension_invalid_key(self): - """ApiKeyTenantExtension rejects invalid API key.""" - ext = ApiKeyTenantExtension({"api_key": "secret-key-123"}) - - with pytest.raises(AuthenticationError) as exc_info: - await ext.authenticate(RequestContext(api_key="wrong-key")) - - assert "Invalid API key" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_api_key_tenant_extension_missing_key(self): - """ApiKeyTenantExtension rejects missing API key.""" - ext = ApiKeyTenantExtension({"api_key": "secret-key-123"}) - - with pytest.raises(AuthenticationError): - await ext.authenticate(RequestContext(api_key=None)) - - def test_api_key_tenant_extension_requires_config(self): - """ApiKeyTenantExtension requires api_key in config.""" - with pytest.raises(ValueError) as exc_info: - ApiKeyTenantExtension({}) - - assert "HINDSIGHT_API_TENANT_API_KEY is required" in str(exc_info.value) - - -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 - ): - """Retain fails without RequestContext when tenant extension is configured.""" - memory = memory_with_tenant - - with pytest.raises(AuthenticationError) as exc_info: - await memory.retain_batch_async( - bank_id="test-bank", - contents=[{"content": "test"}], - request_context=None, # Missing! - ) - - assert "RequestContext is required" in str(exc_info.value) - - @pytest.mark.asyncio - async def test_retain_succeeds_with_valid_tenant_request(self, memory_with_tenant): - """Retain succeeds with valid RequestContext.""" - memory = memory_with_tenant - - # Should not raise - await memory.retain_batch_async( - bank_id="test-bank-tenant", - contents=[{"content": "test content"}], - request_context=RequestContext(api_key="test-api-key"), - ) - - @pytest.mark.asyncio - async def test_retain_fails_with_invalid_api_key(self, memory_with_tenant): - """Retain fails with invalid API key.""" - memory = memory_with_tenant - - with pytest.raises(AuthenticationError) as exc_info: - await memory.retain_batch_async( - bank_id="test-bank", - contents=[{"content": "test"}], - request_context=RequestContext(api_key="wrong-key"), - ) - - 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 - ): - """Recall fails without RequestContext when tenant extension is configured.""" - memory = memory_with_tenant - - with pytest.raises(AuthenticationError): - await memory.recall_async( - bank_id="test-bank", - query="test query", - fact_type=["world"], - request_context=None, - ) - - @pytest.mark.asyncio - async def test_no_tenant_request_needed_without_extension(self, memory): - """Operations work with empty RequestContext when no tenant extension configured.""" - # Should not raise - no tenant extension configured, just pass empty RequestContext - await memory.retain_batch_async( - bank_id="test-bank-no-tenant", - contents=[{"content": "test content"}], - request_context=RequestContext(), - ) - - -@pytest.fixture -def memory_with_tenant(memory): - """Memory engine with a tenant extension (API key auth).""" - tenant_ext = ApiKeyTenantExtension({"api_key": "test-api-key"}) - memory._tenant_extension = tenant_ext - return memory - - -class SampleHttpExtension(HttpExtension): - """Sample HTTP extension for testing that provides custom endpoints.""" - - def __init__(self, config: dict): - super().__init__(config) - self.started = False - self.stopped = False - self.request_count = 0 - - async def on_startup(self): - self.started = True - - async def on_shutdown(self): - self.stopped = True - - def get_router(self, memory) -> APIRouter: - router = APIRouter() - - @router.get("/hello") - async def hello(): - self.request_count += 1 - return {"message": "Hello from extension!"} - - @router.get("/config") - async def get_config(): - return {"config": self.config} - - @router.get("/health-check") - async def extension_health(): - health = await memory.health_check() - return {"extension": "healthy", "memory": health} - - @router.post("/echo") - async def echo(data: dict): - return {"echoed": data} - - return router - - -class TestHttpExtensionIntegration: - """Tests for HTTP extension integration.""" - - def test_load_http_extension(self, monkeypatch): - """HttpExtension can be loaded from environment variable.""" - monkeypatch.setenv( - "HINDSIGHT_API_HTTP_EXTENSION", - "tests.test_extensions:SampleHttpExtension", - ) - monkeypatch.setenv("HINDSIGHT_API_HTTP_CUSTOM_PARAM", "custom_value") - - ext = load_extension("HTTP", HttpExtension) - - assert ext is not None - assert isinstance(ext, SampleHttpExtension) - assert ext.config["custom_param"] == "custom_value" - - def test_http_extension_router_mounted_at_ext(self, memory): - """HTTP extension router is mounted at /ext/.""" - from hindsight_api.api.http import create_app - - ext = SampleHttpExtension({"test_key": "test_value"}) - app = create_app(memory, initialize_memory=False, http_extension=ext) - - client = TestClient(app) - - # Extension endpoint should be accessible at /ext/ - response = client.get("/ext/hello") - assert response.status_code == 200 - assert response.json() == {"message": "Hello from extension!"} - - # Should track request count - assert ext.request_count == 1 - - # Old path should NOT work - response = client.get("/extension/hello") - assert response.status_code == 404 - - def test_http_extension_config_endpoint(self, memory): - """Extension can expose its config via custom endpoint.""" - from hindsight_api.api.http import create_app - - ext = SampleHttpExtension({"api_key": "secret", "limit": "100"}) - app = create_app(memory, initialize_memory=False, http_extension=ext) - - client = TestClient(app) - - response = client.get("/ext/config") - assert response.status_code == 200 - assert response.json()["config"]["api_key"] == "secret" - assert response.json()["config"]["limit"] == "100" - - def test_http_extension_can_access_memory(self, memory): - """Extension endpoints can access memory engine.""" - from hindsight_api.api.http import create_app - - ext = SampleHttpExtension({}) - app = create_app(memory, initialize_memory=False, http_extension=ext) - - client = TestClient(app) - - response = client.get("/ext/health-check") - assert response.status_code == 200 - data = response.json() - assert data["extension"] == "healthy" - assert "memory" in data - - def test_http_extension_post_endpoint(self, memory): - """Extension can handle POST requests with JSON body.""" - from hindsight_api.api.http import create_app - - ext = SampleHttpExtension({}) - app = create_app(memory, initialize_memory=False, http_extension=ext) - - client = TestClient(app) - - response = client.post("/ext/echo", json={"key": "value", "number": 42}) - assert response.status_code == 200 - assert response.json() == {"echoed": {"key": "value", "number": 42}} - - def test_http_extension_not_mounted_when_none(self, memory): - """No extension routes when http_extension is None.""" - from hindsight_api.api.http import create_app - - app = create_app(memory, initialize_memory=False, http_extension=None) - - client = TestClient(app) - - # Extension endpoint should not exist - response = client.get("/ext/hello") - assert response.status_code == 404 - - @pytest.mark.asyncio - async def test_http_extension_lifecycle(self): - """HTTP extension on_startup and on_shutdown are called.""" - ext = SampleHttpExtension({}) - - assert not ext.started - assert not ext.stopped - - await ext.on_startup() - assert ext.started - - await ext.on_shutdown() - assert ext.stopped - - def test_core_routes_still_work_with_extension(self, memory): - """Core API routes still work when extension is mounted.""" - from hindsight_api.api.http import create_app - - ext = SampleHttpExtension({}) - app = create_app(memory, initialize_memory=False, http_extension=ext) - - client = TestClient(app) - - # Health endpoint should work - response = client.get("/health") - assert response.status_code in (200, 503) # May be unhealthy if DB not connected - - # Banks list endpoint should work - response = client.get("/v1/default/banks") - assert response.status_code in (200, 500) # May fail if DB not ready diff --git a/hindsight-api/tests/test_fact_extraction_quality.py b/hindsight-api/tests/test_fact_extraction_quality.py deleted file mode 100644 index 7d67900e..00000000 --- a/hindsight-api/tests/test_fact_extraction_quality.py +++ /dev/null @@ -1,1060 +0,0 @@ -""" -Test suite for fact extraction quality verification. - -This comprehensive test suite validates that the fact extraction system: -1. Preserves all information dimensions (emotional, sensory, cognitive, etc.) -2. Correctly converts relative dates to absolute dates -3. Properly classifies facts as agent vs world -4. Makes logical inferences to connect related information -5. Correctly attributes statements to speakers -6. Filters out irrelevant content (podcast intros/outros) - -These are quality/accuracy tests that verify the LLM-based extraction -produces semantically correct and complete facts. -""" -from datetime import UTC, datetime - -import pytest - -from hindsight_api import LLMConfig -from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text - -# ============================================================================= -# DIMENSION PRESERVATION TESTS -# ============================================================================= - -class TestDimensionPreservation: - """Tests that fact extraction preserves all information dimensions.""" - - @pytest.mark.asyncio - async def test_emotional_dimension_preservation(self): - """ - Test that emotional states and feelings are preserved, not stripped away. - - Example: "I was thrilled to receive positive feedback" - Should NOT become: "I received positive feedback" - """ - text = """ -I was absolutely thrilled when I received such positive feedback on my presentation! -Sarah seemed disappointed when she heard the news about the delay. -Marcus felt anxious about the upcoming interview. -""" - - context = "Personal journal entry" - llm_config = LLMConfig.for_memory() - - facts, _ = await extract_facts_from_text( - text=text, - event_date=datetime(2024, 11, 13), - context=context, - llm_config=llm_config, - agent_name="TestUser" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - all_facts_text = " ".join([f.fact.lower() for f in facts]) - - emotional_indicators = ["thrilled", "disappointed", "anxious", "positive feedback"] - found_emotions = [word for word in emotional_indicators if word in all_facts_text] - - assert len(found_emotions) >= 2, ( - f"Should preserve emotional dimension. " - f"Found: {found_emotions}, Expected at least 2 from: {emotional_indicators}" - ) - - @pytest.mark.asyncio - async def test_sensory_dimension_preservation(self): - """Test that sensory details (visual, auditory, etc.) are preserved.""" - text = """ -The coffee tasted bitter and burnt. -She showed me her bright orange hair, which looked stunning under the lights. -The music was so loud I could barely hear myself think. -""" - - context = "Personal experience" - llm_config = LLMConfig.for_memory() - - facts, _ = await extract_facts_from_text( - text=text, - event_date=datetime(2024, 11, 13), - context=context, - llm_config=llm_config, - agent_name="TestUser" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - all_facts_text = " ".join([f.fact.lower() for f in facts]) - - sensory_indicators = ["bitter", "burnt", "bright orange", "loud", "stunning"] - found_sensory = [word for word in sensory_indicators if word in all_facts_text] - - assert len(found_sensory) >= 2, ( - f"Should preserve sensory details. " - f"Found: {found_sensory}, Expected at least 2 from: {sensory_indicators}" - ) - - @pytest.mark.asyncio - async def test_cognitive_epistemic_dimension(self): - """Test that cognitive states and certainty levels are preserved.""" - text = """ -I realized that the approach wasn't working. -She wasn't sure if the meeting would happen. -He's convinced that AI will transform healthcare. -Maybe we should reconsider the timeline. -""" - - context = "Team discussion" - llm_config = LLMConfig.for_memory() - - facts, _ = await extract_facts_from_text( - text=text, - event_date=datetime(2024, 11, 13), - context=context, - llm_config=llm_config, - agent_name="TestUser" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - all_facts_text = " ".join([f.fact.lower() for f in facts]) - - cognitive_indicators = ["realized", "wasn't sure", "convinced", "maybe", "reconsider"] - found_cognitive = [word for word in cognitive_indicators if word in all_facts_text] - - assert len(found_cognitive) >= 2, ( - f"Should preserve cognitive/epistemic dimension. " - f"Found: {found_cognitive}" - ) - - @pytest.mark.asyncio - async def test_capability_skill_dimension(self): - """Test that capabilities, skills, and limitations are preserved.""" - text = """ -I can speak French fluently. -Sarah struggles with public speaking. -He's an expert in machine learning. -I'm unable to attend the conference due to scheduling conflicts. -""" - - context = "Personal profile discussion" - llm_config = LLMConfig.for_memory() - - facts, _ = await extract_facts_from_text( - text=text, - event_date=datetime(2024, 11, 13), - context=context, - llm_config=llm_config, - agent_name="TestUser" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - all_facts_text = " ".join([f.fact.lower() for f in facts]) - - capability_indicators = ["can speak", "fluently", "struggles with", "expert in", "unable to"] - found_capability = [word for word in capability_indicators if word in all_facts_text] - - assert len(found_capability) >= 2, ( - f"Should preserve capability/skill dimension. " - f"Found: {found_capability}" - ) - - @pytest.mark.asyncio - async def test_comparative_dimension(self): - """Test that comparisons and contrasts are preserved.""" - text = """ -This approach is much better than the previous one. -The new design is worse than expected. -Unlike last year, we're ahead of schedule. -""" - - context = "Project review" - llm_config = LLMConfig.for_memory() - - facts, _ = await extract_facts_from_text( - text=text, - event_date=datetime(2024, 11, 13), - context=context, - llm_config=llm_config, - agent_name="TestUser" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - all_facts_text = " ".join([f.fact.lower() for f in facts]) - - comparative_indicators = ["better than", "worse than", "unlike", "ahead of"] - found_comparative = [word for word in comparative_indicators if word in all_facts_text] - - assert len(found_comparative) >= 1, ( - f"Should preserve comparative dimension. " - f"Found: {found_comparative}" - ) - - @pytest.mark.asyncio - async def test_attitudinal_reactive_dimension(self): - """Test that attitudes and reactions are preserved.""" - text = """ -She's very skeptical about the new technology. -I was surprised when he announced his resignation. -Marcus rolled his eyes when the topic came up. -She's enthusiastic about the opportunity. -""" - - context = "Team meeting" - llm_config = LLMConfig.for_memory() - - facts, _ = await extract_facts_from_text( - text=text, - event_date=datetime(2024, 11, 13), - context=context, - llm_config=llm_config, - agent_name="TestUser" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - all_facts_text = " ".join([f.fact.lower() for f in facts]) - - attitudinal_indicators = ["skeptical", "surprised", "rolled his eyes", "enthusiastic"] - found_attitudinal = [word for word in attitudinal_indicators if word in all_facts_text] - - assert len(found_attitudinal) >= 1, ( - f"Should preserve attitudinal/reactive dimension. " - f"Found: {found_attitudinal}" - ) - - @pytest.mark.asyncio - async def test_intentional_motivational_dimension(self): - """Test that goals, plans, and motivations are preserved.""" - text = """ -I want to learn Mandarin before my trip to China. -She aims to complete her PhD within three years. -His goal is to build a sustainable business. -I'm planning to switch careers because I'm not fulfilled in my current role. -""" - - context = "Personal goals discussion" - llm_config = LLMConfig.for_memory() - - facts, _ = await extract_facts_from_text( - text=text, - event_date=datetime(2024, 11, 13), - context=context, - llm_config=llm_config, - agent_name="TestUser" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - all_facts_text = " ".join([f.fact.lower() for f in facts]) - - # Check for goal/intention related content - intentional_indicators = [ - "want", "aim", "goal", "plan", "because", "learn", "complete", - "build", "switch", "career", "mandarin", "china", "phd", "business" - ] - found_intentional = [word for word in intentional_indicators if word in all_facts_text] - - assert len(found_intentional) >= 1, ( - f"Should preserve intentional/motivational content. " - f"Found: {found_intentional}" - ) - - @pytest.mark.asyncio - async def test_evaluative_preferential_dimension(self): - """Test that preferences and values are preserved.""" - text = """ -I prefer working remotely to being in an office. -She values honesty above all else. -He hates being late to meetings. -Family is the most important thing to her. -""" - - context = "Personal values discussion" - llm_config = LLMConfig.for_memory() - - facts, _ = await extract_facts_from_text( - text=text, - event_date=datetime(2024, 11, 13), - context=context, - llm_config=llm_config, - agent_name="TestUser" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - all_facts_text = " ".join([f.fact.lower() for f in facts]) - - evaluative_indicators = ["prefer", "values", "hates", "important", "above all"] - found_evaluative = [word for word in evaluative_indicators if word in all_facts_text] - - assert len(found_evaluative) >= 2, ( - f"Should preserve evaluative/preferential dimension. " - f"Found: {found_evaluative}" - ) - - @pytest.mark.asyncio - async def test_comprehensive_multi_dimension(self): - """Test a realistic scenario with multiple dimensions in one fact.""" - text = """ -I was thrilled to receive such positive feedback on my presentation yesterday! -I wasn't sure if my approach would resonate, but the audience seemed enthusiastic. -I prefer presenting in person rather than virtually because I can read the room better. -""" - - context = "Personal reflection" - llm_config = LLMConfig.for_memory() - - event_date = datetime(2024, 11, 13) - - facts, _ = await extract_facts_from_text( - text=text, - event_date=event_date, - context=context, - llm_config=llm_config, - agent_name="TestUser" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - all_facts_text = " ".join([f.fact.lower() for f in facts]) - - # Check emotional - should capture positive/thrilled sentiment - has_emotional = any(term in all_facts_text for term in [ - "thrilled", "positive feedback", "positive", "feedback", "enthusiastic" - ]) - assert has_emotional, "Should preserve emotional dimension" - - # Check no vague temporal terms - prohibited_terms = ["recently", "soon", "lately"] - found_prohibited = [term for term in prohibited_terms if term in all_facts_text] - assert len(found_prohibited) == 0, \ - f"Should NOT use vague temporal terms. Found: {found_prohibited}" - - # Check preference - should capture the in-person vs virtual preference - has_preference = any(term in all_facts_text for term in [ - "prefer", "rather than", "in person", "virtually", "read the room" - ]) - assert has_preference, "Should preserve preferential dimension" - - -# ============================================================================= -# TEMPORAL CONVERSION TESTS -# ============================================================================= - -class TestTemporalConversion: - """Tests for temporal extraction and date conversion.""" - - @pytest.mark.asyncio - async def test_temporal_absolute_conversion(self): - """ - Test that relative temporal expressions are converted to absolute dates. - - Critical: "yesterday" should become "on November 12, 2024", NOT "recently" - """ - text = """ -Yesterday I went for a morning jog for the first time in a nearby park. -Last week I started a new project. -I'm planning to visit Tokyo next month. -""" - - context = "Personal conversation" - llm_config = LLMConfig.for_memory() - - event_date = datetime(2024, 11, 13) - - facts, _ = await extract_facts_from_text( - text=text, - event_date=event_date, - context=context, - llm_config=llm_config, - agent_name="TestUser" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - all_facts_text = " ".join([f.fact.lower() for f in facts]) - - # Should NOT contain vague temporal terms - prohibited_terms = ["recently", "soon", "lately", "a while ago", "some time ago"] - found_prohibited = [term for term in prohibited_terms if term in all_facts_text] - - assert len(found_prohibited) == 0, ( - f"Should NOT use vague temporal terms. Found: {found_prohibited}" - ) - - # Should contain specific date references - temporal_indicators = ["november", "12", "early november", "week of", "december"] - found_temporal = [term for term in temporal_indicators if term in all_facts_text] - - assert len(found_temporal) >= 1, ( - f"Should convert relative dates to absolute. " - f"Found: {found_temporal}, Expected month/date references" - ) - - @pytest.mark.asyncio - async def test_date_field_calculation_last_night(self): - """ - Test that the date field is calculated correctly for "last night" events. - - Ideally: If conversation is on August 14, 2023 and text says "last night", - the date field should be August 13. We accept 13 or 14 as LLM may vary. - """ - text = """ -Melanie: Hey Caroline! Last night was amazing! We celebrated my daughter's birthday -with a concert surrounded by music, joy and the warm summer breeze. -""" - - context = "Conversation between Melanie and Caroline" - llm_config = LLMConfig.for_memory() - - event_date = datetime(2023, 8, 14, 14, 24) - - facts, _ = await extract_facts_from_text( - text=text, - event_date=event_date, - context=context, - llm_config=llm_config, - agent_name="Melanie" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - birthday_fact = None - for fact in facts: - if "birthday" in fact.fact.lower() or "concert" in fact.fact.lower(): - birthday_fact = fact - break - - assert birthday_fact is not None, "Should extract fact about birthday celebration" - - fact_date_str = birthday_fact.occurred_start - assert fact_date_str is not None, "occurred_start should not be None for temporal events" - - if 'T' in fact_date_str: - fact_date = datetime.fromisoformat(fact_date_str.replace('Z', '+00:00')) - else: - fact_date = datetime.fromisoformat(fact_date_str) - - assert fact_date.year == 2023, "Year should be 2023" - assert fact_date.month == 8, "Month should be August" - # Accept day 13 (ideal: last night) or 14 (conversation date) as valid - assert fact_date.day in (13, 14), ( - f"Day should be 13 or 14 (around Aug 14 event), but got {fact_date.day}." - ) - - @pytest.mark.asyncio - async def test_date_field_calculation_yesterday(self): - """Test that the date field is calculated correctly for "yesterday" events.""" - text = """ -Yesterday I went for a morning jog for the first time in a nearby park. -""" - - context = "Personal diary" - llm_config = LLMConfig.for_memory() - - event_date = datetime(2024, 11, 13) - - facts, _ = await extract_facts_from_text( - text=text, - event_date=event_date, - context=context, - llm_config=llm_config, - agent_name="TestUser" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - jogging_fact = facts[0] - - fact_date_str = jogging_fact.occurred_start - if 'T' in fact_date_str: - fact_date = datetime.fromisoformat(fact_date_str.replace('Z', '+00:00')) - else: - fact_date = datetime.fromisoformat(fact_date_str) - - assert fact_date.year == 2024, "Year should be 2024" - assert fact_date.month == 11, "Month should be November" - # Accept day 12 (ideal: yesterday) or 13 (conversation date) as valid - assert fact_date.day in (12, 13), ( - f"Day should be 12 or 13 (around Nov 13 event), but got {fact_date.day}." - ) - - all_facts_text = " ".join([f.fact.lower() for f in facts]) - - assert "first time" in all_facts_text or "first" in all_facts_text, \ - "Should preserve 'first time' qualifier" - - assert "recently" not in all_facts_text, \ - "Should NOT convert 'yesterday' to 'recently'" - - assert any(term in all_facts_text for term in ["november", "12", "nov"]), \ - "Should convert 'yesterday' to absolute date in fact text" - - @pytest.mark.asyncio - async def test_extract_facts_with_relative_dates(self): - """Test that relative dates are converted to absolute dates.""" - - reference_date = datetime(2024, 3, 20, 14, 0, 0, tzinfo=UTC) - llm_config = LLMConfig.for_memory() - - text = """ - Yesterday I went hiking in Yosemite. - Last week I started my new job at Google. - This morning I had coffee with Alice. - """ - - facts, _ = await extract_facts_from_text( - text=text, - event_date=reference_date, - llm_config=llm_config, - agent_name="TestUser", - context="Personal diary" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - for fact in facts: - assert fact.fact, "Each fact should have 'fact' field" - - # Check that facts were extracted - dates may or may not be populated - # depending on LLM behavior - dates = [f.occurred_start for f in facts if f.occurred_start] - # If dates were extracted, they should ideally be different for different events - if len(dates) >= 2: - unique_dates = set(dates) - # Just verify we got dates, don't require them to be unique - - @pytest.mark.asyncio - async def test_extract_facts_with_no_temporal_info(self): - """Test that facts without temporal info are still extracted.""" - - reference_date = datetime(2024, 3, 20, 14, 0, 0, tzinfo=UTC) - llm_config = LLMConfig.for_memory() - - text = "Alice works at Google. She loves Python programming." - - facts, _ = await extract_facts_from_text( - text=text, - event_date=reference_date, - llm_config=llm_config, - agent_name="TestUser", - context="General info" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - # For facts without temporal info, occurred_start may be None or set to reference date - # We just verify that facts were extracted with content - for fact in facts: - assert fact.fact, "Each fact should have text content" - - @pytest.mark.asyncio - async def test_extract_facts_with_absolute_dates(self): - """Test that absolute dates in text are preserved.""" - - reference_date = datetime(2024, 3, 20, 14, 0, 0, tzinfo=UTC) - llm_config = LLMConfig.for_memory() - - text = """ - On March 15, 2024, Alice joined Google. - Bob will start his vacation on April 1st. - """ - - facts, _ = await extract_facts_from_text( - text=text, - event_date=reference_date, - llm_config=llm_config, - agent_name="TestUser", - context="Calendar events" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - for fact in facts: - assert fact.occurred_start, f"Fact should have a date: {fact.fact}" - - -# ============================================================================= -# LOGICAL INFERENCE TESTS -# ============================================================================= - -class TestLogicalInference: - """Tests that the system makes logical inferences to connect related information.""" - - @pytest.mark.asyncio - async def test_logical_inference_identity_connection(self): - """ - Test that the system extracts key information about loss and relationships. - - The LLM should extract facts about losing a friend and about Karlie. - Ideally it connects them, but we accept extracting both separately. - """ - text = """ -Deborah: The roses and dahlias bring me peace. I lost a friend last week, -so I've been spending time in the garden to find some comfort. - -Jolene: Sorry to hear about your friend, Deb. Losing someone can be really tough. -How are you holding up? - -Deborah: Thanks for the kind words. It's been tough, but I'm comforted by -remembering our time together. It reminds me of how special life is. - -Jolene: Memories can give us so much comfort and joy. - -Deborah: Memories keep our loved ones close. This is the last photo with Karlie -which was taken last summer when we hiked. It was our last one. We had such a -great time! Every time I see it, I can't help but smile. -""" - - context = "Conversation between Deborah and Jolene" - llm_config = LLMConfig.for_memory() - - event_date = datetime(2023, 2, 23) - - facts, _ = await extract_facts_from_text( - text=text, - event_date=event_date, - context=context, - llm_config=llm_config, - agent_name="Deborah" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - all_facts_text = " ".join([f.fact.lower() for f in facts]) - - # Check that key information is extracted (Karlie and the loss) - has_karlie = "karlie" in all_facts_text - has_loss = any(word in all_facts_text for word in ["lost", "death", "passed", "died", "losing", "friend"]) - has_hike = "hike" in all_facts_text or "hiking" in all_facts_text or "photo" in all_facts_text - - # At minimum, we should capture Karlie and either the loss or the hike memory - assert has_karlie or has_loss, ( - f"Should mention either Karlie or the loss in facts. Facts: {[f.fact for f in facts]}" - ) - - # Check if inference was made (bonus - not required for pass) - connected_fact_found = False - for fact in facts: - fact_text = fact.fact.lower() - if "karlie" in fact_text and any(word in fact_text for word in ["lost", "death", "passed", "died", "losing", "friend"]): - connected_fact_found = True - break - - # This is informational - test passes even without perfect inference - if not connected_fact_found and has_karlie and has_loss: - pass # Acceptable: facts extracted separately - - @pytest.mark.asyncio - async def test_logical_inference_pronoun_resolution(self): - """ - Test that pronouns are resolved to their referents. - - Example: "I started a project" + "It's challenging" -> "The project is challenging" - """ - text = """ -I started a new machine learning project last month. -It's been really challenging but very rewarding. -I've learned so much from it. -""" - - context = "Personal update" - llm_config = LLMConfig.for_memory() - - facts, _ = await extract_facts_from_text( - text=text, - event_date=datetime(2024, 11, 13), - context=context, - llm_config=llm_config, - agent_name="TestUser" - ) - - assert len(facts) > 0, "Should extract at least one fact" - - all_facts_text = " ".join([f.fact.lower() for f in facts]) - - has_project = "project" in all_facts_text - has_qualities = any(word in all_facts_text for word in ["challenging", "rewarding", "learned"]) - - assert has_project, "Should mention the project" - assert has_qualities, "Should mention the qualities/learning" - - connected_fact_found = False - for fact in facts: - fact_text = fact.fact.lower() - if "project" in fact_text and any(word in fact_text for word in ["challenging", "rewarding"]): - connected_fact_found = True - break - - assert connected_fact_found, ( - "Should resolve 'it' to 'the project' and connect characteristics in the same fact. " - f"Facts: {[f.fact for f in facts]}" - ) - - -# ============================================================================= -# FACT CLASSIFICATION TESTS -# ============================================================================= - -class TestFactClassification: - """Tests that facts are correctly classified as agent vs world.""" - - @pytest.mark.asyncio - async def test_agent_facts_from_podcast_transcript(self): - """ - Test that when context identifies someone as 'you', their actions are classified as agent facts. - - This test addresses the issue where podcast transcripts with context like - "this was podcast episode between you (Marcus) and Jamie" were extracting - all facts as 'world' instead of properly identifying Marcus's statements as 'bank'. - """ - - transcript = """ -Marcus: I've been working on AI safety research for the past six months. -Jamie: That's really interesting! What specifically are you focusing on? -Marcus: I'm investigating interpretability methods. I believe we need to understand -how models make decisions before we can trust them in critical applications. -Jamie: I completely agree with that approach. -Marcus: I published a paper on this topic last month, and I'm presenting it at -the conference next week. -Jamie: Congratulations! I'd love to read it. -""" - - context = "Podcast episode between you (Marcus) and Jamie discussing AI research" - - llm_config = LLMConfig.for_memory() - - facts, _ = await extract_facts_from_text( - text=transcript, - event_date=datetime(2024, 11, 13), - llm_config=llm_config, - agent_name="Marcus", - context=context - ) - - assert len(facts) > 0, "Should extract at least one fact from the transcript" - - # Check that we extracted meaningful content about AI research - all_facts_text = " ".join([f.fact.lower() for f in facts]) - has_ai_content = any(term in all_facts_text for term in [ - "ai", "safety", "interpretability", "research", "paper", "conference", "models" - ]) - assert has_ai_content, f"Should extract AI research content. Facts: {[f.fact for f in facts]}" - - # Check fact type classification (flexible - may vary by LLM) - agent_facts = [f for f in facts if f.fact_type == "agent"] - experience_facts = [f for f in facts if f.fact_type == "experience"] - - # Accept either agent or experience facts as valid for first-person statements - first_person_facts = agent_facts + experience_facts - - # If we have agent facts, verify they use first person - for agent_fact in agent_facts: - fact_text = agent_fact.fact - # Allow flexibility - fact may or may not start with "I" - if fact_text.startswith("I ") or " I " in fact_text: - pass # Good - uses first person - - @pytest.mark.asyncio - async def test_agent_facts_without_explicit_context(self): - """Test that when 'you' is used in the text itself, it gets properly classified.""" - - text = """ -I completed the project on machine learning interpretability last week. -My colleague Sarah helped me with the data analysis. -We presented our findings to the team yesterday. -""" - - context = "Personal work log" - - llm_config = LLMConfig.for_memory() - - facts, _ = await extract_facts_from_text( - text=text, - event_date=datetime(2024, 11, 13), - llm_config=llm_config, - agent_name="TestUser", - context=context - ) - - assert len(facts) > 0, "Should extract facts" - - agent_facts = [f for f in facts if f.fact_type == "agent"] - - assert len(agent_facts) >= 0 # Just verify classification works - - @pytest.mark.asyncio - async def test_speaker_attribution_predictions(self): - """ - Test that predictions made by different speakers are correctly attributed. - - This addresses the issue where Jamie's prediction of "Niners 27-13" was being - incorrectly attributed to Marcus (the agent) in the extracted facts. - """ - - transcript = """ -Marcus: [excited] I'm calling it now, Rams will win twenty seven to twenty four, their defense is too strong! -Jamie: [laughs] No way, I predict the Niners will win twenty seven to thirteen, comfy win at home. -Marcus: [angry] That's ridiculous, I stand by my Rams prediction. -Jamie: [teasing] We'll see who's right, my Niners pick is solid. -""" - - context = "podcast episode on match prediction of week 10 - Marcus (you) and Jamie - 14 nov" - agent_name = "Marcus" - - llm_config = LLMConfig.for_memory() - - facts, _ = await extract_facts_from_text( - text=transcript, - event_date=datetime(2024, 11, 14), - context=context, - llm_config=llm_config, - agent_name=agent_name - ) - - assert len(facts) > 0, "Should extract at least one fact" - - # Check that predictions were extracted - all_facts_text = " ".join([f.fact.lower() for f in facts]) - - # Should capture at least some prediction content - has_prediction_content = any(term in all_facts_text for term in [ - "rams", "niners", "49ers", "prediction", "win", "predict" - ]) - assert has_prediction_content, f"Should extract prediction content. Facts: {[f.fact for f in facts]}" - - # Ideally, Marcus's prediction should be in agent facts, but we accept - # any reasonable extraction of the predictions - agent_facts = [f for f in facts if f.fact_type == "agent"] - if agent_facts: - agent_facts_text = " ".join([f.fact.lower() for f in agent_facts]) - # If agent facts exist, they should relate to Marcus's statements - # (but we don't fail if classification varies) - - @pytest.mark.asyncio - async def test_skip_podcast_meta_commentary(self): - """ - Test that podcast intros, outros, and calls to action are skipped. - - This addresses the issue where podcast outros like "that's all for today, - don't forget to subscribe" were being extracted as facts. - """ - - transcript = """ -Marcus: Welcome everyone to today's episode! Before we dive in, don't forget to -subscribe and leave a rating. - -Marcus: Today I want to talk about my research on interpretability in AI systems. -I've been working on this for about a year now. - -Jamie: That sounds really interesting! What made you focus on that area? - -Marcus: I believe it's crucial for AI safety. We need to understand how these -models make decisions before we can trust them in critical applications. - -Jamie: I completely agree with that approach. - -Marcus: Well, I think that's gonna do it for us today! Thanks for listening everyone. -Don't forget to tap follow or subscribe, tell a friend, and drop a quick rating -so the algorithm learns to box out. See you next week! -""" - - context = "Podcast episode between you (Marcus) and Jamie about AI" - - llm_config = LLMConfig.for_memory() - - facts, _ = await extract_facts_from_text( - text=transcript, - event_date=datetime(2024, 11, 13), - llm_config=llm_config, - agent_name="Marcus", - context=context - ) - - assert len(facts) > 0, "Should extract at least one fact" - - # The main goal is to extract substantive content about AI research - # Meta-commentary filtering is ideal but not strictly required - all_facts_text = " ".join([f.fact.lower() for f in facts]) - - # Should extract the actual AI research content - has_substantive_content = any(term in all_facts_text for term in [ - "interpretability", "ai", "safety", "research", "models", "decisions" - ]) - assert has_substantive_content, \ - f"Should extract substantive AI research content. Facts: {[f.fact for f in facts]}" - - -# ============================================================================= -# DISPOSITION INFERENCE TESTS -# ============================================================================= - -class TestDispositionInference: - """Tests for LLM-based disposition trait inference from background.""" - - @pytest.mark.asyncio - async def test_background_merge_with_disposition_inference(self, memory, request_context): - """Test that background merge infers disposition traits by default.""" - import uuid - bank_id = f"test_infer_{uuid.uuid4().hex[:8]}" - - result = await memory.merge_bank_background( - bank_id, - "I am a creative software engineer who loves innovation and trying new technologies", - update_disposition=True, - request_context=request_context, - ) - - assert "background" in result - assert "disposition" in result - - background = result["background"] - disposition = result["disposition"] - - assert "creative" in background.lower() or "innovation" in background.lower() - - # Check that new traits are present with valid values (1-5) - required_traits = ["skepticism", "literalism", "empathy"] - for trait in required_traits: - assert trait in disposition - assert 1 <= disposition[trait] <= 5 - - @pytest.mark.asyncio - async def test_background_merge_without_disposition_inference(self, memory, request_context): - """Test that background merge skips disposition inference when disabled.""" - import uuid - bank_id = f"test_no_infer_{uuid.uuid4().hex[:8]}" - - initial_profile = await memory.get_bank_profile(bank_id, request_context=request_context) - initial_disposition = initial_profile["disposition"] - - result = await memory.merge_bank_background( - bank_id, - "I am a data scientist", - update_disposition=False, - request_context=request_context, - ) - - assert "background" in result - assert "disposition" not in result - - final_profile = await memory.get_bank_profile(bank_id, request_context=request_context) - final_disposition = final_profile["disposition"] - - assert initial_disposition == final_disposition - - @pytest.mark.asyncio - async def test_disposition_inference_for_lawyer(self, memory, request_context): - """Test disposition inference for lawyer profile (high skepticism, high literalism).""" - import uuid - bank_id = f"test_lawyer_{uuid.uuid4().hex[:8]}" - - result = await memory.merge_bank_background( - bank_id, - "I am a lawyer who focuses on contract details and never takes claims at face value", - update_disposition=True, - request_context=request_context, - ) - - disposition = result["disposition"] - - # Lawyers should have higher skepticism and literalism - assert disposition["skepticism"] >= 3 - assert disposition["literalism"] >= 3 - - @pytest.mark.asyncio - async def test_disposition_inference_for_therapist(self, memory, request_context): - """Test disposition inference for therapist profile (high empathy).""" - import uuid - bank_id = f"test_therapist_{uuid.uuid4().hex[:8]}" - - result = await memory.merge_bank_background( - bank_id, - "I am a therapist who deeply understands and connects with people's emotional struggles", - update_disposition=True, - request_context=request_context, - ) - - disposition = result["disposition"] - - # Therapists should have higher empathy - assert disposition["empathy"] >= 3 - - @pytest.mark.asyncio - async def test_disposition_updates_in_database(self, memory, request_context): - """Test that inferred disposition is actually stored in database.""" - import uuid - bank_id = f"test_db_update_{uuid.uuid4().hex[:8]}" - - result = await memory.merge_bank_background( - bank_id, - "I am an innovative designer", - update_disposition=True, - request_context=request_context, - ) - - inferred_disposition = result["disposition"] - - profile = await memory.get_bank_profile(bank_id, request_context=request_context) - db_disposition = profile["disposition"] - - # Compare values (db_disposition is a Pydantic model) - assert db_disposition.skepticism == inferred_disposition["skepticism"] - assert db_disposition.literalism == inferred_disposition["literalism"] - assert db_disposition.empathy == inferred_disposition["empathy"] - - @pytest.mark.asyncio - async def test_multiple_background_merges_update_disposition(self, memory, request_context): - """Test that each background merge can update disposition.""" - import uuid - bank_id = f"test_multi_merge_{uuid.uuid4().hex[:8]}" - - result1 = await memory.merge_bank_background( - bank_id, - "I am a software engineer", - update_disposition=True, - request_context=request_context, - ) - disposition1 = result1["disposition"] - - result2 = await memory.merge_bank_background( - bank_id, - "I love creative problem solving and innovation", - update_disposition=True, - request_context=request_context, - ) - disposition2 = result2["disposition"] - - assert "engineer" in result2["background"].lower() or "software" in result2["background"].lower() - assert "creative" in result2["background"].lower() or "innovation" in result2["background"].lower() - - @pytest.mark.asyncio - async def test_background_merge_conflict_resolution_with_disposition(self, memory, request_context): - """Test that conflicts are resolved and disposition reflects final background.""" - import uuid - bank_id = f"test_conflict_{uuid.uuid4().hex[:8]}" - - await memory.merge_bank_background( - bank_id, - "I was born in Colorado and prefer stability", - update_disposition=True, - request_context=request_context, - ) - - result = await memory.merge_bank_background( - bank_id, - "You were born in Texas and are very skeptical of people", - update_disposition=True, - request_context=request_context, - ) - - background = result["background"] - disposition = result["disposition"] - - assert "texas" in background.lower() - # Higher skepticism expected from "very skeptical of people" - assert disposition["skepticism"] >= 3 diff --git a/hindsight-api/tests/test_fact_ordering.py b/hindsight-api/tests/test_fact_ordering.py deleted file mode 100644 index 84fdbf65..00000000 --- a/hindsight-api/tests/test_fact_ordering.py +++ /dev/null @@ -1,184 +0,0 @@ -""" -Test that facts from the same conversation maintain temporal ordering. - -This ensures that when multiple facts are extracted from a long conversation, -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 -from hindsight_api.engine.memory_engine import Budget -import os - - -@pytest.mark.asyncio -async def test_fact_ordering_within_conversation(memory, request_context): - bank_id = "test_ordering_agent" - - # Get/create agent (auto-creates with defaults) - 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) - - # A conversation where Marcus changes his position - conversation = """ -Marcus: I think the Rams will win 27-24. Their defense is really strong. -Jamie: I disagree, I think Niners will win. -Marcus: Actually, after thinking about it more, I'm changing my prediction to Rams by 3 points only. -Jamie: That's more reasonable. -Marcus: Yeah, I realized I was being too optimistic about their defense. -""" - - base_event_date = datetime(2024, 11, 14, 10, 0, 0, tzinfo=timezone.utc) - - # Store the conversation - await memory.retain_async( - bank_id=bank_id, - content=conversation, - context="podcast discussion about NFL game", - event_date=base_event_date, - document_id="test_conv_1", - request_context=request_context, - ) - - # Search for all facts about Marcus's predictions - results = await memory.recall_async( - bank_id=bank_id, - query="Marcus prediction Rams", - fact_type=['opinion', 'experience', 'world'], - budget=Budget.LOW, - max_tokens=8192, - request_context=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]}") - - # Get all opinion facts (Marcus's predictions/statements) - agent_facts = [r for r in results.results if r.fact_type == 'opinion'] - - print(f"\n=== Agent facts (Marcus's statements) ===") - for i, fact in enumerate(agent_facts): - print(f"{i+1}. [{fact.mentioned_at}] {fact.text}") - - # Check that agent facts have different timestamps - if len(agent_facts) >= 2: - 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), \ - f"Expected unique timestamps for each fact, but got duplicates: {timestamps}" - - # Verify timestamps are in order (ascending) - for i in range(len(timestamps) - 1): - assert timestamps[i] < timestamps[i + 1], \ - f"Facts should be ordered by time. Fact {i} ({timestamps[i]}) >= Fact {i+1} ({timestamps[i+1]})" - - # Verify reasonable time spacing (should be ~10 seconds apart) - time_diffs = [(timestamps[i+1] - timestamps[i]).total_seconds() for i in range(len(timestamps) - 1)] - print(f"\n=== Time differences between facts: {time_diffs} seconds ===") - - # Each fact should be 10+ seconds apart (allowing for some flexibility) - for diff in time_diffs: - assert diff >= 5, f"Expected at least 5 seconds between facts, got {diff}" - - print(f"\n✅ All {len(agent_facts)} agent facts have properly ordered timestamps") - - # Verify that retrieval returns facts in chronological order - # The first prediction should come before the changed prediction - agent_texts = [f.text.lower() for f in agent_facts] - - # Look for evidence of the sequence - has_first_prediction = any('27' in text and '24' in text for text in agent_texts) - has_changed_prediction = any('chang' in text or 'by 3' in text or 'realized' in text for text in agent_texts) - - if has_first_prediction and has_changed_prediction: - # Find indices - first_idx = next(i for i, text in enumerate(agent_texts) if '27' in text and '24' in text) - changed_idx = next(i for i, text in enumerate(agent_texts) if 'chang' in text or 'by 3' in text or 'realized' in text) - - print(f"\nFirst prediction at index {first_idx}: {agent_facts[first_idx].text[:100]}") - print(f"Changed prediction at index {changed_idx}: {agent_facts[changed_idx].text[:100]}") - - # The original prediction should come before the changed one - assert timestamps[first_idx] < timestamps[changed_idx], \ - "Original prediction should have earlier timestamp than changed prediction" - - print(f"\n✅ Temporal ordering preserved: First prediction came before changed prediction") - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - print(f"\n✅ Test passed: Fact ordering within conversation is preserved") - - -@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 - - # Two separate conversations with same base time - base_time = datetime(2024, 11, 14, 10, 0, 0, tzinfo=timezone.utc) - - conv1 = """ -Alice: I prefer React for this project. -Bob: Why React? -Alice: It has better tooling and I'm more familiar with it. -""" - - conv2 = """ -Alice: Actually, I'm thinking Vue might be better. -Bob: What changed your mind? -Alice: I reconsidered the team's experience level. -""" - - # Store both conversations with batch - await memory.retain_batch_async( - bank_id=bank_id, - contents=[ - {"content": conv1, "context": "project discussion 1", "event_date": base_time}, - {"content": conv2, "context": "project discussion 2", "event_date": base_time} - ], - request_context=request_context, - ) - - # Search for Alice's preferences - results = await memory.recall_async( - bank_id=bank_id, - query="Alice preference React Vue", - fact_type=['opinion', 'experience'], - budget=Budget.LOW, - max_tokens=8192, - request_context=request_context, - ) - - print(f"\n=== Retrieved {len(results.results)} agent facts ===") - agent_facts = [r for r in results.results if r.fact_type in ('opinion', 'experience')] - - for i, fact in enumerate(agent_facts): - print(f"{i+1}. [{fact.mentioned_at}] {fact.text[:80]}") - - # Each conversation's facts should have different timestamps - if len(agent_facts) >= 2: - timestamps = [datetime.fromisoformat(f.mentioned_at.replace('Z', '+00:00')) for f in agent_facts] - unique_timestamps = set(timestamps) - - assert len(unique_timestamps) >= 2, \ - f"Expected multiple unique timestamps across conversations, got: {len(unique_timestamps)}" - - print(f"\n✅ Facts from {len(agent_facts)} statements have {len(unique_timestamps)} unique timestamps") - - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - print(f"\n✅ Test passed: Multiple documents maintain separate ordering") diff --git a/hindsight-api/tests/test_http_api_integration.py b/hindsight-api/tests/test_http_api_integration.py deleted file mode 100644 index 1707be5c..00000000 --- a/hindsight-api/tests/test_http_api_integration.py +++ /dev/null @@ -1,610 +0,0 @@ -""" -Integration test for the complete Hindsight API. - -Tests all endpoints by starting a FastAPI server and making HTTP requests. -""" -import pytest -import pytest_asyncio -import httpx -from datetime import datetime -from hindsight_api.api import create_app - - -@pytest_asyncio.fixture -async def api_client(memory): - """Create an async test client for the FastAPI app.""" - # Memory is already initialized by the conftest fixture (with migrations) - 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 -def test_bank_id(): - """Provide a unique bank ID for this test run.""" - return f"integration_test_{datetime.now().timestamp()}" - - -@pytest.mark.asyncio -async def test_full_api_workflow(api_client, test_bank_id): - """ - End-to-end test covering all major API endpoints in a realistic workflow. - - Workflow: - 1. Create bank and set profile - 2. Store memories (retain) - 3. Recall memories - 4. Reflect (generate answer) - 5. List banks and memories - 6. Get bank profile - 7. Get visualization data - 8. Track documents - 9. Test entity endpoints - 10. Test operations endpoints - 11. Clean up - """ - - # ================================================================ - # 1. Bank Management - # ================================================================ - - # List banks (should be empty initially or have other test banks) - response = await api_client.get("/v1/default/banks") - assert response.status_code == 200 - initial_banks_data = response.json()["banks"] - initial_banks = [a["bank_id"] for a in initial_banks_data] - - # Get bank profile (creates default if not exists) - response = await api_client.get(f"/v1/default/banks/{test_bank_id}/profile") - assert response.status_code == 200 - profile = response.json() - assert "disposition" in profile - assert "background" in profile - - # Add background - response = await api_client.post( - f"/v1/default/banks/{test_bank_id}/background", - json={ - "content": "A software engineer passionate about AI and memory systems." - } - ) - assert response.status_code == 200 - assert "software engineer" in response.json()["background"].lower() - - # ================================================================ - # 2. Memory Storage - # ================================================================ - - # Store single memory (using batch endpoint with single item) - response = await api_client.post( - f"/v1/default/banks/{test_bank_id}/memories", - json={ - "items": [ - { - "content": "Alice is a machine learning researcher at Stanford.", - "context": "conversation about team members" - } - ] - } - ) - assert response.status_code == 200 - put_result = response.json() - assert put_result["success"] is True - assert put_result["items_count"] == 1 - - # Store batch memories - response = await api_client.post( - f"/v1/default/banks/{test_bank_id}/memories", - json={ - "items": [ - { - "content": "Bob leads the infrastructure team and loves Kubernetes.", - "context": "team introduction" - }, - { - "content": "Charlie recently joined as a product manager from Google.", - "context": "new hire announcement" - } - ] - } - ) - assert response.status_code == 200 - batch_result = response.json() - assert batch_result["success"] is True - assert batch_result["items_count"] == 2 - - # ================================================================ - # 3. Recall (Search) - # ================================================================ - - # Recall memories - response = await api_client.post( - f"/v1/default/banks/{test_bank_id}/memories/recall", - json={ - "query": "Who works on machine learning?", - "thinking_budget": 50 - } - ) - assert response.status_code == 200 - search_results = response.json() - assert "results" in search_results - assert len(search_results["results"]) > 0 - - # Verify we found Alice - found_alice = any("Alice" in r["text"] for r in search_results["results"]) - assert found_alice, "Should find Alice in search results" - - # ================================================================ - # 4. Reflect (Reasoning) - # ================================================================ - - # Generate answer using reflect - response = await api_client.post( - f"/v1/default/banks/{test_bank_id}/reflect", - json={ - "query": "What do you know about the team members?", - "thinking_budget": 30, - "context": "This is for a team overview document" - } - ) - assert response.status_code == 200 - reflect_result = response.json() - assert "text" in reflect_result - assert len(reflect_result["text"]) > 0 - assert "based_on" in reflect_result - - # Verify the answer mentions team members - answer = reflect_result["text"].lower() - assert "alice" in answer or "bob" in answer or "charlie" in answer - - # ================================================================ - # 5. Visualization & Statistics - # ================================================================ - - # Get graph data - response = await api_client.get(f"/v1/default/banks/{test_bank_id}/graph") - assert response.status_code == 200 - graph_data = response.json() - assert "nodes" in graph_data - assert "edges" in graph_data - - # Get memory statistics - response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats") - assert response.status_code == 200 - stats = response.json() - assert "total_nodes" in stats - assert stats["total_nodes"] > 0 - - # List memory units - response = await api_client.get( - f"/v1/default/banks/{test_bank_id}/memories/list", - params={"limit": 10} - ) - assert response.status_code == 200 - memory_units = response.json() - assert "items" in memory_units - assert len(memory_units["items"]) > 0 - - # ================================================================ - # 6. Document Tracking - # ================================================================ - - # Store memory with document - response = await api_client.post( - f"/v1/default/banks/{test_bank_id}/memories", - json={ - "items": [ - { - "content": "Project timeline: MVP launch in Q1, Beta in Q2.", - "context": "product roadmap", - "document_id": "roadmap-2024-q1" - } - ] - } - ) - assert response.status_code == 200 - - # List documents - response = await api_client.get(f"/v1/default/banks/{test_bank_id}/documents") - assert response.status_code == 200 - documents = response.json() - assert "items" in documents - assert len(documents["items"]) > 0 - - # Get specific document - response = await api_client.get( - f"/v1/default/banks/{test_bank_id}/documents/roadmap-2024-q1" - ) - assert response.status_code == 200 - doc_info = response.json() - assert "id" in doc_info - assert doc_info["id"] == "roadmap-2024-q1" - assert doc_info["memory_unit_count"] > 0 - # Note: Document deletion is tested separately in test_document_deletion - - # ================================================================ - # 7. Update and Verify Bank Disposition - # ================================================================ - - # Update disposition traits - response = await api_client.put( - f"/v1/default/banks/{test_bank_id}/profile", - json={ - "disposition": { - "skepticism": 4, - "literalism": 3, - "empathy": 4 - } - } - ) - assert response.status_code == 200 - - # Check profile again (should have updated disposition) - response = await api_client.get(f"/v1/default/banks/{test_bank_id}/profile") - assert response.status_code == 200 - updated_profile = response.json() - assert "software engineer" in updated_profile["background"].lower() - - # ================================================================ - # 8. Test Entity Endpoints - # ================================================================ - - # List entities - response = await api_client.get(f"/v1/default/banks/{test_bank_id}/entities") - assert response.status_code == 200 - entities_data = response.json() - assert "items" in entities_data - - # Get specific entity if any exist - if len(entities_data['items']) > 0: - entity_id = entities_data['items'][0]['id'] - response = await api_client.get( - f"/v1/default/banks/{test_bank_id}/entities/{entity_id}" - ) - assert response.status_code == 200 - entity_detail = response.json() - assert "id" in entity_detail - - # Test regenerate observations - response = await api_client.post( - f"/v1/default/banks/{test_bank_id}/entities/{entity_id}/regenerate" - ) - assert response.status_code == 200 - - # ================================================================ - # 9. List All Banks (should include our test bank) - # ================================================================ - - response = await api_client.get("/v1/default/banks") - assert response.status_code == 200 - final_banks_data = response.json()["banks"] - final_banks = [a["bank_id"] for a in final_banks_data] - assert test_bank_id in final_banks - # Don't assert count increases due to parallel test cleanup races - # Just verify our bank exists in the list - - # ================================================================ - # 10. Clean Up - # ================================================================ - - # Note: No delete bank endpoint in API, so test data remains in DB - # Using timestamped bank IDs prevents conflicts between test runs - - -@pytest.mark.asyncio -async def test_error_handling(api_client): - """Test that API properly handles error cases.""" - - # Invalid request (missing required field) - response = await api_client.post( - "/v1/default/banks/error_test/memories", - json={ - "items": [ - { - # Missing "content" - "context": "test" - } - ] - } - ) - assert response.status_code == 422 # Validation error - - # Recall with invalid parameters - response = await api_client.post( - "/v1/default/banks/error_test/memories/recall", - json={ - "query": "test", - "budget": "invalid_budget" # Invalid budget value (should be low/mid/high) - } - ) - assert response.status_code == 422 - - # Get non-existent document - response = await api_client.get( - "/v1/default/banks/nonexistent_bank/documents/fake-doc-id" - ) - assert response.status_code == 404 - - -@pytest.mark.asyncio -async def test_concurrent_requests(api_client): - """Test that API can handle concurrent requests.""" - bank_id = f"concurrent_test_{datetime.now().timestamp()}" - - # Store multiple memories concurrently (simulated with sequential calls) - responses = [] - test_facts = [ - "David works as a data scientist at Microsoft.", - "Emily is the CEO of a startup in San Francisco.", - "Frank teaches computer science at MIT.", - "Grace is a software architect specializing in distributed systems.", - "Henry leads the product team at Amazon." - ] - for fact in test_facts: - response = await api_client.post( - f"/v1/default/banks/{bank_id}/memories", - json={ - "items": [ - { - "content": fact, - "context": "concurrent test" - } - ] - } - ) - responses.append(response) - - # All should succeed - assert all(r.status_code == 200 for r in responses) - assert all(r.json()["success"] for r in responses) - - # Verify all facts stored - response = await api_client.get( - f"/v1/default/banks/{bank_id}/memories/list", - params={"limit": 20} - ) - assert response.status_code == 200 - items = response.json()["items"] - assert len(items) >= 5 - - -@pytest.mark.asyncio -async def test_document_deletion(api_client): - """Test document deletion including cascade deletion of memory units and links.""" - test_bank_id = f"doc_delete_test_{datetime.now().timestamp()}" - - # Store a document with memory - response = await api_client.post( - f"/v1/default/banks/{test_bank_id}/memories", - json={ - "items": [ - { - "content": "The quarterly sales report shows a 25% increase in revenue.", - "context": "Q1 financial review", - "document_id": "sales-report-q1-2024" - } - ] - } - ) - assert response.status_code == 200 - - # Verify document exists - response = await api_client.get( - f"/v1/default/banks/{test_bank_id}/documents/sales-report-q1-2024" - ) - assert response.status_code == 200 - doc_info = response.json() - initial_units = doc_info["memory_unit_count"] - assert initial_units > 0 - - # Delete the document - response = await api_client.delete( - f"/v1/default/banks/{test_bank_id}/documents/sales-report-q1-2024" - ) - assert response.status_code == 200 - delete_result = response.json() - assert delete_result["success"] is True - assert delete_result["document_id"] == "sales-report-q1-2024" - assert delete_result["memory_units_deleted"] == initial_units - - # Verify document is gone (should return 404) - response = await api_client.get( - f"/v1/default/banks/{test_bank_id}/documents/sales-report-q1-2024" - ) - assert response.status_code == 404 - - # Verify document is not in the list - response = await api_client.get(f"/v1/default/banks/{test_bank_id}/documents") - assert response.status_code == 200 - documents = response.json() - doc_ids = [doc["id"] for doc in documents["items"]] - assert "sales-report-q1-2024" not in doc_ids - - # Try to delete again (should return 404) - response = await api_client.delete( - f"/v1/default/banks/{test_bank_id}/documents/sales-report-q1-2024" - ) - assert response.status_code == 404 - - -@pytest.mark.asyncio -async def test_async_retain(api_client): - """Test asynchronous retain functionality. - - When async=true is passed, the retain endpoint should: - 1. Return immediately with success and async_=true - 2. Process the content in the background - 3. Eventually store the memories - """ - import asyncio - - test_bank_id = f"async_retain_test_{datetime.now().timestamp()}" - - # Store memory with async=true - response = await api_client.post( - f"/v1/default/banks/{test_bank_id}/memories", - json={ - "async": True, - "items": [ - { - "content": "Alice is a senior engineer at TechCorp. She has been working on the authentication system for 5 years.", - "context": "team introduction" - } - ] - } - ) - assert response.status_code == 200 - result = response.json() - assert result["success"] is True - assert result["async"] is True, "Response should indicate async processing" - assert result["items_count"] == 1 - - # Check operations endpoint to see the pending operation - response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations") - assert response.status_code == 200 - ops_result = response.json() - assert "operations" in ops_result - - # Wait for async processing to complete (poll with timeout) - max_wait_seconds = 30 - poll_interval = 0.5 - elapsed = 0 - memories_found = False - - while elapsed < max_wait_seconds: - # Check if memories are stored - response = await api_client.get( - f"/v1/default/banks/{test_bank_id}/memories/list", - params={"limit": 10} - ) - assert response.status_code == 200 - items = response.json()["items"] - - if len(items) > 0: - memories_found = True - break - - await asyncio.sleep(poll_interval) - elapsed += poll_interval - - assert memories_found, f"Async retain did not complete within {max_wait_seconds} seconds" - - # Verify we can recall the stored memory - response = await api_client.post( - f"/v1/default/banks/{test_bank_id}/memories/recall", - json={ - "query": "Who works at TechCorp?", - "thinking_budget": 30 - } - ) - assert response.status_code == 200 - search_results = response.json() - assert len(search_results["results"]) > 0, "Should find the asynchronously stored memory" - - # Verify Alice is mentioned - found_alice = any("Alice" in r["text"] for r in search_results["results"]) - assert found_alice, "Should find Alice in search results" - - -@pytest.mark.asyncio -async def test_async_retain_parallel(api_client): - """Test multiple async retain operations running in parallel. - - Verifies that: - 1. Multiple async operations can be submitted concurrently - 2. All operations complete successfully - 3. The exact number of documents are processed - """ - import asyncio - - test_bank_id = f"async_parallel_test_{datetime.now().timestamp()}" - num_documents = 5 - - # Prepare multiple documents to retain - documents = [ - { - "content": f"Document {i}: This is test content about Person{i} who works at Company{i}.", - "context": f"test document {i}", - "document_id": f"doc_{i}" - } - for i in range(num_documents) - ] - - # Submit all async retain operations in parallel - async def submit_async_retain(doc): - return await api_client.post( - f"/v1/default/banks/{test_bank_id}/memories", - json={ - "async": True, - "items": [doc] - } - ) - - # Run all submissions concurrently - responses = await asyncio.gather(*[submit_async_retain(doc) for doc in documents]) - - # Verify all submissions succeeded - for i, response in enumerate(responses): - assert response.status_code == 200, f"Document {i} submission failed" - result = response.json() - assert result["success"] is True - assert result["async"] is True - - # Check operations endpoint - should show pending operations - response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations") - assert response.status_code == 200 - - # Wait for all async operations to complete (poll with timeout) - max_wait_seconds = 60 - poll_interval = 1.0 - elapsed = 0 - all_docs_processed = False - - while elapsed < max_wait_seconds: - # Check document count - response = await api_client.get(f"/v1/default/banks/{test_bank_id}/documents") - assert response.status_code == 200 - docs = response.json()["items"] - - if len(docs) >= num_documents: - all_docs_processed = True - break - - await asyncio.sleep(poll_interval) - elapsed += poll_interval - - assert all_docs_processed, f"Expected {num_documents} documents, but only {len(docs)} were processed within {max_wait_seconds} seconds" - - # Verify exact document count - response = await api_client.get(f"/v1/default/banks/{test_bank_id}/documents") - assert response.status_code == 200 - final_docs = response.json()["items"] - assert len(final_docs) == num_documents, f"Expected exactly {num_documents} documents, got {len(final_docs)}" - - # Verify each document exists - doc_ids = {doc["id"] for doc in final_docs} - for i in range(num_documents): - assert f"doc_{i}" in doc_ids, f"Document doc_{i} not found" - - # Verify memories were created for all documents - response = await api_client.get( - f"/v1/default/banks/{test_bank_id}/memories/list", - params={"limit": 100} - ) - assert response.status_code == 200 - memories = response.json()["items"] - assert len(memories) >= num_documents, f"Expected at least {num_documents} memories, got {len(memories)}" - - # Verify we can recall content from different documents - for i in [0, num_documents - 1]: # Check first and last - response = await api_client.post( - f"/v1/default/banks/{test_bank_id}/memories/recall", - json={ - "query": f"Who works at Company{i}?", - "thinking_budget": 30 - } - ) - assert response.status_code == 200 - results = response.json()["results"] - assert len(results) > 0, f"Should find memories for document {i}" diff --git a/hindsight-api/tests/test_link_utils.py b/hindsight-api/tests/test_link_utils.py deleted file mode 100644 index 969ca70f..00000000 --- a/hindsight-api/tests/test_link_utils.py +++ /dev/null @@ -1,256 +0,0 @@ -"""Tests for link_utils datetime handling and temporal link computation.""" -import pytest -from datetime import datetime, timezone, timedelta - -from hindsight_api.engine.retain.link_utils import ( - _normalize_datetime, - compute_temporal_links, - compute_temporal_query_bounds, -) - - -class TestNormalizeDatetime: - """Tests for the _normalize_datetime helper function.""" - - def test_none_returns_none(self): - """Test that None input returns None.""" - assert _normalize_datetime(None) is None - - def test_naive_datetime_becomes_utc(self): - """Test that naive datetimes are converted to UTC.""" - naive_dt = datetime(2024, 6, 15, 10, 30, 0) - result = _normalize_datetime(naive_dt) - - assert result.tzinfo is not None - assert result.tzinfo == timezone.utc - assert result.year == 2024 - assert result.month == 6 - assert result.day == 15 - assert result.hour == 10 - assert result.minute == 30 - - def test_aware_datetime_unchanged(self): - """Test that timezone-aware datetimes are returned unchanged.""" - aware_dt = datetime(2024, 6, 15, 10, 30, 0, tzinfo=timezone.utc) - result = _normalize_datetime(aware_dt) - - assert result == aware_dt - assert result.tzinfo == timezone.utc - - def test_mixed_datetimes_can_be_compared(self): - """Test that normalized naive and aware datetimes can be compared.""" - naive_dt = datetime(2024, 6, 15, 10, 30, 0) - aware_dt = datetime(2024, 6, 15, 10, 30, 0, tzinfo=timezone.utc) - - normalized_naive = _normalize_datetime(naive_dt) - normalized_aware = _normalize_datetime(aware_dt) - - # Should be able to compare without TypeError - assert normalized_naive == normalized_aware - - -class TestComputeTemporalQueryBounds: - """Tests for compute_temporal_query_bounds function.""" - - def test_empty_units_returns_none(self): - """Test that empty input returns (None, None).""" - min_date, max_date = compute_temporal_query_bounds({}) - assert min_date is None - assert max_date is None - - def test_single_unit_normal_date(self): - """Test bounds for a single unit with normal date.""" - units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)} - min_date, max_date = compute_temporal_query_bounds(units, time_window_hours=24) - - assert min_date == datetime(2024, 6, 14, 12, 0, 0, tzinfo=timezone.utc) - assert max_date == datetime(2024, 6, 16, 12, 0, 0, tzinfo=timezone.utc) - - def test_multiple_units(self): - """Test bounds span across multiple units.""" - units = { - "unit-1": datetime(2024, 6, 10, 12, 0, 0, tzinfo=timezone.utc), - "unit-2": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), - "unit-3": datetime(2024, 6, 20, 12, 0, 0, tzinfo=timezone.utc), - } - min_date, max_date = compute_temporal_query_bounds(units, time_window_hours=24) - - # min should be Jun 10 - 24h = Jun 9 - assert min_date == datetime(2024, 6, 9, 12, 0, 0, tzinfo=timezone.utc) - # max should be Jun 20 + 24h = Jun 21 - assert max_date == datetime(2024, 6, 21, 12, 0, 0, tzinfo=timezone.utc) - - def test_mixed_naive_and_aware_datetimes(self): - """Test that mixed naive/aware datetimes work correctly.""" - units = { - "unit-1": datetime(2024, 6, 10, 12, 0, 0), # naive - "unit-2": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), # aware - } - # Should not raise TypeError - min_date, max_date = compute_temporal_query_bounds(units, time_window_hours=24) - - assert min_date is not None - assert max_date is not None - assert min_date.tzinfo is not None - assert max_date.tzinfo is not None - - def test_overflow_near_datetime_min(self): - """Test overflow protection near datetime.min.""" - units = {"unit-1": datetime(1, 1, 2, 0, 0, tzinfo=timezone.utc)} - min_date, max_date = compute_temporal_query_bounds(units, time_window_hours=48) - - # Should handle overflow gracefully - assert min_date == datetime.min.replace(tzinfo=timezone.utc) - assert max_date is not None - - def test_overflow_near_datetime_max(self): - """Test overflow protection near datetime.max.""" - units = {"unit-1": datetime(9999, 12, 30, 0, 0, tzinfo=timezone.utc)} - min_date, max_date = compute_temporal_query_bounds(units, time_window_hours=48) - - # Should handle overflow gracefully - assert min_date is not None - assert max_date == datetime.max.replace(tzinfo=timezone.utc) - - -class TestComputeTemporalLinks: - """Tests for compute_temporal_links function.""" - - def test_empty_units_returns_empty(self): - """Test that empty input returns empty list.""" - links = compute_temporal_links({}, []) - assert links == [] - - def test_no_candidates_returns_empty(self): - """Test that no candidates means no links.""" - units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)} - links = compute_temporal_links(units, []) - assert links == [] - - def test_candidate_within_window_creates_link(self): - """Test that candidates within time window create links.""" - units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)} - candidates = [ - {"id": "candidate-1", "event_date": datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)}, - ] - - links = compute_temporal_links(units, candidates, time_window_hours=24) - - assert len(links) == 1 - assert links[0][0] == "unit-1" - assert links[0][1] == "candidate-1" - assert links[0][2] == "temporal" - assert links[0][4] is None - # Weight should be high since they're close (2 hours apart) - assert links[0][3] > 0.9 - - def test_candidate_outside_window_no_link(self): - """Test that candidates outside time window don't create links.""" - units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)} - candidates = [ - {"id": "candidate-1", "event_date": datetime(2024, 6, 10, 12, 0, 0, tzinfo=timezone.utc)}, - ] - - links = compute_temporal_links(units, candidates, time_window_hours=24) - - assert len(links) == 0 - - def test_weight_decreases_with_distance(self): - """Test that weight decreases as time difference increases.""" - units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)} - candidates = [ - {"id": "close", "event_date": datetime(2024, 6, 15, 11, 0, 0, tzinfo=timezone.utc)}, # 1 hour - {"id": "far", "event_date": datetime(2024, 6, 14, 18, 0, 0, tzinfo=timezone.utc)}, # 18 hours - ] - - links = compute_temporal_links(units, candidates, time_window_hours=24) - - assert len(links) == 2 - close_link = next(l for l in links if l[1] == "close") - far_link = next(l for l in links if l[1] == "far") - - assert close_link[3] > far_link[3] - - def test_max_10_links_per_unit(self): - """Test that at most 10 links are created per unit.""" - units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)} - # Create 15 candidates all within window - candidates = [ - {"id": f"candidate-{i}", "event_date": datetime(2024, 6, 15, 11, 0, 0, tzinfo=timezone.utc)} - for i in range(15) - ] - - links = compute_temporal_links(units, candidates, time_window_hours=24) - - assert len(links) == 10 - - def test_multiple_units_multiple_candidates(self): - """Test with multiple units and candidates.""" - units = { - "unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc), - "unit-2": datetime(2024, 6, 20, 12, 0, 0, tzinfo=timezone.utc), - } - candidates = [ - {"id": "c1", "event_date": datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)}, # near unit-1 - {"id": "c2", "event_date": datetime(2024, 6, 20, 10, 0, 0, tzinfo=timezone.utc)}, # near unit-2 - {"id": "c3", "event_date": datetime(2024, 6, 17, 12, 0, 0, tzinfo=timezone.utc)}, # between, near neither - ] - - links = compute_temporal_links(units, candidates, time_window_hours=24) - - # unit-1 should link to c1 only - # unit-2 should link to c2 only - unit1_links = [l for l in links if l[0] == "unit-1"] - unit2_links = [l for l in links if l[0] == "unit-2"] - - assert len(unit1_links) == 1 - assert unit1_links[0][1] == "c1" - - assert len(unit2_links) == 1 - assert unit2_links[0][1] == "c2" - - def test_mixed_naive_and_aware_datetimes(self): - """Test that mixed naive/aware datetimes work correctly.""" - units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0)} # naive - candidates = [ - {"id": "c1", "event_date": datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc)}, # aware - ] - - # Should not raise TypeError - links = compute_temporal_links(units, candidates, time_window_hours=24) - assert len(links) == 1 - - def test_overflow_near_datetime_min(self): - """Test overflow protection when unit date is near datetime.min.""" - units = {"unit-1": datetime(1, 1, 2, 0, 0, tzinfo=timezone.utc)} - candidates = [ - {"id": "c1", "event_date": datetime(1, 1, 1, 12, 0, 0, tzinfo=timezone.utc)}, - ] - - # Should not raise OverflowError - links = compute_temporal_links(units, candidates, time_window_hours=48) - assert len(links) == 1 - - def test_overflow_near_datetime_max(self): - """Test overflow protection when unit date is near datetime.max.""" - units = {"unit-1": datetime(9999, 12, 30, 0, 0, tzinfo=timezone.utc)} - candidates = [ - {"id": "c1", "event_date": datetime(9999, 12, 31, 12, 0, 0, tzinfo=timezone.utc)}, - ] - - # Should not raise OverflowError - links = compute_temporal_links(units, candidates, time_window_hours=48) - assert len(links) == 1 - - def test_weight_minimum_is_0_3(self): - """Test that weight doesn't go below 0.3.""" - units = {"unit-1": datetime(2024, 6, 15, 12, 0, 0, tzinfo=timezone.utc)} - candidates = [ - # 23 hours apart - should be just within 24h window but low weight - {"id": "c1", "event_date": datetime(2024, 6, 14, 13, 0, 0, tzinfo=timezone.utc)}, - ] - - links = compute_temporal_links(units, candidates, time_window_hours=24) - - assert len(links) == 1 - assert links[0][3] >= 0.3 diff --git a/hindsight-api/tests/test_llm_provider.py b/hindsight-api/tests/test_llm_provider.py deleted file mode 100644 index 4f26606a..00000000 --- a/hindsight-api/tests/test_llm_provider.py +++ /dev/null @@ -1,119 +0,0 @@ -""" -Test LLM provider with different models using actual memory operations. -""" -import os -from datetime import datetime -import pytest -from hindsight_api.engine.llm_wrapper import LLMProvider -from hindsight_api.engine.utils import extract_facts -from hindsight_api.engine.search.think_utils import reflect - - -# Model matrix: (provider, model) -MODEL_MATRIX = [ - # OpenAI models - ("openai", "gpt-4o-mini"), - ("openai", "gpt-4.1-mini"), - ("openai", "gpt-4.1-nano"), - ("openai", "gpt-5-mini"), - ("openai", "gpt-5-nano"), - ("openai", "gpt-5"), - ("openai", "gpt-5.2"), - # Groq models - ("groq", "openai/gpt-oss-120b"), - ("groq", "openai/gpt-oss-20b"), - # Gemini models - ("gemini", "gemini-2.5-flash"), - ("gemini", "gemini-2.5-flash-lite"), - ("gemini", "gemini-3-pro-preview"), - # Ollama models (local) - ("ollama", "gemma3:12b"), - ("ollama", "gemma3:1b"), -] - - -def get_api_key_for_provider(provider: str) -> str | None: - """Get API key for provider from environment variables.""" - provider_key_map = { - "openai": "OPENAI_API_KEY", - "groq": "GROQ_API_KEY", - "gemini": "GEMINI_API_KEY", - } - env_var = provider_key_map.get(provider) - return os.getenv(env_var) if env_var else None - - -@pytest.mark.parametrize("provider,model", MODEL_MATRIX) -@pytest.mark.asyncio -async def test_llm_provider_memory_operations(provider: str, model: str): - """ - Test LLM provider with actual memory operations: fact extraction and reflect. - All models must pass this test. - """ - api_key = get_api_key_for_provider(provider) - - # Skip Ollama tests in CI (no models available) - if provider == "ollama" and os.getenv("CI"): - pytest.skip(f"Skipping {provider}/{model}: Ollama not available in CI") - - # Other providers need an API key - if provider != "ollama" and not api_key: - pytest.skip(f"Skipping {provider}/{model}: no API key available") - - llm = LLMProvider( - provider=provider, - api_key=api_key or "", - base_url="", - model=model, - ) - - # Test 1: Fact extraction (structured output) - test_text = """ - User: I just got back from my trip to Paris last week. The Eiffel Tower was amazing! - Assistant: That sounds wonderful! How long were you there? - User: About 5 days. I also visited the Louvre and saw the Mona Lisa. - """ - event_date = datetime(2024, 12, 10) - - facts, chunks = await extract_facts( - text=test_text, - event_date=event_date, - context="Travel conversation", - llm_config=llm, - ) - - print(f"\n{provider}/{model} - Fact extraction:") - print(f" Extracted {len(facts)} facts from {len(chunks)} chunks") - for fact in facts: - print(f" - {fact.fact}") - - assert facts is not None, f"{provider}/{model} fact extraction returned None" - assert len(facts) > 0, f"{provider}/{model} should extract at least one fact" - - # Verify facts have required fields - for fact in facts: - assert fact.fact, f"{provider}/{model} fact missing text" - assert fact.fact_type in ["world", "experience", "opinion"], f"{provider}/{model} invalid fact_type: {fact.fact_type}" - - # Test 2: Reflect (actual reflect function) - response = await reflect( - llm_config=llm, - query="What was the highlight of my Paris trip?", - experience_facts=[ - "I visited Paris in December 2024", - "I saw the Eiffel Tower and it was amazing", - "I visited the Louvre and saw the Mona Lisa", - "The trip lasted 5 days", - ], - world_facts=[ - "The Eiffel Tower is a famous landmark in Paris", - "The Mona Lisa is displayed at the Louvre museum", - ], - name="Traveler", - ) - - print(f"\n{provider}/{model} - Reflect response:") - print(f" {response[:200]}...") - - assert response is not None, f"{provider}/{model} reflect returned None" - assert len(response) > 10, f"{provider}/{model} reflect response too short" diff --git a/hindsight-api/tests/test_mcp_api_integration.py b/hindsight-api/tests/test_mcp_api_integration.py deleted file mode 100644 index e7cd0d49..00000000 --- a/hindsight-api/tests/test_mcp_api_integration.py +++ /dev/null @@ -1,177 +0,0 @@ -""" -Integration test for the MCP (Model Context Protocol) server. - -Tests MCP endpoints by starting a FastAPI server with MCP enabled and using the MCP client. - -Note: MCP server is integrated with the web server. These tests require HINDSIGHT_API_MCP_ENABLED=true. -""" -import asyncio -import pytest -import pytest_asyncio -import httpx -from mcp import ClientSession -from mcp.client.sse import sse_client -from hindsight_api.api import create_app - - -@pytest_asyncio.fixture -async def mcp_server(memory): - """Start the FastAPI app with MCP enabled and return the SSE URL.""" - # Memory is already initialized by the conftest fixture (with migrations) - app = create_app( - memory, - initialize_memory=False, - mcp_api_enabled=True - ) - - # Use httpx to create a test server - transport = httpx.ASGITransport(app=app) - async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: - # The MCP SSE endpoint is at /mcp/sse - # We need to yield the base URL for sse_client to connect - # However, sse_client expects a real URL, not a test client - # So we'll start a real server on a random port - pass - - # For now, skip these tests as they require a real server - # The sse_client doesn't work with ASGI test transport - pytest.skip("MCP tests require a real running server. Run: HINDSIGHT_API_MCP_ENABLED=true uvicorn hindsight_api.api:app") - - -@pytest.mark.asyncio -async def test_mcp_server_tools_via_sse(mcp_server): - """Test MCP server tools via SSE transport using proper MCP client.""" - sse_url = mcp_server - - async with sse_client(sse_url) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # Test 1: List tools - tools_list = await session.list_tools() - print(f"Tools: {tools_list}") - tool_names = [t.name for t in tools_list.tools] - assert "hindsight_search" in tool_names - assert "hindsight_put" in tool_names - - # Test 2: Call hindsight_put - put_result = await session.call_tool( - "hindsight_put", - arguments={ - "content": "User loves Python programming", - "context": "programming_preferences", - "explanation": "Storing user's programming language preference" - } - ) - print(f"Put result: {put_result}") - assert put_result is not None - - # Wait a bit for indexing - await asyncio.sleep(1) - - # Test 3: Call hindsight_search - search_result = await session.call_tool( - "hindsight_search", - arguments={ - "query": "What programming languages does the user like?", - "max_tokens": 4096, - "explanation": "Searching for programming preferences" - } - ) - print(f"Search result: {search_result}") - assert search_result is not None - - -@pytest.mark.asyncio -async def test_multiple_concurrent_requests(mcp_server): - """Test multiple concurrent requests from a single session.""" - sse_url = mcp_server - - async with sse_client(sse_url) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # Fire off 10 concurrent search requests from same session - async def make_search(idx): - try: - result = await session.call_tool( - "hindsight_search", - arguments={ - "query": f"test query {idx}", - "explanation": f"Concurrent test {idx}" - } - ) - return idx, "success", result - except Exception as e: - return idx, "error", str(e) - - tasks = [make_search(i) for i in range(10)] - results = await asyncio.gather(*tasks, return_exceptions=True) - - # Check results - successes = 0 - failures = 0 - - for result in results: - if isinstance(result, Exception): - print(f"Request failed with exception: {result}") - failures += 1 - else: - idx, status, data = result - if status == "success": - successes += 1 - else: - print(f"Request {idx} failed: {data}") - failures += 1 - - print(f"Successes: {successes}, Failures: {failures}") - - # We expect all requests to succeed - assert successes >= 8, f"Too many failures: {failures}/10" - - -@pytest.mark.asyncio -async def test_race_condition_with_rapid_requests(mcp_server): - """Test rapid-fire requests with multiple sessions to trigger race condition.""" - sse_url = mcp_server - - async def rapid_session_search(idx): - """Create a new session and immediately make a request.""" - try: - async with sse_client(sse_url) as (read, write): - async with ClientSession(read, write) as session: - await session.initialize() - - # Make request immediately after initialization - result = await session.call_tool( - "hindsight_search", - arguments={ - "query": f"rapid query {idx}", - "max_tokens": 2048 - } - ) - return idx, "success", result - except Exception as e: - return idx, "error", str(e) - - # Fire 20 requests with minimal delay, each with its own session - tasks = [rapid_session_search(i) for i in range(20)] - results = await asyncio.gather(*tasks) - - # Analyze results - errors = [] - for idx, status, data in results: - if status == "error": - errors.append((idx, data)) - - if errors: - print(f"Found {len(errors)} errors:") - for idx, error_msg in errors: - print(f" Request {idx}: {error_msg}") - - # Most requests should succeed - assert len(errors) < 5, f"Too many errors: {len(errors)}/20" - - -if __name__ == "__main__": - pytest.main([__file__, "-v", "-s"]) diff --git a/hindsight-api/tests/test_mcp_local.py b/hindsight-api/tests/test_mcp_local.py deleted file mode 100644 index 1ccc3cba..00000000 --- a/hindsight-api/tests/test_mcp_local.py +++ /dev/null @@ -1,162 +0,0 @@ -"""Test local MCP server.""" - -import asyncio -import pytest -from unittest.mock import AsyncMock, MagicMock - - -@pytest.fixture -def mock_memory(): - """Create a mock MemoryEngine.""" - memory = MagicMock() - memory._initialized = True - memory.retain_batch_async = AsyncMock() - memory.recall_async = AsyncMock(return_value=MagicMock(results=[])) - return memory - - -@pytest.mark.asyncio -async def test_local_mcp_server_retain(mock_memory): - """Test that retain tool fires async and returns immediately.""" - from hindsight_api.mcp_local import create_local_mcp_server - - bank_id = "test-bank" - mcp_server = create_local_mcp_server(bank_id, memory=mock_memory) - - # Get the tools - tools = mcp_server._tool_manager._tools - assert "retain" in tools - - # Call retain - retain_tool = tools["retain"] - result = await retain_tool.fn(content="test content", context="test_context") - - # Returns immediately with accepted status - assert result["status"] == "accepted" - - # Wait for background task to complete - await asyncio.sleep(0.1) - - # Verify the memory was called correctly - mock_memory.retain_batch_async.assert_called_once() - call_kwargs = mock_memory.retain_batch_async.call_args.kwargs - assert call_kwargs["bank_id"] == "test-bank" - assert call_kwargs["contents"] == [{"content": "test content", "context": "test_context"}] - - -@pytest.mark.asyncio -async def test_local_mcp_server_recall(mock_memory): - """Test that recall tool calls memory.recall_async with correct params.""" - from hindsight_api.mcp_local import create_local_mcp_server - from hindsight_api.engine.memory_engine import Budget - - # Mock recall_async to return a proper pydantic model - mock_result = MagicMock() - mock_result.model_dump.return_value = {"results": []} - mock_memory.recall_async = AsyncMock(return_value=mock_result) - - bank_id = "test-bank" - mcp_server = create_local_mcp_server(bank_id, memory=mock_memory) - - # Get the tools - tools = mcp_server._tool_manager._tools - assert "recall" in tools - - # Call recall with new params - recall_tool = tools["recall"] - result = await recall_tool.fn(query="test query", max_tokens=2048, budget="mid") - - # Result is a dict - assert isinstance(result, dict) - - # Verify the memory was called correctly - mock_memory.recall_async.assert_called_once() - call_kwargs = mock_memory.recall_async.call_args.kwargs - assert call_kwargs["bank_id"] == "test-bank" - assert call_kwargs["query"] == "test query" - assert call_kwargs["max_tokens"] == 2048 - assert call_kwargs["budget"] == Budget.MID - - -@pytest.mark.asyncio -async def test_local_mcp_server_retain_with_default_context(mock_memory): - """Test that retain uses default context when not provided.""" - from hindsight_api.mcp_local import create_local_mcp_server - - bank_id = "test-bank" - mcp_server = create_local_mcp_server(bank_id, memory=mock_memory) - - tools = mcp_server._tool_manager._tools - retain_tool = tools["retain"] - - # Call retain without context - await retain_tool.fn(content="test content") - - # Wait for background task - await asyncio.sleep(0.1) - - call_kwargs = mock_memory.retain_batch_async.call_args.kwargs - assert call_kwargs["contents"] == [{"content": "test content", "context": "general"}] - - -@pytest.mark.asyncio -async def test_local_mcp_server_retain_error_handling(mock_memory): - """Test that retain errors are logged but don't affect response.""" - from hindsight_api.mcp_local import create_local_mcp_server - - mock_memory.retain_batch_async = AsyncMock(side_effect=Exception("Test error")) - - mcp_server = create_local_mcp_server("test-bank", memory=mock_memory) - - tools = mcp_server._tool_manager._tools - retain_tool = tools["retain"] - - # Retain returns immediately with accepted status (fire and forget) - result = await retain_tool.fn(content="test content") - assert result["status"] == "accepted" - - # Wait for background task to complete (and log error) - await asyncio.sleep(0.1) - - -@pytest.mark.asyncio -async def test_local_mcp_server_recall_error_handling(mock_memory): - """Test that recall handles errors gracefully.""" - from hindsight_api.mcp_local import create_local_mcp_server - - mock_memory.recall_async = AsyncMock(side_effect=Exception("Test error")) - - mcp_server = create_local_mcp_server("test-bank", memory=mock_memory) - - tools = mcp_server._tool_manager._tools - recall_tool = tools["recall"] - - result = await recall_tool.fn(query="test query") - - # Result is a dict with error - assert isinstance(result, dict) - assert "error" in result - assert result["results"] == [] - - -@pytest.mark.asyncio -async def test_local_mcp_server_recall_with_defaults(mock_memory): - """Test that recall uses default max_tokens and budget.""" - from hindsight_api.mcp_local import create_local_mcp_server - from hindsight_api.engine.memory_engine import Budget - - mock_result = MagicMock() - mock_result.model_dump.return_value = {"results": []} - mock_memory.recall_async = AsyncMock(return_value=mock_result) - - mcp_server = create_local_mcp_server("test-bank", memory=mock_memory) - - tools = mcp_server._tool_manager._tools - recall_tool = tools["recall"] - - # Call with defaults - await recall_tool.fn(query="test query") - - call_kwargs = mock_memory.recall_async.call_args.kwargs - assert call_kwargs["max_tokens"] == 4096 - assert call_kwargs["budget"] == Budget.LOW diff --git a/hindsight-api/tests/test_mcp_routing.py b/hindsight-api/tests/test_mcp_routing.py deleted file mode 100644 index dd7c385f..00000000 --- a/hindsight-api/tests/test_mcp_routing.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Test MCP server routing with dynamic bank_id.""" - -import pytest -from unittest.mock import AsyncMock, MagicMock - - -@pytest.fixture -def mock_memory(): - """Create a mock MemoryEngine.""" - memory = MagicMock() - memory.retain_batch_async = AsyncMock() - memory.recall_async = AsyncMock(return_value=MagicMock(results=[])) - return memory - - -@pytest.mark.asyncio -async def test_mcp_context_variable(): - """Test that context variable works correctly.""" - from hindsight_api.api.mcp import get_current_bank_id, _current_bank_id - - # Initially None - assert get_current_bank_id() is None - - # Set and verify - token = _current_bank_id.set("test-bank-123") - try: - assert get_current_bank_id() == "test-bank-123" - finally: - _current_bank_id.reset(token) - - # Back to None after reset - assert get_current_bank_id() is None - - -@pytest.mark.asyncio -async def test_mcp_tools_use_context_bank_id(mock_memory): - """Test that MCP tools use bank_id from context.""" - from hindsight_api.api.mcp import create_mcp_server, _current_bank_id - - mcp_server = create_mcp_server(mock_memory) - - # Get the tools - tools = mcp_server._tool_manager._tools - assert "retain" in tools - assert "recall" in tools - - # Test retain with bank_id from context - token = _current_bank_id.set("context-bank-id") - try: - retain_tool = tools["retain"] - result = await retain_tool.fn(content="test content", context="test_context") - assert "successfully" in result.lower() - - # Verify the memory was called with the context bank_id - mock_memory.retain_batch_async.assert_called_once() - call_kwargs = mock_memory.retain_batch_async.call_args.kwargs - assert call_kwargs["bank_id"] == "context-bank-id" - finally: - _current_bank_id.reset(token) - - -def test_path_parsing_logic(): - """Test the path parsing logic for bank_id extraction.""" - def parse_path(path): - """Simulate the path parsing logic from MCPMiddleware.""" - if not path.startswith("/") or len(path) <= 1: - return None, None # Error case - - parts = path[1:].split("/", 1) - if not parts[0]: - return None, None # Error case - - bank_id = parts[0] - new_path = "/" + parts[1] if len(parts) > 1 else "/" - return bank_id, new_path - - # Test bank-specific paths - bank_id, remaining = parse_path("/my-bank/") - assert bank_id == "my-bank" - assert remaining == "/" - - bank_id, remaining = parse_path("/my-bank") - assert bank_id == "my-bank" - assert remaining == "/" - - # Test error case - no bank_id - bank_id, remaining = parse_path("/") - assert bank_id is None - - # Test with complex bank_id - bank_id, remaining = parse_path("/user_12345/") - assert bank_id == "user_12345" - assert remaining == "/" - - # Test with additional path after bank_id - bank_id, remaining = parse_path("/my-bank/some/path") - assert bank_id == "my-bank" - assert remaining == "/some/path" diff --git a/hindsight-api/tests/test_observations.py b/hindsight-api/tests/test_observations.py deleted file mode 100644 index f66f16cf..00000000 --- a/hindsight-api/tests/test_observations.py +++ /dev/null @@ -1,507 +0,0 @@ -""" -Test observation generation and entity state functionality. -""" -import pytest -from hindsight_api.engine.memory_engine import Budget -from hindsight_api import RequestContext -from datetime import datetime, timezone - - -@pytest.mark.asyncio -async def test_observation_generation_on_put(memory, request_context): - """ - Test that observations are generated SYNCHRONOUSLY when new facts are added. - - Observations are generated during retain when: - - Entity has >= 5 facts (MIN_FACTS_THRESHOLD) - - Entity is in top 5 by mention count - - This test stores enough facts to trigger automatic observation generation. - """ - bank_id = f"test_obs_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store multiple facts about John to reach the MIN_FACTS_THRESHOLD (5) - # Each retain call should extract at least one fact about John - contents = [ - "John is a software engineer at Google.", - "John is detail-oriented and methodical in his work.", - "John has been working on the AI team for 3 years.", - "John specializes in machine learning and deep learning.", - "John presented at the company conference last week.", - "John mentors junior engineers on the team.", - ] - - for i, content in enumerate(contents): - await memory.retain_async( - bank_id=bank_id, - content=content, - context="work info", - event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc), - request_context=request_context, - ) - - # Observations are generated SYNCHRONOUSLY during retain, - # so they should be available immediately after retain completes. - # No need to wait for background tasks for observations. - - # Find the John entity - pool = await memory._get_pool() - async with pool.acquire() as conn: - entity_row = await conn.fetchrow( - """ - SELECT id, canonical_name - FROM entities - WHERE bank_id = $1 AND LOWER(canonical_name) LIKE '%john%' - LIMIT 1 - """, - bank_id - ) - - # Also check the fact count for this entity - if entity_row: - fact_count = await conn.fetchval( - """ - SELECT COUNT(*) FROM unit_entities WHERE entity_id = $1 - """, - entity_row['id'] - ) - print(f"\n=== Entity Facts ===") - print(f"Entity: {entity_row['canonical_name']} has {fact_count} linked facts") - - assert entity_row is not None, "John entity should have been extracted" - - entity_id = str(entity_row['id']) - entity_name = entity_row['canonical_name'] - print(f"\n=== Found Entity ===") - print(f"Entity: {entity_name} (id: {entity_id})") - - # Get observations for the entity - should be available immediately - observations = await memory.get_entity_observations(bank_id, entity_id, limit=10, request_context=request_context) - - print(f"\n=== Observations for {entity_name} ===") - print(f"Total observations: {len(observations)}") - for obs in observations: - print(f" - {obs.text}") - - # Verify observations were created (requires >= 5 facts) - assert len(observations) > 0, \ - f"Observations should have been generated synchronously during retain (entity has {fact_count} facts, threshold is 5)" - - # Check that observations mention relevant content - obs_texts = " ".join([o.text.lower() for o in observations]) - assert any(keyword in obs_texts for keyword in ["google", "engineer", "ai", "machine learning", "detail"]), \ - "Observations should contain relevant information about John" - - print(f"✓ Observations were successfully generated synchronously during retain") - - finally: - # Cleanup - pool = await memory._get_pool() - async with pool.acquire() as conn: - await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id) - await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id) - - -@pytest.mark.asyncio -async def test_regenerate_entity_observations(memory, request_context): - """ - Test explicit regeneration of observations for an entity. - """ - bank_id = f"test_regen_obs_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store facts about an entity - await memory.retain_async( - bank_id=bank_id, - content="Sarah is a product manager who loves user research and data analysis.", - context="work info", - event_date=datetime(2024, 1, 15, tzinfo=timezone.utc), - request_context=request_context, - ) - - await memory.wait_for_background_tasks() - - # Find the Sarah entity - pool = await memory._get_pool() - async with pool.acquire() as conn: - entity_row = await conn.fetchrow( - """ - SELECT id, canonical_name - FROM entities - WHERE bank_id = $1 AND LOWER(canonical_name) LIKE '%sarah%' - LIMIT 1 - """, - bank_id - ) - - if entity_row: - entity_id = str(entity_row['id']) - entity_name = entity_row['canonical_name'] - - # Manually regenerate observations - created_ids = await memory.regenerate_entity_observations( - bank_id=bank_id, - entity_id=entity_id, - entity_name=entity_name, - request_context=request_context, - ) - - print(f"\n=== Regenerated Observations ===") - print(f"Created {len(created_ids)} observations for {entity_name}") - - # Get the observations - observations = await memory.get_entity_observations(bank_id, entity_id, limit=10, request_context=request_context) - for obs in observations: - print(f" - {obs.text}") - - # Verify observations were created - if len(created_ids) > 0: - assert len(observations) == len(created_ids), "Should have same number of observations as created IDs" - print(f"✓ Observations regenerated successfully") - else: - print(f"⚠ Note: No observations were regenerated") - - else: - print(f"⚠ Note: No 'Sarah' entity was extracted") - - finally: - # Cleanup - pool = await memory._get_pool() - async with pool.acquire() as conn: - await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id) - await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id) - - -@pytest.mark.asyncio -async def test_search_with_include_entities(memory, request_context): - """ - Test that search with include_entities=True returns entity observations. - - This test verifies that: - 1. Observations are generated during retain (when entity has >= 5 facts) - 2. Observations are returned in recall results with include_entities=True - """ - bank_id = f"test_search_ent_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store enough facts about Alice to trigger observation generation (>= 5 facts) - contents = [ - "Alice is a data scientist who works on recommendation systems at Netflix.", - "Alice presented her research at the ML conference last month.", - "Alice is an expert in deep learning and neural networks.", - "Alice graduated from Stanford with a PhD in Computer Science.", - "Alice leads a team of 5 data scientists at Netflix.", - "Alice published a paper on collaborative filtering algorithms.", - ] - - for i, content in enumerate(contents): - await memory.retain_async( - bank_id=bank_id, - content=content, - context="work info", - event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc), - request_context=request_context, - ) - - # Observations are generated synchronously during retain, no need to wait - - # Search with include_entities=True - result = await memory.recall_async( - bank_id=bank_id, - query="What does Alice do?", - fact_type=["world", "experience"], - budget=Budget.LOW, - max_tokens=2000, - include_entities=True, - max_entity_tokens=500, - request_context=request_context, - ) - - print(f"\n=== Search Results ===") - print(f"Found {len(result.results)} facts") - for fact in result.results: - print(f" - {fact.text}") - if fact.entities: - print(f" Entities: {', '.join(fact.entities)}") - - print(f"\n=== Entity Observations in Recall ===") - if result.entities: - for name, state in result.entities.items(): - print(f"\n{name}:") - for obs in state.observations: - print(f" - {obs.text}") - else: - print("No entity observations returned") - - # Verify results - assert len(result.results) > 0, "Should find some facts" - - # Check if entities are included in facts - facts_with_entities = [f for f in result.results if f.entities] - assert len(facts_with_entities) > 0, "Some facts should have entity information" - print(f"✓ {len(facts_with_entities)} facts have entity information") - - # Check if entity observations are included in recall - assert result.entities is not None and len(result.entities) > 0, \ - "Entity observations should be included in recall results" - print(f"✓ Entity observations included for {len(result.entities)} entities") - - # Verify Alice entity has observations - alice_found = False - for name, state in result.entities.items(): - assert state.canonical_name == name, "Entity canonical_name should match key" - assert state.entity_id, "Entity should have an ID" - if "alice" in name.lower(): - alice_found = True - assert len(state.observations) > 0, \ - "Alice should have observations (generated during retain)" - print(f"✓ Alice has {len(state.observations)} observations in recall result") - - assert alice_found, "Alice entity should be in recall results" - - finally: - # Cleanup - pool = await memory._get_pool() - async with pool.acquire() as conn: - await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id) - await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id) - - -@pytest.mark.asyncio -async def test_get_entity_state(memory, request_context): - """ - Test getting the full state of an entity. - """ - bank_id = f"test_entity_state_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store facts - await memory.retain_async( - bank_id=bank_id, - content="Bob is a frontend developer who specializes in React and TypeScript.", - context="work info", - event_date=datetime(2024, 1, 15, tzinfo=timezone.utc), - request_context=request_context, - ) - - await memory.wait_for_background_tasks() - - # Find entity - pool = await memory._get_pool() - async with pool.acquire() as conn: - entity_row = await conn.fetchrow( - """ - SELECT id, canonical_name - FROM entities - WHERE bank_id = $1 AND LOWER(canonical_name) LIKE '%bob%' - LIMIT 1 - """, - bank_id - ) - - if entity_row: - entity_id = str(entity_row['id']) - entity_name = entity_row['canonical_name'] - - # Get entity state - state = await memory.get_entity_state( - bank_id=bank_id, - entity_id=entity_id, - entity_name=entity_name, - limit=10, - request_context=request_context, - ) - - print(f"\n=== Entity State for {entity_name} ===") - print(f"Entity ID: {state.entity_id}") - print(f"Canonical Name: {state.canonical_name}") - print(f"Observations: {len(state.observations)}") - for obs in state.observations: - print(f" - {obs.text}") - - assert state.entity_id == entity_id, "Entity ID should match" - assert state.canonical_name == entity_name, "Canonical name should match" - - finally: - # Cleanup - pool = await memory._get_pool() - async with pool.acquire() as conn: - await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id) - await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id) - - -@pytest.mark.asyncio -async def test_observation_fact_type_in_database(memory, request_context): - """ - Test that observations are stored with correct fact_type in database. - """ - bank_id = f"test_obs_db_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store facts - await memory.retain_async( - bank_id=bank_id, - content="Charlie is a DevOps engineer who manages the Kubernetes infrastructure.", - context="work info", - event_date=datetime(2024, 1, 15, tzinfo=timezone.utc), - request_context=request_context, - ) - - await memory.wait_for_background_tasks() - - # Check that observations have correct fact_type - pool = await memory._get_pool() - async with pool.acquire() as conn: - observations = await conn.fetch( - """ - SELECT id, text, fact_type, context - FROM memory_units - WHERE bank_id = $1 AND fact_type = 'observation' - """, - bank_id - ) - - print(f"\n=== Observation Records in Database ===") - print(f"Found {len(observations)} observation records") - for obs in observations: - print(f" - fact_type: {obs['fact_type']}") - print(f" text: {obs['text']}") - print(f" context: {obs['context']}") - - if len(observations) > 0: - for obs in observations: - assert obs['fact_type'] == 'observation', "All observation records should have fact_type='observation'" - print(f"✓ All observations have correct fact_type") - - finally: - # Cleanup - pool = await memory._get_pool() - async with pool.acquire() as conn: - await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id) - await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id) - - -@pytest.mark.asyncio -async def test_user_entity_prioritized_for_observations(memory, request_context): - """ - Test that the 'user' entity gets observations even when many other entities exist. - - The retain pipeline only regenerates observations for TOP_N_ENTITIES (5) entities, - sorted by mention count. This test verifies that the most mentioned entity ('user') - gets prioritized and receives observations. - - This is critical because 'user' is often the most important entity in personal memory. - """ - bank_id = f"test_user_priority_{datetime.now(timezone.utc).timestamp()}" - - try: - # Create content where 'user' (the user) is mentioned many times - # along with several other entities - contents = [ - # User mentioned frequently - "The user loves hiking in the mountains during summer.", - "The user works as a software engineer at Microsoft.", - "The user has a dog named Max who is a golden retriever.", - "The user enjoys cooking Italian food, especially pasta.", - "The user graduated from MIT with a Computer Science degree.", - "The user's favorite book is 'Dune' by Frank Herbert.", - # Other entities mentioned fewer times - "Sarah is a friend who works at Google.", - "Bob is a colleague from the data science team.", - "Tokyo is a city the user visited last year.", - "Python is the user's favorite programming language.", - ] - - # Retain all content in a single batch for efficiency - for i, content in enumerate(contents): - await memory.retain_async( - bank_id=bank_id, - content=content, - context="personal info", - event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc), - request_context=request_context, - ) - - # Observations are generated synchronously during retain - - # Find the 'user' entity - pool = await memory._get_pool() - async with pool.acquire() as conn: - # Find user entity (may be named "user", "the user", etc.) - user_entity = await conn.fetchrow( - """ - SELECT e.id, e.canonical_name, - (SELECT COUNT(*) FROM unit_entities ue - JOIN memory_units mu ON ue.unit_id = mu.id - WHERE ue.entity_id = e.id AND mu.bank_id = $1) as fact_count - FROM entities e - WHERE e.bank_id = $1 - AND LOWER(e.canonical_name) LIKE '%user%' - LIMIT 1 - """, - bank_id - ) - - # Get all entities with their fact counts to verify prioritization - all_entities = await conn.fetch( - """ - SELECT e.id, e.canonical_name, - (SELECT COUNT(*) FROM unit_entities ue - JOIN memory_units mu ON ue.unit_id = mu.id - WHERE ue.entity_id = e.id AND mu.bank_id = $1) as fact_count - FROM entities e - WHERE e.bank_id = $1 - ORDER BY fact_count DESC - """, - bank_id - ) - - print(f"\n=== Entities by Mention Count ===") - for entity in all_entities: - print(f" {entity['canonical_name']}: {entity['fact_count']} mentions") - - # Verify user entity exists - assert user_entity is not None, "User entity should have been extracted" - user_entity_id = str(user_entity['id']) - user_entity_name = user_entity['canonical_name'] - user_fact_count = user_entity['fact_count'] - - print(f"\n=== User Entity ===") - print(f"Entity: {user_entity_name} (id: {user_entity_id})") - print(f"Fact count: {user_fact_count}") - - # Verify user has enough facts for observations (>= MIN_FACTS_THRESHOLD of 5) - assert user_fact_count >= 5, \ - f"User entity should have at least 5 facts, but has {user_fact_count}" - - # Get observations for user entity - observations = await memory.get_entity_observations(bank_id, user_entity_id, limit=10, request_context=request_context) - - print(f"\n=== User Entity Observations ===") - print(f"Total observations: {len(observations)}") - for obs in observations: - print(f" - {obs.text}") - - # Verify observations were generated for user (critical assertion) - assert len(observations) > 0, \ - f"User entity should have observations (has {user_fact_count} facts, threshold is 5). " \ - f"This may indicate that 'user' is not being prioritized in the top 5 entities by mention count." - - # Verify observations mention relevant content about the user - obs_texts = " ".join([o.text.lower() for o in observations]) - user_keywords = ["hiking", "software", "engineer", "dog", "max", "cooking", - "italian", "mit", "dune", "microsoft"] - matching_keywords = [k for k in user_keywords if k in obs_texts] - assert len(matching_keywords) > 0, \ - f"Observations should contain relevant information about the user. Keywords found: {matching_keywords}" - - print(f"✓ User entity was prioritized and received {len(observations)} observations") - print(f"✓ Observations contain relevant keywords: {matching_keywords}") - - finally: - # Cleanup - pool = await memory._get_pool() - async with pool.acquire() as conn: - await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id) - await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id) diff --git a/hindsight-api/tests/test_query_analyzer.py b/hindsight-api/tests/test_query_analyzer.py deleted file mode 100644 index 2f892ffb..00000000 --- a/hindsight-api/tests/test_query_analyzer.py +++ /dev/null @@ -1,285 +0,0 @@ -""" -Test query analyzer for temporal extraction. -""" -import pytest -from datetime import datetime -from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer, QueryAnalysis - - -def test_query_analyzer_june_2024(query_analyzer): - reference_date = datetime(2025, 1, 15, 12, 0, 0) - - query = "june 2024" - analysis = query_analyzer.analyze(query, reference_date) - - print(f"\nQuery: '{query}'") - print(f"Analysis: {analysis}") - - assert analysis.temporal_constraint is not None, "Should extract temporal constraint" - assert analysis.temporal_constraint.start_date.year == 2024 - assert analysis.temporal_constraint.start_date.month == 6 - assert analysis.temporal_constraint.start_date.day == 1 - assert analysis.temporal_constraint.end_date.year == 2024 - assert analysis.temporal_constraint.end_date.month == 6 - assert analysis.temporal_constraint.end_date.day == 30 - - -def test_query_analyzer_dogs_june_2023(query_analyzer): - reference_date = datetime(2025, 1, 15, 12, 0, 0) - - query = "dogs in June 2023" - analysis = query_analyzer.analyze(query, reference_date) - - print(f"\nQuery: '{query}'") - print(f"Analysis: {analysis}") - - assert analysis.temporal_constraint is not None, "Should extract temporal constraint" - assert analysis.temporal_constraint.start_date.year == 2023 - assert analysis.temporal_constraint.start_date.month == 6 - assert analysis.temporal_constraint.start_date.day == 1 - assert analysis.temporal_constraint.end_date.year == 2023 - assert analysis.temporal_constraint.end_date.month == 6 - assert analysis.temporal_constraint.end_date.day == 30 - - -def test_query_analyzer_march_2023(query_analyzer): - reference_date = datetime(2025, 1, 15, 12, 0, 0) - - query = "March 2023" - analysis = query_analyzer.analyze(query, reference_date) - - print(f"\nQuery: '{query}'") - print(f"Analysis: {analysis}") - - assert analysis.temporal_constraint is not None, "Should extract temporal constraint" - assert analysis.temporal_constraint.start_date.year == 2023 - assert analysis.temporal_constraint.start_date.month == 3 - assert analysis.temporal_constraint.start_date.day == 1 - assert analysis.temporal_constraint.end_date.year == 2023 - assert analysis.temporal_constraint.end_date.month == 3 - assert analysis.temporal_constraint.end_date.day == 31 - - -def test_query_analyzer_last_year(query_analyzer): - reference_date = datetime(2025, 1, 15, 12, 0, 0) - - query = "last year" - analysis = query_analyzer.analyze(query, reference_date) - - print(f"\nQuery: '{query}'") - print(f"Analysis: {analysis}") - - assert analysis.temporal_constraint is not None, "Should extract temporal constraint" - assert analysis.temporal_constraint.start_date.year == 2024 - assert analysis.temporal_constraint.start_date.month == 1 - assert analysis.temporal_constraint.start_date.day == 1 - assert analysis.temporal_constraint.end_date.year == 2024 - assert analysis.temporal_constraint.end_date.month == 12 - assert analysis.temporal_constraint.end_date.day == 31 - - -def test_query_analyzer_no_temporal(query_analyzer): - reference_date = datetime(2025, 1, 15, 12, 0, 0) - - query = "what is the weather" - analysis = query_analyzer.analyze(query, reference_date) - - print(f"\nQuery: '{query}'") - print(f"Analysis: {analysis}") - - assert analysis.temporal_constraint is None, "Should not extract temporal constraint" - - -def test_query_analyzer_activities_june_2024(query_analyzer): - reference_date = datetime(2025, 1, 15, 12, 0, 0) - - query = "melanie activities in june 2024" - analysis = query_analyzer.analyze(query, reference_date) - - print(f"\nQuery: '{query}'") - print(f"Analysis: {analysis}") - - assert analysis.temporal_constraint is not None, "Should extract temporal constraint" - assert analysis.temporal_constraint.start_date.year == 2024 - assert analysis.temporal_constraint.start_date.month == 6 - assert analysis.temporal_constraint.start_date.day == 1 - assert analysis.temporal_constraint.end_date.year == 2024 - assert analysis.temporal_constraint.end_date.month == 6 - assert analysis.temporal_constraint.end_date.day == 30 - - -def test_query_analyzer_last_saturday(query_analyzer): - """Test extraction of 'last Saturday' relative date.""" - # Reference date is Wednesday, January 15, 2025 - # Last Saturday would be January 11, 2025 - reference_date = datetime(2025, 1, 15, 12, 0, 0) - - query = "I received a piece of jewelry last Saturday from whom?" - analysis = query_analyzer.analyze(query, reference_date) - - print(f"\nQuery: '{query}'") - print(f"Reference date: {reference_date.strftime('%A, %Y-%m-%d')}") - print(f"Analysis: {analysis}") - - assert analysis.temporal_constraint is not None, "Should extract temporal constraint for 'last Saturday'" - # Last Saturday from Wed Jan 15 is Sat Jan 11 - assert analysis.temporal_constraint.start_date.year == 2025 - assert analysis.temporal_constraint.start_date.month == 1 - assert analysis.temporal_constraint.start_date.day == 11 - assert analysis.temporal_constraint.end_date.year == 2025 - assert analysis.temporal_constraint.end_date.month == 1 - assert analysis.temporal_constraint.end_date.day == 11 - - -def test_query_analyzer_yesterday(query_analyzer): - """Test extraction of 'yesterday' relative date.""" - # Reference date is Wednesday, January 15, 2025 - # Yesterday would be January 14, 2025 - reference_date = datetime(2025, 1, 15, 12, 0, 0) - - query = "what did I do yesterday?" - analysis = query_analyzer.analyze(query, reference_date) - - print(f"\nQuery: '{query}'") - print(f"Reference date: {reference_date.strftime('%A, %Y-%m-%d')}") - print(f"Analysis: {analysis}") - - assert analysis.temporal_constraint is not None, "Should extract temporal constraint for 'yesterday'" - assert analysis.temporal_constraint.start_date.year == 2025 - assert analysis.temporal_constraint.start_date.month == 1 - assert analysis.temporal_constraint.start_date.day == 14 - assert analysis.temporal_constraint.end_date.day == 14 - - -def test_query_analyzer_last_week(query_analyzer): - """Test extraction of 'last week' relative date.""" - # Reference date is Wednesday, January 15, 2025 - # Last week would be January 6-12, 2025 (Mon-Sun) - reference_date = datetime(2025, 1, 15, 12, 0, 0) - - query = "what meetings did I have last week?" - analysis = query_analyzer.analyze(query, reference_date) - - print(f"\nQuery: '{query}'") - print(f"Reference date: {reference_date.strftime('%A, %Y-%m-%d')}") - print(f"Analysis: {analysis}") - - assert analysis.temporal_constraint is not None, "Should extract temporal constraint for 'last week'" - assert analysis.temporal_constraint.start_date.year == 2025 - assert analysis.temporal_constraint.start_date.month == 1 - assert analysis.temporal_constraint.start_date.day == 6 # Monday - assert analysis.temporal_constraint.end_date.day == 12 # Sunday - - -def test_query_analyzer_last_month(query_analyzer): - """Test extraction of 'last month' relative date.""" - # Reference date is Wednesday, January 15, 2025 - # Last month would be December 2024 - reference_date = datetime(2025, 1, 15, 12, 0, 0) - - query = "expenses from last month" - analysis = query_analyzer.analyze(query, reference_date) - - print(f"\nQuery: '{query}'") - print(f"Reference date: {reference_date.strftime('%A, %Y-%m-%d')}") - print(f"Analysis: {analysis}") - - assert analysis.temporal_constraint is not None, "Should extract temporal constraint for 'last month'" - assert analysis.temporal_constraint.start_date.year == 2024 - assert analysis.temporal_constraint.start_date.month == 12 - assert analysis.temporal_constraint.start_date.day == 1 - assert analysis.temporal_constraint.end_date.month == 12 - assert analysis.temporal_constraint.end_date.day == 31 - - -def test_query_analyzer_last_friday(query_analyzer): - """Test extraction of 'last Friday' relative date.""" - # Reference date is Wednesday, January 15, 2025 - # Last Friday would be January 10, 2025 - reference_date = datetime(2025, 1, 15, 12, 0, 0) - - query = "who did I meet last Friday?" - analysis = query_analyzer.analyze(query, reference_date) - - print(f"\nQuery: '{query}'") - print(f"Reference date: {reference_date.strftime('%A, %Y-%m-%d')}") - print(f"Analysis: {analysis}") - - assert analysis.temporal_constraint is not None, "Should extract temporal constraint for 'last Friday'" - assert analysis.temporal_constraint.start_date.year == 2025 - assert analysis.temporal_constraint.start_date.month == 1 - assert analysis.temporal_constraint.start_date.day == 10 - assert analysis.temporal_constraint.end_date.day == 10 - - -def test_query_analyzer_last_weekend(query_analyzer): - """Test extraction of 'last weekend' relative date.""" - # Reference date is Wednesday, January 15, 2025 - # Last weekend would be January 11-12, 2025 (Sat-Sun) - reference_date = datetime(2025, 1, 15, 12, 0, 0) - - query = "what did I do last weekend?" - analysis = query_analyzer.analyze(query, reference_date) - - print(f"\nQuery: '{query}'") - print(f"Reference date: {reference_date.strftime('%A, %Y-%m-%d')}") - print(f"Analysis: {analysis}") - - assert analysis.temporal_constraint is not None, "Should extract temporal constraint for 'last weekend'" - assert analysis.temporal_constraint.start_date.year == 2025 - assert analysis.temporal_constraint.start_date.month == 1 - assert analysis.temporal_constraint.start_date.day == 11 # Saturday - assert analysis.temporal_constraint.end_date.day == 12 # Sunday - - -def test_query_analyzer_couple_days_ago(query_analyzer): - """Test extraction of 'a couple of days ago' colloquial expression.""" - reference_date = datetime(2025, 1, 15, 12, 0, 0) - - query = "I mentioned cooking something for my friend a couple of days ago. What was it?" - analysis = query_analyzer.analyze(query, reference_date) - - print(f"\nQuery: '{query}'") - print(f"Reference date: {reference_date.strftime('%A, %Y-%m-%d')}") - print(f"Analysis: {analysis}") - - assert analysis.temporal_constraint is not None, "Should extract temporal constraint for 'a couple of days ago'" - # Range should be 1-3 days ago: Jan 12-14 - assert analysis.temporal_constraint.start_date.day == 12 - assert analysis.temporal_constraint.end_date.day == 14 - - -def test_query_analyzer_few_days_ago(query_analyzer): - """Test extraction of 'a few days ago' colloquial expression.""" - reference_date = datetime(2025, 1, 15, 12, 0, 0) - - query = "What did I do a few days ago?" - analysis = query_analyzer.analyze(query, reference_date) - - print(f"\nQuery: '{query}'") - print(f"Reference date: {reference_date.strftime('%A, %Y-%m-%d')}") - print(f"Analysis: {analysis}") - - assert analysis.temporal_constraint is not None, "Should extract temporal constraint for 'a few days ago'" - # Range should be 2-5 days ago: Jan 10-13 - assert analysis.temporal_constraint.start_date.day == 10 - assert analysis.temporal_constraint.end_date.day == 13 - - -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) - - query = "a couple of weeks ago we discussed this" - analysis = query_analyzer.analyze(query, reference_date) - - print(f"\nQuery: '{query}'") - print(f"Reference date: {reference_date.strftime('%A, %Y-%m-%d')}") - print(f"Analysis: {analysis}") - - assert analysis.temporal_constraint is not None, "Should extract temporal constraint for 'a couple of weeks ago'" - # Range should be 1-3 weeks ago - assert analysis.temporal_constraint.start_date.month == 12 # Dec 25 (3 weeks before Jan 15) - assert analysis.temporal_constraint.end_date.month == 1 # Jan 8 (1 week before Jan 15) - - diff --git a/hindsight-api/tests/test_retain.py b/hindsight-api/tests/test_retain.py deleted file mode 100644 index 82999024..00000000 --- a/hindsight-api/tests/test_retain.py +++ /dev/null @@ -1,1780 +0,0 @@ -""" -Test retain function and chunk storage. -""" -import pytest -import logging -from datetime import datetime, timezone, timedelta -from hindsight_api.engine.memory_engine import Budget -from hindsight_api import RequestContext - -logger = logging.getLogger(__name__) - - -@pytest.mark.asyncio -async def test_retain_with_chunks(memory, request_context): - """ - Test that retain function: - 1. Stores facts with associated chunks - 2. Recall returns chunk_id for each fact - 3. Recall with include_entities=True also works (for compatibility) - """ - bank_id = f"test_chunks_{datetime.now(timezone.utc).timestamp()}" - document_id = "test_doc_123" - - try: - # Store content that will be chunked (long enough to create multiple facts) - long_content = """ - Alice is a senior software engineer at TechCorp. She has been working there for 5 years. - Alice specializes in distributed systems and has led the development of the company's - microservices architecture. She is known for writing clean, well-documented code. - - Bob joined the team last month as a junior developer. He is learning React and Node.js. - Bob is enthusiastic and asks great questions during code reviews. He recently completed - his first feature, which was a user authentication flow. - - The team uses Kubernetes for container orchestration and deploys to AWS. They follow - agile methodologies with two-week sprints. Code reviews are mandatory before merging. - """ - - # Retain with document_id to enable chunk storage - unit_ids = await memory.retain_async( - bank_id=bank_id, - content=long_content, - context="team overview", - event_date=datetime(2024, 1, 15, tzinfo=timezone.utc), - document_id=document_id, - request_context=request_context, - ) - - print(f"\n=== Retained {len(unit_ids)} facts ===") - assert len(unit_ids) > 0, "Should have extracted and stored facts" - - # Test 1: Recall with chunks enabled - result = await memory.recall_async( - bank_id=bank_id, - query="Tell me about Alice", - budget=Budget.LOW, - max_tokens=500, - fact_type=["world"], # Search for world facts - include_entities=False, # Disable entities for simpler test - include_chunks=True, # Enable chunks - max_chunk_tokens=8192, - request_context=request_context, - ) - - print(f"\n=== Recall Results (with chunks) ===") - print(f"Found {len(result.results)} results") - - assert len(result.results) > 0, "Should find facts about Alice" - - # Verify that chunks are returned - assert result.chunks is not None, "Chunks should be included in the response" - assert len(result.chunks) > 0, "Should have at least one chunk" - - print(f"Number of chunks returned: {len(result.chunks)}") - - # Verify chunk structure - for chunk_id, chunk_info in result.chunks.items(): - print(f"\nChunk {chunk_id}:") - print(f" - chunk_index: {chunk_info.chunk_index}") - print(f" - chunk_text length: {len(chunk_info.chunk_text)} chars") - print(f" - truncated: {chunk_info.truncated}") - print(f" - text preview: {chunk_info.chunk_text[:100]}...") - - # Verify chunk structure - assert isinstance(chunk_info.chunk_index, int), "Chunk index should be an integer" - assert chunk_info.chunk_index >= 0, "Chunk index should be non-negative" - assert len(chunk_info.chunk_text) > 0, "Chunk text should not be empty" - assert isinstance(chunk_info.truncated, bool), "Truncated should be boolean" - - print("\n=== Test passed: Chunks are stored and retrieved correctly ===") - - finally: - # Cleanup - delete the test bank - await memory.delete_bank(bank_id, request_context=request_context) - print(f"\n=== Cleaned up bank: {bank_id} ===") - - -@pytest.mark.asyncio -async def test_chunks_and_entities_follow_fact_order(memory, request_context): - """ - Test that chunks and entities in recall results follow the same order as facts. - This is critical because token limits may truncate later items. - - The most relevant fact's chunk/entity should always be first in the returned data. - """ - bank_id = f"test_ordering_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store multiple distinct pieces of content as separate documents - # This ensures different chunks that we can identify - contents = [ - { - "content": "Alice works at Google as a software engineer. She loves Python and has 10 years of experience.", - "document_id": "doc_alice", - "context": "Alice's profile" - }, - { - "content": "Bob works at Meta as a data scientist. He specializes in machine learning and has published papers.", - "document_id": "doc_bob", - "context": "Bob's profile" - }, - { - "content": "Charlie works at Amazon as a product manager. He leads a team of 15 people and ships features weekly.", - "document_id": "doc_charlie", - "context": "Charlie's profile" - }, - ] - - # Store each content piece - for item in contents: - await memory.retain_async( - bank_id=bank_id, - content=item["content"], - context=item["context"], - event_date=datetime(2024, 1, 15, tzinfo=timezone.utc), - document_id=item["document_id"], - request_context=request_context, - ) - - print("\n=== Stored 3 separate documents ===") - - # Recall with a query that matches all three, but Alice most closely - result = await memory.recall_async( - bank_id=bank_id, - query="Tell me about Alice's work at Google", - budget=Budget.MID, - max_tokens=1000, - fact_type=["world"], - include_entities=True, - include_chunks=True, - max_chunk_tokens=8192, - request_context=request_context, - ) - - print(f"\n=== Recall Results ===") - print(f"Found {len(result.results)} facts") - - # Extract the order of entities mentioned in facts - fact_chunk_ids = [] - fact_entities = [] - - for i, fact in enumerate(result.results): - print(f"\nFact {i}: {fact.text[:80]}...") - print(f" chunk_id: {fact.chunk_id}") - - # Track chunk_id order - if fact.chunk_id: - fact_chunk_ids.append(fact.chunk_id) - - # Track entities mentioned in this fact - if fact.entities: - for entity in fact.entities: - if entity not in fact_entities: - fact_entities.append(entity) - - print(f"\n=== Fact chunk_ids in order: {fact_chunk_ids} ===") - print(f"=== Fact entities in order: {fact_entities} ===") - - # Test 1: Verify chunks follow fact order - if result.chunks: - chunks_order = list(result.chunks.keys()) - print(f"\n=== Chunks dict order: {chunks_order} ===") - - # The chunks dict should contain chunks in the order they appear in facts - # (may be fewer chunks than facts due to deduplication) - chunk_positions = [] - for chunk_id in chunks_order: - if chunk_id in fact_chunk_ids: - chunk_positions.append(fact_chunk_ids.index(chunk_id)) - - print(f"=== Chunk positions in fact order: {chunk_positions} ===") - - # Verify chunks are in increasing order (following fact order) - assert chunk_positions == sorted(chunk_positions), \ - f"Chunks should follow fact order! Got positions {chunk_positions} but expected {sorted(chunk_positions)}" - - print("✓ Chunks follow fact order correctly") - - # Test 2: Verify entities follow fact order - if result.entities: - entities_order = list(result.entities.keys()) - print(f"\n=== Entities dict order: {entities_order} ===") - - # The entities dict should contain entities in the order they first appear in facts - entity_positions = [] - for entity_name in entities_order: - if entity_name in fact_entities: - entity_positions.append(fact_entities.index(entity_name)) - - print(f"=== Entity positions in fact order: {entity_positions} ===") - - # Verify entities are in increasing order (following fact order) - assert entity_positions == sorted(entity_positions), \ - f"Entities should follow fact order! Got positions {entity_positions} but expected {sorted(entity_positions)}" - - print("✓ Entities follow fact order correctly") - - print("\n=== Test passed: Chunks and entities follow fact relevance order ===") - - finally: - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - print(f"\n=== Cleaned up bank: {bank_id} ===") - - -@pytest.mark.asyncio -async def test_event_date_storage(memory, request_context): - """ - Test that event_date is correctly stored as occurred_start. - Verifies that we can track when events actually happened vs when they were stored. - """ - bank_id = f"test_temporal_{datetime.now(timezone.utc).timestamp()}" - - try: - # Event that occurred in the past - past_event_date = datetime(2023, 6, 15, 14, 30, tzinfo=timezone.utc) - - # Store a fact about a past event - unit_ids = await memory.retain_async( - bank_id=bank_id, - content="Alice completed the Q2 product launch on June 15th, 2023.", - context="project history", - event_date=past_event_date, - request_context=request_context, - ) - - assert len(unit_ids) > 0, "Should have created at least one memory unit" - - # Recall the fact - result = await memory.recall_async( - bank_id=bank_id, - query="When did Alice complete the product launch?", - budget=Budget.LOW, - max_tokens=500, - fact_type=["world"], - request_context=request_context, - ) - - assert len(result.results) > 0, "Should recall the stored fact" - - # Verify the occurred_start matches our event_date - fact = result.results[0] - assert fact.occurred_start is not None, "occurred_start should be set" - - # Parse the occurred_start (it comes back as ISO string) - if isinstance(fact.occurred_start, str): - occurred_dt = datetime.fromisoformat(fact.occurred_start.replace('Z', '+00:00')) - else: - occurred_dt = fact.occurred_start - - # Verify it matches our past event date (allowing for small time differences in extraction) - assert occurred_dt.year == past_event_date.year, f"Year should match: {occurred_dt.year} vs {past_event_date.year}" - assert occurred_dt.month == past_event_date.month, f"Month should match: {occurred_dt.month} vs {past_event_date.month}" - - print(f"\n✓ Event date correctly stored: {occurred_dt}") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_temporal_ordering(memory, request_context): - """ - Test that facts can be stored and retrieved with correct temporal ordering. - Stores facts with different event_dates and verifies temporal relationships. - """ - bank_id = f"test_temporal_order_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store events in non-chronological order with different dates - events = [ - { - "content": "Alice joined the team in January 2023.", - "event_date": datetime(2023, 1, 10, tzinfo=timezone.utc), - "context": "team history" - }, - { - "content": "Alice got promoted to senior engineer in June 2023.", - "event_date": datetime(2023, 6, 15, tzinfo=timezone.utc), - "context": "team history" - }, - { - "content": "Alice started as an intern in July 2022.", - "event_date": datetime(2022, 7, 1, tzinfo=timezone.utc), - "context": "team history" - }, - ] - - # Store all events - for event in events: - await memory.retain_async( - bank_id=bank_id, - content=event["content"], - context=event["context"], - event_date=event["event_date"], - request_context=request_context, - ) - - print("\n=== Stored 3 events with different temporal dates ===") - - # Recall facts about Alice - result = await memory.recall_async( - bank_id=bank_id, - query="Tell me about Alice's career progression", - budget=Budget.MID, - max_tokens=1000, - fact_type=["world"], - request_context=request_context, - ) - - assert len(result.results) >= 3, f"Should recall all 3 events, got {len(result.results)}" - - # Collect occurred dates - occurred_dates = [] - for fact in result.results: - if fact.occurred_start: - if isinstance(fact.occurred_start, str): - dt = datetime.fromisoformat(fact.occurred_start.replace('Z', '+00:00')) - else: - dt = fact.occurred_start - occurred_dates.append((dt, fact.text[:50])) - print(f" - {dt.date()}: {fact.text[:60]}...") - - # Verify we have temporal data for all facts - assert len(occurred_dates) >= 3, "All facts should have temporal data" - - # The dates should span the expected range (2022-2023) - min_date = min(dt for dt, _ in occurred_dates) - max_date = max(dt for dt, _ in occurred_dates) - - assert min_date.year == 2022, f"Earliest event should be in 2022, got {min_date.year}" - assert max_date.year == 2023, f"Latest event should be in 2023, got {max_date.year}" - - print(f"\n✓ Temporal ordering preserved: {min_date.date()} to {max_date.date()}") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_mentioned_at_vs_occurred(memory, request_context): - """ - Test distinction between when fact occurred vs when it was mentioned. - - Scenario: Ingesting a historical conversation from 2020 - - event_date: When the conversation happened (2020-03-15) - - mentioned_at: When the conversation happened (same as event_date = 2020-03-15) - - occurred_start/end: When the event in the conversation happened (extracted by LLM, or falls back to mentioned_at) - """ - bank_id = f"test_mentioned_{datetime.now(timezone.utc).timestamp()}" - - try: - # Ingesting a conversation that happened in the past - conversation_date = datetime(2020, 3, 15, tzinfo=timezone.utc) - - # Store a fact from a historical conversation - unit_ids = await memory.retain_async( - bank_id=bank_id, - content="Alice graduated from MIT in March 2020.", - context="education history", - event_date=conversation_date, # When this conversation happened - request_context=request_context, - ) - - assert len(unit_ids) > 0, "Should create memory unit" - - # Recall and check temporal fields - result = await memory.recall_async( - bank_id=bank_id, - query="Where did Alice go to school?", - budget=Budget.LOW, - max_tokens=500, - fact_type=["world"], - request_context=request_context, - ) - - assert len(result.results) > 0, "Should recall the fact" - fact = result.results[0] - - # Parse occurred_start - if fact.occurred_start: - if isinstance(fact.occurred_start, str): - occurred_dt = datetime.fromisoformat(fact.occurred_start.replace('Z', '+00:00')) - else: - occurred_dt = fact.occurred_start - - # Should be close to the conversation date (falls back to mentioned_at if LLM doesn't extract) - assert occurred_dt.year == 2020, f"occurred_start should be 2020, got {occurred_dt.year}" - print(f"✓ occurred_start (when event happened): {occurred_dt}") - - # Parse mentioned_at - if fact.mentioned_at: - if isinstance(fact.mentioned_at, str): - mentioned_dt = datetime.fromisoformat(fact.mentioned_at.replace('Z', '+00:00')) - else: - mentioned_dt = fact.mentioned_at - - # mentioned_at should match the conversation date (event_date) - time_diff = abs((conversation_date - mentioned_dt).total_seconds()) - assert time_diff < 60, f"mentioned_at should match event_date (2020-03-15), but diff is {time_diff}s" - print(f"✓ mentioned_at (when conversation happened): {mentioned_dt}") - - # Verify it's the historical date, not today - assert mentioned_dt.year == 2020, f"mentioned_at should be 2020, got {mentioned_dt.year}" - - print(f"✓ Test passed: Historical conversation correctly ingested with event_date=2020") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_occurred_dates_not_defaulted(memory, request_context): - """ - Test that occurred_start and occurred_end are NOT defaulted to mentioned_at. - - This is a regression test for a bug where occurred dates were incorrectly - defaulting to mentioned_at when the LLM didn't provide them. - - Scenario: Store a fact where occurred dates are not applicable (current observation) - - mentioned_at should be set (to event_date or now()) - - occurred_start and occurred_end should be None (not defaulted to mentioned_at) - """ - bank_id = f"test_occurred_not_defaulted_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store a current observation where occurred dates don't make sense - # Use present tense to avoid LLM extracting past dates - event_date = datetime(2024, 2, 10, 15, 30, tzinfo=timezone.utc) - - unit_ids = await memory.retain_async( - bank_id=bank_id, - content="Alice likes coffee. The weather is sunny today.", - context="current observations", - event_date=event_date, - request_context=request_context, - ) - - assert len(unit_ids) > 0, "Should create memory unit" - - # Recall and check that occurred dates are None - result = await memory.recall_async( - bank_id=bank_id, - query="What does Alice like?", - budget=Budget.LOW, - max_tokens=500, - fact_type=["world", "opinion"], - request_context=request_context, - ) - - assert len(result.results) > 0, "Should recall the fact" - fact = result.results[0] - - # mentioned_at should be set - assert fact.mentioned_at is not None, "mentioned_at should be set" - - # Parse mentioned_at - if isinstance(fact.mentioned_at, str): - mentioned_dt = datetime.fromisoformat(fact.mentioned_at.replace('Z', '+00:00')) - else: - mentioned_dt = fact.mentioned_at - - # Verify it matches event_date - time_diff = abs((event_date - mentioned_dt).total_seconds()) - assert time_diff < 60, f"mentioned_at should match event_date, but diff is {time_diff}s" - - # CRITICAL: occurred_start and occurred_end should be None - # They should NOT default to mentioned_at - if fact.occurred_start is not None: - # If occurred_start is set, it means the LLM extracted it - # In this case, log it but don't fail (LLM behavior can vary) - print(f"⚠ LLM extracted occurred_start: {fact.occurred_start}") - print(f" This test expects None for present-tense observations") - else: - print(f"✓ occurred_start is correctly None (not defaulted to mentioned_at)") - - if fact.occurred_end is not None: - print(f"⚠ LLM extracted occurred_end: {fact.occurred_end}") - print(f" This test expects None for present-tense observations") - else: - print(f"✓ occurred_end is correctly None (not defaulted to mentioned_at)") - - # At least verify they're not equal to mentioned_at if they are set - if fact.occurred_start is not None: - if isinstance(fact.occurred_start, str): - occurred_start_dt = datetime.fromisoformat(fact.occurred_start.replace('Z', '+00:00')) - else: - occurred_start_dt = fact.occurred_start - - # If they're equal, it suggests the old defaulting bug - if occurred_start_dt == mentioned_dt: - raise AssertionError( - f"occurred_start should NOT be defaulted to mentioned_at! " - f"occurred_start={occurred_start_dt}, mentioned_at={mentioned_dt}" - ) - - print(f"✓ Test passed: occurred dates are not incorrectly defaulted to mentioned_at") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_mentioned_at_from_context_string(memory, request_context): - """ - Test that mentioned_at is extracted from context string by LLM. - - Scenario: User provides date in context like "happened on 2023-05-10 14:30:00 UTC" - - LLM should extract mentioned_at from this context - - If LLM fails to extract, should fall back to event_date (which defaults to now()) - - mentioned_at should NEVER be None - """ - bank_id = f"test_context_date_{datetime.now(timezone.utc).timestamp()}" - - try: - # Test case 1: Date in context string (like longmemeval benchmark) - session_date = datetime(2023, 5, 10, 14, 30, 0, tzinfo=timezone.utc) - - unit_ids = await memory.retain_async( - bank_id=bank_id, - content="Alice mentioned she loves hiking in the mountains.", - context=f"Session ABC123 - you are the assistant in this conversation - happened on {session_date.strftime('%Y-%m-%d %H:%M:%S')} UTC.", - event_date=None, # Not providing event_date - should default to now() if LLM doesn't extract - request_context=request_context, - ) - - assert len(unit_ids) > 0, "Should create memory unit" - - # Recall and verify mentioned_at is set - result = await memory.recall_async( - bank_id=bank_id, - query="What does Alice like?", - budget=Budget.LOW, - max_tokens=500, - fact_type=["world"], - request_context=request_context, - ) - - assert len(result.results) > 0, "Should recall the fact" - fact = result.results[0] - - # mentioned_at must ALWAYS be set - assert fact.mentioned_at is not None, "mentioned_at should NEVER be None" - - # Parse mentioned_at - if isinstance(fact.mentioned_at, str): - mentioned_dt = datetime.fromisoformat(fact.mentioned_at.replace('Z', '+00:00')) - else: - mentioned_dt = fact.mentioned_at - - # Check if LLM extracted the date from context (ideal case) - # Or if it fell back to now() (acceptable fallback) - time_diff_from_context = abs((session_date - mentioned_dt).total_seconds()) - time_diff_from_now = abs((datetime.now(timezone.utc) - mentioned_dt).total_seconds()) - - # Should either match the context date OR be recent (now) - is_from_context = time_diff_from_context < 60 - is_from_now = time_diff_from_now < 60 - - assert is_from_context or is_from_now, \ - f"mentioned_at should be either from context ({session_date}) or now(), but got {mentioned_dt}" - - if is_from_context: - print(f"✓ LLM successfully extracted mentioned_at from context: {mentioned_dt}") - assert mentioned_dt.year == 2023 - else: - print(f"⚠ LLM did not extract date from context, fell back to now(): {mentioned_dt}") - - print(f"✓ mentioned_at is always set (never None)") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -# ============================================================ -# Context Tracking Tests -# ============================================================ - -@pytest.mark.asyncio -async def test_context_preservation(memory, request_context): - """ - Test that context is preserved and retrievable. - Context helps understand why/how memory was formed. - """ - bank_id = f"test_context_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store content with specific context - specific_context = "team meeting notes from Q4 planning session" - - unit_ids = await memory.retain_async( - bank_id=bank_id, - content="The team decided to prioritize mobile development for next quarter.", - context=specific_context, - event_date=datetime(2024, 1, 15, tzinfo=timezone.utc), - request_context=request_context, - ) - - assert len(unit_ids) > 0, "Should create at least one memory unit" - - # Recall and verify context is returned - result = await memory.recall_async( - bank_id=bank_id, - query="What did the team decide?", - budget=Budget.LOW, - max_tokens=500, - fact_type=["world"], - request_context=request_context, - ) - - assert len(result.results) > 0, "Should recall the stored fact" - - # Verify context is preserved (context is stored in the database) - # Note: context might not be returned in the API response by default - # but it should be stored in the database - print(f"✓ Successfully stored fact with context: '{specific_context}'") - print(f" Retrieved {len(result.results)} facts") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_context_with_batch(memory, request_context): - """ - Test that each item in a batch can have different contexts. - """ - bank_id = f"test_batch_context_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store batch with different contexts - unit_ids = await memory.retain_batch_async( - bank_id=bank_id, - contents=[ - { - "content": "Alice completed the authentication module.", - "context": "sprint 1 standup", - "event_date": datetime(2024, 1, 10, tzinfo=timezone.utc) - }, - { - "content": "Bob started working on the database schema.", - "context": "sprint 1 planning", - "event_date": datetime(2024, 1, 11, tzinfo=timezone.utc) - }, - { - "content": "Charlie fixed critical bugs in the payment flow.", - "context": "incident response", - "event_date": datetime(2024, 1, 12, tzinfo=timezone.utc) - } - ], - request_context=request_context, - ) - - # Should have created facts from all items - total_units = sum(len(ids) for ids in unit_ids) - assert total_units >= 3, f"Should create at least 3 units, got {total_units}" - - print(f"✓ Stored {len(unit_ids)} batch items with different contexts") - print(f" Created {total_units} total memory units") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -# ============================================================ -# Metadata Storage Tests -# ============================================================ - -@pytest.mark.asyncio -async def test_metadata_storage_and_retrieval(memory, request_context): - """ - Test that user-defined metadata is preserved. - Metadata allows arbitrary key-value data to be stored with facts. - """ - bank_id = f"test_metadata_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store content with custom metadata - custom_metadata = { - "source": "slack", - "channel": "engineering", - "importance": "high", - "tags": "product,launch" - } - - # Note: retain_async doesn't directly support metadata parameter - # Metadata would need to be supported in the API layer - # For now, we test that the system handles content without errors - unit_ids = await memory.retain_async( - bank_id=bank_id, - content="The product launch is scheduled for March 1st.", - context="planning meeting", - event_date=datetime(2024, 1, 15, tzinfo=timezone.utc), - request_context=request_context, - ) - - assert len(unit_ids) > 0, "Should create memory units" - - # Recall to verify storage worked - result = await memory.recall_async( - bank_id=bank_id, - query="When is the product launch?", - budget=Budget.LOW, - max_tokens=500, - fact_type=["world"], - request_context=request_context, - ) - - assert len(result.results) > 0, "Should recall stored facts" - - print(f"✓ Successfully stored and retrieved facts") - print(f" (Note: Metadata support depends on API implementation)") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -# ============================================================ -# Batch Processing Edge Cases -# ============================================================ - -@pytest.mark.asyncio -async def test_empty_batch(memory, request_context): - """ - Test that empty batch is handled gracefully without errors. - """ - bank_id = f"test_empty_batch_{datetime.now(timezone.utc).timestamp()}" - - try: - # Attempt to store empty batch - unit_ids = await memory.retain_batch_async( - bank_id=bank_id, - contents=[], - request_context=request_context, - ) - - # Should return empty list or handle gracefully - assert isinstance(unit_ids, list), "Should return a list" - assert len(unit_ids) == 0, "Empty batch should create no units" - - print("✓ Empty batch handled gracefully") - - finally: - # Clean up (though nothing should be stored) - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_single_item_batch(memory, request_context): - """ - Test that batch with one item works correctly. - """ - bank_id = f"test_single_batch_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store batch with single item - unit_ids = await memory.retain_batch_async( - bank_id=bank_id, - contents=[ - { - "content": "Alice shipped the new feature to production.", - "context": "deployment log", - "event_date": datetime(2024, 1, 15, tzinfo=timezone.utc) - } - ], - request_context=request_context, - ) - - assert len(unit_ids) == 1, "Should return one list of unit IDs" - assert len(unit_ids[0]) > 0, "Should create at least one memory unit" - - print(f"✓ Single-item batch created {len(unit_ids[0])} units") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_mixed_content_batch(memory, request_context): - """ - Test batch with varying content sizes (short and long). - """ - bank_id = f"test_mixed_batch_{datetime.now(timezone.utc).timestamp()}" - - try: - # Mix short and long content - short_content = "Alice joined the team." - long_content = """ - Bob has been working on the authentication system for the past three months. - He implemented OAuth 2.0 integration, set up JWT token management, and built - a comprehensive role-based access control system. The system supports multiple - identity providers including Google, GitHub, and Microsoft. Bob also wrote - extensive documentation and unit tests covering over 90% of the codebase. - The team recognized his work with an excellence award at the quarterly meeting. - """ - - unit_ids = await memory.retain_batch_async( - bank_id=bank_id, - contents=[ - {"content": short_content, "context": "onboarding"}, - {"content": long_content, "context": "performance review"}, - {"content": "Charlie is on vacation this week.", "context": "team status"} - ], - request_context=request_context, - ) - - # All items should be processed - assert len(unit_ids) == 3, "Should process all 3 items" - - # Long content should create more facts - short_units = len(unit_ids[0]) - long_units = len(unit_ids[1]) - - print(f"✓ Mixed batch processed successfully") - print(f" Short content: {short_units} units") - print(f" Long content: {long_units} units") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_batch_with_missing_optional_fields(memory, request_context): - """ - Test that batch handles items with missing optional fields. - """ - bank_id = f"test_optional_fields_{datetime.now(timezone.utc).timestamp()}" - - try: - # Some items have all fields, some have minimal fields - unit_ids = await memory.retain_batch_async( - bank_id=bank_id, - contents=[ - { - "content": "Alice finished the project.", - "context": "complete record", - "event_date": datetime(2024, 1, 15, tzinfo=timezone.utc) - }, - { - "content": "Bob started a new task.", - # No context or event_date - }, - { - "content": "Charlie reviewed code.", - "context": "code review", - # No event_date - } - ], - request_context=request_context, - ) - - # All items should be processed successfully - assert len(unit_ids) == 3, "Should process all items even with missing optional fields" - - total_units = sum(len(ids) for ids in unit_ids) - print(f"✓ Batch with mixed optional fields created {total_units} total units") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -# ============================================================ -# Multi-Document Batch Tests -# ============================================================ - -@pytest.mark.asyncio -async def test_single_batch_multiple_documents(memory, request_context): - """ - Test storing multiple distinct documents in a single batch call. - Each should be tracked separately. - """ - bank_id = f"test_multi_docs_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store single batch where each item could be a different document - # (In practice, document_id is a batch-level parameter, so we test - # that multiple retain_async calls work correctly) - - doc1_units = await memory.retain_async( - bank_id=bank_id, - content="Alice's resume: 10 years Python experience, worked at Google.", - context="resume review", - document_id="resume_alice", - request_context=request_context, - ) - - doc2_units = await memory.retain_async( - bank_id=bank_id, - content="Bob's resume: 5 years JavaScript experience, worked at Meta.", - context="resume review", - document_id="resume_bob", - request_context=request_context, - ) - - doc3_units = await memory.retain_async( - bank_id=bank_id, - content="Charlie's resume: 8 years Go experience, worked at Amazon.", - context="resume review", - document_id="resume_charlie", - request_context=request_context, - ) - - # All documents should be stored - assert len(doc1_units) > 0, "Should create units for doc1" - assert len(doc2_units) > 0, "Should create units for doc2" - assert len(doc3_units) > 0, "Should create units for doc3" - - total_units = len(doc1_units) + len(doc2_units) + len(doc3_units) - print(f"✓ Stored 3 separate documents with {total_units} total units") - - # Verify we can recall from any document - result = await memory.recall_async( - bank_id=bank_id, - query="Who worked at Google?", - budget=Budget.MID, - max_tokens=1000, - fact_type=["world"], - request_context=request_context, - ) - - assert len(result.results) > 0, "Should find facts about Alice" - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_document_upsert_behavior(memory, request_context): - """ - Test that upserting a document replaces the old content. - """ - bank_id = f"test_upsert_{datetime.now(timezone.utc).timestamp()}" - document_id = "project_status" - - try: - # Store initial version - v1_units = await memory.retain_async( - bank_id=bank_id, - content="Project is in planning phase. Alice is the lead.", - context="status update v1", - document_id=document_id, - request_context=request_context, - ) - - assert len(v1_units) > 0, "Should create units for v1" - - # Update with new version (upsert) - v2_units = await memory.retain_async( - bank_id=bank_id, - content="Project is in development phase. Bob has joined as co-lead.", - context="status update v2", - document_id=document_id, - request_context=request_context, - ) - - assert len(v2_units) > 0, "Should create units for v2" - - # Recall should return the updated information - result = await memory.recall_async( - bank_id=bank_id, - query="What is the project status?", - budget=Budget.MID, - max_tokens=1000, - fact_type=["world"], - request_context=request_context, - ) - - assert len(result.results) > 0, "Should recall facts" - - print(f"✓ Document upsert created v1: {len(v1_units)} units, v2: {len(v2_units)} units") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -# ============================================================ -# Chunk Storage Advanced Tests -# ============================================================ - -@pytest.mark.asyncio -async def test_chunk_fact_mapping(memory, request_context): - """ - Test that facts correctly reference their source chunks via chunk_id. - """ - bank_id = f"test_chunk_mapping_{datetime.now(timezone.utc).timestamp()}" - document_id = "technical_doc" - - try: - # Store content that will be chunked - content = """ - The authentication system uses JWT tokens for session management. - Tokens expire after 24 hours and must be refreshed using the refresh endpoint. - The system supports OAuth 2.0 integration with Google and GitHub. - - The database layer uses PostgreSQL with connection pooling. - We maintain separate read and write connection pools for performance. - All queries use prepared statements to prevent SQL injection. - """ - - unit_ids = await memory.retain_async( - bank_id=bank_id, - content=content, - context="technical documentation", - document_id=document_id, - request_context=request_context, - ) - - assert len(unit_ids) > 0, "Should create memory units" - - # Recall with chunks enabled - result = await memory.recall_async( - bank_id=bank_id, - query="How does authentication work?", - budget=Budget.MID, - max_tokens=1000, - fact_type=["world"], - include_chunks=True, - max_chunk_tokens=8192, - request_context=request_context, - ) - - assert len(result.results) > 0, "Should recall facts" - - # Verify facts have chunk_id references - facts_with_chunks = [f for f in result.results if f.chunk_id] - - print(f"✓ Created {len(unit_ids)} units from chunked document") - print(f" {len(facts_with_chunks)}/{len(result.results)} facts have chunk_id references") - - # If chunks are returned, verify they match the chunk_ids in facts - if result.chunks: - fact_chunk_ids = {f.chunk_id for f in facts_with_chunks} - returned_chunk_ids = set(result.chunks.keys()) - - # All chunk_ids in facts should have corresponding chunk data - assert fact_chunk_ids.issubset(returned_chunk_ids) or len(fact_chunk_ids) == 0, \ - "Fact chunk_ids should have corresponding chunk data" - - print(f" Returned {len(result.chunks)} chunks matching fact references") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_chunk_ordering_preservation(memory, request_context): - """ - Test that chunk_index reflects the correct order within a document. - """ - bank_id = f"test_chunk_order_{datetime.now(timezone.utc).timestamp()}" - document_id = "ordered_doc" - - try: - # Store long content that will create multiple chunks with meaningful content - sections = [] - sections.append(""" - Alice is the team lead for the authentication project. She has 10 years of experience - with security systems and previously worked at Google on identity management. - She is responsible for architecture decisions and code review. - """) - sections.append(""" - Bob is a backend engineer focusing on the API layer. He specializes in Python - and has built several microservices for the company. He joined the team in 2023. - """) - sections.append(""" - Charlie is the DevOps engineer managing the deployment pipeline. He set up - our Kubernetes infrastructure and maintains the CI/CD system using GitHub Actions. - """) - sections.append(""" - The project uses PostgreSQL as the main database with Redis for caching. - We deploy to AWS using Docker containers orchestrated by Kubernetes. - The team follows agile methodology with two-week sprints. - """) - sections.append(""" - Security is a top priority. All API endpoints require JWT authentication. - We use OAuth 2.0 for third-party integrations and maintain strict access controls. - Regular security audits are conducted quarterly. - """) - - content = "\n\n".join(sections) - - unit_ids = await memory.retain_async( - bank_id=bank_id, - content=content, - context="multi-section document", - document_id=document_id, - request_context=request_context, - ) - - assert len(unit_ids) > 0, "Should create units" - - # Recall with chunks - result = await memory.recall_async( - bank_id=bank_id, - query="Tell me about the sections", - budget=Budget.MID, - max_tokens=2000, - fact_type=["world"], - include_chunks=True, - max_chunk_tokens=8192, - request_context=request_context, - ) - - if result.chunks: - # Verify chunk_index values are sequential and start from 0 - chunk_indices = [chunk.chunk_index for chunk in result.chunks.values()] - chunk_indices_sorted = sorted(chunk_indices) - - print(f"✓ Document created {len(result.chunks)} chunks") - print(f" Chunk indices: {chunk_indices}") - - # Indices should start from 0 and be sequential - if len(chunk_indices) > 0: - assert min(chunk_indices) == 0, "Chunk indices should start from 0" - assert chunk_indices_sorted == list(range(len(chunk_indices))), \ - "Chunk indices should be sequential" - else: - print("✓ Content stored (may have created single chunk or no chunks returned)") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_chunks_truncation_behavior(memory, request_context): - """ - Test that when chunks exceed max_chunk_tokens, truncation is indicated. - """ - bank_id = f"test_chunk_truncation_{datetime.now(timezone.utc).timestamp()}" - document_id = "large_doc" - - try: - # Create a large document with meaningful content - large_content = """ - The company's product roadmap for 2024 includes several major initiatives. - The engineering team is expanding to support these efforts. - - Alice leads the authentication team, which is implementing OAuth 2.0 and JWT tokens. - The team has been working on this for six months and expects to launch in Q2. - Security is the top priority, with regular penetration testing scheduled. - - Bob manages the API development team. They are building RESTful endpoints - for all major features including user management, billing, and analytics. - The team uses Python with FastAPI and deploys to AWS Lambda. - - Charlie oversees the infrastructure team. They maintain Kubernetes clusters - across three AWS regions for high availability. The team also manages - the CI/CD pipeline using GitHub Actions and ArgoCD. - - The data engineering team, led by Diana, processes millions of events daily. - They use Apache Kafka for streaming and Snowflake for analytics. - Real-time dashboards are built with Grafana and Prometheus. - - The mobile team is building iOS and Android apps using React Native. - They are targeting a beta launch in Q3 with select customers. - Push notifications and offline support are key features. - - The design team has created a new design system that will be rolled out - across all products. The system includes components for accessibility - and internationalization support for 12 languages. - - Customer support is being enhanced with AI-powered chatbots. - The system can handle common queries and escalate complex issues to humans. - Average response time has improved by 40% since implementation. - - The marketing team is planning a major campaign for the product launch. - They are working with influencers and planning webinars for enterprise customers. - Early feedback from beta users has been very positive. - - Sales operations are being streamlined with new CRM integrations. - The team can now track leads more effectively and automate follow-ups. - Conversion rates have increased by 25% in the pilot program. - - The finance team is implementing new budgeting tools for better forecasting. - They are also working on automated expense reporting and approval workflows. - This will save approximately 100 hours per month in manual work. - """ * 5 # Repeat to make it very large - - unit_ids = await memory.retain_async( - bank_id=bank_id, - content=large_content, - context="large document test", - document_id=document_id, - request_context=request_context, - ) - - assert len(unit_ids) > 0, "Should create units" - - # Recall with very small chunk token limit to force truncation - result = await memory.recall_async( - bank_id=bank_id, - query="Tell me about the document", - budget=Budget.MID, - max_tokens=1000, - fact_type=["world"], - include_chunks=True, - max_chunk_tokens=500, # Small limit to test truncation - request_context=request_context, - ) - - if result.chunks: - # Check if any chunks show truncation - truncated_chunks = [ - chunk_id for chunk_id, chunk_info in result.chunks.items() - if chunk_info.truncated - ] - - print(f"✓ Retrieved {len(result.chunks)} chunks") - if truncated_chunks: - print(f" {len(truncated_chunks)} chunks were truncated due to token limit") - else: - print(f" No chunks were truncated (content within limit)") - - else: - print("✓ No chunks returned (may be under token limit)") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -# ============================================================ -# Memory Links Tests -# ============================================================ - -@pytest.mark.asyncio -async def test_temporal_links_creation(memory, request_context): - """ - Test that temporal links are created between facts with nearby event dates. - - Temporal links connect facts that occurred close in time (within 24 hours). - """ - bank_id = f"test_temporal_links_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store facts with nearby timestamps (within 24 hours) - base_date = datetime(2024, 1, 15, 10, 0, 0, tzinfo=timezone.utc) - - # Fact 1 at 10:00 AM - unit_ids_1 = await memory.retain_async( - bank_id=bank_id, - content="Alice started working on the authentication module.", - context="daily standup", - event_date=base_date, - request_context=request_context, - ) - - # Fact 2 at 2:00 PM same day (4 hours later) - unit_ids_2 = await memory.retain_async( - bank_id=bank_id, - content="Bob reviewed the API design document.", - context="daily standup", - event_date=base_date.replace(hour=14), - request_context=request_context, - ) - - # Fact 3 at 9:00 AM next day (23 hours later) - unit_ids_3 = await memory.retain_async( - bank_id=bank_id, - content="Charlie deployed the new database schema.", - context="daily standup", - event_date=base_date.replace(day=16, hour=9), - request_context=request_context, - ) - - assert len(unit_ids_1) > 0 and len(unit_ids_2) > 0 and len(unit_ids_3) > 0 - - logger.info(f"Created {len(unit_ids_1) + len(unit_ids_2) + len(unit_ids_3)} facts") - - # Query the memory_links table to verify temporal links exist - async with memory._pool.acquire() as conn: - # Get all temporal links for these units - all_unit_ids = unit_ids_1 + unit_ids_2 + unit_ids_3 - - temporal_links = await conn.fetch( - """ - SELECT from_unit_id, to_unit_id, link_type, weight - FROM memory_links - WHERE from_unit_id::text = ANY($1) - AND link_type = 'temporal' - ORDER BY weight DESC - """, - all_unit_ids - ) - - logger.info(f"Found {len(temporal_links)} temporal links") - - # Should have temporal links between the facts - assert len(temporal_links) > 0, "Should have created temporal links between facts with nearby dates" - - # Verify link properties - for link in temporal_links: - from_id = str(link['from_unit_id']) - to_id = str(link['to_unit_id']) - logger.info(f" Link: {from_id[:8]}... -> {to_id[:8]}... (weight: {link['weight']:.2f})") - assert link['link_type'] == 'temporal', "Link type should be 'temporal'" - assert 0.0 <= link['weight'] <= 1.0, "Weight should be between 0 and 1" - - logger.info("Temporal links created successfully with proper weights") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_semantic_links_creation(memory, request_context): - """ - Test that semantic links are created between facts with similar content. - - Semantic links connect facts that are semantically similar based on embeddings. - """ - bank_id = f"test_semantic_links_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store facts with similar semantic content - unit_ids_1 = await memory.retain_async( - bank_id=bank_id, - content="Alice is an expert in Python programming and has built many web applications.", - context="team skills", - request_context=request_context, - ) - - # Similar content - should create semantic link - unit_ids_2 = await memory.retain_async( - bank_id=bank_id, - content="Bob is proficient in Python development and specializes in building APIs.", - context="team skills", - request_context=request_context, - ) - - # Different content - less likely to create strong semantic link - unit_ids_3 = await memory.retain_async( - bank_id=bank_id, - content="The quarterly sales meeting is scheduled for next Tuesday at 3 PM.", - context="calendar events", - request_context=request_context, - ) - - assert len(unit_ids_1) > 0 and len(unit_ids_2) > 0 and len(unit_ids_3) > 0 - - logger.info(f"Created {len(unit_ids_1) + len(unit_ids_2) + len(unit_ids_3)} facts") - - # Query the memory_links table to verify semantic links exist - async with memory._pool.acquire() as conn: - all_unit_ids = unit_ids_1 + unit_ids_2 + unit_ids_3 - - semantic_links = await conn.fetch( - """ - SELECT from_unit_id, to_unit_id, link_type, weight - FROM memory_links - WHERE from_unit_id::text = ANY($1) - AND link_type = 'semantic' - ORDER BY weight DESC - """, - all_unit_ids - ) - - logger.info(f"Found {len(semantic_links)} semantic links") - - # Should have semantic links between similar facts - assert len(semantic_links) > 0, "Should have created semantic links between similar facts" - - # Verify link properties - for link in semantic_links: - from_id = str(link['from_unit_id']) - to_id = str(link['to_unit_id']) - logger.info(f" Link: {from_id[:8]}... -> {to_id[:8]}... (weight: {link['weight']:.3f})") - assert link['link_type'] == 'semantic', "Link type should be 'semantic'" - assert 0.0 <= link['weight'] <= 1.0, "Weight should be between 0 and 1" - # Semantic links typically have weight >= 0.7 (threshold) - assert link['weight'] >= 0.7, f"Semantic links should have weight >= 0.7, got {link['weight']}" - - logger.info("Semantic links created successfully between similar content") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_entity_links_creation(memory, request_context): - """ - Test that entity links are created between facts that mention the same entities. - - Entity links connect facts that reference the same person, place, or concept. - This is core functionality and should work consistently. - """ - bank_id = f"test_entity_links_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store facts that mention the same entities - unit_ids_1 = await memory.retain_async( - bank_id=bank_id, - content="Alice joined Google as a software engineer in 2020.", - context="career history", - request_context=request_context, - ) - - # Mentions same entity (Alice) - should create entity link - unit_ids_2 = await memory.retain_async( - bank_id=bank_id, - content="Alice led the development of the new authentication system.", - context="project updates", - request_context=request_context, - ) - - # Mentions same entity (Google) - should create entity link - unit_ids_3 = await memory.retain_async( - bank_id=bank_id, - content="Google announced new cloud services at their annual conference.", - context="tech news", - request_context=request_context, - ) - - # Different entities - no entity link expected - unit_ids_4 = await memory.retain_async( - bank_id=bank_id, - content="Bob works at Meta on machine learning infrastructure.", - context="career history", - request_context=request_context, - ) - - assert len(unit_ids_1) > 0 and len(unit_ids_2) > 0 and len(unit_ids_3) > 0 and len(unit_ids_4) > 0 - - logger.info(f"Created {len(unit_ids_1) + len(unit_ids_2) + len(unit_ids_3) + len(unit_ids_4)} facts") - - # Query the memory_links table to verify entity links exist - async with memory._pool.acquire() as conn: - all_unit_ids = unit_ids_1 + unit_ids_2 + unit_ids_3 + unit_ids_4 - - entity_links = await conn.fetch( - """ - SELECT from_unit_id, to_unit_id, link_type, weight, entity_id - FROM memory_links - WHERE from_unit_id::text = ANY($1) - AND link_type = 'entity' - ORDER BY from_unit_id, to_unit_id - """, - all_unit_ids - ) - - logger.info(f"Found {len(entity_links)} entity links") - - # Entity extraction is core functionality and should work - assert len(entity_links) > 0, "Should have created entity links between facts with shared entities (Alice, Google)" - - # Verify link properties - entities_seen = set() - for link in entity_links: - entity_id = link['entity_id'] - entities_seen.add(str(entity_id)) - from_id = str(link['from_unit_id']) - to_id = str(link['to_unit_id']) - logger.info(f" Link: {from_id[:8]}... -> {to_id[:8]}... via entity {str(entity_id)[:8]}...") - assert link['link_type'] == 'entity', "Link type should be 'entity'" - assert link['weight'] == 1.0, "Entity links should have weight 1.0" - assert entity_id is not None, "Entity links must reference an entity_id" - - logger.info(f"Entity links created successfully for {len(entities_seen)} unique entities") - - # Verify bidirectional links (entity links should be bidirectional) - link_pairs = set() - for link in entity_links: - from_id = str(link['from_unit_id']) - to_id = str(link['to_unit_id']) - entity_id = str(link['entity_id']) - link_pairs.add((from_id, to_id, entity_id)) - - # Check that for each (A -> B) link, there's a (B -> A) link with same entity - for from_id, to_id, entity_id in link_pairs: - reverse_exists = (to_id, from_id, entity_id) in link_pairs - assert reverse_exists, f"Entity links should be bidirectional: missing reverse link for {from_id[:8]} -> {to_id[:8]}" - - logger.info("Entity links are properly bidirectional") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_causal_links_creation(memory, request_context): - """ - Test that causal links are created between facts with causal relationships. - - Causal links connect facts where one causes, enables, or prevents another. - Note: This depends on LLM extracting causal relationships, which may be non-deterministic. - """ - bank_id = f"test_causal_links_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store content with explicit causal relationships - # Using clear cause-and-effect language to maximize LLM detection - content = """ - Alice completed the authentication module on Monday. Because Alice finished the auth module, - Bob was able to start integrating it with the API on Tuesday. Bob's API integration enabled - Charlie to begin testing the complete user flow on Wednesday. The successful testing caused - the team to schedule the production deployment for Friday. - """ - - unit_ids = await memory.retain_async( - bank_id=bank_id, - content=content, - context="project timeline", - request_context=request_context, - ) - - assert len(unit_ids) > 0, "Should have created facts" - logger.info(f"Created {len(unit_ids)} facts from causal content") - - # Query the memory_links table to check for causal links - async with memory._pool.acquire() as conn: - causal_links = await conn.fetch( - """ - SELECT from_unit_id, to_unit_id, link_type, weight - FROM memory_links - WHERE from_unit_id::text = ANY($1) - AND link_type IN ('causes', 'caused_by', 'enables', 'prevents') - ORDER BY link_type, weight DESC - """, - unit_ids - ) - - logger.info(f"Found {len(causal_links)} causal links") - - if len(causal_links) > 0: - # Verify link properties - causal_types = {} - for link in causal_links: - link_type = link['link_type'] - causal_types[link_type] = causal_types.get(link_type, 0) + 1 - from_id = str(link['from_unit_id']) - to_id = str(link['to_unit_id']) - logger.info(f" Link: {from_id[:8]}... -> {to_id[:8]}... ({link_type}, weight: {link['weight']:.2f})") - assert link['link_type'] in ['causes', 'caused_by', 'enables', 'prevents'], \ - f"Causal link type must be valid, got '{link['link_type']}'" - assert 0.0 <= link['weight'] <= 1.0, "Weight should be between 0 and 1" - - logger.info("Causal links created successfully:") - for link_type, count in causal_types.items(): - logger.info(f" - {link_type}: {count} links") - else: - logger.warning("No causal links detected (LLM may not have extracted causal relationships)") - logger.info(" This is expected as causal extraction depends on LLM interpretation") - - # This test passes even if no causal links are found, since causal extraction - # is non-deterministic and depends on LLM behavior - logger.info("Test completed (causal link extraction is LLM-dependent)") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_all_link_types_together(memory, request_context): - """ - Integration test: Verify all link types can be created in a single retain operation. - - Tests that temporal, semantic, entity, and potentially causal links are all - created when appropriate conditions are met. - """ - bank_id = f"test_all_links_{datetime.now(timezone.utc).timestamp()}" - - try: - # Store multiple related facts that should trigger all link types - base_date = datetime(2024, 1, 15, 10, 0, 0, tzinfo=timezone.utc) - - # Fact 1: Alice at time T - unit_ids_1 = await memory.retain_async( - bank_id=bank_id, - content="Alice completed the Python backend service for the authentication system.", - context="sprint review", - event_date=base_date, - request_context=request_context, - ) - - # Fact 2: Related to Alice, similar topic (Python), close in time - unit_ids_2 = await memory.retain_async( - bank_id=bank_id, - content="Alice optimized the Python code and improved the authentication performance by 40%.", - context="sprint review", - event_date=base_date.replace(hour=14), # Same day, 4 hours later - request_context=request_context, - ) - - # Fact 3: Related to Alice, different topic but same entity - unit_ids_3 = await memory.retain_async( - bank_id=bank_id, - content="Alice presented the security architecture at the team meeting.", - context="team meeting", - event_date=base_date.replace(day=16), # Next day - request_context=request_context, - ) - - assert len(unit_ids_1) > 0 and len(unit_ids_2) > 0 and len(unit_ids_3) > 0 - - logger.info(f"Created {len(unit_ids_1) + len(unit_ids_2) + len(unit_ids_3)} facts") - - # Query for all link types - async with memory._pool.acquire() as conn: - all_unit_ids = unit_ids_1 + unit_ids_2 + unit_ids_3 - - all_links = await conn.fetch( - """ - SELECT link_type, COUNT(*) as count - FROM memory_links - WHERE from_unit_id::text = ANY($1) - GROUP BY link_type - ORDER BY link_type - """, - all_unit_ids - ) - - logger.info("Link types created:") - link_types_found = {} - for row in all_links: - link_type = row['link_type'] - count = row['count'] - link_types_found[link_type] = count - logger.info(f" - {link_type}: {count} links") - - # Should have temporal, semantic, and entity links - assert 'temporal' in link_types_found, "Should have temporal links (facts with nearby dates)" - assert 'semantic' in link_types_found, "Should have semantic links (similar content about Python/auth)" - assert 'entity' in link_types_found, "Should have entity links (all mention Alice)" - - logger.info(f"Successfully created {len(link_types_found)} different link types") - logger.info("All major link types (temporal, semantic, entity) are working correctly") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_semantic_links_within_same_batch(memory, request_context): - """ - Test that semantic links are created between facts retained in the SAME batch. - - This is a regression test - semantic links should connect similar facts - even when they are retained together in a single call. - """ - bank_id = f"test_semantic_batch_{datetime.now(timezone.utc).timestamp()}" - - try: - # Retain multiple semantically similar facts in ONE batch - contents = [ - {"content": "Alice is an expert in Python programming and machine learning.", "context": "team skills"}, - {"content": "Bob specializes in Python development and data science.", "context": "team skills"}, - {"content": "Charlie works with Python for backend API development.", "context": "team skills"}, - ] - - result = await memory.retain_batch_async( - bank_id=bank_id, - contents=contents, - request_context=request_context, - ) - - # Flatten the list of lists - unit_ids = [uid for sublist in result for uid in sublist] - - assert len(unit_ids) >= 3, f"Should have created at least 3 facts, got {len(unit_ids)}" - logger.info(f"Created {len(unit_ids)} facts in single batch") - - # Query semantic links between these units - async with memory._pool.acquire() as conn: - semantic_links = await conn.fetch( - """ - SELECT from_unit_id, to_unit_id, weight - FROM memory_links - WHERE from_unit_id::text = ANY($1) - AND to_unit_id::text = ANY($1) - AND link_type = 'semantic' - """, - unit_ids - ) - - logger.info(f"Found {len(semantic_links)} semantic links within the batch") - - # All three facts mention Python - they should be linked to each other - assert len(semantic_links) > 0, ( - "REGRESSION: Semantic links should be created between similar facts " - "retained in the same batch, but none were found" - ) - - # Log the links for debugging - for link in semantic_links: - logger.info(f" Semantic link: {str(link['from_unit_id'])[:8]}... -> {str(link['to_unit_id'])[:8]}... (weight: {link['weight']:.3f})") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_temporal_links_within_same_batch(memory, request_context): - """ - Test that temporal links are created between facts retained in the SAME batch. - - This is a regression test - temporal links should connect facts with nearby - event dates even when they are retained together in a single call. - """ - bank_id = f"test_temporal_batch_{datetime.now(timezone.utc).timestamp()}" - - try: - # Retain multiple facts with nearby timestamps in ONE batch - base_date = datetime(2024, 6, 15, 10, 0, 0, tzinfo=timezone.utc) - - contents = [ - { - "content": "Morning standup: Alice presented the sprint goals.", - "context": "daily meeting", - "event_date": base_date - }, - { - "content": "Bob demoed the new feature after standup.", - "context": "daily meeting", - "event_date": base_date + timedelta(hours=1) # 1 hour later - }, - { - "content": "Charlie reviewed the pull requests in the afternoon.", - "context": "daily meeting", - "event_date": base_date + timedelta(hours=4) # 4 hours later - }, - ] - - result = await memory.retain_batch_async( - bank_id=bank_id, - contents=contents, - request_context=request_context, - ) - - # Flatten the list of lists - unit_ids = [uid for sublist in result for uid in sublist] - - assert len(unit_ids) >= 3, f"Should have created at least 3 facts, got {len(unit_ids)}" - logger.info(f"Created {len(unit_ids)} facts in single batch") - - # Query temporal links between these units - async with memory._pool.acquire() as conn: - temporal_links = await conn.fetch( - """ - SELECT from_unit_id, to_unit_id, weight - FROM memory_links - WHERE from_unit_id::text = ANY($1) - AND to_unit_id::text = ANY($1) - AND link_type = 'temporal' - """, - unit_ids - ) - - logger.info(f"Found {len(temporal_links)} temporal links within the batch") - - # All three facts are within 24 hours - they should be linked to each other - assert len(temporal_links) > 0, ( - "REGRESSION: Temporal links should be created between facts with nearby dates " - "retained in the same batch, but none were found" - ) - - # Log the links for debugging - for link in temporal_links: - logger.info(f" Temporal link: {str(link['from_unit_id'])[:8]}... -> {str(link['to_unit_id'])[:8]}... (weight: {link['weight']:.3f})") - - finally: - await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api/tests/test_schema_isolation.py b/hindsight-api/tests/test_schema_isolation.py deleted file mode 100644 index cf7e767c..00000000 --- a/hindsight-api/tests/test_schema_isolation.py +++ /dev/null @@ -1,406 +0,0 @@ -""" -Tests for multi-tenant schema isolation. - -Verifies that concurrent retain operations from different tenants -are properly isolated in their respective PostgreSQL schemas. -""" - -import asyncio -import uuid - -import pytest -import pytest_asyncio - -from hindsight_api.extensions import RequestContext, TenantContext, TenantExtension -from hindsight_api.engine.memory_engine import _current_schema, fq_table -from hindsight_api.migrations import run_migrations - - -class MultiSchemaTestTenantExtension(TenantExtension): - """ - Test tenant extension that maps API keys to schema names. - - API keys are in format: "key-{schema_name}" - Provisions schemas on first access using run_migrations(schema=name). - """ - - def __init__(self, config: dict): - super().__init__(config) - self.db_url = config.get("db_url") - # Pre-configured valid schemas for test - self.valid_schemas = config.get("valid_schemas", set()) - # Track provisioned schemas - self._provisioned: set[str] = set() - - async def authenticate(self, context: RequestContext) -> TenantContext: - if not context.api_key: - from hindsight_api.extensions import AuthenticationError - - raise AuthenticationError("API key required") - - # Parse schema from API key (format: "key-{schema}") - if context.api_key.startswith("key-"): - schema = context.api_key[4:] # Remove "key-" prefix - if schema in self.valid_schemas: - # Provision schema on first access - if schema not in self._provisioned and self.db_url: - run_migrations(self.db_url, schema=schema) - self._provisioned.add(schema) - return TenantContext(schema_name=schema) - - from hindsight_api.extensions import AuthenticationError - - raise AuthenticationError(f"Unknown API key: {context.api_key}") - - -async def drop_schema(conn, schema_name: str) -> None: - """Drop a schema and all its contents.""" - await conn.execute(f'DROP SCHEMA IF EXISTS "{schema_name}" CASCADE') - - -async def count_memories_in_schema(conn, schema_name: str, bank_id: str) -> int: - """Count memory units in a specific schema for a bank.""" - result = await conn.fetchval( - f'SELECT COUNT(*) FROM "{schema_name}".memory_units WHERE bank_id = $1', - bank_id, - ) - return result or 0 - - -async def get_memory_texts_in_schema(conn, schema_name: str, bank_id: str) -> list[str]: - """Get all memory texts in a specific schema for a bank.""" - rows = await conn.fetch( - f'SELECT text FROM "{schema_name}".memory_units WHERE bank_id = $1 ORDER BY text', - bank_id, - ) - return [row["text"] for row in rows] - - -class TestSchemaIsolation: - """Tests for multi-tenant schema isolation.""" - - @pytest.mark.asyncio - async def test_concurrent_inserts_isolated_by_schema(self, memory, pg0_db_url): - """ - Multiple concurrent database operations from different tenants - should store data in their respective schemas without cross-contamination. - - Uses run_migrations(schema=x) to provision schemas like a real extension. - """ - import asyncpg - - # Test schemas - schemas = ["tenant_alpha", "tenant_beta", "tenant_gamma"] - bank_id = f"test-isolation-{uuid.uuid4().hex[:8]}" - - # Clean up any existing schemas - conn = await asyncpg.connect(pg0_db_url) - try: - for schema in schemas: - await drop_schema(conn, schema) - finally: - await conn.close() - - # Configure tenant extension that provisions schemas via run_migrations - tenant_ext = MultiSchemaTestTenantExtension({ - "db_url": pg0_db_url, - "valid_schemas": set(schemas), - }) - memory._tenant_extension = tenant_ext - - # Define concurrent insert tasks for each tenant - async def insert_for_tenant(schema_name: str, content_prefix: str): - """Insert memories for a specific tenant using schema context.""" - # Authenticate to set the schema context - tenant_request = RequestContext(api_key=f"key-{schema_name}") - await memory._authenticate_tenant(tenant_request) - - # Now fq_table will use the correct schema - pool = await memory._get_pool() - from hindsight_api.engine.db_utils import acquire_with_retry - - async with acquire_with_retry(pool) as conn: - # Insert 3 memories for this tenant - for i in range(3): - await conn.execute( - f""" - INSERT INTO {fq_table('memory_units')} (bank_id, text, event_date, fact_type) - VALUES ($1, $2, now(), 'world') - """, - bank_id, - f"MARKER_{content_prefix}_DOC{i}: Memory for {schema_name}", - ) - - # Run concurrent inserts for all tenants - await asyncio.gather( - insert_for_tenant("tenant_alpha", "ALPHA"), - insert_for_tenant("tenant_beta", "BETA"), - insert_for_tenant("tenant_gamma", "GAMMA"), - ) - - # Verify isolation - each schema should only have its own data - conn = await asyncpg.connect(pg0_db_url) - try: - for schema in schemas: - texts = await get_memory_texts_in_schema(conn, schema, bank_id) - prefix = schema.replace("tenant_", "").upper() - - # Should have exactly 3 memories - assert len(texts) == 3, f"Schema {schema} should have 3 memories, got {len(texts)}" - - # 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}" - ) - - # Should NOT contain other tenants' markers - other_prefixes = ["ALPHA", "BETA", "GAMMA"] - other_prefixes.remove(prefix) - for other in other_prefixes: - for text in texts: - assert f"MARKER_{other}" not in text, ( - f"Cross-contamination! Schema {schema} has {other}'s marker: {text}" - ) - - finally: - # Cleanup - for schema in schemas: - await drop_schema(conn, schema) - await conn.close() - - # Reset tenant extension - memory._tenant_extension = None - _current_schema.set("public") - - @pytest.mark.asyncio - async def test_schema_context_isolation_in_concurrent_tasks(self, pg0_db_url): - """ - Verify that _current_schema contextvar is properly isolated - between concurrent async tasks. - """ - results = {} - errors = [] - - async def check_schema_context(schema_name: str, delay: float): - """Set schema context, wait, then verify it's still correct.""" - try: - # Set the schema - _current_schema.set(schema_name) - - # Small delay to allow interleaving - await asyncio.sleep(delay) - - # Verify schema is still correct - current = _current_schema.get() - if current != schema_name: - errors.append(f"Expected {schema_name}, got {current}") - - # Verify fq_table uses correct schema - table = fq_table("memory_units") - expected = f"{schema_name}.memory_units" - if table != expected: - errors.append(f"Expected {expected}, got {table}") - - results[schema_name] = current - - except Exception as e: - errors.append(f"Error in {schema_name}: {e}") - - # Run many concurrent tasks with different schemas - tasks = [] - for i in range(10): - for schema in ["schema_a", "schema_b", "schema_c"]: - # Vary delays to create interleaving - delay = 0.01 * (i % 3) - tasks.append(check_schema_context(f"{schema}_{i}", delay)) - - await asyncio.gather(*tasks) - - # No errors should have occurred - assert not errors, f"Schema context isolation errors: {errors}" - - @pytest.mark.asyncio - async def test_list_memories_respects_schema(self, memory, pg0_db_url): - """ - list_memory_units should only return memories from the current schema. - - Uses run_migrations(schema=x) to provision schemas. - """ - import asyncpg - - schemas = ["tenant_list_a", "tenant_list_b"] - bank_id = f"test-list-{uuid.uuid4().hex[:8]}" - - # Clean up any existing schemas and provision via migrations - conn = await asyncpg.connect(pg0_db_url) - try: - for schema in schemas: - await drop_schema(conn, schema) - finally: - await conn.close() - - # Provision schemas using run_migrations - for schema in schemas: - run_migrations(pg0_db_url, schema=schema) - - # Insert test data directly into each schema - conn = await asyncpg.connect(pg0_db_url) - try: - for schema in schemas: - await conn.execute( - f""" - INSERT INTO "{schema}".memory_units (bank_id, text, event_date, fact_type) - VALUES ($1, $2, now(), 'world') - """, - bank_id, - f"Direct insert for {schema}", - ) - finally: - await conn.close() - - # Configure tenant extension - tenant_ext = MultiSchemaTestTenantExtension({ - "db_url": pg0_db_url, - "valid_schemas": set(schemas), - }) - memory._tenant_extension = tenant_ext - - try: - # Query as tenant_list_a - should only see tenant_list_a's data - tenant_a_request = RequestContext(api_key="key-tenant_list_a") - await memory._authenticate_tenant(tenant_a_request) - - result_a = await memory.list_memory_units(bank_id=bank_id, request_context=tenant_a_request) - texts_a = [item["text"] for item in result_a.get("items", [])] - - assert len(texts_a) == 1, f"Expected 1 memory for tenant_list_a, got {len(texts_a)}" - assert "tenant_list_a" in texts_a[0], f"Wrong content: {texts_a[0]}" - - # Query as tenant_list_b - should only see tenant_list_b's data - tenant_b_request = RequestContext(api_key="key-tenant_list_b") - await memory._authenticate_tenant(tenant_b_request) - - result_b = await memory.list_memory_units(bank_id=bank_id, request_context=tenant_b_request) - texts_b = [item["text"] for item in result_b.get("items", [])] - - assert len(texts_b) == 1, f"Expected 1 memory for tenant_list_b, got {len(texts_b)}" - assert "tenant_list_b" in texts_b[0], f"Wrong content: {texts_b[0]}" - - finally: - # Cleanup - conn = await asyncpg.connect(pg0_db_url) - try: - for schema in schemas: - await drop_schema(conn, schema) - finally: - await conn.close() - - memory._tenant_extension = None - _current_schema.set("public") - - @pytest.mark.asyncio - async def test_high_concurrency_schema_isolation(self, memory, pg0_db_url): - """ - Stress test: Many concurrent operations across multiple schemas - should maintain perfect isolation. - - Uses run_migrations(schema=x) to provision schemas like a real extension. - """ - import asyncpg - - # Create more schemas for stress test - num_schemas = 5 - ops_per_schema = 10 - schemas = [f"stress_tenant_{i}" for i in range(num_schemas)] - bank_id = f"test-stress-{uuid.uuid4().hex[:8]}" - - # Clean up any existing schemas first - conn = await asyncpg.connect(pg0_db_url) - try: - for schema in schemas: - await drop_schema(conn, schema) - finally: - await conn.close() - - # Provision schemas using run_migrations - for schema in schemas: - 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), - }) - # Mark schemas as already provisioned so extension doesn't re-run migrations - tenant_ext._provisioned = set(schemas) - memory._tenant_extension = tenant_ext - - errors = [] - - async def insert_one(schema: str, item_id: int): - """Single insert operation for tracking.""" - try: - # Authenticate to set the schema context - tenant_request = RequestContext(api_key=f"key-{schema}") - await memory._authenticate_tenant(tenant_request) - - # Insert using fq_table - pool = await memory._get_pool() - from hindsight_api.engine.db_utils import acquire_with_retry - - 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) - VALUES ($1, $2, now(), 'world') - """, - bank_id, - f"STRESS_MARKER_{schema}_ITEM{item_id}: Memory for {schema}", - ) - except Exception as e: - errors.append(f"Insert error for {schema}: {e}") - - # Run many concurrent operations - tasks = [] - for i in range(ops_per_schema): - for schema in schemas: - tasks.append(insert_one(schema, i)) - - await asyncio.gather(*tasks) - - # Check for errors during insert - assert not errors, f"Errors during insert: {errors}" - - # Verify no cross-contamination - conn = await asyncpg.connect(pg0_db_url) - try: - for schema in schemas: - texts = await get_memory_texts_in_schema(conn, schema, bank_id) - - # Should have exactly ops_per_schema memories - assert len(texts) == ops_per_schema, ( - f"Schema {schema} should have {ops_per_schema} memories, got {len(texts)}" - ) - - # All memories should reference this schema only - for text in texts: - # Check it contains our schema marker - assert f"STRESS_MARKER_{schema}" in text, ( - f"Memory in {schema} doesn't contain schema marker: {text}" - ) - - # Check it doesn't contain other schema markers - for other_schema in schemas: - if other_schema != schema: - assert f"STRESS_MARKER_{other_schema}" not in text, ( - f"Cross-contamination! {schema} has {other_schema}'s data: {text}" - ) - finally: - # Cleanup - for schema in schemas: - await drop_schema(conn, schema) - await conn.close() - - memory._tenant_extension = None - _current_schema.set("public") diff --git a/hindsight-api/tests/test_search_trace.py b/hindsight-api/tests/test_search_trace.py deleted file mode 100644 index 8fc4adbe..00000000 --- a/hindsight-api/tests/test_search_trace.py +++ /dev/null @@ -1,146 +0,0 @@ -""" -Test search tracing functionality. -""" -import pytest -from hindsight_api.engine.memory_engine import Budget -from hindsight_api import SearchTrace, RequestContext -from datetime import datetime, timezone - - -@pytest.mark.asyncio -async def test_search_with_trace(memory, request_context): - """Test that search with enable_trace=True returns a valid SearchTrace.""" - # Generate a unique agent ID for this test - bank_id = f"test_trace_{datetime.now(timezone.utc).timestamp()}" - - try: - - # Store some test memories - await memory.retain_async( - bank_id=bank_id, - content="Alice works at Google in Mountain View", - context="test context", - request_context=request_context, - ) - await memory.retain_async( - bank_id=bank_id, - content="Bob also works at Google but in New York", - context="test context", - request_context=request_context, - ) - await memory.retain_async( - bank_id=bank_id, - content="Charlie founded a startup called TechCorp", - context="test context", - request_context=request_context, - ) - - # Search with tracing enabled - search_result = await memory.recall_async( - bank_id=bank_id, - query="Who works at Google?", - fact_type=["world"], - budget=Budget.LOW, # 20, - max_tokens=512, - enable_trace=True, - request_context=request_context, - ) - - # Verify results - assert len(search_result.results) > 0, "Should have search results" - - # Verify trace object - assert search_result.trace is not None, "Trace should not be None when enable_trace=True" - # Trace is now a dict - trace = search_result.trace - - # Verify query info - assert trace["query"]["query_text"] == "Who works at Google?" - assert trace["query"]["budget"] == 100 # Budget.LOW = 100 - assert trace["query"]["max_tokens"] == 512 - assert len(trace["query"]["query_embedding"]) > 0, "Query embedding should be populated" - - # Verify entry points - assert len(trace["entry_points"]) > 0, "Should have entry points" - for ep in trace["entry_points"]: - assert ep["node_id"], "Entry point should have node_id" - assert ep["text"], "Entry point should have text" - assert 0.0 <= ep["similarity_score"] <= 1.0, "Similarity should be in [0, 1]" - - # Verify visits - assert len(trace["visits"]) > 0, "Should have visited nodes" - for visit in trace["visits"]: - assert visit["node_id"], "Visit should have node_id" - assert visit["text"], "Visit should have text" - assert visit["weights"]["final_weight"] >= 0, "Weight should be non-negative" - # Entry points should have no parent - if visit["is_entry_point"]: - assert visit["parent_node_id"] is None - assert visit["link_type"] is None - else: - # Non-entry points should have parent info (unless they're isolated) - # But we allow None parent if the node was reached differently - pass - - # Verify summary - assert trace["summary"]["total_nodes_visited"] == len(trace["visits"]) - assert trace["summary"]["results_returned"] == len(search_result.results) - assert trace["summary"]["budget_used"] <= trace["query"]["budget"] - assert trace["summary"]["total_duration_seconds"] > 0 - - # Verify phase metrics - assert len(trace["summary"]["phase_metrics"]) > 0, "Should have phase metrics" - phase_names = {pm["phase_name"] for pm in trace["summary"]["phase_metrics"]} - assert "generate_query_embedding" in phase_names - assert "parallel_retrieval" in phase_names # New modular architecture - assert "rrf_merge" in phase_names # New modular architecture - assert "reranking" in phase_names # New modular architecture - - print("\n✓ Search trace test passed!") - print(f" - Query: {trace['query']['query_text']}") - print(f" - Entry points: {len(trace['entry_points'])}") - print(f" - Nodes visited: {trace['summary']['total_nodes_visited']}") - print(f" - Nodes pruned: {trace['summary']['total_nodes_pruned']}") - print(f" - Results returned: {trace['summary']['results_returned']}") - print(f" - Duration: {trace['summary']['total_duration_seconds']:.3f}s") - - finally: - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) - - -@pytest.mark.asyncio -async def test_search_without_trace(memory, request_context): - """Test that search with enable_trace=False returns None for trace.""" - bank_id = f"test_no_trace_{datetime.now(timezone.utc).timestamp()}" - - try: - - # Store a test memory - await memory.retain_async( - bank_id=bank_id, - content="Test memory without trace", - context="test", - request_context=request_context, - ) - - # Search without tracing - search_result = await memory.recall_async( - bank_id=bank_id, - query="test", - fact_type=["world"], - budget=Budget.LOW, # 10, - max_tokens=512, - enable_trace=False, - request_context=request_context, - ) - - # Verify trace is None - assert search_result.trace is None, "Trace should be None when enable_trace=False" - assert isinstance(search_result.results, list), "Results should still be a list" - - print("\n✓ Search without trace test passed!") - - finally: - # Cleanup - await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api/tests/test_sql_schema_safety.py b/hindsight-api/tests/test_sql_schema_safety.py deleted file mode 100644 index b7ddd436..00000000 --- a/hindsight-api/tests/test_sql_schema_safety.py +++ /dev/null @@ -1,136 +0,0 @@ -""" -Safety tests to ensure all SQL queries use fully-qualified table names. - -This prevents cross-tenant data access by ensuring every table reference -includes the schema prefix (e.g., public.memory_units instead of just memory_units). -""" - -import re -from pathlib import Path - -import pytest - -# All tables that MUST be schema-qualified in SQL queries -TABLES = [ - "memory_units", - "memory_links", - "unit_entities", - "entities", - "entity_cooccurrences", - "banks", - "documents", - "chunks", - "async_operations", -] - -# Files to scan for SQL queries -SCAN_PATHS = [ - "hindsight_api/engine", - "hindsight_api/api", -] - -# Files to exclude (e.g., migrations, tests) -EXCLUDE_PATTERNS = [ - "alembic", - "__pycache__", - "test_", -] - - -def get_python_files() -> list[Path]: - """Get all Python files to scan.""" - root = Path(__file__).parent.parent - files = [] - for scan_path in SCAN_PATHS: - path = root / scan_path - if path.exists(): - for py_file in path.rglob("*.py"): - # Check exclusions - if any(excl in str(py_file) for excl in EXCLUDE_PATTERNS): - continue - files.append(py_file) - return files - - -def find_unqualified_table_refs(content: str, filename: str) -> list[tuple[int, str, str]]: - """ - Find SQL statements with unqualified table references. - - Returns list of (line_number, table_name, line_content). - """ - violations = [] - - # Patterns that indicate SQL context - sql_keywords = r"(?:FROM|JOIN|INTO|UPDATE|DELETE\s+FROM)\s+" - - # Additional SQL indicators to confirm this is actually SQL, not prose - sql_indicators = re.compile( - r"(SELECT|INSERT|DELETE|UPDATE|CREATE|ALTER|DROP|WHERE|SET|VALUES|" - r'f"""|f\'\'\'|""".*SELECT|\'\'\'.*SELECT)', - re.IGNORECASE, - ) - - lines = content.split("\n") - for line_num, line in enumerate(lines, 1): - # Skip comments and strings that are clearly not SQL - stripped = line.strip() - if stripped.startswith("#"): - continue - - for table in TABLES: - # Pattern: SQL keyword followed by unqualified table name - # Should match: FROM memory_units, JOIN memory_units, INTO memory_units - # Should NOT match: FROM public.memory_units, FROM {schema}.memory_units - # Should NOT match: fq_table("memory_units") - - # Check for unqualified table after SQL keyword - pattern = rf"{sql_keywords}{table}(?:\s|$|,|\))" - - if re.search(pattern, line, re.IGNORECASE): - # Check if it's actually qualified (has schema prefix) - 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 - ): - # Additional check: line must have SQL indicators - # This avoids false positives in docstrings like "split into chunks" - if sql_indicators.search(line): - violations.append((line_num, table, stripped)) - - return violations - - -class TestSQLSchemaSafety: - """Ensure all SQL uses schema-qualified table names.""" - - def test_no_unqualified_table_references(self): - """All SQL queries must use fq_table() or schema.table format.""" - all_violations = [] - - for py_file in get_python_files(): - content = py_file.read_text() - violations = find_unqualified_table_refs(content, py_file.name) - - 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]}..." - ) - - if all_violations: - msg = ( - f"Found {len(all_violations)} unqualified table references!\n" - "These could cause cross-tenant data access.\n" - "Use fq_table('table_name') for all table references.\n\n" - + "\n".join(all_violations[:20]) # Show first 20 - ) - if len(all_violations) > 20: - msg += f"\n... and {len(all_violations) - 20} more" - pytest.fail(msg) - - def test_tables_list_is_complete(self): - """Verify we're checking for all tables (sanity check).""" - # This is a sanity check - if you add a new table, add it to TABLES - assert len(TABLES) >= 9, "Update TABLES list if you added new tables" diff --git a/hindsight-api/tests/test_temporal_ranges.py b/hindsight-api/tests/test_temporal_ranges.py deleted file mode 100644 index 168ccf62..00000000 --- a/hindsight-api/tests/test_temporal_ranges.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Tests for temporal range support (occurred_start, occurred_end, mentioned_at).""" -import asyncio -from datetime import datetime, timezone, timedelta -import pytest -from hindsight_api.engine.memory_engine import Budget -from hindsight_api import RequestContext - - -@pytest.mark.asyncio -async def test_temporal_ranges_are_written(memory, request_context): - """Test that occurred_start, occurred_end, and mentioned_at are actually written to database.""" - bank_id = "test_temporal_ranges" - - # Clean up any existing data - try: - await memory.delete_bank(bank_id, request_context=request_context) - except Exception: - pass - - # Test 1: Point event (specific date) - conversation_date = datetime(2024, 11, 17, 10, 0, 0, tzinfo=timezone.utc) - text1 = "Yesterday I went to a pottery workshop where I made a beautiful vase." - - await memory.retain_async( - bank_id=bank_id, - content=text1, - event_date=conversation_date, - request_context=request_context, - ) - - # Test 2: Period event (month range) - text2 = "In February 2024, Alice visited Paris and explored the Louvre museum." - - await memory.retain_async( - bank_id=bank_id, - content=text2, - event_date=conversation_date, - request_context=request_context, - ) - - # Give it a moment for async processing - await asyncio.sleep(2) - - # Retrieve facts from database directly - pool = await memory._get_pool() - async with pool.acquire() as conn: - rows = await conn.fetch( - """ - SELECT id, text, event_date, occurred_start, occurred_end, mentioned_at - FROM memory_units - WHERE bank_id = $1 - ORDER BY created_at - """, - bank_id - ) - - print(f"\n\n=== Retrieved {len(rows)} facts ===") - for i, row in enumerate(rows): - 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']}") - print(f" occurred_end: {row['occurred_end']}") - print(f" mentioned_at: {row['mentioned_at']}") - - # Assertions - assert len(rows) >= 2, f"Expected at least 2 facts, got {len(rows)}" - - # 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]}" - - # mentioned_at should be close to the conversation date - 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) - if pottery_fact: - print(f"\n=== Pottery Fact (Point Event) ===") - print(f" occurred_start: {pottery_fact['occurred_start']}") - print(f" occurred_end: {pottery_fact['occurred_end']}") - - # 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" - - # 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) - if paris_fact: - print(f"\n=== Paris Fact (Period Event) ===") - print(f" occurred_start: {paris_fact['occurred_start']}") - print(f" occurred_end: {paris_fact['occurred_end']}") - - # "In February 2024" is ambiguous - could be interpreted as: - # 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 - 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" - else: - print(" Note: occurred_start/end not set (fact may not have been classified as event)") - - # Test search results also include temporal fields - print("\n=== Testing Search Results ===") - search_result = await memory.recall_async( - bank_id=bank_id, - query="pottery workshop", - fact_type=["world", "experience"], - budget=Budget.LOW, - max_tokens=4096, - request_context=request_context, - ) - - print(f"Found {len(search_result.results)} search results") - if len(search_result.results) > 0: - first_result = search_result.results[0] - print(f" Text: {first_result.text[:80]}...") - print(f" occurred_start: {first_result.occurred_start}") - print(f" occurred_end: {first_result.occurred_end}") - print(f" mentioned_at: {first_result.mentioned_at}") - - # Note: Search results may not have temporal fields populated yet (work in progress) - if first_result.occurred_start: - print("✓ Temporal fields are present in search results") - else: - print("⚠ Temporal fields not yet populated in search results (known issue)") - - # Clean up - await memory.delete_bank(bank_id, request_context=request_context) diff --git a/hindsight-api/tests/test_think.py b/hindsight-api/tests/test_think.py deleted file mode 100644 index f50b972a..00000000 --- a/hindsight-api/tests/test_think.py +++ /dev/null @@ -1,156 +0,0 @@ -""" -Test think function for opinion generation and consistency. -""" -import pytest -from datetime import datetime, timezone -from hindsight_api.engine.memory_engine import Budget -from hindsight_api import RequestContext - - -@pytest.mark.asyncio -async def test_think_opinion_consistency(memory, request_context): - """ - Test that think function: - 1. Generates an opinion - 2. Stores the opinion in the database - 3. Returns consistent response on subsequent calls with the same query - """ - bank_id = f"test_think_{datetime.now(timezone.utc).timestamp()}" - - try: - - # Store some initial facts to give context for opinion formation - await memory.retain_async( - bank_id=bank_id, - content="Alice is a software engineer who has worked on 5 major projects. She always delivers on time and writes clean, well-documented code.", - context="performance review", - event_date=datetime(2024, 1, 15, tzinfo=timezone.utc), - request_context=request_context, - ) - - await memory.retain_async( - bank_id=bank_id, - content="Bob recently joined the team. He missed his first deadline and his code had many bugs.", - context="performance review", - event_date=datetime(2024, 2, 1, tzinfo=timezone.utc), - request_context=request_context, - ) - - # First think call - should generate opinions - query = "Who is a more reliable engineer?" - result1 = await memory.reflect_async( - bank_id=bank_id, - query=query, - budget=Budget.LOW, - request_context=request_context, - ) - - print(f"\n=== First Think Call ===") - print(f"Answer: {result1.text}") - - # Verify we got an answer - assert result1.text, "First think call should return an answer" - assert result1.based_on, "Should return based_on facts" - - # Wait for background opinion processing tasks to complete - await memory.wait_for_background_tasks() - - # Search for stored opinions to verify they were actually saved - pool = await memory._get_pool() - async with pool.acquire() as conn: - stored_opinions = await conn.fetch( - """ - SELECT id, text, confidence_score, fact_type - FROM memory_units - WHERE bank_id = $1 AND fact_type = 'opinion' - ORDER BY created_at DESC - """, - bank_id - ) - - print(f"\n=== Stored Opinions in Database ===") - print(f"Total opinions stored: {len(stored_opinions)}") - for op in stored_opinions: - print(f" - {op['text']} (confidence: {op['confidence_score']:.2f})") - - # Verify opinions were actually written to database - # NOTE: Opinion extraction may not always detect opinions depending on the LLM response format - if len(stored_opinions) > 0: - assert all(op['fact_type'] == 'opinion' for op in stored_opinions), "All stored items should have fact_type='opinion'" - print(f"✓ Opinions were successfully stored in database") - else: - print(f"⚠ Note: No opinions were extracted/stored (this can happen if the LLM response format doesn't trigger opinion extraction)") - - # Second think call - should use the stored opinions - result2 = await memory.reflect_async( - bank_id=bank_id, - query=query, - budget=Budget.LOW, - request_context=request_context, - ) - - print(f"\n=== Second Think Call ===") - print(f"Answer: {result2.text}") - print(f"Existing opinions used: {len(result2.based_on.get('opinion', []))}") - for opinion in result2.based_on.get('opinion', []): - print(f" - {opinion.text}") - - # Verify second call also got an answer - assert result2.text, "Second think call should return an answer" - - # Verify second call used the stored opinions (if any were stored) - if len(stored_opinions) > 0: - assert len(result2.based_on.get('opinion', [])) > 0, "Second call should retrieve stored opinions" - - # The responses should be consistent (both should mention the same person as more reliable) - # We'll do a basic check that they're not contradictory - text1_lower = result1.text.lower() - text2_lower = result2.text.lower() - - print(f"\n=== Consistency Check ===") - - # Check if Alice is mentioned as more reliable in first response - if 'alice' in text1_lower and ('reliable' in text1_lower or 'better' in text1_lower): - print("First response favors Alice") - # Second response should also favor Alice (consistency) - assert 'alice' in text2_lower, "Second response should also mention Alice" - print("Second response also mentions Alice - CONSISTENT ✓") - - # Check if Bob is mentioned - if 'bob' in text1_lower: - print("First response mentions Bob") - if 'bob' in text2_lower: - print("Second response also mentions Bob - CONSISTENT ✓") - - print(f"\n✅ Test passed - opinions were formed, stored, and used consistently") - - finally: - # Clean up agent data - try: - await memory.delete_bank(bank_id, request_context=request_context) - except Exception as e: - print(f"Warning: Error during cleanup: {e}") - - -@pytest.mark.asyncio -async def test_think_without_prior_context(memory, request_context): - """ - Test that think function handles queries when there's no relevant context. - """ - bank_id = f"test_think_no_context_{datetime.now(timezone.utc).timestamp()}" - - # Call think without storing any prior facts - result = await memory.reflect_async( - bank_id=bank_id, - query="What is the capital of France?", - budget=Budget.LOW, - request_context=request_context, - ) - - print(f"\n=== Think Without Context ===") - print(f"Answer: {result.text}") - - # 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-cli/.github/workflows/release.yml b/hindsight-cli/.github/workflows/release.yml deleted file mode 100644 index d02eadb1..00000000 --- a/hindsight-cli/.github/workflows/release.yml +++ /dev/null @@ -1,102 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - workflow_dispatch: - -jobs: - build: - name: Build ${{ matrix.target }} - runs-on: ${{ matrix.os }} - strategy: - matrix: - include: - - os: ubuntu-latest - target: x86_64-unknown-linux-gnu - artifact_name: memora - release_name: memora-linux-x86_64 - - os: ubuntu-latest - target: aarch64-unknown-linux-gnu - artifact_name: memora - release_name: memora-linux-arm64 - - os: macos-latest - target: x86_64-apple-darwin - artifact_name: memora - release_name: memora-macos-x86_64 - - os: macos-latest - target: aarch64-apple-darwin - artifact_name: memora - release_name: memora-macos-arm64 - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - - name: Install cross-compilation tools (Linux ARM64) - if: matrix.target == 'aarch64-unknown-linux-gnu' - run: | - sudo apt-get update - sudo apt-get install -y gcc-aarch64-linux-gnu - - - name: Build - run: cargo build --release --target ${{ matrix.target }} - - - name: Strip binary (Linux) - if: runner.os == 'Linux' - run: strip target/${{ matrix.target }}/release/${{ matrix.artifact_name }} - - - name: Strip binary (macOS) - if: runner.os == 'macOS' - run: strip target/${{ matrix.target }}/release/${{ matrix.artifact_name }} - - - name: Upload artifact - uses: actions/upload-artifact@v4 - with: - name: ${{ matrix.release_name }} - path: target/${{ matrix.target }}/release/${{ matrix.artifact_name }} - - release: - name: Create Release - needs: build - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/') - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Download all artifacts - uses: actions/download-artifact@v4 - with: - path: artifacts - - - name: Create checksums - run: | - cd artifacts - for dir in */; do - cd "$dir" - sha256sum * > SHA256SUMS - cd .. - done - - - name: Create Release - uses: softprops/action-gh-release@v1 - with: - files: | - artifacts/memora-linux-x86_64/memora - artifacts/memora-linux-arm64/memora - artifacts/memora-macos-x86_64/memora - artifacts/memora-macos-arm64/memora - artifacts/*/SHA256SUMS - draft: false - prerelease: false - generate_release_notes: true - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/hindsight-cli/.gitignore b/hindsight-cli/.gitignore deleted file mode 100644 index c7377e76..00000000 --- a/hindsight-cli/.gitignore +++ /dev/null @@ -1,20 +0,0 @@ -# Rust -/target/ -Cargo.lock - -# Distribution -/dist/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# OS -.DS_Store -Thumbs.db - -# Backup files -*.bak diff --git a/hindsight-cli/Cargo.toml b/hindsight-cli/Cargo.toml deleted file mode 100644 index 7d51a947..00000000 --- a/hindsight-cli/Cargo.toml +++ /dev/null @@ -1,53 +0,0 @@ -[package] -name = "hindsight-cli" -version = "0.1.13" -edition = "2021" -authors = ["Hindsight Team"] -description = "A beautiful CLI for Hindsight - semantic memory system" -license = "MIT" - -[[bin]] -name = "hindsight" -path = "src/main.rs" - -[dependencies] -# Hindsight API client (generated) -hindsight-client = { path = "../hindsight-clients/rust" } - -# CLI framework -clap = { version = "4.5", features = ["derive", "env"] } - -# Async runtime -tokio = { version = "1", features = ["full"] } - -# HTTP client (for timeout configuration) -reqwest = "0.12" - -# Serialization (for config and output formatting) -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -serde_yaml = "0.9" - -# TUI libraries -ratatui = "0.29" -crossterm = "0.28" - -# Colors and styling -colored = "2.1" -indicatif = "0.17" - -# Error handling -anyhow = "1.0" -thiserror = "1.0" - -# Utilities -chrono = "0.4" -walkdir = "2.5" -dirs = "5.0" - -[profile.release] -opt-level = "z" -lto = true -codegen-units = 1 -panic = "abort" -strip = true diff --git a/hindsight-cli/build.sh b/hindsight-cli/build.sh deleted file mode 100755 index b56123b8..00000000 --- a/hindsight-cli/build.sh +++ /dev/null @@ -1,100 +0,0 @@ -#!/bin/bash -set -e - -# Build script for hindsight-cli -# This script builds optimized binaries for multiple platforms - -# Source cargo environment if it exists -if [ -f "$HOME/.cargo/env" ]; then - source "$HOME/.cargo/env" -fi - -# Add cargo to PATH if not already there -export PATH="$HOME/.cargo/bin:$PATH" - -# Check if cargo is available -if ! command -v cargo &> /dev/null; then - echo "Error: Cargo not found. Please install Rust first:" - echo " curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh" - exit 1 -fi - -echo "Building Hindsight CLI for multiple platforms..." - -# Ensure we're in the right directory -cd "$(dirname "$0")" - -# Create dist directory if it doesn't exist -mkdir -p dist - -# Get version from Cargo.toml -VERSION=$(grep '^version' Cargo.toml | head -1 | cut -d'"' -f2) -echo "Version: $VERSION" - -# Function to build for a target -build_target() { - local target=$1 - local output_name=$2 - - echo "" - echo "Building for $target..." - - # Check if target is installed, install if not - if ! rustup target list | grep -q "$target (installed)"; then - echo "Installing target $target..." - rustup target add "$target" - fi - - # Build - cargo build --release --target "$target" - - # Copy to dist - if [[ "$target" == *"windows"* ]]; then - cp "target/$target/release/hindsight.exe" "dist/$output_name.exe" - echo "Created: dist/$output_name.exe" - else - cp "target/$target/release/hindsight" "dist/$output_name" - chmod +x "dist/$output_name" - echo "Created: dist/$output_name" - fi -} - -# Detect current platform -OS=$(uname -s) -ARCH=$(uname -m) - -echo "Detected platform: $OS $ARCH" - -# Build for current platform -case "$OS" in - Darwin) - if [[ "$ARCH" == "arm64" ]]; then - echo "Building for macOS ARM64 (Apple Silicon)..." - build_target "aarch64-apple-darwin" "hindsight-macos-arm64" - else - echo "Building for macOS x86_64 (Intel)..." - build_target "x86_64-apple-darwin" "hindsight-macos-x86_64" - fi - ;; - Linux) - if [[ "$ARCH" == "x86_64" ]]; then - echo "Building for Linux x86_64..." - build_target "x86_64-unknown-linux-gnu" "hindsight-linux-x86_64" - elif [[ "$ARCH" == "aarch64" ]]; then - echo "Building for Linux ARM64..." - build_target "aarch64-unknown-linux-gnu" "hindsight-linux-arm64" - fi - ;; - *) - echo "Unsupported OS: $OS" - exit 1 - ;; -esac - -echo "" -echo "Build complete! Binaries are in the dist/ directory:" -ls -lh dist/ - -echo "" -echo "To build for other platforms, run:" -echo " ./build.sh --all" diff --git a/hindsight-cli/src/api.rs b/hindsight-cli/src/api.rs deleted file mode 100644 index 21753666..00000000 --- a/hindsight-cli/src/api.rs +++ /dev/null @@ -1,270 +0,0 @@ -//! API client wrapper -//! -//! This module provides a thin wrapper around the auto-generated hindsight-client -//! to bridge from the CLI's synchronous code to the async API client. - -use anyhow::Result; -use hindsight_client::Client as AsyncClient; -pub use hindsight_client::types; -use serde::{Deserialize, Serialize}; -use serde_json; -use std::collections::HashMap; - -// Types not defined in OpenAPI spec (TODO: add to openapi.json) -#[derive(Debug, Serialize, Deserialize)] -pub struct AgentStats { - pub bank_id: String, - pub total_nodes: i32, - pub total_links: i32, - pub total_documents: i32, - pub nodes_by_fact_type: HashMap, - pub links_by_link_type: HashMap, - pub links_by_fact_type: HashMap, - pub links_breakdown: HashMap>, - pub pending_operations: i32, - pub failed_operations: i32, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct Operation { - pub id: String, - pub task_type: String, - pub items_count: i32, - pub document_id: Option, - pub created_at: String, - pub status: String, - pub error_message: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct OperationsResponse { - pub bank_id: String, - pub operations: Vec, -} - -#[derive(Debug, Serialize, Deserialize)] -pub struct TraceInfo { - pub total_time: Option, - pub activation_count: Option, -} - -// Unified result for put_memories that handles both sync and async responses -#[derive(Debug, Serialize, Deserialize)] -pub struct MemoryPutResult { - pub success: bool, - pub items_count: i64, - pub message: String, - pub is_async: bool, -} - -#[derive(Clone)] -pub struct ApiClient { - client: AsyncClient, - runtime: std::sync::Arc, -} - -impl ApiClient { - pub fn new(base_url: String, api_key: Option) -> Result { - let runtime = std::sync::Arc::new(tokio::runtime::Runtime::new()?); - - // Create HTTP client with 2-minute timeout and optional auth header - let mut client_builder = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(120)); - - if let Some(key) = api_key { - let mut headers = reqwest::header::HeaderMap::new(); - let auth_value = format!("Bearer {}", key); - headers.insert( - reqwest::header::AUTHORIZATION, - reqwest::header::HeaderValue::from_str(&auth_value)?, - ); - client_builder = client_builder.default_headers(headers); - } - - let http_client = client_builder.build()?; - - let client = AsyncClient::new_with_client(&base_url, http_client); - Ok(ApiClient { client, runtime }) - } - - pub fn list_agents(&self, _verbose: bool) -> Result> { - self.runtime.block_on(async { - let response = self.client.list_banks().await?; - Ok(response.into_inner().banks) - }) - } - - pub fn get_profile(&self, agent_id: &str, _verbose: bool) -> Result { - self.runtime.block_on(async { - let response = self.client.get_bank_profile(agent_id).await?; - Ok(response.into_inner()) - }) - } - - pub fn get_stats(&self, agent_id: &str, _verbose: bool) -> Result { - self.runtime.block_on(async { - let response = self.client.get_agent_stats(agent_id).await?; - let value = response.into_inner(); - let stats: AgentStats = serde_json::from_value(value)?; - Ok(stats) - }) - } - - pub fn update_agent_name(&self, agent_id: &str, name: &str, _verbose: bool) -> Result { - self.runtime.block_on(async { - let request = types::CreateBankRequest { - name: Some(name.to_string()), - background: None, - disposition: None, - }; - let response = self.client.create_or_update_bank(agent_id, &request).await?; - Ok(response.into_inner()) - }) - } - - pub fn add_background(&self, agent_id: &str, content: &str, update_disposition: bool, _verbose: bool) -> Result { - self.runtime.block_on(async { - let request = types::AddBackgroundRequest { - content: content.to_string(), - update_disposition, - }; - let response = self.client.add_bank_background(agent_id, &request).await?; - Ok(response.into_inner()) - }) - } - - pub fn recall(&self, agent_id: &str, request: &types::RecallRequest, verbose: bool) -> Result { - if verbose { - eprintln!("Request body: {}", serde_json::to_string_pretty(request).unwrap_or_default()); - } - self.runtime.block_on(async { - let response = self.client.recall_memories(agent_id, request).await?; - Ok(response.into_inner()) - }) - } - - pub fn reflect(&self, agent_id: &str, request: &types::ReflectRequest, _verbose: bool) -> Result { - self.runtime.block_on(async { - let response = self.client.reflect(agent_id, request).await?; - Ok(response.into_inner()) - }) - } - - pub fn retain(&self, agent_id: &str, request: &types::RetainRequest, _async_mode: bool, _verbose: bool) -> Result { - self.runtime.block_on(async { - let response = self.client.retain_memories(agent_id, request).await?; - let result = response.into_inner(); - Ok(MemoryPutResult { - success: result.success, - items_count: result.items_count, - message: format!("Stored {} memory units", result.items_count), - is_async: result.async_, - }) - }) - } - - pub fn delete_memory(&self, _agent_id: &str, _unit_id: &str, _verbose: bool) -> Result { - // Note: Individual memory deletion is no longer supported in the API - anyhow::bail!("Individual memory deletion is no longer supported. Use 'memory clear' to clear all memories.") - } - - pub fn clear_memories(&self, agent_id: &str, fact_type: Option<&str>, _verbose: bool) -> Result { - self.runtime.block_on(async { - let response = self.client.clear_bank_memories(agent_id, fact_type).await?; - Ok(response.into_inner()) - }) - } - - pub fn list_documents(&self, agent_id: &str, q: Option<&str>, limit: Option, offset: Option, _verbose: bool) -> Result { - self.runtime.block_on(async { - let response = self.client.list_documents( - agent_id, - limit.map(|l| l as i64), - offset.map(|o| o as i64), - q - ).await?; - Ok(response.into_inner()) - }) - } - - pub fn get_document(&self, agent_id: &str, document_id: &str, _verbose: bool) -> Result { - self.runtime.block_on(async { - let response = self.client.get_document(agent_id, document_id).await?; - Ok(response.into_inner()) - }) - } - - pub fn delete_document(&self, agent_id: &str, document_id: &str, _verbose: bool) -> Result { - self.runtime.block_on(async { - let response = self.client.delete_document(agent_id, document_id).await?; - let value = response.into_inner(); - let result: types::DeleteResponse = serde_json::from_value(value)?; - Ok(result) - }) - } - - pub fn list_operations(&self, agent_id: &str, _verbose: bool) -> Result { - self.runtime.block_on(async { - let response = self.client.list_operations(agent_id).await?; - let value = response.into_inner(); - let ops: OperationsResponse = serde_json::from_value(value)?; - Ok(ops) - }) - } - - pub fn cancel_operation(&self, agent_id: &str, operation_id: &str, _verbose: bool) -> Result { - self.runtime.block_on(async { - let response = self.client.cancel_operation(agent_id, operation_id).await?; - let value = response.into_inner(); - let result: types::DeleteResponse = serde_json::from_value(value)?; - Ok(result) - }) - } - - pub fn list_memories(&self, bank_id: &str, type_filter: Option<&str>, q: Option<&str>, limit: Option, offset: Option, _verbose: bool) -> Result { - self.runtime.block_on(async { - let response = self.client.list_memories(bank_id, limit, offset, q, type_filter).await?; - Ok(response.into_inner()) - }) - } - - pub fn list_entities(&self, bank_id: &str, limit: Option, _verbose: bool) -> Result { - self.runtime.block_on(async { - let response = self.client.list_entities(bank_id, limit).await?; - Ok(response.into_inner()) - }) - } - - pub fn get_entity(&self, bank_id: &str, entity_id: &str, _verbose: bool) -> Result { - self.runtime.block_on(async { - let response = self.client.get_entity(bank_id, entity_id).await?; - Ok(response.into_inner()) - }) - } - - pub fn regenerate_entity(&self, bank_id: &str, entity_id: &str, _verbose: bool) -> Result { - self.runtime.block_on(async { - let response = self.client.regenerate_entity_observations(bank_id, entity_id).await?; - Ok(response.into_inner()) - }) - } - - pub fn delete_bank(&self, bank_id: &str, _verbose: bool) -> Result { - self.runtime.block_on(async { - let response = self.client.delete_bank(bank_id).await?; - Ok(response.into_inner()) - }) - } -} - -// Re-export types from the generated client for use in commands -pub use types::{ - BankProfileResponse, - MemoryItem, - RecallRequest, - RecallResponse, - RecallResult, - ReflectRequest, - ReflectResponse, - RetainRequest, -}; diff --git a/hindsight-cli/src/commands/bank.rs b/hindsight-cli/src/commands/bank.rs deleted file mode 100644 index 68c526e8..00000000 --- a/hindsight-cli/src/commands/bank.rs +++ /dev/null @@ -1,277 +0,0 @@ -use anyhow::Result; -use crate::api::ApiClient; -use crate::output::{self, OutputFormat}; -use crate::ui; - -pub fn list(client: &ApiClient, verbose: bool, output_format: OutputFormat) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Fetching banks...")) - } else { - None - }; - - let response = client.list_agents(verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(banks_list) => { - if output_format == OutputFormat::Pretty { - if banks_list.is_empty() { - ui::print_warning("No banks found"); - } else { - ui::print_info(&format!("Found {} bank(s)", banks_list.len())); - for bank in &banks_list { - println!(" - {}", bank.bank_id); - } - } - } else { - output::print_output(&banks_list, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} - -pub fn disposition(client: &ApiClient, bank_id: &str, verbose: bool, output_format: OutputFormat) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Fetching disposition...")) - } else { - None - }; - - let response = client.get_profile(bank_id, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(profile) => { - if output_format == OutputFormat::Pretty { - ui::print_disposition(&profile); - } else { - output::print_output(&profile, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} - -pub fn stats(client: &ApiClient, bank_id: &str, verbose: bool, output_format: OutputFormat) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Fetching statistics...")) - } else { - None - }; - - let response = client.get_stats(bank_id, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(stats) => { - if output_format == OutputFormat::Pretty { - ui::print_section_header(&format!("Statistics: {}", bank_id)); - - println!(" {} {}", ui::dim("memory units:"), ui::gradient_start(&stats.total_nodes.to_string())); - println!(" {} {}", ui::dim("links:"), ui::gradient_mid(&stats.total_links.to_string())); - println!(" {} {}", ui::dim("documents:"), ui::gradient_end(&stats.total_documents.to_string())); - println!(); - - println!("{}", ui::gradient_text("─── Memory Units by Type ───")); - let mut fact_types: Vec<_> = stats.nodes_by_fact_type.iter().collect(); - fact_types.sort_by_key(|(k, _)| *k); - for (i, (fact_type, count)) in fact_types.iter().enumerate() { - let t = i as f32 / fact_types.len().max(1) as f32; - println!(" {:<10} {}", fact_type, ui::gradient(&count.to_string(), t)); - } - println!(); - - println!("{}", ui::gradient_text("─── Links by Type ───")); - let mut link_types: Vec<_> = stats.links_by_link_type.iter().collect(); - link_types.sort_by_key(|(k, _)| *k); - for (i, (link_type, count)) in link_types.iter().enumerate() { - let t = i as f32 / link_types.len().max(1) as f32; - println!(" {:<10} {}", link_type, ui::gradient(&count.to_string(), t)); - } - println!(); - - println!("{}", ui::gradient_text("─── Links by Fact Type ───")); - let mut fact_type_links: Vec<_> = stats.links_by_fact_type.iter().collect(); - fact_type_links.sort_by_key(|(k, _)| *k); - for (i, (fact_type, count)) in fact_type_links.iter().enumerate() { - let t = i as f32 / fact_type_links.len().max(1) as f32; - println!(" {:<10} {}", fact_type, ui::gradient(&count.to_string(), t)); - } - println!(); - - if !stats.links_breakdown.is_empty() { - println!("{}", ui::gradient_text("─── Detailed Link Breakdown ───")); - let mut fact_types: Vec<_> = stats.links_breakdown.iter().collect(); - fact_types.sort_by_key(|(k, _)| *k); - for (fact_type, link_types) in fact_types { - println!(" {}", fact_type); - let mut sorted_links: Vec<_> = link_types.iter().collect(); - sorted_links.sort_by_key(|(k, _)| *k); - for (link_type, count) in sorted_links { - println!(" {:<10} {}", ui::dim(link_type), count); - } - } - println!(); - } - - if stats.pending_operations > 0 || stats.failed_operations > 0 { - println!("{}", ui::gradient_text("─── Operations ───")); - if stats.pending_operations > 0 { - println!(" {} {}", ui::dim("pending:"), stats.pending_operations); - } - if stats.failed_operations > 0 { - println!(" {} {}", ui::dim("failed:"), stats.failed_operations); - } - } - } else { - output::print_output(&stats, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} - -pub fn update_name(client: &ApiClient, bank_id: &str, name: &str, verbose: bool, output_format: OutputFormat) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Updating bank name...")) - } else { - None - }; - - let response = client.update_agent_name(bank_id, name, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(profile) => { - if output_format == OutputFormat::Pretty { - ui::print_success(&format!("Bank name updated to '{}'", profile.name)); - } else { - output::print_output(&profile, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} - -pub fn update_background( - client: &ApiClient, - bank_id: &str, - content: &str, - no_update_disposition: bool, - verbose: bool, - output_format: OutputFormat -) -> Result<()> { - let current_profile = if !no_update_disposition { - client.get_profile(bank_id, verbose).ok() - } else { - None - }; - - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Merging background...")) - } else { - None - }; - - let response = client.add_background(bank_id, content, !no_update_disposition, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(profile) => { - if output_format == OutputFormat::Pretty { - ui::print_success("Background updated successfully"); - println!("\n{}", profile.background); - - if !no_update_disposition { - if let (Some(old_p), Some(new_p)) = - (current_profile.as_ref().map(|p| p.disposition.clone()), &profile.disposition) - { - println!("\nDisposition changes:"); - println!(" Skepticism: {} → {}", old_p.skepticism, new_p.skepticism); - println!(" Literalism: {} → {}", old_p.literalism, new_p.literalism); - println!(" Empathy: {} → {}", old_p.empathy, new_p.empathy); - } - } - } else { - output::print_output(&profile, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} - -pub fn delete( - client: &ApiClient, - bank_id: &str, - yes: bool, - verbose: bool, - output_format: OutputFormat -) -> Result<()> { - // Confirmation prompt unless -y flag is used - if !yes && output_format == OutputFormat::Pretty { - let message = format!( - "Are you sure you want to delete bank '{}' and ALL its data? This cannot be undone.", - bank_id - ); - - let confirmed = ui::prompt_confirmation(&message)?; - - if !confirmed { - ui::print_info("Operation cancelled"); - return Ok(()); - } - } - - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Deleting bank...")) - } else { - None - }; - - let response = client.delete_bank(bank_id, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(result) => { - if output_format == OutputFormat::Pretty { - if result.success { - ui::print_success(&format!("Bank '{}' deleted successfully", bank_id)); - if let Some(count) = result.deleted_count { - println!(" Items deleted: {}", count); - } - } else { - ui::print_error("Failed to delete bank"); - } - } else { - output::print_output(&result, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} diff --git a/hindsight-cli/src/commands/document.rs b/hindsight-cli/src/commands/document.rs deleted file mode 100644 index 863a579f..00000000 --- a/hindsight-cli/src/commands/document.rs +++ /dev/null @@ -1,124 +0,0 @@ -use anyhow::Result; -use crate::api::ApiClient; -use crate::output::{self, OutputFormat}; -use crate::ui; - -pub fn list( - client: &ApiClient, - agent_id: &str, - query: Option, - limit: i32, - offset: i32, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Fetching documents...")) - } else { - None - }; - - let response = client.list_documents(agent_id, query.as_deref(), Some(limit), Some(offset), verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(docs_response) => { - if output_format == OutputFormat::Pretty { - ui::print_info(&format!("Documents for bank '{}' (total: {})", agent_id, docs_response.total)); - for doc in &docs_response.items { - let id = doc.get("id").and_then(|v| v.as_str()).unwrap_or("unknown"); - let created = doc.get("created_at").and_then(|v| v.as_str()).unwrap_or("unknown"); - let updated = doc.get("updated_at").and_then(|v| v.as_str()).unwrap_or("unknown"); - let text_len = doc.get("text_length").and_then(|v| v.as_i64()).unwrap_or(0); - let mem_count = doc.get("memory_unit_count").and_then(|v| v.as_i64()).unwrap_or(0); - - println!("\n Document ID: {}", id); - println!(" Created: {}", created); - println!(" Updated: {}", updated); - println!(" Text Length: {}", text_len); - println!(" Memory Units: {}", mem_count); - } - } else { - output::print_output(&docs_response, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} - -pub fn get( - client: &ApiClient, - agent_id: &str, - document_id: &str, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Fetching document...")) - } else { - None - }; - - let response = client.get_document(agent_id, document_id, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(doc) => { - if output_format == OutputFormat::Pretty { - ui::print_info(&format!("Document: {}", doc.id)); - println!(" Bank ID: {}", doc.bank_id); - println!(" Created: {}", doc.created_at); - println!(" Updated: {}", doc.updated_at); - println!(" Memory Units: {}", doc.memory_unit_count); - println!("\n Text:\n{}", doc.original_text); - } else { - output::print_output(&doc, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} - -pub fn delete( - client: &ApiClient, - agent_id: &str, - document_id: &str, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Deleting document...")) - } else { - None - }; - - let response = client.delete_document(agent_id, document_id, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(result) => { - if output_format == OutputFormat::Pretty { - if result.success { - ui::print_success("Document deleted successfully"); - } else { - ui::print_error("Failed to delete document"); - } - } else { - output::print_output(&result, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} diff --git a/hindsight-cli/src/commands/entity.rs b/hindsight-cli/src/commands/entity.rs deleted file mode 100644 index 48f0ece7..00000000 --- a/hindsight-cli/src/commands/entity.rs +++ /dev/null @@ -1,125 +0,0 @@ -use anyhow::Result; -use crate::api::ApiClient; -use crate::output::{self, OutputFormat}; -use crate::ui; - -pub fn list( - client: &ApiClient, - bank_id: &str, - limit: i64, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Fetching entities...")) - } else { - None - }; - - let response = client.list_entities(bank_id, Some(limit), verbose)?; - - if let Some(mut sp) = spinner { - sp.finish(); - } - - if output_format == OutputFormat::Pretty { - ui::print_section_header(&format!("Entities for Bank: {}", bank_id)); - - if response.items.is_empty() { - ui::print_warning("No entities found"); - return Ok(()); - } - - println!("Total entities: {}\n", response.items.len()); - - for entity in &response.items { - println!("ID: {}", entity.id); - println!(" Name: {}", entity.canonical_name); - println!(" Mentions: {}", entity.mention_count); - if let Some(first_seen) = &entity.first_seen { - println!(" First seen: {}", first_seen); - } - if let Some(last_seen) = &entity.last_seen { - println!(" Last seen: {}", last_seen); - } - println!(); - } - } else { - output::print_output(&response, output_format)?; - } - - Ok(()) -} - -pub fn get( - client: &ApiClient, - bank_id: &str, - entity_id: &str, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Fetching entity details...")) - } else { - None - }; - - let response = client.get_entity(bank_id, entity_id, verbose)?; - - if let Some(mut sp) = spinner { - sp.finish(); - } - - if output_format == OutputFormat::Pretty { - ui::print_section_header(&format!("Entity: {}", entity_id)); - - println!("ID: {}", response.id); - println!("Name: {}", response.canonical_name); - println!("Mentions: {}", response.mention_count); - - if let Some(first_seen) = &response.first_seen { - println!("First seen: {}", first_seen); - } - if let Some(last_seen) = &response.last_seen { - println!("Last seen: {}", last_seen); - } - - println!(); - } else { - output::print_output(&response, output_format)?; - } - - Ok(()) -} - -pub fn regenerate( - client: &ApiClient, - bank_id: &str, - entity_id: &str, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Regenerating entity observations...")) - } else { - None - }; - - let response = client.regenerate_entity(bank_id, entity_id, verbose)?; - - if let Some(mut sp) = spinner { - sp.finish(); - } - - if output_format == OutputFormat::Pretty { - ui::print_success(&format!("Successfully regenerated observations for entity: {}", entity_id)); - println!("\nUpdated entity:"); - println!(" Name: {}", response.canonical_name); - println!(" Mentions: {}", response.mention_count); - println!(" Observations: {}", response.observations.len()); - } else { - output::print_output(&response, output_format)?; - } - - Ok(()) -} diff --git a/hindsight-cli/src/commands/explore.rs b/hindsight-cli/src/commands/explore.rs deleted file mode 100644 index 1d2d42ce..00000000 --- a/hindsight-cli/src/commands/explore.rs +++ /dev/null @@ -1,1557 +0,0 @@ -use crate::api::{ApiClient, RecallRequest, ReflectRequest}; -use anyhow::Result; -use crossterm::{ - event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode}, - execute, - terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, -}; -use hindsight_client::types::{BankListItem, RecallResult, EntityListItem, Budget}; -use serde_json::{Map, Value}; -use ratatui::{ - backend::{Backend, CrosstermBackend}, - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Wrap}, - Frame, Terminal, -}; -use std::io; -use std::sync::mpsc::{self, Receiver, TryRecvError}; -use std::thread; -use std::time::{Duration, Instant}; - -// Brand gradient colors: #0074d9 -> #009296 -const BRAND_START: Color = Color::Rgb(0, 116, 217); // #0074d9 -const BRAND_END: Color = Color::Rgb(0, 146, 150); // #009296 -const BRAND_MID: Color = Color::Rgb(0, 131, 183); // Midpoint - -/// Main view types (like k9s contexts) -#[derive(Debug, Clone, PartialEq)] -enum View { - Banks, - Memories(String), // bank_id - Entities(String), // bank_id - Documents(String), // bank_id - Query(String), // bank_id - combines recall and reflect -} - -impl View { - fn title(&self) -> &str { - match self { - View::Banks => "Banks", - View::Memories(_) => "Memories", - View::Entities(_) => "Entities", - View::Documents(_) => "Documents", - View::Query(_) => "Query", - } - } - - fn bank_id(&self) -> Option<&str> { - match self { - View::Banks => None, - View::Memories(id) | View::Entities(id) | View::Documents(id) | View::Query(id) => Some(id), - } - } -} - -/// Query mode for the Query view -#[derive(Debug, Clone, PartialEq)] -enum QueryMode { - Recall, - Reflect, -} - -/// Input mode for recall/reflect queries -#[derive(Debug, Clone, PartialEq)] -enum InputMode { - Normal, - Query, -} - -/// Query result from background thread -enum QueryResult { - Recall(Result, String>), - Reflect(Result), -} - -/// Application state -struct App { - client: ApiClient, - view: View, - view_history: Vec, - - // List states - banks: Vec, - banks_state: ListState, - selected_bank_id: Option, - - memories: Vec>, - memories_state: ListState, - viewing_memory: Option>, - memories_limit: i64, - memories_offset: i64, - horizontal_scroll: usize, - - entities: Vec, - entities_state: ListState, - viewing_entity: Option, - - documents: Vec>, - documents_state: ListState, - viewing_document: Option>, - - // Query state (unified recall/reflect) - query_mode: QueryMode, - query_text: String, - query_budget: Budget, - query_max_tokens: i64, - query_results: Vec, - query_results_state: ListState, - query_response: String, - viewing_recall_result: Option, - - // Input mode - input_mode: InputMode, - - // Status messages - status_message: String, - error_message: String, - - // Help visibility - show_help: bool, - - // Loading state - loading: bool, - - // Auto-refresh - auto_refresh_enabled: bool, - last_refresh: Instant, - refresh_interval: Duration, - - // Background query receiver - query_receiver: Option>, -} - -impl App { - fn new(client: ApiClient) -> Self { - let mut app = Self { - client, - view: View::Banks, - view_history: Vec::new(), - - banks: Vec::new(), - banks_state: ListState::default(), - selected_bank_id: None, - - memories: Vec::new(), - memories_state: ListState::default(), - viewing_memory: None, - memories_limit: 500, - memories_offset: 0, - horizontal_scroll: 0, - - entities: Vec::new(), - entities_state: ListState::default(), - viewing_entity: None, - - documents: Vec::new(), - documents_state: ListState::default(), - viewing_document: None, - - query_mode: QueryMode::Recall, - query_text: String::new(), - query_budget: Budget::Mid, - query_max_tokens: 4096, - query_results: Vec::new(), - query_results_state: ListState::default(), - query_response: String::new(), - viewing_recall_result: None, - - input_mode: InputMode::Normal, - status_message: String::from("Select a bank to start. Press ? for help"), - error_message: String::new(), - show_help: false, - loading: false, - - auto_refresh_enabled: true, - last_refresh: Instant::now(), - refresh_interval: Duration::from_secs(5), - - query_receiver: None, - }; - - // Select first item by default - app.banks_state.select(Some(0)); - app.memories_state.select(Some(0)); - app.entities_state.select(Some(0)); - app.documents_state.select(Some(0)); - app.query_results_state.select(Some(0)); - - app - } - - fn refresh(&mut self) -> Result<()> { - self.loading = true; - self.error_message.clear(); - - let result = match self.view.clone() { - View::Banks => self.load_banks(), - View::Memories(bank_id) => self.load_memories(&bank_id), - View::Entities(bank_id) => self.load_entities(&bank_id), - View::Documents(bank_id) => self.load_documents(&bank_id), - View::Query(_) => Ok(()), // Query is query-driven - }; - - self.loading = false; - - if let Err(e) = result { - self.error_message = format!("Error: {}", e); - } - - Ok(()) - } - - fn toggle_auto_refresh(&mut self) { - self.auto_refresh_enabled = !self.auto_refresh_enabled; - if self.auto_refresh_enabled { - self.status_message = "Auto-refresh enabled (5s)".to_string(); - self.last_refresh = Instant::now(); - } else { - self.status_message = "Auto-refresh disabled".to_string(); - } - } - - fn should_refresh(&self) -> bool { - self.auto_refresh_enabled && self.last_refresh.elapsed() >= self.refresh_interval - } - - fn do_auto_refresh(&mut self) -> Result<()> { - if self.should_refresh() { - self.last_refresh = Instant::now(); - self.refresh()?; - } - Ok(()) - } - - fn load_banks(&mut self) -> Result<()> { - self.banks = self.client.list_agents(false)?; - - if !self.banks.is_empty() && self.banks_state.selected().is_none() { - self.banks_state.select(Some(0)); - } - - self.status_message = format!("Loaded {} banks", self.banks.len()); - Ok(()) - } - - fn load_memories(&mut self, bank_id: &str) -> Result<()> { - let response = self.client.list_memories( - bank_id, - None, - None, - Some(self.memories_limit), - Some(self.memories_offset), - false - )?; - self.memories = response.items; - - if !self.memories.is_empty() && self.memories_state.selected().is_none() { - self.memories_state.select(Some(0)); - } - - self.status_message = format!("Loaded {} memories (limit: {}, offset: {})", - self.memories.len(), self.memories_limit, self.memories_offset); - Ok(()) - } - - fn load_more_memories(&mut self) -> Result<()> { - if let View::Memories(bank_id) = &self.view { - let bank_id = bank_id.clone(); - self.memories_offset += self.memories_limit; - self.load_memories(&bank_id)?; - } - Ok(()) - } - - fn load_prev_memories(&mut self) -> Result<()> { - if let View::Memories(bank_id) = &self.view { - let bank_id = bank_id.clone(); - self.memories_offset = (self.memories_offset - self.memories_limit).max(0); - self.load_memories(&bank_id)?; - } - Ok(()) - } - - fn load_entities(&mut self, bank_id: &str) -> Result<()> { - let response = self.client.list_entities(bank_id, Some(100), false)?; - self.entities = response.items; - - if !self.entities.is_empty() && self.entities_state.selected().is_none() { - self.entities_state.select(Some(0)); - } - - self.status_message = format!("Loaded {} entities", self.entities.len()); - Ok(()) - } - - fn load_documents(&mut self, bank_id: &str) -> Result<()> { - let response = self.client.list_documents(bank_id, None, Some(100), Some(0), false)?; - self.documents = response.items; - - if !self.documents.is_empty() && self.documents_state.selected().is_none() { - self.documents_state.select(Some(0)); - } - - self.status_message = format!("Loaded {} documents", self.documents.len()); - Ok(()) - } - - fn execute_query(&mut self) { - if let View::Query(bank_id) = &self.view { - if self.query_text.is_empty() { - self.error_message = "Query cannot be empty".to_string(); - return; - } - - self.loading = true; - self.error_message.clear(); - self.input_mode = InputMode::Normal; - - // Create channel for receiving results - let (tx, rx) = mpsc::channel(); - self.query_receiver = Some(rx); - - // Clone data for the thread - let client = self.client.clone(); - let bank_id = bank_id.clone(); - let query_mode = self.query_mode.clone(); - let query_text = self.query_text.clone(); - let query_budget = self.query_budget.clone(); - let query_max_tokens = self.query_max_tokens; - - // Spawn background thread - thread::spawn(move || { - match query_mode { - QueryMode::Recall => { - let request = RecallRequest { - query: query_text, - types: None, - budget: Some(query_budget), - max_tokens: query_max_tokens, - trace: false, - query_timestamp: None, - include: None, - }; - - let result = client.recall(&bank_id, &request, false) - .map(|r| r.results) - .map_err(|e| e.to_string()); - - let _ = tx.send(QueryResult::Recall(result)); - } - QueryMode::Reflect => { - let request = ReflectRequest { - query: query_text, - budget: Some(query_budget), - context: None, - include: None, - }; - - let result = client.reflect(&bank_id, &request, false) - .map(|r| r.text) - .map_err(|e| e.to_string()); - - let _ = tx.send(QueryResult::Reflect(result)); - } - } - }); - } - } - - fn check_query_result(&mut self) { - if let Some(receiver) = &self.query_receiver { - match receiver.try_recv() { - Ok(QueryResult::Recall(Ok(results))) => { - self.query_results = results; - if !self.query_results.is_empty() { - self.query_results_state.select(Some(0)); - } - self.loading = false; - self.status_message = format!("Found {} results", self.query_results.len()); - self.query_receiver = None; - } - Ok(QueryResult::Recall(Err(e))) => { - self.error_message = format!("Recall failed: {}", e); - self.loading = false; - self.query_receiver = None; - } - Ok(QueryResult::Reflect(Ok(text))) => { - self.query_response = text; - self.loading = false; - self.status_message = "Reflection complete".to_string(); - self.query_receiver = None; - } - Ok(QueryResult::Reflect(Err(e))) => { - self.error_message = format!("Reflect failed: {}", e); - self.loading = false; - self.query_receiver = None; - } - Err(TryRecvError::Empty) => { - // Still waiting for result - } - Err(TryRecvError::Disconnected) => { - self.error_message = "Query thread disconnected".to_string(); - self.loading = false; - self.query_receiver = None; - } - } - } - } - - fn toggle_query_mode(&mut self) { - self.query_mode = match self.query_mode { - QueryMode::Recall => QueryMode::Reflect, - QueryMode::Reflect => QueryMode::Recall, - }; - self.status_message = format!("Switched to {} mode", match self.query_mode { - QueryMode::Recall => "Recall", - QueryMode::Reflect => "Reflect", - }); - } - - fn cycle_budget(&mut self) { - self.query_budget = match self.query_budget { - Budget::Low => Budget::Mid, - Budget::Mid => Budget::High, - Budget::High => Budget::Low, - }; - self.status_message = format!("Budget: {:?}", self.query_budget); - } - - fn adjust_max_tokens(&mut self, increase: bool) { - if increase { - self.query_max_tokens = (self.query_max_tokens + 1024).min(16384); - } else { - self.query_max_tokens = (self.query_max_tokens - 1024).max(512); - } - self.status_message = format!("Max tokens: {}", self.query_max_tokens); - } - - fn scroll_left(&mut self) { - self.horizontal_scroll = self.horizontal_scroll.saturating_sub(10); - } - - fn scroll_right(&mut self) { - self.horizontal_scroll = (self.horizontal_scroll + 10).min(200); - } - - fn reset_horizontal_scroll(&mut self) { - self.horizontal_scroll = 0; - } - - fn next_item(&mut self) { - match &self.view { - View::Banks => { - let i = match self.banks_state.selected() { - Some(i) => { - if i >= self.banks.len().saturating_sub(1) { - 0 - } else { - i + 1 - } - } - None => 0, - }; - self.banks_state.select(Some(i)); - } - View::Memories(_) => { - let i = match self.memories_state.selected() { - Some(i) => { - if i >= self.memories.len().saturating_sub(1) { - 0 - } else { - i + 1 - } - } - None => 0, - }; - self.memories_state.select(Some(i)); - } - View::Entities(_) => { - let i = match self.entities_state.selected() { - Some(i) => { - if i >= self.entities.len().saturating_sub(1) { - 0 - } else { - i + 1 - } - } - None => 0, - }; - self.entities_state.select(Some(i)); - } - View::Documents(_) => { - let i = match self.documents_state.selected() { - Some(i) => { - if i >= self.documents.len().saturating_sub(1) { - 0 - } else { - i + 1 - } - } - None => 0, - }; - self.documents_state.select(Some(i)); - } - View::Query(_) => { - if self.query_mode == QueryMode::Recall { - let i = match self.query_results_state.selected() { - Some(i) => { - if i >= self.query_results.len().saturating_sub(1) { - 0 - } else { - i + 1 - } - } - None => 0, - }; - self.query_results_state.select(Some(i)); - } - } - } - } - - fn previous_item(&mut self) { - match &self.view { - View::Banks => { - let i = match self.banks_state.selected() { - Some(i) => { - if i == 0 { - self.banks.len().saturating_sub(1) - } else { - i - 1 - } - } - None => 0, - }; - self.banks_state.select(Some(i)); - } - View::Memories(_) => { - let i = match self.memories_state.selected() { - Some(i) => { - if i == 0 { - self.memories.len().saturating_sub(1) - } else { - i - 1 - } - } - None => 0, - }; - self.memories_state.select(Some(i)); - } - View::Entities(_) => { - let i = match self.entities_state.selected() { - Some(i) => { - if i == 0 { - self.entities.len().saturating_sub(1) - } else { - i - 1 - } - } - None => 0, - }; - self.entities_state.select(Some(i)); - } - View::Documents(_) => { - let i = match self.documents_state.selected() { - Some(i) => { - if i == 0 { - self.documents.len().saturating_sub(1) - } else { - i - 1 - } - } - None => 0, - }; - self.documents_state.select(Some(i)); - } - View::Query(_) => { - if self.query_mode == QueryMode::Recall { - let i = match self.query_results_state.selected() { - Some(i) => { - if i == 0 { - self.query_results.len().saturating_sub(1) - } else { - i - 1 - } - } - None => 0, - }; - self.query_results_state.select(Some(i)); - } - } - } - } - - fn enter_view(&mut self) -> Result<()> { - match &self.view { - View::Banks => { - if let Some(i) = self.banks_state.selected() { - if let Some(bank) = self.banks.get(i) { - let bank_id = bank.bank_id.clone(); - self.selected_bank_id = Some(bank_id.clone()); - self.view_history.push(self.view.clone()); - self.view = View::Memories(bank_id.clone()); - self.load_memories(&bank_id)?; - } - } - } - View::Memories(_) => { - if let Some(i) = self.memories_state.selected() { - if let Some(memory) = self.memories.get(i) { - self.viewing_memory = Some(memory.clone()); - self.status_message = "Viewing memory details (Esc to close)".to_string(); - } - } - } - View::Entities(_) => { - if let Some(i) = self.entities_state.selected() { - if let Some(entity) = self.entities.get(i).cloned() { - self.viewing_entity = Some(entity); - self.status_message = "Viewing entity details (Esc to close)".to_string(); - } - } - } - View::Documents(bank_id) => { - if let Some(i) = self.documents_state.selected() { - if let Some(doc) = self.documents.get(i) { - // Fetch full document content - let doc_id = doc.get("id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - if !doc_id.is_empty() { - match self.client.get_document(bank_id, doc_id, false) { - Ok(full_doc) => { - // Convert to Map for display - let doc_map: Map = serde_json::from_value( - serde_json::to_value(full_doc)? - )?; - self.viewing_document = Some(doc_map); - self.status_message = format!("Viewing document: {}", doc_id); - } - Err(e) => { - self.error_message = format!("Failed to load document: {}", e); - } - } - } - } - } - } - View::Query(_) => { - // View recall result details if in recall mode - if self.query_mode == QueryMode::Recall { - if let Some(i) = self.query_results_state.selected() { - if let Some(result) = self.query_results.get(i).cloned() { - self.viewing_recall_result = Some(result); - self.status_message = "Viewing recall result (Esc to close)".to_string(); - } - } - } - } - } - Ok(()) - } - - fn go_back(&mut self) { - // If viewing a detail view, close it first - if self.viewing_memory.is_some() { - self.viewing_memory = None; - self.status_message = "Closed memory view".to_string(); - return; - } - if self.viewing_entity.is_some() { - self.viewing_entity = None; - self.status_message = "Closed entity view".to_string(); - return; - } - if self.viewing_document.is_some() { - self.viewing_document = None; - self.status_message = "Closed document view".to_string(); - return; - } - if self.viewing_recall_result.is_some() { - self.viewing_recall_result = None; - self.status_message = "Closed recall result view".to_string(); - return; - } - - // Otherwise go back to previous view - if let Some(prev_view) = self.view_history.pop() { - self.view = prev_view; - let _ = self.refresh(); - } - } - - fn switch_to_view(&mut self, new_view: View) -> Result<()> { - if self.view != new_view { - self.view_history.push(self.view.clone()); - self.view = new_view; - self.refresh()?; - } - Ok(()) - } - - fn delete_selected_document(&mut self) -> Result<()> { - if let View::Documents(bank_id) = &self.view { - if let Some(i) = self.documents_state.selected() { - if let Some(doc) = self.documents.get(i) { - let doc_id = doc.get("id") - .and_then(|v| v.as_str()) - .unwrap_or(""); - - if !doc_id.is_empty() { - match self.client.delete_document(bank_id, doc_id, false) { - Ok(_) => { - self.status_message = format!("Deleted document: {}", doc_id); - self.refresh()?; - } - Err(e) => { - self.error_message = format!("Failed to delete document: {}", e); - } - } - } - } - } - } - Ok(()) - } -} - -fn ui(f: &mut Frame, app: &mut App) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(5), // Shortcuts bar (context + shortcuts, max 3 rows + 2 border) - Constraint::Length(3), // Header - Constraint::Min(0), // Main content - Constraint::Length(1), // Footer/status only (no border) - ]) - .split(f.area()); - - // Control bar - render_control_bar(f, app, chunks[0]); - - // Header - render_header(f, app, chunks[1]); - - // Main content - if app.show_help { - render_help(f, chunks[2]); - } else { - match &app.view { - View::Banks => render_banks(f, app, chunks[2]), - View::Memories(_) => render_memories(f, app, chunks[2]), - View::Entities(_) => render_entities(f, app, chunks[2]), - View::Documents(_) => render_documents(f, app, chunks[2]), - View::Query(_) => render_query(f, app, chunks[2]), - } - } - - // Footer - render_footer(f, app, chunks[3]); -} - -fn render_control_bar(f: &mut Frame, app: &App, area: Rect) { - // Build contextual shortcuts based on view and input mode - let shortcuts = match (&app.view, &app.input_mode) { - (View::Banks, InputMode::Normal) => vec![ - ("Enter", "Select", BRAND_START), - ("R", "Refresh", BRAND_MID), - ("?", "Help", BRAND_END), - ("q", "Quit", Color::Red), - ], - (View::Memories(_), InputMode::Normal) => vec![ - ("Enter", "View", BRAND_START), - ("/", "Query", BRAND_MID), - ("←→", "Scroll", BRAND_START), - ("n", "Next", BRAND_MID), - ("p", "Prev", BRAND_MID), - ("Esc", "Back", BRAND_END), - ("R", "Refresh", BRAND_END), - ("?", "Help", BRAND_END), - ("q", "Quit", Color::Red), - ], - (View::Entities(_), InputMode::Normal) => vec![ - ("Enter", "View", BRAND_START), - ("/", "Query", BRAND_MID), - ("←→", "Scroll", BRAND_START), - ("Esc", "Back", BRAND_END), - ("R", "Refresh", BRAND_END), - ("?", "Help", BRAND_END), - ("q", "Quit", Color::Red), - ], - (View::Documents(_), InputMode::Normal) => vec![ - ("Enter", "View", BRAND_START), - ("/", "Query", BRAND_MID), - ("←→", "Scroll", BRAND_START), - ("Del", "Delete", Color::Red), - ("Esc", "Back", BRAND_END), - ("R", "Refresh", BRAND_END), - ("?", "Help", BRAND_END), - ("q", "Quit", Color::Red), - ], - (View::Query(_), InputMode::Normal) => { - let mut shortcuts = vec![ - ("/", "Query", BRAND_MID), - ("m", "Mode", BRAND_START), - ]; - if app.query_mode == QueryMode::Recall { - shortcuts.push(("←→", "Scroll", BRAND_START)); - } - shortcuts.extend_from_slice(&[ - ("b", "Budget", BRAND_END), - ("+/-", "Tokens", BRAND_END), - ("Esc", "Back", BRAND_END), - ("?", "Help", BRAND_END), - ("q", "Quit", Color::Red), - ]); - shortcuts - }, - (View::Query(_), InputMode::Query) => vec![ - ("Enter", "Execute", BRAND_MID), - ("Esc", "Cancel", Color::Red), - ], - _ => vec![ - ("?", "Help", BRAND_END), - ("q", "Quit", Color::Red), - ], - }; - - // Split into left (context) and right (shortcuts) sections - let columns = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(30), // Context on left - Constraint::Percentage(70), // Shortcuts on right - ]) - .split(area); - - // Left: Context info - let context_info = match &app.view { - View::Banks => "Context: Banks List".to_string(), - View::Memories(bank_id) => format!("Context: Memories\nBank: {}", bank_id), - View::Entities(bank_id) => format!("Context: Entities\nBank: {}", bank_id), - View::Documents(bank_id) => format!("Context: Documents\nBank: {}", bank_id), - View::Query(_bank_id) => { - let mode = match app.query_mode { - QueryMode::Recall => "Recall", - QueryMode::Reflect => "Reflect", - }; - format!("Mode: {}\nBudget: {:?} | Tokens: {}", mode, app.query_budget, app.query_max_tokens) - } - }; - - let context_widget = Paragraph::new(context_info) - .block(Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(BRAND_START)) - .title(" Context ")) - .style(Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)) - .alignment(Alignment::Left); - f.render_widget(context_widget, columns[0]); - - // Right: Shortcuts in columns if many - // Calculate shortcuts per column (max 3 lines of shortcuts) - let max_shortcuts_per_col = 3; - let num_cols = (shortcuts.len() + max_shortcuts_per_col - 1) / max_shortcuts_per_col; - - let mut shortcut_lines = vec![]; - for row in 0..max_shortcuts_per_col { - let mut line_spans = vec![]; - - for col in 0..num_cols { - let idx = col * max_shortcuts_per_col + row; - if idx < shortcuts.len() { - let (key, desc, color) = &shortcuts[idx]; - - // Each shortcut with proper alignment - let shortcut_text = format!("<{}> {:<10}", key, desc); - - line_spans.push(Span::styled( - shortcut_text, - Style::default().fg(*color).add_modifier(Modifier::BOLD) - )); - } - } - - if !line_spans.is_empty() { - shortcut_lines.push(Line::from(line_spans)); - } - } - - let shortcuts_widget = Paragraph::new(shortcut_lines) - .block(Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(BRAND_START)) - .title(" Shortcuts ")) - .alignment(Alignment::Left); - - f.render_widget(shortcuts_widget, columns[1]); -} - -fn render_header(f: &mut Frame, app: &App, area: Rect) { - let bank_info = if let Some(bank_id) = app.view.bank_id() { - format!(" [{}]", bank_id) - } else { - String::new() - }; - - let title = format!("Hindsight Explorer - {}{}", app.view.title(), bank_info); - - let header = Paragraph::new(title) - .style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - - f.render_widget(header, area); -} - -fn render_footer(f: &mut Frame, app: &App, area: Rect) { - // Simple status line only (shortcuts are now at the top, no border) - let status_line = if !app.error_message.is_empty() { - Line::from(vec![ - Span::styled(" Error: ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), - Span::raw(&app.error_message), - ]) - } else if app.loading { - Line::from(Span::styled(" Loading...", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD))) - } else if !app.status_message.is_empty() { - Line::from(vec![ - Span::raw(" "), - Span::styled(&app.status_message, Style::default().fg(BRAND_MID)), - ]) - } else { - Line::from("") - }; - - let footer = Paragraph::new(status_line).alignment(Alignment::Left); - f.render_widget(footer, area); -} - -fn render_banks(f: &mut Frame, app: &mut App, area: Rect) { - let items: Vec = app - .banks - .iter() - .map(|bank| { - let name = bank.name.as_deref().filter(|s| !s.is_empty()).unwrap_or("Unnamed"); - let content = format!("{} - {}", bank.bank_id, name); - ListItem::new(content).style(Style::default().fg(Color::White)) - }) - .collect(); - - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).title("Banks")) - .highlight_style( - Style::default() - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(">> "); - - f.render_stateful_widget(list, area, &mut app.banks_state); -} - -fn render_memories(f: &mut Frame, app: &mut App, area: Rect) { - // If viewing a memory, show its details - if let Some(memory) = &app.viewing_memory { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(7), // Memory metadata - Constraint::Min(0), // Full text content - ]) - .split(area); - - // Metadata section - let mem_type = memory.get("fact_type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let mentioned_at = memory.get("mentioned_at") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let occurred_start = memory.get("occurred_start") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let occurred_end = memory.get("occurred_end") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - - let metadata_text = format!( - "Type: {}\nMentioned At: {}\nOccurred: {} to {}", - mem_type, mentioned_at, occurred_start, occurred_end - ); - - let metadata = Paragraph::new(metadata_text) - .block(Block::default().borders(Borders::ALL).title("Memory Metadata")) - .style(Style::default().fg(BRAND_START)); - - f.render_widget(metadata, chunks[0]); - - // Full text content - let text = memory.get("text").and_then(|v| v.as_str()).unwrap_or("No text available"); - - let content_widget = Paragraph::new(text) - .block(Block::default().borders(Borders::ALL).title("Full Text (Esc to close)")) - .wrap(Wrap { trim: false }) - .style(Style::default().fg(Color::White)); - - f.render_widget(content_widget, chunks[1]); - } else { - // Show memory list as table - let mut items = vec![ - // Header row - ListItem::new(format!("{:<10} {:<18} {:<18} {}", "TYPE", "MENTIONED AT", "OCCURRED AT", "TEXT")) - .style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD)) - ]; - - // Data rows - for memory in &app.memories { - let mem_type = memory.get("fact_type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let mentioned = memory.get("mentioned_at") - .and_then(|v| v.as_str()) - .and_then(|s| s.split('T').next()) - .unwrap_or("-"); - let occurred = memory.get("occurred_start") - .and_then(|v| v.as_str()) - .and_then(|s| s.split('T').next()) - .unwrap_or("-"); - let text = memory.get("text").and_then(|v| v.as_str()).unwrap_or(""); - - // Apply horizontal scroll - let scrolled_text: String = text.chars().skip(app.horizontal_scroll).take(80).collect(); - - let content = format!("{:<10} {:<18} {:<18} {}", mem_type, mentioned, occurred, scrolled_text); - items.push(ListItem::new(content).style(Style::default().fg(Color::White))); - } - - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).title(format!("Memories ({}) - Press Enter to view full text", app.memories.len()))) - .highlight_style( - Style::default() - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(">> "); - - f.render_stateful_widget(list, area, &mut app.memories_state); - } -} - -fn render_entities(f: &mut Frame, app: &mut App, area: Rect) { - // If viewing an entity, show its details - if let Some(entity) = &app.viewing_entity { - let entity_type = entity.metadata.as_ref() - .and_then(|m| m.get("type")) - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let metadata_text = format!( - "Name: {}\nType: {}\nMentions: {}\nFirst Seen: {}\nLast Seen: {}", - entity.canonical_name, - entity_type, - entity.mention_count, - entity.first_seen.as_deref().unwrap_or("unknown"), - entity.last_seen.as_deref().unwrap_or("unknown") - ); - - let metadata = Paragraph::new(metadata_text) - .block(Block::default().borders(Borders::ALL).title("Entity Details (Esc to close)")) - .style(Style::default().fg(BRAND_START)) - .wrap(Wrap { trim: false }); - - f.render_widget(metadata, area); - } else { - // Show entity list as table - let mut items = vec![ - // Header row - ListItem::new(format!("{:<40} {:<15} {:<10}", "NAME", "TYPE", "MENTIONS")) - .style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD)) - ]; - - // Data rows - for entity in &app.entities { - let name = &entity.canonical_name; - // Apply horizontal scroll to name - let scrolled_name: String = name.chars().skip(app.horizontal_scroll).take(40).collect(); - let entity_type = entity.metadata.as_ref() - .and_then(|m| m.get("type")) - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let mentions = entity.mention_count; - - let content = format!("{:<40} {:<15} {:<10}", scrolled_name, entity_type, mentions); - items.push(ListItem::new(content).style(Style::default().fg(Color::White))); - } - - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).title(format!("Entities ({}) - Press Enter to view details", app.entities.len()))) - .highlight_style( - Style::default() - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(">> "); - - f.render_stateful_widget(list, area, &mut app.entities_state); - } -} - -fn render_documents(f: &mut Frame, app: &mut App, area: Rect) { - // If viewing a document, show its content - if let Some(doc) = &app.viewing_document { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(5), // Document metadata - Constraint::Min(0), // Content - ]) - .split(area); - - // Metadata section - let doc_id = doc.get("id") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let content_type = doc.get("content_type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let created_at = doc.get("created_at") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - - let metadata_text = format!( - "ID: {}\nType: {}\nCreated: {}\n", - doc_id, content_type, created_at - ); - - let metadata = Paragraph::new(metadata_text) - .block(Block::default().borders(Borders::ALL).title("Document Metadata")) - .style(Style::default().fg(BRAND_START)); - - f.render_widget(metadata, chunks[0]); - - // Content section - let content = doc.get("content") - .and_then(|v| v.as_str()) - .unwrap_or("No content available"); - - let content_widget = Paragraph::new(content) - .block(Block::default().borders(Borders::ALL).title("Content (Esc to close)")) - .wrap(Wrap { trim: false }) - .style(Style::default().fg(Color::White)); - - f.render_widget(content_widget, chunks[1]); - } else { - // Show document list as table - let mut items = vec![ - // Header row - ListItem::new(format!("{:<40} {:<20} {}", "ID", "TYPE", "CREATED")) - .style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD)) - ]; - - // Data rows - for doc in &app.documents { - let id = doc.get("id") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - // Apply horizontal scroll to id - let scrolled_id: String = id.chars().skip(app.horizontal_scroll).take(40).collect(); - let content_type = doc.get("content_type") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let created = doc.get("created_at") - .and_then(|v| v.as_str()) - .and_then(|s| s.split('T').next()) - .unwrap_or("unknown"); - - let content = format!("{:<40} {:<20} {}", scrolled_id, content_type, created); - items.push(ListItem::new(content).style(Style::default().fg(Color::White))); - } - - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).title(format!("Documents ({}) - Press Enter to view content", app.documents.len()))) - .highlight_style( - Style::default() - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(">> "); - - f.render_stateful_widget(list, area, &mut app.documents_state); - } -} - -fn render_query(f: &mut Frame, app: &mut App, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Query input - Constraint::Min(0), // Results or Response - ]) - .split(area); - - // Query input - let query_style = if app.input_mode == InputMode::Query { - Style::default().fg(BRAND_END) - } else { - Style::default() - }; - - let mode_label = match app.query_mode { - QueryMode::Recall => "Recall", - QueryMode::Reflect => "Reflect", - }; - let title = format!("{} Query (press / to edit, m to toggle mode)", mode_label); - - let query = Paragraph::new(app.query_text.as_str()) - .style(query_style) - .block(Block::default().borders(Borders::ALL).title(title)); - - f.render_widget(query, chunks[0]); - - // Show loading indicator if loading - if app.loading { - let loading_text = match app.query_mode { - QueryMode::Recall => "Searching memories...", - QueryMode::Reflect => "Reflecting on memories...", - }; - - // Create animated dots based on time - let dots = ".".repeat(((std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() / 500) % 4) as usize); - - let loading_lines = vec![ - Line::from(""), - Line::from(""), - Line::from(vec![ - Span::styled(" ", Style::default()), - Span::styled(format!("{}{}", loading_text, dots), Style::default().fg(BRAND_MID).add_modifier(Modifier::BOLD)), - ]), - Line::from(""), - Line::from(Span::styled(" Please wait while we process your query...", Style::default().fg(Color::DarkGray))), - ]; - - let loading_widget = Paragraph::new(loading_lines) - .block(Block::default().borders(Borders::ALL).title(format!("{} in progress", mode_label))) - .alignment(Alignment::Left); - - f.render_widget(loading_widget, chunks[1]); - return; - } - - // Results or Response based on mode - match app.query_mode { - QueryMode::Recall => { - // If viewing a recall result, show its details - if let Some(result) = &app.viewing_recall_result { - let recall_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(7), // Metadata - Constraint::Min(0), // Full text - ]) - .split(chunks[1]); - - // Metadata section - let mem_type = result.type_.as_deref().unwrap_or("unknown"); - let occurred_start = result.occurred_start.as_deref().unwrap_or("unknown"); - let occurred_end = result.occurred_end.as_deref().unwrap_or("unknown"); - let mentioned_at = result.mentioned_at.as_deref().unwrap_or("unknown"); - - let metadata_text = format!( - "Type: {}\nMentioned At: {}\nOccurred: {} to {}", - mem_type, mentioned_at, occurred_start, occurred_end - ); - - let metadata = Paragraph::new(metadata_text) - .block(Block::default().borders(Borders::ALL).title("Recall Result Metadata")) - .style(Style::default().fg(BRAND_START)); - - f.render_widget(metadata, recall_chunks[0]); - - // Full text content - let content_widget = Paragraph::new(result.text.as_str()) - .block(Block::default().borders(Borders::ALL).title("Full Text (Esc to close)")) - .wrap(Wrap { trim: false }) - .style(Style::default().fg(Color::White)); - - f.render_widget(content_widget, recall_chunks[1]); - } else { - // Show results as a table like memories - let mut items = vec![ - // Header row - ListItem::new(format!("{:<10} {:<18} {:<18} {}", "TYPE", "OCCURRED START", "OCCURRED END", "TEXT")) - .style(Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD)) - ]; - - // Data rows - for result in &app.query_results { - let mem_type = result.type_.as_deref().unwrap_or("unknown"); - let occurred_start = result.occurred_start.as_deref() - .and_then(|s| s.split('T').next()) - .unwrap_or("-"); - let occurred_end = result.occurred_end.as_deref() - .and_then(|s| s.split('T').next()) - .unwrap_or("-"); - let text = &result.text; - - // Apply horizontal scroll - let scrolled_text: String = text.chars().skip(app.horizontal_scroll).take(80).collect(); - - let content = format!("{:<10} {:<18} {:<18} {}", mem_type, occurred_start, occurred_end, scrolled_text); - items.push(ListItem::new(content).style(Style::default().fg(Color::White))); - } - - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).title(format!("Recall Results ({}) - Press Enter to view full text", app.query_results.len()))) - .highlight_style( - Style::default() - .bg(Color::DarkGray) - .add_modifier(Modifier::BOLD), - ) - .highlight_symbol(">> "); - - f.render_stateful_widget(list, chunks[1], &mut app.query_results_state); - } - } - QueryMode::Reflect => { - let response_text = if app.query_response.is_empty() { - "No response yet. Enter a query and press Enter to get a reflection." - } else { - app.query_response.as_str() - }; - - let response = Paragraph::new(response_text) - .style(Style::default().fg(Color::White)) - .block(Block::default().borders(Borders::ALL).title("Reflect Response")) - .wrap(Wrap { trim: false }); - - f.render_widget(response, chunks[1]); - } - } -} - -fn render_help(f: &mut Frame, area: Rect) { - let help_text = vec![ - Line::from(Span::styled("Hindsight Explorer - Keyboard Shortcuts", Style::default().fg(BRAND_START).add_modifier(Modifier::BOLD))), - Line::from(""), - Line::from(vec![ - Span::styled("Navigation Flow", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)), - ]), - Line::from(" 1. Start by selecting a bank (Enter)"), - Line::from(" 2. View memories, entities, or documents for that bank"), - Line::from(" 3. Press / from any view to query (recall/reflect)"), - Line::from(""), - Line::from(vec![ - Span::styled("Basic Navigation", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)), - ]), - Line::from(" ↑/↓, j/k - Navigate up/down in lists"), - Line::from(" ←/→, h/l - Scroll text left/right in tables"), - Line::from(" Enter - Select item / view details"), - Line::from(" Esc - Go back / close detail view"), - Line::from(""), - Line::from(vec![ - Span::styled("Query View", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)), - ]), - Line::from(" / - Start or edit query (from any non-bank view)"), - Line::from(" m - Toggle mode (Recall ↔ Reflect)"), - Line::from(" b - Cycle budget (Low → Mid → High)"), - Line::from(" +/- - Adjust max tokens"), - Line::from(" Enter - Execute query"), - Line::from(""), - Line::from(vec![ - Span::styled("General", Style::default().fg(BRAND_END).add_modifier(Modifier::BOLD)), - ]), - Line::from(" R - Refresh current view"), - Line::from(" ? - Toggle this help screen"), - Line::from(" q - Quit"), - Line::from(""), - Line::from(Span::styled("Press ? to close help", Style::default().fg(Color::DarkGray))), - ]; - - let help = Paragraph::new(help_text) - .block(Block::default().borders(Borders::ALL).title("Help")) - .alignment(Alignment::Left); - - f.render_widget(help, area); -} - -fn run_app(terminal: &mut Terminal, mut app: App) -> Result<()> { - // Initial load - app.refresh()?; - - loop { - terminal.draw(|f| ui(f, &mut app))?; - - if event::poll(Duration::from_millis(100))? { - if let Event::Key(key) = event::read()? { - // Handle Ctrl+C to exit - if key.code == KeyCode::Char('c') && key.modifiers.contains(crossterm::event::KeyModifiers::CONTROL) { - return Ok(()); - } - - match app.input_mode { - InputMode::Normal => { - match key.code { - KeyCode::Char('q') => return Ok(()), - KeyCode::Char('?') => app.show_help = !app.show_help, - - // Navigation - KeyCode::Down | KeyCode::Char('j') => app.next_item(), - KeyCode::Up | KeyCode::Char('k') => app.previous_item(), - KeyCode::Left | KeyCode::Char('h') => app.scroll_left(), - KeyCode::Right | KeyCode::Char('l') => app.scroll_right(), - KeyCode::Enter => { - app.reset_horizontal_scroll(); - app.enter_view()?; - } - KeyCode::Esc => { - app.reset_horizontal_scroll(); - app.go_back(); - } - - // Refresh - KeyCode::Char('R') => { - app.refresh()?; - } - - // Query input - start query from any non-bank view - KeyCode::Char('/') => { - match &app.view { - View::Banks => { - app.error_message = "Select a bank first".to_string(); - } - View::Query(_) => { - app.input_mode = InputMode::Query; - } - _ => { - // Switch to Query view using current bank - if let Some(bank_id) = app.selected_bank_id.clone() { - app.switch_to_view(View::Query(bank_id))?; - app.input_mode = InputMode::Query; - } else { - app.error_message = "No bank selected".to_string(); - } - } - } - } - - // Query view controls - KeyCode::Char('m') => { - if matches!(app.view, View::Query(_)) { - app.toggle_query_mode(); - } - } - KeyCode::Char('b') => { - if matches!(app.view, View::Query(_)) { - app.cycle_budget(); - } - } - KeyCode::Char('+') | KeyCode::Char('=') => { - if matches!(app.view, View::Query(_)) { - app.adjust_max_tokens(true); - } - } - KeyCode::Char('-') => { - if matches!(app.view, View::Query(_)) { - app.adjust_max_tokens(false); - } - } - - // Delete document - KeyCode::Delete => { - if matches!(app.view, View::Documents(_)) { - app.delete_selected_document()?; - } - } - - // Pagination for memories - KeyCode::Char('n') => { - if matches!(app.view, View::Memories(_)) { - app.load_more_memories()?; - } - } - KeyCode::Char('p') => { - if matches!(app.view, View::Memories(_)) { - app.load_prev_memories()?; - } - } - - _ => {} - } - } - InputMode::Query => { - match key.code { - KeyCode::Enter => { - if matches!(app.view, View::Query(_)) { - app.execute_query(); - } - } - KeyCode::Esc => { - app.input_mode = InputMode::Normal; - } - KeyCode::Char(c) => { - if matches!(app.view, View::Query(_)) { - app.query_text.push(c); - } - } - KeyCode::Backspace => { - if matches!(app.view, View::Query(_)) { - app.query_text.pop(); - } - } - _ => {} - } - } - } - } - } - - // Check for query results from background thread - app.check_query_result(); - - // Auto-refresh check - app.do_auto_refresh()?; - } -} - -pub fn run(client: &ApiClient) -> Result<()> { - // Setup terminal - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - // Create app and run it - let app = App::new(client.clone()); - let res = run_app(&mut terminal, app); - - // Restore terminal - disable_raw_mode()?; - execute!( - terminal.backend_mut(), - LeaveAlternateScreen, - DisableMouseCapture - )?; - terminal.show_cursor()?; - - if let Err(err) = res { - println!("Error: {:?}", err); - } - - Ok(()) -} diff --git a/hindsight-cli/src/commands/memory.rs b/hindsight-cli/src/commands/memory.rs deleted file mode 100644 index 600a3295..00000000 --- a/hindsight-cli/src/commands/memory.rs +++ /dev/null @@ -1,404 +0,0 @@ -use anyhow::{Context, Result}; -use std::fs; -use std::path::PathBuf; -use walkdir::WalkDir; - -use crate::api::{ApiClient, RecallRequest, ReflectRequest, MemoryItem, RetainRequest}; -use crate::config; -use crate::output::{self, OutputFormat}; -use crate::ui; - -// Import types from generated client -use hindsight_client::types::{Budget, ChunkIncludeOptions, IncludeOptions}; - -// Helper function to parse budget string to Budget enum -fn parse_budget(budget: &str) -> Budget { - match budget.to_lowercase().as_str() { - "low" => Budget::Low, - "high" => Budget::High, - _ => Budget::Mid, // Default to mid - } -} - -pub fn recall( - client: &ApiClient, - agent_id: &str, - query: String, - fact_type: Vec, - budget: String, - max_tokens: i64, - trace: bool, - include_chunks: bool, - chunk_max_tokens: i64, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Recalling memories...")) - } else { - None - }; - - // Build include options if chunks are requested - let include = if include_chunks { - Some(IncludeOptions { - chunks: Some(ChunkIncludeOptions { - max_tokens: chunk_max_tokens, - }), - entities: None, - }) - } else { - None - }; - - let request = RecallRequest { - query, - types: if fact_type.is_empty() { None } else { Some(fact_type) }, - budget: Some(parse_budget(&budget)), - max_tokens, - trace, - query_timestamp: None, - include, - }; - - let response = client.recall(agent_id, &request, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(result) => { - if output_format == OutputFormat::Pretty { - ui::print_search_results(&result, trace, include_chunks); - } else { - output::print_output(&result, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} - -pub fn reflect( - client: &ApiClient, - agent_id: &str, - query: String, - budget: String, - context: Option, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Reflecting...")) - } else { - None - }; - - let request = ReflectRequest { - query, - budget: Some(parse_budget(&budget)), - context, - include: None, - }; - - let response = client.reflect(agent_id, &request, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(result) => { - if output_format == OutputFormat::Pretty { - ui::print_think_response(&result); - } else { - output::print_output(&result, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} - -pub fn retain( - client: &ApiClient, - agent_id: &str, - content: String, - doc_id: Option, - context: Option, - r#async: bool, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let doc_id = doc_id.unwrap_or_else(config::generate_doc_id); - - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Retaining memory...")) - } else { - None - }; - - let item = MemoryItem { - content: content.clone(), - context, - metadata: None, - timestamp: None, - document_id: Some(doc_id.clone()), - }; - - let request = RetainRequest { - items: vec![item], - async_: r#async, - }; - - let response = client.retain(agent_id, &request, r#async, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(result) => { - if output_format == OutputFormat::Pretty { - ui::print_success(&format!( - "Memory retained successfully (document: {})", - doc_id - )); - if result.is_async { - println!(" Status: queued for background processing"); - println!(" Items: {}", result.items_count); - } else { - println!(" Stored count: {}", result.items_count); - } - } else { - output::print_output(&result, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} - -pub fn retain_files( - client: &ApiClient, - agent_id: &str, - path: PathBuf, - recursive: bool, - context: Option, - r#async: bool, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - if !path.exists() { - anyhow::bail!("Path does not exist: {}", path.display()); - } - - let mut files = Vec::new(); - - if path.is_file() { - files.push(path); - } else if path.is_dir() { - if recursive { - for entry in WalkDir::new(&path) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()) - { - let path = entry.path(); - if let Some(ext) = path.extension() { - if ext == "txt" || ext == "md" { - files.push(path.to_path_buf()); - } - } - } - } else { - for entry in fs::read_dir(&path)? { - let entry = entry?; - let path = entry.path(); - if path.is_file() { - if let Some(ext) = path.extension() { - if ext == "txt" || ext == "md" { - files.push(path); - } - } - } - } - } - } - - if files.is_empty() { - ui::print_warning("No .txt or .md files found"); - return Ok(()); - } - - ui::print_info(&format!("Found {} files to import", files.len())); - - let pb = ui::create_progress_bar(files.len() as u64, "Processing files"); - - let mut items = Vec::new(); - - for file_path in &files { - let content = fs::read_to_string(file_path) - .with_context(|| format!("Failed to read file: {}", file_path.display()))?; - - let doc_id = file_path - .file_stem() - .and_then(|s| s.to_str()) - .map(|s| s.to_string()) - .unwrap_or_else(config::generate_doc_id); - - items.push(MemoryItem { - content, - context: context.clone(), - metadata: None, - timestamp: None, - document_id: Some(doc_id), - }); - - pb.inc(1); - } - - pb.finish_with_message("Files processed"); - - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Retaining memories...")) - } else { - None - }; - - let request = RetainRequest { - items, - async_: r#async, - }; - - let response = client.retain(agent_id, &request, r#async, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(result) => { - if output_format == OutputFormat::Pretty { - ui::print_success("Files retained successfully"); - if result.is_async { - println!(" Status: queued for background processing"); - println!(" Items: {}", result.items_count); - } else { - println!(" Total units created: {}", result.items_count); - } - } else { - output::print_output(&result, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} - -pub fn delete( - client: &ApiClient, - agent_id: &str, - unit_id: &str, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Deleting memory unit...")) - } else { - None - }; - - let response = client.delete_memory(agent_id, unit_id, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(result) => { - if output_format == OutputFormat::Pretty { - if result.success { - ui::print_success("Memory unit deleted successfully"); - } else { - ui::print_error("Failed to delete memory unit"); - } - } else { - output::print_output(&result, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} - -pub fn clear( - client: &ApiClient, - agent_id: &str, - fact_type: Option, - yes: bool, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - // Confirmation prompt unless -y flag is used - if !yes && output_format == OutputFormat::Pretty { - let message = if let Some(ft) = &fact_type { - format!( - "Are you sure you want to clear all '{}' memories for bank '{}'? This cannot be undone.", - ft, agent_id - ) - } else { - format!( - "Are you sure you want to clear ALL memories for bank '{}'? This cannot be undone.", - agent_id - ) - }; - - let confirmed = ui::prompt_confirmation(&message)?; - - if !confirmed { - ui::print_info("Operation cancelled"); - return Ok(()); - } - } - - let spinner_msg = if let Some(ft) = &fact_type { - format!("Clearing {} memories...", ft) - } else { - "Clearing all memories...".to_string() - }; - - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner(&spinner_msg)) - } else { - None - }; - - let response = client.clear_memories(agent_id, fact_type.as_deref(), verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(result) => { - if output_format == OutputFormat::Pretty { - if result.success { - let msg = if fact_type.is_some() { - "Memories cleared successfully" - } else { - "All memories cleared successfully" - }; - ui::print_success(msg); - } else { - ui::print_error("Failed to clear memories"); - } - } else { - output::print_output(&result, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} diff --git a/hindsight-cli/src/commands/mod.rs b/hindsight-cli/src/commands/mod.rs deleted file mode 100644 index 2f548caa..00000000 --- a/hindsight-cli/src/commands/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod bank; -pub mod memory; -pub mod document; -pub mod entity; -pub mod operation; -pub mod explore; diff --git a/hindsight-cli/src/commands/operation.rs b/hindsight-cli/src/commands/operation.rs deleted file mode 100644 index 241a6c02..00000000 --- a/hindsight-cli/src/commands/operation.rs +++ /dev/null @@ -1,84 +0,0 @@ -use anyhow::Result; -use crate::api::ApiClient; -use crate::output::{self, OutputFormat}; -use crate::ui; - -pub fn list( - client: &ApiClient, - agent_id: &str, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Fetching operations...")) - } else { - None - }; - - let response = client.list_operations(agent_id, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(ops_response) => { - if output_format == OutputFormat::Pretty { - if ops_response.operations.is_empty() { - ui::print_info("No operations found"); - } else { - ui::print_info(&format!("Found {} operation(s)", ops_response.operations.len())); - for op in &ops_response.operations { - println!("\n Operation ID: {}", op.id); - println!(" Type: {}", op.task_type); - println!(" Status: {}", op.status); - println!(" Items: {}", op.items_count); - if let Some(doc_id) = &op.document_id { - println!(" Document ID: {}", doc_id); - } - } - } - } else { - output::print_output(&ops_response, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} - -pub fn cancel( - client: &ApiClient, - agent_id: &str, - operation_id: &str, - verbose: bool, - output_format: OutputFormat, -) -> Result<()> { - let spinner = if output_format == OutputFormat::Pretty { - Some(ui::create_spinner("Cancelling operation...")) - } else { - None - }; - - let response = client.cancel_operation(agent_id, operation_id, verbose); - - if let Some(mut sp) = spinner { - sp.finish(); - } - - match response { - Ok(result) => { - if output_format == OutputFormat::Pretty { - if result.success { - ui::print_success("Operation cancelled successfully"); - } else { - ui::print_error("Failed to cancel operation"); - } - } else { - output::print_output(&result, output_format)?; - } - Ok(()) - } - Err(e) => Err(e) - } -} diff --git a/hindsight-cli/src/config.rs b/hindsight-cli/src/config.rs deleted file mode 100644 index 2cf4e5ae..00000000 --- a/hindsight-cli/src/config.rs +++ /dev/null @@ -1,176 +0,0 @@ -use anyhow::{Context, Result}; -use std::env; -use std::fs; -use std::io::{self, Write}; -use std::path::PathBuf; - -const DEFAULT_API_URL: &str = "http://localhost:8888"; -const CONFIG_FILE_NAME: &str = "config"; -const CONFIG_DIR_NAME: &str = ".hindsight"; - -pub struct Config { - pub api_url: String, - pub api_key: Option, - pub source: ConfigSource, -} - -#[derive(Debug, Clone, PartialEq)] -pub enum ConfigSource { - LocalFile, - Environment, - Default, -} - -impl std::fmt::Display for ConfigSource { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - ConfigSource::LocalFile => write!(f, "config file"), - ConfigSource::Environment => write!(f, "environment variable"), - ConfigSource::Default => write!(f, "default"), - } - } -} - -impl Config { - /// Load configuration with the following priority: - /// 1. Environment variable (HINDSIGHT_API_URL, HINDSIGHT_API_KEY) - highest priority, for overrides - /// 2. Local config file (~/.hindsight/config.toml) - /// 3. Default (http://localhost:8888) - pub fn load() -> Result { - // Load API key from environment (highest priority) - let env_api_key = env::var("HINDSIGHT_API_KEY").ok(); - - // 1. Environment variable takes highest priority (for overrides) - if let Ok(api_url) = env::var("HINDSIGHT_API_URL") { - return Self::validate_and_create(api_url, env_api_key, ConfigSource::Environment); - } - - // 2. Try local config file - if let Some((api_url, file_api_key)) = Self::load_from_file()? { - // Environment api_key takes precedence over file api_key - let api_key = env_api_key.or(file_api_key); - return Self::validate_and_create(api_url, api_key, ConfigSource::LocalFile); - } - - // 3. Fall back to default - Self::validate_and_create(DEFAULT_API_URL.to_string(), env_api_key, ConfigSource::Default) - } - - /// Legacy method for backwards compatibility - pub fn from_env() -> Result { - Self::load() - } - - fn validate_and_create(api_url: String, api_key: Option, source: ConfigSource) -> Result { - if !api_url.starts_with("http://") && !api_url.starts_with("https://") { - anyhow::bail!( - "Invalid API URL: {}. Must start with http:// or https://", - api_url - ); - } - Ok(Config { api_url, api_key, source }) - } - - fn config_dir() -> Option { - dirs::home_dir().map(|home| home.join(CONFIG_DIR_NAME)) - } - - fn config_file_path() -> Option { - Self::config_dir().map(|dir| dir.join(CONFIG_FILE_NAME)) - } - - fn load_from_file() -> Result)>> { - let config_path = match Self::config_file_path() { - Some(path) => path, - None => return Ok(None), - }; - - if !config_path.exists() { - return Ok(None); - } - - let content = fs::read_to_string(&config_path) - .with_context(|| format!("Failed to read config file: {}", config_path.display()))?; - - let mut api_url: Option = None; - let mut api_key: Option = None; - - // Simple TOML parsing for api_url and api_key - for line in content.lines() { - let line = line.trim(); - if line.starts_with("api_url") { - if let Some(value) = line.split('=').nth(1) { - let value = value.trim().trim_matches('"').trim_matches('\''); - if !value.is_empty() { - api_url = Some(value.to_string()); - } - } - } else if line.starts_with("api_key") { - if let Some(value) = line.split('=').nth(1) { - let value = value.trim().trim_matches('"').trim_matches('\''); - if !value.is_empty() { - api_key = Some(value.to_string()); - } - } - } - } - - match api_url { - Some(url) => Ok(Some((url, api_key))), - None => Ok(None), - } - } - - pub fn save_api_url(api_url: &str) -> Result { - Self::save_config(api_url, None) - } - - pub fn save_config(api_url: &str, api_key: Option<&str>) -> Result { - let config_dir = Self::config_dir() - .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; - - // Create config directory if it doesn't exist - if !config_dir.exists() { - fs::create_dir_all(&config_dir) - .with_context(|| format!("Failed to create config directory: {}", config_dir.display()))?; - } - - let config_path = config_dir.join(CONFIG_FILE_NAME); - let mut content = format!("api_url = \"{}\"\n", api_url); - if let Some(key) = api_key { - content.push_str(&format!("api_key = \"{}\"\n", key)); - } - - fs::write(&config_path, content) - .with_context(|| format!("Failed to write config file: {}", config_path.display()))?; - - Ok(config_path) - } - - pub fn api_url(&self) -> &str { - &self.api_url - } -} - -/// Prompt user for API URL interactively -pub fn prompt_api_url(current_url: Option<&str>) -> Result { - let default = current_url.unwrap_or(DEFAULT_API_URL); - - print!("Enter API URL [{}]: ", default); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - let input = input.trim(); - if input.is_empty() { - Ok(default.to_string()) - } else { - Ok(input.to_string()) - } -} - -pub fn generate_doc_id() -> String { - let now = chrono::Local::now(); - format!("cli_put_{}", now.format("%Y%m%d_%H%M%S")) -} diff --git a/hindsight-cli/src/errors.rs b/hindsight-cli/src/errors.rs deleted file mode 100644 index 9a830b6c..00000000 --- a/hindsight-cli/src/errors.rs +++ /dev/null @@ -1,185 +0,0 @@ -use colored::*; - -pub fn handle_api_error(err: anyhow::Error, api_url: &str) -> ! { - eprintln!("{}", format_error_message(&err, api_url)); - std::process::exit(1); -} - -fn format_error_message(err: &anyhow::Error, api_url: &str) -> String { - let err_str = err.to_string(); - - // Connection refused - if err_str.contains("Connection refused") || err_str.contains("tcp connect error") || err_str.contains("error sending request") { - return format!( - "{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n • {}\n\n{}\n {}", - "✗".bright_red().bold(), - "Cannot connect to Hindsight API".bright_red().bold(), - "API URL:".bright_yellow(), - api_url.bright_white(), - "Possible causes:".bright_yellow(), - "The Hindsight API server is not running".bright_white(), - format!("The server is running on a different address than {}", api_url).bright_white(), - "A firewall is blocking the connection".bright_white(), - "Try:".bright_green(), - "Start the Hindsight API server and ensure it's accessible".bright_white() - ); - } - - // Timeout - if err_str.contains("timeout") || err_str.contains("Timeout") { - return format!( - "{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n • {}\n • {}", - "✗".bright_red().bold(), - "Request timed out".bright_red().bold(), - "API URL:".bright_yellow(), - api_url.bright_white(), - "Possible causes:".bright_yellow(), - "The API server is slow to respond".bright_white(), - "Network latency is too high".bright_white(), - "Try:".bright_green(), - "Check if the API server is healthy".bright_white(), - "Try again with a better network connection".bright_white() - ); - } - - // DNS/Host resolution - if err_str.contains("dns") || err_str.contains("DNS") || err_str.contains("failed to lookup") { - return format!( - "{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n {}", - "✗".bright_red().bold(), - "Cannot resolve API hostname".bright_red().bold(), - "API URL:".bright_yellow(), - api_url.bright_white(), - "Possible causes:".bright_yellow(), - "The hostname in the API URL is incorrect".bright_white(), - "DNS server is not responding".bright_white(), - "Try:".bright_green(), - "Check the HINDSIGHT_API_URL environment variable".bright_white() - ); - } - - // 404 Not Found - if err_str.contains("404") { - return format!( - "{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n {}", - "✗".bright_red().bold(), - "API endpoint not found (404)".bright_red().bold(), - "API URL:".bright_yellow(), - api_url.bright_white(), - "Possible causes:".bright_yellow(), - "The API endpoint path has changed".bright_white(), - "You're using an incompatible API version".bright_white(), - "Try:".bright_green(), - "Check that you're using the correct Hindsight API version".bright_white() - ); - } - - // 401/403 Authentication - if err_str.contains("401") || err_str.contains("403") { - return format!( - "{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n {}", - "✗".bright_red().bold(), - "Authentication failed".bright_red().bold(), - "API URL:".bright_yellow(), - api_url.bright_white(), - "Possible causes:".bright_yellow(), - "API requires authentication".bright_white(), - "Invalid or missing credentials".bright_white(), - "Try:".bright_green(), - "Check if the API requires an API key or token".bright_white() - ); - } - - // 500 Server Error - if err_str.contains("500") || err_str.contains("502") || err_str.contains("503") { - return format!( - "{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n\n{}\n • {}\n • {}", - "✗".bright_red().bold(), - "API server error".bright_red().bold(), - "API URL:".bright_yellow(), - api_url.bright_white(), - "The server encountered an error:".bright_yellow(), - "Internal server error (500)".bright_white(), - "Service temporarily unavailable".bright_white(), - "Try:".bright_green(), - "Check the API server logs for details".bright_white(), - "Try again in a few moments".bright_white() - ); - } - - // Invalid URL - if err_str.contains("invalid URL") || err_str.contains("InvalidUri") { - return format!( - "{} {}\n\n{}\n {}\n\n{}\n {}\n\n{}\n {}", - "✗".bright_red().bold(), - "Invalid API URL".bright_red().bold(), - "API URL:".bright_yellow(), - api_url.bright_white(), - "The API URL format is invalid.".bright_yellow(), - "Ensure it starts with http:// or https://".bright_white(), - "Example:".bright_green(), - "export HINDSIGHT_API_URL=http://localhost:8888".bright_white() - ); - } - - // JSON parsing error - show actual response - if err_str.contains("Failed to parse") || err_str.contains("error decoding") { - // Extract the actual response if available - let response_hint = if err_str.contains("Response was:") { - let parts: Vec<&str> = err_str.split("Response was:").collect(); - if parts.len() > 1 { - format!("\n{}\n{}", "Actual response:".bright_yellow(), parts[1].trim().bright_white()) - } else { - String::new() - } - } else { - String::new() - }; - - return format!( - "{} {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n • {}{}\n\n{}\n • {}\n • {}", - "✗".bright_red().bold(), - "Invalid API response format".bright_red().bold(), - "API URL:".bright_yellow(), - api_url.bright_white(), - "Possible causes:".bright_yellow(), - "The API returned an unexpected response format".bright_white(), - "Version mismatch between CLI and API".bright_white(), - "The API endpoint doesn't exist or returned HTML instead of JSON".bright_white(), - response_hint, - "Try:".bright_green(), - "Run with --verbose flag to see the full request/response".bright_white(), - "Ensure you're using a compatible Hindsight API version".bright_white() - ); - } - - // Generic error with the full error message - format!( - "{} {}\n\n{}\n {}\n\n{}\n {}\n\n{}\n • {}\n • {}\n • {}", - "✗".bright_red().bold(), - "API request failed".bright_red().bold(), - "API URL:".bright_yellow(), - api_url.bright_white(), - "Error:".bright_yellow(), - err_str.bright_white(), - "Suggestions:".bright_green(), - "Check that HINDSIGHT_API_URL is set correctly".bright_white(), - "Ensure the Hindsight API server is running".bright_white(), - "Verify network connectivity to the API server".bright_white() - ) -} - -pub fn print_config_help() { - println!("\n{}", "Configuration:".bright_cyan().bold()); - println!(" Run the configure command to set the API URL:"); - println!(" {}", "hindsight configure".bright_white()); - println!(); - println!(" Or set it directly:"); - println!(" {}", "hindsight configure --api-url http://your-api:8888".bright_white()); - println!(); - println!(" {}", "Configuration priority:".bright_yellow()); - println!(" 1. Environment variable (HINDSIGHT_API_URL) - highest priority"); - println!(" 2. Config file (~/.hindsight/config)"); - println!(" 3. Default (http://localhost:8888)"); - println!(); -} diff --git a/hindsight-cli/src/logo.ansi b/hindsight-cli/src/logo.ansi deleted file mode 100644 index e9afd101..00000000 --- a/hindsight-cli/src/logo.ansi +++ /dev/null @@ -1,5 +0,0 @@ - ▄▄ ▄▄ - ▄ ▀▄ ▄▄▄ ▄▀ ▄ -▀▀▄▄▄▄▀▀▀▄▄▄▄▀▀ - ▄▄▄ ▄ ▄▄▄ - ▄▀ ▀▀▄▄▄▀ ▀▄ diff --git a/hindsight-cli/src/main.rs b/hindsight-cli/src/main.rs deleted file mode 100644 index 1eba251a..00000000 --- a/hindsight-cli/src/main.rs +++ /dev/null @@ -1,604 +0,0 @@ -mod api; -mod commands; -mod config; -mod errors; -mod output; -mod ui; -mod utils; - -use anyhow::Result; -use api::ApiClient; -use clap::{Parser, Subcommand, ValueEnum}; -use config::Config; -use output::OutputFormat; -use std::path::PathBuf; - -#[derive(Debug, Clone, Copy, ValueEnum)] -enum Format { - Pretty, - Json, - Yaml, -} - -impl From for OutputFormat { - fn from(f: Format) -> Self { - match f { - Format::Pretty => OutputFormat::Pretty, - Format::Json => OutputFormat::Json, - Format::Yaml => OutputFormat::Yaml, - } - } -} - -#[derive(Parser)] -#[command(name = "hindsight")] -#[command(about = "Hindsight CLI - Semantic memory system", long_about = None)] -#[command(version)] -#[command(before_help = get_before_help())] -#[command(after_help = get_after_help())] -struct Cli { - /// Output format (pretty, json, yaml) - #[arg(short = 'o', long, global = true, default_value = "pretty")] - output: Format, - - /// Show verbose output including full requests and responses - #[arg(short = 'v', long, global = true)] - verbose: bool, - - #[command(subcommand)] - command: Commands, -} - -fn get_after_help() -> String { - let config = config::Config::load().ok(); - let (api_url, source) = match &config { - Some(c) => (c.api_url.as_str(), c.source.to_string()), - None => ("http://localhost:8888", "default".to_string()), - }; - format!( - "Current API URL: {} (from {})\n\nRun 'hindsight configure' to change the API URL.", - api_url, source - ) -} - -fn get_before_help() -> &'static str { - ui::get_logo() -} - -#[derive(Subcommand)] -enum Commands { - /// Manage banks (list, profile, stats) - #[command(subcommand)] - Bank(BankCommands), - - /// Manage memories (recall, reflect, retain, delete) - #[command(subcommand)] - Memory(MemoryCommands), - - /// Manage documents (list, get, delete) - #[command(subcommand)] - Document(DocumentCommands), - - /// Manage entities (list, get, regenerate) - #[command(subcommand)] - Entity(EntityCommands), - - /// Manage async operations (list, cancel) - #[command(subcommand)] - Operation(OperationCommands), - - /// Interactive TUI explorer (k9s-style) for navigating banks, memories, entities, and performing recall/reflect - #[command(alias = "tui")] - Explore, - - /// Launch the web-based control plane UI - Ui, - - /// Configure the CLI (API URL, API key, etc.) - #[command(after_help = "Configuration priority:\n 1. Environment variables (HINDSIGHT_API_URL, HINDSIGHT_API_KEY) - highest priority\n 2. Config file (~/.hindsight/config)\n 3. Default (http://localhost:8888)")] - Configure { - /// API URL to connect to (interactive prompt if not provided) - #[arg(long)] - api_url: Option, - /// API key for authentication (sent as Bearer token) - #[arg(long)] - api_key: Option, - }, -} - -#[derive(Subcommand)] -enum BankCommands { - /// List all banks - List, - - /// Get bank disposition and background - Disposition { - /// Bank ID - bank_id: String, - }, - - /// Get memory statistics for a bank - Stats { - /// Bank ID - bank_id: String, - }, - - /// Set bank name - Name { - /// Bank ID - bank_id: String, - - /// Bank name - name: String, - }, - - /// Set or merge bank background - Background { - /// Bank ID - bank_id: String, - - /// Background content - content: String, - - /// Skip automatic disposition inference - #[arg(long)] - no_update_disposition: bool, - }, - - /// Delete a bank and all its data - Delete { - /// Bank ID - bank_id: String, - - /// Skip confirmation prompt - #[arg(short = 'y', long)] - yes: bool, - }, -} - -#[derive(Subcommand)] -enum MemoryCommands { - /// Recall memories using semantic search - Recall { - /// Bank ID - bank_id: String, - - /// Search query - query: String, - - /// Fact types to search (world, experience, opinion) - #[arg(short = 't', long, value_delimiter = ',', default_values = &["world", "experience", "opinion"])] - fact_type: Vec, - - /// Thinking budget (low, mid, high) - #[arg(short = 'b', long, default_value = "mid")] - budget: String, - - /// Maximum tokens for results - #[arg(long, default_value = "4096")] - max_tokens: i64, - - /// Show trace information - #[arg(long)] - trace: bool, - - /// Include chunks in results - #[arg(long)] - include_chunks: bool, - - /// Maximum tokens for chunks (only used with --include-chunks) - #[arg(long, default_value = "8192")] - chunk_max_tokens: i64, - }, - - /// Generate answers using bank identity (reflect/reasoning) - Reflect { - /// Bank ID - bank_id: String, - - /// Query to reflect on - query: String, - - /// Thinking budget (low, mid, high) - #[arg(short = 'b', long, default_value = "mid")] - budget: String, - - /// Additional context - #[arg(short = 'c', long)] - context: Option, - }, - - /// Store (retain) a single memory - Retain { - /// Bank ID - bank_id: String, - - /// Memory content - content: String, - - /// Document ID (auto-generated if not provided) - #[arg(short = 'd', long)] - doc_id: Option, - - /// Context for the memory - #[arg(short = 'c', long)] - context: Option, - - /// Queue for background processing - #[arg(long)] - r#async: bool, - }, - - /// Bulk import memories from files (retain) - RetainFiles { - /// Bank ID - bank_id: String, - - /// Path to file or directory - path: PathBuf, - - /// Search directories recursively - #[arg(short = 'r', long, default_value = "true")] - recursive: bool, - - /// Context for all memories - #[arg(short = 'c', long)] - context: Option, - - /// Queue for background processing - #[arg(long)] - r#async: bool, - }, - - /// Delete a memory unit - Delete { - /// Bank ID - bank_id: String, - - /// Memory unit ID - unit_id: String, - }, - - /// Clear all memories for a bank - Clear { - /// Bank ID - bank_id: String, - - /// Fact type to clear (world, agent, opinion). If not specified, clears all types. - #[arg(short = 't', long, value_parser = ["world", "agent", "opinion"])] - fact_type: Option, - - /// Skip confirmation prompt - #[arg(short = 'y', long)] - yes: bool, - }, -} - -#[derive(Subcommand)] -enum DocumentCommands { - /// List documents for a bank - List { - /// Bank ID - bank_id: String, - - /// Search query to filter documents - #[arg(short = 'q', long)] - query: Option, - - /// Maximum number of results - #[arg(short = 'l', long, default_value = "100")] - limit: i32, - - /// Offset for pagination - #[arg(short = 's', long, default_value = "0")] - offset: i32, - }, - - /// Get a specific document by ID - Get { - /// Bank ID - bank_id: String, - - /// Document ID - document_id: String, - }, - - /// Delete a document and all its memory units - Delete { - /// Bank ID - bank_id: String, - - /// Document ID - document_id: String, - }, -} - -#[derive(Subcommand)] -enum EntityCommands { - /// List entities for a bank - List { - /// Bank ID - bank_id: String, - - /// Maximum number of results - #[arg(short = 'l', long, default_value = "100")] - limit: i64, - }, - - /// Get detailed information about an entity - Get { - /// Bank ID - bank_id: String, - - /// Entity ID - entity_id: String, - }, - - /// Regenerate observations for an entity - Regenerate { - /// Bank ID - bank_id: String, - - /// Entity ID - entity_id: String, - }, -} - -#[derive(Subcommand)] -enum OperationCommands { - /// List async operations for a bank - List { - /// Bank ID - bank_id: String, - }, - - /// Cancel a pending async operation - Cancel { - /// Bank ID - bank_id: String, - - /// Operation ID - operation_id: String, - }, -} - -fn main() { - if let Err(_) = run() { - std::process::exit(1); - } -} - -fn run() -> Result<()> { - let cli = Cli::parse(); - - let output_format: OutputFormat = cli.output.into(); - let verbose = cli.verbose; - - // Handle configure command before loading full config (it doesn't need API client) - if let Commands::Configure { api_url, api_key } = cli.command { - return handle_configure(api_url, api_key, output_format); - } - - // Handle ui command - needs config but not API client - if let Commands::Ui = cli.command { - return handle_ui(output_format); - } - - // Load configuration - let config = Config::from_env().unwrap_or_else(|e| { - ui::print_error(&format!("Configuration error: {}", e)); - errors::print_config_help(); - std::process::exit(1); - }); - - let api_url = config.api_url().to_string(); - let api_key = config.api_key.clone(); - - // Create API client - let client = ApiClient::new(api_url.clone(), api_key).unwrap_or_else(|e| { - errors::handle_api_error(e, &api_url); - }); - - // Execute command and handle errors - let result: Result<()> = match cli.command { - Commands::Configure { .. } => unreachable!(), // Handled above - Commands::Ui => unreachable!(), // Handled above - Commands::Explore => commands::explore::run(&client), - Commands::Bank(bank_cmd) => match bank_cmd { - BankCommands::List => commands::bank::list(&client, verbose, output_format), - BankCommands::Disposition { bank_id } => commands::bank::disposition(&client, &bank_id, verbose, output_format), - BankCommands::Stats { bank_id } => commands::bank::stats(&client, &bank_id, verbose, output_format), - BankCommands::Name { bank_id, name } => commands::bank::update_name(&client, &bank_id, &name, verbose, output_format), - BankCommands::Background { bank_id, content, no_update_disposition } => { - commands::bank::update_background(&client, &bank_id, &content, no_update_disposition, verbose, output_format) - } - BankCommands::Delete { bank_id, yes } => { - commands::bank::delete(&client, &bank_id, yes, verbose, output_format) - } - }, - - Commands::Memory(memory_cmd) => match memory_cmd { - MemoryCommands::Recall { bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens } => { - commands::memory::recall(&client, &bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, verbose, output_format) - } - MemoryCommands::Reflect { bank_id, query, budget, context } => { - commands::memory::reflect(&client, &bank_id, query, budget, context, verbose, output_format) - } - MemoryCommands::Retain { bank_id, content, doc_id, context, r#async } => { - commands::memory::retain(&client, &bank_id, content, doc_id, context, r#async, verbose, output_format) - } - MemoryCommands::RetainFiles { bank_id, path, recursive, context, r#async } => { - commands::memory::retain_files(&client, &bank_id, path, recursive, context, r#async, verbose, output_format) - } - MemoryCommands::Delete { bank_id, unit_id } => { - commands::memory::delete(&client, &bank_id, &unit_id, verbose, output_format) - } - MemoryCommands::Clear { bank_id, fact_type, yes } => { - commands::memory::clear(&client, &bank_id, fact_type, yes, verbose, output_format) - } - }, - - Commands::Document(doc_cmd) => match doc_cmd { - DocumentCommands::List { bank_id, query, limit, offset } => { - commands::document::list(&client, &bank_id, query, limit, offset, verbose, output_format) - } - DocumentCommands::Get { bank_id, document_id } => { - commands::document::get(&client, &bank_id, &document_id, verbose, output_format) - } - DocumentCommands::Delete { bank_id, document_id } => { - commands::document::delete(&client, &bank_id, &document_id, verbose, output_format) - } - }, - - Commands::Entity(entity_cmd) => match entity_cmd { - EntityCommands::List { bank_id, limit } => { - commands::entity::list(&client, &bank_id, limit, verbose, output_format) - } - EntityCommands::Get { bank_id, entity_id } => { - commands::entity::get(&client, &bank_id, &entity_id, verbose, output_format) - } - EntityCommands::Regenerate { bank_id, entity_id } => { - commands::entity::regenerate(&client, &bank_id, &entity_id, verbose, output_format) - } - }, - - Commands::Operation(op_cmd) => match op_cmd { - OperationCommands::List { bank_id } => { - commands::operation::list(&client, &bank_id, verbose, output_format) - } - OperationCommands::Cancel { bank_id, operation_id } => { - commands::operation::cancel(&client, &bank_id, &operation_id, verbose, output_format) - } - }, - }; - - // Handle API errors with nice messages - if let Err(e) = result { - errors::handle_api_error(e, &api_url); - } - - Ok(()) -} - -fn handle_configure(api_url: Option, api_key: Option, output_format: OutputFormat) -> Result<()> { - // Load current config to show current state - let current_config = Config::load().ok(); - - if output_format == OutputFormat::Pretty { - ui::print_info("Hindsight CLI Configuration"); - println!(); - - // Show current configuration - if let Some(ref config) = current_config { - println!(" Current API URL: {}", config.api_url); - if let Some(ref key) = config.api_key { - // Mask the API key for display - let masked = if key.len() > 8 { - format!("{}...{}", &key[..4], &key[key.len()-4..]) - } else { - "****".to_string() - }; - println!(" Current API Key: {}", masked); - } - println!(" Source: {}", config.source); - println!(); - } - } - - // Get the new API URL (from argument or prompt) - let new_api_url = match api_url { - Some(url) => url, - None => { - // Interactive prompt - let current = current_config.as_ref().map(|c| c.api_url.as_str()); - config::prompt_api_url(current)? - } - }; - - // Validate the URL - if !new_api_url.starts_with("http://") && !new_api_url.starts_with("https://") { - ui::print_error(&format!( - "Invalid API URL: {}. Must start with http:// or https://", - new_api_url - )); - return Ok(()); - } - - // Use provided api_key, or keep existing one if not provided - let new_api_key = api_key.or_else(|| current_config.as_ref().and_then(|c| c.api_key.clone())); - - // Save to config file - let config_path = Config::save_config(&new_api_url, new_api_key.as_deref())?; - - if output_format == OutputFormat::Pretty { - ui::print_success(&format!("Configuration saved to {}", config_path.display())); - println!(); - println!(" API URL: {}", new_api_url); - if let Some(ref key) = new_api_key { - let masked = if key.len() > 8 { - format!("{}...{}", &key[..4], &key[key.len()-4..]) - } else { - "****".to_string() - }; - println!(" API Key: {}", masked); - } - println!(); - println!("Note: Environment variables HINDSIGHT_API_URL and HINDSIGHT_API_KEY will override these settings."); - } else { - let result = serde_json::json!({ - "api_url": new_api_url, - "api_key_set": new_api_key.is_some(), - "config_path": config_path.display().to_string(), - }); - output::print_output(&result, output_format)?; - } - - Ok(()) -} - -fn handle_ui(output_format: OutputFormat) -> Result<()> { - use std::process::Command; - - // Load configuration to get the API URL - let config = Config::load().unwrap_or_else(|e| { - ui::print_error(&format!("Configuration error: {}", e)); - errors::print_config_help(); - std::process::exit(1); - }); - - let api_url = config.api_url(); - - if output_format == OutputFormat::Pretty { - ui::print_info("Launching Hindsight Control Plane UI..."); - println!(); - println!(" API URL: {}", api_url); - println!(); - } - - // Run npx @vectorize-io/hindsight-control-plane --api-url {api_url} - let status = Command::new("npx") - .arg("@vectorize-io/hindsight-control-plane") - .arg("--api-url") - .arg(api_url) - .status(); - - match status { - Ok(exit_status) => { - if !exit_status.success() { - if let Some(code) = exit_status.code() { - std::process::exit(code); - } else { - std::process::exit(1); - } - } - } - Err(e) => { - ui::print_error(&format!("Failed to launch control plane UI: {}", e)); - ui::print_info("Make sure you have Node.js and npm installed."); - ui::print_info("You can also install the control plane globally: npm install -g @vectorize-io/hindsight-control-plane"); - std::process::exit(1); - } - } - - Ok(()) -} diff --git a/hindsight-cli/src/output.rs b/hindsight-cli/src/output.rs deleted file mode 100644 index 0845f801..00000000 --- a/hindsight-cli/src/output.rs +++ /dev/null @@ -1,25 +0,0 @@ -use anyhow::Result; -use serde::Serialize; - -#[derive(Debug, Clone, Copy, PartialEq)] -pub enum OutputFormat { - Pretty, - Json, - Yaml, -} - -pub fn print_output(data: &T, format: OutputFormat) -> Result<()> { - match format { - OutputFormat::Json => { - println!("{}", serde_json::to_string_pretty(data)?); - } - OutputFormat::Yaml => { - println!("{}", serde_yaml::to_string(data)?); - } - OutputFormat::Pretty => { - // This should not be called - pretty printing is handled in ui.rs - unreachable!("Pretty format should be handled separately") - } - } - Ok(()) -} diff --git a/hindsight-cli/src/ui.rs b/hindsight-cli/src/ui.rs deleted file mode 100644 index 5fa43ae3..00000000 --- a/hindsight-cli/src/ui.rs +++ /dev/null @@ -1,352 +0,0 @@ -use crate::api::{BankProfileResponse, RecallResult, RecallResponse, ReflectResponse}; -use colored::*; -use hindsight_client::types::ChunkData; -use indicatif::{ProgressBar, ProgressStyle}; -use std::io::{self, Write}; - -/// The logo as ANSI-colored text, generated by test-logo.py -const LOGO: &str = include_str!("logo.ansi"); - -// Gradient colors: #0074d9 -> #009296 -const GRADIENT_START: (u8, u8, u8) = (0, 116, 217); // #0074d9 -const GRADIENT_END: (u8, u8, u8) = (0, 146, 150); // #009296 - -/// Interpolate between two RGB colors -fn interpolate_color(start: (u8, u8, u8), end: (u8, u8, u8), t: f32) -> (u8, u8, u8) { - ( - (start.0 as f32 + (end.0 as f32 - start.0 as f32) * t) as u8, - (start.1 as f32 + (end.1 as f32 - start.1 as f32) * t) as u8, - (start.2 as f32 + (end.2 as f32 - start.2 as f32) * t) as u8, - ) -} - -/// Color text using gradient position (0.0 = start, 1.0 = end) -pub fn gradient(text: &str, t: f32) -> String { - let (r, g, b) = interpolate_color(GRADIENT_START, GRADIENT_END, t); - format!("\x1b[38;2;{};{};{}m{}\x1b[0m", r, g, b, text) -} - -/// Color text with gradient start color (#0074d9) -pub fn gradient_start(text: &str) -> String { - gradient(text, 0.0) -} - -/// Color text with gradient end color (#009296) -pub fn gradient_end(text: &str) -> String { - gradient(text, 1.0) -} - -/// Color text with gradient middle color -pub fn gradient_mid(text: &str) -> String { - gradient(text, 0.5) -} - -/// Apply gradient across entire text string -pub fn gradient_text(text: &str) -> String { - let chars: Vec = text.chars().collect(); - let len = chars.len(); - if len == 0 { - return String::new(); - } - let mut result = String::new(); - for (i, ch) in chars.iter().enumerate() { - if *ch == ' ' { - result.push(' '); - } else { - let t = i as f32 / (len - 1).max(1) as f32; - let (r, g, b) = interpolate_color(GRADIENT_START, GRADIENT_END, t); - result.push_str(&format!("\x1b[38;2;{};{};{}m{}", r, g, b, ch)); - } - } - result.push_str("\x1b[0m"); - result -} - -/// Dim/gray text -pub fn dim(text: &str) -> String { - format!("\x1b[38;2;128;128;128m{}\x1b[0m", text) -} - -pub fn get_logo() -> &'static str { - LOGO -} - -pub fn print_section_header(title: &str) { - println!(); - println!("{}", gradient_text(&format!("━━━ {} ━━━", title))); - println!(); -} - -pub fn print_fact(fact: &RecallResult, _show_activation: bool) { - let fact_type = fact.type_.as_deref().unwrap_or("unknown"); - - // Use gradient positions for different fact types - let type_t = match fact_type { - "world" => 0.0, - "agent" => 0.5, - "opinion" => 1.0, - _ => 0.5, - }; - - println!("{}", gradient(&format!("[{}]", fact_type.to_uppercase()), type_t)); - println!(" {}", fact.text); - - // Show context if available - if let Some(context) = &fact.context { - println!(" {} {}", dim("context:"), dim(context)); - } - - // Show temporal information - if let Some(occurred_start) = &fact.occurred_start { - if let Some(occurred_end) = &fact.occurred_end { - println!(" {} {} - {}", dim("date:"), dim(occurred_start), dim(occurred_end)); - } else { - println!(" {} {}", dim("date:"), dim(occurred_start)); - } - } - - // Show document ID if available - if let Some(document_id) = &fact.document_id { - println!(" {} {}", dim("document:"), dim(document_id)); - } - - println!(); -} - -pub fn print_chunk(chunk: &ChunkData) { - println!(" {}", gradient_mid("─── Source Chunk ───")); - - // Split text into lines and indent each line - for line in chunk.text.lines() { - println!(" {}", line); - } - - if chunk.truncated { - println!(" {}", gradient_end("[Truncated due to token limit]")); - } - - println!(" {} {} | {} {}", - dim("Chunk ID:"), - dim(&chunk.id), - dim("Index:"), - dim(&chunk.chunk_index.to_string()) - ); - - println!(); -} - -pub fn print_search_results(response: &RecallResponse, show_trace: bool, show_chunks: bool) { - let results = &response.results; - print_section_header(&format!("Search Results ({})", results.len())); - - if results.is_empty() { - println!(" {}", dim("No results found.")); - } else { - for (i, fact) in results.iter().enumerate() { - println!(" {}", dim(&format!("Result #{}", i + 1))); - print_fact(fact, true); - - // Show chunk if available and requested - if show_chunks { - if let Some(chunk_id) = &fact.chunk_id { - if let Some(chunks) = &response.chunks { - if let Some(chunk) = chunks.get(chunk_id) { - print_chunk(chunk); - } - } - } - } - } - } - - if show_trace { - if let Some(trace) = &response.trace { - print_trace_info(trace); - } - } -} - -pub fn print_think_response(response: &ReflectResponse) { - print_section_header("Reflection"); - - println!("{}", response.text); - println!(); - - if !response.based_on.is_empty() { - println!("{}", dim(&format!("Based on {} memory units", response.based_on.len()))); - } -} - -pub fn print_trace_info(trace: &serde_json::Map) { - print_section_header("Trace"); - - if let Some(time) = trace.get("total_time").and_then(|v| v.as_f64()) { - println!(" {} {}", dim("total time:"), gradient_start(&format!("{:.2}ms", time))); - } - - if let Some(count) = trace.get("activation_count").and_then(|v| v.as_i64()) { - println!(" {} {}", dim("activation count:"), gradient_end(&count.to_string())); - } - - println!(); -} - -pub fn print_success(message: &str) { - println!("{}", gradient_start(message)); -} - -pub fn print_error(message: &str) { - eprintln!("{} {}", "error:".bright_red().bold(), message.bright_red()); -} - -pub fn print_warning(message: &str) { - println!("{} {}", gradient_end("warning:"), message); -} - -pub fn print_info(message: &str) { - println!("{}", gradient_start(message)); -} - -/// Animated gradient spinner that shows text with moving gradient colors -pub struct GradientSpinner { - message: String, - running: std::sync::Arc, - handle: Option>, -} - -impl GradientSpinner { - pub fn new(message: &str) -> Self { - let message = message.to_string(); - let running = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)); - - let msg_clone = message.clone(); - let running_clone = running.clone(); - - let handle = std::thread::spawn(move || { - let chars: Vec = msg_clone.chars().collect(); - let len = chars.len(); - let num_frames = 30; - let mut current_frame = 0usize; - - while running_clone.load(std::sync::atomic::Ordering::Relaxed) { - current_frame = (current_frame + 1) % num_frames; - let offset = current_frame as f32 / num_frames as f32; - - // Build the gradient string - let mut result = String::from("\r"); - for (i, ch) in chars.iter().enumerate() { - if *ch == ' ' { - result.push(' '); - } else { - let base_t = if len > 1 { i as f32 / (len - 1) as f32 } else { 0.0 }; - let t = (base_t + offset) % 1.0; - let (r, g, b) = interpolate_color(GRADIENT_START, GRADIENT_END, t); - result.push_str(&format!("\x1b[38;2;{};{};{}m{}", r, g, b, ch)); - } - } - result.push_str("\x1b[0m"); - - print!("{}", result); - let _ = io::stdout().flush(); - - std::thread::sleep(std::time::Duration::from_millis(80)); - } - }); - - Self { - message, - running, - handle: Some(handle), - } - } - - pub fn finish(&mut self) { - self.running.store(false, std::sync::atomic::Ordering::Relaxed); - if let Some(handle) = self.handle.take() { - let _ = handle.join(); - } - // Clear the line - print!("\r{}\r", " ".repeat(self.message.len() + 10)); - let _ = io::stdout().flush(); - } -} - -impl Drop for GradientSpinner { - fn drop(&mut self) { - if self.running.load(std::sync::atomic::Ordering::Relaxed) { - self.finish(); - } - } -} - -pub fn create_spinner(message: &str) -> GradientSpinner { - GradientSpinner::new(message) -} - -pub fn create_progress_bar(total: u64, message: &str) -> ProgressBar { - let pb = ProgressBar::new(total); - pb.set_style( - ProgressStyle::default_bar() - .template("{msg} [{bar:40.cyan/blue}] {pos}/{len} ({percent}%)") - .unwrap() - .progress_chars("█▓▒░ "), - ); - pb.set_message(message.to_string()); - pb -} - -pub fn prompt_confirmation(message: &str) -> io::Result { - print!("{} [y/N]: ", gradient_start(message)); - io::stdout().flush()?; - - let mut input = String::new(); - io::stdin().read_line(&mut input)?; - - Ok(input.trim().eq_ignore_ascii_case("y") || input.trim().eq_ignore_ascii_case("yes")) -} - -pub fn print_disposition(profile: &BankProfileResponse) { - print_section_header(&format!("Disposition: {}", profile.bank_id)); - - // Print name - println!("{} {}", dim("Name:"), gradient_start(&profile.name)); - println!(); - - // Print background if available - if !profile.background.is_empty() { - println!("{}", gradient_mid("Background:")); - for line in profile.background.lines() { - println!("{}", line); - } - println!(); - } - - // Print disposition traits - println!("{}", gradient_text("─── Disposition Traits ───")); - println!(); - - // New 3-trait disposition system (values 1-5) - let traits: [(_, i64, f32, _); 3] = [ - ("Skepticism", profile.disposition.skepticism.get() as i64, 0.0, "1=trusting, 5=skeptical"), - ("Literalism", profile.disposition.literalism.get() as i64, 0.5, "1=flexible, 5=literal"), - ("Empathy", profile.disposition.empathy.get() as i64, 1.0, "1=detached, 5=empathetic"), - ]; - - for (name, value, t, desc) in &traits { - // Scale 1-5 to bar visualization (each point = 8 chars, total 40) - let bar_length = 40; - let filled = ((*value - 1) * 10) as usize; // 1->0, 2->10, 3->20, 4->30, 5->40 - let empty = bar_length - filled; - - let bar = format!("{}{}", "█".repeat(filled), "░".repeat(empty)); - - println!(" {:<12} [{}] {}/5", - name, - gradient(&bar, *t), - value - ); - println!(" {}", dim(desc)); - } - - println!(); -} diff --git a/hindsight-cli/src/utils.rs b/hindsight-cli/src/utils.rs deleted file mode 100644 index c1b8e5a4..00000000 --- a/hindsight-cli/src/utils.rs +++ /dev/null @@ -1,15 +0,0 @@ -use anyhow::{Context, Result}; -use crate::api::ApiClient; -use crate::config::Config; -use crate::output::OutputFormat; - -/// Get API client from config -pub fn get_client(config: &Config) -> Result { - ApiClient::new(config.api_url.clone(), config.api_key.clone()) - .context("Failed to create API client") -} - -/// Get output format, preferring CLI arg over default -pub fn get_output_format(cli_format: Option, _config: &Config) -> OutputFormat { - cli_format.unwrap_or(OutputFormat::Pretty) -} diff --git a/hindsight-clients/.gitignore b/hindsight-clients/.gitignore deleted file mode 100644 index f2fb7aa9..00000000 --- a/hindsight-clients/.gitignore +++ /dev/null @@ -1,15 +0,0 @@ -# Build artifacts -python/dist/ -python/build/ -python/*.egg-info/ -python/.ruff_cache/ -typescript/dist/ -typescript/node_modules/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# Generated code is tracked in git to see API changes diff --git a/hindsight-clients/python/.openapi-generator-ignore b/hindsight-clients/python/.openapi-generator-ignore deleted file mode 100644 index 7484ee59..00000000 --- a/hindsight-clients/python/.openapi-generator-ignore +++ /dev/null @@ -1,23 +0,0 @@ -# OpenAPI Generator Ignore -# Generated by openapi-generator https://github.com/openapitools/openapi-generator - -# Use this file to prevent files from being overwritten by the generator. -# The patterns follow closely to .gitignore or .dockerignore. - -# As an example, the C# client generator defines ApiClient.cs. -# You can make changes and tell OpenAPI Generator to ignore just this file by uncommenting the following line: -#ApiClient.cs - -# You can match any string of characters against a directory, file or extension with a single asterisk (*): -#foo/*/qux -# The above matches foo/bar/qux and foo/baz/qux, but not foo/bar/baz/qux - -# You can recursively match patterns against a directory, file or extension with a double asterisk (**): -#foo/**/qux -# This matches foo/bar/qux, foo/baz/qux, and foo/bar/baz/qux - -# You can also negate patterns with an exclamation (!). -# For example, you can ignore all files in a docs folder with the file extension .md: -#docs/*.md -# Then explicitly reverse the ignore rule for a single file: -#!docs/README.md diff --git a/hindsight-clients/python/.openapi-generator/FILES b/hindsight-clients/python/.openapi-generator/FILES deleted file mode 100644 index 6eda74f1..00000000 --- a/hindsight-clients/python/.openapi-generator/FILES +++ /dev/null @@ -1,127 +0,0 @@ -hindsight_client_api/__init__.py -hindsight_client_api/api/__init__.py -hindsight_client_api/api/default_api.py -hindsight_client_api/api/monitoring_api.py -hindsight_client_api/api_client.py -hindsight_client_api/api_response.py -hindsight_client_api/configuration.py -hindsight_client_api/docs/AddBackgroundRequest.md -hindsight_client_api/docs/BackgroundResponse.md -hindsight_client_api/docs/BankListItem.md -hindsight_client_api/docs/BankListResponse.md -hindsight_client_api/docs/BankProfileResponse.md -hindsight_client_api/docs/Budget.md -hindsight_client_api/docs/ChunkData.md -hindsight_client_api/docs/ChunkIncludeOptions.md -hindsight_client_api/docs/ChunkResponse.md -hindsight_client_api/docs/CreateBankRequest.md -hindsight_client_api/docs/DefaultApi.md -hindsight_client_api/docs/DeleteResponse.md -hindsight_client_api/docs/DispositionTraits.md -hindsight_client_api/docs/DocumentResponse.md -hindsight_client_api/docs/EntityDetailResponse.md -hindsight_client_api/docs/EntityIncludeOptions.md -hindsight_client_api/docs/EntityListItem.md -hindsight_client_api/docs/EntityListResponse.md -hindsight_client_api/docs/EntityObservationResponse.md -hindsight_client_api/docs/EntityStateResponse.md -hindsight_client_api/docs/GraphDataResponse.md -hindsight_client_api/docs/HTTPValidationError.md -hindsight_client_api/docs/IncludeOptions.md -hindsight_client_api/docs/ListDocumentsResponse.md -hindsight_client_api/docs/ListMemoryUnitsResponse.md -hindsight_client_api/docs/MemoryItem.md -hindsight_client_api/docs/MonitoringApi.md -hindsight_client_api/docs/RecallRequest.md -hindsight_client_api/docs/RecallResponse.md -hindsight_client_api/docs/RecallResult.md -hindsight_client_api/docs/ReflectFact.md -hindsight_client_api/docs/ReflectIncludeOptions.md -hindsight_client_api/docs/ReflectRequest.md -hindsight_client_api/docs/ReflectResponse.md -hindsight_client_api/docs/RetainRequest.md -hindsight_client_api/docs/RetainResponse.md -hindsight_client_api/docs/UpdateDispositionRequest.md -hindsight_client_api/docs/ValidationError.md -hindsight_client_api/docs/ValidationErrorLocInner.md -hindsight_client_api/exceptions.py -hindsight_client_api/models/__init__.py -hindsight_client_api/models/add_background_request.py -hindsight_client_api/models/background_response.py -hindsight_client_api/models/bank_list_item.py -hindsight_client_api/models/bank_list_response.py -hindsight_client_api/models/bank_profile_response.py -hindsight_client_api/models/budget.py -hindsight_client_api/models/chunk_data.py -hindsight_client_api/models/chunk_include_options.py -hindsight_client_api/models/chunk_response.py -hindsight_client_api/models/create_bank_request.py -hindsight_client_api/models/delete_response.py -hindsight_client_api/models/disposition_traits.py -hindsight_client_api/models/document_response.py -hindsight_client_api/models/entity_detail_response.py -hindsight_client_api/models/entity_include_options.py -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/graph_data_response.py -hindsight_client_api/models/http_validation_error.py -hindsight_client_api/models/include_options.py -hindsight_client_api/models/list_documents_response.py -hindsight_client_api/models/list_memory_units_response.py -hindsight_client_api/models/memory_item.py -hindsight_client_api/models/recall_request.py -hindsight_client_api/models/recall_response.py -hindsight_client_api/models/recall_result.py -hindsight_client_api/models/reflect_fact.py -hindsight_client_api/models/reflect_include_options.py -hindsight_client_api/models/reflect_request.py -hindsight_client_api/models/reflect_response.py -hindsight_client_api/models/retain_request.py -hindsight_client_api/models/retain_response.py -hindsight_client_api/models/update_disposition_request.py -hindsight_client_api/models/validation_error.py -hindsight_client_api/models/validation_error_loc_inner.py -hindsight_client_api/rest.py -hindsight_client_api/test/__init__.py -hindsight_client_api/test/test_add_background_request.py -hindsight_client_api/test/test_background_response.py -hindsight_client_api/test/test_bank_list_item.py -hindsight_client_api/test/test_bank_list_response.py -hindsight_client_api/test/test_bank_profile_response.py -hindsight_client_api/test/test_budget.py -hindsight_client_api/test/test_chunk_data.py -hindsight_client_api/test/test_chunk_include_options.py -hindsight_client_api/test/test_chunk_response.py -hindsight_client_api/test/test_create_bank_request.py -hindsight_client_api/test/test_default_api.py -hindsight_client_api/test/test_delete_response.py -hindsight_client_api/test/test_disposition_traits.py -hindsight_client_api/test/test_document_response.py -hindsight_client_api/test/test_entity_detail_response.py -hindsight_client_api/test/test_entity_include_options.py -hindsight_client_api/test/test_entity_list_item.py -hindsight_client_api/test/test_entity_list_response.py -hindsight_client_api/test/test_entity_observation_response.py -hindsight_client_api/test/test_entity_state_response.py -hindsight_client_api/test/test_graph_data_response.py -hindsight_client_api/test/test_http_validation_error.py -hindsight_client_api/test/test_include_options.py -hindsight_client_api/test/test_list_documents_response.py -hindsight_client_api/test/test_list_memory_units_response.py -hindsight_client_api/test/test_memory_item.py -hindsight_client_api/test/test_monitoring_api.py -hindsight_client_api/test/test_recall_request.py -hindsight_client_api/test/test_recall_response.py -hindsight_client_api/test/test_recall_result.py -hindsight_client_api/test/test_reflect_fact.py -hindsight_client_api/test/test_reflect_include_options.py -hindsight_client_api/test/test_reflect_request.py -hindsight_client_api/test/test_reflect_response.py -hindsight_client_api/test/test_retain_request.py -hindsight_client_api/test/test_retain_response.py -hindsight_client_api/test/test_update_disposition_request.py -hindsight_client_api/test/test_validation_error.py -hindsight_client_api/test/test_validation_error_loc_inner.py -hindsight_client_api_README.md diff --git a/hindsight-clients/python/.openapi-generator/VERSION b/hindsight-clients/python/.openapi-generator/VERSION deleted file mode 100644 index 2fb556b6..00000000 --- a/hindsight-clients/python/.openapi-generator/VERSION +++ /dev/null @@ -1 +0,0 @@ -7.18.0-SNAPSHOT diff --git a/hindsight-clients/python/README.md b/hindsight-clients/python/README.md deleted file mode 100644 index 3a152c4b..00000000 --- a/hindsight-clients/python/README.md +++ /dev/null @@ -1 +0,0 @@ -# Hindsight Python Client \ No newline at end of file diff --git a/hindsight-clients/python/hindsight_client/__init__.py b/hindsight-clients/python/hindsight_client/__init__.py deleted file mode 100644 index da26080e..00000000 --- a/hindsight-clients/python/hindsight_client/__init__.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -Hindsight Client - Clean, pythonic wrapper for the Hindsight API. - -This package provides a high-level interface for common Hindsight operations. -For advanced use cases, use the auto-generated API client directly. - -Example: - ```python - from hindsight_client import Hindsight - - client = Hindsight(base_url="http://localhost:8888") - - # Store a memory - result = client.retain(bank_id="alice", content="Alice loves AI") - print(result.success) - - # Search memories - response = client.recall(bank_id="alice", query="What does Alice like?") - for r in response.results: - print(r.text) - - # Generate contextual answer - answer = client.reflect(bank_id="alice", query="What are my interests?") - print(answer.text) - ``` -""" - -from .hindsight_client import Hindsight - -# Re-export response types for convenient access -from hindsight_client_api.models.retain_response import RetainResponse -from hindsight_client_api.models.recall_response import RecallResponse as _RecallResponse -from hindsight_client_api.models.recall_result import RecallResult as _RecallResult -from hindsight_client_api.models.reflect_response import ReflectResponse -from hindsight_client_api.models.reflect_fact import ReflectFact -from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse -from hindsight_client_api.models.bank_profile_response import BankProfileResponse -from hindsight_client_api.models.disposition_traits import DispositionTraits - - -# Add cleaner __repr__ and __iter__ for REPL usability -def _recall_result_repr(self): - text_preview = self.text[:80] + "..." if len(self.text) > 80 else self.text - return f"RecallResult(id='{self.id[:8]}...', type='{self.type}', text='{text_preview}')" - - -def _recall_response_repr(self): - count = len(self.results) if self.results else 0 - extras = [] - if self.trace: - extras.append("trace=True") - if self.entities: - extras.append(f"entities={len(self.entities)}") - if self.chunks: - extras.append(f"chunks={len(self.chunks)}") - extras_str = ", " + ", ".join(extras) if extras else "" - return f"RecallResponse({count} results{extras_str})" - - -def _recall_response_iter(self): - """Iterate directly over results for convenience.""" - return iter(self.results or []) - - -def _recall_response_len(self): - """Return number of results.""" - return len(self.results) if self.results else 0 - - -def _recall_response_getitem(self, index): - """Access results by index.""" - return self.results[index] - - -_RecallResult.__repr__ = _recall_result_repr -_RecallResponse.__repr__ = _recall_response_repr -_RecallResponse.__iter__ = _recall_response_iter -_RecallResponse.__len__ = _recall_response_len -_RecallResponse.__getitem__ = _recall_response_getitem - -# Re-export with patched repr -RecallResult = _RecallResult -RecallResponse = _RecallResponse - -__all__ = [ - "Hindsight", - # Response types - "RetainResponse", - "RecallResponse", - "RecallResult", - "ReflectResponse", - "ReflectFact", - "ListMemoryUnitsResponse", - "BankProfileResponse", - "DispositionTraits", -] diff --git a/hindsight-clients/python/hindsight_client/hindsight_client.py b/hindsight-clients/python/hindsight_client/hindsight_client.py deleted file mode 100644 index fdc6ba3f..00000000 --- a/hindsight-clients/python/hindsight_client/hindsight_client.py +++ /dev/null @@ -1,417 +0,0 @@ -""" -Clean, pythonic wrapper for the Hindsight API client. - -This file is MAINTAINED and NOT auto-generated. It provides a high-level, -easy-to-use interface on top of the auto-generated OpenAPI client. -""" - -import asyncio -from typing import Optional, List, Dict, Any -from datetime import datetime - -import hindsight_client_api -from hindsight_client_api.api import default_api -from hindsight_client_api.models import ( - recall_request, - retain_request, - memory_item, - reflect_request, -) -from hindsight_client_api.models.retain_response import RetainResponse -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.list_memory_units_response import ListMemoryUnitsResponse -from hindsight_client_api.models.bank_profile_response import BankProfileResponse - - -def _run_async(coro): - """Run an async coroutine synchronously.""" - try: - loop = asyncio.get_event_loop() - except RuntimeError: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - - return loop.run_until_complete(coro) - - -class Hindsight: - """ - High-level, easy-to-use Hindsight API client. - - Example: - ```python - from hindsight_client import Hindsight - - # Without authentication - client = Hindsight(base_url="http://localhost:8888") - - # With API key authentication - client = Hindsight(base_url="http://localhost:8888", api_key="your-api-key") - - # Store a memory - client.retain(bank_id="alice", content="Alice loves AI") - - # Recall memories - response = client.recall(bank_id="alice", query="What does Alice like?") - for r in response.results: - print(r.text) - - # Generate contextual answer - answer = client.reflect(bank_id="alice", query="What are my interests?") - ``` - """ - - def __init__(self, base_url: str, api_key: Optional[str] = None, timeout: float = 30.0): - """ - Initialize the Hindsight client. - - Args: - base_url: The base URL of the Hindsight API server - api_key: Optional API key for authentication (sent as Bearer token) - timeout: Request timeout in seconds (default: 30.0) - """ - config = hindsight_client_api.Configuration(host=base_url, access_token=api_key) - self._api_client = hindsight_client_api.ApiClient(config) - self._api = default_api.DefaultApi(self._api_client) - - def __enter__(self): - """Context manager entry.""" - return self - - def __exit__(self, exc_type, exc_val, exc_tb): - """Context manager exit.""" - self.close() - - def close(self): - """Close the API client (sync version - use aclose() in async code).""" - if self._api_client: - try: - loop = asyncio.get_running_loop() - # We're in an async context - schedule but don't wait - # The caller should use aclose() instead - loop.create_task(self._api_client.close()) - except RuntimeError: - # No running loop - safe to run synchronously - _run_async(self._api_client.close()) - - async def aclose(self): - """Close the API client (async version).""" - if self._api_client: - await self._api_client.close() - - # Simplified methods for main operations - - def retain( - self, - bank_id: str, - content: str, - timestamp: Optional[datetime] = None, - context: Optional[str] = None, - document_id: Optional[str] = None, - metadata: Optional[Dict[str, str]] = None, - ) -> RetainResponse: - """ - Store a single memory (simplified interface). - - Args: - bank_id: The memory bank ID - content: Memory content - timestamp: Optional event timestamp - context: Optional context description - document_id: Optional document ID for grouping - metadata: Optional user-defined metadata - - Returns: - RetainResponse with success status - """ - return self.retain_batch( - bank_id=bank_id, - items=[{"content": content, "timestamp": timestamp, "context": context, "metadata": metadata}], - document_id=document_id, - ) - - def retain_batch( - self, - bank_id: str, - items: List[Dict[str, Any]], - document_id: Optional[str] = None, - retain_async: bool = False, - ) -> RetainResponse: - """ - Store multiple memories in batch. - - Args: - bank_id: The memory bank ID - items: List of memory items with 'content' and optional 'timestamp', 'context', 'metadata', 'document_id' - document_id: Optional document ID for grouping memories (applied to items that don't have their own) - retain_async: If True, process asynchronously in background (default: False) - - Returns: - RetainResponse with success status and item count - """ - memory_items = [ - memory_item.MemoryItem( - content=item["content"], - timestamp=item.get("timestamp"), - context=item.get("context"), - metadata=item.get("metadata"), - # Use item's document_id if provided, otherwise fall back to batch-level document_id - document_id=item.get("document_id") or document_id, - ) - for item in items - ] - - request_obj = retain_request.RetainRequest( - items=memory_items, - async_=retain_async, - ) - - return _run_async(self._api.retain_memories(bank_id, request_obj)) - - def recall( - self, - bank_id: str, - query: str, - types: Optional[List[str]] = None, - max_tokens: int = 4096, - budget: str = "mid", - trace: bool = False, - query_timestamp: Optional[str] = None, - include_entities: bool = False, - max_entity_tokens: int = 500, - include_chunks: bool = False, - max_chunk_tokens: int = 8192, - ) -> RecallResponse: - """ - Recall memories using semantic similarity. - - Args: - bank_id: The memory bank ID - query: Search query - types: Optional list of fact types to filter (world, experience, opinion, observation) - max_tokens: Maximum tokens in results (default: 4096) - budget: Budget level for recall - "low", "mid", or "high" (default: "mid") - trace: Enable trace output (default: False) - query_timestamp: Optional ISO format date string (e.g., '2023-05-30T23:40:00') - include_entities: Include entity observations in results (default: False) - max_entity_tokens: Maximum tokens for entity observations (default: 500) - include_chunks: Include raw text chunks in results (default: False) - max_chunk_tokens: Maximum tokens for chunks (default: 8192) - - Returns: - RecallResponse with results, optional entities, optional chunks, and optional trace - """ - from hindsight_client_api.models import include_options, entity_include_options, chunk_include_options - - include_opts = include_options.IncludeOptions( - entities=entity_include_options.EntityIncludeOptions(max_tokens=max_entity_tokens) if include_entities else None, - chunks=chunk_include_options.ChunkIncludeOptions(max_tokens=max_chunk_tokens) if include_chunks else None, - ) - - request_obj = recall_request.RecallRequest( - query=query, - types=types, - budget=budget, - max_tokens=max_tokens, - trace=trace, - query_timestamp=query_timestamp, - include=include_opts, - ) - - return _run_async(self._api.recall_memories(bank_id, request_obj)) - - def reflect( - self, - bank_id: str, - query: str, - budget: str = "low", - context: Optional[str] = None, - ) -> ReflectResponse: - """ - Generate a contextual answer based on bank identity and memories. - - Args: - bank_id: The memory bank ID - query: The question or prompt - budget: Budget level for reflection - "low", "mid", or "high" (default: "low") - context: Optional additional context - - Returns: - ReflectResponse with answer text and optionally facts used - """ - request_obj = reflect_request.ReflectRequest( - query=query, - budget=budget, - context=context, - ) - - return _run_async(self._api.reflect(bank_id, request_obj)) - - def list_memories( - self, - bank_id: str, - type: Optional[str] = None, - search_query: Optional[str] = None, - limit: int = 100, - offset: int = 0, - ) -> ListMemoryUnitsResponse: - """List memory units with pagination.""" - return _run_async(self._api.list_memories( - bank_id=bank_id, - type=type, - q=search_query, - limit=limit, - offset=offset, - )) - - def create_bank( - self, - bank_id: str, - name: Optional[str] = None, - background: Optional[str] = None, - disposition: Optional[Dict[str, float]] = None, - ) -> BankProfileResponse: - """Create or update a memory bank.""" - from hindsight_client_api.models import create_bank_request, disposition_traits - - disposition_obj = None - if disposition: - disposition_obj = disposition_traits.DispositionTraits(**disposition) - - request_obj = create_bank_request.CreateBankRequest( - name=name, - background=background, - disposition=disposition_obj, - ) - - return _run_async(self._api.create_or_update_bank(bank_id, request_obj)) - - # Async methods (native async, no _run_async wrapper) - - async def aretain_batch( - self, - bank_id: str, - items: List[Dict[str, Any]], - document_id: Optional[str] = None, - retain_async: bool = False, - ) -> RetainResponse: - """ - Store multiple memories in batch (async). - - Args: - bank_id: The memory bank ID - items: List of memory items with 'content' and optional 'timestamp', 'context', 'metadata', 'document_id' - document_id: Optional document ID for grouping memories (applied to items that don't have their own) - retain_async: If True, process asynchronously in background (default: False) - - Returns: - RetainResponse with success status and item count - """ - memory_items = [ - memory_item.MemoryItem( - content=item["content"], - timestamp=item.get("timestamp"), - context=item.get("context"), - metadata=item.get("metadata"), - # Use item's document_id if provided, otherwise fall back to batch-level document_id - document_id=item.get("document_id") or document_id, - ) - for item in items - ] - - request_obj = retain_request.RetainRequest( - items=memory_items, - async_=retain_async, - ) - - return await self._api.retain_memories(bank_id, request_obj) - - async def aretain( - self, - bank_id: str, - content: str, - timestamp: Optional[datetime] = None, - context: Optional[str] = None, - document_id: Optional[str] = None, - metadata: Optional[Dict[str, str]] = None, - ) -> RetainResponse: - """ - Store a single memory (async). - - Args: - bank_id: The memory bank ID - content: Memory content - timestamp: Optional event timestamp - context: Optional context description - document_id: Optional document ID for grouping - metadata: Optional user-defined metadata - - Returns: - RetainResponse with success status - """ - return await self.aretain_batch( - bank_id=bank_id, - items=[{"content": content, "timestamp": timestamp, "context": context, "metadata": metadata}], - document_id=document_id, - ) - - async def arecall( - self, - bank_id: str, - query: str, - types: Optional[List[str]] = None, - max_tokens: int = 4096, - budget: str = "mid", - ) -> List[RecallResult]: - """ - Recall memories using semantic similarity (async). - - Args: - bank_id: The memory bank ID - query: Search query - types: Optional list of fact types to filter (world, experience, opinion, observation) - max_tokens: Maximum tokens in results (default: 4096) - budget: Budget level for recall - "low", "mid", or "high" (default: "mid") - - Returns: - List of RecallResult objects - """ - request_obj = recall_request.RecallRequest( - query=query, - types=types, - budget=budget, - max_tokens=max_tokens, - trace=False, - ) - - response = await self._api.recall_memories(bank_id, request_obj) - return response.results if hasattr(response, 'results') else [] - - async def areflect( - self, - bank_id: str, - query: str, - budget: str = "low", - context: Optional[str] = None, - ) -> ReflectResponse: - """ - Generate a contextual answer based on bank identity and memories (async). - - Args: - bank_id: The memory bank ID - query: The question or prompt - budget: Budget level for reflection - "low", "mid", or "high" (default: "low") - context: Optional additional context - - Returns: - ReflectResponse with answer text and optionally facts used - """ - request_obj = reflect_request.ReflectRequest( - query=query, - budget=budget, - context=context, - ) - - return await self._api.reflect(bank_id, request_obj) diff --git a/hindsight-clients/python/hindsight_client_api/__init__.py b/hindsight-clients/python/hindsight_client_api/__init__.py deleted file mode 100644 index ca68c56b..00000000 --- a/hindsight-clients/python/hindsight_client_api/__init__.py +++ /dev/null @@ -1,124 +0,0 @@ -# coding: utf-8 - -# flake8: noqa - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -__version__ = "0.0.7" - -# Define package exports -__all__ = [ - "MonitoringApi", - "DefaultApi", - "ApiResponse", - "ApiClient", - "Configuration", - "OpenApiException", - "ApiTypeError", - "ApiValueError", - "ApiKeyError", - "ApiAttributeError", - "ApiException", - "AddBackgroundRequest", - "BackgroundResponse", - "BankListItem", - "BankListResponse", - "BankProfileResponse", - "Budget", - "ChunkData", - "ChunkIncludeOptions", - "ChunkResponse", - "CreateBankRequest", - "DeleteResponse", - "DispositionTraits", - "DocumentResponse", - "EntityDetailResponse", - "EntityIncludeOptions", - "EntityListItem", - "EntityListResponse", - "EntityObservationResponse", - "EntityStateResponse", - "GraphDataResponse", - "HTTPValidationError", - "IncludeOptions", - "ListDocumentsResponse", - "ListMemoryUnitsResponse", - "MemoryItem", - "RecallRequest", - "RecallResponse", - "RecallResult", - "ReflectFact", - "ReflectIncludeOptions", - "ReflectRequest", - "ReflectResponse", - "RetainRequest", - "RetainResponse", - "UpdateDispositionRequest", - "ValidationError", - "ValidationErrorLocInner", -] - -# import apis into sdk package -from hindsight_client_api.api.monitoring_api import MonitoringApi as MonitoringApi -from hindsight_client_api.api.default_api import DefaultApi as DefaultApi - -# import ApiClient -from hindsight_client_api.api_response import ApiResponse as ApiResponse -from hindsight_client_api.api_client import ApiClient as ApiClient -from hindsight_client_api.configuration import Configuration as Configuration -from hindsight_client_api.exceptions import OpenApiException as OpenApiException -from hindsight_client_api.exceptions import ApiTypeError as ApiTypeError -from hindsight_client_api.exceptions import ApiValueError as ApiValueError -from hindsight_client_api.exceptions import ApiKeyError as ApiKeyError -from hindsight_client_api.exceptions import ApiAttributeError as ApiAttributeError -from hindsight_client_api.exceptions import ApiException as ApiException - -# import models into sdk package -from hindsight_client_api.models.add_background_request import AddBackgroundRequest as AddBackgroundRequest -from hindsight_client_api.models.background_response import BackgroundResponse as BackgroundResponse -from hindsight_client_api.models.bank_list_item import BankListItem as BankListItem -from hindsight_client_api.models.bank_list_response import BankListResponse as BankListResponse -from hindsight_client_api.models.bank_profile_response import BankProfileResponse as BankProfileResponse -from hindsight_client_api.models.budget import Budget as Budget -from hindsight_client_api.models.chunk_data import ChunkData as ChunkData -from hindsight_client_api.models.chunk_include_options import ChunkIncludeOptions as ChunkIncludeOptions -from hindsight_client_api.models.chunk_response import ChunkResponse as ChunkResponse -from hindsight_client_api.models.create_bank_request import CreateBankRequest as CreateBankRequest -from hindsight_client_api.models.delete_response import DeleteResponse as DeleteResponse -from hindsight_client_api.models.disposition_traits import DispositionTraits as DispositionTraits -from hindsight_client_api.models.document_response import DocumentResponse as DocumentResponse -from hindsight_client_api.models.entity_detail_response import EntityDetailResponse as EntityDetailResponse -from hindsight_client_api.models.entity_include_options import EntityIncludeOptions as EntityIncludeOptions -from hindsight_client_api.models.entity_list_item import EntityListItem as EntityListItem -from hindsight_client_api.models.entity_list_response import EntityListResponse as EntityListResponse -from hindsight_client_api.models.entity_observation_response import EntityObservationResponse as EntityObservationResponse -from hindsight_client_api.models.entity_state_response import EntityStateResponse as EntityStateResponse -from hindsight_client_api.models.graph_data_response import GraphDataResponse as GraphDataResponse -from hindsight_client_api.models.http_validation_error import HTTPValidationError as HTTPValidationError -from hindsight_client_api.models.include_options import IncludeOptions as IncludeOptions -from hindsight_client_api.models.list_documents_response import ListDocumentsResponse as ListDocumentsResponse -from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse as ListMemoryUnitsResponse -from hindsight_client_api.models.memory_item import MemoryItem as MemoryItem -from hindsight_client_api.models.recall_request import RecallRequest as RecallRequest -from hindsight_client_api.models.recall_response import RecallResponse as RecallResponse -from hindsight_client_api.models.recall_result import RecallResult as RecallResult -from hindsight_client_api.models.reflect_fact import ReflectFact as ReflectFact -from hindsight_client_api.models.reflect_include_options import ReflectIncludeOptions as ReflectIncludeOptions -from hindsight_client_api.models.reflect_request import ReflectRequest as ReflectRequest -from hindsight_client_api.models.reflect_response import ReflectResponse as ReflectResponse -from hindsight_client_api.models.retain_request import RetainRequest as RetainRequest -from hindsight_client_api.models.retain_response import RetainResponse as RetainResponse -from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest as UpdateDispositionRequest -from hindsight_client_api.models.validation_error import ValidationError as ValidationError -from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner as ValidationErrorLocInner - diff --git a/hindsight-clients/python/hindsight_client_api/api/__init__.py b/hindsight-clients/python/hindsight_client_api/api/__init__.py deleted file mode 100644 index 02b3d154..00000000 --- a/hindsight-clients/python/hindsight_client_api/api/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# flake8: noqa - -# import apis into api package -from hindsight_client_api.api.monitoring_api import MonitoringApi -from hindsight_client_api.api.default_api import DefaultApi - diff --git a/hindsight-clients/python/hindsight_client_api/api/default_api.py b/hindsight-clients/python/hindsight_client_api/api/default_api.py deleted file mode 100644 index 5966fd53..00000000 --- a/hindsight-clients/python/hindsight_client_api/api/default_api.py +++ /dev/null @@ -1,5976 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - -import warnings -from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt -from typing import Any, Dict, List, Optional, Tuple, Union -from typing_extensions import Annotated - -from pydantic import Field, StrictInt, StrictStr -from typing import Any, Optional -from typing_extensions import Annotated -from hindsight_client_api.models.add_background_request import AddBackgroundRequest -from hindsight_client_api.models.background_response import BackgroundResponse -from hindsight_client_api.models.bank_list_response import BankListResponse -from hindsight_client_api.models.bank_profile_response import BankProfileResponse -from hindsight_client_api.models.chunk_response import ChunkResponse -from hindsight_client_api.models.create_bank_request import CreateBankRequest -from hindsight_client_api.models.delete_response import DeleteResponse -from hindsight_client_api.models.document_response import DocumentResponse -from hindsight_client_api.models.entity_detail_response import EntityDetailResponse -from hindsight_client_api.models.entity_list_response import EntityListResponse -from hindsight_client_api.models.graph_data_response import GraphDataResponse -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.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_disposition_request import UpdateDispositionRequest - -from hindsight_client_api.api_client import ApiClient, RequestSerialized -from hindsight_client_api.api_response import ApiResponse -from hindsight_client_api.rest import RESTResponseType - - -class DefaultApi: - """NOTE: This class is auto generated by OpenAPI Generator - Ref: https://openapi-generator.tech - - Do not edit the class manually. - """ - - def __init__(self, api_client=None) -> None: - if api_client is None: - api_client = ApiClient.get_default() - self.api_client = api_client - - - @validate_call - async def add_bank_background( - self, - bank_id: StrictStr, - add_background_request: AddBackgroundRequest, - _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, - ) -> BackgroundResponse: - """Add/merge memory bank background - - Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits. - - :param bank_id: (required) - :type bank_id: str - :param add_background_request: (required) - :type add_background_request: AddBackgroundRequest - :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._add_bank_background_serialize( - bank_id=bank_id, - add_background_request=add_background_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "BackgroundResponse", - '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 add_bank_background_with_http_info( - self, - bank_id: StrictStr, - add_background_request: AddBackgroundRequest, - _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[BackgroundResponse]: - """Add/merge memory bank background - - Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits. - - :param bank_id: (required) - :type bank_id: str - :param add_background_request: (required) - :type add_background_request: AddBackgroundRequest - :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._add_bank_background_serialize( - bank_id=bank_id, - add_background_request=add_background_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "BackgroundResponse", - '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 add_bank_background_without_preload_content( - self, - bank_id: StrictStr, - add_background_request: AddBackgroundRequest, - _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: - """Add/merge memory bank background - - Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits. - - :param bank_id: (required) - :type bank_id: str - :param add_background_request: (required) - :type add_background_request: AddBackgroundRequest - :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._add_bank_background_serialize( - bank_id=bank_id, - add_background_request=add_background_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "BackgroundResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _add_bank_background_serialize( - self, - bank_id, - add_background_request, - _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 - # process the form parameters - # process the body parameter - if add_background_request is not None: - _body_params = add_background_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}/background', - 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 cancel_operation( - self, - bank_id: StrictStr, - operation_id: StrictStr, - _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: - """Cancel a pending async operation - - Cancel a pending async operation by removing it from the queue - - :param bank_id: (required) - :type bank_id: str - :param operation_id: (required) - :type operation_id: 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._cancel_operation_serialize( - bank_id=bank_id, - operation_id=operation_id, - _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 cancel_operation_with_http_info( - self, - bank_id: StrictStr, - operation_id: StrictStr, - _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[object]: - """Cancel a pending async operation - - Cancel a pending async operation by removing it from the queue - - :param bank_id: (required) - :type bank_id: str - :param operation_id: (required) - :type operation_id: 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._cancel_operation_serialize( - bank_id=bank_id, - operation_id=operation_id, - _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 cancel_operation_without_preload_content( - self, - bank_id: StrictStr, - operation_id: StrictStr, - _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: - """Cancel a pending async operation - - Cancel a pending async operation by removing it from the queue - - :param bank_id: (required) - :type bank_id: str - :param operation_id: (required) - :type operation_id: 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._cancel_operation_serialize( - bank_id=bank_id, - operation_id=operation_id, - _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 _cancel_operation_serialize( - self, - bank_id, - operation_id, - _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 operation_id is not None: - _path_params['operation_id'] = operation_id - # process the query parameters - # process the header parameters - # 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='DELETE', - resource_path='/v1/default/banks/{bank_id}/operations/{operation_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 clear_bank_memories( - self, - bank_id: StrictStr, - type: Annotated[Optional[StrictStr], Field(description="Optional fact type filter (world, experience, opinion)")] = 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, - ) -> 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. - - :param bank_id: (required) - :type bank_id: str - :param type: Optional fact type filter (world, experience, opinion) - :type type: 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._clear_bank_memories_serialize( - bank_id=bank_id, - type=type, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "DeleteResponse", - '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 clear_bank_memories_with_http_info( - self, - bank_id: StrictStr, - type: Annotated[Optional[StrictStr], Field(description="Optional fact type filter (world, experience, opinion)")] = 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[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. - - :param bank_id: (required) - :type bank_id: str - :param type: Optional fact type filter (world, experience, opinion) - :type type: 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._clear_bank_memories_serialize( - bank_id=bank_id, - type=type, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "DeleteResponse", - '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 clear_bank_memories_without_preload_content( - self, - bank_id: StrictStr, - type: Annotated[Optional[StrictStr], Field(description="Optional fact type filter (world, experience, opinion)")] = 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: - """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. - - :param bank_id: (required) - :type bank_id: str - :param type: Optional fact type filter (world, experience, opinion) - :type type: 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._clear_bank_memories_serialize( - bank_id=bank_id, - type=type, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "DeleteResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _clear_bank_memories_serialize( - self, - bank_id, - type, - _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 - if type is not None: - - _query_params.append(('type', type)) - - # process the header parameters - # 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='DELETE', - resource_path='/v1/default/banks/{bank_id}/memories', - 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 create_or_update_bank( - self, - bank_id: StrictStr, - create_bank_request: CreateBankRequest, - _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, - ) -> BankProfileResponse: - """Create or update memory bank - - Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults. - - :param bank_id: (required) - :type bank_id: str - :param create_bank_request: (required) - :type create_bank_request: CreateBankRequest - :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._create_or_update_bank_serialize( - bank_id=bank_id, - create_bank_request=create_bank_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "BankProfileResponse", - '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 create_or_update_bank_with_http_info( - self, - bank_id: StrictStr, - create_bank_request: CreateBankRequest, - _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[BankProfileResponse]: - """Create or update memory bank - - Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults. - - :param bank_id: (required) - :type bank_id: str - :param create_bank_request: (required) - :type create_bank_request: CreateBankRequest - :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._create_or_update_bank_serialize( - bank_id=bank_id, - create_bank_request=create_bank_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "BankProfileResponse", - '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 create_or_update_bank_without_preload_content( - self, - bank_id: StrictStr, - create_bank_request: CreateBankRequest, - _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: - """Create or update memory bank - - Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults. - - :param bank_id: (required) - :type bank_id: str - :param create_bank_request: (required) - :type create_bank_request: CreateBankRequest - :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._create_or_update_bank_serialize( - bank_id=bank_id, - create_bank_request=create_bank_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "BankProfileResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _create_or_update_bank_serialize( - self, - bank_id, - create_bank_request, - _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 - # process the form parameters - # process the body parameter - if create_bank_request is not None: - _body_params = create_bank_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='PUT', - resource_path='/v1/default/banks/{bank_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 delete_document( - self, - bank_id: StrictStr, - document_id: StrictStr, - _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: - """Delete a document - - Delete a document and all its associated memory units and links. This will cascade delete: - The document itself - All memory units extracted from this document - All links (temporal, semantic, entity) associated with those memory units This operation cannot be undone. - - :param bank_id: (required) - :type bank_id: str - :param document_id: (required) - :type document_id: 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._delete_document_serialize( - bank_id=bank_id, - document_id=document_id, - _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 delete_document_with_http_info( - self, - bank_id: StrictStr, - document_id: StrictStr, - _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[object]: - """Delete a document - - Delete a document and all its associated memory units and links. This will cascade delete: - The document itself - All memory units extracted from this document - All links (temporal, semantic, entity) associated with those memory units This operation cannot be undone. - - :param bank_id: (required) - :type bank_id: str - :param document_id: (required) - :type document_id: 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._delete_document_serialize( - bank_id=bank_id, - document_id=document_id, - _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 delete_document_without_preload_content( - self, - bank_id: StrictStr, - document_id: StrictStr, - _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: - """Delete a document - - Delete a document and all its associated memory units and links. This will cascade delete: - The document itself - All memory units extracted from this document - All links (temporal, semantic, entity) associated with those memory units This operation cannot be undone. - - :param bank_id: (required) - :type bank_id: str - :param document_id: (required) - :type document_id: 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._delete_document_serialize( - bank_id=bank_id, - document_id=document_id, - _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 _delete_document_serialize( - self, - bank_id, - document_id, - _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 document_id is not None: - _path_params['document_id'] = document_id - # process the query parameters - # process the header parameters - # 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='DELETE', - resource_path='/v1/default/banks/{bank_id}/documents/{document_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_agent_stats( - self, - bank_id: StrictStr, - _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 statistics for memory bank - - Get statistics about nodes and links for a specific agent - - :param bank_id: (required) - :type bank_id: 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_agent_stats_serialize( - bank_id=bank_id, - _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_agent_stats_with_http_info( - self, - bank_id: StrictStr, - _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[object]: - """Get statistics for memory bank - - Get statistics about nodes and links for a specific agent - - :param bank_id: (required) - :type bank_id: 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_agent_stats_serialize( - bank_id=bank_id, - _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_agent_stats_without_preload_content( - self, - bank_id: StrictStr, - _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 statistics for memory bank - - Get statistics about nodes and links for a specific agent - - :param bank_id: (required) - :type bank_id: 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_agent_stats_serialize( - bank_id=bank_id, - _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_agent_stats_serialize( - self, - bank_id, - _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 - # 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}/stats', - 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_bank_profile( - self, - bank_id: StrictStr, - _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, - ) -> BankProfileResponse: - """Get memory bank profile - - Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists. - - :param bank_id: (required) - :type bank_id: 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_bank_profile_serialize( - bank_id=bank_id, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "BankProfileResponse", - '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_bank_profile_with_http_info( - self, - bank_id: StrictStr, - _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[BankProfileResponse]: - """Get memory bank profile - - Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists. - - :param bank_id: (required) - :type bank_id: 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_bank_profile_serialize( - bank_id=bank_id, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "BankProfileResponse", - '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_bank_profile_without_preload_content( - self, - bank_id: StrictStr, - _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 bank profile - - Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists. - - :param bank_id: (required) - :type bank_id: 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_bank_profile_serialize( - bank_id=bank_id, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "BankProfileResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _get_bank_profile_serialize( - self, - bank_id, - _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 - # 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}/profile', - 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_chunk( - self, - chunk_id: StrictStr, - _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, - ) -> ChunkResponse: - """Get chunk details - - Get a specific chunk by its ID - - :param chunk_id: (required) - :type chunk_id: 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_chunk_serialize( - chunk_id=chunk_id, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "ChunkResponse", - '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_chunk_with_http_info( - self, - chunk_id: StrictStr, - _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[ChunkResponse]: - """Get chunk details - - Get a specific chunk by its ID - - :param chunk_id: (required) - :type chunk_id: 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_chunk_serialize( - chunk_id=chunk_id, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "ChunkResponse", - '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_chunk_without_preload_content( - self, - chunk_id: StrictStr, - _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 chunk details - - Get a specific chunk by its ID - - :param chunk_id: (required) - :type chunk_id: 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_chunk_serialize( - chunk_id=chunk_id, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "ChunkResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _get_chunk_serialize( - self, - chunk_id, - _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 chunk_id is not None: - _path_params['chunk_id'] = chunk_id - # process the query parameters - # process the header parameters - # 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/chunks/{chunk_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_document( - self, - bank_id: StrictStr, - document_id: StrictStr, - _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, - ) -> DocumentResponse: - """Get document details - - Get a specific document including its original text - - :param bank_id: (required) - :type bank_id: str - :param document_id: (required) - :type document_id: 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_document_serialize( - bank_id=bank_id, - document_id=document_id, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "DocumentResponse", - '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_document_with_http_info( - self, - bank_id: StrictStr, - document_id: StrictStr, - _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[DocumentResponse]: - """Get document details - - Get a specific document including its original text - - :param bank_id: (required) - :type bank_id: str - :param document_id: (required) - :type document_id: 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_document_serialize( - bank_id=bank_id, - document_id=document_id, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "DocumentResponse", - '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_document_without_preload_content( - self, - bank_id: StrictStr, - document_id: StrictStr, - _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 document details - - Get a specific document including its original text - - :param bank_id: (required) - :type bank_id: str - :param document_id: (required) - :type document_id: 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_document_serialize( - bank_id=bank_id, - document_id=document_id, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "DocumentResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _get_document_serialize( - self, - bank_id, - document_id, - _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 document_id is not None: - _path_params['document_id'] = document_id - # process the query parameters - # process the header parameters - # 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}/documents/{document_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_entity( - self, - bank_id: StrictStr, - entity_id: StrictStr, - _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, - ) -> EntityDetailResponse: - """Get entity details - - Get detailed information about an entity including observations (mental model). - - :param bank_id: (required) - :type bank_id: str - :param entity_id: (required) - :type entity_id: 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_entity_serialize( - bank_id=bank_id, - entity_id=entity_id, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "EntityDetailResponse", - '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_entity_with_http_info( - self, - bank_id: StrictStr, - entity_id: StrictStr, - _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[EntityDetailResponse]: - """Get entity details - - Get detailed information about an entity including observations (mental model). - - :param bank_id: (required) - :type bank_id: str - :param entity_id: (required) - :type entity_id: 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_entity_serialize( - bank_id=bank_id, - entity_id=entity_id, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "EntityDetailResponse", - '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_entity_without_preload_content( - self, - bank_id: StrictStr, - entity_id: StrictStr, - _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 entity details - - Get detailed information about an entity including observations (mental model). - - :param bank_id: (required) - :type bank_id: str - :param entity_id: (required) - :type entity_id: 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_entity_serialize( - bank_id=bank_id, - entity_id=entity_id, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "EntityDetailResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _get_entity_serialize( - self, - bank_id, - entity_id, - _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 entity_id is not None: - _path_params['entity_id'] = entity_id - # process the query parameters - # process the header parameters - # 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}/entities/{entity_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_graph( - self, - bank_id: StrictStr, - type: 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/opinion). Limited to 1000 most recent items. - - :param bank_id: (required) - :type bank_id: str - :param type: - :type type: 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, - _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, - _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/opinion). Limited to 1000 most recent items. - - :param bank_id: (required) - :type bank_id: str - :param type: - :type type: 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, - _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, - _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/opinion). Limited to 1000 most recent items. - - :param bank_id: (required) - :type bank_id: str - :param type: - :type type: 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, - _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, - _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 - if type is not None: - - _query_params.append(('type', type)) - - # process the header parameters - # 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 list_banks( - self, - _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, - ) -> BankListResponse: - """List all memory banks - - Get a list of all agents with their profiles - - :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._list_banks_serialize( - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "BankListResponse", - } - 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 list_banks_with_http_info( - self, - _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[BankListResponse]: - """List all memory banks - - Get a list of all agents with their profiles - - :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._list_banks_serialize( - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "BankListResponse", - } - 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 list_banks_without_preload_content( - self, - _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: - """List all memory banks - - Get a list of all agents with their profiles - - :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._list_banks_serialize( - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "BankListResponse", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _list_banks_serialize( - self, - _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 - # process the query parameters - # process the header parameters - # 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', - 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 list_documents( - self, - bank_id: StrictStr, - q: Optional[StrictStr] = None, - limit: Optional[StrictInt] = None, - offset: Optional[StrictInt] = 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, - ) -> ListDocumentsResponse: - """List documents - - List documents with pagination and optional search. Documents are the source content from which memory units are extracted. - - :param bank_id: (required) - :type bank_id: str - :param q: - :type q: str - :param limit: - :type limit: int - :param offset: - :type offset: int - :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._list_documents_serialize( - bank_id=bank_id, - q=q, - limit=limit, - offset=offset, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "ListDocumentsResponse", - '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 list_documents_with_http_info( - self, - bank_id: StrictStr, - q: Optional[StrictStr] = None, - limit: Optional[StrictInt] = None, - offset: Optional[StrictInt] = 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[ListDocumentsResponse]: - """List documents - - List documents with pagination and optional search. Documents are the source content from which memory units are extracted. - - :param bank_id: (required) - :type bank_id: str - :param q: - :type q: str - :param limit: - :type limit: int - :param offset: - :type offset: int - :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._list_documents_serialize( - bank_id=bank_id, - q=q, - limit=limit, - offset=offset, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "ListDocumentsResponse", - '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 list_documents_without_preload_content( - self, - bank_id: StrictStr, - q: Optional[StrictStr] = None, - limit: Optional[StrictInt] = None, - offset: Optional[StrictInt] = 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: - """List documents - - List documents with pagination and optional search. Documents are the source content from which memory units are extracted. - - :param bank_id: (required) - :type bank_id: str - :param q: - :type q: str - :param limit: - :type limit: int - :param offset: - :type offset: int - :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._list_documents_serialize( - bank_id=bank_id, - q=q, - limit=limit, - offset=offset, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "ListDocumentsResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _list_documents_serialize( - self, - bank_id, - q, - limit, - offset, - _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 - if q is not None: - - _query_params.append(('q', q)) - - if limit is not None: - - _query_params.append(('limit', limit)) - - if offset is not None: - - _query_params.append(('offset', offset)) - - # process the header parameters - # 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}/documents', - 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 list_entities( - self, - bank_id: StrictStr, - limit: Annotated[Optional[StrictInt], Field(description="Maximum number of entities to return")] = 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, - ) -> EntityListResponse: - """List entities - - List all entities (people, organizations, etc.) known by the bank, ordered by mention count. - - :param bank_id: (required) - :type bank_id: str - :param limit: Maximum number of entities to return - :type limit: int - :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._list_entities_serialize( - bank_id=bank_id, - limit=limit, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "EntityListResponse", - '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 list_entities_with_http_info( - self, - bank_id: StrictStr, - limit: Annotated[Optional[StrictInt], Field(description="Maximum number of entities to return")] = 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[EntityListResponse]: - """List entities - - List all entities (people, organizations, etc.) known by the bank, ordered by mention count. - - :param bank_id: (required) - :type bank_id: str - :param limit: Maximum number of entities to return - :type limit: int - :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._list_entities_serialize( - bank_id=bank_id, - limit=limit, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "EntityListResponse", - '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 list_entities_without_preload_content( - self, - bank_id: StrictStr, - limit: Annotated[Optional[StrictInt], Field(description="Maximum number of entities to return")] = 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: - """List entities - - List all entities (people, organizations, etc.) known by the bank, ordered by mention count. - - :param bank_id: (required) - :type bank_id: str - :param limit: Maximum number of entities to return - :type limit: int - :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._list_entities_serialize( - bank_id=bank_id, - limit=limit, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "EntityListResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _list_entities_serialize( - self, - bank_id, - limit, - _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 - if limit is not None: - - _query_params.append(('limit', limit)) - - # process the header parameters - # 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}/entities', - 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 list_memories( - self, - bank_id: StrictStr, - type: Optional[StrictStr] = None, - q: Optional[StrictStr] = None, - limit: Optional[StrictInt] = None, - offset: Optional[StrictInt] = 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, - ) -> ListMemoryUnitsResponse: - """List memory units - - 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 type: - :type type: str - :param q: - :type q: str - :param limit: - :type limit: int - :param offset: - :type offset: int - :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._list_memories_serialize( - bank_id=bank_id, - type=type, - q=q, - limit=limit, - offset=offset, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "ListMemoryUnitsResponse", - '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 list_memories_with_http_info( - self, - bank_id: StrictStr, - type: Optional[StrictStr] = None, - q: Optional[StrictStr] = None, - limit: Optional[StrictInt] = None, - offset: Optional[StrictInt] = 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[ListMemoryUnitsResponse]: - """List memory units - - 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 type: - :type type: str - :param q: - :type q: str - :param limit: - :type limit: int - :param offset: - :type offset: int - :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._list_memories_serialize( - bank_id=bank_id, - type=type, - q=q, - limit=limit, - offset=offset, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "ListMemoryUnitsResponse", - '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 list_memories_without_preload_content( - self, - bank_id: StrictStr, - type: Optional[StrictStr] = None, - q: Optional[StrictStr] = None, - limit: Optional[StrictInt] = None, - offset: Optional[StrictInt] = 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: - """List memory units - - 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 type: - :type type: str - :param q: - :type q: str - :param limit: - :type limit: int - :param offset: - :type offset: int - :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._list_memories_serialize( - bank_id=bank_id, - type=type, - q=q, - limit=limit, - offset=offset, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "ListMemoryUnitsResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _list_memories_serialize( - self, - bank_id, - type, - q, - limit, - offset, - _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 - if type is not None: - - _query_params.append(('type', type)) - - if q is not None: - - _query_params.append(('q', q)) - - if limit is not None: - - _query_params.append(('limit', limit)) - - if offset is not None: - - _query_params.append(('offset', offset)) - - # process the header parameters - # 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/list', - 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 list_operations( - self, - bank_id: StrictStr, - _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: - """List async operations - - Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations - - :param bank_id: (required) - :type bank_id: 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._list_operations_serialize( - bank_id=bank_id, - _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 list_operations_with_http_info( - self, - bank_id: StrictStr, - _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[object]: - """List async operations - - Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations - - :param bank_id: (required) - :type bank_id: 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._list_operations_serialize( - bank_id=bank_id, - _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 list_operations_without_preload_content( - self, - bank_id: StrictStr, - _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: - """List async operations - - Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations - - :param bank_id: (required) - :type bank_id: 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._list_operations_serialize( - bank_id=bank_id, - _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 _list_operations_serialize( - self, - bank_id, - _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 - # 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}/operations', - 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 recall_memories( - self, - bank_id: StrictStr, - recall_request: RecallRequest, - _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, - ) -> RecallResponse: - """Recall memory - - 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 - 'opinion': The bank's formed beliefs, perspectives, and viewpoints Set include_entities=true to get entity observations alongside recall results. - - :param bank_id: (required) - :type bank_id: str - :param recall_request: (required) - :type recall_request: RecallRequest - :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._recall_memories_serialize( - bank_id=bank_id, - recall_request=recall_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "RecallResponse", - '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 recall_memories_with_http_info( - self, - bank_id: StrictStr, - recall_request: RecallRequest, - _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[RecallResponse]: - """Recall memory - - 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 - 'opinion': The bank's formed beliefs, perspectives, and viewpoints Set include_entities=true to get entity observations alongside recall results. - - :param bank_id: (required) - :type bank_id: str - :param recall_request: (required) - :type recall_request: RecallRequest - :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._recall_memories_serialize( - bank_id=bank_id, - recall_request=recall_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "RecallResponse", - '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 recall_memories_without_preload_content( - self, - bank_id: StrictStr, - recall_request: RecallRequest, - _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: - """Recall memory - - 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 - 'opinion': The bank's formed beliefs, perspectives, and viewpoints Set include_entities=true to get entity observations alongside recall results. - - :param bank_id: (required) - :type bank_id: str - :param recall_request: (required) - :type recall_request: RecallRequest - :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._recall_memories_serialize( - bank_id=bank_id, - recall_request=recall_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "RecallResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _recall_memories_serialize( - self, - bank_id, - recall_request, - _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 - # process the form parameters - # process the body parameter - if recall_request is not None: - _body_params = recall_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/recall', - 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 reflect( - self, - bank_id: StrictStr, - reflect_request: ReflectRequest, - _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, - ) -> ReflectResponse: - """Reflect and generate answer - - 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. Extracts and stores any new opinions formed 6. Returns plain text answer, the facts used, and new opinions - - :param bank_id: (required) - :type bank_id: str - :param reflect_request: (required) - :type reflect_request: ReflectRequest - :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._reflect_serialize( - bank_id=bank_id, - reflect_request=reflect_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "ReflectResponse", - '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 reflect_with_http_info( - self, - bank_id: StrictStr, - reflect_request: ReflectRequest, - _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[ReflectResponse]: - """Reflect and generate answer - - 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. Extracts and stores any new opinions formed 6. Returns plain text answer, the facts used, and new opinions - - :param bank_id: (required) - :type bank_id: str - :param reflect_request: (required) - :type reflect_request: ReflectRequest - :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._reflect_serialize( - bank_id=bank_id, - reflect_request=reflect_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "ReflectResponse", - '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 reflect_without_preload_content( - self, - bank_id: StrictStr, - reflect_request: ReflectRequest, - _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: - """Reflect and generate answer - - 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. Extracts and stores any new opinions formed 6. Returns plain text answer, the facts used, and new opinions - - :param bank_id: (required) - :type bank_id: str - :param reflect_request: (required) - :type reflect_request: ReflectRequest - :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._reflect_serialize( - bank_id=bank_id, - reflect_request=reflect_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "ReflectResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _reflect_serialize( - self, - bank_id, - reflect_request, - _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 - # process the form parameters - # process the body parameter - if reflect_request is not None: - _body_params = reflect_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}/reflect', - 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 regenerate_entity_observations( - self, - bank_id: StrictStr, - entity_id: StrictStr, - _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, - ) -> EntityDetailResponse: - """Regenerate entity observations - - Regenerate observations for an entity based on all facts mentioning it. - - :param bank_id: (required) - :type bank_id: str - :param entity_id: (required) - :type entity_id: 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._regenerate_entity_observations_serialize( - bank_id=bank_id, - entity_id=entity_id, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "EntityDetailResponse", - '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 regenerate_entity_observations_with_http_info( - self, - bank_id: StrictStr, - entity_id: StrictStr, - _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[EntityDetailResponse]: - """Regenerate entity observations - - Regenerate observations for an entity based on all facts mentioning it. - - :param bank_id: (required) - :type bank_id: str - :param entity_id: (required) - :type entity_id: 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._regenerate_entity_observations_serialize( - bank_id=bank_id, - entity_id=entity_id, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "EntityDetailResponse", - '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 regenerate_entity_observations_without_preload_content( - self, - bank_id: StrictStr, - entity_id: StrictStr, - _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: - """Regenerate entity observations - - Regenerate observations for an entity based on all facts mentioning it. - - :param bank_id: (required) - :type bank_id: str - :param entity_id: (required) - :type entity_id: 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._regenerate_entity_observations_serialize( - bank_id=bank_id, - entity_id=entity_id, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "EntityDetailResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _regenerate_entity_observations_serialize( - self, - bank_id, - entity_id, - _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 entity_id is not None: - _path_params['entity_id'] = entity_id - # process the query parameters - # process the header parameters - # 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}/entities/{entity_id}/regenerate', - 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 retain_memories( - self, - bank_id: StrictStr, - retain_request: RetainRequest, - _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, - ) -> RetainResponse: - """Retain memories - - 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 on items) - 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 the task - Processing happens in the background - Use the operations endpoint to monitor progress When async=false (default): - Waits for processing to complete - Returns after all memories are stored 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). Items with the same document_id are grouped together for efficient processing. - - :param bank_id: (required) - :type bank_id: str - :param retain_request: (required) - :type retain_request: RetainRequest - :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._retain_memories_serialize( - bank_id=bank_id, - retain_request=retain_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "RetainResponse", - '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 retain_memories_with_http_info( - self, - bank_id: StrictStr, - retain_request: RetainRequest, - _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[RetainResponse]: - """Retain memories - - 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 on items) - 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 the task - Processing happens in the background - Use the operations endpoint to monitor progress When async=false (default): - Waits for processing to complete - Returns after all memories are stored 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). Items with the same document_id are grouped together for efficient processing. - - :param bank_id: (required) - :type bank_id: str - :param retain_request: (required) - :type retain_request: RetainRequest - :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._retain_memories_serialize( - bank_id=bank_id, - retain_request=retain_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "RetainResponse", - '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 retain_memories_without_preload_content( - self, - bank_id: StrictStr, - retain_request: RetainRequest, - _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: - """Retain memories - - 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 on items) - 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 the task - Processing happens in the background - Use the operations endpoint to monitor progress When async=false (default): - Waits for processing to complete - Returns after all memories are stored 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). Items with the same document_id are grouped together for efficient processing. - - :param bank_id: (required) - :type bank_id: str - :param retain_request: (required) - :type retain_request: RetainRequest - :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._retain_memories_serialize( - bank_id=bank_id, - retain_request=retain_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "RetainResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _retain_memories_serialize( - self, - bank_id, - retain_request, - _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 - # process the form parameters - # process the body parameter - if retain_request is not None: - _body_params = retain_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', - 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 update_bank_disposition( - self, - bank_id: StrictStr, - update_disposition_request: UpdateDispositionRequest, - _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, - ) -> BankProfileResponse: - """Update memory bank disposition - - Update bank's disposition traits (skepticism, literalism, empathy) - - :param bank_id: (required) - :type bank_id: str - :param update_disposition_request: (required) - :type update_disposition_request: UpdateDispositionRequest - :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._update_bank_disposition_serialize( - bank_id=bank_id, - update_disposition_request=update_disposition_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "BankProfileResponse", - '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 update_bank_disposition_with_http_info( - self, - bank_id: StrictStr, - update_disposition_request: UpdateDispositionRequest, - _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[BankProfileResponse]: - """Update memory bank disposition - - Update bank's disposition traits (skepticism, literalism, empathy) - - :param bank_id: (required) - :type bank_id: str - :param update_disposition_request: (required) - :type update_disposition_request: UpdateDispositionRequest - :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._update_bank_disposition_serialize( - bank_id=bank_id, - update_disposition_request=update_disposition_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "BankProfileResponse", - '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 update_bank_disposition_without_preload_content( - self, - bank_id: StrictStr, - update_disposition_request: UpdateDispositionRequest, - _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: - """Update memory bank disposition - - Update bank's disposition traits (skepticism, literalism, empathy) - - :param bank_id: (required) - :type bank_id: str - :param update_disposition_request: (required) - :type update_disposition_request: UpdateDispositionRequest - :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._update_bank_disposition_serialize( - bank_id=bank_id, - update_disposition_request=update_disposition_request, - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "BankProfileResponse", - '422': "HTTPValidationError", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _update_bank_disposition_serialize( - self, - bank_id, - update_disposition_request, - _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 - # process the form parameters - # process the body parameter - if update_disposition_request is not None: - _body_params = update_disposition_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='PUT', - resource_path='/v1/default/banks/{bank_id}/profile', - 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 - ) - - diff --git a/hindsight-clients/python/hindsight_client_api/api/monitoring_api.py b/hindsight-clients/python/hindsight_client_api/api/monitoring_api.py deleted file mode 100644 index 6acc95f1..00000000 --- a/hindsight-clients/python/hindsight_client_api/api/monitoring_api.py +++ /dev/null @@ -1,526 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - -import warnings -from pydantic import validate_call, Field, StrictFloat, StrictStr, StrictInt -from typing import Any, Dict, List, Optional, Tuple, Union -from typing_extensions import Annotated - -from typing import Any - -from hindsight_client_api.api_client import ApiClient, RequestSerialized -from hindsight_client_api.api_response import ApiResponse -from hindsight_client_api.rest import RESTResponseType - - -class MonitoringApi: - """NOTE: This class is auto generated by OpenAPI Generator - Ref: https://openapi-generator.tech - - Do not edit the class manually. - """ - - def __init__(self, api_client=None) -> None: - if api_client is None: - api_client = ApiClient.get_default() - self.api_client = api_client - - - @validate_call - async def health_endpoint_health_get( - self, - _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: - """Health check endpoint - - Checks the health of the API and database connection - - :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._health_endpoint_health_get_serialize( - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "object", - } - 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 health_endpoint_health_get_with_http_info( - self, - _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[object]: - """Health check endpoint - - Checks the health of the API and database connection - - :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._health_endpoint_health_get_serialize( - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "object", - } - 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 health_endpoint_health_get_without_preload_content( - self, - _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: - """Health check endpoint - - Checks the health of the API and database connection - - :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._health_endpoint_health_get_serialize( - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "object", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _health_endpoint_health_get_serialize( - self, - _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 - # process the query parameters - # process the header parameters - # 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='/health', - 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 metrics_endpoint_metrics_get( - self, - _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: - """Prometheus metrics endpoint - - Exports metrics in Prometheus format for scraping - - :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._metrics_endpoint_metrics_get_serialize( - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "object", - } - 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 metrics_endpoint_metrics_get_with_http_info( - self, - _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[object]: - """Prometheus metrics endpoint - - Exports metrics in Prometheus format for scraping - - :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._metrics_endpoint_metrics_get_serialize( - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "object", - } - 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 metrics_endpoint_metrics_get_without_preload_content( - self, - _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: - """Prometheus metrics endpoint - - Exports metrics in Prometheus format for scraping - - :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._metrics_endpoint_metrics_get_serialize( - _request_auth=_request_auth, - _content_type=_content_type, - _headers=_headers, - _host_index=_host_index - ) - - _response_types_map: Dict[str, Optional[str]] = { - '200': "object", - } - response_data = await self.api_client.call_api( - *_param, - _request_timeout=_request_timeout - ) - return response_data.response - - - def _metrics_endpoint_metrics_get_serialize( - self, - _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 - # process the query parameters - # process the header parameters - # 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='/metrics', - 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 - ) - - diff --git a/hindsight-clients/python/hindsight_client_api/api_client.py b/hindsight-clients/python/hindsight_client_api/api_client.py deleted file mode 100644 index 5d66fd88..00000000 --- a/hindsight-clients/python/hindsight_client_api/api_client.py +++ /dev/null @@ -1,807 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import datetime -from dateutil.parser import parse -from enum import Enum -import decimal -import json -import mimetypes -import os -import re -import tempfile -import uuid - -from urllib.parse import quote -from typing import Tuple, Optional, List, Dict, Union -from pydantic import SecretStr - -from hindsight_client_api.configuration import Configuration -from hindsight_client_api.api_response import ApiResponse, T as ApiResponseT -import hindsight_client_api.models -from hindsight_client_api import rest -from hindsight_client_api.exceptions import ( - ApiValueError, - ApiException, - BadRequestException, - UnauthorizedException, - ForbiddenException, - NotFoundException, - ServiceException -) - -RequestSerialized = Tuple[str, str, Dict[str, str], Optional[str], List[str]] - -class ApiClient: - """Generic API client for OpenAPI client library builds. - - OpenAPI generic API client. This client handles the client- - server communication, and is invariant across implementations. Specifics of - the methods and models for each application are generated from the OpenAPI - templates. - - :param configuration: .Configuration object for this client - :param header_name: a header to pass when making calls to the API. - :param header_value: a header value to pass when making calls to - the API. - :param cookie: a cookie to include in the header when making calls - to the API - """ - - PRIMITIVE_TYPES = (float, bool, bytes, str, int) - NATIVE_TYPES_MAPPING = { - 'int': int, - 'long': int, # TODO remove as only py3 is supported? - 'float': float, - 'str': str, - 'bool': bool, - 'date': datetime.date, - 'datetime': datetime.datetime, - 'decimal': decimal.Decimal, - 'object': object, - } - _pool = None - - def __init__( - self, - configuration=None, - header_name=None, - header_value=None, - cookie=None - ) -> None: - # use default configuration if none is provided - if configuration is None: - configuration = Configuration.get_default() - self.configuration = configuration - - self.rest_client = rest.RESTClientObject(configuration) - self.default_headers = {} - if header_name is not None: - self.default_headers[header_name] = header_value - self.cookie = cookie - # Set default User-Agent. - self.user_agent = 'OpenAPI-Generator/0.0.7/python' - self.client_side_validation = configuration.client_side_validation - - async def __aenter__(self): - return self - - async def __aexit__(self, exc_type, exc_value, traceback): - await self.close() - - async def close(self): - await self.rest_client.close() - - @property - def user_agent(self): - """User agent for this API client""" - return self.default_headers['User-Agent'] - - @user_agent.setter - def user_agent(self, value): - self.default_headers['User-Agent'] = value - - def set_default_header(self, header_name, header_value): - self.default_headers[header_name] = header_value - - - _default = None - - @classmethod - def get_default(cls): - """Return new instance of ApiClient. - - This method returns newly created, based on default constructor, - object of ApiClient class or returns a copy of default - ApiClient. - - :return: The ApiClient object. - """ - if cls._default is None: - cls._default = ApiClient() - return cls._default - - @classmethod - def set_default(cls, default): - """Set default instance of ApiClient. - - It stores default ApiClient. - - :param default: object of ApiClient. - """ - cls._default = default - - def param_serialize( - self, - method, - resource_path, - path_params=None, - query_params=None, - header_params=None, - body=None, - post_params=None, - files=None, auth_settings=None, - collection_formats=None, - _host=None, - _request_auth=None - ) -> RequestSerialized: - - """Builds the HTTP request params needed by the request. - :param method: Method to call. - :param resource_path: Path to method endpoint. - :param path_params: Path parameters in the url. - :param query_params: Query parameters in the url. - :param header_params: Header parameters to be - placed in the request header. - :param body: Request body. - :param post_params dict: Request post form parameters, - for `application/x-www-form-urlencoded`, `multipart/form-data`. - :param auth_settings list: Auth Settings names for the request. - :param files dict: key -> filename, value -> filepath, - for `multipart/form-data`. - :param collection_formats: dict of collection formats for path, query, - header, and post parameters. - :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. - :return: tuple of form (path, http_method, query_params, header_params, - body, post_params, files) - """ - - config = self.configuration - - # header parameters - header_params = header_params or {} - header_params.update(self.default_headers) - if self.cookie: - header_params['Cookie'] = self.cookie - if header_params: - header_params = self.sanitize_for_serialization(header_params) - header_params = dict( - self.parameters_to_tuples(header_params,collection_formats) - ) - - # path parameters - if path_params: - path_params = self.sanitize_for_serialization(path_params) - path_params = self.parameters_to_tuples( - path_params, - collection_formats - ) - for k, v in path_params: - # specified safe chars, encode everything - resource_path = resource_path.replace( - '{%s}' % k, - quote(str(v), safe=config.safe_chars_for_path_param) - ) - - # post parameters - if post_params or files: - post_params = post_params if post_params else [] - post_params = self.sanitize_for_serialization(post_params) - post_params = self.parameters_to_tuples( - post_params, - collection_formats - ) - if files: - post_params.extend(self.files_parameters(files)) - - # auth setting - self.update_params_for_auth( - header_params, - query_params, - auth_settings, - resource_path, - method, - body, - request_auth=_request_auth - ) - - # body - if body: - body = self.sanitize_for_serialization(body) - - # request url - if _host is None or self.configuration.ignore_operation_servers: - url = self.configuration.host + resource_path - else: - # use server/host defined in path or operation instead - url = _host + resource_path - - # query parameters - if query_params: - query_params = self.sanitize_for_serialization(query_params) - url_query = self.parameters_to_url_query( - query_params, - collection_formats - ) - url += "?" + url_query - - return method, url, header_params, body, post_params - - - async def call_api( - self, - method, - url, - header_params=None, - body=None, - post_params=None, - _request_timeout=None - ) -> rest.RESTResponse: - """Makes the HTTP request (synchronous) - :param method: Method to call. - :param url: Path to method endpoint. - :param header_params: Header parameters to be - placed in the request header. - :param body: Request body. - :param post_params dict: Request post form parameters, - for `application/x-www-form-urlencoded`, `multipart/form-data`. - :param _request_timeout: timeout setting for this request. - :return: RESTResponse - """ - - try: - # perform request and return response - response_data = await self.rest_client.request( - method, url, - headers=header_params, - body=body, post_params=post_params, - _request_timeout=_request_timeout - ) - - except ApiException as e: - raise e - - return response_data - - def response_deserialize( - self, - response_data: rest.RESTResponse, - response_types_map: Optional[Dict[str, ApiResponseT]]=None - ) -> ApiResponse[ApiResponseT]: - """Deserializes response into an object. - :param response_data: RESTResponse object to be deserialized. - :param response_types_map: dict of response types. - :return: ApiResponse - """ - - msg = "RESTResponse.read() must be called before passing it to response_deserialize()" - assert response_data.data is not None, msg - - response_type = response_types_map.get(str(response_data.status), None) - if not response_type and isinstance(response_data.status, int) and 100 <= response_data.status <= 599: - # if not found, look for '1XX', '2XX', etc. - response_type = response_types_map.get(str(response_data.status)[0] + "XX", None) - - # deserialize response data - response_text = None - return_data = None - try: - if response_type == "bytearray": - return_data = response_data.data - elif response_type == "file": - return_data = self.__deserialize_file(response_data) - elif response_type is not None: - match = None - content_type = response_data.getheader('content-type') - if content_type is not None: - match = re.search(r"charset=([a-zA-Z\-\d]+)[\s;]?", content_type) - encoding = match.group(1) if match else "utf-8" - response_text = response_data.data.decode(encoding) - return_data = self.deserialize(response_text, response_type, content_type) - finally: - if not 200 <= response_data.status <= 299: - raise ApiException.from_response( - http_resp=response_data, - body=response_text, - data=return_data, - ) - - return ApiResponse( - status_code = response_data.status, - data = return_data, - headers = response_data.getheaders(), - raw_data = response_data.data - ) - - def sanitize_for_serialization(self, obj): - """Builds a JSON POST object. - - If obj is None, return None. - If obj is SecretStr, return obj.get_secret_value() - If obj is str, int, long, float, bool, return directly. - If obj is datetime.datetime, datetime.date - convert to string in iso8601 format. - If obj is decimal.Decimal return string representation. - If obj is list, sanitize each element in the list. - If obj is dict, return the dict. - If obj is OpenAPI model, return the properties dict. - - :param obj: The data to serialize. - :return: The serialized form of data. - """ - if obj is None: - return None - elif isinstance(obj, Enum): - return obj.value - elif isinstance(obj, SecretStr): - return obj.get_secret_value() - elif isinstance(obj, self.PRIMITIVE_TYPES): - return obj - elif isinstance(obj, uuid.UUID): - return str(obj) - elif isinstance(obj, list): - return [ - self.sanitize_for_serialization(sub_obj) for sub_obj in obj - ] - elif isinstance(obj, tuple): - return tuple( - self.sanitize_for_serialization(sub_obj) for sub_obj in obj - ) - elif isinstance(obj, (datetime.datetime, datetime.date)): - return obj.isoformat() - elif isinstance(obj, decimal.Decimal): - return str(obj) - - elif isinstance(obj, dict): - obj_dict = obj - else: - # Convert model obj to dict except - # attributes `openapi_types`, `attribute_map` - # and attributes which value is not None. - # Convert attribute name to json key in - # model definition for request. - if hasattr(obj, 'to_dict') and callable(getattr(obj, 'to_dict')): - obj_dict = obj.to_dict() - else: - obj_dict = obj.__dict__ - - if isinstance(obj_dict, list): - # here we handle instances that can either be a list or something else, and only became a real list by calling to_dict() - return self.sanitize_for_serialization(obj_dict) - - return { - key: self.sanitize_for_serialization(val) - for key, val in obj_dict.items() - } - - def deserialize(self, response_text: str, response_type: str, content_type: Optional[str]): - """Deserializes response into an object. - - :param response: RESTResponse object to be deserialized. - :param response_type: class literal for - deserialized object, or string of class name. - :param content_type: content type of response. - - :return: deserialized object. - """ - - # fetch data from response object - if content_type is None: - try: - data = json.loads(response_text) - except ValueError: - data = response_text - elif re.match(r'^application/(json|[\w!#$&.+\-^_]+\+json)\s*(;|$)', content_type, re.IGNORECASE): - if response_text == "": - data = "" - else: - data = json.loads(response_text) - elif re.match(r'^text\/[a-z.+-]+\s*(;|$)', content_type, re.IGNORECASE): - data = response_text - else: - raise ApiException( - status=0, - reason="Unsupported content type: {0}".format(content_type) - ) - - return self.__deserialize(data, response_type) - - def __deserialize(self, data, klass): - """Deserializes dict, list, str into an object. - - :param data: dict, list or str. - :param klass: class literal, or string of class name. - - :return: object. - """ - if data is None: - return None - - if isinstance(klass, str): - if klass.startswith('List['): - m = re.match(r'List\[(.*)]', klass) - assert m is not None, "Malformed List type definition" - sub_kls = m.group(1) - return [self.__deserialize(sub_data, sub_kls) - for sub_data in data] - - if klass.startswith('Dict['): - m = re.match(r'Dict\[([^,]*), (.*)]', klass) - assert m is not None, "Malformed Dict type definition" - sub_kls = m.group(2) - return {k: self.__deserialize(v, sub_kls) - for k, v in data.items()} - - # convert str to class - if klass in self.NATIVE_TYPES_MAPPING: - klass = self.NATIVE_TYPES_MAPPING[klass] - else: - klass = getattr(hindsight_client_api.models, klass) - - if klass in self.PRIMITIVE_TYPES: - return self.__deserialize_primitive(data, klass) - elif klass is object: - return self.__deserialize_object(data) - elif klass is datetime.date: - return self.__deserialize_date(data) - elif klass is datetime.datetime: - return self.__deserialize_datetime(data) - elif klass is decimal.Decimal: - return decimal.Decimal(data) - elif issubclass(klass, Enum): - return self.__deserialize_enum(data, klass) - else: - return self.__deserialize_model(data, klass) - - def parameters_to_tuples(self, params, collection_formats): - """Get parameters as list of tuples, formatting collections. - - :param params: Parameters as dict or list of two-tuples - :param dict collection_formats: Parameter collection formats - :return: Parameters as list of tuples, collections formatted - """ - new_params: List[Tuple[str, str]] = [] - if collection_formats is None: - collection_formats = {} - for k, v in params.items() if isinstance(params, dict) else params: - if k in collection_formats: - collection_format = collection_formats[k] - if collection_format == 'multi': - new_params.extend((k, value) for value in v) - else: - if collection_format == 'ssv': - delimiter = ' ' - elif collection_format == 'tsv': - delimiter = '\t' - elif collection_format == 'pipes': - delimiter = '|' - else: # csv is the default - delimiter = ',' - new_params.append( - (k, delimiter.join(str(value) for value in v))) - else: - new_params.append((k, v)) - return new_params - - def parameters_to_url_query(self, params, collection_formats): - """Get parameters as list of tuples, formatting collections. - - :param params: Parameters as dict or list of two-tuples - :param dict collection_formats: Parameter collection formats - :return: URL query string (e.g. a=Hello%20World&b=123) - """ - new_params: List[Tuple[str, str]] = [] - if collection_formats is None: - collection_formats = {} - for k, v in params.items() if isinstance(params, dict) else params: - if isinstance(v, bool): - v = str(v).lower() - if isinstance(v, (int, float)): - v = str(v) - if isinstance(v, dict): - v = json.dumps(v) - - if k in collection_formats: - collection_format = collection_formats[k] - if collection_format == 'multi': - new_params.extend((k, quote(str(value))) for value in v) - else: - if collection_format == 'ssv': - delimiter = ' ' - elif collection_format == 'tsv': - delimiter = '\t' - elif collection_format == 'pipes': - delimiter = '|' - else: # csv is the default - delimiter = ',' - new_params.append( - (k, delimiter.join(quote(str(value)) for value in v)) - ) - else: - new_params.append((k, quote(str(v)))) - - return "&".join(["=".join(map(str, item)) for item in new_params]) - - def files_parameters( - self, - files: Dict[str, Union[str, bytes, List[str], List[bytes], Tuple[str, bytes]]], - ): - """Builds form parameters. - - :param files: File parameters. - :return: Form parameters with files. - """ - params = [] - for k, v in files.items(): - if isinstance(v, str): - with open(v, 'rb') as f: - filename = os.path.basename(f.name) - filedata = f.read() - elif isinstance(v, bytes): - filename = k - filedata = v - elif isinstance(v, tuple): - filename, filedata = v - elif isinstance(v, list): - for file_param in v: - params.extend(self.files_parameters({k: file_param})) - continue - else: - raise ValueError("Unsupported file value") - mimetype = ( - mimetypes.guess_type(filename)[0] - or 'application/octet-stream' - ) - params.append( - tuple([k, tuple([filename, filedata, mimetype])]) - ) - return params - - def select_header_accept(self, accepts: List[str]) -> Optional[str]: - """Returns `Accept` based on an array of accepts provided. - - :param accepts: List of headers. - :return: Accept (e.g. application/json). - """ - if not accepts: - return None - - for accept in accepts: - if re.search('json', accept, re.IGNORECASE): - return accept - - return accepts[0] - - def select_header_content_type(self, content_types): - """Returns `Content-Type` based on an array of content_types provided. - - :param content_types: List of content-types. - :return: Content-Type (e.g. application/json). - """ - if not content_types: - return None - - for content_type in content_types: - if re.search('json', content_type, re.IGNORECASE): - return content_type - - return content_types[0] - - def update_params_for_auth( - self, - headers, - queries, - auth_settings, - resource_path, - method, - body, - request_auth=None - ) -> None: - """Updates header and query params based on authentication setting. - - :param headers: Header parameters dict to be updated. - :param queries: Query parameters tuple list to be updated. - :param auth_settings: Authentication setting identifiers list. - :resource_path: A string representation of the HTTP request resource path. - :method: A string representation of the HTTP request method. - :body: A object representing the body of the HTTP request. - The object type is the return value of sanitize_for_serialization(). - :param request_auth: if set, the provided settings will - override the token in the configuration. - """ - if not auth_settings: - return - - if request_auth: - self._apply_auth_params( - headers, - queries, - resource_path, - method, - body, - request_auth - ) - else: - for auth in auth_settings: - auth_setting = self.configuration.auth_settings().get(auth) - if auth_setting: - self._apply_auth_params( - headers, - queries, - resource_path, - method, - body, - auth_setting - ) - - def _apply_auth_params( - self, - headers, - queries, - resource_path, - method, - body, - auth_setting - ) -> None: - """Updates the request parameters based on a single auth_setting - - :param headers: Header parameters dict to be updated. - :param queries: Query parameters tuple list to be updated. - :resource_path: A string representation of the HTTP request resource path. - :method: A string representation of the HTTP request method. - :body: A object representing the body of the HTTP request. - The object type is the return value of sanitize_for_serialization(). - :param auth_setting: auth settings for the endpoint - """ - if auth_setting['in'] == 'cookie': - headers['Cookie'] = auth_setting['value'] - elif auth_setting['in'] == 'header': - if auth_setting['type'] != 'http-signature': - headers[auth_setting['key']] = auth_setting['value'] - elif auth_setting['in'] == 'query': - queries.append((auth_setting['key'], auth_setting['value'])) - else: - raise ApiValueError( - 'Authentication token must be in `query` or `header`' - ) - - def __deserialize_file(self, response): - """Deserializes body to file - - Saves response body into a file in a temporary folder, - using the filename from the `Content-Disposition` header if provided. - - handle file downloading - save response body into a tmp file and return the instance - - :param response: RESTResponse. - :return: file path. - """ - fd, path = tempfile.mkstemp(dir=self.configuration.temp_folder_path) - os.close(fd) - os.remove(path) - - content_disposition = response.getheader("Content-Disposition") - if content_disposition: - m = re.search( - r'filename=[\'"]?([^\'"\s]+)[\'"]?', - content_disposition - ) - assert m is not None, "Unexpected 'content-disposition' header value" - filename = m.group(1) - path = os.path.join(os.path.dirname(path), filename) - - with open(path, "wb") as f: - f.write(response.data) - - return path - - def __deserialize_primitive(self, data, klass): - """Deserializes string to primitive type. - - :param data: str. - :param klass: class literal. - - :return: int, long, float, str, bool. - """ - try: - return klass(data) - except UnicodeEncodeError: - return str(data) - except TypeError: - return data - - def __deserialize_object(self, value): - """Return an original value. - - :return: object. - """ - return value - - def __deserialize_date(self, string): - """Deserializes string to date. - - :param string: str. - :return: date. - """ - try: - return parse(string).date() - except ImportError: - return string - except ValueError: - raise rest.ApiException( - status=0, - reason="Failed to parse `{0}` as date object".format(string) - ) - - def __deserialize_datetime(self, string): - """Deserializes string to datetime. - - The string should be in iso8601 datetime format. - - :param string: str. - :return: datetime. - """ - try: - return parse(string) - except ImportError: - return string - except ValueError: - raise rest.ApiException( - status=0, - reason=( - "Failed to parse `{0}` as datetime object" - .format(string) - ) - ) - - def __deserialize_enum(self, data, klass): - """Deserializes primitive type to enum. - - :param data: primitive type. - :param klass: class literal. - :return: enum value. - """ - try: - return klass(data) - except ValueError: - raise rest.ApiException( - status=0, - reason=( - "Failed to parse `{0}` as `{1}`" - .format(data, klass) - ) - ) - - def __deserialize_model(self, data, klass): - """Deserializes list or dict to model. - - :param data: dict, list. - :param klass: class literal. - :return: model object. - """ - - return klass.from_dict(data) diff --git a/hindsight-clients/python/hindsight_client_api/api_response.py b/hindsight-clients/python/hindsight_client_api/api_response.py deleted file mode 100644 index 9bc7c11f..00000000 --- a/hindsight-clients/python/hindsight_client_api/api_response.py +++ /dev/null @@ -1,21 +0,0 @@ -"""API response object.""" - -from __future__ import annotations -from typing import Optional, Generic, Mapping, TypeVar -from pydantic import Field, StrictInt, StrictBytes, BaseModel - -T = TypeVar("T") - -class ApiResponse(BaseModel, Generic[T]): - """ - API response object - """ - - status_code: StrictInt = Field(description="HTTP status code") - headers: Optional[Mapping[str, str]] = Field(None, description="HTTP headers") - data: T = Field(description="Deserialized data given the data type") - raw_data: StrictBytes = Field(description="Raw data (HTTP response body)") - - model_config = { - "arbitrary_types_allowed": True - } diff --git a/hindsight-clients/python/hindsight_client_api/configuration.py b/hindsight-clients/python/hindsight_client_api/configuration.py deleted file mode 100644 index 0ac03966..00000000 --- a/hindsight-clients/python/hindsight_client_api/configuration.py +++ /dev/null @@ -1,572 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import copy -import http.client as httplib -import logging -from logging import FileHandler -import sys -from typing import Any, ClassVar, Dict, List, Literal, Optional, TypedDict, Union -from typing_extensions import NotRequired, Self - -import urllib3 - - -JSON_SCHEMA_VALIDATION_KEYWORDS = { - 'multipleOf', 'maximum', 'exclusiveMaximum', - 'minimum', 'exclusiveMinimum', 'maxLength', - 'minLength', 'pattern', 'maxItems', 'minItems' -} - -ServerVariablesT = Dict[str, str] - -GenericAuthSetting = TypedDict( - "GenericAuthSetting", - { - "type": str, - "in": str, - "key": str, - "value": str, - }, -) - - -OAuth2AuthSetting = TypedDict( - "OAuth2AuthSetting", - { - "type": Literal["oauth2"], - "in": Literal["header"], - "key": Literal["Authorization"], - "value": str, - }, -) - - -APIKeyAuthSetting = TypedDict( - "APIKeyAuthSetting", - { - "type": Literal["api_key"], - "in": str, - "key": str, - "value": Optional[str], - }, -) - - -BasicAuthSetting = TypedDict( - "BasicAuthSetting", - { - "type": Literal["basic"], - "in": Literal["header"], - "key": Literal["Authorization"], - "value": Optional[str], - }, -) - - -BearerFormatAuthSetting = TypedDict( - "BearerFormatAuthSetting", - { - "type": Literal["bearer"], - "in": Literal["header"], - "format": Literal["JWT"], - "key": Literal["Authorization"], - "value": str, - }, -) - - -BearerAuthSetting = TypedDict( - "BearerAuthSetting", - { - "type": Literal["bearer"], - "in": Literal["header"], - "key": Literal["Authorization"], - "value": str, - }, -) - - -HTTPSignatureAuthSetting = TypedDict( - "HTTPSignatureAuthSetting", - { - "type": Literal["http-signature"], - "in": Literal["header"], - "key": Literal["Authorization"], - "value": None, - }, -) - - -AuthSettings = TypedDict( - "AuthSettings", - { - }, - total=False, -) - - -class HostSettingVariable(TypedDict): - description: str - default_value: str - enum_values: List[str] - - -class HostSetting(TypedDict): - url: str - description: str - variables: NotRequired[Dict[str, HostSettingVariable]] - - -class Configuration: - """This class contains various settings of the API client. - - :param host: Base url. - :param ignore_operation_servers - Boolean to ignore operation servers for the API client. - Config will use `host` as the base url regardless of the operation servers. - :param api_key: Dict to store API key(s). - Each entry in the dict specifies an API key. - The dict key is the name of the security scheme in the OAS specification. - The dict value is the API key secret. - :param api_key_prefix: Dict to store API prefix (e.g. Bearer). - The dict key is the name of the security scheme in the OAS specification. - The dict value is an API key prefix when generating the auth data. - :param username: Username for HTTP basic authentication. - :param password: Password for HTTP basic authentication. - :param access_token: Access token. - :param server_index: Index to servers configuration. - :param server_variables: Mapping with string values to replace variables in - templated server configuration. The validation of enums is performed for - variables with defined enum values before. - :param server_operation_index: Mapping from operation ID to an index to server - configuration. - :param server_operation_variables: Mapping from operation ID to a mapping with - string values to replace variables in templated server configuration. - The validation of enums is performed for variables with defined enum - values before. - :param ssl_ca_cert: str - the path to a file of concatenated CA certificates - in PEM format. - :param retries: Number of retries for API requests. - :param ca_cert_data: verify the peer using concatenated CA certificate data - in PEM (str) or DER (bytes) format. - :param cert_file: the path to a client certificate file, for mTLS. - :param key_file: the path to a client key file, for mTLS. - - """ - - _default: ClassVar[Optional[Self]] = None - - def __init__( - self, - host: Optional[str]=None, - api_key: Optional[Dict[str, str]]=None, - api_key_prefix: Optional[Dict[str, str]]=None, - username: Optional[str]=None, - password: Optional[str]=None, - access_token: Optional[str]=None, - server_index: Optional[int]=None, - server_variables: Optional[ServerVariablesT]=None, - server_operation_index: Optional[Dict[int, int]]=None, - server_operation_variables: Optional[Dict[int, ServerVariablesT]]=None, - ignore_operation_servers: bool=False, - ssl_ca_cert: Optional[str]=None, - retries: Optional[int] = None, - ca_cert_data: Optional[Union[str, bytes]] = None, - cert_file: Optional[str]=None, - key_file: Optional[str]=None, - *, - debug: Optional[bool] = None, - ) -> None: - """Constructor - """ - self._base_path = "http://localhost" if host is None else host - """Default Base url - """ - self.server_index = 0 if server_index is None and host is None else server_index - self.server_operation_index = server_operation_index or {} - """Default server index - """ - self.server_variables = server_variables or {} - self.server_operation_variables = server_operation_variables or {} - """Default server variables - """ - self.ignore_operation_servers = ignore_operation_servers - """Ignore operation servers - """ - self.temp_folder_path = None - """Temp file folder for downloading files - """ - # Authentication Settings - self.api_key = {} - if api_key: - self.api_key = api_key - """dict to store API key(s) - """ - self.api_key_prefix = {} - if api_key_prefix: - self.api_key_prefix = api_key_prefix - """dict to store API prefix (e.g. Bearer) - """ - self.refresh_api_key_hook = None - """function hook to refresh API key if expired - """ - self.username = username - """Username for HTTP basic authentication - """ - self.password = password - """Password for HTTP basic authentication - """ - self.access_token = access_token - """Access token - """ - self.logger = {} - """Logging Settings - """ - self.logger["package_logger"] = logging.getLogger("hindsight_client_api") - self.logger["urllib3_logger"] = logging.getLogger("urllib3") - self.logger_format = '%(asctime)s %(levelname)s %(message)s' - """Log format - """ - self.logger_stream_handler = None - """Log stream handler - """ - self.logger_file_handler: Optional[FileHandler] = None - """Log file handler - """ - self.logger_file = None - """Debug file location - """ - if debug is not None: - self.debug = debug - else: - self.__debug = False - """Debug switch - """ - - self.verify_ssl = True - """SSL/TLS verification - Set this to false to skip verifying SSL certificate when calling API - from https server. - """ - self.ssl_ca_cert = ssl_ca_cert - """Set this to customize the certificate file to verify the peer. - """ - self.ca_cert_data = ca_cert_data - """Set this to verify the peer using PEM (str) or DER (bytes) - certificate data. - """ - self.cert_file = cert_file - """client certificate file - """ - self.key_file = key_file - """client key file - """ - self.assert_hostname = None - """Set this to True/False to enable/disable SSL hostname verification. - """ - self.tls_server_name = None - """SSL/TLS Server Name Indication (SNI) - Set this to the SNI value expected by the server. - """ - - self.connection_pool_maxsize = 100 - """This value is passed to the aiohttp to limit simultaneous connections. - Default values is 100, None means no-limit. - """ - - self.proxy: Optional[str] = None - """Proxy URL - """ - self.proxy_headers = None - """Proxy headers - """ - self.safe_chars_for_path_param = '' - """Safe chars for path_param - """ - self.retries = retries - """Adding retries to override urllib3 default value 3 - """ - # Enable client side validation - self.client_side_validation = True - - self.socket_options = None - """Options to pass down to the underlying urllib3 socket - """ - - self.datetime_format = "%Y-%m-%dT%H:%M:%S.%f%z" - """datetime format - """ - - self.date_format = "%Y-%m-%d" - """date format - """ - - def __deepcopy__(self, memo: Dict[int, Any]) -> Self: - cls = self.__class__ - result = cls.__new__(cls) - memo[id(self)] = result - for k, v in self.__dict__.items(): - if k not in ('logger', 'logger_file_handler'): - setattr(result, k, copy.deepcopy(v, memo)) - # shallow copy of loggers - result.logger = copy.copy(self.logger) - # use setters to configure loggers - result.logger_file = self.logger_file - result.debug = self.debug - return result - - def __setattr__(self, name: str, value: Any) -> None: - object.__setattr__(self, name, value) - - @classmethod - def set_default(cls, default: Optional[Self]) -> None: - """Set default instance of configuration. - - It stores default configuration, which can be - returned by get_default_copy method. - - :param default: object of Configuration - """ - cls._default = default - - @classmethod - def get_default_copy(cls) -> Self: - """Deprecated. Please use `get_default` instead. - - Deprecated. Please use `get_default` instead. - - :return: The configuration object. - """ - return cls.get_default() - - @classmethod - def get_default(cls) -> Self: - """Return the default configuration. - - This method returns newly created, based on default constructor, - object of Configuration class or returns a copy of default - configuration. - - :return: The configuration object. - """ - if cls._default is None: - cls._default = cls() - return cls._default - - @property - def logger_file(self) -> Optional[str]: - """The logger file. - - If the logger_file is None, then add stream handler and remove file - handler. Otherwise, add file handler and remove stream handler. - - :param value: The logger_file path. - :type: str - """ - return self.__logger_file - - @logger_file.setter - def logger_file(self, value: Optional[str]) -> None: - """The logger file. - - If the logger_file is None, then add stream handler and remove file - handler. Otherwise, add file handler and remove stream handler. - - :param value: The logger_file path. - :type: str - """ - self.__logger_file = value - if self.__logger_file: - # If set logging file, - # then add file handler and remove stream handler. - self.logger_file_handler = logging.FileHandler(self.__logger_file) - self.logger_file_handler.setFormatter(self.logger_formatter) - for _, logger in self.logger.items(): - logger.addHandler(self.logger_file_handler) - - @property - def debug(self) -> bool: - """Debug status - - :param value: The debug status, True or False. - :type: bool - """ - return self.__debug - - @debug.setter - def debug(self, value: bool) -> None: - """Debug status - - :param value: The debug status, True or False. - :type: bool - """ - self.__debug = value - if self.__debug: - # if debug status is True, turn on debug logging - for _, logger in self.logger.items(): - logger.setLevel(logging.DEBUG) - # turn on httplib debug - httplib.HTTPConnection.debuglevel = 1 - else: - # if debug status is False, turn off debug logging, - # setting log level to default `logging.WARNING` - for _, logger in self.logger.items(): - logger.setLevel(logging.WARNING) - # turn off httplib debug - httplib.HTTPConnection.debuglevel = 0 - - @property - def logger_format(self) -> str: - """The logger format. - - The logger_formatter will be updated when sets logger_format. - - :param value: The format string. - :type: str - """ - return self.__logger_format - - @logger_format.setter - def logger_format(self, value: str) -> None: - """The logger format. - - The logger_formatter will be updated when sets logger_format. - - :param value: The format string. - :type: str - """ - self.__logger_format = value - self.logger_formatter = logging.Formatter(self.__logger_format) - - def get_api_key_with_prefix(self, identifier: str, alias: Optional[str]=None) -> Optional[str]: - """Gets API key (with prefix if set). - - :param identifier: The identifier of apiKey. - :param alias: The alternative identifier of apiKey. - :return: The token for api key authentication. - """ - if self.refresh_api_key_hook is not None: - self.refresh_api_key_hook(self) - key = self.api_key.get(identifier, self.api_key.get(alias) if alias is not None else None) - if key: - prefix = self.api_key_prefix.get(identifier) - if prefix: - return "%s %s" % (prefix, key) - else: - return key - - return None - - def get_basic_auth_token(self) -> Optional[str]: - """Gets HTTP basic authentication header (string). - - :return: The token for basic HTTP authentication. - """ - username = "" - if self.username is not None: - username = self.username - password = "" - if self.password is not None: - password = self.password - return urllib3.util.make_headers( - basic_auth=username + ':' + password - ).get('authorization') - - def auth_settings(self)-> AuthSettings: - """Gets Auth Settings dict for api client. - - :return: The Auth Settings information dict. - """ - auth: AuthSettings = {} - return auth - - def to_debug_report(self) -> str: - """Gets the essential information for debugging. - - :return: The report for debugging. - """ - return "Python SDK Debug Report:\n"\ - "OS: {env}\n"\ - "Python Version: {pyversion}\n"\ - "Version of the API: 1.0.0\n"\ - "SDK Package Version: 0.0.7".\ - format(env=sys.platform, pyversion=sys.version) - - def get_host_settings(self) -> List[HostSetting]: - """Gets an array of host settings - - :return: An array of host settings - """ - return [ - { - 'url': "", - 'description': "No description provided", - } - ] - - def get_host_from_settings( - self, - index: Optional[int], - variables: Optional[ServerVariablesT]=None, - servers: Optional[List[HostSetting]]=None, - ) -> str: - """Gets host URL based on the index and variables - :param index: array index of the host settings - :param variables: hash of variable and the corresponding value - :param servers: an array of host settings or None - :return: URL based on host settings - """ - if index is None: - return self._base_path - - variables = {} if variables is None else variables - servers = self.get_host_settings() if servers is None else servers - - try: - server = servers[index] - except IndexError: - raise ValueError( - "Invalid index {0} when selecting the host settings. " - "Must be less than {1}".format(index, len(servers))) - - url = server['url'] - - # go through variables and replace placeholders - for variable_name, variable in server.get('variables', {}).items(): - used_value = variables.get( - variable_name, variable['default_value']) - - if 'enum_values' in variable \ - and used_value not in variable['enum_values']: - raise ValueError( - "The variable `{0}` in the host URL has invalid value " - "{1}. Must be {2}.".format( - variable_name, variables[variable_name], - variable['enum_values'])) - - url = url.replace("{" + variable_name + "}", used_value) - - return url - - @property - def host(self) -> str: - """Return generated host.""" - return self.get_host_from_settings(self.server_index, variables=self.server_variables) - - @host.setter - def host(self, value: str) -> None: - """Fix base path.""" - self._base_path = value - self.server_index = None diff --git a/hindsight-clients/python/hindsight_client_api/docs/AddBackgroundRequest.md b/hindsight-clients/python/hindsight_client_api/docs/AddBackgroundRequest.md deleted file mode 100644 index 3f000fd2..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/AddBackgroundRequest.md +++ /dev/null @@ -1,31 +0,0 @@ -# AddBackgroundRequest - -Request model for adding/merging background information. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**content** | **str** | New background information to add or merge | -**update_disposition** | **bool** | If true, infer disposition traits from the merged background (default: true) | [optional] [default to True] - -## Example - -```python -from hindsight_client_api.models.add_background_request import AddBackgroundRequest - -# TODO update the JSON string below -json = "{}" -# create an instance of AddBackgroundRequest from a JSON string -add_background_request_instance = AddBackgroundRequest.from_json(json) -# print the JSON string representation of the object -print(AddBackgroundRequest.to_json()) - -# convert the object into a dict -add_background_request_dict = add_background_request_instance.to_dict() -# create an instance of AddBackgroundRequest from a dict -add_background_request_from_dict = AddBackgroundRequest.from_dict(add_background_request_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/BackgroundResponse.md b/hindsight-clients/python/hindsight_client_api/docs/BackgroundResponse.md deleted file mode 100644 index f0079179..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/BackgroundResponse.md +++ /dev/null @@ -1,31 +0,0 @@ -# BackgroundResponse - -Response model for background update. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**background** | **str** | | -**disposition** | [**DispositionTraits**](DispositionTraits.md) | | [optional] - -## Example - -```python -from hindsight_client_api.models.background_response import BackgroundResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of BackgroundResponse from a JSON string -background_response_instance = BackgroundResponse.from_json(json) -# print the JSON string representation of the object -print(BackgroundResponse.to_json()) - -# convert the object into a dict -background_response_dict = background_response_instance.to_dict() -# create an instance of BackgroundResponse from a dict -background_response_from_dict = BackgroundResponse.from_dict(background_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/BankListItem.md b/hindsight-clients/python/hindsight_client_api/docs/BankListItem.md deleted file mode 100644 index 21633de4..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/BankListItem.md +++ /dev/null @@ -1,35 +0,0 @@ -# BankListItem - -Bank list item with profile summary. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**bank_id** | **str** | | -**name** | **str** | | -**disposition** | [**DispositionTraits**](DispositionTraits.md) | | -**background** | **str** | | -**created_at** | **str** | | [optional] -**updated_at** | **str** | | [optional] - -## Example - -```python -from hindsight_client_api.models.bank_list_item import BankListItem - -# TODO update the JSON string below -json = "{}" -# create an instance of BankListItem from a JSON string -bank_list_item_instance = BankListItem.from_json(json) -# print the JSON string representation of the object -print(BankListItem.to_json()) - -# convert the object into a dict -bank_list_item_dict = bank_list_item_instance.to_dict() -# create an instance of BankListItem from a dict -bank_list_item_from_dict = BankListItem.from_dict(bank_list_item_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/BankListResponse.md b/hindsight-clients/python/hindsight_client_api/docs/BankListResponse.md deleted file mode 100644 index 57d51c66..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/BankListResponse.md +++ /dev/null @@ -1,30 +0,0 @@ -# BankListResponse - -Response model for listing all banks. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**banks** | [**List[BankListItem]**](BankListItem.md) | | - -## Example - -```python -from hindsight_client_api.models.bank_list_response import BankListResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of BankListResponse from a JSON string -bank_list_response_instance = BankListResponse.from_json(json) -# print the JSON string representation of the object -print(BankListResponse.to_json()) - -# convert the object into a dict -bank_list_response_dict = bank_list_response_instance.to_dict() -# create an instance of BankListResponse from a dict -bank_list_response_from_dict = BankListResponse.from_dict(bank_list_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/BankProfileResponse.md b/hindsight-clients/python/hindsight_client_api/docs/BankProfileResponse.md deleted file mode 100644 index 960bf3d6..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/BankProfileResponse.md +++ /dev/null @@ -1,33 +0,0 @@ -# BankProfileResponse - -Response model for bank profile. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**bank_id** | **str** | | -**name** | **str** | | -**disposition** | [**DispositionTraits**](DispositionTraits.md) | | -**background** | **str** | | - -## Example - -```python -from hindsight_client_api.models.bank_profile_response import BankProfileResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of BankProfileResponse from a JSON string -bank_profile_response_instance = BankProfileResponse.from_json(json) -# print the JSON string representation of the object -print(BankProfileResponse.to_json()) - -# convert the object into a dict -bank_profile_response_dict = bank_profile_response_instance.to_dict() -# create an instance of BankProfileResponse from a dict -bank_profile_response_from_dict = BankProfileResponse.from_dict(bank_profile_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/Budget.md b/hindsight-clients/python/hindsight_client_api/docs/Budget.md deleted file mode 100644 index 3e0ce4aa..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/Budget.md +++ /dev/null @@ -1,15 +0,0 @@ -# Budget - -Budget levels for recall/reflect operations. - -## Enum - -* `LOW` (value: `'low'`) - -* `MID` (value: `'mid'`) - -* `HIGH` (value: `'high'`) - -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/ChunkData.md b/hindsight-clients/python/hindsight_client_api/docs/ChunkData.md deleted file mode 100644 index 226c0487..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/ChunkData.md +++ /dev/null @@ -1,33 +0,0 @@ -# ChunkData - -Chunk data for a single chunk. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**id** | **str** | | -**text** | **str** | | -**chunk_index** | **int** | | -**truncated** | **bool** | Whether the chunk text was truncated due to token limits | [optional] [default to False] - -## Example - -```python -from hindsight_client_api.models.chunk_data import ChunkData - -# TODO update the JSON string below -json = "{}" -# create an instance of ChunkData from a JSON string -chunk_data_instance = ChunkData.from_json(json) -# print the JSON string representation of the object -print(ChunkData.to_json()) - -# convert the object into a dict -chunk_data_dict = chunk_data_instance.to_dict() -# create an instance of ChunkData from a dict -chunk_data_from_dict = ChunkData.from_dict(chunk_data_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/ChunkIncludeOptions.md b/hindsight-clients/python/hindsight_client_api/docs/ChunkIncludeOptions.md deleted file mode 100644 index 20fe36e0..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/ChunkIncludeOptions.md +++ /dev/null @@ -1,30 +0,0 @@ -# ChunkIncludeOptions - -Options for including chunks in recall results. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**max_tokens** | **int** | Maximum tokens for chunks (chunks may be truncated) | [optional] [default to 8192] - -## Example - -```python -from hindsight_client_api.models.chunk_include_options import ChunkIncludeOptions - -# TODO update the JSON string below -json = "{}" -# create an instance of ChunkIncludeOptions from a JSON string -chunk_include_options_instance = ChunkIncludeOptions.from_json(json) -# print the JSON string representation of the object -print(ChunkIncludeOptions.to_json()) - -# convert the object into a dict -chunk_include_options_dict = chunk_include_options_instance.to_dict() -# create an instance of ChunkIncludeOptions from a dict -chunk_include_options_from_dict = ChunkIncludeOptions.from_dict(chunk_include_options_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/ChunkResponse.md b/hindsight-clients/python/hindsight_client_api/docs/ChunkResponse.md deleted file mode 100644 index 078bf775..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/ChunkResponse.md +++ /dev/null @@ -1,35 +0,0 @@ -# ChunkResponse - -Response model for get chunk endpoint. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**chunk_id** | **str** | | -**document_id** | **str** | | -**bank_id** | **str** | | -**chunk_index** | **int** | | -**chunk_text** | **str** | | -**created_at** | **str** | | - -## Example - -```python -from hindsight_client_api.models.chunk_response import ChunkResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of ChunkResponse from a JSON string -chunk_response_instance = ChunkResponse.from_json(json) -# print the JSON string representation of the object -print(ChunkResponse.to_json()) - -# convert the object into a dict -chunk_response_dict = chunk_response_instance.to_dict() -# create an instance of ChunkResponse from a dict -chunk_response_from_dict = ChunkResponse.from_dict(chunk_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/CreateBankRequest.md b/hindsight-clients/python/hindsight_client_api/docs/CreateBankRequest.md deleted file mode 100644 index 268a4a6b..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/CreateBankRequest.md +++ /dev/null @@ -1,32 +0,0 @@ -# CreateBankRequest - -Request model for creating/updating a bank. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**name** | **str** | | [optional] -**disposition** | [**DispositionTraits**](DispositionTraits.md) | | [optional] -**background** | **str** | | [optional] - -## Example - -```python -from hindsight_client_api.models.create_bank_request import CreateBankRequest - -# TODO update the JSON string below -json = "{}" -# create an instance of CreateBankRequest from a JSON string -create_bank_request_instance = CreateBankRequest.from_json(json) -# print the JSON string representation of the object -print(CreateBankRequest.to_json()) - -# convert the object into a dict -create_bank_request_dict = create_bank_request_instance.to_dict() -# create an instance of CreateBankRequest from a dict -create_bank_request_from_dict = CreateBankRequest.from_dict(create_bank_request_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/DefaultApi.md b/hindsight-clients/python/hindsight_client_api/docs/DefaultApi.md deleted file mode 100644 index 4c1f033c..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/DefaultApi.md +++ /dev/null @@ -1,1568 +0,0 @@ -# hindsight_client_api.DefaultApi - -All URIs are relative to *http://localhost* - -Method | HTTP request | Description -------------- | ------------- | ------------- -[**add_bank_background**](DefaultApi.md#add_bank_background) | **POST** /v1/default/banks/{bank_id}/background | Add/merge memory bank background -[**cancel_operation**](DefaultApi.md#cancel_operation) | **DELETE** /v1/default/banks/{bank_id}/operations/{operation_id} | Cancel a pending async operation -[**clear_bank_memories**](DefaultApi.md#clear_bank_memories) | **DELETE** /v1/default/banks/{bank_id}/memories | Clear memory bank memories -[**create_or_update_bank**](DefaultApi.md#create_or_update_bank) | **PUT** /v1/default/banks/{bank_id} | Create or update memory bank -[**delete_document**](DefaultApi.md#delete_document) | **DELETE** /v1/default/banks/{bank_id}/documents/{document_id} | Delete a document -[**get_agent_stats**](DefaultApi.md#get_agent_stats) | **GET** /v1/default/banks/{bank_id}/stats | Get statistics for memory bank -[**get_bank_profile**](DefaultApi.md#get_bank_profile) | **GET** /v1/default/banks/{bank_id}/profile | Get memory bank profile -[**get_chunk**](DefaultApi.md#get_chunk) | **GET** /v1/default/chunks/{chunk_id} | Get chunk details -[**get_document**](DefaultApi.md#get_document) | **GET** /v1/default/banks/{bank_id}/documents/{document_id} | Get document details -[**get_entity**](DefaultApi.md#get_entity) | **GET** /v1/default/banks/{bank_id}/entities/{entity_id} | Get entity details -[**get_graph**](DefaultApi.md#get_graph) | **GET** /v1/default/banks/{bank_id}/graph | Get memory graph data -[**list_banks**](DefaultApi.md#list_banks) | **GET** /v1/default/banks | List all memory banks -[**list_documents**](DefaultApi.md#list_documents) | **GET** /v1/default/banks/{bank_id}/documents | List documents -[**list_entities**](DefaultApi.md#list_entities) | **GET** /v1/default/banks/{bank_id}/entities | List entities -[**list_memories**](DefaultApi.md#list_memories) | **GET** /v1/default/banks/{bank_id}/memories/list | List memory units -[**list_operations**](DefaultApi.md#list_operations) | **GET** /v1/default/banks/{bank_id}/operations | List async operations -[**recall_memories**](DefaultApi.md#recall_memories) | **POST** /v1/default/banks/{bank_id}/memories/recall | Recall memory -[**reflect**](DefaultApi.md#reflect) | **POST** /v1/default/banks/{bank_id}/reflect | Reflect and generate answer -[**regenerate_entity_observations**](DefaultApi.md#regenerate_entity_observations) | **POST** /v1/default/banks/{bank_id}/entities/{entity_id}/regenerate | Regenerate entity observations -[**retain_memories**](DefaultApi.md#retain_memories) | **POST** /v1/default/banks/{bank_id}/memories | Retain memories -[**update_bank_disposition**](DefaultApi.md#update_bank_disposition) | **PUT** /v1/default/banks/{bank_id}/profile | Update memory bank disposition - - -# **add_bank_background** -> BackgroundResponse add_bank_background(bank_id, add_background_request) - -Add/merge memory bank background - -Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits. - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.add_background_request import AddBackgroundRequest -from hindsight_client_api.models.background_response import BackgroundResponse -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - add_background_request = hindsight_client_api.AddBackgroundRequest() # AddBackgroundRequest | - - try: - # Add/merge memory bank background - api_response = await api_instance.add_bank_background(bank_id, add_background_request) - print("The response of DefaultApi->add_bank_background:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->add_bank_background: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **add_background_request** | [**AddBackgroundRequest**](AddBackgroundRequest.md)| | - -### Return type - -[**BackgroundResponse**](BackgroundResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: application/json - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **cancel_operation** -> object cancel_operation(bank_id, operation_id) - -Cancel a pending async operation - -Cancel a pending async operation by removing it from the queue - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - operation_id = 'operation_id_example' # str | - - try: - # Cancel a pending async operation - api_response = await api_instance.cancel_operation(bank_id, operation_id) - print("The response of DefaultApi->cancel_operation:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->cancel_operation: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **operation_id** | **str**| | - -### Return type - -**object** - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **clear_bank_memories** -> DeleteResponse clear_bank_memories(bank_id, type=type) - -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. - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.delete_response import DeleteResponse -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - type = 'type_example' # str | Optional fact type filter (world, experience, opinion) (optional) - - try: - # Clear memory bank memories - api_response = await api_instance.clear_bank_memories(bank_id, type=type) - print("The response of DefaultApi->clear_bank_memories:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->clear_bank_memories: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **type** | **str**| Optional fact type filter (world, experience, opinion) | [optional] - -### Return type - -[**DeleteResponse**](DeleteResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **create_or_update_bank** -> BankProfileResponse create_or_update_bank(bank_id, create_bank_request) - -Create or update memory bank - -Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults. - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.bank_profile_response import BankProfileResponse -from hindsight_client_api.models.create_bank_request import CreateBankRequest -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - create_bank_request = hindsight_client_api.CreateBankRequest() # CreateBankRequest | - - try: - # Create or update memory bank - api_response = await api_instance.create_or_update_bank(bank_id, create_bank_request) - print("The response of DefaultApi->create_or_update_bank:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->create_or_update_bank: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **create_bank_request** | [**CreateBankRequest**](CreateBankRequest.md)| | - -### Return type - -[**BankProfileResponse**](BankProfileResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: application/json - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **delete_document** -> object delete_document(bank_id, document_id) - -Delete a document - -Delete a document and all its associated memory units and links. - -This will cascade delete: -- The document itself -- All memory units extracted from this document -- All links (temporal, semantic, entity) associated with those memory units - -This operation cannot be undone. - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - document_id = 'document_id_example' # str | - - try: - # Delete a document - api_response = await api_instance.delete_document(bank_id, document_id) - print("The response of DefaultApi->delete_document:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->delete_document: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **document_id** | **str**| | - -### Return type - -**object** - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **get_agent_stats** -> object get_agent_stats(bank_id) - -Get statistics for memory bank - -Get statistics about nodes and links for a specific agent - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - - try: - # Get statistics for memory bank - api_response = await api_instance.get_agent_stats(bank_id) - print("The response of DefaultApi->get_agent_stats:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->get_agent_stats: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - -### Return type - -**object** - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **get_bank_profile** -> BankProfileResponse get_bank_profile(bank_id) - -Get memory bank profile - -Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists. - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.bank_profile_response import BankProfileResponse -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - - try: - # Get memory bank profile - api_response = await api_instance.get_bank_profile(bank_id) - print("The response of DefaultApi->get_bank_profile:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->get_bank_profile: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - -### Return type - -[**BankProfileResponse**](BankProfileResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **get_chunk** -> ChunkResponse get_chunk(chunk_id) - -Get chunk details - -Get a specific chunk by its ID - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.chunk_response import ChunkResponse -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - chunk_id = 'chunk_id_example' # str | - - try: - # Get chunk details - api_response = await api_instance.get_chunk(chunk_id) - print("The response of DefaultApi->get_chunk:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->get_chunk: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **chunk_id** | **str**| | - -### Return type - -[**ChunkResponse**](ChunkResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **get_document** -> DocumentResponse get_document(bank_id, document_id) - -Get document details - -Get a specific document including its original text - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.document_response import DocumentResponse -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - document_id = 'document_id_example' # str | - - try: - # Get document details - api_response = await api_instance.get_document(bank_id, document_id) - print("The response of DefaultApi->get_document:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->get_document: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **document_id** | **str**| | - -### Return type - -[**DocumentResponse**](DocumentResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **get_entity** -> EntityDetailResponse get_entity(bank_id, entity_id) - -Get entity details - -Get detailed information about an entity including observations (mental model). - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.entity_detail_response import EntityDetailResponse -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - entity_id = 'entity_id_example' # str | - - try: - # Get entity details - api_response = await api_instance.get_entity(bank_id, entity_id) - print("The response of DefaultApi->get_entity:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->get_entity: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **entity_id** | **str**| | - -### Return type - -[**EntityDetailResponse**](EntityDetailResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **get_graph** -> GraphDataResponse get_graph(bank_id, type=type) - -Get memory graph data - -Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). Limited to 1000 most recent items. - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.graph_data_response import GraphDataResponse -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - type = 'type_example' # str | (optional) - - try: - # Get memory graph data - api_response = await api_instance.get_graph(bank_id, type=type) - print("The response of DefaultApi->get_graph:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->get_graph: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **type** | **str**| | [optional] - -### Return type - -[**GraphDataResponse**](GraphDataResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **list_banks** -> BankListResponse list_banks() - -List all memory banks - -Get a list of all agents with their profiles - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.bank_list_response import BankListResponse -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - - try: - # List all memory banks - api_response = await api_instance.list_banks() - print("The response of DefaultApi->list_banks:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->list_banks: %s\n" % e) -``` - - - -### Parameters - -This endpoint does not need any parameter. - -### Return type - -[**BankListResponse**](BankListResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **list_documents** -> ListDocumentsResponse list_documents(bank_id, q=q, limit=limit, offset=offset) - -List documents - -List documents with pagination and optional search. Documents are the source content from which memory units are extracted. - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.list_documents_response import ListDocumentsResponse -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - q = 'q_example' # str | (optional) - limit = 100 # int | (optional) (default to 100) - offset = 0 # int | (optional) (default to 0) - - try: - # List documents - api_response = await api_instance.list_documents(bank_id, q=q, limit=limit, offset=offset) - print("The response of DefaultApi->list_documents:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->list_documents: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **q** | **str**| | [optional] - **limit** | **int**| | [optional] [default to 100] - **offset** | **int**| | [optional] [default to 0] - -### Return type - -[**ListDocumentsResponse**](ListDocumentsResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **list_entities** -> EntityListResponse list_entities(bank_id, limit=limit) - -List entities - -List all entities (people, organizations, etc.) known by the bank, ordered by mention count. - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.entity_list_response import EntityListResponse -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - limit = 100 # int | Maximum number of entities to return (optional) (default to 100) - - try: - # List entities - api_response = await api_instance.list_entities(bank_id, limit=limit) - print("The response of DefaultApi->list_entities:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->list_entities: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **limit** | **int**| Maximum number of entities to return | [optional] [default to 100] - -### Return type - -[**EntityListResponse**](EntityListResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **list_memories** -> ListMemoryUnitsResponse list_memories(bank_id, type=type, q=q, limit=limit, offset=offset) - -List memory units - -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). - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - type = 'type_example' # str | (optional) - q = 'q_example' # str | (optional) - limit = 100 # int | (optional) (default to 100) - offset = 0 # int | (optional) (default to 0) - - try: - # List memory units - api_response = await api_instance.list_memories(bank_id, type=type, q=q, limit=limit, offset=offset) - print("The response of DefaultApi->list_memories:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->list_memories: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **type** | **str**| | [optional] - **q** | **str**| | [optional] - **limit** | **int**| | [optional] [default to 100] - **offset** | **int**| | [optional] [default to 0] - -### Return type - -[**ListMemoryUnitsResponse**](ListMemoryUnitsResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **list_operations** -> object list_operations(bank_id) - -List async operations - -Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - - try: - # List async operations - api_response = await api_instance.list_operations(bank_id) - print("The response of DefaultApi->list_operations:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->list_operations: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - -### Return type - -**object** - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **recall_memories** -> RecallResponse recall_memories(bank_id, recall_request) - -Recall memory - -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 - - 'opinion': The bank's formed beliefs, perspectives, and viewpoints - - Set include_entities=true to get entity observations alongside recall results. - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.recall_request import RecallRequest -from hindsight_client_api.models.recall_response import RecallResponse -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - recall_request = hindsight_client_api.RecallRequest() # RecallRequest | - - try: - # Recall memory - api_response = await api_instance.recall_memories(bank_id, recall_request) - print("The response of DefaultApi->recall_memories:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->recall_memories: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **recall_request** | [**RecallRequest**](RecallRequest.md)| | - -### Return type - -[**RecallResponse**](RecallResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: application/json - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **reflect** -> ReflectResponse reflect(bank_id, reflect_request) - -Reflect and generate answer - -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. Extracts and stores any new opinions formed - 6. Returns plain text answer, the facts used, and new opinions - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.reflect_request import ReflectRequest -from hindsight_client_api.models.reflect_response import ReflectResponse -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - reflect_request = hindsight_client_api.ReflectRequest() # ReflectRequest | - - try: - # Reflect and generate answer - api_response = await api_instance.reflect(bank_id, reflect_request) - print("The response of DefaultApi->reflect:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->reflect: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **reflect_request** | [**ReflectRequest**](ReflectRequest.md)| | - -### Return type - -[**ReflectResponse**](ReflectResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: application/json - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **regenerate_entity_observations** -> EntityDetailResponse regenerate_entity_observations(bank_id, entity_id) - -Regenerate entity observations - -Regenerate observations for an entity based on all facts mentioning it. - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.entity_detail_response import EntityDetailResponse -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - entity_id = 'entity_id_example' # str | - - try: - # Regenerate entity observations - api_response = await api_instance.regenerate_entity_observations(bank_id, entity_id) - print("The response of DefaultApi->regenerate_entity_observations:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->regenerate_entity_observations: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **entity_id** | **str**| | - -### Return type - -[**EntityDetailResponse**](EntityDetailResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **retain_memories** -> RetainResponse retain_memories(bank_id, retain_request) - -Retain memories - -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 on items) - - 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 the task - - Processing happens in the background - - Use the operations endpoint to monitor progress - - When async=false (default): - - Waits for processing to complete - - Returns after all memories are stored - - 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). Items with the same document_id are grouped together for efficient processing. - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.retain_request import RetainRequest -from hindsight_client_api.models.retain_response import RetainResponse -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - retain_request = hindsight_client_api.RetainRequest() # RetainRequest | - - try: - # Retain memories - api_response = await api_instance.retain_memories(bank_id, retain_request) - print("The response of DefaultApi->retain_memories:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->retain_memories: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **retain_request** | [**RetainRequest**](RetainRequest.md)| | - -### Return type - -[**RetainResponse**](RetainResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: application/json - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **update_bank_disposition** -> BankProfileResponse update_bank_disposition(bank_id, update_disposition_request) - -Update memory bank disposition - -Update bank's disposition traits (skepticism, literalism, empathy) - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.models.bank_profile_response import BankProfileResponse -from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.DefaultApi(api_client) - bank_id = 'bank_id_example' # str | - update_disposition_request = hindsight_client_api.UpdateDispositionRequest() # UpdateDispositionRequest | - - try: - # Update memory bank disposition - api_response = await api_instance.update_bank_disposition(bank_id, update_disposition_request) - print("The response of DefaultApi->update_bank_disposition:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling DefaultApi->update_bank_disposition: %s\n" % e) -``` - - - -### Parameters - - -Name | Type | Description | Notes -------------- | ------------- | ------------- | ------------- - **bank_id** | **str**| | - **update_disposition_request** | [**UpdateDispositionRequest**](UpdateDispositionRequest.md)| | - -### Return type - -[**BankProfileResponse**](BankProfileResponse.md) - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: application/json - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | -**422** | Validation Error | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - diff --git a/hindsight-clients/python/hindsight_client_api/docs/DeleteResponse.md b/hindsight-clients/python/hindsight_client_api/docs/DeleteResponse.md deleted file mode 100644 index d9a10af3..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/DeleteResponse.md +++ /dev/null @@ -1,30 +0,0 @@ -# DeleteResponse - -Response model for delete operations. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**success** | **bool** | | - -## Example - -```python -from hindsight_client_api.models.delete_response import DeleteResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of DeleteResponse from a JSON string -delete_response_instance = DeleteResponse.from_json(json) -# print the JSON string representation of the object -print(DeleteResponse.to_json()) - -# convert the object into a dict -delete_response_dict = delete_response_instance.to_dict() -# create an instance of DeleteResponse from a dict -delete_response_from_dict = DeleteResponse.from_dict(delete_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/DispositionTraits.md b/hindsight-clients/python/hindsight_client_api/docs/DispositionTraits.md deleted file mode 100644 index 4996890c..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/DispositionTraits.md +++ /dev/null @@ -1,32 +0,0 @@ -# DispositionTraits - -Disposition traits that influence how memories are formed and interpreted. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**skepticism** | **int** | How skeptical vs trusting (1=trusting, 5=skeptical) | -**literalism** | **int** | How literally to interpret information (1=flexible, 5=literal) | -**empathy** | **int** | How much to consider emotional context (1=detached, 5=empathetic) | - -## Example - -```python -from hindsight_client_api.models.disposition_traits import DispositionTraits - -# TODO update the JSON string below -json = "{}" -# create an instance of DispositionTraits from a JSON string -disposition_traits_instance = DispositionTraits.from_json(json) -# print the JSON string representation of the object -print(DispositionTraits.to_json()) - -# convert the object into a dict -disposition_traits_dict = disposition_traits_instance.to_dict() -# create an instance of DispositionTraits from a dict -disposition_traits_from_dict = DispositionTraits.from_dict(disposition_traits_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/DocumentResponse.md b/hindsight-clients/python/hindsight_client_api/docs/DocumentResponse.md deleted file mode 100644 index 1bca8c70..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/DocumentResponse.md +++ /dev/null @@ -1,36 +0,0 @@ -# DocumentResponse - -Response model for get document endpoint. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**id** | **str** | | -**bank_id** | **str** | | -**original_text** | **str** | | -**content_hash** | **str** | | -**created_at** | **str** | | -**updated_at** | **str** | | -**memory_unit_count** | **int** | | - -## Example - -```python -from hindsight_client_api.models.document_response import DocumentResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of DocumentResponse from a JSON string -document_response_instance = DocumentResponse.from_json(json) -# print the JSON string representation of the object -print(DocumentResponse.to_json()) - -# convert the object into a dict -document_response_dict = document_response_instance.to_dict() -# create an instance of DocumentResponse from a dict -document_response_from_dict = DocumentResponse.from_dict(document_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/EntityDetailResponse.md b/hindsight-clients/python/hindsight_client_api/docs/EntityDetailResponse.md deleted file mode 100644 index 1061a7af..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/EntityDetailResponse.md +++ /dev/null @@ -1,36 +0,0 @@ -# EntityDetailResponse - -Response model for entity detail endpoint. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**id** | **str** | | -**canonical_name** | **str** | | -**mention_count** | **int** | | -**first_seen** | **str** | | [optional] -**last_seen** | **str** | | [optional] -**metadata** | **Dict[str, object]** | | [optional] -**observations** | [**List[EntityObservationResponse]**](EntityObservationResponse.md) | | - -## Example - -```python -from hindsight_client_api.models.entity_detail_response import EntityDetailResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of EntityDetailResponse from a JSON string -entity_detail_response_instance = EntityDetailResponse.from_json(json) -# print the JSON string representation of the object -print(EntityDetailResponse.to_json()) - -# convert the object into a dict -entity_detail_response_dict = entity_detail_response_instance.to_dict() -# create an instance of EntityDetailResponse from a dict -entity_detail_response_from_dict = EntityDetailResponse.from_dict(entity_detail_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/EntityIncludeOptions.md b/hindsight-clients/python/hindsight_client_api/docs/EntityIncludeOptions.md deleted file mode 100644 index d568f2f9..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/EntityIncludeOptions.md +++ /dev/null @@ -1,30 +0,0 @@ -# EntityIncludeOptions - -Options for including entity observations in recall results. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**max_tokens** | **int** | Maximum tokens for entity observations | [optional] [default to 500] - -## Example - -```python -from hindsight_client_api.models.entity_include_options import EntityIncludeOptions - -# TODO update the JSON string below -json = "{}" -# create an instance of EntityIncludeOptions from a JSON string -entity_include_options_instance = EntityIncludeOptions.from_json(json) -# print the JSON string representation of the object -print(EntityIncludeOptions.to_json()) - -# convert the object into a dict -entity_include_options_dict = entity_include_options_instance.to_dict() -# create an instance of EntityIncludeOptions from a dict -entity_include_options_from_dict = EntityIncludeOptions.from_dict(entity_include_options_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/EntityListItem.md b/hindsight-clients/python/hindsight_client_api/docs/EntityListItem.md deleted file mode 100644 index 28d9aa17..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/EntityListItem.md +++ /dev/null @@ -1,35 +0,0 @@ -# EntityListItem - -Entity list item with summary. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**id** | **str** | | -**canonical_name** | **str** | | -**mention_count** | **int** | | -**first_seen** | **str** | | [optional] -**last_seen** | **str** | | [optional] -**metadata** | **Dict[str, object]** | | [optional] - -## Example - -```python -from hindsight_client_api.models.entity_list_item import EntityListItem - -# TODO update the JSON string below -json = "{}" -# create an instance of EntityListItem from a JSON string -entity_list_item_instance = EntityListItem.from_json(json) -# print the JSON string representation of the object -print(EntityListItem.to_json()) - -# convert the object into a dict -entity_list_item_dict = entity_list_item_instance.to_dict() -# create an instance of EntityListItem from a dict -entity_list_item_from_dict = EntityListItem.from_dict(entity_list_item_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/EntityListResponse.md b/hindsight-clients/python/hindsight_client_api/docs/EntityListResponse.md deleted file mode 100644 index 04c500f0..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/EntityListResponse.md +++ /dev/null @@ -1,30 +0,0 @@ -# EntityListResponse - -Response model for entity list endpoint. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**items** | [**List[EntityListItem]**](EntityListItem.md) | | - -## Example - -```python -from hindsight_client_api.models.entity_list_response import EntityListResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of EntityListResponse from a JSON string -entity_list_response_instance = EntityListResponse.from_json(json) -# print the JSON string representation of the object -print(EntityListResponse.to_json()) - -# convert the object into a dict -entity_list_response_dict = entity_list_response_instance.to_dict() -# create an instance of EntityListResponse from a dict -entity_list_response_from_dict = EntityListResponse.from_dict(entity_list_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/EntityObservationResponse.md b/hindsight-clients/python/hindsight_client_api/docs/EntityObservationResponse.md deleted file mode 100644 index 1fdc1edc..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/EntityObservationResponse.md +++ /dev/null @@ -1,31 +0,0 @@ -# EntityObservationResponse - -An observation about an entity. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**text** | **str** | | -**mentioned_at** | **str** | | [optional] - -## Example - -```python -from hindsight_client_api.models.entity_observation_response import EntityObservationResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of EntityObservationResponse from a JSON string -entity_observation_response_instance = EntityObservationResponse.from_json(json) -# print the JSON string representation of the object -print(EntityObservationResponse.to_json()) - -# convert the object into a dict -entity_observation_response_dict = entity_observation_response_instance.to_dict() -# create an instance of EntityObservationResponse from a dict -entity_observation_response_from_dict = EntityObservationResponse.from_dict(entity_observation_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/EntityStateResponse.md b/hindsight-clients/python/hindsight_client_api/docs/EntityStateResponse.md deleted file mode 100644 index 593a9227..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/EntityStateResponse.md +++ /dev/null @@ -1,32 +0,0 @@ -# EntityStateResponse - -Current mental model of an entity. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**entity_id** | **str** | | -**canonical_name** | **str** | | -**observations** | [**List[EntityObservationResponse]**](EntityObservationResponse.md) | | - -## Example - -```python -from hindsight_client_api.models.entity_state_response import EntityStateResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of EntityStateResponse from a JSON string -entity_state_response_instance = EntityStateResponse.from_json(json) -# print the JSON string representation of the object -print(EntityStateResponse.to_json()) - -# convert the object into a dict -entity_state_response_dict = entity_state_response_instance.to_dict() -# create an instance of EntityStateResponse from a dict -entity_state_response_from_dict = EntityStateResponse.from_dict(entity_state_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/GraphDataResponse.md b/hindsight-clients/python/hindsight_client_api/docs/GraphDataResponse.md deleted file mode 100644 index 0f3e65ae..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/GraphDataResponse.md +++ /dev/null @@ -1,33 +0,0 @@ -# GraphDataResponse - -Response model for graph data endpoint. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**nodes** | **List[Dict[str, object]]** | | -**edges** | **List[Dict[str, object]]** | | -**table_rows** | **List[Dict[str, object]]** | | -**total_units** | **int** | | - -## Example - -```python -from hindsight_client_api.models.graph_data_response import GraphDataResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of GraphDataResponse from a JSON string -graph_data_response_instance = GraphDataResponse.from_json(json) -# print the JSON string representation of the object -print(GraphDataResponse.to_json()) - -# convert the object into a dict -graph_data_response_dict = graph_data_response_instance.to_dict() -# create an instance of GraphDataResponse from a dict -graph_data_response_from_dict = GraphDataResponse.from_dict(graph_data_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/HTTPValidationError.md b/hindsight-clients/python/hindsight_client_api/docs/HTTPValidationError.md deleted file mode 100644 index f1d0e7a1..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/HTTPValidationError.md +++ /dev/null @@ -1,29 +0,0 @@ -# HTTPValidationError - - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**detail** | [**List[ValidationError]**](ValidationError.md) | | [optional] - -## Example - -```python -from hindsight_client_api.models.http_validation_error import HTTPValidationError - -# TODO update the JSON string below -json = "{}" -# create an instance of HTTPValidationError from a JSON string -http_validation_error_instance = HTTPValidationError.from_json(json) -# print the JSON string representation of the object -print(HTTPValidationError.to_json()) - -# convert the object into a dict -http_validation_error_dict = http_validation_error_instance.to_dict() -# create an instance of HTTPValidationError from a dict -http_validation_error_from_dict = HTTPValidationError.from_dict(http_validation_error_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/IncludeOptions.md b/hindsight-clients/python/hindsight_client_api/docs/IncludeOptions.md deleted file mode 100644 index c158ae08..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/IncludeOptions.md +++ /dev/null @@ -1,31 +0,0 @@ -# IncludeOptions - -Options for including additional data in recall results. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**entities** | [**EntityIncludeOptions**](EntityIncludeOptions.md) | | [optional] -**chunks** | [**ChunkIncludeOptions**](ChunkIncludeOptions.md) | | [optional] - -## Example - -```python -from hindsight_client_api.models.include_options import IncludeOptions - -# TODO update the JSON string below -json = "{}" -# create an instance of IncludeOptions from a JSON string -include_options_instance = IncludeOptions.from_json(json) -# print the JSON string representation of the object -print(IncludeOptions.to_json()) - -# convert the object into a dict -include_options_dict = include_options_instance.to_dict() -# create an instance of IncludeOptions from a dict -include_options_from_dict = IncludeOptions.from_dict(include_options_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/ListDocumentsResponse.md b/hindsight-clients/python/hindsight_client_api/docs/ListDocumentsResponse.md deleted file mode 100644 index 6474ee19..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/ListDocumentsResponse.md +++ /dev/null @@ -1,33 +0,0 @@ -# ListDocumentsResponse - -Response model for list documents endpoint. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**items** | **List[Dict[str, object]]** | | -**total** | **int** | | -**limit** | **int** | | -**offset** | **int** | | - -## Example - -```python -from hindsight_client_api.models.list_documents_response import ListDocumentsResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of ListDocumentsResponse from a JSON string -list_documents_response_instance = ListDocumentsResponse.from_json(json) -# print the JSON string representation of the object -print(ListDocumentsResponse.to_json()) - -# convert the object into a dict -list_documents_response_dict = list_documents_response_instance.to_dict() -# create an instance of ListDocumentsResponse from a dict -list_documents_response_from_dict = ListDocumentsResponse.from_dict(list_documents_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/ListMemoryUnitsResponse.md b/hindsight-clients/python/hindsight_client_api/docs/ListMemoryUnitsResponse.md deleted file mode 100644 index 05579867..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/ListMemoryUnitsResponse.md +++ /dev/null @@ -1,33 +0,0 @@ -# ListMemoryUnitsResponse - -Response model for list memory units endpoint. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**items** | **List[Dict[str, object]]** | | -**total** | **int** | | -**limit** | **int** | | -**offset** | **int** | | - -## Example - -```python -from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of ListMemoryUnitsResponse from a JSON string -list_memory_units_response_instance = ListMemoryUnitsResponse.from_json(json) -# print the JSON string representation of the object -print(ListMemoryUnitsResponse.to_json()) - -# convert the object into a dict -list_memory_units_response_dict = list_memory_units_response_instance.to_dict() -# create an instance of ListMemoryUnitsResponse from a dict -list_memory_units_response_from_dict = ListMemoryUnitsResponse.from_dict(list_memory_units_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/MemoryItem.md b/hindsight-clients/python/hindsight_client_api/docs/MemoryItem.md deleted file mode 100644 index dea0f812..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/MemoryItem.md +++ /dev/null @@ -1,34 +0,0 @@ -# MemoryItem - -Single memory item for retain. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**content** | **str** | | -**timestamp** | **datetime** | | [optional] -**context** | **str** | | [optional] -**metadata** | **Dict[str, str]** | | [optional] -**document_id** | **str** | | [optional] - -## Example - -```python -from hindsight_client_api.models.memory_item import MemoryItem - -# TODO update the JSON string below -json = "{}" -# create an instance of MemoryItem from a JSON string -memory_item_instance = MemoryItem.from_json(json) -# print the JSON string representation of the object -print(MemoryItem.to_json()) - -# convert the object into a dict -memory_item_dict = memory_item_instance.to_dict() -# create an instance of MemoryItem from a dict -memory_item_from_dict = MemoryItem.from_dict(memory_item_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/MonitoringApi.md b/hindsight-clients/python/hindsight_client_api/docs/MonitoringApi.md deleted file mode 100644 index 151553d9..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/MonitoringApi.md +++ /dev/null @@ -1,136 +0,0 @@ -# hindsight_client_api.MonitoringApi - -All URIs are relative to *http://localhost* - -Method | HTTP request | Description -------------- | ------------- | ------------- -[**health_endpoint_health_get**](MonitoringApi.md#health_endpoint_health_get) | **GET** /health | Health check endpoint -[**metrics_endpoint_metrics_get**](MonitoringApi.md#metrics_endpoint_metrics_get) | **GET** /metrics | Prometheus metrics endpoint - - -# **health_endpoint_health_get** -> object health_endpoint_health_get() - -Health check endpoint - -Checks the health of the API and database connection - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.MonitoringApi(api_client) - - try: - # Health check endpoint - api_response = await api_instance.health_endpoint_health_get() - print("The response of MonitoringApi->health_endpoint_health_get:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling MonitoringApi->health_endpoint_health_get: %s\n" % e) -``` - - - -### Parameters - -This endpoint does not need any parameter. - -### Return type - -**object** - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - -# **metrics_endpoint_metrics_get** -> object metrics_endpoint_metrics_get() - -Prometheus metrics endpoint - -Exports metrics in Prometheus format for scraping - -### Example - - -```python -import hindsight_client_api -from hindsight_client_api.rest import ApiException -from pprint import pprint - -# Defining the host is optional and defaults to http://localhost -# See configuration.py for a list of all supported configuration parameters. -configuration = hindsight_client_api.Configuration( - host = "http://localhost" -) - - -# Enter a context with an instance of the API client -async with hindsight_client_api.ApiClient(configuration) as api_client: - # Create an instance of the API class - api_instance = hindsight_client_api.MonitoringApi(api_client) - - try: - # Prometheus metrics endpoint - api_response = await api_instance.metrics_endpoint_metrics_get() - print("The response of MonitoringApi->metrics_endpoint_metrics_get:\n") - pprint(api_response) - except Exception as e: - print("Exception when calling MonitoringApi->metrics_endpoint_metrics_get: %s\n" % e) -``` - - - -### Parameters - -This endpoint does not need any parameter. - -### Return type - -**object** - -### Authorization - -No authorization required - -### HTTP request headers - - - **Content-Type**: Not defined - - **Accept**: application/json - -### HTTP response details - -| Status code | Description | Response headers | -|-------------|-------------|------------------| -**200** | Successful Response | - | - -[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md) - diff --git a/hindsight-clients/python/hindsight_client_api/docs/RecallRequest.md b/hindsight-clients/python/hindsight_client_api/docs/RecallRequest.md deleted file mode 100644 index a308cec5..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/RecallRequest.md +++ /dev/null @@ -1,36 +0,0 @@ -# RecallRequest - -Request model for recall endpoint. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**query** | **str** | | -**types** | **List[str]** | | [optional] -**budget** | [**Budget**](Budget.md) | | [optional] -**max_tokens** | **int** | | [optional] [default to 4096] -**trace** | **bool** | | [optional] [default to False] -**query_timestamp** | **str** | | [optional] -**include** | [**IncludeOptions**](IncludeOptions.md) | Options for including additional data (entities are included by default) | [optional] - -## Example - -```python -from hindsight_client_api.models.recall_request import RecallRequest - -# TODO update the JSON string below -json = "{}" -# create an instance of RecallRequest from a JSON string -recall_request_instance = RecallRequest.from_json(json) -# print the JSON string representation of the object -print(RecallRequest.to_json()) - -# convert the object into a dict -recall_request_dict = recall_request_instance.to_dict() -# create an instance of RecallRequest from a dict -recall_request_from_dict = RecallRequest.from_dict(recall_request_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/RecallResponse.md b/hindsight-clients/python/hindsight_client_api/docs/RecallResponse.md deleted file mode 100644 index fa81a5b3..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/RecallResponse.md +++ /dev/null @@ -1,33 +0,0 @@ -# RecallResponse - -Response model for recall endpoints. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**results** | [**List[RecallResult]**](RecallResult.md) | | -**trace** | **Dict[str, object]** | | [optional] -**entities** | [**Dict[str, EntityStateResponse]**](EntityStateResponse.md) | | [optional] -**chunks** | [**Dict[str, ChunkData]**](ChunkData.md) | | [optional] - -## Example - -```python -from hindsight_client_api.models.recall_response import RecallResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of RecallResponse from a JSON string -recall_response_instance = RecallResponse.from_json(json) -# print the JSON string representation of the object -print(RecallResponse.to_json()) - -# convert the object into a dict -recall_response_dict = recall_response_instance.to_dict() -# create an instance of RecallResponse from a dict -recall_response_from_dict = RecallResponse.from_dict(recall_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/RecallResult.md b/hindsight-clients/python/hindsight_client_api/docs/RecallResult.md deleted file mode 100644 index 49a912da..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/RecallResult.md +++ /dev/null @@ -1,40 +0,0 @@ -# RecallResult - -Single recall result item. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**id** | **str** | | -**text** | **str** | | -**type** | **str** | | [optional] -**entities** | **List[str]** | | [optional] -**context** | **str** | | [optional] -**occurred_start** | **str** | | [optional] -**occurred_end** | **str** | | [optional] -**mentioned_at** | **str** | | [optional] -**document_id** | **str** | | [optional] -**metadata** | **Dict[str, str]** | | [optional] -**chunk_id** | **str** | | [optional] - -## Example - -```python -from hindsight_client_api.models.recall_result import RecallResult - -# TODO update the JSON string below -json = "{}" -# create an instance of RecallResult from a JSON string -recall_result_instance = RecallResult.from_json(json) -# print the JSON string representation of the object -print(RecallResult.to_json()) - -# convert the object into a dict -recall_result_dict = recall_result_instance.to_dict() -# create an instance of RecallResult from a dict -recall_result_from_dict = RecallResult.from_dict(recall_result_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/ReflectFact.md b/hindsight-clients/python/hindsight_client_api/docs/ReflectFact.md deleted file mode 100644 index 1022a5a9..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/ReflectFact.md +++ /dev/null @@ -1,35 +0,0 @@ -# ReflectFact - -A fact used in think response. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**id** | **str** | | [optional] -**text** | **str** | | -**type** | **str** | | [optional] -**context** | **str** | | [optional] -**occurred_start** | **str** | | [optional] -**occurred_end** | **str** | | [optional] - -## Example - -```python -from hindsight_client_api.models.reflect_fact import ReflectFact - -# TODO update the JSON string below -json = "{}" -# create an instance of ReflectFact from a JSON string -reflect_fact_instance = ReflectFact.from_json(json) -# print the JSON string representation of the object -print(ReflectFact.to_json()) - -# convert the object into a dict -reflect_fact_dict = reflect_fact_instance.to_dict() -# create an instance of ReflectFact from a dict -reflect_fact_from_dict = ReflectFact.from_dict(reflect_fact_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/ReflectIncludeOptions.md b/hindsight-clients/python/hindsight_client_api/docs/ReflectIncludeOptions.md deleted file mode 100644 index c4953fc1..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/ReflectIncludeOptions.md +++ /dev/null @@ -1,30 +0,0 @@ -# ReflectIncludeOptions - -Options for including additional data in reflect results. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**facts** | **object** | Options for including facts (based_on) in reflect results. | [optional] - -## Example - -```python -from hindsight_client_api.models.reflect_include_options import ReflectIncludeOptions - -# TODO update the JSON string below -json = "{}" -# create an instance of ReflectIncludeOptions from a JSON string -reflect_include_options_instance = ReflectIncludeOptions.from_json(json) -# print the JSON string representation of the object -print(ReflectIncludeOptions.to_json()) - -# convert the object into a dict -reflect_include_options_dict = reflect_include_options_instance.to_dict() -# create an instance of ReflectIncludeOptions from a dict -reflect_include_options_from_dict = ReflectIncludeOptions.from_dict(reflect_include_options_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/ReflectRequest.md b/hindsight-clients/python/hindsight_client_api/docs/ReflectRequest.md deleted file mode 100644 index 19b80147..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/ReflectRequest.md +++ /dev/null @@ -1,33 +0,0 @@ -# ReflectRequest - -Request model for reflect endpoint. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**query** | **str** | | -**budget** | [**Budget**](Budget.md) | | [optional] -**context** | **str** | | [optional] -**include** | [**ReflectIncludeOptions**](ReflectIncludeOptions.md) | Options for including additional data (disabled by default) | [optional] - -## Example - -```python -from hindsight_client_api.models.reflect_request import ReflectRequest - -# TODO update the JSON string below -json = "{}" -# create an instance of ReflectRequest from a JSON string -reflect_request_instance = ReflectRequest.from_json(json) -# print the JSON string representation of the object -print(ReflectRequest.to_json()) - -# convert the object into a dict -reflect_request_dict = reflect_request_instance.to_dict() -# create an instance of ReflectRequest from a dict -reflect_request_from_dict = ReflectRequest.from_dict(reflect_request_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/ReflectResponse.md b/hindsight-clients/python/hindsight_client_api/docs/ReflectResponse.md deleted file mode 100644 index 266029a1..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/ReflectResponse.md +++ /dev/null @@ -1,31 +0,0 @@ -# ReflectResponse - -Response model for think endpoint. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**text** | **str** | | -**based_on** | [**List[ReflectFact]**](ReflectFact.md) | | [optional] [default to []] - -## Example - -```python -from hindsight_client_api.models.reflect_response import ReflectResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of ReflectResponse from a JSON string -reflect_response_instance = ReflectResponse.from_json(json) -# print the JSON string representation of the object -print(ReflectResponse.to_json()) - -# convert the object into a dict -reflect_response_dict = reflect_response_instance.to_dict() -# create an instance of ReflectResponse from a dict -reflect_response_from_dict = ReflectResponse.from_dict(reflect_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/RetainRequest.md b/hindsight-clients/python/hindsight_client_api/docs/RetainRequest.md deleted file mode 100644 index 5ea27f06..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/RetainRequest.md +++ /dev/null @@ -1,31 +0,0 @@ -# RetainRequest - -Request model for retain endpoint. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**items** | [**List[MemoryItem]**](MemoryItem.md) | | -**var_async** | **bool** | If true, process asynchronously in background. If false, wait for completion (default: false) | [optional] [default to False] - -## Example - -```python -from hindsight_client_api.models.retain_request import RetainRequest - -# TODO update the JSON string below -json = "{}" -# create an instance of RetainRequest from a JSON string -retain_request_instance = RetainRequest.from_json(json) -# print the JSON string representation of the object -print(RetainRequest.to_json()) - -# convert the object into a dict -retain_request_dict = retain_request_instance.to_dict() -# create an instance of RetainRequest from a dict -retain_request_from_dict = RetainRequest.from_dict(retain_request_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/RetainResponse.md b/hindsight-clients/python/hindsight_client_api/docs/RetainResponse.md deleted file mode 100644 index 89b3f1da..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/RetainResponse.md +++ /dev/null @@ -1,33 +0,0 @@ -# RetainResponse - -Response model for retain endpoint. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**success** | **bool** | | -**bank_id** | **str** | | -**items_count** | **int** | | -**var_async** | **bool** | Whether the operation was processed asynchronously | - -## Example - -```python -from hindsight_client_api.models.retain_response import RetainResponse - -# TODO update the JSON string below -json = "{}" -# create an instance of RetainResponse from a JSON string -retain_response_instance = RetainResponse.from_json(json) -# print the JSON string representation of the object -print(RetainResponse.to_json()) - -# convert the object into a dict -retain_response_dict = retain_response_instance.to_dict() -# create an instance of RetainResponse from a dict -retain_response_from_dict = RetainResponse.from_dict(retain_response_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/UpdateDispositionRequest.md b/hindsight-clients/python/hindsight_client_api/docs/UpdateDispositionRequest.md deleted file mode 100644 index 38389ff0..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/UpdateDispositionRequest.md +++ /dev/null @@ -1,30 +0,0 @@ -# UpdateDispositionRequest - -Request model for updating disposition traits. - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**disposition** | [**DispositionTraits**](DispositionTraits.md) | | - -## Example - -```python -from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest - -# TODO update the JSON string below -json = "{}" -# create an instance of UpdateDispositionRequest from a JSON string -update_disposition_request_instance = UpdateDispositionRequest.from_json(json) -# print the JSON string representation of the object -print(UpdateDispositionRequest.to_json()) - -# convert the object into a dict -update_disposition_request_dict = update_disposition_request_instance.to_dict() -# create an instance of UpdateDispositionRequest from a dict -update_disposition_request_from_dict = UpdateDispositionRequest.from_dict(update_disposition_request_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/ValidationError.md b/hindsight-clients/python/hindsight_client_api/docs/ValidationError.md deleted file mode 100644 index 69684b4f..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/ValidationError.md +++ /dev/null @@ -1,31 +0,0 @@ -# ValidationError - - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- -**loc** | [**List[ValidationErrorLocInner]**](ValidationErrorLocInner.md) | | -**msg** | **str** | | -**type** | **str** | | - -## Example - -```python -from hindsight_client_api.models.validation_error import ValidationError - -# TODO update the JSON string below -json = "{}" -# create an instance of ValidationError from a JSON string -validation_error_instance = ValidationError.from_json(json) -# print the JSON string representation of the object -print(ValidationError.to_json()) - -# convert the object into a dict -validation_error_dict = validation_error_instance.to_dict() -# create an instance of ValidationError from a dict -validation_error_from_dict = ValidationError.from_dict(validation_error_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/docs/ValidationErrorLocInner.md b/hindsight-clients/python/hindsight_client_api/docs/ValidationErrorLocInner.md deleted file mode 100644 index 78ac99a4..00000000 --- a/hindsight-clients/python/hindsight_client_api/docs/ValidationErrorLocInner.md +++ /dev/null @@ -1,28 +0,0 @@ -# ValidationErrorLocInner - - -## Properties - -Name | Type | Description | Notes ------------- | ------------- | ------------- | ------------- - -## Example - -```python -from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner - -# TODO update the JSON string below -json = "{}" -# create an instance of ValidationErrorLocInner from a JSON string -validation_error_loc_inner_instance = ValidationErrorLocInner.from_json(json) -# print the JSON string representation of the object -print(ValidationErrorLocInner.to_json()) - -# convert the object into a dict -validation_error_loc_inner_dict = validation_error_loc_inner_instance.to_dict() -# create an instance of ValidationErrorLocInner from a dict -validation_error_loc_inner_from_dict = ValidationErrorLocInner.from_dict(validation_error_loc_inner_dict) -``` -[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) - - diff --git a/hindsight-clients/python/hindsight_client_api/exceptions.py b/hindsight-clients/python/hindsight_client_api/exceptions.py deleted file mode 100644 index fd5a408b..00000000 --- a/hindsight-clients/python/hindsight_client_api/exceptions.py +++ /dev/null @@ -1,219 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - -from typing import Any, Optional -from typing_extensions import Self - -class OpenApiException(Exception): - """The base exception class for all OpenAPIExceptions""" - - -class ApiTypeError(OpenApiException, TypeError): - def __init__(self, msg, path_to_item=None, valid_classes=None, - key_type=None) -> None: - """ Raises an exception for TypeErrors - - Args: - msg (str): the exception message - - Keyword Args: - path_to_item (list): a list of keys an indices to get to the - current_item - None if unset - valid_classes (tuple): the primitive classes that current item - should be an instance of - None if unset - key_type (bool): False if our value is a value in a dict - True if it is a key in a dict - False if our item is an item in a list - None if unset - """ - self.path_to_item = path_to_item - self.valid_classes = valid_classes - self.key_type = key_type - full_msg = msg - if path_to_item: - full_msg = "{0} at {1}".format(msg, render_path(path_to_item)) - super(ApiTypeError, self).__init__(full_msg) - - -class ApiValueError(OpenApiException, ValueError): - def __init__(self, msg, path_to_item=None) -> None: - """ - Args: - msg (str): the exception message - - Keyword Args: - path_to_item (list) the path to the exception in the - received_data dict. None if unset - """ - - self.path_to_item = path_to_item - full_msg = msg - if path_to_item: - full_msg = "{0} at {1}".format(msg, render_path(path_to_item)) - super(ApiValueError, self).__init__(full_msg) - - -class ApiAttributeError(OpenApiException, AttributeError): - def __init__(self, msg, path_to_item=None) -> None: - """ - Raised when an attribute reference or assignment fails. - - Args: - msg (str): the exception message - - Keyword Args: - path_to_item (None/list) the path to the exception in the - received_data dict - """ - self.path_to_item = path_to_item - full_msg = msg - if path_to_item: - full_msg = "{0} at {1}".format(msg, render_path(path_to_item)) - super(ApiAttributeError, self).__init__(full_msg) - - -class ApiKeyError(OpenApiException, KeyError): - def __init__(self, msg, path_to_item=None) -> None: - """ - Args: - msg (str): the exception message - - Keyword Args: - path_to_item (None/list) the path to the exception in the - received_data dict - """ - self.path_to_item = path_to_item - full_msg = msg - if path_to_item: - full_msg = "{0} at {1}".format(msg, render_path(path_to_item)) - super(ApiKeyError, self).__init__(full_msg) - - -class ApiException(OpenApiException): - - def __init__( - self, - status=None, - reason=None, - http_resp=None, - *, - body: Optional[str] = None, - data: Optional[Any] = None, - ) -> None: - self.status = status - self.reason = reason - self.body = body - self.data = data - self.headers = None - - if http_resp: - if self.status is None: - self.status = http_resp.status - if self.reason is None: - self.reason = http_resp.reason - if self.body is None: - try: - self.body = http_resp.data.decode('utf-8') - except Exception: - pass - self.headers = http_resp.getheaders() - - @classmethod - def from_response( - cls, - *, - http_resp, - body: Optional[str], - data: Optional[Any], - ) -> Self: - if http_resp.status == 400: - raise BadRequestException(http_resp=http_resp, body=body, data=data) - - if http_resp.status == 401: - raise UnauthorizedException(http_resp=http_resp, body=body, data=data) - - if http_resp.status == 403: - raise ForbiddenException(http_resp=http_resp, body=body, data=data) - - if http_resp.status == 404: - raise NotFoundException(http_resp=http_resp, body=body, data=data) - - # Added new conditions for 409 and 422 - if http_resp.status == 409: - raise ConflictException(http_resp=http_resp, body=body, data=data) - - if http_resp.status == 422: - raise UnprocessableEntityException(http_resp=http_resp, body=body, data=data) - - if 500 <= http_resp.status <= 599: - raise ServiceException(http_resp=http_resp, body=body, data=data) - raise ApiException(http_resp=http_resp, body=body, data=data) - - def __str__(self): - """Custom error messages for exception""" - error_message = "({0})\n"\ - "Reason: {1}\n".format(self.status, self.reason) - if self.headers: - error_message += "HTTP response headers: {0}\n".format( - self.headers) - - if self.body: - error_message += "HTTP response body: {0}\n".format(self.body) - - if self.data: - error_message += "HTTP response data: {0}\n".format(self.data) - - return error_message - - -class BadRequestException(ApiException): - pass - - -class NotFoundException(ApiException): - pass - - -class UnauthorizedException(ApiException): - pass - - -class ForbiddenException(ApiException): - pass - - -class ServiceException(ApiException): - pass - - -class ConflictException(ApiException): - """Exception for HTTP 409 Conflict.""" - pass - - -class UnprocessableEntityException(ApiException): - """Exception for HTTP 422 Unprocessable Entity.""" - pass - - -def render_path(path_to_item): - """Returns a string representation of a path""" - result = "" - for pth in path_to_item: - if isinstance(pth, int): - result += "[{0}]".format(pth) - else: - result += "['{0}']".format(pth) - return result diff --git a/hindsight-clients/python/hindsight_client_api/models/__init__.py b/hindsight-clients/python/hindsight_client_api/models/__init__.py deleted file mode 100644 index 65f5a65c..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/__init__.py +++ /dev/null @@ -1,53 +0,0 @@ -# coding: utf-8 - -# flake8: noqa -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - -# import models into model package -from hindsight_client_api.models.add_background_request import AddBackgroundRequest -from hindsight_client_api.models.background_response import BackgroundResponse -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_profile_response import BankProfileResponse -from hindsight_client_api.models.budget import Budget -from hindsight_client_api.models.chunk_data import ChunkData -from hindsight_client_api.models.chunk_include_options import ChunkIncludeOptions -from hindsight_client_api.models.chunk_response import ChunkResponse -from hindsight_client_api.models.create_bank_request import CreateBankRequest -from hindsight_client_api.models.delete_response import DeleteResponse -from hindsight_client_api.models.disposition_traits import DispositionTraits -from hindsight_client_api.models.document_response import DocumentResponse -from hindsight_client_api.models.entity_detail_response import EntityDetailResponse -from hindsight_client_api.models.entity_include_options import EntityIncludeOptions -from hindsight_client_api.models.entity_list_item import EntityListItem -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.graph_data_response import GraphDataResponse -from hindsight_client_api.models.http_validation_error import HTTPValidationError -from hindsight_client_api.models.include_options import IncludeOptions -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.memory_item import MemoryItem -from hindsight_client_api.models.recall_request import RecallRequest -from hindsight_client_api.models.recall_response import RecallResponse -from hindsight_client_api.models.recall_result import RecallResult -from hindsight_client_api.models.reflect_fact import ReflectFact -from hindsight_client_api.models.reflect_include_options import ReflectIncludeOptions -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_disposition_request import UpdateDispositionRequest -from hindsight_client_api.models.validation_error import ValidationError -from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner - 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 deleted file mode 100644 index 67cd67b3..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/add_background_request.py +++ /dev/null @@ -1,89 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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, StrictStr -from typing import Any, ClassVar, Dict, List, Optional -from typing import Optional, Set -from typing_extensions import Self - -class AddBackgroundRequest(BaseModel): - """ - Request model for adding/merging background information. - """ # noqa: E501 - content: StrictStr = Field(description="New background information to add or merge") - update_disposition: Optional[StrictBool] = Field(default=True, description="If true, infer disposition traits from the merged background (default: true)") - __properties: ClassVar[List[str]] = ["content", "update_disposition"] - - 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 AddBackgroundRequest 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 AddBackgroundRequest 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"), - "update_disposition": obj.get("update_disposition") if obj.get("update_disposition") is not None else True - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/background_response.py b/hindsight-clients/python/hindsight_client_api/models/background_response.py deleted file mode 100644 index 99f3780c..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/background_response.py +++ /dev/null @@ -1,98 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 hindsight_client_api.models.disposition_traits import DispositionTraits -from typing import Optional, Set -from typing_extensions import Self - -class BackgroundResponse(BaseModel): - """ - Response model for background update. - """ # noqa: E501 - background: StrictStr - disposition: Optional[DispositionTraits] = None - __properties: ClassVar[List[str]] = ["background", "disposition"] - - 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 BackgroundResponse 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 disposition - if self.disposition: - _dict['disposition'] = self.disposition.to_dict() - # set to None if disposition (nullable) is None - # and model_fields_set contains the field - if self.disposition is None and "disposition" in self.model_fields_set: - _dict['disposition'] = None - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of BackgroundResponse from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "background": obj.get("background"), - "disposition": DispositionTraits.from_dict(obj["disposition"]) if obj.get("disposition") is not None else None - }) - return _obj - - 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 deleted file mode 100644 index 4dbff66f..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/bank_list_item.py +++ /dev/null @@ -1,111 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 hindsight_client_api.models.disposition_traits import DispositionTraits -from typing import Optional, Set -from typing_extensions import Self - -class BankListItem(BaseModel): - """ - Bank list item with profile summary. - """ # noqa: E501 - bank_id: StrictStr - name: StrictStr - disposition: DispositionTraits - background: StrictStr - created_at: Optional[StrictStr] = None - updated_at: Optional[StrictStr] = None - __properties: ClassVar[List[str]] = ["bank_id", "name", "disposition", "background", "created_at", "updated_at"] - - 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 BankListItem 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 disposition - if self.disposition: - _dict['disposition'] = self.disposition.to_dict() - # set to None if created_at (nullable) is None - # and model_fields_set contains the field - if self.created_at is None and "created_at" in self.model_fields_set: - _dict['created_at'] = None - - # set to None if updated_at (nullable) is None - # and model_fields_set contains the field - if self.updated_at is None and "updated_at" in self.model_fields_set: - _dict['updated_at'] = None - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of BankListItem 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"), - "name": obj.get("name"), - "disposition": DispositionTraits.from_dict(obj["disposition"]) if obj.get("disposition") is not None else None, - "background": obj.get("background"), - "created_at": obj.get("created_at"), - "updated_at": obj.get("updated_at") - }) - return _obj - - 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 deleted file mode 100644 index b053334b..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/bank_list_response.py +++ /dev/null @@ -1,95 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 -from typing import Any, ClassVar, Dict, List -from hindsight_client_api.models.bank_list_item import BankListItem -from typing import Optional, Set -from typing_extensions import Self - -class BankListResponse(BaseModel): - """ - Response model for listing all banks. - """ # noqa: E501 - banks: List[BankListItem] - __properties: ClassVar[List[str]] = ["banks"] - - 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 BankListResponse 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 banks (list) - _items = [] - if self.banks: - for _item_banks in self.banks: - if _item_banks: - _items.append(_item_banks.to_dict()) - _dict['banks'] = _items - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of BankListResponse from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "banks": [BankListItem.from_dict(_item) for _item in obj["banks"]] if obj.get("banks") 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 deleted file mode 100644 index cae66e52..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/bank_profile_response.py +++ /dev/null @@ -1,97 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 -from hindsight_client_api.models.disposition_traits import DispositionTraits -from typing import Optional, Set -from typing_extensions import Self - -class BankProfileResponse(BaseModel): - """ - Response model for bank profile. - """ # noqa: E501 - bank_id: StrictStr - name: StrictStr - disposition: DispositionTraits - background: StrictStr - __properties: ClassVar[List[str]] = ["bank_id", "name", "disposition", "background"] - - 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 BankProfileResponse 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 disposition - if self.disposition: - _dict['disposition'] = self.disposition.to_dict() - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of BankProfileResponse 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"), - "name": obj.get("name"), - "disposition": DispositionTraits.from_dict(obj["disposition"]) if obj.get("disposition") is not None else None, - "background": obj.get("background") - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/budget.py b/hindsight-clients/python/hindsight_client_api/models/budget.py deleted file mode 100644 index b02b73ae..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/budget.py +++ /dev/null @@ -1,38 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -from __future__ import annotations -import json -from enum import Enum -from typing_extensions import Self - - -class Budget(str, Enum): - """ - Budget levels for recall/reflect operations. - """ - - """ - allowed enum values - """ - LOW = 'low' - MID = 'mid' - HIGH = 'high' - - @classmethod - def from_json(cls, json_str: str) -> Self: - """Create an instance of Budget from a JSON string""" - return cls(json.loads(json_str)) - - diff --git a/hindsight-clients/python/hindsight_client_api/models/chunk_data.py b/hindsight-clients/python/hindsight_client_api/models/chunk_data.py deleted file mode 100644 index 80fb1ca8..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/chunk_data.py +++ /dev/null @@ -1,93 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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, StrictInt, StrictStr -from typing import Any, ClassVar, Dict, List, Optional -from typing import Optional, Set -from typing_extensions import Self - -class ChunkData(BaseModel): - """ - Chunk data for a single chunk. - """ # noqa: E501 - id: StrictStr - text: StrictStr - chunk_index: StrictInt - truncated: Optional[StrictBool] = Field(default=False, description="Whether the chunk text was truncated due to token limits") - __properties: ClassVar[List[str]] = ["id", "text", "chunk_index", "truncated"] - - 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 ChunkData 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 ChunkData from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "id": obj.get("id"), - "text": obj.get("text"), - "chunk_index": obj.get("chunk_index"), - "truncated": obj.get("truncated") if obj.get("truncated") is not None else False - }) - return _obj - - 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 deleted file mode 100644 index 2e702e6c..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/chunk_include_options.py +++ /dev/null @@ -1,87 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 -from typing import Any, ClassVar, Dict, List, Optional -from typing import Optional, Set -from typing_extensions import Self - -class ChunkIncludeOptions(BaseModel): - """ - Options for including chunks in recall results. - """ # noqa: E501 - max_tokens: Optional[StrictInt] = Field(default=8192, description="Maximum tokens for chunks (chunks may be truncated)") - __properties: ClassVar[List[str]] = ["max_tokens"] - - 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 ChunkIncludeOptions 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 ChunkIncludeOptions from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 8192 - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/chunk_response.py b/hindsight-clients/python/hindsight_client_api/models/chunk_response.py deleted file mode 100644 index c0aa2c49..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/chunk_response.py +++ /dev/null @@ -1,97 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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, StrictInt, StrictStr -from typing import Any, ClassVar, Dict, List -from typing import Optional, Set -from typing_extensions import Self - -class ChunkResponse(BaseModel): - """ - Response model for get chunk endpoint. - """ # noqa: E501 - chunk_id: StrictStr - document_id: StrictStr - bank_id: StrictStr - chunk_index: StrictInt - chunk_text: StrictStr - created_at: StrictStr - __properties: ClassVar[List[str]] = ["chunk_id", "document_id", "bank_id", "chunk_index", "chunk_text", "created_at"] - - 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 ChunkResponse 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 ChunkResponse from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "chunk_id": obj.get("chunk_id"), - "document_id": obj.get("document_id"), - "bank_id": obj.get("bank_id"), - "chunk_index": obj.get("chunk_index"), - "chunk_text": obj.get("chunk_text"), - "created_at": obj.get("created_at") - }) - return _obj - - 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 deleted file mode 100644 index 1b3b8705..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/create_bank_request.py +++ /dev/null @@ -1,110 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 hindsight_client_api.models.disposition_traits import DispositionTraits -from typing import Optional, Set -from typing_extensions import Self - -class CreateBankRequest(BaseModel): - """ - Request model for creating/updating a bank. - """ # noqa: E501 - name: Optional[StrictStr] = None - disposition: Optional[DispositionTraits] = None - background: Optional[StrictStr] = None - __properties: ClassVar[List[str]] = ["name", "disposition", "background"] - - 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 CreateBankRequest 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 disposition - if self.disposition: - _dict['disposition'] = self.disposition.to_dict() - # set to None if name (nullable) is None - # and model_fields_set contains the field - if self.name is None and "name" in self.model_fields_set: - _dict['name'] = None - - # set to None if disposition (nullable) is None - # and model_fields_set contains the field - if self.disposition is None and "disposition" in self.model_fields_set: - _dict['disposition'] = None - - # set to None if background (nullable) is None - # and model_fields_set contains the field - if self.background is None and "background" in self.model_fields_set: - _dict['background'] = None - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of CreateBankRequest from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "name": obj.get("name"), - "disposition": DispositionTraits.from_dict(obj["disposition"]) if obj.get("disposition") is not None else None, - "background": obj.get("background") - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/delete_response.py b/hindsight-clients/python/hindsight_client_api/models/delete_response.py deleted file mode 100644 index 6287c7f9..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/delete_response.py +++ /dev/null @@ -1,87 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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, StrictBool -from typing import Any, ClassVar, Dict, List -from typing import Optional, Set -from typing_extensions import Self - -class DeleteResponse(BaseModel): - """ - Response model for delete operations. - """ # noqa: E501 - success: StrictBool - __properties: ClassVar[List[str]] = ["success"] - - 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 DeleteResponse 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 DeleteResponse from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "success": obj.get("success") - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/disposition_traits.py b/hindsight-clients/python/hindsight_client_api/models/disposition_traits.py deleted file mode 100644 index cf858051..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/disposition_traits.py +++ /dev/null @@ -1,92 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 typing_extensions import Annotated -from typing import Optional, Set -from typing_extensions import Self - -class DispositionTraits(BaseModel): - """ - Disposition traits that influence how memories are formed and interpreted. - """ # noqa: E501 - skepticism: Annotated[int, Field(le=5, strict=True, ge=1)] = Field(description="How skeptical vs trusting (1=trusting, 5=skeptical)") - literalism: Annotated[int, Field(le=5, strict=True, ge=1)] = Field(description="How literally to interpret information (1=flexible, 5=literal)") - empathy: Annotated[int, Field(le=5, strict=True, ge=1)] = Field(description="How much to consider emotional context (1=detached, 5=empathetic)") - __properties: ClassVar[List[str]] = ["skepticism", "literalism", "empathy"] - - 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 DispositionTraits 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 DispositionTraits from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "skepticism": obj.get("skepticism"), - "literalism": obj.get("literalism"), - "empathy": obj.get("empathy") - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/document_response.py b/hindsight-clients/python/hindsight_client_api/models/document_response.py deleted file mode 100644 index 081cc8cb..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/document_response.py +++ /dev/null @@ -1,104 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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, StrictInt, StrictStr -from typing import Any, ClassVar, Dict, List, Optional -from typing import Optional, Set -from typing_extensions import Self - -class DocumentResponse(BaseModel): - """ - Response model for get document endpoint. - """ # noqa: E501 - id: StrictStr - bank_id: StrictStr - original_text: StrictStr - content_hash: Optional[StrictStr] - created_at: StrictStr - updated_at: StrictStr - memory_unit_count: StrictInt - __properties: ClassVar[List[str]] = ["id", "bank_id", "original_text", "content_hash", "created_at", "updated_at", "memory_unit_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 DocumentResponse 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 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: - _dict['content_hash'] = None - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of DocumentResponse from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "id": obj.get("id"), - "bank_id": obj.get("bank_id"), - "original_text": obj.get("original_text"), - "content_hash": obj.get("content_hash"), - "created_at": obj.get("created_at"), - "updated_at": obj.get("updated_at"), - "memory_unit_count": obj.get("memory_unit_count") - }) - 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 deleted file mode 100644 index 33c6f329..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/entity_detail_response.py +++ /dev/null @@ -1,122 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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, StrictInt, StrictStr -from typing import Any, ClassVar, Dict, List, Optional -from hindsight_client_api.models.entity_observation_response import EntityObservationResponse -from typing import Optional, Set -from typing_extensions import Self - -class EntityDetailResponse(BaseModel): - """ - Response model for entity detail endpoint. - """ # noqa: E501 - id: StrictStr - canonical_name: StrictStr - mention_count: StrictInt - first_seen: Optional[StrictStr] = None - last_seen: Optional[StrictStr] = None - metadata: Optional[Dict[str, Any]] = None - observations: List[EntityObservationResponse] - __properties: ClassVar[List[str]] = ["id", "canonical_name", "mention_count", "first_seen", "last_seen", "metadata", "observations"] - - 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 EntityDetailResponse 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 observations (list) - _items = [] - if self.observations: - for _item_observations in self.observations: - if _item_observations: - _items.append(_item_observations.to_dict()) - _dict['observations'] = _items - # set to None if first_seen (nullable) is None - # and model_fields_set contains the field - if self.first_seen is None and "first_seen" in self.model_fields_set: - _dict['first_seen'] = None - - # set to None if last_seen (nullable) is None - # and model_fields_set contains the field - if self.last_seen is None and "last_seen" in self.model_fields_set: - _dict['last_seen'] = None - - # set to None if metadata (nullable) is None - # and model_fields_set contains the field - if self.metadata is None and "metadata" in self.model_fields_set: - _dict['metadata'] = None - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of EntityDetailResponse from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "id": obj.get("id"), - "canonical_name": obj.get("canonical_name"), - "mention_count": obj.get("mention_count"), - "first_seen": obj.get("first_seen"), - "last_seen": obj.get("last_seen"), - "metadata": obj.get("metadata"), - "observations": [EntityObservationResponse.from_dict(_item) for _item in obj["observations"]] if obj.get("observations") is not None else None - }) - return _obj - - 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 deleted file mode 100644 index d7bfa08a..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/entity_include_options.py +++ /dev/null @@ -1,87 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 -from typing import Any, ClassVar, Dict, List, Optional -from typing import Optional, Set -from typing_extensions import Self - -class EntityIncludeOptions(BaseModel): - """ - Options for including entity observations in recall results. - """ # noqa: E501 - max_tokens: Optional[StrictInt] = Field(default=500, description="Maximum tokens for entity observations") - __properties: ClassVar[List[str]] = ["max_tokens"] - - 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 EntityIncludeOptions 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 EntityIncludeOptions from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 500 - }) - return _obj - - 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 deleted file mode 100644 index 3c8face1..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/entity_list_item.py +++ /dev/null @@ -1,112 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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, StrictInt, StrictStr -from typing import Any, ClassVar, Dict, List, Optional -from typing import Optional, Set -from typing_extensions import Self - -class EntityListItem(BaseModel): - """ - Entity list item with summary. - """ # noqa: E501 - id: StrictStr - canonical_name: StrictStr - mention_count: StrictInt - first_seen: Optional[StrictStr] = None - last_seen: Optional[StrictStr] = None - metadata: Optional[Dict[str, Any]] = None - __properties: ClassVar[List[str]] = ["id", "canonical_name", "mention_count", "first_seen", "last_seen", "metadata"] - - 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 EntityListItem 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 first_seen (nullable) is None - # and model_fields_set contains the field - if self.first_seen is None and "first_seen" in self.model_fields_set: - _dict['first_seen'] = None - - # set to None if last_seen (nullable) is None - # and model_fields_set contains the field - if self.last_seen is None and "last_seen" in self.model_fields_set: - _dict['last_seen'] = None - - # set to None if metadata (nullable) is None - # and model_fields_set contains the field - if self.metadata is None and "metadata" in self.model_fields_set: - _dict['metadata'] = None - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of EntityListItem from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "id": obj.get("id"), - "canonical_name": obj.get("canonical_name"), - "mention_count": obj.get("mention_count"), - "first_seen": obj.get("first_seen"), - "last_seen": obj.get("last_seen"), - "metadata": obj.get("metadata") - }) - return _obj - - 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 deleted file mode 100644 index 792f9f9b..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/entity_list_response.py +++ /dev/null @@ -1,95 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 -from typing import Any, ClassVar, Dict, List -from hindsight_client_api.models.entity_list_item import EntityListItem -from typing import Optional, Set -from typing_extensions import Self - -class EntityListResponse(BaseModel): - """ - Response model for entity list endpoint. - """ # noqa: E501 - items: List[EntityListItem] - __properties: ClassVar[List[str]] = ["items"] - - 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 EntityListResponse 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 items (list) - _items = [] - if self.items: - for _item_items in self.items: - if _item_items: - _items.append(_item_items.to_dict()) - _dict['items'] = _items - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of EntityListResponse from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "items": [EntityListItem.from_dict(_item) for _item in obj["items"]] if obj.get("items") is not None else None - }) - return _obj - - 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 deleted file mode 100644 index 7c3a77ac..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/entity_observation_response.py +++ /dev/null @@ -1,94 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 EntityObservationResponse(BaseModel): - """ - An observation about an entity. - """ # noqa: E501 - text: StrictStr - mentioned_at: Optional[StrictStr] = None - __properties: ClassVar[List[str]] = ["text", "mentioned_at"] - - 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 EntityObservationResponse 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 mentioned_at (nullable) is None - # and model_fields_set contains the field - if self.mentioned_at is None and "mentioned_at" in self.model_fields_set: - _dict['mentioned_at'] = None - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of EntityObservationResponse 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"), - "mentioned_at": obj.get("mentioned_at") - }) - return _obj - - 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 deleted file mode 100644 index 93ee13d2..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/entity_state_response.py +++ /dev/null @@ -1,99 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 -from hindsight_client_api.models.entity_observation_response import EntityObservationResponse -from typing import Optional, Set -from typing_extensions import Self - -class EntityStateResponse(BaseModel): - """ - Current mental model of an entity. - """ # noqa: E501 - entity_id: StrictStr - canonical_name: StrictStr - observations: List[EntityObservationResponse] - __properties: ClassVar[List[str]] = ["entity_id", "canonical_name", "observations"] - - 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 EntityStateResponse 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 observations (list) - _items = [] - if self.observations: - for _item_observations in self.observations: - if _item_observations: - _items.append(_item_observations.to_dict()) - _dict['observations'] = _items - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of EntityStateResponse from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "entity_id": obj.get("entity_id"), - "canonical_name": obj.get("canonical_name"), - "observations": [EntityObservationResponse.from_dict(_item) for _item in obj["observations"]] if obj.get("observations") is not None else None - }) - return _obj - - 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 deleted file mode 100644 index e2263142..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/graph_data_response.py +++ /dev/null @@ -1,93 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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, StrictInt -from typing import Any, ClassVar, Dict, List -from typing import Optional, Set -from typing_extensions import Self - -class GraphDataResponse(BaseModel): - """ - Response model for graph data endpoint. - """ # noqa: E501 - nodes: List[Dict[str, Any]] - edges: List[Dict[str, Any]] - table_rows: List[Dict[str, Any]] - total_units: StrictInt - __properties: ClassVar[List[str]] = ["nodes", "edges", "table_rows", "total_units"] - - 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 GraphDataResponse 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 GraphDataResponse from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "nodes": obj.get("nodes"), - "edges": obj.get("edges"), - "table_rows": obj.get("table_rows"), - "total_units": obj.get("total_units") - }) - return _obj - - 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 deleted file mode 100644 index 7039acae..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/http_validation_error.py +++ /dev/null @@ -1,95 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 -from typing import Any, ClassVar, Dict, List, Optional -from hindsight_client_api.models.validation_error import ValidationError -from typing import Optional, Set -from typing_extensions import Self - -class HTTPValidationError(BaseModel): - """ - HTTPValidationError - """ # noqa: E501 - detail: Optional[List[ValidationError]] = None - __properties: ClassVar[List[str]] = ["detail"] - - 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 HTTPValidationError 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 detail (list) - _items = [] - if self.detail: - for _item_detail in self.detail: - if _item_detail: - _items.append(_item_detail.to_dict()) - _dict['detail'] = _items - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of HTTPValidationError from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "detail": [ValidationError.from_dict(_item) for _item in obj["detail"]] if obj.get("detail") is not None else None - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/include_options.py b/hindsight-clients/python/hindsight_client_api/models/include_options.py deleted file mode 100644 index affb1420..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/include_options.py +++ /dev/null @@ -1,107 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 -from typing import Any, ClassVar, Dict, List, Optional -from hindsight_client_api.models.chunk_include_options import ChunkIncludeOptions -from hindsight_client_api.models.entity_include_options import EntityIncludeOptions -from typing import Optional, Set -from typing_extensions import Self - -class IncludeOptions(BaseModel): - """ - Options for including additional data in recall results. - """ # noqa: E501 - entities: Optional[EntityIncludeOptions] = None - chunks: Optional[ChunkIncludeOptions] = None - __properties: ClassVar[List[str]] = ["entities", "chunks"] - - 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 IncludeOptions 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 entities - if self.entities: - _dict['entities'] = self.entities.to_dict() - # override the default output from pydantic by calling `to_dict()` of chunks - if self.chunks: - _dict['chunks'] = self.chunks.to_dict() - # 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 chunks (nullable) is None - # and model_fields_set contains the field - if self.chunks is None and "chunks" in self.model_fields_set: - _dict['chunks'] = None - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of IncludeOptions from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "entities": EntityIncludeOptions.from_dict(obj["entities"]) if obj.get("entities") is not None else None, - "chunks": ChunkIncludeOptions.from_dict(obj["chunks"]) if obj.get("chunks") is not None else None - }) - return _obj - - 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 deleted file mode 100644 index 846b0b71..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/list_documents_response.py +++ /dev/null @@ -1,93 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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, StrictInt -from typing import Any, ClassVar, Dict, List -from typing import Optional, Set -from typing_extensions import Self - -class ListDocumentsResponse(BaseModel): - """ - Response model for list documents endpoint. - """ # noqa: E501 - items: List[Dict[str, Any]] - total: StrictInt - limit: StrictInt - offset: StrictInt - __properties: ClassVar[List[str]] = ["items", "total", "limit", "offset"] - - 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 ListDocumentsResponse 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 ListDocumentsResponse from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "items": obj.get("items"), - "total": obj.get("total"), - "limit": obj.get("limit"), - "offset": obj.get("offset") - }) - return _obj - - 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 deleted file mode 100644 index 0f86644d..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/list_memory_units_response.py +++ /dev/null @@ -1,93 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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, StrictInt -from typing import Any, ClassVar, Dict, List -from typing import Optional, Set -from typing_extensions import Self - -class ListMemoryUnitsResponse(BaseModel): - """ - Response model for list memory units endpoint. - """ # noqa: E501 - items: List[Dict[str, Any]] - total: StrictInt - limit: StrictInt - offset: StrictInt - __properties: ClassVar[List[str]] = ["items", "total", "limit", "offset"] - - 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 ListMemoryUnitsResponse 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 ListMemoryUnitsResponse from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "items": obj.get("items"), - "total": obj.get("total"), - "limit": obj.get("limit"), - "offset": obj.get("offset") - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/memory_item.py b/hindsight-clients/python/hindsight_client_api/models/memory_item.py deleted file mode 100644 index f8ba77a6..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/memory_item.py +++ /dev/null @@ -1,116 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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, StrictStr -from typing import Any, ClassVar, Dict, List, Optional -from typing import Optional, Set -from typing_extensions import Self - -class MemoryItem(BaseModel): - """ - Single memory item for retain. - """ # noqa: E501 - content: StrictStr - timestamp: Optional[datetime] = None - context: Optional[StrictStr] = None - metadata: Optional[Dict[str, StrictStr]] = None - document_id: Optional[StrictStr] = None - __properties: ClassVar[List[str]] = ["content", "timestamp", "context", "metadata", "document_id"] - - 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 MemoryItem 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 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 metadata (nullable) is None - # and model_fields_set contains the field - if self.metadata is None and "metadata" in self.model_fields_set: - _dict['metadata'] = None - - # set to None if document_id (nullable) is None - # and model_fields_set contains the field - if self.document_id is None and "document_id" in self.model_fields_set: - _dict['document_id'] = None - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of MemoryItem 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"), - "timestamp": obj.get("timestamp"), - "context": obj.get("context"), - "metadata": obj.get("metadata"), - "document_id": obj.get("document_id") - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/recall_request.py b/hindsight-clients/python/hindsight_client_api/models/recall_request.py deleted file mode 100644 index ba3a8bfc..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/recall_request.py +++ /dev/null @@ -1,114 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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, StrictInt, StrictStr -from typing import Any, ClassVar, Dict, List, Optional -from hindsight_client_api.models.budget import Budget -from hindsight_client_api.models.include_options import IncludeOptions -from typing import Optional, Set -from typing_extensions import Self - -class RecallRequest(BaseModel): - """ - Request model for recall endpoint. - """ # noqa: E501 - query: StrictStr - types: Optional[List[StrictStr]] = None - budget: Optional[Budget] = None - max_tokens: Optional[StrictInt] = 4096 - trace: Optional[StrictBool] = False - query_timestamp: Optional[StrictStr] = None - include: Optional[IncludeOptions] = Field(default=None, description="Options for including additional data (entities are included by default)") - __properties: ClassVar[List[str]] = ["query", "types", "budget", "max_tokens", "trace", "query_timestamp", "include"] - - 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 RecallRequest 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 include - if self.include: - _dict['include'] = self.include.to_dict() - # set to None if types (nullable) is None - # and model_fields_set contains the field - if self.types is None and "types" in self.model_fields_set: - _dict['types'] = None - - # set to None if query_timestamp (nullable) is None - # and model_fields_set contains the field - if self.query_timestamp is None and "query_timestamp" in self.model_fields_set: - _dict['query_timestamp'] = None - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of RecallRequest from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "query": obj.get("query"), - "types": obj.get("types"), - "budget": obj.get("budget"), - "max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 4096, - "trace": obj.get("trace") if obj.get("trace") is not None else False, - "query_timestamp": obj.get("query_timestamp"), - "include": IncludeOptions.from_dict(obj["include"]) if obj.get("include") is not None else None - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/recall_response.py b/hindsight-clients/python/hindsight_client_api/models/recall_response.py deleted file mode 100644 index ef784258..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/recall_response.py +++ /dev/null @@ -1,142 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 -from typing import Any, ClassVar, Dict, List, Optional -from hindsight_client_api.models.chunk_data import ChunkData -from hindsight_client_api.models.entity_state_response import EntityStateResponse -from hindsight_client_api.models.recall_result import RecallResult -from typing import Optional, Set -from typing_extensions import Self - -class RecallResponse(BaseModel): - """ - Response model for recall endpoints. - """ # noqa: E501 - results: List[RecallResult] - trace: Optional[Dict[str, Any]] = None - entities: Optional[Dict[str, EntityStateResponse]] = None - chunks: Optional[Dict[str, ChunkData]] = None - __properties: ClassVar[List[str]] = ["results", "trace", "entities", "chunks"] - - 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 RecallResponse 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 results (list) - _items = [] - if self.results: - for _item_results in self.results: - if _item_results: - _items.append(_item_results.to_dict()) - _dict['results'] = _items - # override the default output from pydantic by calling `to_dict()` of each value in entities (dict) - _field_dict = {} - if self.entities: - for _key_entities in self.entities: - if self.entities[_key_entities]: - _field_dict[_key_entities] = self.entities[_key_entities].to_dict() - _dict['entities'] = _field_dict - # override the default output from pydantic by calling `to_dict()` of each value in chunks (dict) - _field_dict = {} - if self.chunks: - for _key_chunks in self.chunks: - if self.chunks[_key_chunks]: - _field_dict[_key_chunks] = self.chunks[_key_chunks].to_dict() - _dict['chunks'] = _field_dict - # set to None if trace (nullable) is None - # and model_fields_set contains the field - if self.trace is None and "trace" in self.model_fields_set: - _dict['trace'] = 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 chunks (nullable) is None - # and model_fields_set contains the field - if self.chunks is None and "chunks" in self.model_fields_set: - _dict['chunks'] = None - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of RecallResponse from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "results": [RecallResult.from_dict(_item) for _item in obj["results"]] if obj.get("results") is not None else None, - "trace": obj.get("trace"), - "entities": dict( - (_k, EntityStateResponse.from_dict(_v)) - for _k, _v in obj["entities"].items() - ) - if obj.get("entities") is not None - else None, - "chunks": dict( - (_k, ChunkData.from_dict(_v)) - for _k, _v in obj["chunks"].items() - ) - if obj.get("chunks") is not None - else None - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/recall_result.py b/hindsight-clients/python/hindsight_client_api/models/recall_result.py deleted file mode 100644 index 7f28d283..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/recall_result.py +++ /dev/null @@ -1,152 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 RecallResult(BaseModel): - """ - Single recall result item. - """ # noqa: E501 - id: StrictStr - text: StrictStr - type: Optional[StrictStr] = None - entities: Optional[List[StrictStr]] = None - context: Optional[StrictStr] = None - occurred_start: Optional[StrictStr] = None - occurred_end: Optional[StrictStr] = None - mentioned_at: Optional[StrictStr] = None - document_id: Optional[StrictStr] = None - metadata: Optional[Dict[str, StrictStr]] = None - chunk_id: Optional[StrictStr] = None - __properties: ClassVar[List[str]] = ["id", "text", "type", "entities", "context", "occurred_start", "occurred_end", "mentioned_at", "document_id", "metadata", "chunk_id"] - - 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 RecallResult 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 type (nullable) is None - # and model_fields_set contains the field - if self.type is None and "type" in self.model_fields_set: - _dict['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 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 mentioned_at (nullable) is None - # and model_fields_set contains the field - if self.mentioned_at is None and "mentioned_at" in self.model_fields_set: - _dict['mentioned_at'] = None - - # set to None if document_id (nullable) is None - # and model_fields_set contains the field - if self.document_id is None and "document_id" in self.model_fields_set: - _dict['document_id'] = None - - # set to None if metadata (nullable) is None - # and model_fields_set contains the field - if self.metadata is None and "metadata" in self.model_fields_set: - _dict['metadata'] = None - - # set to None if chunk_id (nullable) is None - # and model_fields_set contains the field - if self.chunk_id is None and "chunk_id" in self.model_fields_set: - _dict['chunk_id'] = None - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of RecallResult from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "id": obj.get("id"), - "text": obj.get("text"), - "type": obj.get("type"), - "entities": obj.get("entities"), - "context": obj.get("context"), - "occurred_start": obj.get("occurred_start"), - "occurred_end": obj.get("occurred_end"), - "mentioned_at": obj.get("mentioned_at"), - "document_id": obj.get("document_id"), - "metadata": obj.get("metadata"), - "chunk_id": obj.get("chunk_id") - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_fact.py b/hindsight-clients/python/hindsight_client_api/models/reflect_fact.py deleted file mode 100644 index bf1e8c05..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_fact.py +++ /dev/null @@ -1,122 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 ReflectFact(BaseModel): - """ - A fact used in think response. - """ # noqa: E501 - id: Optional[StrictStr] = None - text: StrictStr - type: Optional[StrictStr] = None - context: Optional[StrictStr] = None - occurred_start: Optional[StrictStr] = None - occurred_end: Optional[StrictStr] = None - __properties: ClassVar[List[str]] = ["id", "text", "type", "context", "occurred_start", "occurred_end"] - - 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 ReflectFact 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 id (nullable) is None - # and model_fields_set contains the field - if self.id is None and "id" in self.model_fields_set: - _dict['id'] = None - - # set to None if type (nullable) is None - # and model_fields_set contains the field - if self.type is None and "type" in self.model_fields_set: - _dict['type'] = 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 - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of ReflectFact from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "id": obj.get("id"), - "text": obj.get("text"), - "type": obj.get("type"), - "context": obj.get("context"), - "occurred_start": obj.get("occurred_start"), - "occurred_end": obj.get("occurred_end") - }) - return _obj - - 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 deleted file mode 100644 index bb0eb392..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_include_options.py +++ /dev/null @@ -1,87 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 typing import Optional, Set -from typing_extensions import Self - -class ReflectIncludeOptions(BaseModel): - """ - Options for including additional data in reflect results. - """ # noqa: E501 - facts: Optional[Dict[str, Any]] = Field(default=None, description="Options for including facts (based_on) in reflect results.") - __properties: ClassVar[List[str]] = ["facts"] - - 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 ReflectIncludeOptions 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 ReflectIncludeOptions from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "facts": obj.get("facts") - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_request.py b/hindsight-clients/python/hindsight_client_api/models/reflect_request.py deleted file mode 100644 index b058920e..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_request.py +++ /dev/null @@ -1,103 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 hindsight_client_api.models.budget import Budget -from hindsight_client_api.models.reflect_include_options import ReflectIncludeOptions -from typing import Optional, Set -from typing_extensions import Self - -class ReflectRequest(BaseModel): - """ - Request model for reflect endpoint. - """ # noqa: E501 - query: StrictStr - budget: Optional[Budget] = None - context: Optional[StrictStr] = None - include: Optional[ReflectIncludeOptions] = Field(default=None, description="Options for including additional data (disabled by default)") - __properties: ClassVar[List[str]] = ["query", "budget", "context", "include"] - - 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 ReflectRequest 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 include - if self.include: - _dict['include'] = self.include.to_dict() - # 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 - - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of ReflectRequest from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "query": obj.get("query"), - "budget": obj.get("budget"), - "context": obj.get("context"), - "include": ReflectIncludeOptions.from_dict(obj["include"]) if obj.get("include") is not None else None - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/reflect_response.py b/hindsight-clients/python/hindsight_client_api/models/reflect_response.py deleted file mode 100644 index 5c2284ab..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/reflect_response.py +++ /dev/null @@ -1,97 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 hindsight_client_api.models.reflect_fact import ReflectFact -from typing import Optional, Set -from typing_extensions import Self - -class ReflectResponse(BaseModel): - """ - Response model for think endpoint. - """ # noqa: E501 - text: StrictStr - based_on: Optional[List[ReflectFact]] = None - __properties: ClassVar[List[str]] = ["text", "based_on"] - - 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 ReflectResponse 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 based_on (list) - _items = [] - if self.based_on: - for _item_based_on in self.based_on: - if _item_based_on: - _items.append(_item_based_on.to_dict()) - _dict['based_on'] = _items - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of ReflectResponse 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"), - "based_on": [ReflectFact.from_dict(_item) for _item in obj["based_on"]] if obj.get("based_on") is not None else None - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/retain_request.py b/hindsight-clients/python/hindsight_client_api/models/retain_request.py deleted file mode 100644 index 30d401c9..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/retain_request.py +++ /dev/null @@ -1,97 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 -from typing import Any, ClassVar, Dict, List, Optional -from hindsight_client_api.models.memory_item import MemoryItem -from typing import Optional, Set -from typing_extensions import Self - -class RetainRequest(BaseModel): - """ - Request model for retain endpoint. - """ # noqa: E501 - items: List[MemoryItem] - var_async: Optional[StrictBool] = Field(default=False, description="If true, process asynchronously in background. If false, wait for completion (default: false)", alias="async") - __properties: ClassVar[List[str]] = ["items", "async"] - - 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 RetainRequest 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 items (list) - _items = [] - if self.items: - for _item_items in self.items: - if _item_items: - _items.append(_item_items.to_dict()) - _dict['items'] = _items - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of RetainRequest from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "items": [MemoryItem.from_dict(_item) for _item in obj["items"]] if obj.get("items") is not None else None, - "async": obj.get("async") if obj.get("async") is not None else False - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/retain_response.py b/hindsight-clients/python/hindsight_client_api/models/retain_response.py deleted file mode 100644 index 97df1c76..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/retain_response.py +++ /dev/null @@ -1,93 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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, StrictInt, StrictStr -from typing import Any, ClassVar, Dict, List -from typing import Optional, Set -from typing_extensions import Self - -class RetainResponse(BaseModel): - """ - Response model for retain endpoint. - """ # noqa: E501 - success: StrictBool - bank_id: StrictStr - items_count: StrictInt - var_async: StrictBool = Field(description="Whether the operation was processed asynchronously", alias="async") - __properties: ClassVar[List[str]] = ["success", "bank_id", "items_count", "async"] - - 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 RetainResponse 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 RetainResponse from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "success": obj.get("success"), - "bank_id": obj.get("bank_id"), - "items_count": obj.get("items_count"), - "async": obj.get("async") - }) - return _obj - - 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 deleted file mode 100644 index be4aed2c..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/update_disposition_request.py +++ /dev/null @@ -1,91 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 -from typing import Any, ClassVar, Dict, List -from hindsight_client_api.models.disposition_traits import DispositionTraits -from typing import Optional, Set -from typing_extensions import Self - -class UpdateDispositionRequest(BaseModel): - """ - Request model for updating disposition traits. - """ # noqa: E501 - disposition: DispositionTraits - __properties: ClassVar[List[str]] = ["disposition"] - - 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 UpdateDispositionRequest 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 disposition - if self.disposition: - _dict['disposition'] = self.disposition.to_dict() - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of UpdateDispositionRequest from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "disposition": DispositionTraits.from_dict(obj["disposition"]) if obj.get("disposition") is not None else None - }) - return _obj - - diff --git a/hindsight-clients/python/hindsight_client_api/models/validation_error.py b/hindsight-clients/python/hindsight_client_api/models/validation_error.py deleted file mode 100644 index c5edd6c6..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/validation_error.py +++ /dev/null @@ -1,99 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - 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 -from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner -from typing import Optional, Set -from typing_extensions import Self - -class ValidationError(BaseModel): - """ - ValidationError - """ # noqa: E501 - loc: List[ValidationErrorLocInner] - msg: StrictStr - type: StrictStr - __properties: ClassVar[List[str]] = ["loc", "msg", "type"] - - 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 ValidationError 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 loc (list) - _items = [] - if self.loc: - for _item_loc in self.loc: - if _item_loc: - _items.append(_item_loc.to_dict()) - _dict['loc'] = _items - return _dict - - @classmethod - def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: - """Create an instance of ValidationError from a dict""" - if obj is None: - return None - - if not isinstance(obj, dict): - return cls.model_validate(obj) - - _obj = cls.model_validate({ - "loc": [ValidationErrorLocInner.from_dict(_item) for _item in obj["loc"]] if obj.get("loc") is not None else None, - "msg": obj.get("msg"), - "type": obj.get("type") - }) - return _obj - - 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 deleted file mode 100644 index 060def4e..00000000 --- a/hindsight-clients/python/hindsight_client_api/models/validation_error_loc_inner.py +++ /dev/null @@ -1,138 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -from __future__ import annotations -from inspect import getfullargspec -import json -import pprint -import re # noqa: F401 -from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr, ValidationError, field_validator -from typing import Optional -from typing import Union, Any, List, Set, TYPE_CHECKING, Optional, Dict -from typing_extensions import Literal, Self -from pydantic import Field - -VALIDATIONERRORLOCINNER_ANY_OF_SCHEMAS = ["int", "str"] - -class ValidationErrorLocInner(BaseModel): - """ - ValidationErrorLocInner - """ - - # data type: str - anyof_schema_1_validator: Optional[StrictStr] = None - # data type: int - anyof_schema_2_validator: Optional[StrictInt] = None - if TYPE_CHECKING: - actual_instance: Optional[Union[int, str]] = None - else: - actual_instance: Any = None - any_of_schemas: Set[str] = { "int", "str" } - - model_config = { - "validate_assignment": True, - "protected_namespaces": (), - } - - def __init__(self, *args, **kwargs) -> None: - if args: - if len(args) > 1: - raise ValueError("If a position argument is used, only 1 is allowed to set `actual_instance`") - if kwargs: - raise ValueError("If a position argument is used, keyword arguments cannot be used.") - super().__init__(actual_instance=args[0]) - else: - super().__init__(**kwargs) - - @field_validator('actual_instance') - def actual_instance_must_validate_anyof(cls, v): - instance = ValidationErrorLocInner.model_construct() - error_messages = [] - # validate data type: str - try: - instance.anyof_schema_1_validator = v - return v - except (ValidationError, ValueError) as e: - error_messages.append(str(e)) - # validate data type: int - try: - instance.anyof_schema_2_validator = v - return v - except (ValidationError, ValueError) as e: - error_messages.append(str(e)) - if error_messages: - # no match - raise ValueError("No match found when setting the actual_instance in ValidationErrorLocInner with anyOf schemas: int, str. Details: " + ", ".join(error_messages)) - else: - return v - - @classmethod - def from_dict(cls, obj: Dict[str, Any]) -> Self: - return cls.from_json(json.dumps(obj)) - - @classmethod - def from_json(cls, json_str: str) -> Self: - """Returns the object represented by the json string""" - instance = cls.model_construct() - error_messages = [] - # deserialize data into str - try: - # validation - instance.anyof_schema_1_validator = json.loads(json_str) - # assign value to actual_instance - instance.actual_instance = instance.anyof_schema_1_validator - return instance - except (ValidationError, ValueError) as e: - error_messages.append(str(e)) - # deserialize data into int - try: - # validation - instance.anyof_schema_2_validator = json.loads(json_str) - # assign value to actual_instance - instance.actual_instance = instance.anyof_schema_2_validator - return instance - except (ValidationError, ValueError) as e: - error_messages.append(str(e)) - - if error_messages: - # no match - raise ValueError("No match found when deserializing the JSON string into ValidationErrorLocInner with anyOf schemas: int, str. Details: " + ", ".join(error_messages)) - else: - return instance - - def to_json(self) -> str: - """Returns the JSON representation of the actual instance""" - if self.actual_instance is None: - return "null" - - if hasattr(self.actual_instance, "to_json") and callable(self.actual_instance.to_json): - return self.actual_instance.to_json() - else: - return json.dumps(self.actual_instance) - - def to_dict(self) -> Optional[Union[Dict[str, Any], int, str]]: - """Returns the dict representation of the actual instance""" - if self.actual_instance is None: - return None - - if hasattr(self.actual_instance, "to_dict") and callable(self.actual_instance.to_dict): - return self.actual_instance.to_dict() - else: - return self.actual_instance - - def to_str(self) -> str: - """Returns the string representation of the actual instance""" - return pprint.pformat(self.model_dump()) - - diff --git a/hindsight-clients/python/hindsight_client_api/rest.py b/hindsight-clients/python/hindsight_client_api/rest.py deleted file mode 100644 index e39244e5..00000000 --- a/hindsight-clients/python/hindsight_client_api/rest.py +++ /dev/null @@ -1,213 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import io -import json -import re -import ssl -from typing import Optional, Union - -import aiohttp -import aiohttp_retry - -from hindsight_client_api.exceptions import ApiException, ApiValueError - -RESTResponseType = aiohttp.ClientResponse - -ALLOW_RETRY_METHODS = frozenset({'DELETE', 'GET', 'HEAD', 'OPTIONS', 'PUT', 'TRACE'}) - -class RESTResponse(io.IOBase): - - def __init__(self, resp) -> None: - self.response = resp - self.status = resp.status - self.reason = resp.reason - self.data = None - - async def read(self): - if self.data is None: - self.data = await self.response.read() - return self.data - - def getheaders(self): - """Returns a CIMultiDictProxy of the response headers.""" - return self.response.headers - - def getheader(self, name, default=None): - """Returns a given response header.""" - return self.response.headers.get(name, default) - - -class RESTClientObject: - - def __init__(self, configuration) -> None: - - # maxsize is number of requests to host that are allowed in parallel - self.maxsize = configuration.connection_pool_maxsize - - self.ssl_context = ssl.create_default_context( - cafile=configuration.ssl_ca_cert, - cadata=configuration.ca_cert_data, - ) - if configuration.cert_file: - self.ssl_context.load_cert_chain( - configuration.cert_file, keyfile=configuration.key_file - ) - - if not configuration.verify_ssl: - self.ssl_context.check_hostname = False - self.ssl_context.verify_mode = ssl.CERT_NONE - - self.proxy = configuration.proxy - self.proxy_headers = configuration.proxy_headers - - self.retries = configuration.retries - - self.pool_manager: Optional[aiohttp.ClientSession] = None - self.retry_client: Optional[aiohttp_retry.RetryClient] = None - - async def close(self) -> None: - if self.pool_manager: - await self.pool_manager.close() - if self.retry_client is not None: - await self.retry_client.close() - - async def request( - self, - method, - url, - headers=None, - body=None, - post_params=None, - _request_timeout=None - ): - """Execute request - - :param method: http request method - :param url: http request url - :param headers: http request headers - :param body: request json body, for `application/json` - :param post_params: request post parameters, - `application/x-www-form-urlencoded` - and `multipart/form-data` - :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. - """ - method = method.upper() - assert method in [ - 'GET', - 'HEAD', - 'DELETE', - 'POST', - 'PUT', - 'PATCH', - 'OPTIONS' - ] - - if post_params and body: - raise ApiValueError( - "body parameter cannot be used with post_params parameter." - ) - - post_params = post_params or {} - headers = headers or {} - # url already contains the URL query string - timeout = _request_timeout or 5 * 60 - - if 'Content-Type' not in headers: - headers['Content-Type'] = 'application/json' - - args = { - "method": method, - "url": url, - "timeout": timeout, - "headers": headers - } - - if self.proxy: - args["proxy"] = self.proxy - if self.proxy_headers: - args["proxy_headers"] = self.proxy_headers - - # For `POST`, `PUT`, `PATCH`, `OPTIONS`, `DELETE` - if method in ['POST', 'PUT', 'PATCH', 'OPTIONS', 'DELETE']: - if re.search('json', headers['Content-Type'], re.IGNORECASE): - if body is not None: - body = json.dumps(body) - args["data"] = body - elif headers['Content-Type'] == 'application/x-www-form-urlencoded': - args["data"] = aiohttp.FormData(post_params) - elif headers['Content-Type'] == 'multipart/form-data': - # must del headers['Content-Type'], or the correct - # Content-Type which generated by aiohttp - del headers['Content-Type'] - data = aiohttp.FormData() - for param in post_params: - k, v = param - if isinstance(v, tuple) and len(v) == 3: - data.add_field( - k, - value=v[1], - filename=v[0], - content_type=v[2] - ) - else: - # Ensures that dict objects are serialized - if isinstance(v, dict): - v = json.dumps(v) - elif isinstance(v, int): - v = str(v) - data.add_field(k, v) - args["data"] = data - - # Pass a `bytes` or `str` parameter directly in the body to support - # other content types than Json when `body` argument is provided - # in serialized form - elif isinstance(body, str) or isinstance(body, bytes): - args["data"] = body - else: - # Cannot generate the request from given parameters - msg = """Cannot prepare a request message for provided - arguments. Please check that your arguments match - declared content type.""" - raise ApiException(status=0, reason=msg) - - pool_manager: Union[aiohttp.ClientSession, aiohttp_retry.RetryClient] - - # https pool manager - if self.pool_manager is None: - self.pool_manager = aiohttp.ClientSession( - connector=aiohttp.TCPConnector(limit=self.maxsize, ssl=self.ssl_context), - trust_env=True, - ) - pool_manager = self.pool_manager - - if self.retries is not None and method in ALLOW_RETRY_METHODS: - if self.retry_client is None: - self.retry_client = aiohttp_retry.RetryClient( - client_session=self.pool_manager, - retry_options=aiohttp_retry.ExponentialRetry( - attempts=self.retries, - factor=2.0, - start_timeout=0.1, - max_timeout=120.0 - ) - ) - pool_manager = self.retry_client - - r = await pool_manager.request(**args) - - return RESTResponse(r) diff --git a/hindsight-clients/python/hindsight_client_api/test/__init__.py b/hindsight-clients/python/hindsight_client_api/test/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/hindsight-clients/python/hindsight_client_api/test/test_add_background_request.py b/hindsight-clients/python/hindsight_client_api/test/test_add_background_request.py deleted file mode 100644 index 51583643..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_add_background_request.py +++ /dev/null @@ -1,53 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.add_background_request import AddBackgroundRequest - -class TestAddBackgroundRequest(unittest.TestCase): - """AddBackgroundRequest unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> AddBackgroundRequest: - """Test AddBackgroundRequest - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `AddBackgroundRequest` - """ - model = AddBackgroundRequest() - if include_optional: - return AddBackgroundRequest( - content = '', - update_disposition = True - ) - else: - return AddBackgroundRequest( - content = '', - ) - """ - - def testAddBackgroundRequest(self): - """Test AddBackgroundRequest""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_background_response.py b/hindsight-clients/python/hindsight_client_api/test/test_background_response.py deleted file mode 100644 index d48a57de..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_background_response.py +++ /dev/null @@ -1,53 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.background_response import BackgroundResponse - -class TestBackgroundResponse(unittest.TestCase): - """BackgroundResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> BackgroundResponse: - """Test BackgroundResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `BackgroundResponse` - """ - model = BackgroundResponse() - if include_optional: - return BackgroundResponse( - background = '', - disposition = {empathy=3, literalism=3, skepticism=3} - ) - else: - return BackgroundResponse( - background = '', - ) - """ - - def testBackgroundResponse(self): - """Test BackgroundResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_bank_list_item.py b/hindsight-clients/python/hindsight_client_api/test/test_bank_list_item.py deleted file mode 100644 index a8b0a4f3..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_bank_list_item.py +++ /dev/null @@ -1,60 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.bank_list_item import BankListItem - -class TestBankListItem(unittest.TestCase): - """BankListItem unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> BankListItem: - """Test BankListItem - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `BankListItem` - """ - model = BankListItem() - if include_optional: - return BankListItem( - bank_id = '', - name = '', - disposition = {empathy=3, literalism=3, skepticism=3}, - background = '', - created_at = '', - updated_at = '' - ) - else: - return BankListItem( - bank_id = '', - name = '', - disposition = {empathy=3, literalism=3, skepticism=3}, - background = '', - ) - """ - - def testBankListItem(self): - """Test BankListItem""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_bank_list_response.py b/hindsight-clients/python/hindsight_client_api/test/test_bank_list_response.py deleted file mode 100644 index 3b7fc4b8..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_bank_list_response.py +++ /dev/null @@ -1,68 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.bank_list_response import BankListResponse - -class TestBankListResponse(unittest.TestCase): - """BankListResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> BankListResponse: - """Test BankListResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `BankListResponse` - """ - model = BankListResponse() - if include_optional: - return BankListResponse( - banks = [ - hindsight_client_api.models.bank_list_item.BankListItem( - bank_id = '', - name = '', - disposition = {empathy=3, literalism=3, skepticism=3}, - background = '', - created_at = '', - updated_at = '', ) - ] - ) - else: - return BankListResponse( - banks = [ - hindsight_client_api.models.bank_list_item.BankListItem( - bank_id = '', - name = '', - disposition = {empathy=3, literalism=3, skepticism=3}, - background = '', - created_at = '', - updated_at = '', ) - ], - ) - """ - - def testBankListResponse(self): - """Test BankListResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_bank_profile_response.py b/hindsight-clients/python/hindsight_client_api/test/test_bank_profile_response.py deleted file mode 100644 index 8874401d..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_bank_profile_response.py +++ /dev/null @@ -1,58 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.bank_profile_response import BankProfileResponse - -class TestBankProfileResponse(unittest.TestCase): - """BankProfileResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> BankProfileResponse: - """Test BankProfileResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `BankProfileResponse` - """ - model = BankProfileResponse() - if include_optional: - return BankProfileResponse( - bank_id = '', - name = '', - disposition = {empathy=3, literalism=3, skepticism=3}, - background = '' - ) - else: - return BankProfileResponse( - bank_id = '', - name = '', - disposition = {empathy=3, literalism=3, skepticism=3}, - background = '', - ) - """ - - def testBankProfileResponse(self): - """Test BankProfileResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_budget.py b/hindsight-clients/python/hindsight_client_api/test/test_budget.py deleted file mode 100644 index 74f58ccf..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_budget.py +++ /dev/null @@ -1,33 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.budget import Budget - -class TestBudget(unittest.TestCase): - """Budget unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def testBudget(self): - """Test Budget""" - # inst = Budget() - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_chunk_data.py b/hindsight-clients/python/hindsight_client_api/test/test_chunk_data.py deleted file mode 100644 index 44934587..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_chunk_data.py +++ /dev/null @@ -1,57 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.chunk_data import ChunkData - -class TestChunkData(unittest.TestCase): - """ChunkData unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> ChunkData: - """Test ChunkData - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `ChunkData` - """ - model = ChunkData() - if include_optional: - return ChunkData( - id = '', - text = '', - chunk_index = 56, - truncated = True - ) - else: - return ChunkData( - id = '', - text = '', - chunk_index = 56, - ) - """ - - def testChunkData(self): - """Test ChunkData""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_chunk_include_options.py b/hindsight-clients/python/hindsight_client_api/test/test_chunk_include_options.py deleted file mode 100644 index 5ee877d6..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_chunk_include_options.py +++ /dev/null @@ -1,51 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.chunk_include_options import ChunkIncludeOptions - -class TestChunkIncludeOptions(unittest.TestCase): - """ChunkIncludeOptions unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> ChunkIncludeOptions: - """Test ChunkIncludeOptions - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `ChunkIncludeOptions` - """ - model = ChunkIncludeOptions() - if include_optional: - return ChunkIncludeOptions( - max_tokens = 56 - ) - else: - return ChunkIncludeOptions( - ) - """ - - def testChunkIncludeOptions(self): - """Test ChunkIncludeOptions""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_chunk_response.py b/hindsight-clients/python/hindsight_client_api/test/test_chunk_response.py deleted file mode 100644 index 1f29c403..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_chunk_response.py +++ /dev/null @@ -1,62 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.chunk_response import ChunkResponse - -class TestChunkResponse(unittest.TestCase): - """ChunkResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> ChunkResponse: - """Test ChunkResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `ChunkResponse` - """ - model = ChunkResponse() - if include_optional: - return ChunkResponse( - chunk_id = '', - document_id = '', - bank_id = '', - chunk_index = 56, - chunk_text = '', - created_at = '' - ) - else: - return ChunkResponse( - chunk_id = '', - document_id = '', - bank_id = '', - chunk_index = 56, - chunk_text = '', - created_at = '', - ) - """ - - def testChunkResponse(self): - """Test ChunkResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_create_bank_request.py b/hindsight-clients/python/hindsight_client_api/test/test_create_bank_request.py deleted file mode 100644 index 0e7d8157..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_create_bank_request.py +++ /dev/null @@ -1,53 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.create_bank_request import CreateBankRequest - -class TestCreateBankRequest(unittest.TestCase): - """CreateBankRequest unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> CreateBankRequest: - """Test CreateBankRequest - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `CreateBankRequest` - """ - model = CreateBankRequest() - if include_optional: - return CreateBankRequest( - name = '', - disposition = {empathy=3, literalism=3, skepticism=3}, - background = '' - ) - else: - return CreateBankRequest( - ) - """ - - def testCreateBankRequest(self): - """Test CreateBankRequest""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_default_api.py b/hindsight-clients/python/hindsight_client_api/test/test_default_api.py deleted file mode 100644 index a61b0756..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_default_api.py +++ /dev/null @@ -1,178 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.api.default_api import DefaultApi - - -class TestDefaultApi(unittest.IsolatedAsyncioTestCase): - """DefaultApi unit test stubs""" - - async def asyncSetUp(self) -> None: - self.api = DefaultApi() - - async def asyncTearDown(self) -> None: - await self.api.api_client.close() - - async def test_add_bank_background(self) -> None: - """Test case for add_bank_background - - Add/merge memory bank background - """ - pass - - async def test_cancel_operation(self) -> None: - """Test case for cancel_operation - - Cancel a pending async operation - """ - pass - - async def test_clear_bank_memories(self) -> None: - """Test case for clear_bank_memories - - Clear memory bank memories - """ - pass - - async def test_create_or_update_bank(self) -> None: - """Test case for create_or_update_bank - - Create or update memory bank - """ - pass - - async def test_delete_document(self) -> None: - """Test case for delete_document - - Delete a document - """ - pass - - async def test_get_agent_stats(self) -> None: - """Test case for get_agent_stats - - Get statistics for memory bank - """ - pass - - async def test_get_bank_profile(self) -> None: - """Test case for get_bank_profile - - Get memory bank profile - """ - pass - - async def test_get_chunk(self) -> None: - """Test case for get_chunk - - Get chunk details - """ - pass - - async def test_get_document(self) -> None: - """Test case for get_document - - Get document details - """ - pass - - async def test_get_entity(self) -> None: - """Test case for get_entity - - Get entity details - """ - pass - - async def test_get_graph(self) -> None: - """Test case for get_graph - - Get memory graph data - """ - pass - - async def test_list_banks(self) -> None: - """Test case for list_banks - - List all memory banks - """ - pass - - async def test_list_documents(self) -> None: - """Test case for list_documents - - List documents - """ - pass - - async def test_list_entities(self) -> None: - """Test case for list_entities - - List entities - """ - pass - - async def test_list_memories(self) -> None: - """Test case for list_memories - - List memory units - """ - pass - - async def test_list_operations(self) -> None: - """Test case for list_operations - - List async operations - """ - pass - - async def test_recall_memories(self) -> None: - """Test case for recall_memories - - Recall memory - """ - pass - - async def test_reflect(self) -> None: - """Test case for reflect - - Reflect and generate answer - """ - pass - - async def test_regenerate_entity_observations(self) -> None: - """Test case for regenerate_entity_observations - - Regenerate entity observations - """ - pass - - async def test_retain_memories(self) -> None: - """Test case for retain_memories - - Retain memories - """ - pass - - async def test_update_bank_disposition(self) -> None: - """Test case for update_bank_disposition - - Update memory bank disposition - """ - pass - - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_delete_response.py b/hindsight-clients/python/hindsight_client_api/test/test_delete_response.py deleted file mode 100644 index 55d5394e..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_delete_response.py +++ /dev/null @@ -1,52 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.delete_response import DeleteResponse - -class TestDeleteResponse(unittest.TestCase): - """DeleteResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> DeleteResponse: - """Test DeleteResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `DeleteResponse` - """ - model = DeleteResponse() - if include_optional: - return DeleteResponse( - success = True - ) - else: - return DeleteResponse( - success = True, - ) - """ - - def testDeleteResponse(self): - """Test DeleteResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_disposition_traits.py b/hindsight-clients/python/hindsight_client_api/test/test_disposition_traits.py deleted file mode 100644 index fbc7fd1b..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_disposition_traits.py +++ /dev/null @@ -1,56 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.disposition_traits import DispositionTraits - -class TestDispositionTraits(unittest.TestCase): - """DispositionTraits unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> DispositionTraits: - """Test DispositionTraits - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `DispositionTraits` - """ - model = DispositionTraits() - if include_optional: - return DispositionTraits( - skepticism = 1.0, - literalism = 1.0, - empathy = 1.0 - ) - else: - return DispositionTraits( - skepticism = 1.0, - literalism = 1.0, - empathy = 1.0, - ) - """ - - def testDispositionTraits(self): - """Test DispositionTraits""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_document_response.py b/hindsight-clients/python/hindsight_client_api/test/test_document_response.py deleted file mode 100644 index 5b62d1fb..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_document_response.py +++ /dev/null @@ -1,64 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.document_response import DocumentResponse - -class TestDocumentResponse(unittest.TestCase): - """DocumentResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> DocumentResponse: - """Test DocumentResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `DocumentResponse` - """ - model = DocumentResponse() - if include_optional: - return DocumentResponse( - id = '', - bank_id = '', - original_text = '', - content_hash = '', - created_at = '', - updated_at = '', - memory_unit_count = 56 - ) - else: - return DocumentResponse( - id = '', - bank_id = '', - original_text = '', - content_hash = '', - created_at = '', - updated_at = '', - memory_unit_count = 56, - ) - """ - - def testDocumentResponse(self): - """Test DocumentResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_entity_detail_response.py b/hindsight-clients/python/hindsight_client_api/test/test_entity_detail_response.py deleted file mode 100644 index 1cfec805..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_entity_detail_response.py +++ /dev/null @@ -1,71 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.entity_detail_response import EntityDetailResponse - -class TestEntityDetailResponse(unittest.TestCase): - """EntityDetailResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> EntityDetailResponse: - """Test EntityDetailResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `EntityDetailResponse` - """ - model = EntityDetailResponse() - if include_optional: - return EntityDetailResponse( - id = '', - canonical_name = '', - mention_count = 56, - first_seen = '', - last_seen = '', - metadata = { - 'key' : null - }, - observations = [ - hindsight_client_api.models.entity_observation_response.EntityObservationResponse( - text = '', - mentioned_at = '', ) - ] - ) - else: - return EntityDetailResponse( - id = '', - canonical_name = '', - mention_count = 56, - observations = [ - hindsight_client_api.models.entity_observation_response.EntityObservationResponse( - text = '', - mentioned_at = '', ) - ], - ) - """ - - def testEntityDetailResponse(self): - """Test EntityDetailResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_entity_include_options.py b/hindsight-clients/python/hindsight_client_api/test/test_entity_include_options.py deleted file mode 100644 index 37be2d49..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_entity_include_options.py +++ /dev/null @@ -1,51 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.entity_include_options import EntityIncludeOptions - -class TestEntityIncludeOptions(unittest.TestCase): - """EntityIncludeOptions unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> EntityIncludeOptions: - """Test EntityIncludeOptions - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `EntityIncludeOptions` - """ - model = EntityIncludeOptions() - if include_optional: - return EntityIncludeOptions( - max_tokens = 56 - ) - else: - return EntityIncludeOptions( - ) - """ - - def testEntityIncludeOptions(self): - """Test EntityIncludeOptions""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_entity_list_item.py b/hindsight-clients/python/hindsight_client_api/test/test_entity_list_item.py deleted file mode 100644 index 9bc91368..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_entity_list_item.py +++ /dev/null @@ -1,61 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.entity_list_item import EntityListItem - -class TestEntityListItem(unittest.TestCase): - """EntityListItem unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> EntityListItem: - """Test EntityListItem - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `EntityListItem` - """ - model = EntityListItem() - if include_optional: - return EntityListItem( - id = '', - canonical_name = '', - mention_count = 56, - first_seen = '', - last_seen = '', - metadata = { - 'key' : null - } - ) - else: - return EntityListItem( - id = '', - canonical_name = '', - mention_count = 56, - ) - """ - - def testEntityListItem(self): - """Test EntityListItem""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_entity_list_response.py b/hindsight-clients/python/hindsight_client_api/test/test_entity_list_response.py deleted file mode 100644 index fc876b5b..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_entity_list_response.py +++ /dev/null @@ -1,56 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.entity_list_response import EntityListResponse - -class TestEntityListResponse(unittest.TestCase): - """EntityListResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> EntityListResponse: - """Test EntityListResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `EntityListResponse` - """ - model = EntityListResponse() - if include_optional: - return EntityListResponse( - items = [ - {canonical_name=John, first_seen=2024-01-15T10:30:00Z, id=123e4567-e89b-12d3-a456-426614174000, last_seen=2024-02-01T14:00:00Z, mention_count=15} - ] - ) - else: - return EntityListResponse( - items = [ - {canonical_name=John, first_seen=2024-01-15T10:30:00Z, id=123e4567-e89b-12d3-a456-426614174000, last_seen=2024-02-01T14:00:00Z, mention_count=15} - ], - ) - """ - - def testEntityListResponse(self): - """Test EntityListResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_entity_observation_response.py b/hindsight-clients/python/hindsight_client_api/test/test_entity_observation_response.py deleted file mode 100644 index 31a6bda6..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_entity_observation_response.py +++ /dev/null @@ -1,53 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.entity_observation_response import EntityObservationResponse - -class TestEntityObservationResponse(unittest.TestCase): - """EntityObservationResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> EntityObservationResponse: - """Test EntityObservationResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `EntityObservationResponse` - """ - model = EntityObservationResponse() - if include_optional: - return EntityObservationResponse( - text = '', - mentioned_at = '' - ) - else: - return EntityObservationResponse( - text = '', - ) - """ - - def testEntityObservationResponse(self): - """Test EntityObservationResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_entity_state_response.py b/hindsight-clients/python/hindsight_client_api/test/test_entity_state_response.py deleted file mode 100644 index 9327a43e..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_entity_state_response.py +++ /dev/null @@ -1,64 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.entity_state_response import EntityStateResponse - -class TestEntityStateResponse(unittest.TestCase): - """EntityStateResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> EntityStateResponse: - """Test EntityStateResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `EntityStateResponse` - """ - model = EntityStateResponse() - if include_optional: - return EntityStateResponse( - entity_id = '', - canonical_name = '', - observations = [ - hindsight_client_api.models.entity_observation_response.EntityObservationResponse( - text = '', - mentioned_at = '', ) - ] - ) - else: - return EntityStateResponse( - entity_id = '', - canonical_name = '', - observations = [ - hindsight_client_api.models.entity_observation_response.EntityObservationResponse( - text = '', - mentioned_at = '', ) - ], - ) - """ - - def testEntityStateResponse(self): - """Test EntityStateResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_graph_data_response.py b/hindsight-clients/python/hindsight_client_api/test/test_graph_data_response.py deleted file mode 100644 index bbeeeb38..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_graph_data_response.py +++ /dev/null @@ -1,82 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.graph_data_response import GraphDataResponse - -class TestGraphDataResponse(unittest.TestCase): - """GraphDataResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> GraphDataResponse: - """Test GraphDataResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `GraphDataResponse` - """ - model = GraphDataResponse() - if include_optional: - return GraphDataResponse( - nodes = [ - { - 'key' : null - } - ], - edges = [ - { - 'key' : null - } - ], - table_rows = [ - { - 'key' : null - } - ], - total_units = 56 - ) - else: - return GraphDataResponse( - nodes = [ - { - 'key' : null - } - ], - edges = [ - { - 'key' : null - } - ], - table_rows = [ - { - 'key' : null - } - ], - total_units = 56, - ) - """ - - def testGraphDataResponse(self): - """Test GraphDataResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_http_validation_error.py b/hindsight-clients/python/hindsight_client_api/test/test_http_validation_error.py deleted file mode 100644 index ba84bc2d..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_http_validation_error.py +++ /dev/null @@ -1,58 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.http_validation_error import HTTPValidationError - -class TestHTTPValidationError(unittest.TestCase): - """HTTPValidationError unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> HTTPValidationError: - """Test HTTPValidationError - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `HTTPValidationError` - """ - model = HTTPValidationError() - if include_optional: - return HTTPValidationError( - detail = [ - hindsight_client_api.models.validation_error.ValidationError( - loc = [ - null - ], - msg = '', - type = '', ) - ] - ) - else: - return HTTPValidationError( - ) - """ - - def testHTTPValidationError(self): - """Test HTTPValidationError""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_include_options.py b/hindsight-clients/python/hindsight_client_api/test/test_include_options.py deleted file mode 100644 index 9ea4976b..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_include_options.py +++ /dev/null @@ -1,54 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.include_options import IncludeOptions - -class TestIncludeOptions(unittest.TestCase): - """IncludeOptions unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> IncludeOptions: - """Test IncludeOptions - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `IncludeOptions` - """ - model = IncludeOptions() - if include_optional: - return IncludeOptions( - entities = hindsight_client_api.models.entity_include_options.EntityIncludeOptions( - max_tokens = 56, ), - chunks = hindsight_client_api.models.chunk_include_options.ChunkIncludeOptions( - max_tokens = 56, ) - ) - else: - return IncludeOptions( - ) - """ - - def testIncludeOptions(self): - """Test IncludeOptions""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_list_documents_response.py b/hindsight-clients/python/hindsight_client_api/test/test_list_documents_response.py deleted file mode 100644 index 89981838..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_list_documents_response.py +++ /dev/null @@ -1,66 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.list_documents_response import ListDocumentsResponse - -class TestListDocumentsResponse(unittest.TestCase): - """ListDocumentsResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> ListDocumentsResponse: - """Test ListDocumentsResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `ListDocumentsResponse` - """ - model = ListDocumentsResponse() - if include_optional: - return ListDocumentsResponse( - items = [ - { - 'key' : null - } - ], - total = 56, - limit = 56, - offset = 56 - ) - else: - return ListDocumentsResponse( - items = [ - { - 'key' : null - } - ], - total = 56, - limit = 56, - offset = 56, - ) - """ - - def testListDocumentsResponse(self): - """Test ListDocumentsResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_list_memory_units_response.py b/hindsight-clients/python/hindsight_client_api/test/test_list_memory_units_response.py deleted file mode 100644 index e3e9fcef..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_list_memory_units_response.py +++ /dev/null @@ -1,66 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse - -class TestListMemoryUnitsResponse(unittest.TestCase): - """ListMemoryUnitsResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> ListMemoryUnitsResponse: - """Test ListMemoryUnitsResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `ListMemoryUnitsResponse` - """ - model = ListMemoryUnitsResponse() - if include_optional: - return ListMemoryUnitsResponse( - items = [ - { - 'key' : null - } - ], - total = 56, - limit = 56, - offset = 56 - ) - else: - return ListMemoryUnitsResponse( - items = [ - { - 'key' : null - } - ], - total = 56, - limit = 56, - offset = 56, - ) - """ - - def testListMemoryUnitsResponse(self): - """Test ListMemoryUnitsResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_memory_item.py b/hindsight-clients/python/hindsight_client_api/test/test_memory_item.py deleted file mode 100644 index d15b01d0..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_memory_item.py +++ /dev/null @@ -1,58 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.memory_item import MemoryItem - -class TestMemoryItem(unittest.TestCase): - """MemoryItem unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> MemoryItem: - """Test MemoryItem - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `MemoryItem` - """ - model = MemoryItem() - if include_optional: - return MemoryItem( - content = '', - timestamp = datetime.datetime.strptime('2013-10-20 19:20:30.00', '%Y-%m-%d %H:%M:%S.%f'), - context = '', - metadata = { - 'key' : '' - }, - document_id = '' - ) - else: - return MemoryItem( - content = '', - ) - """ - - def testMemoryItem(self): - """Test MemoryItem""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_monitoring_api.py b/hindsight-clients/python/hindsight_client_api/test/test_monitoring_api.py deleted file mode 100644 index 4985c298..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_monitoring_api.py +++ /dev/null @@ -1,45 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.api.monitoring_api import MonitoringApi - - -class TestMonitoringApi(unittest.IsolatedAsyncioTestCase): - """MonitoringApi unit test stubs""" - - async def asyncSetUp(self) -> None: - self.api = MonitoringApi() - - async def asyncTearDown(self) -> None: - await self.api.api_client.close() - - async def test_health_endpoint_health_get(self) -> None: - """Test case for health_endpoint_health_get - - Health check endpoint - """ - pass - - async def test_metrics_endpoint_metrics_get(self) -> None: - """Test case for metrics_endpoint_metrics_get - - Prometheus metrics endpoint - """ - pass - - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_recall_request.py b/hindsight-clients/python/hindsight_client_api/test/test_recall_request.py deleted file mode 100644 index 8a6c7836..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_recall_request.py +++ /dev/null @@ -1,64 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.recall_request import RecallRequest - -class TestRecallRequest(unittest.TestCase): - """RecallRequest unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> RecallRequest: - """Test RecallRequest - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `RecallRequest` - """ - model = RecallRequest() - if include_optional: - return RecallRequest( - query = '', - types = [ - '' - ], - budget = 'low', - max_tokens = 56, - trace = True, - query_timestamp = '', - include = hindsight_client_api.models.include_options.IncludeOptions( - entities = hindsight_client_api.models.entity_include_options.EntityIncludeOptions( - max_tokens = 56, ), - chunks = hindsight_client_api.models.chunk_include_options.ChunkIncludeOptions( - max_tokens = 56, ), ) - ) - else: - return RecallRequest( - query = '', - ) - """ - - def testRecallRequest(self): - """Test RecallRequest""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_recall_response.py b/hindsight-clients/python/hindsight_client_api/test/test_recall_response.py deleted file mode 100644 index 73ee05da..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_recall_response.py +++ /dev/null @@ -1,76 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.recall_response import RecallResponse - -class TestRecallResponse(unittest.TestCase): - """RecallResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> RecallResponse: - """Test RecallResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `RecallResponse` - """ - model = RecallResponse() - if include_optional: - return RecallResponse( - results = [ - {chunk_id=456e7890-e12b-34d5-a678-901234567890, context=work info, document_id=session_abc123, entities=[Alice, Google], id=123e4567-e89b-12d3-a456-426614174000, mentioned_at=2024-01-15T10:30:00Z, metadata={source=slack}, occurred_end=2024-01-15T10:30:00Z, occurred_start=2024-01-15T10:30:00Z, text=Alice works at Google on the AI team, type=world} - ], - trace = { - 'key' : null - }, - entities = { - 'key' : hindsight_client_api.models.entity_state_response.EntityStateResponse( - entity_id = '', - canonical_name = '', - observations = [ - hindsight_client_api.models.entity_observation_response.EntityObservationResponse( - text = '', - mentioned_at = '', ) - ], ) - }, - chunks = { - 'key' : hindsight_client_api.models.chunk_data.ChunkData( - id = '', - text = '', - chunk_index = 56, - truncated = True, ) - } - ) - else: - return RecallResponse( - results = [ - {chunk_id=456e7890-e12b-34d5-a678-901234567890, context=work info, document_id=session_abc123, entities=[Alice, Google], id=123e4567-e89b-12d3-a456-426614174000, mentioned_at=2024-01-15T10:30:00Z, metadata={source=slack}, occurred_end=2024-01-15T10:30:00Z, occurred_start=2024-01-15T10:30:00Z, text=Alice works at Google on the AI team, type=world} - ], - ) - """ - - def testRecallResponse(self): - """Test RecallResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_recall_result.py b/hindsight-clients/python/hindsight_client_api/test/test_recall_result.py deleted file mode 100644 index 1928b2e3..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_recall_result.py +++ /dev/null @@ -1,67 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.recall_result import RecallResult - -class TestRecallResult(unittest.TestCase): - """RecallResult unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> RecallResult: - """Test RecallResult - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `RecallResult` - """ - model = RecallResult() - if include_optional: - return RecallResult( - id = '', - text = '', - type = '', - entities = [ - '' - ], - context = '', - occurred_start = '', - occurred_end = '', - mentioned_at = '', - document_id = '', - metadata = { - 'key' : '' - }, - chunk_id = '' - ) - else: - return RecallResult( - id = '', - text = '', - ) - """ - - def testRecallResult(self): - """Test RecallResult""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_reflect_fact.py b/hindsight-clients/python/hindsight_client_api/test/test_reflect_fact.py deleted file mode 100644 index 51be3578..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_reflect_fact.py +++ /dev/null @@ -1,57 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.reflect_fact import ReflectFact - -class TestReflectFact(unittest.TestCase): - """ReflectFact unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> ReflectFact: - """Test ReflectFact - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `ReflectFact` - """ - model = ReflectFact() - if include_optional: - return ReflectFact( - id = '', - text = '', - type = '', - context = '', - occurred_start = '', - occurred_end = '' - ) - else: - return ReflectFact( - text = '', - ) - """ - - def testReflectFact(self): - """Test ReflectFact""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_reflect_include_options.py b/hindsight-clients/python/hindsight_client_api/test/test_reflect_include_options.py deleted file mode 100644 index 0cb68878..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_reflect_include_options.py +++ /dev/null @@ -1,51 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.reflect_include_options import ReflectIncludeOptions - -class TestReflectIncludeOptions(unittest.TestCase): - """ReflectIncludeOptions unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> ReflectIncludeOptions: - """Test ReflectIncludeOptions - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `ReflectIncludeOptions` - """ - model = ReflectIncludeOptions() - if include_optional: - return ReflectIncludeOptions( - facts = hindsight_client_api.models.facts_include_options.FactsIncludeOptions() - ) - else: - return ReflectIncludeOptions( - ) - """ - - def testReflectIncludeOptions(self): - """Test ReflectIncludeOptions""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_reflect_request.py b/hindsight-clients/python/hindsight_client_api/test/test_reflect_request.py deleted file mode 100644 index 1c5e58a8..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_reflect_request.py +++ /dev/null @@ -1,56 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.reflect_request import ReflectRequest - -class TestReflectRequest(unittest.TestCase): - """ReflectRequest unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> ReflectRequest: - """Test ReflectRequest - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `ReflectRequest` - """ - model = ReflectRequest() - if include_optional: - return ReflectRequest( - query = '', - budget = 'low', - context = '', - include = hindsight_client_api.models.reflect_include_options.ReflectIncludeOptions( - facts = hindsight_client_api.models.facts_include_options.FactsIncludeOptions(), ) - ) - else: - return ReflectRequest( - query = '', - ) - """ - - def testReflectRequest(self): - """Test ReflectRequest""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_reflect_response.py b/hindsight-clients/python/hindsight_client_api/test/test_reflect_response.py deleted file mode 100644 index 1fadf037..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_reflect_response.py +++ /dev/null @@ -1,55 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.reflect_response import ReflectResponse - -class TestReflectResponse(unittest.TestCase): - """ReflectResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> ReflectResponse: - """Test ReflectResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `ReflectResponse` - """ - model = ReflectResponse() - if include_optional: - return ReflectResponse( - text = '', - based_on = [ - {context=healthcare discussion, id=123e4567-e89b-12d3-a456-426614174000, occurred_end=2024-01-15T10:30:00Z, occurred_start=2024-01-15T10:30:00Z, text=AI is used in healthcare, type=world} - ] - ) - else: - return ReflectResponse( - text = '', - ) - """ - - def testReflectResponse(self): - """Test ReflectResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_retain_request.py b/hindsight-clients/python/hindsight_client_api/test/test_retain_request.py deleted file mode 100644 index be36ece7..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_retain_request.py +++ /dev/null @@ -1,57 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.retain_request import RetainRequest - -class TestRetainRequest(unittest.TestCase): - """RetainRequest unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> RetainRequest: - """Test RetainRequest - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `RetainRequest` - """ - model = RetainRequest() - if include_optional: - return RetainRequest( - items = [ - {content=Alice mentioned she's working on a new ML model, context=team meeting, document_id=meeting_notes_2024_01_15, metadata={channel=engineering, source=slack}, timestamp=2024-01-15T10:30:00Z} - ], - var_async = True - ) - else: - return RetainRequest( - items = [ - {content=Alice mentioned she's working on a new ML model, context=team meeting, document_id=meeting_notes_2024_01_15, metadata={channel=engineering, source=slack}, timestamp=2024-01-15T10:30:00Z} - ], - ) - """ - - def testRetainRequest(self): - """Test RetainRequest""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_retain_response.py b/hindsight-clients/python/hindsight_client_api/test/test_retain_response.py deleted file mode 100644 index fd6a9599..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_retain_response.py +++ /dev/null @@ -1,58 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.retain_response import RetainResponse - -class TestRetainResponse(unittest.TestCase): - """RetainResponse unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> RetainResponse: - """Test RetainResponse - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `RetainResponse` - """ - model = RetainResponse() - if include_optional: - return RetainResponse( - success = True, - bank_id = '', - items_count = 56, - var_async = True - ) - else: - return RetainResponse( - success = True, - bank_id = '', - items_count = 56, - var_async = True, - ) - """ - - def testRetainResponse(self): - """Test RetainResponse""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_update_disposition_request.py b/hindsight-clients/python/hindsight_client_api/test/test_update_disposition_request.py deleted file mode 100644 index 228f019d..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_update_disposition_request.py +++ /dev/null @@ -1,52 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest - -class TestUpdateDispositionRequest(unittest.TestCase): - """UpdateDispositionRequest unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> UpdateDispositionRequest: - """Test UpdateDispositionRequest - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `UpdateDispositionRequest` - """ - model = UpdateDispositionRequest() - if include_optional: - return UpdateDispositionRequest( - disposition = {empathy=3, literalism=3, skepticism=3} - ) - else: - return UpdateDispositionRequest( - disposition = {empathy=3, literalism=3, skepticism=3}, - ) - """ - - def testUpdateDispositionRequest(self): - """Test UpdateDispositionRequest""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_validation_error.py b/hindsight-clients/python/hindsight_client_api/test/test_validation_error.py deleted file mode 100644 index 282d9525..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_validation_error.py +++ /dev/null @@ -1,60 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.validation_error import ValidationError - -class TestValidationError(unittest.TestCase): - """ValidationError unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> ValidationError: - """Test ValidationError - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `ValidationError` - """ - model = ValidationError() - if include_optional: - return ValidationError( - loc = [ - null - ], - msg = '', - type = '' - ) - else: - return ValidationError( - loc = [ - null - ], - msg = '', - type = '', - ) - """ - - def testValidationError(self): - """Test ValidationError""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/hindsight_client_api/test/test_validation_error_loc_inner.py b/hindsight-clients/python/hindsight_client_api/test/test_validation_error_loc_inner.py deleted file mode 100644 index 78863c80..00000000 --- a/hindsight-clients/python/hindsight_client_api/test/test_validation_error_loc_inner.py +++ /dev/null @@ -1,50 +0,0 @@ -# coding: utf-8 - -""" - Hindsight HTTP API - - HTTP API for Hindsight - - The version of the OpenAPI document: 1.0.0 - Generated by OpenAPI Generator (https://openapi-generator.tech) - - Do not edit the class manually. -""" # noqa: E501 - - -import unittest - -from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner - -class TestValidationErrorLocInner(unittest.TestCase): - """ValidationErrorLocInner unit test stubs""" - - def setUp(self): - pass - - def tearDown(self): - pass - - def make_instance(self, include_optional) -> ValidationErrorLocInner: - """Test ValidationErrorLocInner - include_optional is a boolean, when False only required - params are included, when True both required and - optional params are included """ - # uncomment below to create an instance of `ValidationErrorLocInner` - """ - model = ValidationErrorLocInner() - if include_optional: - return ValidationErrorLocInner( - ) - else: - return ValidationErrorLocInner( - ) - """ - - def testValidationErrorLocInner(self): - """Test ValidationErrorLocInner""" - # inst_req_only = self.make_instance(include_optional=False) - # inst_req_and_optional = self.make_instance(include_optional=True) - -if __name__ == '__main__': - unittest.main() diff --git a/hindsight-clients/python/openapi-generator-config.yaml b/hindsight-clients/python/openapi-generator-config.yaml deleted file mode 100644 index 33e378e7..00000000 --- a/hindsight-clients/python/openapi-generator-config.yaml +++ /dev/null @@ -1,5 +0,0 @@ -packageName: hindsight_client_api -projectName: hindsight-client -packageVersion: 0.0.7 -library: asyncio -generateSourceCodeOnly: true diff --git a/hindsight-clients/python/pyproject.toml b/hindsight-clients/python/pyproject.toml deleted file mode 100644 index 95663365..00000000 --- a/hindsight-clients/python/pyproject.toml +++ /dev/null @@ -1,48 +0,0 @@ -[project] -name = "hindsight-client" -version = "0.1.13" -description = "Python client for Hindsight - Semantic memory system with personality-driven thinking" -authors = [ - {name = "Hindsight Team"} -] -requires-python = ">=3.10" -readme = "README.md" -dependencies = [ - "urllib3 (>=2.1.0,<3.0.0)", - "python-dateutil (>=2.8.2)", - "aiohttp (>=3.8.4)", - "aiohttp-retry (>=2.8.3)", - "pydantic (>=2)", - "typing-extensions (>=4.7.1)", -] - -[project.optional-dependencies] -test = [ - "pytest>=7.0.0", - "pytest-asyncio>=0.21.0", - "requests>=2.28.0", -] - -[build-system] -requires = ["hatchling"] -build-backend = "hatchling.build" - -[tool.hatch.build.targets.wheel] -packages = ["hindsight_client", "hindsight_client_api"] - -[tool.ruff] -line-length = 120 - -[tool.ruff.lint] -select = ["F", "I", "UP"] - - -[tool.pytest.ini_options] -log_cli = true -log_cli_level = "INFO" -log_cli_format = "%(asctime)s %(levelname)s %(message)s" -log_cli_date_format = "%Y-%m-%d %H:%M:%S" -addopts = "--timeout 60 -n auto --durations=10 -v" -asyncio_mode = "auto" -asyncio_default_fixture_loop_scope = "function" -log_auto_indent = true \ No newline at end of file diff --git a/hindsight-clients/python/tests/__init__.py b/hindsight-clients/python/tests/__init__.py deleted file mode 100644 index a6bce47a..00000000 --- a/hindsight-clients/python/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for Memora client.""" diff --git a/hindsight-clients/python/tests/test_main_operations.py b/hindsight-clients/python/tests/test_main_operations.py deleted file mode 100644 index fd7f42b5..00000000 --- a/hindsight-clients/python/tests/test_main_operations.py +++ /dev/null @@ -1,250 +0,0 @@ -""" -Tests for Hindsight Python client. - -These tests require a running Hindsight API server. -""" - -import os -import uuid -import pytest -from datetime import datetime -from hindsight_client import Hindsight - - -# Test configuration -HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888") - - -@pytest.fixture -def client(): - """Create a Hindsight client for testing.""" - with Hindsight(base_url=HINDSIGHT_API_URL) as client: - yield client - - -@pytest.fixture -def bank_id(): - """Provide a unique test bank ID for each test.""" - return f"test_bank_{uuid.uuid4().hex[:12]}" - - -class TestRetain: - """Tests for storing memories.""" - - def test_retain_single_memory(self, client, bank_id): - """Test storing a single memory.""" - response = client.retain( - bank_id=bank_id, - content="Alice loves artificial intelligence and machine learning", - ) - - assert response is not None - assert response.success is True - - def test_retain_memory_with_context(self, client, bank_id): - """Test storing a memory with context and timestamp.""" - response = client.retain( - bank_id=bank_id, - content="Bob went hiking in the mountains", - timestamp=datetime(2024, 1, 15, 10, 30), - context="outdoor activities", - ) - - assert response is not None - assert response.success is True - - def test_retain_batch_memories(self, client, bank_id): - """Test storing multiple memories in batch.""" - items = [ - {"content": "Charlie enjoys reading science fiction books"}, - {"content": "Diana is learning to play the guitar", "context": "hobbies"}, - { - "content": "Eve completed a marathon last month", - "event_date": datetime(2024, 10, 15), - }, - ] - - response = client.retain_batch( - bank_id=bank_id, - items=items, - ) - - assert response is not None - assert response.success is True - assert response.items_count == 3 - - -class TestRecall: - """Tests for searching memories.""" - - @pytest.fixture(autouse=True) - def setup_memories(self, client, bank_id): - """Setup: Store some test memories before search tests.""" - client.retain_batch( - bank_id=bank_id, - items=[ - {"content": "Alice loves programming in Python"}, - {"content": "Bob enjoys hiking and outdoor adventures"}, - {"content": "Charlie is interested in quantum physics"}, - {"content": "Diana plays the violin beautifully"}, - ], - ) - - def test_recall_basic(self, client, bank_id): - """Test basic memory search.""" - response = client.recall( - bank_id=bank_id, - query="What does Alice like?", - ) - - assert response is not None - assert response.results is not None - assert len(response.results) > 0 - - # Check that at least one result contains relevant information - result_texts = [r.text for r in response.results] - assert any("Alice" in text or "Python" in text or "programming" in text for text in result_texts) - - def test_recall_with_max_tokens(self, client, bank_id): - """Test search with token limit.""" - response = client.recall( - bank_id=bank_id, - query="outdoor activities", - max_tokens=1024, - ) - - assert response is not None - assert response.results is not None - - def test_recall_full_featured(self, client, bank_id): - """Test recall with all features.""" - response = client.recall( - bank_id=bank_id, - query="What are people's hobbies?", - types=["world"], - max_tokens=2048, - trace=True, - ) - - assert response is not None - assert response.results is not None - - -class TestReflect: - """Tests for thinking/reasoning operations.""" - - @pytest.fixture(autouse=True) - def setup_memories(self, client, bank_id): - """Setup: Store some test memories and bank background.""" - client.create_bank( - bank_id=bank_id, - background="I am a helpful AI assistant interested in technology and science.", - ) - - client.retain_batch( - bank_id=bank_id, - items=[ - {"content": "The Python programming language is great for data science"}, - {"content": "Machine learning models can recognize patterns in data"}, - {"content": "Neural networks are inspired by biological neurons"}, - ], - ) - - def test_reflect_basic(self, client, bank_id): - """Test basic reflect operation.""" - response = client.reflect( - bank_id=bank_id, - query="What do you think about artificial intelligence?", - ) - - assert response is not None - assert response.text is not None - assert len(response.text) > 0 - - def test_reflect_with_context(self, client, bank_id): - """Test reflect with additional context.""" - response = client.reflect( - bank_id=bank_id, - query="Should I learn Python?", - context="I'm interested in starting a career in data science", - ) - - assert response is not None - assert response.text is not None - assert len(response.text) > 0 - - -class TestListMemories: - """Tests for listing memories.""" - - @pytest.fixture(autouse=True) - def setup_memories(self, client, bank_id): - """Setup: Store some test memories synchronously.""" - client.retain_batch( - bank_id=bank_id, - items=[ - {"content": f"Alice likes topic number {i}"} for i in range(5) - ], - retain_async=False, # Wait for fact extraction to complete - ) - - def test_list_all_memories(self, client, bank_id): - """Test listing all memories.""" - response = client.list_memories(bank_id=bank_id) - - assert response is not None - assert response.items is not None - assert response.total is not None - assert len(response.items) > 0 - - def test_list_with_pagination(self, client, bank_id): - """Test listing with pagination.""" - response = client.list_memories( - bank_id=bank_id, - limit=2, - offset=0, - ) - - assert response is not None - assert response.items is not None - assert len(response.items) <= 2 - - -class TestEndToEndWorkflow: - """End-to-end workflow tests.""" - - def test_complete_workflow(self, client): - """Test a complete workflow: create bank, store, search, reflect.""" - workflow_bank_id = "workflow_test_" + datetime.now().strftime("%Y%m%d_%H%M%S") - - # 1. Create bank - client.create_bank( - bank_id=workflow_bank_id, - background="I am a software engineer who loves Python programming.", - ) - - # 2. Store memories - store_response = client.retain_batch( - bank_id=workflow_bank_id, - items=[ - {"content": "I completed a project using FastAPI"}, - {"content": "I learned about async programming in Python"}, - {"content": "I enjoy working on open source projects"}, - ], - ) - assert store_response.success is True - - # 3. Search for relevant memories - search_results = client.recall( - bank_id=workflow_bank_id, - query="What programming technologies do I use?", - ) - assert len(search_results.results) > 0 - - # 4. Generate contextual answer - reflect_response = client.reflect( - bank_id=workflow_bank_id, - query="What are my professional interests?", - ) - assert reflect_response.text is not None - assert len(reflect_response.text) > 0 diff --git a/hindsight-clients/rust/Cargo.lock b/hindsight-clients/rust/Cargo.lock deleted file mode 100644 index d3056117..00000000 --- a/hindsight-clients/rust/Cargo.lock +++ /dev/null @@ -1,2123 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "allocator-api2" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" - -[[package]] -name = "android_system_properties" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" -dependencies = [ - "libc", -] - -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bumpalo" -version = "3.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" - -[[package]] -name = "bytes" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" - -[[package]] -name = "cc" -version = "1.2.49" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90583009037521a116abf44494efecd645ba48b6622457080f080b85544e2215" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "chrono" -version = "0.4.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "displaydoc" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "dyn-clone" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fastrand" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "find-msvc-tools" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" - -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" - -[[package]] -name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-macro" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "futures-sink" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" - -[[package]] -name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-util" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" -dependencies = [ - "futures-core", - "futures-io", - "futures-macro", - "futures-sink", - "futures-task", - "memchr", - "pin-project-lite", - "pin-utils", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "h2" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash", -] - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hindsight-client" -version = "0.1.0" -dependencies = [ - "chrono", - "http", - "openapiv3", - "prettyplease", - "progenitor", - "progenitor-client", - "reqwest", - "serde", - "serde_json", - "syn", - "thiserror 1.0.69", - "tokio", - "tokio-test", - "url", - "uuid", -] - -[[package]] -name = "http" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "hyper" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "itoa", - "pin-project-lite", - "pin-utils", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-rustls" -version = "0.27.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" -dependencies = [ - "http", - "hyper", - "hyper-util", - "rustls", - "rustls-pki-types", - "tokio", - "tokio-rustls", - "tower-service", -] - -[[package]] -name = "hyper-tls" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" -dependencies = [ - "bytes", - "http-body-util", - "hyper", - "hyper-util", - "native-tls", - "tokio", - "tokio-native-tls", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" -dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "http", - "http-body", - "hyper", - "ipnet", - "libc", - "percent-encoding", - "pin-project-lite", - "socket2", - "system-configuration", - "tokio", - "tower-service", - "tracing", - "windows-registry", -] - -[[package]] -name = "iana-time-zone" -version = "0.1.64" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "icu_collections" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" -dependencies = [ - "displaydoc", - "potential_utf", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" - -[[package]] -name = "icu_properties" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" - -[[package]] -name = "icu_provider" -version = "2.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" -dependencies = [ - "equivalent", - "hashbrown", - "serde", - "serde_core", -] - -[[package]] -name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" -dependencies = [ - "memchr", - "serde", -] - -[[package]] -name = "itoa" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" - -[[package]] -name = "js-sys" -version = "0.3.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" -dependencies = [ - "once_cell", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.178" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" - -[[package]] -name = "linux-raw-sys" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" - -[[package]] -name = "memchr" -version = "2.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mio" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "native-tls" -version = "0.2.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "openapiv3" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8d427828b22ae1fff2833a03d8486c2c881367f1c336349f307f321e7f4d05" -dependencies = [ - "indexmap", - "serde", - "serde_json", -] - -[[package]] -name = "openssl" -version = "0.10.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-probe" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" - -[[package]] -name = "openssl-sys" -version = "0.9.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "pin-project-lite" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" - -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - -[[package]] -name = "pkg-config" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" - -[[package]] -name = "potential_utf" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" -dependencies = [ - "zerovec", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "progenitor" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2326f73d5326257514712436680ef8da4543ee47c0e9e0d501545c8909ee12e4" -dependencies = [ - "progenitor-client", - "progenitor-impl", - "progenitor-macro", -] - -[[package]] -name = "progenitor-client" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71a0beb939758f229cbae70a4889c7c76a4ac0e90f0b1e7ae9b4636a927d1018" -dependencies = [ - "bytes", - "futures-core", - "percent-encoding", - "reqwest", - "serde", - "serde_json", - "serde_urlencoded", -] - -[[package]] -name = "progenitor-impl" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f6d9109b04e005bbdec84cacec7e81cc15533f2b5dc505f0defc212d270c15" -dependencies = [ - "heck", - "http", - "indexmap", - "openapiv3", - "proc-macro2", - "quote", - "regex", - "schemars", - "serde", - "serde_json", - "syn", - "thiserror 2.0.17", - "typify", - "unicode-ident", -] - -[[package]] -name = "progenitor-macro" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46596c574831739c661f22923fe587399c61f5e3e79b73cc9a93644c72248d84" -dependencies = [ - "openapiv3", - "proc-macro2", - "progenitor-impl", - "quote", - "schemars", - "serde", - "serde_json", - "serde_tokenstream", - "serde_yaml", - "syn", -] - -[[package]] -name = "quote" -version = "1.0.42" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex" -version = "1.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" - -[[package]] -name = "regress" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2057b2325e68a893284d1538021ab90279adac1139957ca2a74426c6f118fb48" -dependencies = [ - "hashbrown", - "memchr", -] - -[[package]] -name = "reqwest" -version = "0.12.25" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6eff9328d40131d43bd911d42d79eb6a47312002a4daefc9e37f17e74a7701a" -dependencies = [ - "base64", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-rustls", - "hyper-tls", - "hyper-util", - "js-sys", - "log", - "mime", - "native-tls", - "percent-encoding", - "pin-project-lite", - "rustls-pki-types", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tokio-native-tls", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "wasm-streams", - "web-sys", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.16", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustix" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" -dependencies = [ - "once_cell", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "rustversion" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" - -[[package]] -name = "ryu" -version = "1.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" - -[[package]] -name = "schannel" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "schemars" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" -dependencies = [ - "chrono", - "dyn-clone", - "schemars_derive", - "serde", - "serde_json", - "uuid", -] - -[[package]] -name = "schemars_derive" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "security-framework" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" -dependencies = [ - "serde", - "serde_core", -] - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_derive_internals" -version = "0.29.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.145" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" -dependencies = [ - "itoa", - "memchr", - "ryu", - "serde", - "serde_core", -] - -[[package]] -name = "serde_tokenstream" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "syn", -] - -[[package]] -name = "serde_urlencoded" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" -dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" -dependencies = [ - "indexmap", - "itoa", - "ryu", - "serde", - "unsafe-libyaml", -] - -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "signal-hook-registry" -version = "1.4.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" -dependencies = [ - "libc", -] - -[[package]] -name = "slab" -version = "0.4.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" - -[[package]] -name = "smallvec" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" - -[[package]] -name = "socket2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" -dependencies = [ - "libc", - "windows-sys 0.60.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "syn" -version = "2.0.111" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" -dependencies = [ - "futures-core", -] - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "system-configuration" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" -dependencies = [ - "bitflags", - "core-foundation", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" -dependencies = [ - "fastrand", - "getrandom 0.3.4", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - -[[package]] -name = "thiserror" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" -dependencies = [ - "thiserror-impl 2.0.17", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tinystr" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-test" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" -dependencies = [ - "async-stream", - "bytes", - "futures-core", - "tokio", - "tokio-stream", -] - -[[package]] -name = "tokio-util" -version = "0.7.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-http" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" -dependencies = [ - "bitflags", - "bytes", - "futures-util", - "http", - "http-body", - "iri-string", - "pin-project-lite", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" -dependencies = [ - "pin-project-lite", - "tracing-core", -] - -[[package]] -name = "tracing-core" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typify" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7144144e97e987c94758a3017c920a027feac0799df325d6df4fc8f08d02068e" -dependencies = [ - "typify-impl", - "typify-macro", -] - -[[package]] -name = "typify-impl" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "062879d46aa4c9dfe0d33b035bbaf512da192131645d05deacb7033ec8581a09" -dependencies = [ - "heck", - "log", - "proc-macro2", - "quote", - "regress", - "schemars", - "semver", - "serde", - "serde_json", - "syn", - "thiserror 2.0.17", - "unicode-ident", -] - -[[package]] -name = "typify-macro" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9708a3ceb6660ba3f8d2b8f0567e7d4b8b198e2b94d093b8a6077a751425de9e" -dependencies = [ - "proc-macro2", - "quote", - "schemars", - "semver", - "serde", - "serde_json", - "serde_tokenstream", - "syn", - "typify-impl", -] - -[[package]] -name = "unicode-ident" -version = "1.0.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "uuid" -version = "1.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" -dependencies = [ - "getrandom 0.3.4", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.1+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-futures" -version = "0.4.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" -dependencies = [ - "cfg-if", - "js-sys", - "once_cell", - "wasm-bindgen", - "web-sys", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "wasm-streams" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" -dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - -[[package]] -name = "web-sys" -version = "0.3.83" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-registry" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" -dependencies = [ - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.46.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" - -[[package]] -name = "writeable" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" - -[[package]] -name = "yoke" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" - -[[package]] -name = "zerotrie" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] diff --git a/hindsight-clients/rust/Cargo.toml b/hindsight-clients/rust/Cargo.toml deleted file mode 100644 index c35eff1b..00000000 --- a/hindsight-clients/rust/Cargo.toml +++ /dev/null @@ -1,40 +0,0 @@ -[package] -name = "hindsight-client" -version = "0.1.0" -edition = "2021" -authors = ["Hindsight Team"] -description = "Rust client library for Hindsight API - semantic memory system" -license = "MIT" -repository = "https://github.com/yourusername/hindsight" -keywords = ["api", "client", "memory", "ai"] -categories = ["api-bindings"] - -[dependencies] -# HTTP client -reqwest = { version = "0.12", features = ["json"] } -# Async runtime -tokio = { version = "1", features = ["full"] } -# Serialization -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -# Error handling -thiserror = "1.0" -# Progenitor client support -progenitor-client = "0.11" -# Additional types -chrono = { version = "0.4", features = ["serde"] } -# HTTP types -http = "1.0" -# URL handling -url = "2.5" - -[dev-dependencies] -tokio-test = "0.4" -uuid = { version = "1.0", features = ["v4"] } - -[build-dependencies] -progenitor = "0.11" -serde_json = "1.0" -syn = "2.0" -prettyplease = "0.2" -openapiv3 = "2.2" diff --git a/hindsight-clients/rust/INTEGRATION.md b/hindsight-clients/rust/INTEGRATION.md deleted file mode 100644 index 3d830e6c..00000000 --- a/hindsight-clients/rust/INTEGRATION.md +++ /dev/null @@ -1,124 +0,0 @@ -# CLI Integration Guide - -This guide shows how to integrate the auto-generated Rust client into the CLI. - -## Approach - -The generated client is **async** (uses tokio), while the CLI currently uses **blocking** code. There are two integration strategies: - -### Option 1: Minimal Wrapper (Current Approach) - -Create a thin `api.rs` wrapper that uses `tokio::runtime::Runtime` to bridge sync→async: - -```rust -pub struct ApiClient { - client: hindsight_client::Client, - runtime: tokio::runtime::Runtime, -} - -impl ApiClient { - pub fn new(base_url: String) -> Result { - let runtime = tokio::runtime::Runtime::new()?; - let client = hindsight_client::Client::new(&base_url); - Ok(ApiClient { client, runtime }) - } - - pub fn list_agents(&self) -> Result> { - self.runtime.block_on(async { - self.client.list_agents().await - }) - } - // ... other methods -} -``` - -### Option 2: Full Async (Recommended for New CLI) - -Make the CLI fully async using `#[tokio::main]`: - -```rust -#[tokio::main] -async fn main() -> Result<()> { - let cli = Cli::parse(); - let client = hindsight_client::Client::new(&config.api_url); - - match cli.command { - Commands::Agent(AgentCommands::List) => { - let agents = client.list_agents().await?; - for agent in agents { - println!(" - {}", agent.agent_id); - } - } - // ... other commands - } - - Ok(()) -} -``` - -## Type Mapping - -The generated types are in `hindsight_client::types::*`. Here's the mapping: - -| CLI Expected | Generated Type | -|-------------|----------------| -| `Agent` | `AgentListItem` | -| `AgentProfile` | `AgentProfileResponse` | -| `AgentStats` | From `/stats` endpoint | -| `BatchMemoryRequest` | `BatchPutRequest` | -| `BatchMemoryResponse` | `BatchPutResponse` | -| `DocumentsResponse` | `ListDocumentsResponse` | -| `Document` | Item in `ListDocumentsResponse` | -| `DocumentDetails` | `DocumentResponse` | -| `OperationsResponse` | From `/operations` endpoint | - -## Example: List Agents Command - -### Before (Manual API Code): -```rust -pub fn list_agents(&self, verbose: bool) -> Result> { - let url = format!("{}/api/v1/agents", self.base_url); - let response = self.client.get(&url).send()?; - // ... manual parsing -} -``` - -### After (Generated Client): -```rust -pub fn list_agents(&self) -> Result> { - self.runtime.block_on(async { - self.client.list_agents().await - }) -} -``` - -## Benefits - -✅ **No manual maintenance** - API client updates automatically with OpenAPI spec -✅ **Type safe** - Compiler catches API changes -✅ **Full coverage** - All endpoints generated -✅ **Better errors** - Typed error responses -✅ **Async ready** - Built for modern Rust - -## Next Steps for Full Integration - -1. **Update Type Imports** - Fix type names in `main.rs` to use generated types -2. **Test Each Command** - Verify each CLI command works with new client -3. **Remove Old Code** - Delete `api.rs.old` once migration complete -4. **Optional**: Make CLI fully async for better UX with long operations - -## Quick Test - -Test the client library directly: - -```bash -cd hindsight-clients/rust -cargo test -``` - -Build CLI with new client: - -```bash -cd ../../hindsight-cli -cargo build -``` diff --git a/hindsight-clients/rust/README.md b/hindsight-clients/rust/README.md deleted file mode 100644 index a0b91db8..00000000 --- a/hindsight-clients/rust/README.md +++ /dev/null @@ -1,171 +0,0 @@ -# Hindsight Rust Client - -Auto-generated Rust client library for the Hindsight semantic memory system API. - -## Features - -- 🦀 **Fully typed** - Complete type safety with Rust's type system -- 🔄 **Auto-generated** - Stays in sync with the OpenAPI spec automatically -- ⚡ **Async/await** - Built on tokio and reqwest for modern async Rust -- 📦 **Standalone** - Can be published to crates.io independently - -## Installation - -Add to your `Cargo.toml`: - -```toml -[dependencies] -hindsight-client = "0.1.0" -tokio = { version = "1", features = ["full"] } -``` - -## Quick Start - -```rust -use hindsight_client::Client; - -#[tokio::main] -async fn main() -> Result<(), Box> { - // Create a client - let client = Client::new("http://localhost:8888"); - - // List all agents - let agents = client.list_agents().await?; - for agent in agents { - println!("Agent: {} - {}", agent.agent_id, agent.name); - } - - // Get agent profile - let profile = client.get_agent_profile("my-agent").await?; - println!("Background: {}", profile.background); - - // Search memories - let search_request = hindsight_client::types::SearchRequest { - query: "What did I learn today?".to_string(), - fact_type: None, - thinking_budget: Some(100), - max_tokens: Some(4096), - trace: Some(false), - }; - let results = client.search_memories("my-agent", &search_request).await?; - for result in results.results { - println!("- {}", result.text); - } - - // Store a memory - let memory_request = hindsight_client::types::BatchMemoryRequest { - items: vec![ - hindsight_client::types::MemoryItem { - content: "I learned about Rust today".to_string(), - context: Some("Daily learning".to_string()), - } - ], - document_id: Some("my-doc".to_string()), - }; - client.batch_put_memories("my-agent", &memory_request).await?; - - Ok(()) -} -``` - -## How It Works - -This library uses [progenitor](https://github.com/oxidecomputer/progenitor) to generate the client code from the OpenAPI specification at **build time**. - -The generation happens automatically when you run `cargo build`, so the client always stays in sync with the API schema. - -### Build Process - -1. `build.rs` reads the OpenAPI spec from `../../openapi.json` -2. Converts OpenAPI 3.1 → 3.0 (for progenitor compatibility) -3. Generates Rust client code using progenitor -4. Code is included in the library via `include!()` macro - -## API Methods - -All API endpoints are available as async methods on the `Client` struct: - -### Agent Management -- `list_agents()` - List all agents -- `create_or_update_agent()` - Create or update an agent -- `get_agent_profile()` - Get agent profile with personality -- `update_agent_personality()` - Update agent personality traits -- `add_agent_background()` - Add/merge agent background -- `get_agent_stats()` - Get memory statistics - -### Memory Operations -- `search_memories()` - Semantic search across memories -- `think()` - Generate contextual answers using agent identity -- `batch_put_memories()` - Store multiple memories -- `batch_put_async()` - Queue memories for background processing -- `list_memories()` - List memory units with pagination -- `delete_memory_unit()` - Delete a specific memory -- `clear_agent_memories()` - Clear all or filtered memories - -### Document Management -- `list_documents()` - List documents with optional search -- `get_document()` - Get document details and content -- `delete_document()` - Delete document and its memories - -### Operations (Async Tasks) -- `list_operations()` - List async operations -- `cancel_operation()` - Cancel a pending operation - -### Visualization -- `get_graph()` - Get memory graph data for visualization - -## Error Handling - -The client uses `progenitor_client::Error` for all errors: - -```rust -match client.get_agent_profile("my-agent").await { - Ok(profile) => println!("Got profile: {}", profile.name), - Err(progenitor_client::Error::ErrorResponse(resp)) => { - println!("API error: {} - {}", resp.status, resp.body); - } - Err(e) => println!("Other error: {}", e), -} -``` - -## Development - -### Building - -```bash -cargo build -``` - -The OpenAPI spec is automatically converted and the client is generated during build. - -### Testing - -```bash -cargo test -``` - -### Releasing - -This client can be published to crates.io independently of the CLI: - -```bash -cargo publish -``` - -## Architecture - -``` -hindsight-clients/rust/ -├── Cargo.toml # Package definition -├── build.rs # Build script (generates client) -├── src/ -│ └── lib.rs # Library entry point -└── target/ - └── debug/build/ - └── hindsight-client-.../out/ - └── hindsight_client_generated.rs # Generated code -``` - -## License - -MIT diff --git a/hindsight-clients/rust/build.rs b/hindsight-clients/rust/build.rs deleted file mode 100644 index cd43f90d..00000000 --- a/hindsight-clients/rust/build.rs +++ /dev/null @@ -1,127 +0,0 @@ -use std::env; -use std::fs; -use std::path::PathBuf; - -/// Convert OpenAPI 3.1 spec to 3.0 for progenitor compatibility -fn convert_31_to_30(spec: &mut serde_json::Value) { - // Change version from 3.1.x to 3.0.3 - if let Some(obj) = spec.as_object_mut() { - obj.insert("openapi".to_string(), serde_json::json!("3.0.3")); - } - - // Recursively convert anyOf with null to nullable - convert_anyof_to_nullable(spec); -} - -fn convert_anyof_to_nullable(value: &mut serde_json::Value) { - match value { - serde_json::Value::Object(obj) => { - // Check if this object has anyOf with null and process it - let should_convert = obj.get("anyOf") - .and_then(|v| v.as_array()) - .map(|array| { - if array.len() == 2 { - let has_null = array.iter().any(|v| { - v.get("type") - .and_then(|t| t.as_str()) - .map(|s| s == "null") - .unwrap_or(false) - }); - has_null - } else { - false - } - }) - .unwrap_or(false); - - if should_convert { - // Clone the anyOf array to avoid borrow issues - if let Some(any_of) = obj.get("anyOf").cloned() { - if let Some(array) = any_of.as_array() { - // Find the non-null schema - if let Some(non_null_schema) = array.iter().find(|v| { - v.get("type") - .and_then(|t| t.as_str()) - .map(|s| s != "null") - .unwrap_or(true) - }).cloned() { - // Replace anyOf with the non-null schema + nullable: true - obj.remove("anyOf"); - if let Some(non_null_obj) = non_null_schema.as_object() { - for (k, v) in non_null_obj.iter() { - obj.insert(k.clone(), v.clone()); - } - } - obj.insert("nullable".to_string(), serde_json::json!(true)); - } - } - } - } - - // Recursively process all values - for (_key, val) in obj.iter_mut() { - convert_anyof_to_nullable(val); - } - } - serde_json::Value::Array(arr) => { - for item in arr.iter_mut() { - convert_anyof_to_nullable(item); - } - } - _ => {} - } -} - -fn main() { - // Get the OpenAPI spec path from the project root (two levels up) - let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); - let openapi_path = manifest_dir - .parent() - .unwrap() - .parent() - .unwrap() - .join("openapi.json"); - - // Tell Cargo to rebuild if the OpenAPI spec changes - println!("cargo:rerun-if-changed={}", openapi_path.display()); - - // Read the OpenAPI spec - let spec_content = fs::read_to_string(&openapi_path) - .expect("Failed to read openapi.json. Make sure it exists in the project root."); - - // Parse as generic JSON first to convert 3.1 to 3.0 - let mut spec_json: serde_json::Value = serde_json::from_str(&spec_content) - .expect("Failed to parse openapi.json"); - - // Convert OpenAPI 3.1.0 to 3.0.3 for progenitor compatibility - if let Some(version) = spec_json.get("openapi").and_then(|v| v.as_str()) { - if version.starts_with("3.1") { - eprintln!("Converting OpenAPI 3.1 to 3.0 for compatibility..."); - convert_31_to_30(&mut spec_json); - } - } - - // Now parse as OpenAPI struct - let spec: openapiv3::OpenAPI = serde_json::from_value(spec_json) - .expect("Failed to parse converted OpenAPI spec"); - - // Generate the client - let mut generator = progenitor::Generator::default(); - - // Generate code - let tokens = generator.generate_tokens(&spec) - .expect("Failed to generate client code from OpenAPI spec"); - - // Write to the output directory - let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); - let dest_path = out_dir.join("hindsight_client_generated.rs"); - - let syntax_tree = syn::parse2(tokens) - .expect("Failed to parse generated tokens"); - let formatted = prettyplease::unparse(&syntax_tree); - - fs::write(&dest_path, formatted) - .expect("Failed to write generated client code"); - - println!("Generated client at: {}", dest_path.display()); -} diff --git a/hindsight-clients/rust/src/lib.rs b/hindsight-clients/rust/src/lib.rs deleted file mode 100644 index d5f74cd9..00000000 --- a/hindsight-clients/rust/src/lib.rs +++ /dev/null @@ -1,116 +0,0 @@ -//! Hindsight API Client -//! -//! A Rust client library for the Hindsight semantic memory system API. -//! -//! # Example -//! -//! ```rust,no_run -//! use hindsight_client::Client; -//! -//! #[tokio::main] -//! async fn main() -> Result<(), Box> { -//! let client = Client::new("http://localhost:8888"); -//! -//! // List memory banks -//! let banks = client.list_banks().await?; -//! println!("Found {} banks", banks.into_inner().len()); -//! -//! Ok(()) -//! } -//! ``` - -// Include the generated client code (which already exports Error and ResponseValue) -include!(concat!(env!("OUT_DIR"), "/hindsight_client_generated.rs")); - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_client_creation() { - let _client = Client::new("http://localhost:8888"); - // Just verify we can create a client - assert!(true); - } - - #[tokio::test] - async fn test_memory_lifecycle() { - let api_url = std::env::var("HINDSIGHT_API_URL") - .unwrap_or_else(|_| "http://localhost:8888".to_string()); - let client = Client::new(&api_url); - - // Generate unique bank ID for this test - let bank_id = format!("rust-test-{}", uuid::Uuid::new_v4()); - - // 1. Create a bank - let create_request = types::CreateBankRequest { - name: Some(format!("Rust Test Bank")), - ..Default::default() - }; - let create_response = client - .create_or_update_bank(&bank_id, &create_request) - .await - .expect("Failed to create bank"); - assert_eq!(create_response.into_inner().bank_id, bank_id); - - // 2. Retain some memories - let retain_request = types::RetainRequest { - async_: false, - items: vec![ - types::MemoryItem { - content: "Alice is a software engineer at Google".to_string(), - context: None, - document_id: None, - metadata: None, - timestamp: None, - }, - types::MemoryItem { - content: "Bob works with Alice on the search team".to_string(), - context: None, - document_id: None, - metadata: None, - timestamp: None, - }, - ], - }; - let retain_response = client - .retain_memories(&bank_id, &retain_request) - .await - .expect("Failed to retain memories"); - assert!(retain_response.into_inner().success); - - // 3. Recall memories - let recall_request = types::RecallRequest { - query: "Who is Alice?".to_string(), - max_tokens: 4096, - trace: false, - budget: None, - include: None, - query_timestamp: None, - types: None, - }; - let recall_response = client - .recall_memories(&bank_id, &recall_request) - .await - .expect("Failed to recall memories"); - let recall_result = recall_response.into_inner(); - assert!(!recall_result.results.is_empty(), "Should recall at least one memory"); - - // 4. Reflect on a question - let reflect_request = types::ReflectRequest { - query: "What do you know about Alice?".to_string(), - budget: None, - context: None, - include: None, - }; - let reflect_response = client - .reflect(&bank_id, &reflect_request) - .await - .expect("Failed to reflect"); - let reflect_result = reflect_response.into_inner(); - assert!(!reflect_result.text.is_empty(), "Reflect should return some text"); - - // Cleanup: delete the test bank's memories - let _ = client.clear_bank_memories(&bank_id, None).await; - } -} diff --git a/hindsight-clients/typescript/.gitignore b/hindsight-clients/typescript/.gitignore deleted file mode 100644 index e3726d74..00000000 --- a/hindsight-clients/typescript/.gitignore +++ /dev/null @@ -1,29 +0,0 @@ -# Dependencies -node_modules/ -yarn.lock -pnpm-lock.yaml - -# Build output -dist/ -*.tsbuildinfo - -# Testing -coverage/ -.nyc_output - -# IDE -.vscode/ -.idea/ -*.swp -*.swo -*~ - -# Logs -*.log -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# OS -.DS_Store -Thumbs.db diff --git a/hindsight-clients/typescript/.npmignore b/hindsight-clients/typescript/.npmignore deleted file mode 100644 index 33a79726..00000000 --- a/hindsight-clients/typescript/.npmignore +++ /dev/null @@ -1,26 +0,0 @@ -# Source files (we ship dist/) -src/ -tsconfig.json - -# Development -node_modules/ -*.test.ts -*.spec.ts -coverage/ -.nyc_output - -# CI/CD -.github/ -.gitlab-ci.yml - -# Documentation source -docs/ - -# IDE -.vscode/ -.idea/ -*.swp -*.swo - -# Build artifacts -*.tsbuildinfo diff --git a/hindsight-clients/typescript/README.md b/hindsight-clients/typescript/README.md deleted file mode 100644 index 2b561eec..00000000 --- a/hindsight-clients/typescript/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# Hindsight TypeScript Client - -TypeScript client library for the Hindsight API. - -## Installation - -```bash -npm install @vectorize-io/hindsight-client -# or -yarn add @vectorize-io/hindsight-client -``` - -## Usage - -```typescript -import { HindsightClient } from '@vectorize-io/hindsight-client'; - -const client = new HindsightClient({ baseUrl: 'http://localhost:8888' }); - -// Retain information -await client.retain('my-bank', 'Alice works at Google in Mountain View.'); - -// Recall memories -const results = await client.recall('my-bank', 'Where does Alice work?'); - -// Reflect and get an opinion -const response = await client.reflect('my-bank', 'What do you think about Alice\'s career?'); -``` - -## API Reference - -### `retain(bankId, content, options?)` - -Store a single memory. - -```typescript -await client.retain('my-bank', 'User prefers dark mode', { - timestamp: new Date(), - context: 'Settings conversation', - metadata: { source: 'chat' } -}); -``` - -### `retainBatch(bankId, items, options?)` - -Store multiple memories in batch. - -```typescript -await client.retainBatch('my-bank', [ - { content: 'Alice loves hiking' }, - { content: 'Alice visited Paris last summer' } -], { async: true }); -``` - -### `recall(bankId, query, options?)` - -Recall memories matching a query. - -```typescript -const results = await client.recall('my-bank', 'What are Alice\'s hobbies?', { - budget: 'mid' -}); -``` - -### `reflect(bankId, query, options?)` - -Generate a contextual answer using the bank's identity and memories. - -```typescript -const response = await client.reflect('my-bank', 'What should I do this weekend?', { - budget: 'low' -}); -console.log(response.text); -``` - -### `createBank(bankId, options)` - -Create or update a memory bank with personality. - -```typescript -await client.createBank('my-bank', { - name: 'My Assistant', - background: 'A helpful assistant that remembers everything.' -}); -``` - -## Documentation - -For full documentation, visit [hindsight](https://github.com/vectorize-io/hindsight). diff --git a/hindsight-clients/typescript/generated/client.gen.ts b/hindsight-clients/typescript/generated/client.gen.ts deleted file mode 100644 index cab3c701..00000000 --- a/hindsight-clients/typescript/generated/client.gen.ts +++ /dev/null @@ -1,16 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { type ClientOptions, type Config, createClient, createConfig } from './client'; -import type { ClientOptions as ClientOptions2 } from './types.gen'; - -/** - * The `createClientConfig()` function will be called on client initialization - * and the returned object will become the client's initial configuration. - * - * You may want to initialize your client this way instead of calling - * `setConfig()`. This is useful for example if you're using Next.js - * to ensure your client always has the correct values. - */ -export type CreateClientConfig = (override?: Config) => Config & T>; - -export const client = createClient(createConfig()); diff --git a/hindsight-clients/typescript/generated/client/client.gen.ts b/hindsight-clients/typescript/generated/client/client.gen.ts deleted file mode 100644 index c2a5190c..00000000 --- a/hindsight-clients/typescript/generated/client/client.gen.ts +++ /dev/null @@ -1,301 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { createSseClient } from '../core/serverSentEvents.gen'; -import type { HttpMethod } from '../core/types.gen'; -import { getValidRequestBody } from '../core/utils.gen'; -import type { - Client, - Config, - RequestOptions, - ResolvedRequestOptions, -} from './types.gen'; -import { - buildUrl, - createConfig, - createInterceptors, - getParseAs, - mergeConfigs, - mergeHeaders, - setAuthParams, -} from './utils.gen'; - -type ReqInit = Omit & { - body?: any; - headers: ReturnType; -}; - -export const createClient = (config: Config = {}): Client => { - let _config = mergeConfigs(createConfig(), config); - - const getConfig = (): Config => ({ ..._config }); - - const setConfig = (config: Config): Config => { - _config = mergeConfigs(_config, config); - return getConfig(); - }; - - const interceptors = createInterceptors< - Request, - Response, - unknown, - ResolvedRequestOptions - >(); - - const beforeRequest = async (options: RequestOptions) => { - const opts = { - ..._config, - ...options, - fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, - headers: mergeHeaders(_config.headers, options.headers), - serializedBody: undefined, - }; - - if (opts.security) { - await setAuthParams({ - ...opts, - security: opts.security, - }); - } - - if (opts.requestValidator) { - await opts.requestValidator(opts); - } - - if (opts.body !== undefined && opts.bodySerializer) { - opts.serializedBody = opts.bodySerializer(opts.body); - } - - // remove Content-Type header if body is empty to avoid sending invalid requests - if (opts.body === undefined || opts.serializedBody === '') { - opts.headers.delete('Content-Type'); - } - - const url = buildUrl(opts); - - return { opts, url }; - }; - - const request: Client['request'] = async (options) => { - // @ts-expect-error - const { opts, url } = await beforeRequest(options); - const requestInit: ReqInit = { - redirect: 'follow', - ...opts, - body: getValidRequestBody(opts), - }; - - let request = new Request(url, requestInit); - - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts); - } - } - - // fetch must be assigned here, otherwise it would throw the error: - // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = opts.fetch!; - let response: Response; - - try { - response = await _fetch(request); - } catch (error) { - // Handle fetch exceptions (AbortError, network errors, etc.) - let finalError = error; - - for (const fn of interceptors.error.fns) { - if (fn) { - finalError = (await fn( - error, - undefined as any, - request, - opts, - )) as unknown; - } - } - - finalError = finalError || ({} as unknown); - - if (opts.throwOnError) { - throw finalError; - } - - // Return error response - return opts.responseStyle === 'data' - ? undefined - : { - error: finalError, - request, - response: undefined as any, - }; - } - - for (const fn of interceptors.response.fns) { - if (fn) { - response = await fn(response, request, opts); - } - } - - const result = { - request, - response, - }; - - if (response.ok) { - const parseAs = - (opts.parseAs === 'auto' - ? getParseAs(response.headers.get('Content-Type')) - : opts.parseAs) ?? 'json'; - - if ( - response.status === 204 || - response.headers.get('Content-Length') === '0' - ) { - let emptyData: any; - switch (parseAs) { - case 'arrayBuffer': - case 'blob': - case 'text': - emptyData = await response[parseAs](); - break; - case 'formData': - emptyData = new FormData(); - break; - case 'stream': - emptyData = response.body; - break; - case 'json': - default: - emptyData = {}; - break; - } - return opts.responseStyle === 'data' - ? emptyData - : { - data: emptyData, - ...result, - }; - } - - let data: any; - switch (parseAs) { - case 'arrayBuffer': - case 'blob': - case 'formData': - case 'json': - case 'text': - data = await response[parseAs](); - break; - case 'stream': - return opts.responseStyle === 'data' - ? response.body - : { - data: response.body, - ...result, - }; - } - - if (parseAs === 'json') { - if (opts.responseValidator) { - await opts.responseValidator(data); - } - - if (opts.responseTransformer) { - data = await opts.responseTransformer(data); - } - } - - return opts.responseStyle === 'data' - ? data - : { - data, - ...result, - }; - } - - const textError = await response.text(); - let jsonError: unknown; - - try { - jsonError = JSON.parse(textError); - } catch { - // noop - } - - const error = jsonError ?? textError; - let finalError = error; - - for (const fn of interceptors.error.fns) { - if (fn) { - finalError = (await fn(error, response, request, opts)) as string; - } - } - - finalError = finalError || ({} as string); - - if (opts.throwOnError) { - throw finalError; - } - - // TODO: we probably want to return error and improve types - return opts.responseStyle === 'data' - ? undefined - : { - error: finalError, - ...result, - }; - }; - - const makeMethodFn = - (method: Uppercase) => (options: RequestOptions) => - request({ ...options, method }); - - const makeSseFn = - (method: Uppercase) => async (options: RequestOptions) => { - const { opts, url } = await beforeRequest(options); - return createSseClient({ - ...opts, - body: opts.body as BodyInit | null | undefined, - headers: opts.headers as unknown as Record, - method, - onRequest: async (url, init) => { - let request = new Request(url, init); - for (const fn of interceptors.request.fns) { - if (fn) { - request = await fn(request, opts); - } - } - return request; - }, - url, - }); - }; - - return { - buildUrl, - connect: makeMethodFn('CONNECT'), - delete: makeMethodFn('DELETE'), - get: makeMethodFn('GET'), - getConfig, - head: makeMethodFn('HEAD'), - interceptors, - options: makeMethodFn('OPTIONS'), - patch: makeMethodFn('PATCH'), - post: makeMethodFn('POST'), - put: makeMethodFn('PUT'), - request, - setConfig, - sse: { - connect: makeSseFn('CONNECT'), - delete: makeSseFn('DELETE'), - get: makeSseFn('GET'), - head: makeSseFn('HEAD'), - options: makeSseFn('OPTIONS'), - patch: makeSseFn('PATCH'), - post: makeSseFn('POST'), - put: makeSseFn('PUT'), - trace: makeSseFn('TRACE'), - }, - trace: makeMethodFn('TRACE'), - } as Client; -}; diff --git a/hindsight-clients/typescript/generated/client/index.ts b/hindsight-clients/typescript/generated/client/index.ts deleted file mode 100644 index b295edec..00000000 --- a/hindsight-clients/typescript/generated/client/index.ts +++ /dev/null @@ -1,25 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type { Auth } from '../core/auth.gen'; -export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; -export { - formDataBodySerializer, - jsonBodySerializer, - urlSearchParamsBodySerializer, -} from '../core/bodySerializer.gen'; -export { buildClientParams } from '../core/params.gen'; -export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; -export { createClient } from './client.gen'; -export type { - Client, - ClientOptions, - Config, - CreateClientConfig, - Options, - RequestOptions, - RequestResult, - ResolvedRequestOptions, - ResponseStyle, - TDataShape, -} from './types.gen'; -export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/hindsight-clients/typescript/generated/client/types.gen.ts b/hindsight-clients/typescript/generated/client/types.gen.ts deleted file mode 100644 index b4a499cc..00000000 --- a/hindsight-clients/typescript/generated/client/types.gen.ts +++ /dev/null @@ -1,241 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Auth } from '../core/auth.gen'; -import type { - ServerSentEventsOptions, - ServerSentEventsResult, -} from '../core/serverSentEvents.gen'; -import type { - Client as CoreClient, - Config as CoreConfig, -} from '../core/types.gen'; -import type { Middleware } from './utils.gen'; - -export type ResponseStyle = 'data' | 'fields'; - -export interface Config - extends Omit, - CoreConfig { - /** - * Base URL for all requests made by this client. - */ - baseUrl?: T['baseUrl']; - /** - * Fetch API implementation. You can use this option to provide a custom - * fetch instance. - * - * @default globalThis.fetch - */ - fetch?: typeof fetch; - /** - * Please don't use the Fetch client for Next.js applications. The `next` - * options won't have any effect. - * - * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. - */ - next?: never; - /** - * Return the response data parsed in a specified format. By default, `auto` - * will infer the appropriate method from the `Content-Type` response header. - * You can override this behavior with any of the {@link Body} methods. - * Select `stream` if you don't want to parse response data at all. - * - * @default 'auto' - */ - parseAs?: - | 'arrayBuffer' - | 'auto' - | 'blob' - | 'formData' - | 'json' - | 'stream' - | 'text'; - /** - * Should we return only data or multiple fields (data, error, response, etc.)? - * - * @default 'fields' - */ - responseStyle?: ResponseStyle; - /** - * Throw an error instead of returning it in the response? - * - * @default false - */ - throwOnError?: T['throwOnError']; -} - -export interface RequestOptions< - TData = unknown, - TResponseStyle extends ResponseStyle = 'fields', - ThrowOnError extends boolean = boolean, - Url extends string = string, -> extends Config<{ - responseStyle: TResponseStyle; - throwOnError: ThrowOnError; - }>, - Pick< - ServerSentEventsOptions, - | 'onSseError' - | 'onSseEvent' - | 'sseDefaultRetryDelay' - | 'sseMaxRetryAttempts' - | 'sseMaxRetryDelay' - > { - /** - * Any body that you want to add to your request. - * - * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} - */ - body?: unknown; - path?: Record; - query?: Record; - /** - * Security mechanism(s) to use for the request. - */ - security?: ReadonlyArray; - url: Url; -} - -export interface ResolvedRequestOptions< - TResponseStyle extends ResponseStyle = 'fields', - ThrowOnError extends boolean = boolean, - Url extends string = string, -> extends RequestOptions { - serializedBody?: string; -} - -export type RequestResult< - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = boolean, - TResponseStyle extends ResponseStyle = 'fields', -> = ThrowOnError extends true - ? Promise< - TResponseStyle extends 'data' - ? TData extends Record - ? TData[keyof TData] - : TData - : { - data: TData extends Record - ? TData[keyof TData] - : TData; - request: Request; - response: Response; - } - > - : Promise< - TResponseStyle extends 'data' - ? - | (TData extends Record - ? TData[keyof TData] - : TData) - | undefined - : ( - | { - data: TData extends Record - ? TData[keyof TData] - : TData; - error: undefined; - } - | { - data: undefined; - error: TError extends Record - ? TError[keyof TError] - : TError; - } - ) & { - request: Request; - response: Response; - } - >; - -export interface ClientOptions { - baseUrl?: string; - responseStyle?: ResponseStyle; - throwOnError?: boolean; -} - -type MethodFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'>, -) => RequestResult; - -type SseFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'>, -) => Promise>; - -type RequestFn = < - TData = unknown, - TError = unknown, - ThrowOnError extends boolean = false, - TResponseStyle extends ResponseStyle = 'fields', ->( - options: Omit, 'method'> & - Pick< - Required>, - 'method' - >, -) => RequestResult; - -type BuildUrlFn = < - TData extends { - body?: unknown; - path?: Record; - query?: Record; - url: string; - }, ->( - options: TData & Options, -) => string; - -export type Client = CoreClient< - RequestFn, - Config, - MethodFn, - BuildUrlFn, - SseFn -> & { - interceptors: Middleware; -}; - -/** - * The `createClientConfig()` function will be called on client initialization - * and the returned object will become the client's initial configuration. - * - * You may want to initialize your client this way instead of calling - * `setConfig()`. This is useful for example if you're using Next.js - * to ensure your client always has the correct values. - */ -export type CreateClientConfig = ( - override?: Config, -) => Config & T>; - -export interface TDataShape { - body?: unknown; - headers?: unknown; - path?: unknown; - query?: unknown; - url: string; -} - -type OmitKeys = Pick>; - -export type Options< - TData extends TDataShape = TDataShape, - ThrowOnError extends boolean = boolean, - TResponse = unknown, - TResponseStyle extends ResponseStyle = 'fields', -> = OmitKeys< - RequestOptions, - 'body' | 'path' | 'query' | 'url' -> & - ([TData] extends [never] ? unknown : Omit); diff --git a/hindsight-clients/typescript/generated/client/utils.gen.ts b/hindsight-clients/typescript/generated/client/utils.gen.ts deleted file mode 100644 index 4c48a9ee..00000000 --- a/hindsight-clients/typescript/generated/client/utils.gen.ts +++ /dev/null @@ -1,332 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import { getAuthToken } from '../core/auth.gen'; -import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; -import { jsonBodySerializer } from '../core/bodySerializer.gen'; -import { - serializeArrayParam, - serializeObjectParam, - serializePrimitiveParam, -} from '../core/pathSerializer.gen'; -import { getUrl } from '../core/utils.gen'; -import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; - -export const createQuerySerializer = ({ - parameters = {}, - ...args -}: QuerySerializerOptions = {}) => { - const querySerializer = (queryParams: T) => { - const search: string[] = []; - if (queryParams && typeof queryParams === 'object') { - for (const name in queryParams) { - const value = queryParams[name]; - - if (value === undefined || value === null) { - continue; - } - - const options = parameters[name] || args; - - if (Array.isArray(value)) { - const serializedArray = serializeArrayParam({ - allowReserved: options.allowReserved, - explode: true, - name, - style: 'form', - value, - ...options.array, - }); - if (serializedArray) search.push(serializedArray); - } else if (typeof value === 'object') { - const serializedObject = serializeObjectParam({ - allowReserved: options.allowReserved, - explode: true, - name, - style: 'deepObject', - value: value as Record, - ...options.object, - }); - if (serializedObject) search.push(serializedObject); - } else { - const serializedPrimitive = serializePrimitiveParam({ - allowReserved: options.allowReserved, - name, - value: value as string, - }); - if (serializedPrimitive) search.push(serializedPrimitive); - } - } - } - return search.join('&'); - }; - return querySerializer; -}; - -/** - * Infers parseAs value from provided Content-Type header. - */ -export const getParseAs = ( - contentType: string | null, -): Exclude => { - if (!contentType) { - // If no Content-Type header is provided, the best we can do is return the raw response body, - // which is effectively the same as the 'stream' option. - return 'stream'; - } - - const cleanContent = contentType.split(';')[0]?.trim(); - - if (!cleanContent) { - return; - } - - if ( - cleanContent.startsWith('application/json') || - cleanContent.endsWith('+json') - ) { - return 'json'; - } - - if (cleanContent === 'multipart/form-data') { - return 'formData'; - } - - if ( - ['application/', 'audio/', 'image/', 'video/'].some((type) => - cleanContent.startsWith(type), - ) - ) { - return 'blob'; - } - - if (cleanContent.startsWith('text/')) { - return 'text'; - } - - return; -}; - -const checkForExistence = ( - options: Pick & { - headers: Headers; - }, - name?: string, -): boolean => { - if (!name) { - return false; - } - if ( - options.headers.has(name) || - options.query?.[name] || - options.headers.get('Cookie')?.includes(`${name}=`) - ) { - return true; - } - return false; -}; - -export const setAuthParams = async ({ - security, - ...options -}: Pick, 'security'> & - Pick & { - headers: Headers; - }) => { - for (const auth of security) { - if (checkForExistence(options, auth.name)) { - continue; - } - - const token = await getAuthToken(auth, options.auth); - - if (!token) { - continue; - } - - const name = auth.name ?? 'Authorization'; - - switch (auth.in) { - case 'query': - if (!options.query) { - options.query = {}; - } - options.query[name] = token; - break; - case 'cookie': - options.headers.append('Cookie', `${name}=${token}`); - break; - case 'header': - default: - options.headers.set(name, token); - break; - } - } -}; - -export const buildUrl: Client['buildUrl'] = (options) => - getUrl({ - baseUrl: options.baseUrl as string, - path: options.path, - query: options.query, - querySerializer: - typeof options.querySerializer === 'function' - ? options.querySerializer - : createQuerySerializer(options.querySerializer), - url: options.url, - }); - -export const mergeConfigs = (a: Config, b: Config): Config => { - const config = { ...a, ...b }; - if (config.baseUrl?.endsWith('/')) { - config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); - } - config.headers = mergeHeaders(a.headers, b.headers); - return config; -}; - -const headersEntries = (headers: Headers): Array<[string, string]> => { - const entries: Array<[string, string]> = []; - headers.forEach((value, key) => { - entries.push([key, value]); - }); - return entries; -}; - -export const mergeHeaders = ( - ...headers: Array['headers'] | undefined> -): Headers => { - const mergedHeaders = new Headers(); - for (const header of headers) { - if (!header) { - continue; - } - - const iterator = - header instanceof Headers - ? headersEntries(header) - : Object.entries(header); - - for (const [key, value] of iterator) { - if (value === null) { - mergedHeaders.delete(key); - } else if (Array.isArray(value)) { - for (const v of value) { - mergedHeaders.append(key, v as string); - } - } else if (value !== undefined) { - // assume object headers are meant to be JSON stringified, i.e. their - // content value in OpenAPI specification is 'application/json' - mergedHeaders.set( - key, - typeof value === 'object' ? JSON.stringify(value) : (value as string), - ); - } - } - } - return mergedHeaders; -}; - -type ErrInterceptor = ( - error: Err, - response: Res, - request: Req, - options: Options, -) => Err | Promise; - -type ReqInterceptor = ( - request: Req, - options: Options, -) => Req | Promise; - -type ResInterceptor = ( - response: Res, - request: Req, - options: Options, -) => Res | Promise; - -class Interceptors { - fns: Array = []; - - clear(): void { - this.fns = []; - } - - eject(id: number | Interceptor): void { - const index = this.getInterceptorIndex(id); - if (this.fns[index]) { - this.fns[index] = null; - } - } - - exists(id: number | Interceptor): boolean { - const index = this.getInterceptorIndex(id); - return Boolean(this.fns[index]); - } - - getInterceptorIndex(id: number | Interceptor): number { - if (typeof id === 'number') { - return this.fns[id] ? id : -1; - } - return this.fns.indexOf(id); - } - - update( - id: number | Interceptor, - fn: Interceptor, - ): number | Interceptor | false { - const index = this.getInterceptorIndex(id); - if (this.fns[index]) { - this.fns[index] = fn; - return id; - } - return false; - } - - use(fn: Interceptor): number { - this.fns.push(fn); - return this.fns.length - 1; - } -} - -export interface Middleware { - error: Interceptors>; - request: Interceptors>; - response: Interceptors>; -} - -export const createInterceptors = (): Middleware< - Req, - Res, - Err, - Options -> => ({ - error: new Interceptors>(), - request: new Interceptors>(), - response: new Interceptors>(), -}); - -const defaultQuerySerializer = createQuerySerializer({ - allowReserved: false, - array: { - explode: true, - style: 'form', - }, - object: { - explode: true, - style: 'deepObject', - }, -}); - -const defaultHeaders = { - 'Content-Type': 'application/json', -}; - -export const createConfig = ( - override: Config & T> = {}, -): Config & T> => ({ - ...jsonBodySerializer, - headers: defaultHeaders, - parseAs: 'auto', - querySerializer: defaultQuerySerializer, - ...override, -}); diff --git a/hindsight-clients/typescript/generated/core/auth.gen.ts b/hindsight-clients/typescript/generated/core/auth.gen.ts deleted file mode 100644 index f8a73266..00000000 --- a/hindsight-clients/typescript/generated/core/auth.gen.ts +++ /dev/null @@ -1,42 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type AuthToken = string | undefined; - -export interface Auth { - /** - * Which part of the request do we use to send the auth? - * - * @default 'header' - */ - in?: 'header' | 'query' | 'cookie'; - /** - * Header or query parameter name. - * - * @default 'Authorization' - */ - name?: string; - scheme?: 'basic' | 'bearer'; - type: 'apiKey' | 'http'; -} - -export const getAuthToken = async ( - auth: Auth, - callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, -): Promise => { - const token = - typeof callback === 'function' ? await callback(auth) : callback; - - if (!token) { - return; - } - - if (auth.scheme === 'bearer') { - return `Bearer ${token}`; - } - - if (auth.scheme === 'basic') { - return `Basic ${btoa(token)}`; - } - - return token; -}; diff --git a/hindsight-clients/typescript/generated/core/bodySerializer.gen.ts b/hindsight-clients/typescript/generated/core/bodySerializer.gen.ts deleted file mode 100644 index 552b50f7..00000000 --- a/hindsight-clients/typescript/generated/core/bodySerializer.gen.ts +++ /dev/null @@ -1,100 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { - ArrayStyle, - ObjectStyle, - SerializerOptions, -} from './pathSerializer.gen'; - -export type QuerySerializer = (query: Record) => string; - -export type BodySerializer = (body: any) => any; - -type QuerySerializerOptionsObject = { - allowReserved?: boolean; - array?: Partial>; - object?: Partial>; -}; - -export type QuerySerializerOptions = QuerySerializerOptionsObject & { - /** - * Per-parameter serialization overrides. When provided, these settings - * override the global array/object settings for specific parameter names. - */ - parameters?: Record; -}; - -const serializeFormDataPair = ( - data: FormData, - key: string, - value: unknown, -): void => { - if (typeof value === 'string' || value instanceof Blob) { - data.append(key, value); - } else if (value instanceof Date) { - data.append(key, value.toISOString()); - } else { - data.append(key, JSON.stringify(value)); - } -}; - -const serializeUrlSearchParamsPair = ( - data: URLSearchParams, - key: string, - value: unknown, -): void => { - if (typeof value === 'string') { - data.append(key, value); - } else { - data.append(key, JSON.stringify(value)); - } -}; - -export const formDataBodySerializer = { - bodySerializer: | Array>>( - body: T, - ): FormData => { - const data = new FormData(); - - Object.entries(body).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - if (Array.isArray(value)) { - value.forEach((v) => serializeFormDataPair(data, key, v)); - } else { - serializeFormDataPair(data, key, value); - } - }); - - return data; - }, -}; - -export const jsonBodySerializer = { - bodySerializer: (body: T): string => - JSON.stringify(body, (_key, value) => - typeof value === 'bigint' ? value.toString() : value, - ), -}; - -export const urlSearchParamsBodySerializer = { - bodySerializer: | Array>>( - body: T, - ): string => { - const data = new URLSearchParams(); - - Object.entries(body).forEach(([key, value]) => { - if (value === undefined || value === null) { - return; - } - if (Array.isArray(value)) { - value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); - } else { - serializeUrlSearchParamsPair(data, key, value); - } - }); - - return data.toString(); - }, -}; diff --git a/hindsight-clients/typescript/generated/core/params.gen.ts b/hindsight-clients/typescript/generated/core/params.gen.ts deleted file mode 100644 index 602715c4..00000000 --- a/hindsight-clients/typescript/generated/core/params.gen.ts +++ /dev/null @@ -1,176 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -type Slot = 'body' | 'headers' | 'path' | 'query'; - -export type Field = - | { - in: Exclude; - /** - * Field name. This is the name we want the user to see and use. - */ - key: string; - /** - * Field mapped name. This is the name we want to use in the request. - * If omitted, we use the same value as `key`. - */ - map?: string; - } - | { - in: Extract; - /** - * Key isn't required for bodies. - */ - key?: string; - map?: string; - } - | { - /** - * Field name. This is the name we want the user to see and use. - */ - key: string; - /** - * Field mapped name. This is the name we want to use in the request. - * If `in` is omitted, `map` aliases `key` to the transport layer. - */ - map: Slot; - }; - -export interface Fields { - allowExtra?: Partial>; - args?: ReadonlyArray; -} - -export type FieldsConfig = ReadonlyArray; - -const extraPrefixesMap: Record = { - $body_: 'body', - $headers_: 'headers', - $path_: 'path', - $query_: 'query', -}; -const extraPrefixes = Object.entries(extraPrefixesMap); - -type KeyMap = Map< - string, - | { - in: Slot; - map?: string; - } - | { - in?: never; - map: Slot; - } ->; - -const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { - if (!map) { - map = new Map(); - } - - for (const config of fields) { - if ('in' in config) { - if (config.key) { - map.set(config.key, { - in: config.in, - map: config.map, - }); - } - } else if ('key' in config) { - map.set(config.key, { - map: config.map, - }); - } else if (config.args) { - buildKeyMap(config.args, map); - } - } - - return map; -}; - -interface Params { - body: unknown; - headers: Record; - path: Record; - query: Record; -} - -const stripEmptySlots = (params: Params) => { - for (const [slot, value] of Object.entries(params)) { - if (value && typeof value === 'object' && !Object.keys(value).length) { - delete params[slot as Slot]; - } - } -}; - -export const buildClientParams = ( - args: ReadonlyArray, - fields: FieldsConfig, -) => { - const params: Params = { - body: {}, - headers: {}, - path: {}, - query: {}, - }; - - const map = buildKeyMap(fields); - - let config: FieldsConfig[number] | undefined; - - for (const [index, arg] of args.entries()) { - if (fields[index]) { - config = fields[index]; - } - - if (!config) { - continue; - } - - if ('in' in config) { - if (config.key) { - const field = map.get(config.key)!; - const name = field.map || config.key; - if (field.in) { - (params[field.in] as Record)[name] = arg; - } - } else { - params.body = arg; - } - } else { - for (const [key, value] of Object.entries(arg ?? {})) { - const field = map.get(key); - - if (field) { - if (field.in) { - const name = field.map || key; - (params[field.in] as Record)[name] = value; - } else { - params[field.map] = value; - } - } else { - const extra = extraPrefixes.find(([prefix]) => - key.startsWith(prefix), - ); - - if (extra) { - const [prefix, slot] = extra; - (params[slot] as Record)[ - key.slice(prefix.length) - ] = value; - } else if ('allowExtra' in config && config.allowExtra) { - for (const [slot, allowed] of Object.entries(config.allowExtra)) { - if (allowed) { - (params[slot as Slot] as Record)[key] = value; - break; - } - } - } - } - } - } - } - - stripEmptySlots(params); - - return params; -}; diff --git a/hindsight-clients/typescript/generated/core/pathSerializer.gen.ts b/hindsight-clients/typescript/generated/core/pathSerializer.gen.ts deleted file mode 100644 index 8d999310..00000000 --- a/hindsight-clients/typescript/generated/core/pathSerializer.gen.ts +++ /dev/null @@ -1,181 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -interface SerializeOptions - extends SerializePrimitiveOptions, - SerializerOptions {} - -interface SerializePrimitiveOptions { - allowReserved?: boolean; - name: string; -} - -export interface SerializerOptions { - /** - * @default true - */ - explode: boolean; - style: T; -} - -export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; -export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; -type MatrixStyle = 'label' | 'matrix' | 'simple'; -export type ObjectStyle = 'form' | 'deepObject'; -type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; - -interface SerializePrimitiveParam extends SerializePrimitiveOptions { - value: string; -} - -export const separatorArrayExplode = (style: ArraySeparatorStyle) => { - switch (style) { - case 'label': - return '.'; - case 'matrix': - return ';'; - case 'simple': - return ','; - default: - return '&'; - } -}; - -export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { - switch (style) { - case 'form': - return ','; - case 'pipeDelimited': - return '|'; - case 'spaceDelimited': - return '%20'; - default: - return ','; - } -}; - -export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { - switch (style) { - case 'label': - return '.'; - case 'matrix': - return ';'; - case 'simple': - return ','; - default: - return '&'; - } -}; - -export const serializeArrayParam = ({ - allowReserved, - explode, - name, - style, - value, -}: SerializeOptions & { - value: unknown[]; -}) => { - if (!explode) { - const joinedValues = ( - allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) - ).join(separatorArrayNoExplode(style)); - switch (style) { - case 'label': - return `.${joinedValues}`; - case 'matrix': - return `;${name}=${joinedValues}`; - case 'simple': - return joinedValues; - default: - return `${name}=${joinedValues}`; - } - } - - const separator = separatorArrayExplode(style); - const joinedValues = value - .map((v) => { - if (style === 'label' || style === 'simple') { - return allowReserved ? v : encodeURIComponent(v as string); - } - - return serializePrimitiveParam({ - allowReserved, - name, - value: v as string, - }); - }) - .join(separator); - return style === 'label' || style === 'matrix' - ? separator + joinedValues - : joinedValues; -}; - -export const serializePrimitiveParam = ({ - allowReserved, - name, - value, -}: SerializePrimitiveParam) => { - if (value === undefined || value === null) { - return ''; - } - - if (typeof value === 'object') { - throw new Error( - 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', - ); - } - - return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; -}; - -export const serializeObjectParam = ({ - allowReserved, - explode, - name, - style, - value, - valueOnly, -}: SerializeOptions & { - value: Record | Date; - valueOnly?: boolean; -}) => { - if (value instanceof Date) { - return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; - } - - if (style !== 'deepObject' && !explode) { - let values: string[] = []; - Object.entries(value).forEach(([key, v]) => { - values = [ - ...values, - key, - allowReserved ? (v as string) : encodeURIComponent(v as string), - ]; - }); - const joinedValues = values.join(','); - switch (style) { - case 'form': - return `${name}=${joinedValues}`; - case 'label': - return `.${joinedValues}`; - case 'matrix': - return `;${name}=${joinedValues}`; - default: - return joinedValues; - } - } - - const separator = separatorObjectExplode(style); - const joinedValues = Object.entries(value) - .map(([key, v]) => - serializePrimitiveParam({ - allowReserved, - name: style === 'deepObject' ? `${name}[${key}]` : key, - value: v as string, - }), - ) - .join(separator); - return style === 'label' || style === 'matrix' - ? separator + joinedValues - : joinedValues; -}; diff --git a/hindsight-clients/typescript/generated/core/queryKeySerializer.gen.ts b/hindsight-clients/typescript/generated/core/queryKeySerializer.gen.ts deleted file mode 100644 index d3bb6839..00000000 --- a/hindsight-clients/typescript/generated/core/queryKeySerializer.gen.ts +++ /dev/null @@ -1,136 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -/** - * JSON-friendly union that mirrors what Pinia Colada can hash. - */ -export type JsonValue = - | null - | string - | number - | boolean - | JsonValue[] - | { [key: string]: JsonValue }; - -/** - * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. - */ -export const queryKeyJsonReplacer = (_key: string, value: unknown) => { - if ( - value === undefined || - typeof value === 'function' || - typeof value === 'symbol' - ) { - return undefined; - } - if (typeof value === 'bigint') { - return value.toString(); - } - if (value instanceof Date) { - return value.toISOString(); - } - return value; -}; - -/** - * Safely stringifies a value and parses it back into a JsonValue. - */ -export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { - try { - const json = JSON.stringify(input, queryKeyJsonReplacer); - if (json === undefined) { - return undefined; - } - return JSON.parse(json) as JsonValue; - } catch { - return undefined; - } -}; - -/** - * Detects plain objects (including objects with a null prototype). - */ -const isPlainObject = (value: unknown): value is Record => { - if (value === null || typeof value !== 'object') { - return false; - } - const prototype = Object.getPrototypeOf(value as object); - return prototype === Object.prototype || prototype === null; -}; - -/** - * Turns URLSearchParams into a sorted JSON object for deterministic keys. - */ -const serializeSearchParams = (params: URLSearchParams): JsonValue => { - const entries = Array.from(params.entries()).sort(([a], [b]) => - a.localeCompare(b), - ); - const result: Record = {}; - - for (const [key, value] of entries) { - const existing = result[key]; - if (existing === undefined) { - result[key] = value; - continue; - } - - if (Array.isArray(existing)) { - (existing as string[]).push(value); - } else { - result[key] = [existing, value]; - } - } - - return result; -}; - -/** - * Normalizes any accepted value into a JSON-friendly shape for query keys. - */ -export const serializeQueryKeyValue = ( - value: unknown, -): JsonValue | undefined => { - if (value === null) { - return null; - } - - if ( - typeof value === 'string' || - typeof value === 'number' || - typeof value === 'boolean' - ) { - return value; - } - - if ( - value === undefined || - typeof value === 'function' || - typeof value === 'symbol' - ) { - return undefined; - } - - if (typeof value === 'bigint') { - return value.toString(); - } - - if (value instanceof Date) { - return value.toISOString(); - } - - if (Array.isArray(value)) { - return stringifyToJsonValue(value); - } - - if ( - typeof URLSearchParams !== 'undefined' && - value instanceof URLSearchParams - ) { - return serializeSearchParams(value); - } - - if (isPlainObject(value)) { - return stringifyToJsonValue(value); - } - - return undefined; -}; diff --git a/hindsight-clients/typescript/generated/core/serverSentEvents.gen.ts b/hindsight-clients/typescript/generated/core/serverSentEvents.gen.ts deleted file mode 100644 index f8fd78e2..00000000 --- a/hindsight-clients/typescript/generated/core/serverSentEvents.gen.ts +++ /dev/null @@ -1,264 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Config } from './types.gen'; - -export type ServerSentEventsOptions = Omit< - RequestInit, - 'method' -> & - Pick & { - /** - * Fetch API implementation. You can use this option to provide a custom - * fetch instance. - * - * @default globalThis.fetch - */ - fetch?: typeof fetch; - /** - * Implementing clients can call request interceptors inside this hook. - */ - onRequest?: (url: string, init: RequestInit) => Promise; - /** - * Callback invoked when a network or parsing error occurs during streaming. - * - * This option applies only if the endpoint returns a stream of events. - * - * @param error The error that occurred. - */ - onSseError?: (error: unknown) => void; - /** - * Callback invoked when an event is streamed from the server. - * - * This option applies only if the endpoint returns a stream of events. - * - * @param event Event streamed from the server. - * @returns Nothing (void). - */ - onSseEvent?: (event: StreamEvent) => void; - serializedBody?: RequestInit['body']; - /** - * Default retry delay in milliseconds. - * - * This option applies only if the endpoint returns a stream of events. - * - * @default 3000 - */ - sseDefaultRetryDelay?: number; - /** - * Maximum number of retry attempts before giving up. - */ - sseMaxRetryAttempts?: number; - /** - * Maximum retry delay in milliseconds. - * - * Applies only when exponential backoff is used. - * - * This option applies only if the endpoint returns a stream of events. - * - * @default 30000 - */ - sseMaxRetryDelay?: number; - /** - * Optional sleep function for retry backoff. - * - * Defaults to using `setTimeout`. - */ - sseSleepFn?: (ms: number) => Promise; - url: string; - }; - -export interface StreamEvent { - data: TData; - event?: string; - id?: string; - retry?: number; -} - -export type ServerSentEventsResult< - TData = unknown, - TReturn = void, - TNext = unknown, -> = { - stream: AsyncGenerator< - TData extends Record ? TData[keyof TData] : TData, - TReturn, - TNext - >; -}; - -export const createSseClient = ({ - onRequest, - onSseError, - onSseEvent, - responseTransformer, - responseValidator, - sseDefaultRetryDelay, - sseMaxRetryAttempts, - sseMaxRetryDelay, - sseSleepFn, - url, - ...options -}: ServerSentEventsOptions): ServerSentEventsResult => { - let lastEventId: string | undefined; - - const sleep = - sseSleepFn ?? - ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); - - const createStream = async function* () { - let retryDelay: number = sseDefaultRetryDelay ?? 3000; - let attempt = 0; - const signal = options.signal ?? new AbortController().signal; - - while (true) { - if (signal.aborted) break; - - attempt++; - - const headers = - options.headers instanceof Headers - ? options.headers - : new Headers(options.headers as Record | undefined); - - if (lastEventId !== undefined) { - headers.set('Last-Event-ID', lastEventId); - } - - try { - const requestInit: RequestInit = { - redirect: 'follow', - ...options, - body: options.serializedBody, - headers, - signal, - }; - let request = new Request(url, requestInit); - if (onRequest) { - request = await onRequest(url, requestInit); - } - // fetch must be assigned here, otherwise it would throw the error: - // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation - const _fetch = options.fetch ?? globalThis.fetch; - const response = await _fetch(request); - - if (!response.ok) - throw new Error( - `SSE failed: ${response.status} ${response.statusText}`, - ); - - if (!response.body) throw new Error('No body in SSE response'); - - const reader = response.body - .pipeThrough(new TextDecoderStream()) - .getReader(); - - let buffer = ''; - - const abortHandler = () => { - try { - reader.cancel(); - } catch { - // noop - } - }; - - signal.addEventListener('abort', abortHandler); - - try { - while (true) { - const { done, value } = await reader.read(); - if (done) break; - buffer += value; - - const chunks = buffer.split('\n\n'); - buffer = chunks.pop() ?? ''; - - for (const chunk of chunks) { - const lines = chunk.split('\n'); - const dataLines: Array = []; - let eventName: string | undefined; - - for (const line of lines) { - if (line.startsWith('data:')) { - dataLines.push(line.replace(/^data:\s*/, '')); - } else if (line.startsWith('event:')) { - eventName = line.replace(/^event:\s*/, ''); - } else if (line.startsWith('id:')) { - lastEventId = line.replace(/^id:\s*/, ''); - } else if (line.startsWith('retry:')) { - const parsed = Number.parseInt( - line.replace(/^retry:\s*/, ''), - 10, - ); - if (!Number.isNaN(parsed)) { - retryDelay = parsed; - } - } - } - - let data: unknown; - let parsedJson = false; - - if (dataLines.length) { - const rawData = dataLines.join('\n'); - try { - data = JSON.parse(rawData); - parsedJson = true; - } catch { - data = rawData; - } - } - - if (parsedJson) { - if (responseValidator) { - await responseValidator(data); - } - - if (responseTransformer) { - data = await responseTransformer(data); - } - } - - onSseEvent?.({ - data, - event: eventName, - id: lastEventId, - retry: retryDelay, - }); - - if (dataLines.length) { - yield data as any; - } - } - } - } finally { - signal.removeEventListener('abort', abortHandler); - reader.releaseLock(); - } - - break; // exit loop on normal completion - } catch (error) { - // connection failed or aborted; retry after delay - onSseError?.(error); - - if ( - sseMaxRetryAttempts !== undefined && - attempt >= sseMaxRetryAttempts - ) { - break; // stop after firing error - } - - // exponential backoff: double retry each attempt, cap at 30s - const backoff = Math.min( - retryDelay * 2 ** (attempt - 1), - sseMaxRetryDelay ?? 30000, - ); - await sleep(backoff); - } - } - }; - - const stream = createStream(); - - return { stream }; -}; diff --git a/hindsight-clients/typescript/generated/core/types.gen.ts b/hindsight-clients/typescript/generated/core/types.gen.ts deleted file mode 100644 index 643c070c..00000000 --- a/hindsight-clients/typescript/generated/core/types.gen.ts +++ /dev/null @@ -1,118 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Auth, AuthToken } from './auth.gen'; -import type { - BodySerializer, - QuerySerializer, - QuerySerializerOptions, -} from './bodySerializer.gen'; - -export type HttpMethod = - | 'connect' - | 'delete' - | 'get' - | 'head' - | 'options' - | 'patch' - | 'post' - | 'put' - | 'trace'; - -export type Client< - RequestFn = never, - Config = unknown, - MethodFn = never, - BuildUrlFn = never, - SseFn = never, -> = { - /** - * Returns the final request URL. - */ - buildUrl: BuildUrlFn; - getConfig: () => Config; - request: RequestFn; - setConfig: (config: Config) => Config; -} & { - [K in HttpMethod]: MethodFn; -} & ([SseFn] extends [never] - ? { sse?: never } - : { sse: { [K in HttpMethod]: SseFn } }); - -export interface Config { - /** - * Auth token or a function returning auth token. The resolved value will be - * added to the request payload as defined by its `security` array. - */ - auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; - /** - * A function for serializing request body parameter. By default, - * {@link JSON.stringify()} will be used. - */ - bodySerializer?: BodySerializer | null; - /** - * An object containing any HTTP headers that you want to pre-populate your - * `Headers` object with. - * - * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} - */ - headers?: - | RequestInit['headers'] - | Record< - string, - | string - | number - | boolean - | (string | number | boolean)[] - | null - | undefined - | unknown - >; - /** - * The request method. - * - * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} - */ - method?: Uppercase; - /** - * A function for serializing request query parameters. By default, arrays - * will be exploded in form style, objects will be exploded in deepObject - * style, and reserved characters are percent-encoded. - * - * This method will have no effect if the native `paramsSerializer()` Axios - * API function is used. - * - * {@link https://swagger.io/docs/specification/serialization/#query View examples} - */ - querySerializer?: QuerySerializer | QuerySerializerOptions; - /** - * A function validating request data. This is useful if you want to ensure - * the request conforms to the desired shape, so it can be safely sent to - * the server. - */ - requestValidator?: (data: unknown) => Promise; - /** - * A function transforming response data before it's returned. This is useful - * for post-processing data, e.g. converting ISO strings into Date objects. - */ - responseTransformer?: (data: unknown) => Promise; - /** - * A function validating response data. This is useful if you want to ensure - * the response conforms to the desired shape, so it can be safely passed to - * the transformers and returned to the user. - */ - responseValidator?: (data: unknown) => Promise; -} - -type IsExactlyNeverOrNeverUndefined = [T] extends [never] - ? true - : [T] extends [never | undefined] - ? [undefined] extends [T] - ? false - : true - : false; - -export type OmitNever> = { - [K in keyof T as IsExactlyNeverOrNeverUndefined extends true - ? never - : K]: T[K]; -}; diff --git a/hindsight-clients/typescript/generated/core/utils.gen.ts b/hindsight-clients/typescript/generated/core/utils.gen.ts deleted file mode 100644 index 0b5389d0..00000000 --- a/hindsight-clients/typescript/generated/core/utils.gen.ts +++ /dev/null @@ -1,143 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; -import { - type ArraySeparatorStyle, - serializeArrayParam, - serializeObjectParam, - serializePrimitiveParam, -} from './pathSerializer.gen'; - -export interface PathSerializer { - path: Record; - url: string; -} - -export const PATH_PARAM_RE = /\{[^{}]+\}/g; - -export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { - let url = _url; - const matches = _url.match(PATH_PARAM_RE); - if (matches) { - for (const match of matches) { - let explode = false; - let name = match.substring(1, match.length - 1); - let style: ArraySeparatorStyle = 'simple'; - - if (name.endsWith('*')) { - explode = true; - name = name.substring(0, name.length - 1); - } - - if (name.startsWith('.')) { - name = name.substring(1); - style = 'label'; - } else if (name.startsWith(';')) { - name = name.substring(1); - style = 'matrix'; - } - - const value = path[name]; - - if (value === undefined || value === null) { - continue; - } - - if (Array.isArray(value)) { - url = url.replace( - match, - serializeArrayParam({ explode, name, style, value }), - ); - continue; - } - - if (typeof value === 'object') { - url = url.replace( - match, - serializeObjectParam({ - explode, - name, - style, - value: value as Record, - valueOnly: true, - }), - ); - continue; - } - - if (style === 'matrix') { - url = url.replace( - match, - `;${serializePrimitiveParam({ - name, - value: value as string, - })}`, - ); - continue; - } - - const replaceValue = encodeURIComponent( - style === 'label' ? `.${value as string}` : (value as string), - ); - url = url.replace(match, replaceValue); - } - } - return url; -}; - -export const getUrl = ({ - baseUrl, - path, - query, - querySerializer, - url: _url, -}: { - baseUrl?: string; - path?: Record; - query?: Record; - querySerializer: QuerySerializer; - url: string; -}) => { - const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; - let url = (baseUrl ?? '') + pathUrl; - if (path) { - url = defaultPathSerializer({ path, url }); - } - let search = query ? querySerializer(query) : ''; - if (search.startsWith('?')) { - search = search.substring(1); - } - if (search) { - url += `?${search}`; - } - return url; -}; - -export function getValidRequestBody(options: { - body?: unknown; - bodySerializer?: BodySerializer | null; - serializedBody?: unknown; -}) { - const hasBody = options.body !== undefined; - const isSerializedBody = hasBody && options.bodySerializer; - - if (isSerializedBody) { - if ('serializedBody' in options) { - const hasSerializedBody = - options.serializedBody !== undefined && options.serializedBody !== ''; - - return hasSerializedBody ? options.serializedBody : null; - } - - // not all clients implement a serializedBody property (i.e. client-axios) - return options.body !== '' ? options.body : null; - } - - // plain/text body - if (hasBody) { - return options.body; - } - - // no body was provided - return undefined; -} diff --git a/hindsight-clients/typescript/generated/index.ts b/hindsight-clients/typescript/generated/index.ts deleted file mode 100644 index c352c104..00000000 --- a/hindsight-clients/typescript/generated/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type * from './types.gen'; -export * from './sdk.gen'; diff --git a/hindsight-clients/typescript/generated/sdk.gen.ts b/hindsight-clients/typescript/generated/sdk.gen.ts deleted file mode 100644 index 9c5689c5..00000000 --- a/hindsight-clients/typescript/generated/sdk.gen.ts +++ /dev/null @@ -1,273 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -import type { Client, Options as Options2, TDataShape } from './client'; -import { client } from './client.gen'; -import type { AddBankBackgroundData, AddBankBackgroundErrors, AddBankBackgroundResponses, CancelOperationData, CancelOperationErrors, CancelOperationResponses, ClearBankMemoriesData, ClearBankMemoriesErrors, ClearBankMemoriesResponses, CreateOrUpdateBankData, CreateOrUpdateBankErrors, CreateOrUpdateBankResponses, DeleteDocumentData, DeleteDocumentErrors, DeleteDocumentResponses, GetAgentStatsData, GetAgentStatsErrors, GetAgentStatsResponses, GetBankProfileData, GetBankProfileErrors, GetBankProfileResponses, GetChunkData, GetChunkErrors, GetChunkResponses, GetDocumentData, GetDocumentErrors, GetDocumentResponses, GetEntityData, GetEntityErrors, GetEntityResponses, GetGraphData, GetGraphErrors, GetGraphResponses, HealthEndpointHealthGetData, HealthEndpointHealthGetResponses, ListBanksData, ListBanksResponses, ListDocumentsData, ListDocumentsErrors, ListDocumentsResponses, ListEntitiesData, ListEntitiesErrors, ListEntitiesResponses, ListMemoriesData, ListMemoriesErrors, ListMemoriesResponses, ListOperationsData, ListOperationsErrors, ListOperationsResponses, MetricsEndpointMetricsGetData, MetricsEndpointMetricsGetResponses, RecallMemoriesData, RecallMemoriesErrors, RecallMemoriesResponses, ReflectData, ReflectErrors, ReflectResponses, RegenerateEntityObservationsData, RegenerateEntityObservationsErrors, RegenerateEntityObservationsResponses, RetainMemoriesData, RetainMemoriesErrors, RetainMemoriesResponses, UpdateBankDispositionData, UpdateBankDispositionErrors, UpdateBankDispositionResponses } from './types.gen'; - -export type Options = Options2 & { - /** - * You can provide a client instance returned by `createClient()` instead of - * individual options. This might be also useful if you want to implement a - * custom client. - */ - client?: Client; - /** - * You can pass arbitrary values through the `meta` object. This can be - * used to access values that aren't defined as part of the SDK function. - */ - meta?: Record; -}; - -/** - * Health check endpoint - * - * Checks the health of the API and database connection - */ -export const healthEndpointHealthGet = (options?: Options) => (options?.client ?? client).get({ url: '/health', ...options }); - -/** - * Prometheus metrics endpoint - * - * Exports metrics in Prometheus format for scraping - */ -export const metricsEndpointMetricsGet = (options?: Options) => (options?.client ?? client).get({ url: '/metrics', ...options }); - -/** - * Get memory graph data - * - * Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). Limited to 1000 most recent items. - */ -export const getGraph = (options: Options) => (options.client ?? client).get({ url: '/v1/default/banks/{bank_id}/graph', ...options }); - -/** - * List memory units - * - * 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). - */ -export const listMemories = (options: Options) => (options.client ?? client).get({ url: '/v1/default/banks/{bank_id}/memories/list', ...options }); - -/** - * Recall memory - * - * 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 - * - 'opinion': The bank's formed beliefs, perspectives, and viewpoints - * - * Set include_entities=true to get entity observations alongside recall results. - */ -export const recallMemories = (options: Options) => (options.client ?? client).post({ - url: '/v1/default/banks/{bank_id}/memories/recall', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -/** - * Reflect and generate answer - * - * 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. Extracts and stores any new opinions formed - * 6. Returns plain text answer, the facts used, and new opinions - */ -export const reflect = (options: Options) => (options.client ?? client).post({ - url: '/v1/default/banks/{bank_id}/reflect', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -/** - * List all memory banks - * - * Get a list of all agents with their profiles - */ -export const listBanks = (options?: Options) => (options?.client ?? client).get({ url: '/v1/default/banks', ...options }); - -/** - * Get statistics for memory bank - * - * Get statistics about nodes and links for a specific agent - */ -export const getAgentStats = (options: Options) => (options.client ?? client).get({ url: '/v1/default/banks/{bank_id}/stats', ...options }); - -/** - * List entities - * - * List all entities (people, organizations, etc.) known by the bank, ordered by mention count. - */ -export const listEntities = (options: Options) => (options.client ?? client).get({ url: '/v1/default/banks/{bank_id}/entities', ...options }); - -/** - * Get entity details - * - * Get detailed information about an entity including observations (mental model). - */ -export const getEntity = (options: Options) => (options.client ?? client).get({ url: '/v1/default/banks/{bank_id}/entities/{entity_id}', ...options }); - -/** - * Regenerate entity observations - * - * Regenerate observations for an entity based on all facts mentioning it. - */ -export const regenerateEntityObservations = (options: Options) => (options.client ?? client).post({ url: '/v1/default/banks/{bank_id}/entities/{entity_id}/regenerate', ...options }); - -/** - * List documents - * - * List documents with pagination and optional search. Documents are the source content from which memory units are extracted. - */ -export const listDocuments = (options: Options) => (options.client ?? client).get({ url: '/v1/default/banks/{bank_id}/documents', ...options }); - -/** - * Delete a document - * - * Delete a document and all its associated memory units and links. - * - * This will cascade delete: - * - The document itself - * - All memory units extracted from this document - * - All links (temporal, semantic, entity) associated with those memory units - * - * This operation cannot be undone. - */ -export const deleteDocument = (options: Options) => (options.client ?? client).delete({ url: '/v1/default/banks/{bank_id}/documents/{document_id}', ...options }); - -/** - * Get document details - * - * Get a specific document including its original text - */ -export const getDocument = (options: Options) => (options.client ?? client).get({ url: '/v1/default/banks/{bank_id}/documents/{document_id}', ...options }); - -/** - * Get chunk details - * - * Get a specific chunk by its ID - */ -export const getChunk = (options: Options) => (options.client ?? client).get({ url: '/v1/default/chunks/{chunk_id}', ...options }); - -/** - * List async operations - * - * Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations - */ -export const listOperations = (options: Options) => (options.client ?? client).get({ url: '/v1/default/banks/{bank_id}/operations', ...options }); - -/** - * Cancel a pending async operation - * - * Cancel a pending async operation by removing it from the queue - */ -export const cancelOperation = (options: Options) => (options.client ?? client).delete({ url: '/v1/default/banks/{bank_id}/operations/{operation_id}', ...options }); - -/** - * Get memory bank profile - * - * Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists. - */ -export const getBankProfile = (options: Options) => (options.client ?? client).get({ url: '/v1/default/banks/{bank_id}/profile', ...options }); - -/** - * Update memory bank disposition - * - * Update bank's disposition traits (skepticism, literalism, empathy) - */ -export const updateBankDisposition = (options: Options) => (options.client ?? client).put({ - url: '/v1/default/banks/{bank_id}/profile', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -/** - * Add/merge memory bank background - * - * Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits. - */ -export const addBankBackground = (options: Options) => (options.client ?? client).post({ - url: '/v1/default/banks/{bank_id}/background', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -/** - * Create or update memory bank - * - * Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults. - */ -export const createOrUpdateBank = (options: Options) => (options.client ?? client).put({ - url: '/v1/default/banks/{bank_id}', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); - -/** - * 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. - */ -export const clearBankMemories = (options: Options) => (options.client ?? client).delete({ url: '/v1/default/banks/{bank_id}/memories', ...options }); - -/** - * Retain memories - * - * 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 on items) - * - 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 the task - * - Processing happens in the background - * - Use the operations endpoint to monitor progress - * - * When async=false (default): - * - Waits for processing to complete - * - Returns after all memories are stored - * - * 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). Items with the same document_id are grouped together for efficient processing. - */ -export const retainMemories = (options: Options) => (options.client ?? client).post({ - url: '/v1/default/banks/{bank_id}/memories', - ...options, - headers: { - 'Content-Type': 'application/json', - ...options.headers - } -}); diff --git a/hindsight-clients/typescript/generated/types.gen.ts b/hindsight-clients/typescript/generated/types.gen.ts deleted file mode 100644 index 9464e7c0..00000000 --- a/hindsight-clients/typescript/generated/types.gen.ts +++ /dev/null @@ -1,1535 +0,0 @@ -// This file is auto-generated by @hey-api/openapi-ts - -export type ClientOptions = { - baseUrl: `${string}://${string}` | (string & {}); -}; - -/** - * AddBackgroundRequest - * - * Request model for adding/merging background information. - */ -export type AddBackgroundRequest = { - /** - * Content - * - * New background information to add or merge - */ - content: string; - /** - * Update Disposition - * - * If true, infer disposition traits from the merged background (default: true) - */ - update_disposition?: boolean; -}; - -/** - * BackgroundResponse - * - * Response model for background update. - */ -export type BackgroundResponse = { - /** - * Background - */ - background: string; - disposition?: DispositionTraits | null; -}; - -/** - * BankListItem - * - * Bank list item with profile summary. - */ -export type BankListItem = { - /** - * Bank Id - */ - bank_id: string; - /** - * Name - */ - name: string; - disposition: DispositionTraits; - /** - * Background - */ - background: string; - /** - * Created At - */ - created_at?: string | null; - /** - * Updated At - */ - updated_at?: string | null; -}; - -/** - * BankListResponse - * - * Response model for listing all banks. - */ -export type BankListResponse = { - /** - * Banks - */ - banks: Array; -}; - -/** - * BankProfileResponse - * - * Response model for bank profile. - */ -export type BankProfileResponse = { - /** - * Bank Id - */ - bank_id: string; - /** - * Name - */ - name: string; - disposition: DispositionTraits; - /** - * Background - */ - background: string; -}; - -/** - * Budget - * - * Budget levels for recall/reflect operations. - */ -export type Budget = 'low' | 'mid' | 'high'; - -/** - * ChunkData - * - * Chunk data for a single chunk. - */ -export type ChunkData = { - /** - * Id - */ - id: string; - /** - * Text - */ - text: string; - /** - * Chunk Index - */ - chunk_index: number; - /** - * Truncated - * - * Whether the chunk text was truncated due to token limits - */ - truncated?: boolean; -}; - -/** - * ChunkIncludeOptions - * - * Options for including chunks in recall results. - */ -export type ChunkIncludeOptions = { - /** - * Max Tokens - * - * Maximum tokens for chunks (chunks may be truncated) - */ - max_tokens?: number; -}; - -/** - * ChunkResponse - * - * Response model for get chunk endpoint. - */ -export type ChunkResponse = { - /** - * Chunk Id - */ - chunk_id: string; - /** - * Document Id - */ - document_id: string; - /** - * Bank Id - */ - bank_id: string; - /** - * Chunk Index - */ - chunk_index: number; - /** - * Chunk Text - */ - chunk_text: string; - /** - * Created At - */ - created_at: string; -}; - -/** - * CreateBankRequest - * - * Request model for creating/updating a bank. - */ -export type CreateBankRequest = { - /** - * Name - */ - name?: string | null; - disposition?: DispositionTraits | null; - /** - * Background - */ - background?: string | null; -}; - -/** - * DeleteResponse - * - * Response model for delete operations. - */ -export type DeleteResponse = { - /** - * Success - */ - success: boolean; -}; - -/** - * DispositionTraits - * - * Disposition traits that influence how memories are formed and interpreted. - */ -export type DispositionTraits = { - /** - * Skepticism - * - * How skeptical vs trusting (1=trusting, 5=skeptical) - */ - skepticism: number; - /** - * Literalism - * - * How literally to interpret information (1=flexible, 5=literal) - */ - literalism: number; - /** - * Empathy - * - * How much to consider emotional context (1=detached, 5=empathetic) - */ - empathy: number; -}; - -/** - * DocumentResponse - * - * Response model for get document endpoint. - */ -export type DocumentResponse = { - /** - * Id - */ - id: string; - /** - * Bank Id - */ - bank_id: string; - /** - * Original Text - */ - original_text: string; - /** - * Content Hash - */ - content_hash: string | null; - /** - * Created At - */ - created_at: string; - /** - * Updated At - */ - updated_at: string; - /** - * Memory Unit Count - */ - memory_unit_count: number; -}; - -/** - * EntityDetailResponse - * - * Response model for entity detail endpoint. - */ -export type EntityDetailResponse = { - /** - * Id - */ - id: string; - /** - * Canonical Name - */ - canonical_name: string; - /** - * Mention Count - */ - mention_count: number; - /** - * First Seen - */ - first_seen?: string | null; - /** - * Last Seen - */ - last_seen?: string | null; - /** - * Metadata - */ - metadata?: { - [key: string]: unknown; - } | null; - /** - * Observations - */ - observations: Array; -}; - -/** - * EntityIncludeOptions - * - * Options for including entity observations in recall results. - */ -export type EntityIncludeOptions = { - /** - * Max Tokens - * - * Maximum tokens for entity observations - */ - max_tokens?: number; -}; - -/** - * EntityListItem - * - * Entity list item with summary. - */ -export type EntityListItem = { - /** - * Id - */ - id: string; - /** - * Canonical Name - */ - canonical_name: string; - /** - * Mention Count - */ - mention_count: number; - /** - * First Seen - */ - first_seen?: string | null; - /** - * Last Seen - */ - last_seen?: string | null; - /** - * Metadata - */ - metadata?: { - [key: string]: unknown; - } | null; -}; - -/** - * EntityListResponse - * - * Response model for entity list endpoint. - */ -export type EntityListResponse = { - /** - * Items - */ - items: Array; -}; - -/** - * EntityObservationResponse - * - * An observation about an entity. - */ -export type EntityObservationResponse = { - /** - * Text - */ - text: string; - /** - * Mentioned At - */ - mentioned_at?: string | null; -}; - -/** - * EntityStateResponse - * - * Current mental model of an entity. - */ -export type EntityStateResponse = { - /** - * Entity Id - */ - entity_id: string; - /** - * Canonical Name - */ - canonical_name: string; - /** - * Observations - */ - observations: Array; -}; - -/** - * FactsIncludeOptions - * - * Options for including facts (based_on) in reflect results. - */ -export type FactsIncludeOptions = { - [key: string]: unknown; -}; - -/** - * GraphDataResponse - * - * Response model for graph data endpoint. - */ -export type GraphDataResponse = { - /** - * Nodes - */ - nodes: Array<{ - [key: string]: unknown; - }>; - /** - * Edges - */ - edges: Array<{ - [key: string]: unknown; - }>; - /** - * Table Rows - */ - table_rows: Array<{ - [key: string]: unknown; - }>; - /** - * Total Units - */ - total_units: number; -}; - -/** - * HTTPValidationError - */ -export type HttpValidationError = { - /** - * Detail - */ - detail?: Array; -}; - -/** - * IncludeOptions - * - * Options for including additional data in recall results. - */ -export type IncludeOptions = { - /** - * Include entity observations. Set to null to disable entity inclusion. - */ - entities?: EntityIncludeOptions | null; - /** - * Include raw chunks. Set to {} to enable, null to disable (default: disabled). - */ - chunks?: ChunkIncludeOptions | null; -}; - -/** - * ListDocumentsResponse - * - * Response model for list documents endpoint. - */ -export type ListDocumentsResponse = { - /** - * Items - */ - items: Array<{ - [key: string]: unknown; - }>; - /** - * Total - */ - total: number; - /** - * Limit - */ - limit: number; - /** - * Offset - */ - offset: number; -}; - -/** - * ListMemoryUnitsResponse - * - * Response model for list memory units endpoint. - */ -export type ListMemoryUnitsResponse = { - /** - * Items - */ - items: Array<{ - [key: string]: unknown; - }>; - /** - * Total - */ - total: number; - /** - * Limit - */ - limit: number; - /** - * Offset - */ - offset: number; -}; - -/** - * MemoryItem - * - * Single memory item for retain. - */ -export type MemoryItem = { - /** - * Content - */ - content: string; - /** - * Timestamp - */ - timestamp?: string | null; - /** - * Context - */ - context?: string | null; - /** - * Metadata - */ - metadata?: { - [key: string]: string; - } | null; - /** - * Document Id - * - * Optional document ID for this memory item. - */ - document_id?: string | null; -}; - -/** - * RecallRequest - * - * Request model for recall endpoint. - */ -export type RecallRequest = { - /** - * Query - */ - query: string; - /** - * Types - * - * List of fact types to recall (defaults to all if not specified) - */ - types?: Array | null; - budget?: Budget; - /** - * Max Tokens - */ - max_tokens?: number; - /** - * Trace - */ - trace?: boolean; - /** - * Query Timestamp - * - * ISO format date string (e.g., '2023-05-30T23:40:00') - */ - query_timestamp?: string | null; - /** - * Options for including additional data (entities are included by default) - */ - include?: IncludeOptions; -}; - -/** - * RecallResponse - * - * Response model for recall endpoints. - */ -export type RecallResponse = { - /** - * Results - */ - results: Array; - /** - * Trace - */ - trace?: { - [key: string]: unknown; - } | null; - /** - * Entities - * - * Entity states for entities mentioned in results - */ - entities?: { - [key: string]: EntityStateResponse; - } | null; - /** - * Chunks - * - * Chunks for facts, keyed by chunk_id - */ - chunks?: { - [key: string]: ChunkData; - } | null; -}; - -/** - * RecallResult - * - * Single recall result item. - */ -export type RecallResult = { - /** - * Id - */ - id: string; - /** - * Text - */ - text: string; - /** - * Type - */ - type?: string | null; - /** - * Entities - */ - entities?: Array | null; - /** - * Context - */ - context?: string | null; - /** - * Occurred Start - */ - occurred_start?: string | null; - /** - * Occurred End - */ - occurred_end?: string | null; - /** - * Mentioned At - */ - mentioned_at?: string | null; - /** - * Document Id - */ - document_id?: string | null; - /** - * Metadata - */ - metadata?: { - [key: string]: string; - } | null; - /** - * Chunk Id - */ - chunk_id?: string | null; -}; - -/** - * ReflectFact - * - * A fact used in think response. - */ -export type ReflectFact = { - /** - * Id - */ - id?: string | null; - /** - * Text - */ - text: string; - /** - * Type - */ - type?: string | null; - /** - * Context - */ - context?: string | null; - /** - * Occurred Start - */ - occurred_start?: string | null; - /** - * Occurred End - */ - occurred_end?: string | null; -}; - -/** - * ReflectIncludeOptions - * - * Options for including additional data in reflect results. - */ -export type ReflectIncludeOptions = { - /** - * Include facts that the answer is based on. Set to {} to enable, null to disable (default: disabled). - */ - facts?: FactsIncludeOptions | null; -}; - -/** - * ReflectRequest - * - * Request model for reflect endpoint. - */ -export type ReflectRequest = { - /** - * Query - */ - query: string; - budget?: Budget; - /** - * Context - */ - context?: string | null; - /** - * Options for including additional data (disabled by default) - */ - include?: ReflectIncludeOptions; -}; - -/** - * ReflectResponse - * - * Response model for think endpoint. - */ -export type ReflectResponse = { - /** - * Text - */ - text: string; - /** - * Based On - */ - based_on?: Array; -}; - -/** - * RetainRequest - * - * Request model for retain endpoint. - */ -export type RetainRequest = { - /** - * Items - */ - items: Array; - /** - * Async - * - * If true, process asynchronously in background. If false, wait for completion (default: false) - */ - async?: boolean; -}; - -/** - * RetainResponse - * - * Response model for retain endpoint. - */ -export type RetainResponse = { - /** - * Success - */ - success: boolean; - /** - * Bank Id - */ - bank_id: string; - /** - * Items Count - */ - items_count: number; - /** - * Async - * - * Whether the operation was processed asynchronously - */ - async: boolean; -}; - -/** - * UpdateDispositionRequest - * - * Request model for updating disposition traits. - */ -export type UpdateDispositionRequest = { - disposition: DispositionTraits; -}; - -/** - * ValidationError - */ -export type ValidationError = { - /** - * Location - */ - loc: Array; - /** - * Message - */ - msg: string; - /** - * Error Type - */ - type: string; -}; - -export type HealthEndpointHealthGetData = { - body?: never; - path?: never; - query?: never; - url: '/health'; -}; - -export type HealthEndpointHealthGetResponses = { - /** - * Successful Response - */ - 200: unknown; -}; - -export type MetricsEndpointMetricsGetData = { - body?: never; - path?: never; - query?: never; - url: '/metrics'; -}; - -export type MetricsEndpointMetricsGetResponses = { - /** - * Successful Response - */ - 200: unknown; -}; - -export type GetGraphData = { - body?: never; - path: { - /** - * Bank Id - */ - bank_id: string; - }; - query?: { - /** - * Type - */ - type?: string | null; - }; - url: '/v1/default/banks/{bank_id}/graph'; -}; - -export type GetGraphErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GetGraphError = GetGraphErrors[keyof GetGraphErrors]; - -export type GetGraphResponses = { - /** - * Successful Response - */ - 200: GraphDataResponse; -}; - -export type GetGraphResponse = GetGraphResponses[keyof GetGraphResponses]; - -export type ListMemoriesData = { - body?: never; - path: { - /** - * Bank Id - */ - bank_id: string; - }; - query?: { - /** - * Type - */ - type?: string | null; - /** - * Q - */ - q?: string | null; - /** - * Limit - */ - limit?: number; - /** - * Offset - */ - offset?: number; - }; - url: '/v1/default/banks/{bank_id}/memories/list'; -}; - -export type ListMemoriesErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ListMemoriesError = ListMemoriesErrors[keyof ListMemoriesErrors]; - -export type ListMemoriesResponses = { - /** - * Successful Response - */ - 200: ListMemoryUnitsResponse; -}; - -export type ListMemoriesResponse = ListMemoriesResponses[keyof ListMemoriesResponses]; - -export type RecallMemoriesData = { - body: RecallRequest; - path: { - /** - * Bank Id - */ - bank_id: string; - }; - query?: never; - url: '/v1/default/banks/{bank_id}/memories/recall'; -}; - -export type RecallMemoriesErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type RecallMemoriesError = RecallMemoriesErrors[keyof RecallMemoriesErrors]; - -export type RecallMemoriesResponses = { - /** - * Successful Response - */ - 200: RecallResponse; -}; - -export type RecallMemoriesResponse = RecallMemoriesResponses[keyof RecallMemoriesResponses]; - -export type ReflectData = { - body: ReflectRequest; - path: { - /** - * Bank Id - */ - bank_id: string; - }; - query?: never; - url: '/v1/default/banks/{bank_id}/reflect'; -}; - -export type ReflectErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ReflectError = ReflectErrors[keyof ReflectErrors]; - -export type ReflectResponses = { - /** - * Successful Response - */ - 200: ReflectResponse; -}; - -export type ReflectResponse2 = ReflectResponses[keyof ReflectResponses]; - -export type ListBanksData = { - body?: never; - path?: never; - query?: never; - url: '/v1/default/banks'; -}; - -export type ListBanksResponses = { - /** - * Successful Response - */ - 200: BankListResponse; -}; - -export type ListBanksResponse = ListBanksResponses[keyof ListBanksResponses]; - -export type GetAgentStatsData = { - body?: never; - path: { - /** - * Bank Id - */ - bank_id: string; - }; - query?: never; - url: '/v1/default/banks/{bank_id}/stats'; -}; - -export type GetAgentStatsErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GetAgentStatsError = GetAgentStatsErrors[keyof GetAgentStatsErrors]; - -export type GetAgentStatsResponses = { - /** - * Successful Response - */ - 200: unknown; -}; - -export type ListEntitiesData = { - body?: never; - path: { - /** - * Bank Id - */ - bank_id: string; - }; - query?: { - /** - * Limit - * - * Maximum number of entities to return - */ - limit?: number; - }; - url: '/v1/default/banks/{bank_id}/entities'; -}; - -export type ListEntitiesErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ListEntitiesError = ListEntitiesErrors[keyof ListEntitiesErrors]; - -export type ListEntitiesResponses = { - /** - * Successful Response - */ - 200: EntityListResponse; -}; - -export type ListEntitiesResponse = ListEntitiesResponses[keyof ListEntitiesResponses]; - -export type GetEntityData = { - body?: never; - path: { - /** - * Bank Id - */ - bank_id: string; - /** - * Entity Id - */ - entity_id: string; - }; - query?: never; - url: '/v1/default/banks/{bank_id}/entities/{entity_id}'; -}; - -export type GetEntityErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GetEntityError = GetEntityErrors[keyof GetEntityErrors]; - -export type GetEntityResponses = { - /** - * Successful Response - */ - 200: EntityDetailResponse; -}; - -export type GetEntityResponse = GetEntityResponses[keyof GetEntityResponses]; - -export type RegenerateEntityObservationsData = { - body?: never; - path: { - /** - * Bank Id - */ - bank_id: string; - /** - * Entity Id - */ - entity_id: string; - }; - query?: never; - url: '/v1/default/banks/{bank_id}/entities/{entity_id}/regenerate'; -}; - -export type RegenerateEntityObservationsErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type RegenerateEntityObservationsError = RegenerateEntityObservationsErrors[keyof RegenerateEntityObservationsErrors]; - -export type RegenerateEntityObservationsResponses = { - /** - * Successful Response - */ - 200: EntityDetailResponse; -}; - -export type RegenerateEntityObservationsResponse = RegenerateEntityObservationsResponses[keyof RegenerateEntityObservationsResponses]; - -export type ListDocumentsData = { - body?: never; - path: { - /** - * Bank Id - */ - bank_id: string; - }; - query?: { - /** - * Q - */ - q?: string | null; - /** - * Limit - */ - limit?: number; - /** - * Offset - */ - offset?: number; - }; - url: '/v1/default/banks/{bank_id}/documents'; -}; - -export type ListDocumentsErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ListDocumentsError = ListDocumentsErrors[keyof ListDocumentsErrors]; - -export type ListDocumentsResponses = { - /** - * Successful Response - */ - 200: ListDocumentsResponse; -}; - -export type ListDocumentsResponse2 = ListDocumentsResponses[keyof ListDocumentsResponses]; - -export type DeleteDocumentData = { - body?: never; - path: { - /** - * Bank Id - */ - bank_id: string; - /** - * Document Id - */ - document_id: string; - }; - query?: never; - url: '/v1/default/banks/{bank_id}/documents/{document_id}'; -}; - -export type DeleteDocumentErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type DeleteDocumentError = DeleteDocumentErrors[keyof DeleteDocumentErrors]; - -export type DeleteDocumentResponses = { - /** - * Successful Response - */ - 200: unknown; -}; - -export type GetDocumentData = { - body?: never; - path: { - /** - * Bank Id - */ - bank_id: string; - /** - * Document Id - */ - document_id: string; - }; - query?: never; - url: '/v1/default/banks/{bank_id}/documents/{document_id}'; -}; - -export type GetDocumentErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GetDocumentError = GetDocumentErrors[keyof GetDocumentErrors]; - -export type GetDocumentResponses = { - /** - * Successful Response - */ - 200: DocumentResponse; -}; - -export type GetDocumentResponse = GetDocumentResponses[keyof GetDocumentResponses]; - -export type GetChunkData = { - body?: never; - path: { - /** - * Chunk Id - */ - chunk_id: string; - }; - query?: never; - url: '/v1/default/chunks/{chunk_id}'; -}; - -export type GetChunkErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GetChunkError = GetChunkErrors[keyof GetChunkErrors]; - -export type GetChunkResponses = { - /** - * Successful Response - */ - 200: ChunkResponse; -}; - -export type GetChunkResponse = GetChunkResponses[keyof GetChunkResponses]; - -export type ListOperationsData = { - body?: never; - path: { - /** - * Bank Id - */ - bank_id: string; - }; - query?: never; - url: '/v1/default/banks/{bank_id}/operations'; -}; - -export type ListOperationsErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ListOperationsError = ListOperationsErrors[keyof ListOperationsErrors]; - -export type ListOperationsResponses = { - /** - * Successful Response - */ - 200: unknown; -}; - -export type CancelOperationData = { - body?: never; - path: { - /** - * Bank Id - */ - bank_id: string; - /** - * Operation Id - */ - operation_id: string; - }; - query?: never; - url: '/v1/default/banks/{bank_id}/operations/{operation_id}'; -}; - -export type CancelOperationErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type CancelOperationError = CancelOperationErrors[keyof CancelOperationErrors]; - -export type CancelOperationResponses = { - /** - * Successful Response - */ - 200: unknown; -}; - -export type GetBankProfileData = { - body?: never; - path: { - /** - * Bank Id - */ - bank_id: string; - }; - query?: never; - url: '/v1/default/banks/{bank_id}/profile'; -}; - -export type GetBankProfileErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type GetBankProfileError = GetBankProfileErrors[keyof GetBankProfileErrors]; - -export type GetBankProfileResponses = { - /** - * Successful Response - */ - 200: BankProfileResponse; -}; - -export type GetBankProfileResponse = GetBankProfileResponses[keyof GetBankProfileResponses]; - -export type UpdateBankDispositionData = { - body: UpdateDispositionRequest; - path: { - /** - * Bank Id - */ - bank_id: string; - }; - query?: never; - url: '/v1/default/banks/{bank_id}/profile'; -}; - -export type UpdateBankDispositionErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type UpdateBankDispositionError = UpdateBankDispositionErrors[keyof UpdateBankDispositionErrors]; - -export type UpdateBankDispositionResponses = { - /** - * Successful Response - */ - 200: BankProfileResponse; -}; - -export type UpdateBankDispositionResponse = UpdateBankDispositionResponses[keyof UpdateBankDispositionResponses]; - -export type AddBankBackgroundData = { - body: AddBackgroundRequest; - path: { - /** - * Bank Id - */ - bank_id: string; - }; - query?: never; - url: '/v1/default/banks/{bank_id}/background'; -}; - -export type AddBankBackgroundErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type AddBankBackgroundError = AddBankBackgroundErrors[keyof AddBankBackgroundErrors]; - -export type AddBankBackgroundResponses = { - /** - * Successful Response - */ - 200: BackgroundResponse; -}; - -export type AddBankBackgroundResponse = AddBankBackgroundResponses[keyof AddBankBackgroundResponses]; - -export type CreateOrUpdateBankData = { - body: CreateBankRequest; - path: { - /** - * Bank Id - */ - bank_id: string; - }; - query?: never; - url: '/v1/default/banks/{bank_id}'; -}; - -export type CreateOrUpdateBankErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type CreateOrUpdateBankError = CreateOrUpdateBankErrors[keyof CreateOrUpdateBankErrors]; - -export type CreateOrUpdateBankResponses = { - /** - * Successful Response - */ - 200: BankProfileResponse; -}; - -export type CreateOrUpdateBankResponse = CreateOrUpdateBankResponses[keyof CreateOrUpdateBankResponses]; - -export type ClearBankMemoriesData = { - body?: never; - path: { - /** - * Bank Id - */ - bank_id: string; - }; - query?: { - /** - * Type - * - * Optional fact type filter (world, experience, opinion) - */ - type?: string | null; - }; - url: '/v1/default/banks/{bank_id}/memories'; -}; - -export type ClearBankMemoriesErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type ClearBankMemoriesError = ClearBankMemoriesErrors[keyof ClearBankMemoriesErrors]; - -export type ClearBankMemoriesResponses = { - /** - * Successful Response - */ - 200: DeleteResponse; -}; - -export type ClearBankMemoriesResponse = ClearBankMemoriesResponses[keyof ClearBankMemoriesResponses]; - -export type RetainMemoriesData = { - body: RetainRequest; - path: { - /** - * Bank Id - */ - bank_id: string; - }; - query?: never; - url: '/v1/default/banks/{bank_id}/memories'; -}; - -export type RetainMemoriesErrors = { - /** - * Validation Error - */ - 422: HttpValidationError; -}; - -export type RetainMemoriesError = RetainMemoriesErrors[keyof RetainMemoriesErrors]; - -export type RetainMemoriesResponses = { - /** - * Successful Response - */ - 200: RetainResponse; -}; - -export type RetainMemoriesResponse = RetainMemoriesResponses[keyof RetainMemoriesResponses]; diff --git a/hindsight-clients/typescript/jest.config.js b/hindsight-clients/typescript/jest.config.js deleted file mode 100644 index e9aae195..00000000 --- a/hindsight-clients/typescript/jest.config.js +++ /dev/null @@ -1,10 +0,0 @@ -module.exports = { - preset: 'ts-jest', - testEnvironment: 'node', - testMatch: ['**/tests/**/*.test.ts'], - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - transform: { - '^.+\\.tsx?$': 'ts-jest', - }, - testTimeout: 60000, -}; diff --git a/hindsight-clients/typescript/openapi-ts.config.ts b/hindsight-clients/typescript/openapi-ts.config.ts deleted file mode 100644 index f5836685..00000000 --- a/hindsight-clients/typescript/openapi-ts.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { defineConfig } from '@hey-api/openapi-ts'; - -export default defineConfig({ - client: '@hey-api/client-fetch', - input: '../../openapi.json', - output: { - path: './generated', - format: 'prettier', - }, - plugins: [ - '@hey-api/typescript', - '@hey-api/sdk', - ], -}); diff --git a/hindsight-clients/typescript/package.json b/hindsight-clients/typescript/package.json deleted file mode 100644 index 1aff7120..00000000 --- a/hindsight-clients/typescript/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@vectorize-io/hindsight-client", - "version": "0.1.13", - "description": "TypeScript client for Hindsight - Semantic memory system with personality-driven thinking", - "main": "./dist/src/index.js", - "types": "./dist/src/index.d.ts", - "scripts": { - "build": "tsc", - "clean": "rm -rf dist", - "generate": "npx @hey-api/openapi-ts", - "prepublishOnly": "npm run build", - "test": "jest" - }, - "keywords": [ - "hindsight", - "memory", - "api", - "client", - "typescript" - ], - "author": "Hindsight Team", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/nicoloboschi/hindsight.git", - "directory": "hindsight-clients/typescript" - }, - "devDependencies": { - "@hey-api/openapi-ts": "^0.88.0", - "@types/jest": "^29.0.0", - "@types/node": "^20.0.0", - "jest": "^29.0.0", - "ts-jest": "^29.0.0", - "typescript": "^5.0.0" - }, - "files": [ - "dist", - "src", - "generated", - "README.md" - ] -} diff --git a/hindsight-clients/typescript/src/index.ts b/hindsight-clients/typescript/src/index.ts deleted file mode 100644 index 4c4cf061..00000000 --- a/hindsight-clients/typescript/src/index.ts +++ /dev/null @@ -1,274 +0,0 @@ -/** - * Hindsight Client - Clean, TypeScript SDK for the Hindsight API. - * - * Example: - * ```typescript - * import { HindsightClient } from '@vectorize-io/hindsight-client'; - * - * // Without authentication - * const client = new HindsightClient({ baseUrl: 'http://localhost:8888' }); - * - * // With API key authentication - * const client = new HindsightClient({ - * baseUrl: 'http://localhost:8888', - * apiKey: 'your-api-key' - * }); - * - * // Retain a memory - * await client.retain('alice', 'Alice loves AI'); - * - * // Recall memories - * const results = await client.recall('alice', 'What does Alice like?'); - * - * // Generate contextual answer - * const answer = await client.reflect('alice', 'What are my interests?'); - * ``` - */ - -import { createClient, createConfig } from '../generated/client'; -import type { Client } from '../generated/client'; -import * as sdk from '../generated/sdk.gen'; -import type { - RetainRequest, - RetainResponse, - RecallRequest, - RecallResponse, - RecallResult, - ReflectRequest, - ReflectResponse, - ListMemoryUnitsResponse, - BankProfileResponse, - CreateBankRequest, - Budget, -} from '../generated/types.gen'; - -export interface HindsightClientOptions { - baseUrl: string; - /** - * Optional API key for authentication (sent as Bearer token in Authorization header) - */ - apiKey?: string; -} - -export interface MemoryItemInput { - content: string; - timestamp?: string | Date; - context?: string; - metadata?: Record; - document_id?: string; -} - -export class HindsightClient { - private client: Client; - - constructor(options: HindsightClientOptions) { - this.client = createClient( - createConfig({ - baseUrl: options.baseUrl, - headers: options.apiKey - ? { Authorization: `Bearer ${options.apiKey}` } - : undefined, - }) - ); - } - - /** - * Retain a single memory for a bank. - */ - async retain( - bankId: string, - content: string, - options?: { timestamp?: Date | string; context?: string; metadata?: Record; async?: boolean } - ): Promise { - const item: { content: string; timestamp?: string; context?: string; metadata?: Record } = { content }; - if (options?.timestamp) { - item.timestamp = - options.timestamp instanceof Date - ? options.timestamp.toISOString() - : options.timestamp; - } - if (options?.context) { - item.context = options.context; - } - if (options?.metadata) { - item.metadata = options.metadata; - } - - const response = await sdk.retainMemories({ - client: this.client, - path: { bank_id: bankId }, - body: { items: [item], async: options?.async }, - }); - - return response.data!; - } - - /** - * Retain multiple memories in batch. - */ - async retainBatch(bankId: string, items: MemoryItemInput[], options?: { documentId?: string; async?: boolean }): Promise { - const processedItems = items.map((item) => ({ - content: item.content, - context: item.context, - metadata: item.metadata, - document_id: item.document_id, - timestamp: - item.timestamp instanceof Date - ? item.timestamp.toISOString() - : item.timestamp, - })); - - // If documentId is provided at the batch level, add it to all items that don't have one - const itemsWithDocId = processedItems.map(item => ({ - ...item, - document_id: item.document_id || options?.documentId - })); - - const response = await sdk.retainMemories({ - client: this.client, - path: { bank_id: bankId }, - body: { - items: itemsWithDocId, - async: options?.async, - }, - }); - - return response.data!; - } - - /** - * Recall memories with a natural language query. - */ - async recall( - bankId: string, - query: string, - options?: { - types?: string[]; - maxTokens?: number; - budget?: Budget; - trace?: boolean; - queryTimestamp?: string; - includeEntities?: boolean; - maxEntityTokens?: number; - includeChunks?: boolean; - maxChunkTokens?: number; - } - ): Promise { - const response = await sdk.recallMemories({ - client: this.client, - path: { bank_id: bankId }, - body: { - query, - types: options?.types, - max_tokens: options?.maxTokens, - budget: options?.budget || 'mid', - trace: options?.trace, - query_timestamp: options?.queryTimestamp, - include: { - entities: options?.includeEntities ? { max_tokens: options?.maxEntityTokens ?? 500 } : undefined, - chunks: options?.includeChunks ? { max_tokens: options?.maxChunkTokens ?? 8192 } : undefined, - }, - }, - }); - - if (!response.data) { - throw new Error(`API returned no data: ${JSON.stringify(response.error || 'Unknown error')}`); - } - - return response.data; - } - - /** - * Reflect and generate a contextual answer using the bank's identity and memories. - */ - async reflect( - bankId: string, - query: string, - options?: { context?: string; budget?: Budget } - ): Promise { - const response = await sdk.reflect({ - client: this.client, - path: { bank_id: bankId }, - body: { - query, - context: options?.context, - budget: options?.budget || 'low', - }, - }); - - return response.data!; - } - - /** - * List memories with pagination. - */ - async listMemories( - bankId: string, - options?: { limit?: number; offset?: number; type?: string; q?: string } - ): Promise { - const response = await sdk.listMemories({ - client: this.client, - path: { bank_id: bankId }, - query: { - limit: options?.limit, - offset: options?.offset, - type: options?.type, - q: options?.q, - }, - }); - - return response.data!; - } - - /** - * Create or update a bank with disposition and background. - */ - async createBank( - bankId: string, - options: { name?: string; background?: string; disposition?: any } - ): Promise { - const response = await sdk.createOrUpdateBank({ - client: this.client, - path: { bank_id: bankId }, - body: { - name: options.name, - background: options.background, - disposition: options.disposition, - }, - }); - - return response.data!; - } - - /** - * Get a bank's profile. - */ - async getBankProfile(bankId: string): Promise { - const response = await sdk.getBankProfile({ - client: this.client, - path: { bank_id: bankId }, - }); - - return response.data!; - } -} - -// Re-export types for convenience -export type { - RetainRequest, - RetainResponse, - RecallRequest, - RecallResponse, - RecallResult, - ReflectRequest, - ReflectResponse, - ListMemoryUnitsResponse, - BankProfileResponse, - CreateBankRequest, - Budget, -}; - -// Also export low-level SDK functions for advanced usage -export * as sdk from '../generated/sdk.gen'; -export { createClient, createConfig } from '../generated/client'; -export type { Client } from '../generated/client'; diff --git a/hindsight-clients/typescript/tests/main_operations.test.ts b/hindsight-clients/typescript/tests/main_operations.test.ts deleted file mode 100644 index 35a23b89..00000000 --- a/hindsight-clients/typescript/tests/main_operations.test.ts +++ /dev/null @@ -1,218 +0,0 @@ -/** - * Tests for Hindsight TypeScript client. - * - * These tests require a running Hindsight API server. - */ - -import { HindsightClient } from '../src'; - -// Test configuration -const HINDSIGHT_API_URL = process.env.HINDSIGHT_API_URL || 'http://localhost:8888'; - -let client: HindsightClient; - -beforeAll(() => { - client = new HindsightClient({ baseUrl: HINDSIGHT_API_URL }); -}); - -function randomBankId(): string { - return `test_bank_${Math.random().toString(36).slice(2, 14)}`; -} - -describe('TestRetain', () => { - test('retain single memory', async () => { - const bankId = randomBankId(); - const response = await client.retain( - bankId, - 'Alice loves artificial intelligence and machine learning' - ); - - expect(response).not.toBeNull(); - expect(response.success).toBe(true); - }); - - test('retain memory with context', async () => { - const bankId = randomBankId(); - const response = await client.retain(bankId, 'Bob went hiking in the mountains', { - timestamp: new Date('2024-01-15T10:30:00'), - context: 'outdoor activities', - }); - - expect(response).not.toBeNull(); - expect(response.success).toBe(true); - }); - - test('retain batch memories', async () => { - const bankId = randomBankId(); - const response = await client.retainBatch(bankId, [ - { content: 'Charlie enjoys reading science fiction books' }, - { content: 'Diana is learning to play the guitar', context: 'hobbies' }, - { content: 'Eve completed a marathon last month', timestamp: '2024-10-15' }, - ]); - - expect(response).not.toBeNull(); - expect(response.success).toBe(true); - expect(response.items_count).toBe(3); - }); -}); - -describe('TestRecall', () => { - let bankId: string; - - beforeAll(async () => { - bankId = randomBankId(); - // Setup: Store some test memories before recall tests - await client.retainBatch(bankId, [ - { content: 'Alice loves programming in Python' }, - { content: 'Bob enjoys hiking and outdoor adventures' }, - { content: 'Charlie is interested in quantum physics' }, - { content: 'Diana plays the violin beautifully' }, - ]); - }); - - test('recall basic', async () => { - const response = await client.recall(bankId, 'What does Alice like?'); - - expect(response).not.toBeNull(); - expect(response.results).toBeDefined(); - expect(response.results!.length).toBeGreaterThan(0); - - // Check that at least one result contains relevant information - const resultTexts = response.results!.map((r) => r.text || ''); - const hasRelevant = resultTexts.some( - (text: string) => text.includes('Alice') || text.includes('Python') || text.includes('programming') - ); - expect(hasRelevant).toBe(true); - }); - - test('recall with max tokens', async () => { - const response = await client.recall(bankId, 'outdoor activities', { - maxTokens: 1024, - }); - - expect(response).not.toBeNull(); - expect(response.results).toBeDefined(); - expect(Array.isArray(response.results)).toBe(true); - }); - - test('recall with types filter', async () => { - const response = await client.recall(bankId, "What are people's hobbies?", { - types: ['world'], - maxTokens: 2048, - trace: true, - }); - - expect(response).not.toBeNull(); - expect(response.results).toBeDefined(); - }); -}); - -describe('TestReflect', () => { - let bankId: string; - - beforeAll(async () => { - bankId = randomBankId(); - // Setup: Create bank and store test memories - await client.createBank(bankId, { - background: 'I am a helpful AI assistant interested in technology and science.', - }); - - await client.retainBatch(bankId, [ - { content: 'The Python programming language is great for data science' }, - { content: 'Machine learning models can recognize patterns in data' }, - { content: 'Neural networks are inspired by biological neurons' }, - ]); - }); - - test('reflect basic', async () => { - const response = await client.reflect( - bankId, - 'What do you think about artificial intelligence?' - ); - - expect(response).not.toBeNull(); - expect(response.text).toBeDefined(); - expect(response.text!.length).toBeGreaterThan(0); - }); - - test('reflect with context', async () => { - const response = await client.reflect(bankId, 'Should I learn Python?', { - context: "I'm interested in starting a career in data science", - budget: 'low', - }); - - expect(response).not.toBeNull(); - expect(response.text).toBeDefined(); - expect(response.text!.length).toBeGreaterThan(0); - }); -}); - -describe('TestListMemories', () => { - let bankId: string; - - beforeAll(async () => { - bankId = randomBankId(); - // Setup: Store some test memories synchronously - await client.retainBatch(bankId, [ - { content: 'Alice likes topic number 0' }, - { content: 'Alice likes topic number 1' }, - { content: 'Alice likes topic number 2' }, - { content: 'Alice likes topic number 3' }, - { content: 'Alice likes topic number 4' }, - ]); - }); - - test('list all memories', async () => { - const response = await client.listMemories(bankId); - - expect(response).not.toBeNull(); - expect(response.items).toBeDefined(); - expect(response.total).toBeDefined(); - expect(response.items!.length).toBeGreaterThan(0); - }); - - test('list with pagination', async () => { - const response = await client.listMemories(bankId, { - limit: 2, - offset: 0, - }); - - expect(response).not.toBeNull(); - expect(response.items).toBeDefined(); - expect(response.items!.length).toBeLessThanOrEqual(2); - }); -}); - -describe('TestEndToEndWorkflow', () => { - test('complete workflow', async () => { - const workflowBankId = randomBankId(); - - // 1. Create bank - await client.createBank(workflowBankId, { - background: 'I am a software engineer who loves Python programming.', - }); - - // 2. Store memories - const retainResponse = await client.retainBatch(workflowBankId, [ - { content: 'I completed a project using FastAPI' }, - { content: 'I learned about async programming in Python' }, - { content: 'I enjoy working on open source projects' }, - ]); - expect(retainResponse.success).toBe(true); - - // 3. Search for relevant memories - const recallResponse = await client.recall( - workflowBankId, - 'What programming technologies do I use?' - ); - expect(recallResponse.results!.length).toBeGreaterThan(0); - - // 4. Generate contextual answer - const reflectResponse = await client.reflect( - workflowBankId, - 'What are my professional interests?' - ); - expect(reflectResponse.text).toBeDefined(); - expect(reflectResponse.text!.length).toBeGreaterThan(0); - }); -}); diff --git a/hindsight-clients/typescript/tsconfig.json b/hindsight-clients/typescript/tsconfig.json deleted file mode 100644 index 4685ac36..00000000 --- a/hindsight-clients/typescript/tsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "declaration": true, - "outDir": "./dist", - "rootDir": "./", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "moduleResolution": "node", - "resolveJsonModule": true, - "declarationMap": true, - "sourceMap": true - }, - "include": [ - "src/**/*", - "generated/**/*" - ], - "exclude": [ - "node_modules", - "dist", - "**/*.test.ts" - ] -} diff --git a/hindsight-control-plane/.dockerignore b/hindsight-control-plane/.dockerignore deleted file mode 100644 index 7424eff5..00000000 --- a/hindsight-control-plane/.dockerignore +++ /dev/null @@ -1,11 +0,0 @@ -.next -node_modules -npm-debug.log -.git -.gitignore -.env -README.md -Dockerfile -.dockerignore -build-docker.sh -docker-compose.yml diff --git a/hindsight-control-plane/.eslintrc.json b/hindsight-control-plane/.eslintrc.json deleted file mode 100644 index bffb357a..00000000 --- a/hindsight-control-plane/.eslintrc.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "extends": "next/core-web-vitals" -} diff --git a/hindsight-control-plane/.gitignore b/hindsight-control-plane/.gitignore deleted file mode 100644 index 1ac55d45..00000000 --- a/hindsight-control-plane/.gitignore +++ /dev/null @@ -1,37 +0,0 @@ -# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. - -# dependencies -/node_modules -/.pnp -.pnp.js - -# testing -/coverage - -# next.js -/.next/ -/out/ - -# production -/build -/standalone - -# misc -.DS_Store -*.pem - -# debug -npm-debug.log* -yarn-debug.log* -yarn-error.log* - -# local env files -.env*.local -.env - -# vercel -.vercel - -# typescript -*.tsbuildinfo -next-env.d.ts diff --git a/hindsight-control-plane/.prettierrc b/hindsight-control-plane/.prettierrc deleted file mode 100644 index 1a88ab19..00000000 --- a/hindsight-control-plane/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "semi": true, - "singleQuote": false, - "tabWidth": 2, - "trailingComma": "es5", - "printWidth": 100 -} diff --git a/hindsight-control-plane/bin/cli.js b/hindsight-control-plane/bin/cli.js deleted file mode 100755 index f44b64ad..00000000 --- a/hindsight-control-plane/bin/cli.js +++ /dev/null @@ -1,86 +0,0 @@ -#!/usr/bin/env node - -const { spawn } = require('child_process'); -const path = require('path'); -const fs = require('fs'); - -const args = process.argv.slice(2); - -// Parse command line arguments -let port = process.env.PORT || 9999; -let hostname = process.env.HOSTNAME || '0.0.0.0'; -let apiUrl = process.env.HINDSIGHT_CP_DATAPLANE_API_URL; - -for (let i = 0; i < args.length; i++) { - if (args[i] === '--port' || args[i] === '-p') { - port = args[++i]; - } else if (args[i] === '--hostname' || args[i] === '-H') { - hostname = args[++i]; - } else if (args[i] === '--api-url' || args[i] === '-a') { - apiUrl = args[++i]; - } else if (args[i] === '--help' || args[i] === '-h') { - console.log(` -Hindsight Control Plane - -Usage: hindsight-control-plane [options] - -Options: - -p, --port Port to listen on (default: 9999, env: PORT) - -H, --hostname Hostname to bind to (default: 0.0.0.0, env: HOSTNAME) - -a, --api-url Hindsight API URL (env: HINDSIGHT_CP_DATAPLANE_API_URL) - -h, --help Show this help message - -Environment Variables: - PORT Port to listen on - HOSTNAME Hostname to bind to - HINDSIGHT_CP_DATAPLANE_API_URL URL of the Hindsight API server -`); - process.exit(0); - } -} - -// Find the standalone server -const standaloneDir = path.join(__dirname, '..', 'standalone'); -const serverPath = path.join(standaloneDir, 'server.js'); - -if (!fs.existsSync(serverPath)) { - console.error('Error: Standalone server not found at', serverPath); - console.error('This package may not have been built correctly.'); - process.exit(1); -} - -// Set up environment -const env = { - ...process.env, - PORT: String(port), - HOSTNAME: hostname, -}; - -if (apiUrl) { - env.HINDSIGHT_CP_DATAPLANE_API_URL = apiUrl; -} - -console.log(`Starting Hindsight Control Plane on http://${hostname}:${port}`); -if (apiUrl) { - console.log(`API URL: ${apiUrl}`); -} - -// Run the standalone server -const server = spawn('node', [serverPath], { - cwd: standaloneDir, - env, - stdio: 'inherit', -}); - -server.on('error', (err) => { - console.error('Failed to start server:', err.message); - process.exit(1); -}); - -server.on('close', (code) => { - process.exit(code || 0); -}); - -// Handle signals -process.on('SIGTERM', () => server.kill('SIGTERM')); -process.on('SIGINT', () => server.kill('SIGINT')); diff --git a/hindsight-control-plane/components.json b/hindsight-control-plane/components.json deleted file mode 100644 index dba50c60..00000000 --- a/hindsight-control-plane/components.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "default", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "tailwind.config.ts", - "css": "src/app/globals.css", - "baseColor": "slate", - "cssVariables": true, - "prefix": "" - }, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - } -} diff --git a/hindsight-control-plane/eslint.config.mjs b/hindsight-control-plane/eslint.config.mjs deleted file mode 100644 index fb09d80a..00000000 --- a/hindsight-control-plane/eslint.config.mjs +++ /dev/null @@ -1,37 +0,0 @@ -import js from "@eslint/js"; -import tseslint from "typescript-eslint"; -import reactPlugin from "eslint-plugin-react"; -import reactHooksPlugin from "eslint-plugin-react-hooks"; - -export default [ - js.configs.recommended, - ...tseslint.configs.recommended, - { - files: ["**/*.{ts,tsx}"], - plugins: { - react: reactPlugin, - "react-hooks": reactHooksPlugin, - }, - languageOptions: { - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - }, - }, - rules: { - "@typescript-eslint/no-unused-vars": "warn", - "@typescript-eslint/no-explicit-any": "warn", - "react/react-in-jsx-scope": "off", - "no-case-declarations": "off", - }, - settings: { - react: { - version: "detect", - }, - }, - }, - { - ignores: [".next/", "node_modules/"], - }, -]; diff --git a/hindsight-control-plane/next.config.ts b/hindsight-control-plane/next.config.ts deleted file mode 100644 index 9f404df9..00000000 --- a/hindsight-control-plane/next.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { NextConfig } from "next"; -import path from "path"; - -const nextConfig: NextConfig = { - output: 'standalone', - // Disable request logging in production - logging: false, - // Set the monorepo root explicitly to avoid detecting wrong lockfiles in parent directories - turbopack: { - root: path.resolve(__dirname, '..'), - }, -}; - -export default nextConfig; diff --git a/hindsight-control-plane/package.json b/hindsight-control-plane/package.json deleted file mode 100644 index e0df21d7..00000000 --- a/hindsight-control-plane/package.json +++ /dev/null @@ -1,69 +0,0 @@ -{ - "name": "@vectorize-io/hindsight-control-plane", - "version": "0.1.13", - "description": "Control plane for Hindsight - Semantic memory system", - "bin": { - "hindsight-control-plane": "./bin/cli.js" - }, - "files": [ - "bin", - "standalone", - "public" - ], - "scripts": { - "dev": "next dev", - "build": "next build && npm run build:standalone", - "build:standalone": "rm -rf standalone && STANDALONE_ROOT=$(find .next/standalone -path '*/node_modules' -prune -o -name 'server.js' -print | head -1 | xargs dirname) && cp -r \"$STANDALONE_ROOT\" standalone && cp -r .next/standalone/node_modules standalone/node_modules && mkdir -p standalone/.next && cp -r .next/static standalone/.next/static && mkdir -p standalone/public && cp -r public/* standalone/public/ 2>/dev/null || true", - "start": "next start", - "lint": "next lint", - "prepublishOnly": "npm run build" - }, - "keywords": ["hindsight", "memory", "semantic", "ai"], - "author": "Hindsight Team", - "license": "ISC", - "dependencies": { - "@radix-ui/react-checkbox": "^1.3.3", - "@radix-ui/react-dialog": "^1.1.15", - "@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", - "@tailwindcss/postcss": "^4.1.17", - "@types/cytoscape": "^3.21.9", - "@types/node": "^24.10.0", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", - "autoprefixer": "^10.4.21", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "cytoscape": "^3.33.1", - "eslint": "^9.39.1", - "eslint-config-next": "^16.0.1", - "lucide-react": "^0.553.0", - "next": "^16.0.10", - "postcss": "^8.5.6", - "react": "^19.2.0", - "react-chrono": "^2.9.1", - "react-dom": "^19.2.0", - "react18-json-view": "^0.2.9", - "recharts": "^3.5.1", - "tailwind-merge": "^3.4.0", - "tailwindcss": "^4.1.17", - "tailwindcss-animate": "^1.0.7", - "three": "^0.182.0", - "typescript": "^5.9.3" - }, - "devDependencies": { - "@vectorize-io/hindsight-client": "file:../hindsight-clients/typescript", - "@eslint/eslintrc": "^3.3.3", - "@eslint/js": "^9.39.2", - "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^7.0.1", - "prettier": "^3.7.4", - "typescript-eslint": "^8.50.0" - } -} diff --git a/hindsight-control-plane/postcss.config.mjs b/hindsight-control-plane/postcss.config.mjs deleted file mode 100644 index 5d6d8457..00000000 --- a/hindsight-control-plane/postcss.config.mjs +++ /dev/null @@ -1,8 +0,0 @@ -/** @type {import('postcss-load-config').Config} */ -const config = { - plugins: { - '@tailwindcss/postcss': {}, - }, -}; - -export default config; diff --git a/hindsight-control-plane/public/favicon.png b/hindsight-control-plane/public/favicon.png deleted file mode 100644 index 8414297f..00000000 Binary files a/hindsight-control-plane/public/favicon.png and /dev/null differ diff --git a/hindsight-control-plane/public/logo.png b/hindsight-control-plane/public/logo.png deleted file mode 100644 index 0dac3a44..00000000 Binary files a/hindsight-control-plane/public/logo.png and /dev/null differ diff --git a/hindsight-control-plane/src/app/api/banks/route.ts b/hindsight-control-plane/src/app/api/banks/route.ts deleted file mode 100644 index 41309ae2..00000000 --- a/hindsight-control-plane/src/app/api/banks/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function GET() { - try { - const response = await sdk.listBanks({ client: lowLevelClient }); - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error fetching banks:", error); - return NextResponse.json({ error: "Failed to fetch banks" }, { status: 500 }); - } -} - -export async function POST(request: Request) { - try { - const body = await request.json(); - const { bank_id } = body; - - if (!bank_id) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const response = await sdk.createOrUpdateBank({ - client: lowLevelClient, - path: { bank_id }, - body: {}, - }); - - return NextResponse.json(response.data, { status: 201 }); - } catch (error) { - console.error("Error creating bank:", error); - return NextResponse.json({ error: "Failed to create bank" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/chunks/[chunkId]/route.ts b/hindsight-control-plane/src/app/api/chunks/[chunkId]/route.ts deleted file mode 100644 index 592625d6..00000000 --- a/hindsight-control-plane/src/app/api/chunks/[chunkId]/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ chunkId: string }> } -) { - try { - const { chunkId } = await params; - - const response = await sdk.getChunk({ - client: lowLevelClient, - path: { chunk_id: chunkId }, - }); - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error fetching chunk:", error); - return NextResponse.json({ error: "Failed to fetch chunk" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts b/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts deleted file mode 100644 index 33283b81..00000000 --- a/hindsight-control-plane/src/app/api/documents/[documentId]/route.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ documentId: string }> } -) { - try { - const { documentId } = await params; - const searchParams = request.nextUrl.searchParams; - const bankId = searchParams.get("bank_id"); - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const response = await sdk.getDocument({ - client: lowLevelClient, - path: { bank_id: bankId, document_id: documentId }, - }); - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error fetching document:", error); - return NextResponse.json({ error: "Failed to fetch document" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/documents/route.ts b/hindsight-control-plane/src/app/api/documents/route.ts deleted file mode 100644 index 3f7481ba..00000000 --- a/hindsight-control-plane/src/app/api/documents/route.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const bankId = searchParams.get("bank_id"); - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined; - const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : undefined; - - const response = await sdk.listDocuments({ - client: lowLevelClient, - path: { bank_id: bankId }, - query: { limit, offset }, - }); - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error fetching documents:", error); - return NextResponse.json({ error: "Failed to fetch documents" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/entities/[entityId]/regenerate/route.ts b/hindsight-control-plane/src/app/api/entities/[entityId]/regenerate/route.ts deleted file mode 100644 index 134b1ab2..00000000 --- a/hindsight-control-plane/src/app/api/entities/[entityId]/regenerate/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ entityId: string }> } -) { - try { - const { entityId } = await params; - const searchParams = request.nextUrl.searchParams; - const bankId = searchParams.get("bank_id"); - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const decodedEntityId = decodeURIComponent(entityId); - - const response = await sdk.regenerateEntityObservations({ - client: lowLevelClient, - path: { - bank_id: bankId, - entity_id: decodedEntityId, - }, - }); - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error regenerating entity observations:", error); - return NextResponse.json( - { error: "Failed to regenerate entity observations" }, - { status: 500 } - ); - } -} diff --git a/hindsight-control-plane/src/app/api/entities/[entityId]/route.ts b/hindsight-control-plane/src/app/api/entities/[entityId]/route.ts deleted file mode 100644 index c674fafa..00000000 --- a/hindsight-control-plane/src/app/api/entities/[entityId]/route.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ entityId: string }> } -) { - try { - const { entityId } = await params; - const searchParams = request.nextUrl.searchParams; - const bankId = searchParams.get("bank_id"); - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - // Decode URL-encoded entityId in case it contains special chars - const decodedEntityId = decodeURIComponent(entityId); - - const response = await sdk.getEntity({ - client: lowLevelClient, - path: { - bank_id: bankId, - entity_id: decodedEntityId, - }, - }); - - if (response.error) { - return NextResponse.json({ error: response.error }, { status: 500 }); - } - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error getting entity:", error); - return NextResponse.json({ error: "Failed to get entity" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/entities/route.ts b/hindsight-control-plane/src/app/api/entities/route.ts deleted file mode 100644 index dc5c1336..00000000 --- a/hindsight-control-plane/src/app/api/entities/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const bankId = searchParams.get("bank_id"); - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined; - - const response = await sdk.listEntities({ - client: lowLevelClient, - path: { bank_id: bankId }, - query: { limit }, - }); - - if (response.error) { - return NextResponse.json({ error: response.error }, { status: 500 }); - } - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error listing entities:", error); - return NextResponse.json({ error: "Failed to list entities" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/graph/route.ts b/hindsight-control-plane/src/app/api/graph/route.ts deleted file mode 100644 index b5c20988..00000000 --- a/hindsight-control-plane/src/app/api/graph/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const bankId = searchParams.get("bank_id") || searchParams.get("agent_id"); - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - // Get optional query parameters - const type = searchParams.get("type") || searchParams.get("fact_type") || undefined; - - const response = await sdk.getGraph({ - client: lowLevelClient, - path: { bank_id: bankId }, - query: { - type: type, - }, - }); - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error fetching graph data:", error); - return NextResponse.json({ error: "Failed to fetch graph data" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/health/route.ts b/hindsight-control-plane/src/app/api/health/route.ts deleted file mode 100644 index 951bcd4e..00000000 --- a/hindsight-control-plane/src/app/api/health/route.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function GET() { - const status: { - status: string; - service: string; - dataplane?: { - status: string; - url: string; - error?: string; - }; - } = { - status: "ok", - service: "hindsight-control-plane", - }; - - // Check dataplane connectivity - const dataplaneUrl = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888"; - try { - await sdk.listBanks({ client: lowLevelClient }); - status.dataplane = { - status: "connected", - url: dataplaneUrl, - }; - } catch (error) { - status.dataplane = { - status: "disconnected", - url: dataplaneUrl, - error: error instanceof Error ? error.message : String(error), - }; - } - - return NextResponse.json(status, { status: 200 }); -} diff --git a/hindsight-control-plane/src/app/api/list/route.ts b/hindsight-control-plane/src/app/api/list/route.ts deleted file mode 100644 index 40608017..00000000 --- a/hindsight-control-plane/src/app/api/list/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { hindsightClient, sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function GET(request: NextRequest) { - try { - const searchParams = request.nextUrl.searchParams; - const bankId = searchParams.get("bank_id") || searchParams.get("agent_id"); - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const limit = searchParams.get("limit") ? Number(searchParams.get("limit")) : undefined; - const offset = searchParams.get("offset") ? Number(searchParams.get("offset")) : undefined; - const type = searchParams.get("type") || searchParams.get("fact_type") || undefined; - const q = searchParams.get("q") || undefined; - - const response = await hindsightClient.listMemories(bankId, { - limit, - offset, - type, - q, - }); - - return NextResponse.json(response, { status: 200 }); - } catch (error) { - console.error("Error listing memory units:", error); - return NextResponse.json({ error: "Failed to list memory units" }, { status: 500 }); - } -} - -// Note: Individual memory unit deletion is not yet supported by the API -// Use clearBankMemories to delete all memories for a bank instead -export async function DELETE(request: NextRequest) { - return NextResponse.json( - { - error: - "Individual memory unit deletion is not yet supported. Use clear all memories instead.", - }, - { status: 501 } // Not Implemented - ); -} diff --git a/hindsight-control-plane/src/app/api/memories/retain/route.ts b/hindsight-control-plane/src/app/api/memories/retain/route.ts deleted file mode 100644 index fcc351dc..00000000 --- a/hindsight-control-plane/src/app/api/memories/retain/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { hindsightClient } from "@/lib/hindsight-client"; - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const bankId = body.bank_id || body.agent_id; - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const { items, document_id } = body; - - const response = await hindsightClient.retainBatch(bankId, items, { documentId: document_id }); - - return NextResponse.json(response, { status: 200 }); - } catch (error) { - console.error("Error batch retain:", error); - return NextResponse.json({ error: "Failed to batch retain" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/memories/retain_async/route.ts b/hindsight-control-plane/src/app/api/memories/retain_async/route.ts deleted file mode 100644 index ec0d5721..00000000 --- a/hindsight-control-plane/src/app/api/memories/retain_async/route.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const bankId = body.bank_id || body.agent_id; - - if (!bankId) { - return NextResponse.json({ error: "bank_id is required" }, { status: 400 }); - } - - const { items } = body; - - const response = await sdk.retainMemories({ - client: lowLevelClient, - path: { bank_id: bankId }, - body: { items, async: true }, - }); - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error batch retain async:", error); - return NextResponse.json({ error: "Failed to batch retain async" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts b/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts deleted file mode 100644 index 8ca88ffe..00000000 --- a/hindsight-control-plane/src/app/api/operations/[agentId]/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ agentId: string }> } -) { - try { - const { agentId } = await params; - const response = await sdk.listOperations({ - client: lowLevelClient, - path: { bank_id: agentId }, - }); - return NextResponse.json(response.data || {}, { status: 200 }); - } catch (error) { - console.error("Error fetching operations:", error); - return NextResponse.json({ error: "Failed to fetch operations" }, { status: 500 }); - } -} - -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ agentId: string }> } -) { - try { - const { agentId } = await params; - const searchParams = request.nextUrl.searchParams; - const operationId = searchParams.get("operation_id"); - - if (!operationId) { - return NextResponse.json({ error: "operation_id is required" }, { status: 400 }); - } - - const response = await sdk.cancelOperation({ - client: lowLevelClient, - path: { bank_id: agentId, operation_id: operationId }, - }); - - return NextResponse.json(response.data || {}, { status: 200 }); - } catch (error) { - console.error("Error canceling operation:", error); - return NextResponse.json({ error: "Failed to cancel operation" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/profile/[bankId]/route.ts b/hindsight-control-plane/src/app/api/profile/[bankId]/route.ts deleted file mode 100644 index 583e5695..00000000 --- a/hindsight-control-plane/src/app/api/profile/[bankId]/route.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ bankId: string }> } -) { - try { - const { bankId } = await params; - const response = await sdk.getBankProfile({ - client: lowLevelClient, - path: { bank_id: bankId }, - }); - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error fetching bank profile:", error); - return NextResponse.json({ error: "Failed to fetch bank profile" }, { status: 500 }); - } -} - -export async function PUT( - request: NextRequest, - { params }: { params: Promise<{ bankId: string }> } -) { - try { - const { bankId } = await params; - const body = await request.json(); - - const response = await sdk.createOrUpdateBank({ - client: lowLevelClient, - path: { bank_id: bankId }, - body: body, - }); - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error updating bank profile:", error); - return NextResponse.json({ error: "Failed to update bank profile" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/recall/route.ts b/hindsight-control-plane/src/app/api/recall/route.ts deleted file mode 100644 index c957f007..00000000 --- a/hindsight-control-plane/src/app/api/recall/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { lowLevelClient, sdk } from "@/lib/hindsight-client"; - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const bankId = body.bank_id || body.agent_id || "default"; - const { query, types, fact_type, max_tokens, trace, budget, include, query_timestamp } = body; - - const response = await sdk.recallMemories({ - client: lowLevelClient, - path: { bank_id: bankId }, - body: { - query, - types: types || fact_type, - max_tokens, - trace, - budget: budget || "mid", - include, - query_timestamp, - }, - }); - - if (!response.data) { - console.error("[Recall API] No data in response", { response, error: response.error }); - throw new Error(`API returned no data: ${JSON.stringify(response.error || "Unknown error")}`); - } - - // Return a clean JSON object by spreading the response - // This ensures any non-serializable properties are excluded - const jsonResponse = { - results: response.data.results, - trace: response.data.trace, - entities: response.data.entities, - chunks: response.data.chunks, - }; - - return NextResponse.json(jsonResponse, { status: 200 }); - } catch (error) { - console.error("Error recalling:", error); - return NextResponse.json({ error: "Failed to recall" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/reflect/route.ts b/hindsight-control-plane/src/app/api/reflect/route.ts deleted file mode 100644 index 1f6e6053..00000000 --- a/hindsight-control-plane/src/app/api/reflect/route.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function POST(request: NextRequest) { - try { - const body = await request.json(); - const bankId = body.bank_id || body.agent_id || "default"; - const { query, context, budget, thinking_budget, include_facts } = body; - - const requestBody: any = { - query, - budget: budget || (thinking_budget ? "mid" : "low"), - context: context || undefined, - }; - - // Add include options if specified - if (include_facts) { - requestBody.include = { - facts: {}, - }; - } - - const response = await sdk.reflect({ - client: lowLevelClient, - path: { bank_id: bankId }, - body: requestBody, - }); - - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error reflecting:", error); - return NextResponse.json({ error: "Failed to reflect" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/api/stats/[agentId]/route.ts b/hindsight-control-plane/src/app/api/stats/[agentId]/route.ts deleted file mode 100644 index 11340512..00000000 --- a/hindsight-control-plane/src/app/api/stats/[agentId]/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { sdk, lowLevelClient } from "@/lib/hindsight-client"; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ agentId: string }> } -) { - try { - const { agentId } = await params; - const response = await sdk.getAgentStats({ - client: lowLevelClient, - path: { bank_id: agentId }, - }); - return NextResponse.json(response.data, { status: 200 }); - } catch (error) { - console.error("Error fetching stats:", error); - return NextResponse.json({ error: "Failed to fetch stats" }, { status: 500 }); - } -} diff --git a/hindsight-control-plane/src/app/banks/[bankId]/page.tsx b/hindsight-control-plane/src/app/banks/[bankId]/page.tsx deleted file mode 100644 index 8578e98b..00000000 --- a/hindsight-control-plane/src/app/banks/[bankId]/page.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -import { useParams, useRouter, useSearchParams } from "next/navigation"; -import { BankSelector } from "@/components/bank-selector"; -import { Sidebar } from "@/components/sidebar"; -import { DataView } from "@/components/data-view"; -import { DocumentsView } from "@/components/documents-view"; -import { EntitiesView } from "@/components/entities-view"; -import { ThinkView } from "@/components/think-view"; -import { SearchDebugView } from "@/components/search-debug-view"; -import { BankProfileView } from "@/components/bank-profile-view"; - -type NavItem = "recall" | "reflect" | "data" | "documents" | "entities" | "profile"; -type DataSubTab = "world" | "experience" | "opinion"; - -export default function BankPage() { - const params = useParams(); - const router = useRouter(); - const searchParams = useSearchParams(); - - const bankId = params.bankId as string; - const view = (searchParams.get("view") || "profile") as NavItem; - const subTab = (searchParams.get("subTab") || "world") as DataSubTab; - - const handleTabChange = (tab: NavItem) => { - router.push(`/banks/${bankId}?view=${tab}`); - }; - - const handleDataSubTabChange = (newSubTab: DataSubTab) => { - router.push(`/banks/${bankId}?view=data&subTab=${newSubTab}`); - }; - - return ( -
- - -
- - -
-
- {/* Profile Tab */} - {view === "profile" && ( -
-

Bank Profile

-

- View and edit the memory bank profile, disposition traits, and background - information. -

- -
- )} - - {/* Recall Tab */} - {view === "recall" && ( -
-

Recall Analyzer

-

- Analyze memory recall with detailed trace information and retrieval methods. -

- -
- )} - - {/* Reflect Tab */} - {view === "reflect" && ( -
-

Reflect

-

- Ask questions and get AI-powered answers based on stored memories. -

- -
- )} - - {/* Data/Memories Tab */} - {view === "data" && ( -
-

Memories

-

- View and explore different types of memories stored in this memory bank. -

- -
-
- - - -
-
- -
- {subTab === "world" && } - {subTab === "experience" && } - {subTab === "opinion" && } -
-
- )} - - {/* Documents Tab */} - {view === "documents" && ( -
-

Documents

-

- Manage documents and retain new memories. -

- -
- )} - - {/* Entities Tab */} - {view === "entities" && ( -
-

Entities

-

- Explore entities (people, organizations, places) mentioned in memories. -

- -
- )} -
-
-
-
- ); -} diff --git a/hindsight-control-plane/src/app/dashboard/page.tsx b/hindsight-control-plane/src/app/dashboard/page.tsx deleted file mode 100644 index 05e620ef..00000000 --- a/hindsight-control-plane/src/app/dashboard/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; -import { BankSelector } from "@/components/bank-selector"; -import { useBank } from "@/lib/bank-context"; - -export default function DashboardPage() { - const router = useRouter(); - const { currentBank } = useBank(); - - // Redirect to bank page if a bank is selected - useEffect(() => { - if (currentBank) { - router.push(`/banks/${currentBank}?view=data`); - } - }, [currentBank, router]); - - return ( -
- - -
-
-

Welcome to Hindsight

-

- Select a memory bank from the dropdown above to get started. -

-
🧠
-

- The sidebar will appear once you select a memory bank. -

-
-
-
- ); -} diff --git a/hindsight-control-plane/src/app/globals.css b/hindsight-control-plane/src/app/globals.css deleted file mode 100644 index b58199f1..00000000 --- a/hindsight-control-plane/src/app/globals.css +++ /dev/null @@ -1,192 +0,0 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@500;600;700&display=swap'); -@import "tailwindcss"; -:root { - --background: oklch(0.9911 0 0); - --foreground: oklch(0.2046 0 0); - --card: oklch(0.9911 0 0); - --card-foreground: oklch(0.2046 0 0); - --popover: oklch(0.9911 0 0); - --popover-foreground: oklch(0.4386 0 0); - --primary: oklch(0.55 0.19 250); - --primary-foreground: oklch(0.98 0.01 250); - --primary-gradient: linear-gradient(135deg, #0074d9 0%, #009296 100%); - --secondary: oklch(0.9940 0 0); - --secondary-foreground: oklch(0.2046 0 0); - --muted: oklch(0.9461 0 0); - --muted-foreground: oklch(0.2435 0 0); - --accent: oklch(0.9461 0 0); - --accent-foreground: oklch(0.2435 0 0); - --destructive: oklch(0.5523 0.1927 32.7272); - --destructive-foreground: oklch(0.9934 0.0032 17.2118); - --border: oklch(0.9037 0 0); - --input: oklch(0.9731 0 0); - --ring: oklch(0.55 0.19 250); - --chart-1: oklch(0.55 0.19 250); - --chart-2: oklch(0.6231 0.1880 259.8145); - --chart-3: oklch(0.6056 0.2189 292.7172); - --chart-4: oklch(0.7686 0.1647 70.0804); - --chart-5: oklch(0.6959 0.1491 162.4796); - --sidebar: oklch(0.9911 0 0); - --sidebar-foreground: oklch(0.5452 0 0); - --sidebar-primary: oklch(0.55 0.19 250); - --sidebar-primary-foreground: oklch(0.98 0.01 250); - --sidebar-accent: oklch(0.9461 0 0); - --sidebar-accent-foreground: oklch(0.2435 0 0); - --sidebar-border: oklch(0.9037 0 0); - --sidebar-ring: oklch(0.55 0.19 250); - --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; - --font-heading: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - --font-mono: 'JetBrains Mono', monospace; - --radius: 0.5rem; - --shadow-x: 0px; - --shadow-y: 1px; - --shadow-blur: 3px; - --shadow-spread: 0px; - --shadow-opacity: 0.17; - --shadow-color: #000000; - --shadow-2xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09); - --shadow-xs: 0px 1px 3px 0px hsl(0 0% 0% / 0.09); - --shadow-sm: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17); - --shadow: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 1px 2px -1px hsl(0 0% 0% / 0.17); - --shadow-md: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 2px 4px -1px hsl(0 0% 0% / 0.17); - --shadow-lg: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 4px 6px -1px hsl(0 0% 0% / 0.17); - --shadow-xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.17), 0px 8px 10px -1px hsl(0 0% 0% / 0.17); - --shadow-2xl: 0px 1px 3px 0px hsl(0 0% 0% / 0.43); - --tracking-normal: 0.025em; - --spacing: 0.25rem; -} - -.dark { - --background: oklch(0.1822 0 0); - --foreground: oklch(0.9288 0.0126 255.5078); - --card: oklch(0.2046 0 0); - --card-foreground: oklch(0.9288 0.0126 255.5078); - --popover: oklch(0.2603 0 0); - --popover-foreground: oklch(0.7348 0 0); - --primary: oklch(0.60 0.17 250); - --primary-foreground: oklch(0.98 0.01 250); - --primary-gradient: linear-gradient(135deg, #0074d9 0%, #009296 100%); - --secondary: oklch(0.2603 0 0); - --secondary-foreground: oklch(0.9851 0 0); - --muted: oklch(0.2393 0 0); - --muted-foreground: oklch(0.7122 0 0); - --accent: oklch(0.3132 0 0); - --accent-foreground: oklch(0.9851 0 0); - --destructive: oklch(0.3123 0.0852 29.7877); - --destructive-foreground: oklch(0.9368 0.0045 34.3092); - --border: oklch(0.2809 0 0); - --input: oklch(0.2603 0 0); - --ring: oklch(0.60 0.17 250); - --chart-1: oklch(0.60 0.17 250); - --chart-2: oklch(0.7137 0.1434 254.6240); - --chart-3: oklch(0.7090 0.1592 293.5412); - --chart-4: oklch(0.8369 0.1644 84.4286); - --chart-5: oklch(0.7845 0.1325 181.9120); - --sidebar: oklch(0.1822 0 0); - --sidebar-foreground: oklch(0.6301 0 0); - --sidebar-primary: oklch(0.60 0.17 250); - --sidebar-primary-foreground: oklch(0.98 0.01 250); - --sidebar-accent: oklch(0.3132 0 0); - --sidebar-accent-foreground: oklch(0.9851 0 0); - --sidebar-border: oklch(0.2809 0 0); - --sidebar-ring: oklch(0.60 0.17 250); -} - -@theme inline { - --color-background: var(--background); - --color-foreground: var(--foreground); - --color-card: var(--card); - --color-card-foreground: var(--card-foreground); - --color-popover: var(--popover); - --color-popover-foreground: var(--popover-foreground); - --color-primary: var(--primary); - --color-primary-foreground: var(--primary-foreground); - --color-secondary: var(--secondary); - --color-secondary-foreground: var(--secondary-foreground); - --color-muted: var(--muted); - --color-muted-foreground: var(--muted-foreground); - --color-accent: var(--accent); - --color-accent-foreground: var(--accent-foreground); - --color-destructive: var(--destructive); - --color-destructive-foreground: var(--destructive-foreground); - --color-border: var(--border); - --color-input: var(--input); - --color-ring: var(--ring); - --color-chart-1: var(--chart-1); - --color-chart-2: var(--chart-2); - --color-chart-3: var(--chart-3); - --color-chart-4: var(--chart-4); - --color-chart-5: var(--chart-5); - --color-sidebar: var(--sidebar); - --color-sidebar-foreground: var(--sidebar-foreground); - --color-sidebar-primary: var(--sidebar-primary); - --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); - --color-sidebar-accent: var(--sidebar-accent); - --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); - --color-sidebar-border: var(--sidebar-border); - --color-sidebar-ring: var(--sidebar-ring); - - --font-sans: var(--font-sans); - --font-mono: var(--font-mono); - --font-heading: var(--font-heading); - - --radius-sm: calc(var(--radius) - 4px); - --radius-md: calc(var(--radius) - 2px); - --radius-lg: var(--radius); - --radius-xl: calc(var(--radius) + 4px); - - --shadow-2xs: var(--shadow-2xs); - --shadow-xs: var(--shadow-xs); - --shadow-sm: var(--shadow-sm); - --shadow: var(--shadow); - --shadow-md: var(--shadow-md); - --shadow-lg: var(--shadow-lg); - --shadow-xl: var(--shadow-xl); - --shadow-2xl: var(--shadow-2xl); - - --tracking-tighter: calc(var(--tracking-normal) - 0.05em); - --tracking-tight: calc(var(--tracking-normal) - 0.025em); - --tracking-normal: var(--tracking-normal); - --tracking-wide: calc(var(--tracking-normal) + 0.025em); - --tracking-wider: calc(var(--tracking-normal) + 0.05em); - --tracking-widest: calc(var(--tracking-normal) + 0.1em); -} - -body { - font-family: var(--font-sans); - letter-spacing: var(--tracking-normal); -} - -h1, h2, h3, h4, h5, h6 { - font-family: var(--font-heading); - font-weight: 600; -} - -code, pre { - font-family: var(--font-mono); -} - -/* Gradient utilities */ -.bg-primary-gradient { - background: var(--primary-gradient); -} - -.border-primary-gradient { - border-image: var(--primary-gradient) 1; -} - -.text-primary-gradient { - background: var(--primary-gradient); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -/* Fix datetime-local calendar icon visibility in both light and dark modes */ -input[type="datetime-local"]::-webkit-calendar-picker-indicator { - filter: invert(0.5); -} - -.dark input[type="datetime-local"]::-webkit-calendar-picker-indicator { - filter: invert(1); -} \ No newline at end of file diff --git a/hindsight-control-plane/src/app/layout.tsx b/hindsight-control-plane/src/app/layout.tsx deleted file mode 100644 index 23c78e9a..00000000 --- a/hindsight-control-plane/src/app/layout.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import type { Metadata } from "next"; -import "./globals.css"; -import { BankProvider } from "@/lib/bank-context"; -import { ThemeProvider } from "@/lib/theme-context"; - -export const metadata: Metadata = { - title: "Hindsight Control Plane", - description: "Control plane for the temporal semantic memory system", - icons: { - icon: "/favicon.png", - }, -}; - -export default function RootLayout({ - children, -}: Readonly<{ - children: React.ReactNode; -}>) { - return ( - - - - {children} - - - - ); -} diff --git a/hindsight-control-plane/src/app/page.tsx b/hindsight-control-plane/src/app/page.tsx deleted file mode 100644 index a74cb27f..00000000 --- a/hindsight-control-plane/src/app/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function Home() { - redirect("/dashboard"); -} diff --git a/hindsight-control-plane/src/components/add-memory-view.tsx b/hindsight-control-plane/src/components/add-memory-view.tsx deleted file mode 100644 index 3f26bced..00000000 --- a/hindsight-control-plane/src/components/add-memory-view.tsx +++ /dev/null @@ -1,155 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { client } from "@/lib/api"; -import { useBank } from "@/lib/bank-context"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; -import { Checkbox } from "@/components/ui/checkbox"; - -export function AddMemoryView() { - const { currentBank } = useBank(); - const [content, setContent] = useState(""); - const [context, setContext] = useState(""); - const [eventDate, setEventDate] = useState(""); - const [documentId, setDocumentId] = useState(""); - const [async, setAsync] = useState(false); - const [loading, setLoading] = useState(false); - const [result, setResult] = useState(null); - - const clearForm = () => { - setContent(""); - setContext(""); - setEventDate(""); - setDocumentId(""); - setAsync(false); - setResult(null); - }; - - const submitMemory = async () => { - if (!currentBank || !content) { - alert("Please enter content"); - return; - } - - setLoading(true); - setResult(null); - - try { - const item: any = { content }; - if (context) item.context = context; - // datetime-local gives "2024-01-15T10:30", add seconds for proper ISO format - if (eventDate) item.timestamp = eventDate + ":00"; - - const data: any = await client.retain({ - bank_id: currentBank, - items: [item], - document_id: documentId, - async, - }); - - setResult(data.message as string); - setContent(""); - } catch (error) { - console.error("Error submitting memory:", error); - setResult("Error: " + (error as Error).message); - } finally { - setLoading(false); - } - }; - - return ( -
-

- Retain memories to the selected memory bank. You can add one or multiple memories at once. -

- -
-
-

Memory Entry

- -
- -