Compare commits
12
Commits
deadc-oder
..
times
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76014d0a00 | ||
|
|
b4bb711001 | ||
|
|
b4b5c44a87 | ||
|
|
d5e62162e8 | ||
|
|
7dad9da02d | ||
|
|
ff55283018 | ||
|
|
b3b541fc53 | ||
|
|
4f112101ac | ||
|
|
e408b7e072 | ||
|
|
d871c3009d | ||
|
|
d8376ecf6b | ||
|
|
71e408c27b |
@@ -0,0 +1,32 @@
|
||||
# PostgreSQL with pgvector and pg_textsearch extensions
|
||||
# Note: pg_textsearch requires PostgreSQL 17+
|
||||
FROM postgres:17
|
||||
|
||||
# Install build dependencies
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
git \
|
||||
postgresql-server-dev-17 \
|
||||
libpq-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install pgvector
|
||||
RUN cd /tmp && \
|
||||
git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git && \
|
||||
cd pgvector && \
|
||||
make && \
|
||||
make install
|
||||
|
||||
# Install pg_textsearch
|
||||
RUN cd /tmp && \
|
||||
git clone https://github.com/timescale/pg_textsearch.git && \
|
||||
cd pg_textsearch && \
|
||||
make && \
|
||||
make install
|
||||
|
||||
# Clean up source files and build dependencies
|
||||
RUN rm -rf /tmp/pgvector /tmp/pg_textsearch && \
|
||||
apt-get purge -y --auto-remove build-essential git postgresql-server-dev-17
|
||||
|
||||
# Ensure extensions are preloaded
|
||||
RUN echo "shared_preload_libraries = 'pg_textsearch'" >> /usr/share/postgresql/postgresql.conf.sample
|
||||
@@ -0,0 +1,91 @@
|
||||
name: hindsight
|
||||
# Docker Compose file for Hindsight with PostgreSQL and Timescale pg_textsearch
|
||||
# docker compose -f docker/docker-compose/pg_textsearch/docker-compose.yaml down && sleep 2 && docker compose -f docker/docker-compose/pg_textsearch/docker-compose.yaml up -d
|
||||
# Make sure to set the required environment variables before running:
|
||||
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
|
||||
# - Configure LLM provider variables as needed (see below in the hindsight service)
|
||||
#
|
||||
# Usage:
|
||||
# docker compose up -d
|
||||
#
|
||||
# Optional environment variables with defaults:
|
||||
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
|
||||
# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user)
|
||||
# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db)
|
||||
|
||||
services:
|
||||
db:
|
||||
# Use custom PostgreSQL image with pgvector and pg_textsearch extensions
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: hindsight-db
|
||||
restart: always
|
||||
# Expose PostgreSQL port
|
||||
ports:
|
||||
- "5437:5432"
|
||||
environment:
|
||||
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
|
||||
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:-hindsight_password}
|
||||
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- hindsight-net
|
||||
|
||||
pg-textsearch-init:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
- PGPASSWORD=${HINDSIGHT_DB_PASSWORD:-hindsight_password}
|
||||
command: >
|
||||
bash -c "
|
||||
echo 'Waiting for PostgreSQL to be ready...';
|
||||
until pg_isready -h hindsight-db -p 5432 -U hindsight_user; do
|
||||
echo 'PostgreSQL is unavailable - sleeping';
|
||||
sleep 2;
|
||||
done;
|
||||
echo 'PostgreSQL is ready - creating hindsight_db database';
|
||||
psql -h hindsight-db -p 5432 -U hindsight_user -c 'CREATE DATABASE hindsight_db;' 2>/dev/null || echo 'Database already exists';
|
||||
echo 'Creating extensions in hindsight_db database';
|
||||
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS vector CASCADE;';
|
||||
psql -h hindsight-db -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS pg_textsearch CASCADE;';
|
||||
echo 'Database and extensions created successfully';
|
||||
"
|
||||
restart: "no"
|
||||
networks:
|
||||
- hindsight-net
|
||||
|
||||
hindsight:
|
||||
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
|
||||
container_name: hindsight-app
|
||||
ports:
|
||||
- "8888:8888"
|
||||
- "9999:9999"
|
||||
environment:
|
||||
# LLM Configuration
|
||||
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
|
||||
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
|
||||
|
||||
# Database Configuration
|
||||
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
|
||||
|
||||
# Vector and Text Search Extensions
|
||||
HINDSIGHT_API_VECTOR_EXTENSION: pgvector
|
||||
HINDSIGHT_API_TEXT_SEARCH_EXTENSION: pg_textsearch
|
||||
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
- hindsight-net
|
||||
|
||||
|
||||
networks:
|
||||
hindsight-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
@@ -0,0 +1,16 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
.gitattributes
|
||||
|
||||
# Docker
|
||||
docker-compose.yaml
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
README.md
|
||||
*.md
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.example
|
||||
@@ -0,0 +1,25 @@
|
||||
# PostgreSQL Configuration
|
||||
HINDSIGHT_DB_USER=hindsight_user
|
||||
HINDSIGHT_DB_PASSWORD=change-me-to-secure-password
|
||||
HINDSIGHT_DB_NAME=hindsight_db
|
||||
|
||||
# Hindsight Version
|
||||
HINDSIGHT_VERSION=latest
|
||||
|
||||
# LLM Configuration
|
||||
HINDSIGHT_API_LLM_PROVIDER=openai
|
||||
OPENAI_API_KEY=your-openai-api-key-here
|
||||
|
||||
# Alternative LLM providers (uncomment and configure as needed):
|
||||
# HINDSIGHT_API_LLM_PROVIDER=anthropic
|
||||
# ANTHROPIC_API_KEY=your-anthropic-api-key
|
||||
|
||||
# HINDSIGHT_API_LLM_PROVIDER=gemini
|
||||
# GEMINI_API_KEY=your-gemini-api-key
|
||||
|
||||
# HINDSIGHT_API_LLM_PROVIDER=groq
|
||||
# GROQ_API_KEY=your-groq-api-key
|
||||
|
||||
# Vector and Text Search (already configured in docker-compose.yaml)
|
||||
# HINDSIGHT_API_VECTOR_EXTENSION=pgvectorscale
|
||||
# HINDSIGHT_API_TEXT_SEARCH_EXTENSION=pg_textsearch
|
||||
@@ -0,0 +1,55 @@
|
||||
# PostgreSQL with pgvector, pgvectorscale, and pg_textsearch extensions
|
||||
# All three extensions from Timescale/pgvector for high-performance vector and text search
|
||||
# Note: Requires PostgreSQL 16+
|
||||
FROM postgres:17
|
||||
|
||||
# Install build dependencies and Rust toolchain
|
||||
RUN apt-get update && apt-get install -y \
|
||||
build-essential \
|
||||
git \
|
||||
postgresql-server-dev-17 \
|
||||
libpq-dev \
|
||||
cmake \
|
||||
curl \
|
||||
pkg-config \
|
||||
libssl-dev \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Rust toolchain (required for pgvectorscale)
|
||||
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
|
||||
# Install pgvector (required by pgvectorscale)
|
||||
RUN cd /tmp && \
|
||||
git clone --branch v0.8.0 https://github.com/pgvector/pgvector.git && \
|
||||
cd pgvector && \
|
||||
make && \
|
||||
make install && \
|
||||
rm -rf /tmp/pgvector
|
||||
|
||||
# Install cargo-pgrx (PostgreSQL extension framework for Rust)
|
||||
RUN cargo install cargo-pgrx --version 0.12.5 --locked && \
|
||||
cargo pgrx init --pg17 /usr/bin/pg_config
|
||||
|
||||
# Install pgvectorscale (DiskANN index support)
|
||||
RUN cd /tmp && \
|
||||
git clone --branch 0.5.1 https://github.com/timescale/pgvectorscale.git && \
|
||||
cd pgvectorscale/pgvectorscale && \
|
||||
cargo pgrx install --release && \
|
||||
rm -rf /tmp/pgvectorscale
|
||||
|
||||
# Install pg_textsearch (BM25 text search)
|
||||
RUN cd /tmp && \
|
||||
git clone https://github.com/timescale/pg_textsearch.git && \
|
||||
cd pg_textsearch && \
|
||||
make && \
|
||||
make install && \
|
||||
rm -rf /tmp/pg_textsearch
|
||||
|
||||
# Clean up build dependencies (keep runtime dependencies)
|
||||
RUN apt-get purge -y --auto-remove git cmake curl && \
|
||||
rm -rf /root/.cargo/registry /root/.cargo/git
|
||||
|
||||
# Ensure extensions are preloaded (pg_textsearch requires preloading)
|
||||
RUN echo "shared_preload_libraries = 'pg_textsearch'" >> /usr/share/postgresql/postgresql.conf.sample
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Hindsight with Timescale Extensions
|
||||
|
||||
This Docker Compose setup provides a complete Hindsight deployment with **Timescale extensions**:
|
||||
- **pgvectorscale** - DiskANN algorithm for disk-based scalable vector search
|
||||
- **pg_textsearch** - High-performance BM25 text search
|
||||
|
||||
Both extensions are from [Timescale](https://github.com/timescale) and provide production-grade performance.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Docker and Docker Compose installed
|
||||
- OpenAI API key (or another LLM provider)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Set environment variables
|
||||
export HINDSIGHT_DB_PASSWORD="your-secure-password"
|
||||
export OPENAI_API_KEY="your-openai-api-key"
|
||||
|
||||
# Build and start
|
||||
docker compose -f docker/docker-compose/timescale/docker-compose.yaml up -d --build
|
||||
|
||||
# Check logs
|
||||
|
||||
docker compose -f docker/docker-compose/timescale/docker-compose.yaml logs -f
|
||||
```
|
||||
|
||||
**Access:**
|
||||
- API: http://localhost:8888
|
||||
- Control Plane: http://localhost:9999
|
||||
|
||||
## Stop and Clean Up
|
||||
|
||||
```bash
|
||||
# Stop services
|
||||
docker compose -f docker/docker-compose/timescale/docker-compose.yaml down
|
||||
|
||||
# Remove volumes (deletes all data)
|
||||
docker compose -f docker/docker-compose/timescale/docker-compose.yaml down -v
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_DB_PASSWORD` | PostgreSQL password | `hindsight_password` |
|
||||
| `HINDSIGHT_DB_USER` | PostgreSQL username | `hindsight_user` |
|
||||
| `HINDSIGHT_DB_NAME` | Database name | `hindsight_db` |
|
||||
| `HINDSIGHT_VERSION` | Hindsight Docker image version | `latest` |
|
||||
| `OPENAI_API_KEY` | OpenAI API key | (required) |
|
||||
| `HINDSIGHT_API_LLM_PROVIDER` | LLM provider | `openai` |
|
||||
|
||||
### Why Timescale Extensions?
|
||||
|
||||
**pgvectorscale (DiskANN):**
|
||||
- 28x lower p95 latency vs dedicated vector databases
|
||||
- 16x higher query throughput at 99% recall
|
||||
- 60-75% cost reduction (disk is cheaper than RAM)
|
||||
- Best for large datasets (10M+ vectors)
|
||||
|
||||
**pg_textsearch (BM25):**
|
||||
- High-performance keyword retrieval
|
||||
- Native BM25 ranking algorithm
|
||||
- Optimized for full-text search
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Extensions not installed
|
||||
|
||||
Check if extensions are available:
|
||||
|
||||
```bash
|
||||
docker exec -it hindsight-db-timescale psql -U hindsight_user -d hindsight_db -c "\dx"
|
||||
```
|
||||
|
||||
You should see:
|
||||
- `vector` (pgvector)
|
||||
- `vectorscale` (pgvectorscale/DiskANN)
|
||||
- `pg_textsearch` (BM25 search)
|
||||
|
||||
### Build fails
|
||||
|
||||
If the Docker build fails during pgvectorscale compilation:
|
||||
|
||||
1. Ensure you have sufficient memory (recommended: 4GB+)
|
||||
2. Check Docker build logs for Rust compilation errors
|
||||
3. Try building with more resources: `docker compose build --no-cache --memory 4g`
|
||||
|
||||
### Port conflicts
|
||||
|
||||
If port 5438 is already in use, modify the `ports` section in docker-compose.yaml.
|
||||
|
||||
## Learn More
|
||||
|
||||
- [pgvectorscale GitHub](https://github.com/timescale/pgvectorscale)
|
||||
- [pg_textsearch GitHub](https://github.com/timescale/pg_textsearch)
|
||||
- [HNSW vs DiskANN](https://www.tigerdata.com/learn/hnsw-vs-diskann)
|
||||
- [Hindsight Documentation](https://hindsight.dev)
|
||||
@@ -0,0 +1,108 @@
|
||||
name: hindsight
|
||||
# Docker Compose file for Hindsight with Timescale extensions
|
||||
# - pgvectorscale: DiskANN vector search (disk-based, scalable)
|
||||
# - pg_textsearch: BM25 text search (high-performance keyword retrieval)
|
||||
#
|
||||
# Quick start:
|
||||
# docker compose -f docker/docker-compose/timescale/docker-compose.yaml up -d --build
|
||||
#
|
||||
# Required environment variables:
|
||||
# - HINDSIGHT_DB_PASSWORD: Password for the PostgreSQL user
|
||||
# - OPENAI_API_KEY (or configure another LLM provider)
|
||||
#
|
||||
# Optional environment variables with defaults:
|
||||
# - HINDSIGHT_VERSION: Hindsight application version (default: latest)
|
||||
# - HINDSIGHT_DB_USER: PostgreSQL user (default: hindsight_user)
|
||||
# - HINDSIGHT_DB_NAME: PostgreSQL database name (default: hindsight_db)
|
||||
|
||||
services:
|
||||
db:
|
||||
# Custom PostgreSQL image with Timescale extensions (pgvectorscale + pg_textsearch)
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
container_name: hindsight-db-timescale
|
||||
restart: always
|
||||
# Expose PostgreSQL port (using 5438 to avoid conflicts with other setups)
|
||||
ports:
|
||||
- "5438:5432"
|
||||
environment:
|
||||
POSTGRES_USER: ${HINDSIGHT_DB_USER:-hindsight_user}
|
||||
POSTGRES_PASSWORD: ${HINDSIGHT_DB_PASSWORD:-hindsight_password}
|
||||
POSTGRES_DB: ${HINDSIGHT_DB_NAME:-hindsight_db}
|
||||
volumes:
|
||||
- pg_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- hindsight-net
|
||||
# Health check to ensure database is ready
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U hindsight_user"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
timescale-init:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
- PGPASSWORD=${HINDSIGHT_DB_PASSWORD:-hindsight_password}
|
||||
command: >
|
||||
bash -c "
|
||||
echo 'PostgreSQL is ready - creating hindsight_db database';
|
||||
psql -h hindsight-db-timescale -p 5432 -U hindsight_user -c 'CREATE DATABASE hindsight_db;' 2>/dev/null || echo 'Database already exists';
|
||||
echo 'Installing Timescale extensions...';
|
||||
echo '1/3: Installing pgvector (required by pgvectorscale)...';
|
||||
psql -h hindsight-db-timescale -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS vector CASCADE;';
|
||||
echo '2/3: Installing pgvectorscale (DiskANN vector search)...';
|
||||
psql -h hindsight-db-timescale -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS vectorscale CASCADE;';
|
||||
echo '3/3: Installing pg_textsearch (BM25 text search)...';
|
||||
psql -h hindsight-db-timescale -p 5432 -U hindsight_user -d hindsight_db -c 'CREATE EXTENSION IF NOT EXISTS pg_textsearch CASCADE;';
|
||||
echo '';
|
||||
echo '✅ Timescale extensions installed successfully';
|
||||
echo '';
|
||||
echo 'Installed extensions:';
|
||||
psql -h hindsight-db-timescale -p 5432 -U hindsight_user -d hindsight_db -c \"\\dx\" | grep -E '(vector|vectorscale|pg_textsearch)';
|
||||
"
|
||||
restart: "no"
|
||||
networks:
|
||||
- hindsight-net
|
||||
|
||||
hindsight:
|
||||
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
|
||||
container_name: hindsight-app-timescale
|
||||
ports:
|
||||
- "8888:8888"
|
||||
- "9999:9999"
|
||||
environment:
|
||||
# LLM Configuration
|
||||
HINDSIGHT_API_LLM_PROVIDER: ${HINDSIGHT_API_LLM_PROVIDER:-openai}
|
||||
HINDSIGHT_API_LLM_API_KEY: ${OPENAI_API_KEY:-your-api-key}
|
||||
|
||||
# Database Configuration
|
||||
HINDSIGHT_API_DATABASE_URL: postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:-hindsight_password}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
|
||||
|
||||
# Timescale Extensions
|
||||
# pgvectorscale: DiskANN algorithm for disk-based scalable vector search
|
||||
HINDSIGHT_API_VECTOR_EXTENSION: pgvectorscale
|
||||
# pg_textsearch: High-performance BM25 text search
|
||||
HINDSIGHT_API_TEXT_SEARCH_EXTENSION: pg_textsearch
|
||||
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
timescale-init:
|
||||
condition: service_completed_successfully
|
||||
networks:
|
||||
- hindsight-net
|
||||
|
||||
|
||||
networks:
|
||||
hindsight-net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
pg_data:
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: hindsight
|
||||
description: Hindsight helm chart
|
||||
type: application
|
||||
version: 0.4.10
|
||||
appVersion: "0.4.10"
|
||||
version: 0.4.11
|
||||
appVersion: "0.4.11"
|
||||
keywords:
|
||||
- ai
|
||||
- memory
|
||||
|
||||
@@ -46,4 +46,4 @@ __all__ = [
|
||||
"RemoteTEICrossEncoder",
|
||||
"LLMConfig",
|
||||
]
|
||||
__version__ = "0.4.10"
|
||||
__version__ = "0.4.11"
|
||||
|
||||
@@ -24,14 +24,27 @@ depends_on: str | Sequence[str] | None = None
|
||||
|
||||
def _detect_vector_extension() -> str:
|
||||
"""
|
||||
Detect or validate vector extension: 'vchord' or 'pgvector'.
|
||||
Detect or validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
|
||||
Respects HINDSIGHT_API_VECTOR_EXTENSION env var if set.
|
||||
"""
|
||||
conn = op.get_bind()
|
||||
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
|
||||
|
||||
# Validate configured extension is installed
|
||||
if vector_extension == "vchord":
|
||||
if vector_extension == "pgvectorscale":
|
||||
# pgvectorscale requires pgvector
|
||||
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
|
||||
if not pgvector_check:
|
||||
raise RuntimeError(
|
||||
"pgvectorscale requires pgvector. Install with: CREATE EXTENSION vector; CREATE EXTENSION vectorscale CASCADE;"
|
||||
)
|
||||
vectorscale_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")).scalar()
|
||||
if not vectorscale_check:
|
||||
raise RuntimeError(
|
||||
"Configured vector extension 'pgvectorscale' not found. Install it with: CREATE EXTENSION vectorscale CASCADE;"
|
||||
)
|
||||
return "pgvectorscale"
|
||||
elif vector_extension == "vchord":
|
||||
vchord_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord'")).scalar()
|
||||
if not vchord_check:
|
||||
raise RuntimeError(
|
||||
@@ -46,12 +59,14 @@ def _detect_vector_extension() -> str:
|
||||
)
|
||||
return "pgvector"
|
||||
else:
|
||||
raise ValueError(f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {vector_extension}. Must be 'pgvector' or 'vchord'")
|
||||
raise ValueError(
|
||||
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {vector_extension}. Must be 'pgvector', 'vchord', or 'pgvectorscale'"
|
||||
)
|
||||
|
||||
|
||||
def _detect_text_search_extension() -> str:
|
||||
"""
|
||||
Detect or validate text search extension: 'native' or 'vchord'.
|
||||
Detect or validate text search extension: 'native', 'vchord', or 'pg_textsearch'.
|
||||
Respects HINDSIGHT_API_TEXT_SEARCH_EXTENSION env var.
|
||||
Creates the extension if needed.
|
||||
"""
|
||||
@@ -69,11 +84,23 @@ def _detect_text_search_extension() -> str:
|
||||
# Extension truly doesn't exist - re-raise the error
|
||||
raise
|
||||
return "vchord"
|
||||
elif text_search_extension == "pg_textsearch":
|
||||
# Create pg_textsearch extension if not exists
|
||||
try:
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS pg_textsearch CASCADE")
|
||||
except Exception:
|
||||
# Extension might already exist or user lacks permissions - verify it exists
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch'")).fetchone()
|
||||
if not result:
|
||||
# Extension truly doesn't exist - re-raise the error
|
||||
raise
|
||||
return "pg_textsearch"
|
||||
elif text_search_extension == "native":
|
||||
return "native"
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid HINDSIGHT_API_TEXT_SEARCH_EXTENSION: {text_search_extension}. Must be 'native' or 'vchord'"
|
||||
f"Invalid HINDSIGHT_API_TEXT_SEARCH_EXTENSION: {text_search_extension}. Must be 'native', 'vchord', or 'pg_textsearch'"
|
||||
)
|
||||
|
||||
|
||||
@@ -232,6 +259,12 @@ def upgrade() -> None:
|
||||
ALTER TABLE memory_units
|
||||
ADD COLUMN search_vector bm25_catalog.bm25vector
|
||||
""")
|
||||
elif text_search_ext == "pg_textsearch":
|
||||
# Timescale pg_textsearch: dummy TEXT column for consistency (indexes operate on base columns directly)
|
||||
op.execute("""
|
||||
ALTER TABLE memory_units
|
||||
ADD COLUMN search_vector TEXT
|
||||
""")
|
||||
else: # native
|
||||
# Native PostgreSQL: tsvector with automatic generation
|
||||
op.execute("""
|
||||
@@ -271,7 +304,14 @@ def upgrade() -> None:
|
||||
# Create vector index - conditional based on available extension
|
||||
vector_ext = _detect_vector_extension()
|
||||
|
||||
if vector_ext == "vchord":
|
||||
if vector_ext == "pgvectorscale":
|
||||
# Use DiskANN index for pgvectorscale (disk-based, scalable)
|
||||
op.execute("""
|
||||
CREATE INDEX idx_memory_units_embedding ON memory_units
|
||||
USING diskann (embedding vector_cosine_ops)
|
||||
WITH (num_neighbors = 50)
|
||||
""")
|
||||
elif vector_ext == "vchord":
|
||||
# Use vchordrq index for vchord (supports high-dimensional embeddings)
|
||||
op.execute("""
|
||||
CREATE INDEX idx_memory_units_embedding ON memory_units
|
||||
@@ -295,6 +335,14 @@ def upgrade() -> None:
|
||||
CREATE INDEX idx_memory_units_text_search ON memory_units
|
||||
USING bm25 (search_vector bm25_catalog.bm25_ops)
|
||||
""")
|
||||
elif text_search_ext == "pg_textsearch":
|
||||
# Timescale pg_textsearch BM25 index on text column
|
||||
# Note: pg_textsearch doesn't support expressions, so we index the main text column
|
||||
op.execute("""
|
||||
CREATE INDEX idx_memory_units_text_search ON memory_units
|
||||
USING bm25(text)
|
||||
WITH (text_config='english')
|
||||
""")
|
||||
else: # native
|
||||
# Native PostgreSQL GIN index
|
||||
op.execute("""
|
||||
|
||||
+65
-7
@@ -31,14 +31,27 @@ def _get_schema_prefix() -> str:
|
||||
|
||||
def _detect_vector_extension() -> str:
|
||||
"""
|
||||
Detect or validate vector extension: 'vchord' or 'pgvector'.
|
||||
Detect or validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
|
||||
Respects HINDSIGHT_API_VECTOR_EXTENSION env var if set.
|
||||
"""
|
||||
conn = op.get_bind()
|
||||
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
|
||||
|
||||
# Validate configured extension is installed
|
||||
if vector_extension == "vchord":
|
||||
if vector_extension == "pgvectorscale":
|
||||
# pgvectorscale requires pgvector
|
||||
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
|
||||
if not pgvector_check:
|
||||
raise RuntimeError(
|
||||
"pgvectorscale requires pgvector. Install with: CREATE EXTENSION vector; CREATE EXTENSION vectorscale CASCADE;"
|
||||
)
|
||||
vectorscale_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")).scalar()
|
||||
if not vectorscale_check:
|
||||
raise RuntimeError(
|
||||
"Configured vector extension 'pgvectorscale' not found. Install it with: CREATE EXTENSION vectorscale CASCADE;"
|
||||
)
|
||||
return "pgvectorscale"
|
||||
elif vector_extension == "vchord":
|
||||
vchord_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord'")).scalar()
|
||||
if not vchord_check:
|
||||
raise RuntimeError(
|
||||
@@ -53,12 +66,14 @@ def _detect_vector_extension() -> str:
|
||||
)
|
||||
return "pgvector"
|
||||
else:
|
||||
raise ValueError(f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {vector_extension}. Must be 'pgvector' or 'vchord'")
|
||||
raise ValueError(
|
||||
f"Invalid HINDSIGHT_API_VECTOR_EXTENSION: {vector_extension}. Must be 'pgvector', 'vchord', or 'pgvectorscale'"
|
||||
)
|
||||
|
||||
|
||||
def _detect_text_search_extension() -> str:
|
||||
"""
|
||||
Detect or validate text search extension: 'native' or 'vchord'.
|
||||
Detect or validate text search extension: 'native', 'vchord', or 'pg_textsearch'.
|
||||
Respects HINDSIGHT_API_TEXT_SEARCH_EXTENSION env var.
|
||||
Creates the extension if needed.
|
||||
"""
|
||||
@@ -76,11 +91,23 @@ def _detect_text_search_extension() -> str:
|
||||
# Extension truly doesn't exist - re-raise the error
|
||||
raise
|
||||
return "vchord"
|
||||
elif text_search_extension == "pg_textsearch":
|
||||
# Create pg_textsearch extension if not exists
|
||||
try:
|
||||
op.execute("CREATE EXTENSION IF NOT EXISTS pg_textsearch CASCADE")
|
||||
except Exception:
|
||||
# Extension might already exist or user lacks permissions - verify it exists
|
||||
conn = op.get_bind()
|
||||
result = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'pg_textsearch'")).fetchone()
|
||||
if not result:
|
||||
# Extension truly doesn't exist - re-raise the error
|
||||
raise
|
||||
return "pg_textsearch"
|
||||
elif text_search_extension == "native":
|
||||
return "native"
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Invalid HINDSIGHT_API_TEXT_SEARCH_EXTENSION: {text_search_extension}. Must be 'native' or 'vchord'"
|
||||
f"Invalid HINDSIGHT_API_TEXT_SEARCH_EXTENSION: {text_search_extension}. Must be 'native', 'vchord', or 'pg_textsearch'"
|
||||
)
|
||||
|
||||
|
||||
@@ -122,7 +149,13 @@ def upgrade() -> None:
|
||||
op.execute(f"CREATE INDEX idx_learnings_bank_id ON {schema}learnings(bank_id)")
|
||||
|
||||
# Create vector index based on detected extension
|
||||
if vector_ext == "vchord":
|
||||
if vector_ext == "pgvectorscale":
|
||||
op.execute(f"""
|
||||
CREATE INDEX idx_learnings_embedding ON {schema}learnings
|
||||
USING diskann (embedding vector_cosine_ops)
|
||||
WITH (num_neighbors = 50)
|
||||
""")
|
||||
elif vector_ext == "vchord":
|
||||
op.execute(f"""
|
||||
CREATE INDEX idx_learnings_embedding ON {schema}learnings
|
||||
USING vchordrq (embedding vector_l2_ops)
|
||||
@@ -146,6 +179,15 @@ def upgrade() -> None:
|
||||
CREATE INDEX idx_learnings_text_search ON {schema}learnings
|
||||
USING bm25 (search_vector bm25_catalog.bm25_ops)
|
||||
""")
|
||||
elif text_search_ext == "pg_textsearch":
|
||||
# Timescale pg_textsearch: dummy TEXT column for consistency (indexes operate on base columns directly)
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}learnings ADD COLUMN search_vector TEXT
|
||||
""")
|
||||
op.execute(f"""
|
||||
CREATE INDEX idx_learnings_text_search ON {schema}learnings
|
||||
USING bm25(text) WITH (text_config='english')
|
||||
""")
|
||||
else: # native
|
||||
# Native PostgreSQL: tsvector with automatic generation
|
||||
op.execute(f"""
|
||||
@@ -180,7 +222,13 @@ def upgrade() -> None:
|
||||
op.execute(f"CREATE INDEX idx_pinned_reflections_bank_id ON {schema}pinned_reflections(bank_id)")
|
||||
|
||||
# Create vector index based on detected extension
|
||||
if vector_ext == "vchord":
|
||||
if vector_ext == "pgvectorscale":
|
||||
op.execute(f"""
|
||||
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
|
||||
USING diskann (embedding vector_cosine_ops)
|
||||
WITH (num_neighbors = 50)
|
||||
""")
|
||||
elif vector_ext == "vchord":
|
||||
op.execute(f"""
|
||||
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
|
||||
USING vchordrq (embedding vector_l2_ops)
|
||||
@@ -204,6 +252,16 @@ def upgrade() -> None:
|
||||
CREATE INDEX idx_pinned_reflections_text_search ON {schema}pinned_reflections
|
||||
USING bm25 (search_vector bm25_catalog.bm25_ops)
|
||||
""")
|
||||
elif text_search_ext == "pg_textsearch":
|
||||
# Timescale pg_textsearch: dummy TEXT column for consistency (indexes operate on base columns directly)
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}pinned_reflections ADD COLUMN search_vector TEXT
|
||||
""")
|
||||
op.execute(f"""
|
||||
CREATE INDEX idx_pinned_reflections_text_search ON {schema}pinned_reflections
|
||||
USING bm25(content)
|
||||
WITH (text_config='english')
|
||||
""")
|
||||
else: # native
|
||||
# Native PostgreSQL: tsvector with automatic generation
|
||||
op.execute(f"""
|
||||
|
||||
@@ -86,6 +86,8 @@ def print_startup_info(
|
||||
reranker_provider: str,
|
||||
mcp_enabled: bool = False,
|
||||
version: str | None = None,
|
||||
vector_extension: str | None = None,
|
||||
text_search_extension: str | None = None,
|
||||
):
|
||||
"""Print styled startup information."""
|
||||
print(color_start("Starting Hindsight API..."))
|
||||
@@ -96,6 +98,8 @@ def print_startup_info(
|
||||
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)}")
|
||||
extensions = f"{vector_extension or 'default'} (vector) / {text_search_extension or 'default'} (text)"
|
||||
print(f" {dim('Extensions:')} {color(extensions, 0.4)}")
|
||||
if mcp_enabled:
|
||||
print(f" {dim('MCP:')} {color_end('enabled at /mcp')}")
|
||||
print()
|
||||
|
||||
@@ -189,6 +189,14 @@ ENV_RERANKER_LITELLM_API_BASE = "HINDSIGHT_API_RERANKER_LITELLM_API_BASE"
|
||||
ENV_RERANKER_LITELLM_API_KEY = "HINDSIGHT_API_RERANKER_LITELLM_API_KEY"
|
||||
ENV_RERANKER_LITELLM_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_MODEL"
|
||||
|
||||
# LiteLLM SDK configuration (direct API access, no proxy needed)
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_API_KEY = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_KEY"
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_MODEL"
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_API_BASE = "HINDSIGHT_API_EMBEDDINGS_LITELLM_SDK_API_BASE"
|
||||
ENV_RERANKER_LITELLM_SDK_API_KEY = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY"
|
||||
ENV_RERANKER_LITELLM_SDK_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL"
|
||||
ENV_RERANKER_LITELLM_SDK_API_BASE = "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_BASE"
|
||||
|
||||
# Deprecated: Legacy shared LiteLLM config (for backward compatibility)
|
||||
ENV_LITELLM_API_BASE = "HINDSIGHT_API_LITELLM_API_BASE"
|
||||
ENV_LITELLM_API_KEY = "HINDSIGHT_API_LITELLM_API_KEY"
|
||||
@@ -326,17 +334,21 @@ DEFAULT_RERANKER_FLASHRANK_CACHE_DIR = None # Use default cache directory
|
||||
DEFAULT_EMBEDDINGS_COHERE_MODEL = "embed-english-v3.0"
|
||||
DEFAULT_RERANKER_COHERE_MODEL = "rerank-english-v3.0"
|
||||
|
||||
# Vector extension (pgvector vs vchord)
|
||||
DEFAULT_VECTOR_EXTENSION = "pgvector" # Options: "pgvector", "vchord"
|
||||
# Vector extension (pgvector, vchord, or pgvectorscale)
|
||||
DEFAULT_VECTOR_EXTENSION = "pgvector" # Options: "pgvector", "vchord", "pgvectorscale"
|
||||
|
||||
# Text search extension (native PostgreSQL vs vchord BM25)
|
||||
DEFAULT_TEXT_SEARCH_EXTENSION = "native" # Options: "native", "vchord"
|
||||
# Text search extension (native PostgreSQL, vchord BM25, or Timescale pg_textsearch)
|
||||
DEFAULT_TEXT_SEARCH_EXTENSION = "native" # Options: "native", "vchord", "pg_textsearch"
|
||||
|
||||
# LiteLLM defaults
|
||||
DEFAULT_LITELLM_API_BASE = "http://localhost:4000"
|
||||
DEFAULT_EMBEDDINGS_LITELLM_MODEL = "text-embedding-3-small"
|
||||
DEFAULT_RERANKER_LITELLM_MODEL = "cohere/rerank-english-v3.0"
|
||||
|
||||
# LiteLLM SDK defaults
|
||||
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL = "cohere/embed-english-v3.0"
|
||||
DEFAULT_RERANKER_LITELLM_SDK_MODEL = "cohere/rerank-english-v3.0"
|
||||
|
||||
DEFAULT_HOST = "0.0.0.0"
|
||||
DEFAULT_PORT = 8888
|
||||
DEFAULT_BASE_PATH = "" # Empty string = root path
|
||||
@@ -532,6 +544,9 @@ class HindsightConfig:
|
||||
embeddings_litellm_api_base: str
|
||||
embeddings_litellm_api_key: str | None
|
||||
embeddings_litellm_model: str
|
||||
embeddings_litellm_sdk_api_key: str | None
|
||||
embeddings_litellm_sdk_model: str
|
||||
embeddings_litellm_sdk_api_base: str | None
|
||||
|
||||
# Reranker
|
||||
reranker_provider: str
|
||||
@@ -549,6 +564,9 @@ class HindsightConfig:
|
||||
reranker_litellm_api_base: str
|
||||
reranker_litellm_api_key: str | None
|
||||
reranker_litellm_model: str
|
||||
reranker_litellm_sdk_api_key: str | None
|
||||
reranker_litellm_sdk_model: str
|
||||
reranker_litellm_sdk_api_base: str | None
|
||||
|
||||
# Server
|
||||
host: str
|
||||
@@ -699,14 +717,14 @@ class HindsightConfig:
|
||||
def validate(self) -> None:
|
||||
"""Validate configuration values and raise errors for invalid combinations."""
|
||||
# Validate vector_extension
|
||||
valid_extensions = ("pgvector", "vchord")
|
||||
valid_extensions = ("pgvector", "vchord", "pgvectorscale")
|
||||
if self.vector_extension not in valid_extensions:
|
||||
raise ValueError(
|
||||
f"Invalid vector_extension: {self.vector_extension}. Must be one of: {', '.join(valid_extensions)}"
|
||||
)
|
||||
|
||||
# Validate text_search_extension
|
||||
valid_text_search = ("native", "vchord")
|
||||
valid_text_search = ("native", "vchord", "pg_textsearch")
|
||||
if self.text_search_extension not in valid_text_search:
|
||||
raise ValueError(
|
||||
f"Invalid text_search_extension: {self.text_search_extension}. Must be one of: {', '.join(valid_text_search)}"
|
||||
@@ -847,6 +865,12 @@ class HindsightConfig:
|
||||
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
|
||||
embeddings_litellm_api_key=os.getenv(ENV_EMBEDDINGS_LITELLM_API_KEY) or os.getenv(ENV_LITELLM_API_KEY),
|
||||
embeddings_litellm_model=os.getenv(ENV_EMBEDDINGS_LITELLM_MODEL, DEFAULT_EMBEDDINGS_LITELLM_MODEL),
|
||||
# LiteLLM SDK embeddings (direct API access)
|
||||
embeddings_litellm_sdk_api_key=os.getenv(ENV_EMBEDDINGS_LITELLM_SDK_API_KEY),
|
||||
embeddings_litellm_sdk_model=os.getenv(
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_MODEL, DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL
|
||||
),
|
||||
embeddings_litellm_sdk_api_base=os.getenv(ENV_EMBEDDINGS_LITELLM_SDK_API_BASE) 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),
|
||||
@@ -876,6 +900,10 @@ class HindsightConfig:
|
||||
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
|
||||
reranker_litellm_api_key=os.getenv(ENV_RERANKER_LITELLM_API_KEY) or os.getenv(ENV_LITELLM_API_KEY),
|
||||
reranker_litellm_model=os.getenv(ENV_RERANKER_LITELLM_MODEL, DEFAULT_RERANKER_LITELLM_MODEL),
|
||||
# LiteLLM SDK reranker (direct API access)
|
||||
reranker_litellm_sdk_api_key=os.getenv(ENV_RERANKER_LITELLM_SDK_API_KEY),
|
||||
reranker_litellm_sdk_model=os.getenv(ENV_RERANKER_LITELLM_SDK_MODEL, DEFAULT_RERANKER_LITELLM_SDK_MODEL),
|
||||
reranker_litellm_sdk_api_base=os.getenv(ENV_RERANKER_LITELLM_SDK_API_BASE) or None,
|
||||
# Server
|
||||
host=os.getenv(ENV_HOST, DEFAULT_HOST),
|
||||
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
|
||||
|
||||
@@ -1030,8 +1030,9 @@ async def _create_observation_directly(
|
||||
tokenize($3, 'llmlingua2')::bm25_catalog.bm25vector)
|
||||
RETURNING id
|
||||
"""
|
||||
else: # native
|
||||
else: # native or pg_textsearch
|
||||
# Native PostgreSQL: search_vector is GENERATED ALWAYS, don't include it
|
||||
# pg_textsearch: indexes operate on base columns directly, don't populate search_vector
|
||||
query = f"""
|
||||
INSERT INTO {fq_table("memory_units")} (
|
||||
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
|
||||
|
||||
@@ -21,6 +21,7 @@ from ..config import (
|
||||
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR,
|
||||
DEFAULT_RERANKER_FLASHRANK_MODEL,
|
||||
DEFAULT_RERANKER_LITELLM_MODEL,
|
||||
DEFAULT_RERANKER_LITELLM_SDK_MODEL,
|
||||
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
|
||||
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
|
||||
DEFAULT_RERANKER_LOCAL_MODEL,
|
||||
@@ -32,6 +33,7 @@ from ..config import (
|
||||
ENV_RERANKER_COHERE_MODEL,
|
||||
ENV_RERANKER_FLASHRANK_CACHE_DIR,
|
||||
ENV_RERANKER_FLASHRANK_MODEL,
|
||||
ENV_RERANKER_LITELLM_SDK_API_KEY,
|
||||
ENV_RERANKER_LOCAL_FORCE_CPU,
|
||||
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
|
||||
ENV_RERANKER_LOCAL_MODEL,
|
||||
@@ -828,6 +830,126 @@ class LiteLLMCrossEncoder(CrossEncoderModel):
|
||||
return all_scores
|
||||
|
||||
|
||||
class LiteLLMSDKCrossEncoder(CrossEncoderModel):
|
||||
"""
|
||||
LiteLLM SDK cross-encoder for direct API integration.
|
||||
|
||||
Supports reranking via LiteLLM SDK without requiring a proxy server.
|
||||
Supported providers: Cohere, DeepInfra, Together AI, HuggingFace, Jina AI, Voyage AI, AWS Bedrock.
|
||||
|
||||
Example model names:
|
||||
- cohere/rerank-english-v3.0
|
||||
- deepinfra/Qwen3-reranker-8B
|
||||
- together_ai/Salesforce/Llama-Rank-V1
|
||||
- huggingface/BAAI/bge-reranker-v2-m3
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
model: str = DEFAULT_RERANKER_LITELLM_SDK_MODEL,
|
||||
api_base: str | None = None,
|
||||
timeout: float = 60.0,
|
||||
):
|
||||
"""
|
||||
Initialize LiteLLM SDK cross-encoder client.
|
||||
|
||||
Args:
|
||||
api_key: API key for the reranking provider
|
||||
model: Model name with provider prefix (e.g., "deepinfra/Qwen3-reranker-8B")
|
||||
api_base: Custom base URL for API (optional)
|
||||
timeout: Request timeout in seconds (default: 60.0)
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.api_base = api_base
|
||||
self.timeout = timeout
|
||||
self._initialized = False
|
||||
self._litellm = None # Will be set during initialization
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "litellm-sdk"
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the LiteLLM SDK client."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
import litellm
|
||||
|
||||
self._litellm = litellm # Store reference
|
||||
except ImportError:
|
||||
raise ImportError("litellm is required for LiteLLMSDKCrossEncoder. Install it with: pip install litellm")
|
||||
|
||||
api_base_msg = f" at {self.api_base}" if self.api_base else ""
|
||||
logger.info(f"Reranker: initializing LiteLLM SDK provider with model {self.model}{api_base_msg}")
|
||||
|
||||
self._initialized = True
|
||||
logger.info("Reranker: LiteLLM SDK provider initialized")
|
||||
|
||||
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""
|
||||
Score query-document pairs using the LiteLLM SDK.
|
||||
|
||||
Args:
|
||||
pairs: List of (query, document) tuples to score
|
||||
|
||||
Returns:
|
||||
List of relevance scores
|
||||
"""
|
||||
if not self._initialized:
|
||||
raise RuntimeError("Reranker not initialized. Call initialize() first.")
|
||||
|
||||
if not pairs:
|
||||
return []
|
||||
|
||||
# Group pairs by query for efficient batching
|
||||
# LiteLLM rerank expects one query with multiple documents
|
||||
query_groups: dict[str, list[tuple[int, str]]] = {}
|
||||
for idx, (query, text) in enumerate(pairs):
|
||||
if query not in query_groups:
|
||||
query_groups[query] = []
|
||||
query_groups[query].append((idx, text))
|
||||
|
||||
all_scores = [0.0] * len(pairs)
|
||||
|
||||
for query, indexed_texts in query_groups.items():
|
||||
texts = [text for _, text in indexed_texts]
|
||||
indices = [idx for idx, _ in indexed_texts]
|
||||
|
||||
# Build kwargs for rerank call
|
||||
rerank_kwargs = {
|
||||
"model": self.model,
|
||||
"query": query,
|
||||
"documents": texts,
|
||||
"api_key": self.api_key,
|
||||
}
|
||||
if self.api_base:
|
||||
rerank_kwargs["api_base"] = self.api_base
|
||||
|
||||
response = await self._litellm.arerank(**rerank_kwargs)
|
||||
|
||||
# Map scores back to original positions
|
||||
# Response format: RerankResponse with results list
|
||||
# Each result is a TypedDict with "index" and "relevance_score"
|
||||
if hasattr(response, "results") and response.results:
|
||||
for result in response.results:
|
||||
# Results are TypedDicts, use dict-style access
|
||||
original_idx = result["index"]
|
||||
score = result.get("relevance_score", result.get("score", 0.0))
|
||||
all_scores[indices[original_idx]] = score
|
||||
elif isinstance(response, list):
|
||||
# Direct list of scores (unlikely but defensive)
|
||||
for i, score in enumerate(response):
|
||||
all_scores[indices[i]] = score
|
||||
else:
|
||||
logger.warning(f"Unexpected response format from LiteLLM rerank: {type(response)}")
|
||||
|
||||
return all_scores
|
||||
|
||||
|
||||
def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
"""
|
||||
Create a CrossEncoderModel instance based on configuration.
|
||||
@@ -877,9 +999,20 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
api_key=config.reranker_litellm_api_key,
|
||||
model=config.reranker_litellm_model,
|
||||
)
|
||||
elif provider == "litellm-sdk":
|
||||
api_key = config.reranker_litellm_sdk_api_key
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"{ENV_RERANKER_LITELLM_SDK_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'litellm-sdk'"
|
||||
)
|
||||
return LiteLLMSDKCrossEncoder(
|
||||
api_key=api_key,
|
||||
model=config.reranker_litellm_sdk_model,
|
||||
api_base=config.reranker_litellm_sdk_api_base,
|
||||
)
|
||||
elif provider == "rrf":
|
||||
return RRFPassthroughCrossEncoder()
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'flashrank', 'litellm', 'rrf'"
|
||||
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere', 'flashrank', 'litellm', 'litellm-sdk', 'rrf'"
|
||||
)
|
||||
|
||||
@@ -19,6 +19,7 @@ import httpx
|
||||
from ..config import (
|
||||
DEFAULT_EMBEDDINGS_COHERE_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
|
||||
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
|
||||
@@ -26,6 +27,7 @@ from ..config import (
|
||||
DEFAULT_EMBEDDINGS_PROVIDER,
|
||||
DEFAULT_LITELLM_API_BASE,
|
||||
ENV_EMBEDDINGS_COHERE_API_KEY,
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_API_KEY,
|
||||
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
|
||||
ENV_EMBEDDINGS_LOCAL_MODEL,
|
||||
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
|
||||
@@ -720,6 +722,148 @@ class LiteLLMEmbeddings(Embeddings):
|
||||
return all_embeddings
|
||||
|
||||
|
||||
class LiteLLMSDKEmbeddings(Embeddings):
|
||||
"""
|
||||
LiteLLM SDK embeddings for direct API integration.
|
||||
|
||||
Supports embeddings via LiteLLM SDK without requiring a proxy server.
|
||||
Supported providers: Cohere, OpenAI, Azure OpenAI, HuggingFace, Voyage AI, Together AI, etc.
|
||||
|
||||
Example model names:
|
||||
- cohere/embed-english-v3.0
|
||||
- openai/text-embedding-3-small
|
||||
- together_ai/togethercomputer/m2-bert-80M-8k-retrieval
|
||||
- voyage/voyage-2
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
model: str = DEFAULT_EMBEDDINGS_LITELLM_SDK_MODEL,
|
||||
api_base: str | None = None,
|
||||
batch_size: int = 100,
|
||||
timeout: float = 60.0,
|
||||
):
|
||||
"""
|
||||
Initialize LiteLLM SDK embeddings client.
|
||||
|
||||
Args:
|
||||
api_key: API key for the embedding provider
|
||||
model: Model name with provider prefix (e.g., "cohere/embed-english-v3.0")
|
||||
api_base: Custom base URL for API (optional)
|
||||
batch_size: Maximum batch size for embedding requests (default: 100)
|
||||
timeout: Request timeout in seconds (default: 60.0)
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.api_base = api_base
|
||||
self.batch_size = batch_size
|
||||
self.timeout = timeout
|
||||
self._litellm = None # Will be set during initialization
|
||||
self._dimension: int | None = None
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "litellm-sdk"
|
||||
|
||||
@property
|
||||
def dimension(self) -> int:
|
||||
if self._dimension is None:
|
||||
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
|
||||
return self._dimension
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the LiteLLM SDK client and detect dimension."""
|
||||
if self._litellm is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
import litellm
|
||||
|
||||
self._litellm = litellm # Store reference
|
||||
except ImportError:
|
||||
raise ImportError("litellm is required for LiteLLMSDKEmbeddings. Install it with: pip install litellm")
|
||||
|
||||
api_base_msg = f" at {self.api_base}" if self.api_base else ""
|
||||
logger.info(f"Embeddings: initializing LiteLLM SDK provider with model {self.model}{api_base_msg}")
|
||||
|
||||
# Do a test embedding to detect dimension
|
||||
try:
|
||||
# Build kwargs for embedding call
|
||||
embed_kwargs = {
|
||||
"model": self.model,
|
||||
"input": ["test"],
|
||||
"api_key": self.api_key,
|
||||
}
|
||||
if self.api_base:
|
||||
embed_kwargs["api_base"] = self.api_base
|
||||
|
||||
# Use async embedding method (standard in litellm)
|
||||
response = await self._litellm.aembedding(**embed_kwargs)
|
||||
|
||||
# Extract dimension from response
|
||||
if response.data and len(response.data) > 0:
|
||||
self._dimension = len(response.data[0]["embedding"])
|
||||
else:
|
||||
raise RuntimeError(f"Unable to detect embedding dimension for model {self.model}")
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to initialize LiteLLM SDK embeddings: {e}")
|
||||
|
||||
logger.info(f"Embeddings: LiteLLM SDK provider initialized (model: {self.model}, dim: {self._dimension})")
|
||||
|
||||
def encode(self, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings using the LiteLLM SDK.
|
||||
|
||||
Args:
|
||||
texts: List of text strings to encode
|
||||
|
||||
Returns:
|
||||
List of embedding vectors (one per input text)
|
||||
"""
|
||||
if self._litellm 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:
|
||||
# Build kwargs for embedding call
|
||||
embed_kwargs = {
|
||||
"model": self.model,
|
||||
"input": batch,
|
||||
"api_key": self.api_key,
|
||||
}
|
||||
if self.api_base:
|
||||
embed_kwargs["api_base"] = self.api_base
|
||||
|
||||
# Use sync embedding (litellm doesn't have async in thread-safe way)
|
||||
response = self._litellm.embedding(**embed_kwargs)
|
||||
|
||||
# Extract embeddings from response
|
||||
# Sort by index to ensure correct order
|
||||
batch_embeddings = sorted(response.data, key=lambda x: x.get("index", 0))
|
||||
all_embeddings.extend([e["embedding"] for e in batch_embeddings])
|
||||
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
logger.error(
|
||||
f"Error in LiteLLM embedding for batch starting at index {i}: {e}\n"
|
||||
f"Traceback: {traceback.format_exc()}"
|
||||
)
|
||||
raise
|
||||
|
||||
return all_embeddings
|
||||
|
||||
|
||||
def create_embeddings_from_env() -> Embeddings:
|
||||
"""
|
||||
Create an Embeddings instance based on configuration.
|
||||
@@ -771,7 +915,19 @@ def create_embeddings_from_env() -> Embeddings:
|
||||
api_key=config.embeddings_litellm_api_key,
|
||||
model=config.embeddings_litellm_model,
|
||||
)
|
||||
elif provider == "litellm-sdk":
|
||||
api_key = config.embeddings_litellm_sdk_api_key
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"{ENV_EMBEDDINGS_LITELLM_SDK_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'litellm-sdk'"
|
||||
)
|
||||
return LiteLLMSDKEmbeddings(
|
||||
api_key=api_key,
|
||||
model=config.embeddings_litellm_sdk_model,
|
||||
api_base=config.embeddings_litellm_sdk_api_base,
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei', 'openai', 'cohere', 'litellm'"
|
||||
f"Unknown embeddings provider: {provider}. "
|
||||
f"Supported: 'local', 'tei', 'openai', 'cohere', 'litellm', 'litellm-sdk'"
|
||||
)
|
||||
|
||||
@@ -48,6 +48,7 @@ class MemoryEngineInterface(ABC):
|
||||
contents: list[dict[str, Any]],
|
||||
*,
|
||||
request_context: "RequestContext",
|
||||
document_tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Retain a batch of memory items.
|
||||
@@ -55,8 +56,9 @@ class MemoryEngineInterface(ABC):
|
||||
Args:
|
||||
bank_id: The memory bank ID.
|
||||
contents: List of content dicts with 'content', optional 'event_date',
|
||||
'context', 'metadata', 'document_id'.
|
||||
'context', 'metadata', 'document_id', and per-item 'tags'.
|
||||
request_context: Request context for authentication.
|
||||
document_tags: Optional tags applied to all items in the batch.
|
||||
|
||||
Returns:
|
||||
Dict with processing results.
|
||||
@@ -561,6 +563,7 @@ class MemoryEngineInterface(ABC):
|
||||
contents: list[dict[str, Any]],
|
||||
*,
|
||||
request_context: "RequestContext",
|
||||
document_tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Submit a batch retain operation to run asynchronously.
|
||||
@@ -569,6 +572,7 @@ class MemoryEngineInterface(ABC):
|
||||
bank_id: The memory bank ID.
|
||||
contents: List of content dicts to retain.
|
||||
request_context: Request context for authentication.
|
||||
document_tags: Optional tags applied to all items in the async batch.
|
||||
|
||||
Returns:
|
||||
Dict with operation_id and items_count.
|
||||
|
||||
@@ -540,6 +540,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if not bank_id:
|
||||
raise ValueError("bank_id is required for batch retain task")
|
||||
contents = task_dict.get("contents", [])
|
||||
document_tags = task_dict.get("document_tags")
|
||||
|
||||
logger.info(
|
||||
f"[BATCH_RETAIN_TASK] Starting background batch retain for bank_id={bank_id}, {len(contents)} items"
|
||||
@@ -557,7 +558,12 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
tenant_id=task_dict.get("_tenant_id"),
|
||||
api_key_id=task_dict.get("_api_key_id"),
|
||||
)
|
||||
await self.retain_batch_async(bank_id=bank_id, contents=contents, request_context=context)
|
||||
await self.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=contents,
|
||||
document_tags=document_tags,
|
||||
request_context=context,
|
||||
)
|
||||
|
||||
logger.info(f"[BATCH_RETAIN_TASK] Completed background batch retain for bank_id={bank_id}")
|
||||
|
||||
@@ -1682,15 +1688,21 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
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)
|
||||
NOTE: Chunks are fetched independently of max_tokens filtering.
|
||||
This means setting max_tokens=0 will return 0 facts but can still
|
||||
return chunks from the top-scored (reranked) results.
|
||||
Chunks are fetched in batches (estimated as (max_chunk_tokens // retain_chunk_size) * 2)
|
||||
until the token budget is exhausted or all chunks are fetched.
|
||||
This handles varying chunk sizes across documents.
|
||||
tags: Optional list of tags for visibility filtering (OR matching - returns
|
||||
memories that have at least one matching tag)
|
||||
|
||||
Returns:
|
||||
RecallResultModel containing:
|
||||
- results: List of MemoryFact objects
|
||||
- results: List of MemoryFact objects (filtered by max_tokens)
|
||||
- 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)
|
||||
- chunks: Optional dict of chunks (if include_chunks=True, independent of max_tokens)
|
||||
"""
|
||||
# Authenticate tenant and set schema in context (for fq_table())
|
||||
await self._authenticate_tenant(request_context)
|
||||
@@ -1918,7 +1930,8 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
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
|
||||
5. Chunks: Fetch chunks from top-scored results (BEFORE token filtering)
|
||||
6. Token Filter: Limit facts to max_tokens budget
|
||||
|
||||
Args:
|
||||
bank_id: bank IDentifier
|
||||
@@ -1929,7 +1942,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
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
|
||||
include_chunks: Whether to include raw chunks (fetched before max_tokens filtering)
|
||||
max_chunk_tokens: Maximum tokens for chunks
|
||||
|
||||
Returns:
|
||||
@@ -2352,6 +2365,85 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
top_scored = scored_results[:rerank_limit]
|
||||
log_buffer.append(f" [5] Truncated to top {len(top_scored)} results")
|
||||
|
||||
# Step 5.5: Fetch chunks from top-scored results (before token filtering)
|
||||
# Chunks are fetched independently of max_tokens filtering
|
||||
chunks_dict = None
|
||||
total_chunk_tokens = 0
|
||||
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:
|
||||
# Estimate batch size based on retain_chunk_size * 2 (rough estimate)
|
||||
# Chunk sizes vary per document, so we fetch in batches until budget is exhausted
|
||||
bank_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
|
||||
estimated_batch_size = max(1, (max_chunk_tokens // bank_config.retain_chunk_size) * 2)
|
||||
|
||||
chunks_dict = {}
|
||||
encoding = _get_tiktoken_encoding()
|
||||
chunk_offset = 0
|
||||
|
||||
# Fetch chunks in batches until we run out of budget or chunks
|
||||
while chunk_offset < len(chunk_ids_ordered) and total_chunk_tokens < max_chunk_tokens:
|
||||
# Get next batch of chunk IDs
|
||||
batch_chunk_ids = chunk_ids_ordered[chunk_offset : chunk_offset + estimated_batch_size]
|
||||
chunk_offset += estimated_batch_size
|
||||
|
||||
# Fetch chunk data from database
|
||||
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[])
|
||||
""",
|
||||
batch_chunk_ids,
|
||||
)
|
||||
|
||||
# Create a lookup dict for fast access (preserves order from batch_chunk_ids)
|
||||
chunks_lookup = {row["chunk_id"]: row for row in chunks_rows}
|
||||
|
||||
# Process chunks in order, respecting token budget
|
||||
for chunk_id in batch_chunk_ids:
|
||||
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
|
||||
# Budget exhausted - stop fetching more batches
|
||||
break
|
||||
else:
|
||||
chunks_dict[chunk_id] = ChunkInfo(
|
||||
chunk_text=chunk_text, chunk_index=row["chunk_index"], truncated=False
|
||||
)
|
||||
total_chunk_tokens += chunk_tokens
|
||||
|
||||
# If we hit the budget limit in this batch, stop fetching more batches
|
||||
if total_chunk_tokens >= max_chunk_tokens:
|
||||
break
|
||||
|
||||
# Step 6: Token budget filtering
|
||||
step_start = time.time()
|
||||
|
||||
@@ -2446,68 +2538,6 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Entity observations removed - always set to None
|
||||
entities_dict = None
|
||||
|
||||
# Fetch chunks if requested
|
||||
chunks_dict = None
|
||||
total_chunk_tokens = 0
|
||||
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:
|
||||
|
||||
@@ -97,8 +97,9 @@ async def insert_facts_batch(
|
||||
FROM input_data
|
||||
RETURNING id
|
||||
"""
|
||||
else: # native
|
||||
else: # native or pg_textsearch
|
||||
# Native PostgreSQL: search_vector is GENERATED ALWAYS, don't include it
|
||||
# pg_textsearch: indexes operate on base columns directly, don't populate search_vector
|
||||
query = f"""
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
|
||||
@@ -164,99 +164,74 @@ async def retrieve_semantic_bm25_combined(
|
||||
# Build tags clause - param 6 if tags provided
|
||||
tags_clause = build_tags_where_clause_simple(tags, 6, match=tags_match)
|
||||
|
||||
# Build backend-specific BM25 parts
|
||||
if config.text_search_extension == "vchord":
|
||||
# VectorChord BM25: use <&> operator with to_bm25query and tokenize
|
||||
# Note: VectorChord scores are negative (higher = better, so -1 > -10)
|
||||
bm25_score_expr = "search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize($5, 'llmlingua2'))"
|
||||
bm25_order_by = f"{bm25_score_expr} DESC"
|
||||
bm25_where_filter = "" # No additional WHERE filter for vchord
|
||||
params = [query_emb_str, bank_id, fact_types, limit, query_text] # Pass raw query_text for tokenization
|
||||
if tags:
|
||||
params.append(tags)
|
||||
|
||||
query = f"""
|
||||
WITH semantic_ranked AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity,
|
||||
NULL::float AS bm25_score,
|
||||
'semantic' AS source,
|
||||
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY embedding <=> $1::vector) AS rn
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
AND embedding IS NOT NULL
|
||||
AND fact_type = ANY($3)
|
||||
AND (1 - (embedding <=> $1::vector)) >= 0.3
|
||||
{tags_clause}
|
||||
),
|
||||
bm25_ranked AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
NULL::float AS similarity,
|
||||
search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize($5, 'llmlingua2')) AS bm25_score,
|
||||
'bm25' AS source,
|
||||
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY search_vector <&> to_bm25query('idx_memory_units_text_search', tokenize($5, 'llmlingua2')) DESC) AS rn
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
AND fact_type = ANY($3)
|
||||
{tags_clause}
|
||||
),
|
||||
semantic AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
similarity, bm25_score, source
|
||||
FROM semantic_ranked WHERE rn <= $4
|
||||
),
|
||||
bm25 AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
similarity, bm25_score, source
|
||||
FROM bm25_ranked WHERE rn <= $4
|
||||
)
|
||||
SELECT * FROM semantic
|
||||
UNION ALL
|
||||
SELECT * FROM bm25
|
||||
"""
|
||||
elif config.text_search_extension == "pg_textsearch":
|
||||
# Timescale pg_textsearch: use <@> operator with to_bm25query
|
||||
# Note: pg_textsearch scores are negative (lower/more negative = better, so -10 > -1)
|
||||
# We negate the score to maintain API consistency (higher = better)
|
||||
bm25_score_expr = "-(text <@> to_bm25query($5, 'idx_memory_units_text_search'))"
|
||||
bm25_order_by = "text <@> to_bm25query($5, 'idx_memory_units_text_search') ASC"
|
||||
bm25_where_filter = "" # No additional WHERE filter for pg_textsearch
|
||||
params = [query_emb_str, bank_id, fact_types, limit, query_text]
|
||||
else: # native
|
||||
# Native PostgreSQL: use ts_rank_cd with to_tsquery
|
||||
query_tsquery = " | ".join(tokens)
|
||||
bm25_score_expr = "ts_rank_cd(search_vector, to_tsquery('english', $5))"
|
||||
bm25_order_by = f"{bm25_score_expr} DESC"
|
||||
bm25_where_filter = "AND search_vector @@ to_tsquery('english', $5)"
|
||||
params = [query_emb_str, bank_id, fact_types, limit, query_tsquery]
|
||||
if tags:
|
||||
params.append(tags)
|
||||
|
||||
query = f"""
|
||||
WITH semantic_ranked AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity,
|
||||
NULL::float AS bm25_score,
|
||||
'semantic' AS source,
|
||||
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY embedding <=> $1::vector) AS rn
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
AND embedding IS NOT NULL
|
||||
AND fact_type = ANY($3)
|
||||
AND (1 - (embedding <=> $1::vector)) >= 0.3
|
||||
{tags_clause}
|
||||
),
|
||||
bm25_ranked AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
NULL::float AS similarity,
|
||||
ts_rank_cd(search_vector, to_tsquery('english', $5)) AS bm25_score,
|
||||
'bm25' AS source,
|
||||
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY ts_rank_cd(search_vector, to_tsquery('english', $5)) DESC) AS rn
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
AND fact_type = ANY($3)
|
||||
AND search_vector @@ to_tsquery('english', $5)
|
||||
{tags_clause}
|
||||
),
|
||||
semantic AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
similarity, bm25_score, source
|
||||
FROM semantic_ranked WHERE rn <= $4
|
||||
),
|
||||
bm25 AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
similarity, bm25_score, source
|
||||
FROM bm25_ranked WHERE rn <= $4
|
||||
)
|
||||
SELECT * FROM semantic
|
||||
UNION ALL
|
||||
SELECT * FROM bm25
|
||||
"""
|
||||
if tags:
|
||||
params.append(tags)
|
||||
|
||||
# Single query template with backend-specific parts injected
|
||||
query = f"""
|
||||
WITH semantic_ranked AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity,
|
||||
NULL::float AS bm25_score,
|
||||
'semantic' AS source,
|
||||
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY embedding <=> $1::vector) AS rn
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
AND embedding IS NOT NULL
|
||||
AND fact_type = ANY($3)
|
||||
AND (1 - (embedding <=> $1::vector)) >= 0.3
|
||||
{tags_clause}
|
||||
),
|
||||
bm25_ranked AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
NULL::float AS similarity,
|
||||
{bm25_score_expr} AS bm25_score,
|
||||
'bm25' AS source,
|
||||
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY {bm25_order_by}) AS rn
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
AND fact_type = ANY($3)
|
||||
{bm25_where_filter}
|
||||
{tags_clause}
|
||||
),
|
||||
semantic AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
similarity, bm25_score, source
|
||||
FROM semantic_ranked WHERE rn <= $4
|
||||
),
|
||||
bm25 AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
similarity, bm25_score, source
|
||||
FROM bm25_ranked WHERE rn <= $4
|
||||
)
|
||||
SELECT * FROM semantic
|
||||
UNION ALL
|
||||
SELECT * FROM bm25
|
||||
"""
|
||||
|
||||
# Combined CTE query for both semantic and BM25 across all fact types
|
||||
# Uses window functions to limit per fact_type per method
|
||||
|
||||
@@ -208,6 +208,9 @@ def main():
|
||||
embeddings_litellm_api_base=config.embeddings_litellm_api_base,
|
||||
embeddings_litellm_api_key=config.embeddings_litellm_api_key,
|
||||
embeddings_litellm_model=config.embeddings_litellm_model,
|
||||
embeddings_litellm_sdk_api_key=config.embeddings_litellm_sdk_api_key,
|
||||
embeddings_litellm_sdk_model=config.embeddings_litellm_sdk_model,
|
||||
embeddings_litellm_sdk_api_base=config.embeddings_litellm_sdk_api_base,
|
||||
reranker_provider=config.reranker_provider,
|
||||
reranker_local_model=config.reranker_local_model,
|
||||
reranker_local_force_cpu=config.reranker_local_force_cpu,
|
||||
@@ -223,6 +226,9 @@ def main():
|
||||
reranker_litellm_api_base=config.reranker_litellm_api_base,
|
||||
reranker_litellm_api_key=config.reranker_litellm_api_key,
|
||||
reranker_litellm_model=config.reranker_litellm_model,
|
||||
reranker_litellm_sdk_api_key=config.reranker_litellm_sdk_api_key,
|
||||
reranker_litellm_sdk_model=config.reranker_litellm_sdk_model,
|
||||
reranker_litellm_sdk_api_base=config.reranker_litellm_sdk_api_base,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
base_path=config.base_path,
|
||||
@@ -368,6 +374,8 @@ def main():
|
||||
reranker_provider=config.reranker_provider,
|
||||
mcp_enabled=config.mcp_enabled,
|
||||
version=__version__,
|
||||
vector_extension=config.vector_extension,
|
||||
text_search_extension=config.text_search_extension,
|
||||
)
|
||||
|
||||
# Start idle checker in daemon mode
|
||||
|
||||
@@ -35,20 +35,38 @@ MIGRATION_LOCK_ID = 123456789
|
||||
|
||||
def _detect_vector_extension(conn, vector_extension: str = "pgvector") -> str:
|
||||
"""
|
||||
Validate vector extension: 'vchord' or 'pgvector'.
|
||||
Validate vector extension: 'pgvector', 'vchord', or 'pgvectorscale'.
|
||||
|
||||
Args:
|
||||
conn: SQLAlchemy connection object
|
||||
vector_extension: Configured extension ("pgvector" or "vchord")
|
||||
vector_extension: Configured extension ("pgvector", "vchord", or "pgvectorscale")
|
||||
|
||||
Returns:
|
||||
"vchord" or "pgvector"
|
||||
"pgvector", "vchord", or "pgvectorscale"
|
||||
|
||||
Raises:
|
||||
RuntimeError: If configured extension is not installed
|
||||
"""
|
||||
# Verify the configured extension is installed
|
||||
if vector_extension == "vchord":
|
||||
if vector_extension == "pgvectorscale":
|
||||
# pgvectorscale requires pgvector to be installed first
|
||||
pgvector_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vector'")).scalar()
|
||||
if not pgvector_check:
|
||||
raise RuntimeError(
|
||||
"pgvectorscale requires pgvector to be installed. "
|
||||
"Install it with: CREATE EXTENSION vector; CREATE EXTENSION vectorscale CASCADE;"
|
||||
)
|
||||
|
||||
# Check for vectorscale extension
|
||||
vectorscale_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")).scalar()
|
||||
if not vectorscale_check:
|
||||
raise RuntimeError(
|
||||
"Configured vector extension 'pgvectorscale' not found. "
|
||||
"Install it with: CREATE EXTENSION vectorscale CASCADE;"
|
||||
)
|
||||
logger.debug("Using configured vector extension: pgvectorscale (DiskANN)")
|
||||
return "pgvectorscale"
|
||||
elif vector_extension == "vchord":
|
||||
vchord_check = conn.execute(text("SELECT 1 FROM pg_extension WHERE extname = 'vchord'")).scalar()
|
||||
if not vchord_check:
|
||||
raise RuntimeError(
|
||||
@@ -65,7 +83,9 @@ def _detect_vector_extension(conn, vector_extension: str = "pgvector") -> str:
|
||||
logger.debug("Using configured vector extension: pgvector")
|
||||
return "pgvector"
|
||||
else:
|
||||
raise ValueError(f"Invalid vector_extension: {vector_extension}. Must be 'pgvector' or 'vchord'")
|
||||
raise ValueError(
|
||||
f"Invalid vector_extension: {vector_extension}. Must be 'pgvector', 'vchord', or 'pgvectorscale'"
|
||||
)
|
||||
|
||||
|
||||
def _get_schema_lock_id(schema: str) -> int:
|
||||
@@ -277,6 +297,48 @@ def run_migrations(
|
||||
"Please install it with: CREATE EXTENSION vector;"
|
||||
) from e
|
||||
|
||||
# If using pgvectorscale, ensure vectorscale extension is also installed
|
||||
vector_extension = os.getenv("HINDSIGHT_API_VECTOR_EXTENSION", "pgvector").lower()
|
||||
if vector_extension == "pgvectorscale":
|
||||
logger.debug("Checking pgvectorscale (vectorscale) extension availability...")
|
||||
|
||||
vectorscale_check = conn.execute(
|
||||
text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")
|
||||
).scalar()
|
||||
|
||||
if vectorscale_check:
|
||||
logger.info("pgvectorscale extension already installed")
|
||||
else:
|
||||
# Extension doesn't exist - try to install
|
||||
logger.info("pgvectorscale extension not found, attempting to install...")
|
||||
try:
|
||||
conn.execute(text("CREATE EXTENSION vectorscale CASCADE"))
|
||||
conn.commit()
|
||||
logger.info("pgvectorscale extension installed successfully")
|
||||
except Exception as e:
|
||||
# Installation failed - check one more time in case another process installed it
|
||||
conn.rollback()
|
||||
vectorscale_recheck = conn.execute(
|
||||
text("SELECT 1 FROM pg_extension WHERE extname = 'vectorscale'")
|
||||
).fetchone()
|
||||
|
||||
if vectorscale_recheck:
|
||||
logger.warning(
|
||||
"Could not install pgvectorscale extension (permission denied?), "
|
||||
"but extension exists. Continuing..."
|
||||
)
|
||||
else:
|
||||
# Extension truly doesn't exist and we can't install it
|
||||
logger.error(
|
||||
f"pgvectorscale extension is not installed and cannot be installed: {e}. "
|
||||
f"Please ensure pgvectorscale is installed by a database administrator. "
|
||||
f"See: https://github.com/timescale/pgvectorscale#installation"
|
||||
)
|
||||
raise RuntimeError(
|
||||
"pgvectorscale extension is required but not installed. "
|
||||
"Please install it with: CREATE EXTENSION vectorscale CASCADE;"
|
||||
) from e
|
||||
|
||||
# Run migrations while holding the lock
|
||||
_run_migrations_internal(database_url, script_location, schema=schema)
|
||||
finally:
|
||||
@@ -475,7 +537,17 @@ def ensure_embedding_dimension(
|
||||
conn.commit()
|
||||
|
||||
# Recreate index with appropriate type based on detected extension
|
||||
if vector_ext == "vchord":
|
||||
if vector_ext == "pgvectorscale":
|
||||
conn.execute(
|
||||
text(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_diskann
|
||||
ON {schema_name}.memory_units
|
||||
USING diskann (embedding vector_cosine_ops)
|
||||
WITH (num_neighbors = 50)
|
||||
""")
|
||||
)
|
||||
logger.info(f"Created DiskANN index for {required_dimension}-dimensional embeddings")
|
||||
elif vector_ext == "vchord":
|
||||
conn.execute(
|
||||
text(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_units_embedding_vchordrq
|
||||
@@ -537,7 +609,12 @@ def ensure_vector_extension(
|
||||
]
|
||||
|
||||
# Determine target index type
|
||||
target_index_type = "vchordrq" if target_ext == "vchord" else "hnsw"
|
||||
if target_ext == "pgvectorscale":
|
||||
target_index_type = "diskann"
|
||||
elif target_ext == "vchord":
|
||||
target_index_type = "vchordrq"
|
||||
else:
|
||||
target_index_type = "hnsw"
|
||||
|
||||
mismatched_tables = []
|
||||
tables_with_data = []
|
||||
@@ -576,7 +653,9 @@ def ensure_vector_extension(
|
||||
continue
|
||||
|
||||
indexdef = current_index_info[0].lower()
|
||||
if "vchordrq" in indexdef:
|
||||
if "diskann" in indexdef:
|
||||
current_index_type = "diskann"
|
||||
elif "vchordrq" in indexdef:
|
||||
current_index_type = "vchordrq"
|
||||
elif "hnsw" in indexdef:
|
||||
current_index_type = "hnsw"
|
||||
@@ -609,13 +688,18 @@ def ensure_vector_extension(
|
||||
# If there's data in any mismatched table, raise error
|
||||
if tables_with_data:
|
||||
table_list = ", ".join([f"{table}({count} rows)" for table, count in tables_with_data])
|
||||
# Map index type back to extension name for error message
|
||||
current_ext_name = {"diskann": "pgvectorscale", "vchordrq": "vchord", "hnsw": "pgvector"}.get(
|
||||
current_index_type, current_index_type
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Cannot change vector extension from {current_index_type} to {target_index_type}: "
|
||||
f"the following tables contain data: {table_list}. "
|
||||
f"To change vector extension, you must either:\n"
|
||||
f" 1. Re-embed all data: DELETE FROM {schema_name}.memory_units; "
|
||||
f"DELETE FROM {schema_name}.learnings; DELETE FROM {schema_name}.pinned_reflections; then restart\n"
|
||||
f" 2. Use the current vector extension (set HINDSIGHT_API_VECTOR_EXTENSION='{current_index_type.replace('vchordrq', 'vchord').replace('hnsw', 'pgvector')}')"
|
||||
f" 2. Use the current vector extension (set HINDSIGHT_API_VECTOR_EXTENSION='{current_ext_name}')"
|
||||
)
|
||||
|
||||
# Tables are empty, safe to recreate indexes
|
||||
@@ -628,7 +712,17 @@ def ensure_vector_extension(
|
||||
conn.execute(text(f"DROP INDEX IF EXISTS {schema_name}.{index_name}"))
|
||||
|
||||
# Create new index with appropriate type
|
||||
if target_ext == "vchord":
|
||||
if target_ext == "pgvectorscale":
|
||||
logger.info(f"Creating DiskANN index on {table_name}")
|
||||
conn.execute(
|
||||
text(f"""
|
||||
CREATE INDEX IF NOT EXISTS {index_name}
|
||||
ON {schema_name}.{table_name}
|
||||
USING diskann (embedding vector_cosine_ops)
|
||||
WITH (num_neighbors = 50)
|
||||
""")
|
||||
)
|
||||
elif target_ext == "vchord":
|
||||
logger.info(f"Creating vchordrq index on {table_name}")
|
||||
conn.execute(
|
||||
text(f"""
|
||||
@@ -688,6 +782,9 @@ def ensure_text_search_extension(
|
||||
if text_search_extension == "vchord":
|
||||
target_column_type = "bm25vector"
|
||||
target_index_type = "bm25"
|
||||
elif text_search_extension == "pg_textsearch":
|
||||
target_column_type = "text"
|
||||
target_index_type = "bm25"
|
||||
else: # native
|
||||
target_column_type = "tsvector"
|
||||
target_index_type = "gin"
|
||||
@@ -775,7 +872,16 @@ def ensure_text_search_extension(
|
||||
# If there's data in any mismatched table, raise error
|
||||
if tables_with_data:
|
||||
table_list = ", ".join([f"{table}({count} rows)" for table, count in tables_with_data])
|
||||
current_ext = "native" if mismatched_tables[0][1] == "tsvector" else "vchord"
|
||||
# Detect current extension from column type
|
||||
current_col_type = mismatched_tables[0][1]
|
||||
if current_col_type == "tsvector":
|
||||
current_ext = "native"
|
||||
elif current_col_type == "bm25vector":
|
||||
current_ext = "vchord"
|
||||
elif current_col_type == "text":
|
||||
current_ext = "pg_textsearch"
|
||||
else:
|
||||
current_ext = "unknown"
|
||||
raise RuntimeError(
|
||||
f"Cannot change text search extension from {current_ext} to {text_search_extension}: "
|
||||
f"the following tables contain data: {table_list}. "
|
||||
@@ -820,6 +926,27 @@ def ensure_text_search_extension(
|
||||
USING bm25 (search_vector bm25_catalog.bm25_ops)
|
||||
""")
|
||||
)
|
||||
elif text_search_extension == "pg_textsearch":
|
||||
logger.info(f"Creating TEXT column on {table_name}")
|
||||
# Dummy TEXT column for consistency (indexes operate on base columns)
|
||||
conn.execute(text(f"ALTER TABLE {schema_name}.{table_name} ADD COLUMN search_vector TEXT"))
|
||||
|
||||
# Create BM25 index on expression
|
||||
logger.info(f"Creating BM25 index on {table_name}")
|
||||
# Different expression for each table
|
||||
if table_name == "memory_units":
|
||||
index_expr = "(COALESCE(text, '') || ' ' || COALESCE(context, ''))"
|
||||
else: # reflections
|
||||
index_expr = "(COALESCE(name, '') || ' ' || content)"
|
||||
|
||||
conn.execute(
|
||||
text(f"""
|
||||
CREATE INDEX idx_{table_name.replace(".", "_")}_text_search
|
||||
ON {schema_name}.{table_name}
|
||||
USING bm25({index_expr})
|
||||
WITH (text_config='english')
|
||||
""")
|
||||
)
|
||||
else: # native
|
||||
logger.info(f"Creating tsvector column on {table_name}")
|
||||
# Different GENERATED expression for each table
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api"
|
||||
version = "0.4.10"
|
||||
version = "0.4.11"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -42,6 +42,7 @@ dependencies = [
|
||||
"typer>=0.9.0",
|
||||
"cohere>=5.0.0",
|
||||
"flashrank>=0.2.0",
|
||||
"litellm>=1.0.0",
|
||||
# Local ML models for embeddings/reranking - can be excluded in Docker with INCLUDE_LOCAL_MODELS=false
|
||||
"sentence-transformers>=3.3.0",
|
||||
"transformers>=4.53.0", # Security fixes for ReDoS vulnerabilities
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Unit tests for async retain tag propagation."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.memory_engine import MemoryEngine
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_async_retain_includes_document_tags_in_task_payload():
|
||||
"""submit_async_retain should include document_tags in queued task payload."""
|
||||
engine = MemoryEngine.__new__(MemoryEngine)
|
||||
engine._authenticate_tenant = AsyncMock()
|
||||
engine._submit_async_operation = AsyncMock(return_value={"operation_id": "op-1"})
|
||||
|
||||
request_context = RequestContext(tenant_id="tenant-a", api_key_id="key-a")
|
||||
contents = [{"content": "Async retain payload test."}]
|
||||
document_tags = ["scope:tools", "user:alice"]
|
||||
|
||||
result = await MemoryEngine.submit_async_retain(
|
||||
engine,
|
||||
bank_id="bank-1",
|
||||
contents=contents,
|
||||
document_tags=document_tags,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert result == {"operation_id": "op-1", "items_count": 1}
|
||||
engine._authenticate_tenant.assert_awaited_once_with(request_context)
|
||||
engine._submit_async_operation.assert_awaited_once()
|
||||
|
||||
kwargs = engine._submit_async_operation.await_args.kwargs
|
||||
assert kwargs["bank_id"] == "bank-1"
|
||||
assert kwargs["operation_type"] == "retain"
|
||||
assert kwargs["task_type"] == "batch_retain"
|
||||
assert kwargs["task_payload"]["contents"] == contents
|
||||
assert kwargs["task_payload"]["document_tags"] == document_tags
|
||||
assert kwargs["task_payload"]["_tenant_id"] == "tenant-a"
|
||||
assert kwargs["task_payload"]["_api_key_id"] == "key-a"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handle_batch_retain_forwards_document_tags_to_retain_batch_async():
|
||||
"""Worker handler should forward document_tags from task payload."""
|
||||
engine = MemoryEngine.__new__(MemoryEngine)
|
||||
engine.retain_batch_async = AsyncMock(return_value={"items_count": 1})
|
||||
|
||||
task_dict = {
|
||||
"bank_id": "bank-1",
|
||||
"contents": [{"content": "Forward tags test."}],
|
||||
"document_tags": ["scope:client"],
|
||||
"_tenant_id": "tenant-a",
|
||||
"_api_key_id": "key-a",
|
||||
}
|
||||
|
||||
await MemoryEngine._handle_batch_retain(engine, task_dict)
|
||||
|
||||
engine.retain_batch_async.assert_awaited_once()
|
||||
kwargs = engine.retain_batch_async.await_args.kwargs
|
||||
assert kwargs["bank_id"] == "bank-1"
|
||||
assert kwargs["contents"] == task_dict["contents"]
|
||||
assert kwargs["document_tags"] == ["scope:client"]
|
||||
|
||||
request_context = kwargs["request_context"]
|
||||
assert request_context.internal is True
|
||||
assert request_context.user_initiated is True
|
||||
assert request_context.tenant_id == "tenant-a"
|
||||
assert request_context.api_key_id == "key-a"
|
||||
@@ -0,0 +1,392 @@
|
||||
"""
|
||||
Tests for LiteLLMSDKCrossEncoder.
|
||||
|
||||
Tests the LiteLLM SDK-based cross-encoder implementation for reranking.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.cross_encoder import LiteLLMSDKCrossEncoder, create_cross_encoder_from_env
|
||||
|
||||
|
||||
class TestLiteLLMSDKCrossEncoder:
|
||||
"""Test suite for LiteLLMSDKCrossEncoder class."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialization_success(self):
|
||||
"""Test successful initialization with valid config."""
|
||||
encoder = LiteLLMSDKCrossEncoder(
|
||||
api_key="test_key",
|
||||
model="deepinfra/Qwen3-reranker-8B",
|
||||
)
|
||||
|
||||
assert encoder.provider_name == "litellm-sdk"
|
||||
assert encoder.api_key == "test_key"
|
||||
assert encoder.model == "deepinfra/Qwen3-reranker-8B"
|
||||
assert encoder._initialized is False
|
||||
|
||||
# Mock the litellm import
|
||||
mock_litellm = MagicMock()
|
||||
with patch.dict("sys.modules", {"litellm": mock_litellm}):
|
||||
await encoder.initialize()
|
||||
assert encoder._initialized is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialization_missing_package(self):
|
||||
"""Test initialization fails when litellm package is missing."""
|
||||
encoder = LiteLLMSDKCrossEncoder(
|
||||
api_key="test_key",
|
||||
model="cohere/rerank-english-v3.0",
|
||||
)
|
||||
|
||||
with patch.dict("sys.modules", {"litellm": None}):
|
||||
with pytest.raises(ImportError, match="litellm is required"):
|
||||
await encoder.initialize()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialization_idempotent(self):
|
||||
"""Test that calling initialize() multiple times is safe."""
|
||||
encoder = LiteLLMSDKCrossEncoder(
|
||||
api_key="test_key",
|
||||
model="cohere/rerank-english-v3.0",
|
||||
)
|
||||
|
||||
mock_litellm = MagicMock()
|
||||
with patch.dict("sys.modules", {"litellm": mock_litellm}):
|
||||
await encoder.initialize()
|
||||
assert encoder._initialized is True
|
||||
|
||||
# Second call should be no-op
|
||||
await encoder.initialize()
|
||||
assert encoder._initialized is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_predict_single_query(self):
|
||||
"""Test prediction with a single query and multiple documents."""
|
||||
encoder = LiteLLMSDKCrossEncoder(
|
||||
api_key="test_key",
|
||||
model="deepinfra/Qwen3-reranker-8B",
|
||||
)
|
||||
|
||||
# Create mock response with results as TypedDicts
|
||||
mock_response = MagicMock()
|
||||
mock_response.results = [
|
||||
{"index": 0, "relevance_score": 0.9},
|
||||
{"index": 1, "relevance_score": 0.7},
|
||||
{"index": 2, "relevance_score": 0.5},
|
||||
]
|
||||
|
||||
mock_litellm = MagicMock()
|
||||
mock_litellm.arerank = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch.dict("sys.modules", {"litellm": mock_litellm}):
|
||||
await encoder.initialize()
|
||||
|
||||
pairs = [
|
||||
("What is Python?", "Python is a programming language"),
|
||||
("What is Python?", "Python is a snake"),
|
||||
("What is Python?", "Python is a British comedy group"),
|
||||
]
|
||||
|
||||
scores = await encoder.predict(pairs)
|
||||
|
||||
assert len(scores) == 3
|
||||
assert scores == [0.9, 0.7, 0.5]
|
||||
|
||||
# Verify arerank was called correctly
|
||||
mock_litellm.arerank.assert_called_once()
|
||||
call_args = mock_litellm.arerank.call_args
|
||||
assert call_args.kwargs["model"] == "deepinfra/Qwen3-reranker-8B"
|
||||
assert call_args.kwargs["query"] == "What is Python?"
|
||||
assert len(call_args.kwargs["documents"]) == 3
|
||||
assert call_args.kwargs["api_key"] == "test_key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_predict_multiple_queries(self):
|
||||
"""Test prediction with multiple different queries (grouped efficiently)."""
|
||||
encoder = LiteLLMSDKCrossEncoder(
|
||||
api_key="test_key",
|
||||
model="cohere/rerank-english-v3.0",
|
||||
)
|
||||
|
||||
# First query response
|
||||
mock_response1 = MagicMock()
|
||||
mock_response1.results = [
|
||||
{"index": 0, "relevance_score": 0.9},
|
||||
{"index": 1, "relevance_score": 0.7},
|
||||
]
|
||||
|
||||
# Second query response
|
||||
mock_response2 = MagicMock()
|
||||
mock_response2.results = [
|
||||
{"index": 0, "relevance_score": 0.8},
|
||||
]
|
||||
|
||||
mock_litellm = MagicMock()
|
||||
mock_litellm.arerank = AsyncMock(side_effect=[mock_response1, mock_response2])
|
||||
|
||||
with patch.dict("sys.modules", {"litellm": mock_litellm}):
|
||||
await encoder.initialize()
|
||||
|
||||
pairs = [
|
||||
("What is Python?", "Python is a programming language"),
|
||||
("What is Python?", "Python is a snake"),
|
||||
("What is Java?", "Java is a programming language"),
|
||||
]
|
||||
|
||||
scores = await encoder.predict(pairs)
|
||||
|
||||
assert len(scores) == 3
|
||||
assert scores[0] == 0.9 # First query, first doc
|
||||
assert scores[1] == 0.7 # First query, second doc
|
||||
assert scores[2] == 0.8 # Second query, first doc
|
||||
|
||||
# Verify arerank was called twice (once per unique query)
|
||||
assert mock_litellm.arerank.call_count == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_predict_empty_pairs(self):
|
||||
"""Test prediction with empty input."""
|
||||
encoder = LiteLLMSDKCrossEncoder(
|
||||
api_key="test_key",
|
||||
model="cohere/rerank-english-v3.0",
|
||||
)
|
||||
|
||||
mock_litellm = MagicMock()
|
||||
with patch.dict("sys.modules", {"litellm": mock_litellm}):
|
||||
await encoder.initialize()
|
||||
scores = await encoder.predict([])
|
||||
assert scores == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_predict_not_initialized(self):
|
||||
"""Test that predict fails if encoder not initialized."""
|
||||
encoder = LiteLLMSDKCrossEncoder(
|
||||
api_key="test_key",
|
||||
model="cohere/rerank-english-v3.0",
|
||||
)
|
||||
|
||||
pairs = [("query", "document")]
|
||||
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
await encoder.predict(pairs)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_predict_error_handling(self):
|
||||
"""Test that errors during prediction are raised."""
|
||||
encoder = LiteLLMSDKCrossEncoder(
|
||||
api_key="test_key",
|
||||
model="cohere/rerank-english-v3.0",
|
||||
)
|
||||
|
||||
# Mock litellm to raise an error
|
||||
mock_litellm = MagicMock()
|
||||
mock_litellm.arerank = AsyncMock(side_effect=Exception("API Error"))
|
||||
|
||||
with patch.dict("sys.modules", {"litellm": mock_litellm}):
|
||||
await encoder.initialize()
|
||||
|
||||
pairs = [
|
||||
("What is Python?", "Python is a programming language"),
|
||||
]
|
||||
|
||||
# Should raise the exception
|
||||
with pytest.raises(Exception, match="API Error"):
|
||||
await encoder.predict(pairs)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_custom_api_base(self):
|
||||
"""Test that custom API base URL is passed to rerank calls."""
|
||||
encoder = LiteLLMSDKCrossEncoder(
|
||||
api_key="test_key",
|
||||
model="cohere/rerank-english-v3.0",
|
||||
api_base="https://custom.api.example.com",
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.results = [
|
||||
{"index": 0, "relevance_score": 0.9},
|
||||
]
|
||||
|
||||
mock_litellm = MagicMock()
|
||||
mock_litellm.arerank = AsyncMock(return_value=mock_response)
|
||||
|
||||
with patch.dict("sys.modules", {"litellm": mock_litellm}):
|
||||
await encoder.initialize()
|
||||
|
||||
# Test that api_base is passed to arerank
|
||||
pairs = [("query", "document")]
|
||||
scores = await encoder.predict(pairs)
|
||||
|
||||
assert scores == [0.9]
|
||||
mock_litellm.arerank.assert_called_once()
|
||||
call_args = mock_litellm.arerank.call_args
|
||||
assert call_args.kwargs["api_base"] == "https://custom.api.example.com"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_response_with_direct_score_list(self):
|
||||
"""Test handling of response format with direct score list."""
|
||||
encoder = LiteLLMSDKCrossEncoder(
|
||||
api_key="test_key",
|
||||
model="some-provider/model",
|
||||
)
|
||||
|
||||
# Mock litellm to return direct list of scores
|
||||
mock_litellm = MagicMock()
|
||||
mock_litellm.arerank = AsyncMock(return_value=[0.9, 0.7, 0.5])
|
||||
|
||||
with patch.dict("sys.modules", {"litellm": mock_litellm}):
|
||||
await encoder.initialize()
|
||||
|
||||
pairs = [
|
||||
("query", "doc1"),
|
||||
("query", "doc2"),
|
||||
("query", "doc3"),
|
||||
]
|
||||
|
||||
scores = await encoder.predict(pairs)
|
||||
|
||||
assert scores == [0.9, 0.7, 0.5]
|
||||
|
||||
|
||||
class TestFactoryFunction:
|
||||
"""Test suite for create_cross_encoder_from_env factory function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_litellm_sdk_from_env(self):
|
||||
"""Test creating LiteLLM SDK cross-encoder from environment variables."""
|
||||
env_vars = {
|
||||
"HINDSIGHT_API_RERANKER_PROVIDER": "litellm-sdk",
|
||||
"HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY": "test_key",
|
||||
"HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL": "deepinfra/Qwen3-reranker-8B",
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, env_vars, clear=False):
|
||||
# Need to reload config to pick up env vars
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
config = HindsightConfig.from_env()
|
||||
|
||||
with patch("hindsight_api.config.get_config", return_value=config):
|
||||
encoder = create_cross_encoder_from_env()
|
||||
|
||||
assert isinstance(encoder, LiteLLMSDKCrossEncoder)
|
||||
assert encoder.api_key == "test_key"
|
||||
assert encoder.model == "deepinfra/Qwen3-reranker-8B"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_litellm_sdk_missing_api_key(self):
|
||||
"""Test that factory raises error when API key is missing."""
|
||||
env_vars = {
|
||||
"HINDSIGHT_API_RERANKER_PROVIDER": "litellm-sdk",
|
||||
"HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL": "deepinfra/Qwen3-reranker-8B",
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, env_vars, clear=False):
|
||||
# Remove API key if set
|
||||
if "HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY" in os.environ:
|
||||
del os.environ["HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY"]
|
||||
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
config = HindsightConfig.from_env()
|
||||
|
||||
with patch("hindsight_api.config.get_config", return_value=config):
|
||||
with pytest.raises(ValueError, match="HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY is required"):
|
||||
create_cross_encoder_from_env()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_litellm_sdk_with_custom_api_base(self):
|
||||
"""Test creating LiteLLM SDK cross-encoder with custom API base."""
|
||||
env_vars = {
|
||||
"HINDSIGHT_API_RERANKER_PROVIDER": "litellm-sdk",
|
||||
"HINDSIGHT_API_RERANKER_LITELLM_SDK_API_KEY": "test_key",
|
||||
"HINDSIGHT_API_RERANKER_LITELLM_SDK_MODEL": "cohere/rerank-english-v3.0",
|
||||
"HINDSIGHT_API_RERANKER_LITELLM_SDK_API_BASE": "https://custom.api.example.com",
|
||||
}
|
||||
|
||||
with patch.dict(os.environ, env_vars, clear=False):
|
||||
from hindsight_api.config import HindsightConfig
|
||||
|
||||
config = HindsightConfig.from_env()
|
||||
|
||||
with patch("hindsight_api.config.get_config", return_value=config):
|
||||
encoder = create_cross_encoder_from_env()
|
||||
|
||||
assert isinstance(encoder, LiteLLMSDKCrossEncoder)
|
||||
assert encoder.api_base == "https://custom.api.example.com"
|
||||
|
||||
|
||||
class TestLiteLLMSDKCohereCrossEncoder:
|
||||
"""Tests for LiteLLM SDK calling Cohere (runs in CI with COHERE_API_KEY)."""
|
||||
|
||||
@pytest.fixture
|
||||
async def litellm_cohere_cross_encoder(self):
|
||||
"""Create LiteLLM SDK cross-encoder instance for Cohere."""
|
||||
if not os.environ.get("COHERE_API_KEY"):
|
||||
pytest.skip("Cohere API key not available (set COHERE_API_KEY)")
|
||||
|
||||
encoder = LiteLLMSDKCrossEncoder(
|
||||
api_key=os.environ["COHERE_API_KEY"],
|
||||
model="cohere/rerank-english-v3.0",
|
||||
)
|
||||
await encoder.initialize()
|
||||
return encoder
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_litellm_sdk_cohere_initialization(self, litellm_cohere_cross_encoder):
|
||||
"""Test that LiteLLM SDK Cohere cross-encoder initializes correctly."""
|
||||
assert litellm_cohere_cross_encoder.provider_name == "litellm-sdk"
|
||||
assert litellm_cohere_cross_encoder.model == "cohere/rerank-english-v3.0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_litellm_sdk_cohere_predict(self, litellm_cohere_cross_encoder):
|
||||
"""Test that LiteLLM SDK can call Cohere rerank API."""
|
||||
pairs = [
|
||||
("What is the capital of France?", "Paris is the capital of France."),
|
||||
("What is the capital of France?", "The Eiffel Tower is in Paris."),
|
||||
("What is the capital of France?", "Python is a programming language."),
|
||||
]
|
||||
scores = await litellm_cohere_cross_encoder.predict(pairs)
|
||||
|
||||
assert len(scores) == 3
|
||||
assert all(isinstance(s, float) for s in scores)
|
||||
# The first result should be most relevant
|
||||
assert scores[0] > scores[2], "Direct answer should score higher than unrelated text"
|
||||
# All scores should be in valid range
|
||||
assert all(0.0 <= score <= 1.0 for score in scores)
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests with real API (optional - requires API keys)."""
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.environ.get("DEEPINFRA_API_KEY"),
|
||||
reason="DEEPINFRA_API_KEY not set - skipping integration test",
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_deepinfra_api(self):
|
||||
"""Test with real DeepInfra API (requires DEEPINFRA_API_KEY env var)."""
|
||||
encoder = LiteLLMSDKCrossEncoder(
|
||||
api_key=os.environ["DEEPINFRA_API_KEY"],
|
||||
model="deepinfra/Qwen3-reranker-8B",
|
||||
)
|
||||
|
||||
await encoder.initialize()
|
||||
|
||||
pairs = [
|
||||
("What is Python?", "Python is a high-level programming language"),
|
||||
("What is Python?", "Python is a species of snake"),
|
||||
("What is Python?", "Python is unrelated text about cars"),
|
||||
]
|
||||
|
||||
scores = await encoder.predict(pairs)
|
||||
|
||||
# First doc should have highest score (most relevant)
|
||||
assert len(scores) == 3
|
||||
assert scores[0] > scores[1]
|
||||
assert scores[1] > scores[2]
|
||||
assert all(0.0 <= score <= 1.0 for score in scores)
|
||||
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
Tests for LiteLLM SDK embeddings implementation.
|
||||
|
||||
These tests cover:
|
||||
1. Initialization (success, missing package, missing API key, idempotent)
|
||||
2. Encode (single text, multiple texts, batching, error handling)
|
||||
3. Provider-specific configuration (Cohere, OpenAI, etc.)
|
||||
4. Factory function (create from env, validation errors)
|
||||
5. Dimension detection
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.config import (
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_API_KEY,
|
||||
ENV_EMBEDDINGS_LITELLM_SDK_MODEL,
|
||||
ENV_EMBEDDINGS_PROVIDER,
|
||||
HindsightConfig,
|
||||
)
|
||||
from hindsight_api.engine.embeddings import LiteLLMSDKEmbeddings, create_embeddings_from_env
|
||||
|
||||
|
||||
class TestLiteLLMSDKEmbeddings:
|
||||
"""Unit tests for LiteLLMSDKEmbeddings with mocked litellm responses."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_litellm(self):
|
||||
"""Mock litellm module."""
|
||||
mock = MagicMock()
|
||||
|
||||
# Mock aembedding (async) for initialization
|
||||
mock_response = MagicMock()
|
||||
mock_response.data = [{"embedding": [0.1] * 768, "index": 0}]
|
||||
mock.aembedding = AsyncMock(return_value=mock_response)
|
||||
|
||||
# Mock embedding (sync) for encode
|
||||
mock_sync_response = MagicMock()
|
||||
mock_sync_response.data = [
|
||||
{"embedding": [0.1] * 768, "index": 0},
|
||||
{"embedding": [0.2] * 768, "index": 1},
|
||||
]
|
||||
mock.embedding = MagicMock(return_value=mock_sync_response)
|
||||
|
||||
return mock
|
||||
|
||||
@pytest.fixture
|
||||
async def embeddings(self, mock_litellm):
|
||||
"""Create initialized LiteLLMSDKEmbeddings instance."""
|
||||
emb = LiteLLMSDKEmbeddings(
|
||||
api_key="test_key",
|
||||
model="cohere/embed-english-v3.0",
|
||||
api_base=None,
|
||||
batch_size=100,
|
||||
timeout=60.0,
|
||||
)
|
||||
# Manually set the mock (simulating successful initialization)
|
||||
emb._litellm = mock_litellm
|
||||
emb._dimension = 768
|
||||
return emb
|
||||
|
||||
async def test_initialization_success(self, mock_litellm):
|
||||
"""Test successful initialization."""
|
||||
with patch("builtins.__import__", side_effect=lambda name, *args: mock_litellm if name == "litellm" else __import__(name, *args)):
|
||||
emb = LiteLLMSDKEmbeddings(
|
||||
api_key="test_key",
|
||||
model="cohere/embed-english-v3.0",
|
||||
api_base=None,
|
||||
batch_size=100,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
assert emb._litellm is None
|
||||
assert emb._dimension is None
|
||||
|
||||
await emb.initialize()
|
||||
|
||||
assert emb._litellm is not None
|
||||
assert emb._dimension == 768
|
||||
|
||||
# Verify test embedding was called
|
||||
mock_litellm.aembedding.assert_called_once_with(
|
||||
model="cohere/embed-english-v3.0",
|
||||
input=["test"],
|
||||
api_key="test_key",
|
||||
)
|
||||
|
||||
async def test_initialization_missing_package(self):
|
||||
"""Test initialization fails gracefully when litellm is not installed."""
|
||||
def mock_import(name, *args):
|
||||
if name == "litellm":
|
||||
raise ImportError("No module named 'litellm'")
|
||||
return __import__(name, *args)
|
||||
|
||||
with patch("builtins.__import__", side_effect=mock_import):
|
||||
emb = LiteLLMSDKEmbeddings(
|
||||
api_key="test_key",
|
||||
model="cohere/embed-english-v3.0",
|
||||
api_base=None,
|
||||
batch_size=100,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
with pytest.raises(ImportError, match="litellm is required"):
|
||||
await emb.initialize()
|
||||
|
||||
async def test_initialization_idempotent(self, embeddings, mock_litellm):
|
||||
"""Test that calling initialize() multiple times is safe."""
|
||||
# embeddings._litellm is already set in fixture
|
||||
assert embeddings._litellm is not None
|
||||
|
||||
# Call again
|
||||
await embeddings.initialize()
|
||||
|
||||
# Should still have same litellm instance
|
||||
assert embeddings._litellm is not None
|
||||
|
||||
async def test_encode_single_text(self, embeddings, mock_litellm):
|
||||
"""Test encoding a single text."""
|
||||
# Set up mock response
|
||||
mock_litellm.embedding.return_value.data = [
|
||||
{"embedding": [0.5] * 768, "index": 0},
|
||||
]
|
||||
|
||||
result = embeddings.encode(["Hello world"])
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == 768
|
||||
assert all(isinstance(x, float) for x in result[0])
|
||||
assert all(abs(x - 0.5) < 0.001 for x in result[0])
|
||||
|
||||
# Verify call
|
||||
mock_litellm.embedding.assert_called_once_with(
|
||||
model="cohere/embed-english-v3.0",
|
||||
input=["Hello world"],
|
||||
api_key="test_key",
|
||||
)
|
||||
|
||||
async def test_encode_multiple_texts(self, embeddings, mock_litellm):
|
||||
"""Test encoding multiple texts."""
|
||||
# Set up mock response
|
||||
mock_litellm.embedding.return_value.data = [
|
||||
{"embedding": [0.1] * 768, "index": 0},
|
||||
{"embedding": [0.2] * 768, "index": 1},
|
||||
{"embedding": [0.3] * 768, "index": 2},
|
||||
]
|
||||
|
||||
texts = ["First text", "Second text", "Third text"]
|
||||
result = embeddings.encode(texts)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 3
|
||||
assert len(result[0]) == 768
|
||||
assert len(result[1]) == 768
|
||||
assert len(result[2]) == 768
|
||||
assert all(abs(x - 0.1) < 0.001 for x in result[0])
|
||||
assert all(abs(x - 0.2) < 0.001 for x in result[1])
|
||||
assert all(abs(x - 0.3) < 0.001 for x in result[2])
|
||||
|
||||
async def test_encode_batching(self, embeddings, mock_litellm):
|
||||
"""Test that large inputs are batched correctly."""
|
||||
# Create embeddings with small batch size
|
||||
emb = LiteLLMSDKEmbeddings(
|
||||
api_key="test_key",
|
||||
model="cohere/embed-english-v3.0",
|
||||
api_base=None,
|
||||
batch_size=2, # Small batch for testing
|
||||
timeout=60.0,
|
||||
)
|
||||
emb._litellm = mock_litellm
|
||||
emb._initialized = True
|
||||
emb._dimension = 768
|
||||
|
||||
# Mock responses for each batch
|
||||
def mock_embedding_side_effect(model, input, **kwargs):
|
||||
mock_response = MagicMock()
|
||||
mock_response.data = [
|
||||
{"embedding": [float(i)] * 768, "index": i} for i in range(len(input))
|
||||
]
|
||||
return mock_response
|
||||
|
||||
mock_litellm.embedding.side_effect = mock_embedding_side_effect
|
||||
|
||||
# Encode 5 texts (should create 3 batches: 2, 2, 1)
|
||||
texts = [f"Text {i}" for i in range(5)]
|
||||
result = emb.encode(texts)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 5
|
||||
assert all(len(embedding) == 768 for embedding in result)
|
||||
|
||||
# Verify batching: should be called 3 times
|
||||
assert mock_litellm.embedding.call_count == 3
|
||||
|
||||
# Verify batch sizes
|
||||
calls = mock_litellm.embedding.call_args_list
|
||||
assert len(calls[0][1]["input"]) == 2 # First batch
|
||||
assert len(calls[1][1]["input"]) == 2 # Second batch
|
||||
assert len(calls[2][1]["input"]) == 1 # Third batch
|
||||
|
||||
async def test_encode_empty_list(self, embeddings):
|
||||
"""Test encoding empty list returns empty list."""
|
||||
result = embeddings.encode([])
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 0
|
||||
|
||||
async def test_encode_before_initialization(self, mock_litellm):
|
||||
"""Test that encode raises error if not initialized."""
|
||||
emb = LiteLLMSDKEmbeddings(
|
||||
api_key="test_key",
|
||||
model="cohere/embed-english-v3.0",
|
||||
api_base=None,
|
||||
batch_size=100,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
emb.encode(["test"])
|
||||
|
||||
async def test_encode_error_handling(self, embeddings, mock_litellm):
|
||||
"""Test error handling during encoding."""
|
||||
# Make embedding raise an error
|
||||
mock_litellm.embedding.side_effect = Exception("API Error")
|
||||
|
||||
with pytest.raises(Exception, match="API Error"):
|
||||
embeddings.encode(["test"])
|
||||
|
||||
async def test_dimension_property(self, embeddings):
|
||||
"""Test dimension property."""
|
||||
assert embeddings.dimension == 768
|
||||
|
||||
async def test_dimension_before_initialization(self, mock_litellm):
|
||||
"""Test dimension raises error if not initialized."""
|
||||
emb = LiteLLMSDKEmbeddings(
|
||||
api_key="test_key",
|
||||
model="cohere/embed-english-v3.0",
|
||||
api_base=None,
|
||||
batch_size=100,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="not initialized"):
|
||||
_ = emb.dimension
|
||||
|
||||
async def test_custom_api_base(self, mock_litellm):
|
||||
"""Test custom API base URL is passed to embedding calls."""
|
||||
with patch("builtins.__import__", side_effect=lambda name, *args: mock_litellm if name == "litellm" else __import__(name, *args)):
|
||||
emb = LiteLLMSDKEmbeddings(
|
||||
api_key="test_key",
|
||||
model="cohere/embed-english-v3.0",
|
||||
api_base="https://custom.api.com",
|
||||
batch_size=100,
|
||||
timeout=60.0,
|
||||
)
|
||||
|
||||
await emb.initialize()
|
||||
|
||||
# Verify api_base is set
|
||||
assert emb.api_base == "https://custom.api.com"
|
||||
|
||||
# Verify api_base is passed to aembedding
|
||||
mock_litellm.aembedding.assert_called_once()
|
||||
call_args = mock_litellm.aembedding.call_args
|
||||
assert call_args.kwargs["api_base"] == "https://custom.api.com"
|
||||
|
||||
# Test encode also passes api_base
|
||||
mock_litellm.embedding.return_value.data = [{"embedding": [0.1] * 768, "index": 0}]
|
||||
emb.encode(["test"])
|
||||
|
||||
mock_litellm.embedding.assert_called_once()
|
||||
call_args = mock_litellm.embedding.call_args
|
||||
assert call_args.kwargs["api_base"] == "https://custom.api.com"
|
||||
|
||||
|
||||
class TestLiteLLMSDKEmbeddingsFactory:
|
||||
"""Test the factory function for creating LiteLLM SDK embeddings."""
|
||||
|
||||
def test_create_from_env_success(self, monkeypatch):
|
||||
"""Test creating embeddings from environment variables."""
|
||||
# Mock get_config() to return configured HindsightConfig
|
||||
mock_config = MagicMock()
|
||||
mock_config.embeddings_provider = "litellm-sdk"
|
||||
mock_config.embeddings_litellm_sdk_api_key = "test_key"
|
||||
mock_config.embeddings_litellm_sdk_model = "cohere/embed-english-v3.0"
|
||||
mock_config.embeddings_litellm_sdk_api_base = None
|
||||
|
||||
with patch("hindsight_api.config.get_config", return_value=mock_config):
|
||||
embeddings = create_embeddings_from_env()
|
||||
|
||||
assert isinstance(embeddings, LiteLLMSDKEmbeddings)
|
||||
assert embeddings.api_key == "test_key"
|
||||
assert embeddings.model == "cohere/embed-english-v3.0"
|
||||
|
||||
def test_create_from_env_missing_api_key(self, monkeypatch):
|
||||
"""Test that missing API key raises error."""
|
||||
# Mock get_config() with missing API key
|
||||
mock_config = MagicMock()
|
||||
mock_config.embeddings_provider = "litellm-sdk"
|
||||
mock_config.embeddings_litellm_sdk_api_key = None # Missing key
|
||||
mock_config.embeddings_litellm_sdk_model = "cohere/embed-english-v3.0"
|
||||
|
||||
with patch("hindsight_api.config.get_config", return_value=mock_config):
|
||||
with pytest.raises(ValueError, match=ENV_EMBEDDINGS_LITELLM_SDK_API_KEY):
|
||||
create_embeddings_from_env()
|
||||
|
||||
def test_create_from_env_with_api_base(self, monkeypatch):
|
||||
"""Test creating embeddings with custom API base."""
|
||||
# Mock get_config() with custom API base
|
||||
mock_config = MagicMock()
|
||||
mock_config.embeddings_provider = "litellm-sdk"
|
||||
mock_config.embeddings_litellm_sdk_api_key = "test_key"
|
||||
mock_config.embeddings_litellm_sdk_model = "cohere/embed-english-v3.0"
|
||||
mock_config.embeddings_litellm_sdk_api_base = "https://custom.api.com"
|
||||
|
||||
with patch("hindsight_api.config.get_config", return_value=mock_config):
|
||||
embeddings = create_embeddings_from_env()
|
||||
|
||||
assert isinstance(embeddings, LiteLLMSDKEmbeddings)
|
||||
assert embeddings.api_base == "https://custom.api.com"
|
||||
|
||||
|
||||
class TestLiteLLMSDKCohereEmbeddings:
|
||||
"""Integration tests calling real Cohere API (matches CI pattern)."""
|
||||
|
||||
@pytest.fixture
|
||||
async def litellm_cohere_embeddings(self):
|
||||
"""Create embeddings instance with real Cohere API key."""
|
||||
if not os.environ.get("COHERE_API_KEY"):
|
||||
pytest.skip("Cohere API key not available")
|
||||
|
||||
emb = LiteLLMSDKEmbeddings(
|
||||
api_key=os.environ["COHERE_API_KEY"],
|
||||
model="cohere/embed-english-v3.0",
|
||||
api_base=None,
|
||||
batch_size=100,
|
||||
timeout=60.0,
|
||||
)
|
||||
await emb.initialize()
|
||||
return emb
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_litellm_sdk_cohere_encode(self, litellm_cohere_embeddings):
|
||||
"""Test real Cohere API call for embeddings."""
|
||||
texts = [
|
||||
"The quick brown fox jumps over the lazy dog",
|
||||
"Machine learning is a subset of artificial intelligence",
|
||||
"Python is a popular programming language",
|
||||
]
|
||||
|
||||
result = litellm_cohere_embeddings.encode(texts)
|
||||
|
||||
# Verify result type and shape
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 3
|
||||
assert all(len(embedding) > 0 for embedding in result)
|
||||
assert all(isinstance(x, float) for x in result[0])
|
||||
|
||||
# Verify embeddings are not zeros (common API failure mode)
|
||||
for i, embedding in enumerate(result):
|
||||
assert not all(abs(x) < 0.0001 for x in embedding), f"Embedding {i} is all zeros"
|
||||
|
||||
# Verify embeddings are normalized (Cohere returns normalized vectors)
|
||||
for i, embedding in enumerate(result):
|
||||
norm = sum(x * x for x in embedding) ** 0.5
|
||||
assert 0.9 < norm < 1.1, f"Embedding {i} norm {norm} is not close to 1.0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_litellm_sdk_cohere_dimension(self, litellm_cohere_embeddings):
|
||||
"""Test dimension detection with real Cohere API."""
|
||||
dimension = litellm_cohere_embeddings.dimension
|
||||
|
||||
# Cohere embed-english-v3.0 has 1024 dimensions
|
||||
assert dimension == 1024
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_litellm_sdk_cohere_single_text(self, litellm_cohere_embeddings):
|
||||
"""Test encoding single text with real Cohere API."""
|
||||
result = litellm_cohere_embeddings.encode(["Hello world"])
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 1
|
||||
assert len(result[0]) == 1024
|
||||
assert not all(abs(x) < 0.0001 for x in result[0])
|
||||
@@ -0,0 +1,274 @@
|
||||
"""
|
||||
Test that recall chunks are fetched independently of max_tokens filtering.
|
||||
|
||||
This test verifies the new behavior where:
|
||||
1. Chunks are fetched BEFORE max_tokens filtering
|
||||
2. max_tokens=0 returns 0 facts but can still return chunks
|
||||
3. Chunks are fetched in batches to handle varying chunk sizes
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_chunks_independent_of_max_tokens(memory, request_context):
|
||||
"""
|
||||
Test that chunks are fetched independently of max_tokens.
|
||||
|
||||
When max_tokens=0, recall should:
|
||||
- Return 0 memory facts
|
||||
- Still return chunks (up to max_chunk_tokens)
|
||||
- Chunks should come from top-scored results before token filtering
|
||||
"""
|
||||
bank_id = "test-chunks-independence"
|
||||
|
||||
try:
|
||||
|
||||
# Retain some test content with substantial size to generate chunks
|
||||
test_content = """
|
||||
The quantum computing research team at MIT has made significant breakthroughs.
|
||||
Dr. Sarah Chen leads the team and focuses on quantum error correction.
|
||||
The team published three papers in Nature Physics this year.
|
||||
Their work on topological qubits shows promise for scalable quantum computers.
|
||||
Collaborators include IBM Research and Google Quantum AI.
|
||||
The research is funded by a $5M NSF grant running through 2026.
|
||||
""" * 10 # Repeat to ensure we get multiple chunks
|
||||
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=test_content,
|
||||
context="research notes",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Test 1: Normal recall with both facts and chunks
|
||||
result_normal = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="quantum computing",
|
||||
max_tokens=4096, # Normal token budget
|
||||
include_chunks=True,
|
||||
max_chunk_tokens=2000,
|
||||
budget=Budget.MID,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(result_normal.results) > 0, "Should return memory facts with normal max_tokens"
|
||||
assert result_normal.chunks is not None, "Should include chunks when requested"
|
||||
assert len(result_normal.chunks) > 0, "Should return at least one chunk"
|
||||
|
||||
# Test 2: Recall with max_tokens=0 but chunks enabled
|
||||
result_chunks_only = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="quantum computing",
|
||||
max_tokens=0, # Zero token budget for facts
|
||||
include_chunks=True,
|
||||
max_chunk_tokens=2000, # But allow chunks
|
||||
budget=Budget.MID,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Key assertions for new behavior
|
||||
assert len(result_chunks_only.results) == 0, "max_tokens=0 should return 0 facts"
|
||||
assert result_chunks_only.chunks is not None, "Should still include chunks dict"
|
||||
assert len(result_chunks_only.chunks) > 0, "Should return chunks even with max_tokens=0"
|
||||
|
||||
# Verify chunks are from the same content (non-empty text)
|
||||
for chunk_id, chunk_info in result_chunks_only.chunks.items():
|
||||
assert len(chunk_info.chunk_text) > 0, "Chunks should contain text"
|
||||
assert chunk_info.chunk_index >= 0, "Chunk should have valid index"
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_chunks_batching_with_varying_sizes(memory, request_context):
|
||||
"""
|
||||
Test that chunk batching works correctly with varying chunk sizes.
|
||||
|
||||
This verifies that:
|
||||
1. Chunks are fetched in batches until token budget is exhausted
|
||||
2. The system handles varying chunk sizes across documents
|
||||
3. Token budget is respected across multiple batch fetches
|
||||
"""
|
||||
bank_id = "test-chunks-batching"
|
||||
|
||||
try:
|
||||
|
||||
# Retain multiple documents with different content sizes
|
||||
# Document 1: Short content (small chunks)
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Alice is a software engineer who specializes in Python programming and machine learning.",
|
||||
context="doc1",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Document 2: Medium content
|
||||
content_bob = """
|
||||
Bob works as a data scientist at a tech startup in San Francisco.
|
||||
He has expertise in natural language processing and computer vision.
|
||||
Bob completed his PhD at Stanford University in 2020.
|
||||
He leads a team of five engineers working on AI-powered recommendation systems.
|
||||
""" * 5
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=content_bob,
|
||||
context="doc2",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Document 3: Long content (large chunks)
|
||||
content_charlie = """
|
||||
Charlie is the CTO of a growing AI company focused on healthcare applications.
|
||||
He has over 15 years of experience in software architecture and distributed systems.
|
||||
Charlie's team builds machine learning models for medical image analysis and diagnosis.
|
||||
The company recently raised $50 million in Series B funding.
|
||||
They have partnerships with major hospitals in the United States and Europe.
|
||||
Charlie holds several patents in medical imaging and deep learning.
|
||||
""" * 20
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=content_charlie,
|
||||
context="doc3",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Recall with modest chunk token budget
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice Bob Charlie",
|
||||
max_tokens=0, # No facts, only chunks
|
||||
include_chunks=True,
|
||||
max_chunk_tokens=1000, # Limited chunk budget
|
||||
budget=Budget.MID,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(result.results) == 0, "Should return 0 facts with max_tokens=0"
|
||||
assert result.chunks is not None, "Should include chunks"
|
||||
|
||||
# Verify we got chunks and respected the token budget
|
||||
if len(result.chunks) > 0:
|
||||
# Count total tokens (approximate)
|
||||
total_chunk_chars = sum(len(chunk.chunk_text) for chunk in result.chunks.values())
|
||||
# Very rough estimate: 1 token ≈ 4 characters
|
||||
estimated_tokens = total_chunk_chars // 4
|
||||
|
||||
# Should be reasonably close to budget (within 2x due to estimation and batching)
|
||||
assert estimated_tokens <= 1000 * 2, f"Should respect chunk token budget (got ~{estimated_tokens} tokens)"
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_chunks_ordering_by_relevance(memory, request_context):
|
||||
"""
|
||||
Test that chunks are returned in order of fact relevance.
|
||||
|
||||
Chunks should be ordered based on the top-scored (reranked) results,
|
||||
not in document order or random order.
|
||||
"""
|
||||
bank_id = "test-chunks-ordering"
|
||||
|
||||
try:
|
||||
|
||||
# Retain content with different relevance to query
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="The Python programming language is widely used for machine learning and data science applications.",
|
||||
context="topic: Python",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="JavaScript is commonly used for web development and frontend applications.",
|
||||
context="topic: JavaScript",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Python's scikit-learn library is excellent for traditional machine learning tasks and model training.",
|
||||
context="topic: Python ML",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Query specifically about Python - should rank Python facts higher
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Python machine learning",
|
||||
max_tokens=0, # No facts
|
||||
include_chunks=True,
|
||||
max_chunk_tokens=5000, # Enough for all chunks
|
||||
budget=Budget.HIGH, # Use high budget for better recall
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(result.results) == 0, "Should return 0 facts with max_tokens=0"
|
||||
assert result.chunks is not None, "Should include chunks"
|
||||
|
||||
# We should get chunks, and they should be ordered by relevance
|
||||
# The exact ordering depends on the reranker, but we should have chunks
|
||||
assert len(result.chunks) > 0, "Should return chunks from relevant facts"
|
||||
|
||||
# Verify chunks contain relevant content
|
||||
all_chunk_text = " ".join(chunk.chunk_text for chunk in result.chunks.values())
|
||||
# At least some chunks should mention Python (higher relevance)
|
||||
# This is a soft check since exact ordering depends on scoring
|
||||
assert "Python" in all_chunk_text or "python" in all_chunk_text.lower(), \
|
||||
"Chunks should include content about Python (relevant to query)"
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_chunks_without_include_flag(memory, request_context):
|
||||
"""
|
||||
Test that chunks are NOT returned when include_chunks=False (default).
|
||||
|
||||
This ensures backward compatibility - chunks are only fetched when explicitly requested.
|
||||
"""
|
||||
bank_id = "test-chunks-no-include"
|
||||
|
||||
try:
|
||||
|
||||
# Retain content
|
||||
test_content = """
|
||||
Sarah is a product manager at a fintech company in New York.
|
||||
She specializes in user experience design and agile methodologies.
|
||||
Sarah graduated from MIT with a degree in computer science.
|
||||
She has led the development of three successful mobile banking applications.
|
||||
"""
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=test_content,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Recall without include_chunks flag (default is False)
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Sarah product manager",
|
||||
max_tokens=4096,
|
||||
request_context=request_context,
|
||||
# include_chunks=False is the default
|
||||
)
|
||||
|
||||
# Should have facts but no chunks
|
||||
assert len(result.results) > 0, "Should return facts"
|
||||
assert result.chunks is None or len(result.chunks) == 0, \
|
||||
"Should NOT return chunks when include_chunks=False"
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hindsight-cli"
|
||||
version = "0.4.10"
|
||||
version = "0.4.11"
|
||||
edition = "2021"
|
||||
authors = ["Hindsight Team"]
|
||||
description = "A beautiful CLI for Hindsight - semantic memory system"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
@@ -489,7 +489,7 @@ class Configuration:
|
||||
return "Python SDK Debug Report:\n"\
|
||||
"OS: {env}\n"\
|
||||
"Python Version: {pyversion}\n"\
|
||||
"Version of the API: 0.4.10\n"\
|
||||
"Version of the API: 0.4.11\n"\
|
||||
"SDK Package Version: 0.0.7".\
|
||||
format(env=sys.platform, pyversion=sys.version)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
The version of the OpenAPI document: 0.4.11
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user