Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd93bb8204 | ||
|
|
b69226c318 | ||
|
|
38ee5c46de | ||
|
|
c65c6a9dc0 | ||
|
|
4de0730c40 | ||
|
|
5e1f13e4f2 | ||
|
|
67c1a4295f | ||
|
|
37fc7fb8bd | ||
|
|
29a542dc23 | ||
|
|
ecc1f31996 | ||
|
|
233bd2e5d4 | ||
|
|
b3becb6e9a | ||
|
|
67b273de69 | ||
|
|
5a3090b5e5 | ||
|
|
2a00df0bc0 | ||
|
|
7715a5110e | ||
|
|
c06d9b4e4f | ||
|
|
39e3f7c528 |
@@ -325,6 +325,7 @@ jobs:
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
|
||||
HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+7
-2
@@ -5,7 +5,8 @@ build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
.mcp.json
|
||||
.osgrep
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
@@ -41,4 +42,8 @@ hindsight-docs/static/llms-full.txt
|
||||
hindsight-dev/benchmarks/locomo/results/
|
||||
hindsight-dev/benchmarks/longmemeval/results/
|
||||
hindsight-cli/target
|
||||
hindsight-clients/rust/target
|
||||
hindsight-clients/rust/target
|
||||
.claude
|
||||
whats-next.md
|
||||
TASK.md
|
||||
CHANGELOG.md
|
||||
@@ -4,7 +4,11 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
Hindsight is an agent memory system that provides long-term memory for AI agents using biomimetic data structures. It stores memories as World facts, Experiences, Opinions, and Observations across memory banks.
|
||||
Hindsight is an agent memory system that provides long-term memory for AI agents using biomimetic data structures. Memories are organized as:
|
||||
- **World facts**: General knowledge ("The sky is blue")
|
||||
- **Experience facts**: Personal experiences ("I visited Paris in 2023")
|
||||
- **Opinion facts**: Beliefs with confidence scores ("Paris is beautiful" - 0.9 confidence)
|
||||
- **Observations**: Complex mental models derived from reflection
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -13,14 +17,21 @@ Hindsight is an agent memory system that provides long-term memory for AI agents
|
||||
# Start API server (loads .env automatically)
|
||||
./scripts/dev/start-api.sh
|
||||
|
||||
# Run tests
|
||||
# Run all tests (parallelized with pytest-xdist)
|
||||
cd hindsight-api && uv run pytest tests/
|
||||
|
||||
# Run specific test file
|
||||
cd hindsight-api && uv run pytest tests/test_http_api_integration.py -v
|
||||
|
||||
# Lint
|
||||
# Run single test function
|
||||
cd hindsight-api && uv run pytest tests/test_retain.py::test_retain_simple -v
|
||||
|
||||
# Lint and format
|
||||
cd hindsight-api && uv run ruff check .
|
||||
cd hindsight-api && uv run ruff format .
|
||||
|
||||
# Type checking (uses ty - extremely fast type checker from Astral)
|
||||
cd hindsight-api && uv run ty check hindsight_api/
|
||||
```
|
||||
|
||||
### Control Plane (Next.js)
|
||||
@@ -37,7 +48,7 @@ cd hindsight-control-plane && npm run dev
|
||||
|
||||
### Generating Clients/OpenAPI
|
||||
```bash
|
||||
# Regenerate OpenAPI spec after API changes
|
||||
# Regenerate OpenAPI spec after API changes (REQUIRED after changing endpoints)
|
||||
./scripts/generate-openapi.sh
|
||||
|
||||
# Regenerate all client SDKs (Python, TypeScript, Rust)
|
||||
@@ -57,64 +68,64 @@ cd hindsight-control-plane && npm run dev
|
||||
- **hindsight-api/**: Core FastAPI server with memory engine (Python, uv)
|
||||
- **hindsight/**: Embedded Python bundle (hindsight-all package)
|
||||
- **hindsight-control-plane/**: Admin UI (Next.js, npm)
|
||||
- **hindsight-cli/**: CLI tool (Rust, cargo)
|
||||
- **hindsight-cli/**: CLI tool (Rust, cargo, uses progenitor for API client)
|
||||
- **hindsight-clients/**: Generated SDK clients (Python, TypeScript, Rust)
|
||||
- **hindsight-docs/**: Docusaurus documentation site
|
||||
- **hindsight-integrations/**: Framework integrations (LiteLLM, OpenAI)
|
||||
- **hindsight-dev/**: Development tools and benchmarks
|
||||
|
||||
### Core Engine (hindsight-api/hindsight_api/engine/)
|
||||
- `memory_engine.py`: Main orchestrator for retain/recall/reflect operations
|
||||
- `memory_engine.py`: Main orchestrator (~170KB) for retain/recall/reflect operations
|
||||
- `llm_wrapper.py`: LLM abstraction supporting OpenAI, Anthropic, Gemini, Groq, Ollama, LM Studio
|
||||
- `embeddings.py`: Embedding generation (local or TEI)
|
||||
- `embeddings.py`: Embedding generation (local sentence-transformers or TEI)
|
||||
- `cross_encoder.py`: Reranking (local or TEI)
|
||||
- `entity_resolver.py`: Entity extraction and normalization
|
||||
- `query_analyzer.py`: Query intent analysis
|
||||
- `retain/`: Memory ingestion pipeline
|
||||
- `search/`: Multi-strategy retrieval (semantic, BM25, graph, temporal)
|
||||
|
||||
**retain/**: Memory ingestion pipeline
|
||||
- `orchestrator.py`: Coordinates the retain flow
|
||||
- `fact_extraction.py`: LLM-based fact extraction from content
|
||||
- `link_utils.py`: Entity link creation and management
|
||||
|
||||
**search/**: Multi-strategy retrieval
|
||||
- `retrieval.py`: Main retrieval orchestrator
|
||||
- `graph_retrieval.py`: Entity/relationship graph traversal
|
||||
- `mpfp_retrieval.py`: Multi-Path Fact Propagation retrieval
|
||||
- `fusion.py`: Reciprocal rank fusion for combining results
|
||||
- `reranking.py`: Cross-encoder reranking
|
||||
|
||||
### API Layer (hindsight-api/hindsight_api/api/)
|
||||
FastAPI routers for all endpoints. Main operations:
|
||||
- `http.py`: FastAPI HTTP routers (~80KB) for all REST endpoints
|
||||
- `mcp.py`: Model Context Protocol server implementation
|
||||
|
||||
Main operations:
|
||||
- **Retain**: Store memories, extracts facts/entities/relationships
|
||||
- **Recall**: Retrieve memories via parallel search strategies + reranking
|
||||
- **Reflect**: Deep analysis forming new opinions/observations
|
||||
- **Recall**: Retrieve memories via 4 parallel strategies (semantic, BM25, graph, temporal) + reranking
|
||||
- **Reflect**: Deep analysis forming new opinions/observations (disposition-aware)
|
||||
|
||||
### Database
|
||||
PostgreSQL with pgvector. Schema managed via Alembic migrations in `hindsight-api/hindsight_api/alembic/`. Migrations run automatically on API startup.
|
||||
|
||||
Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links`
|
||||
|
||||
### Database Backups (IMPORTANT)
|
||||
**Before any operation that may affect the database, run a backup:**
|
||||
```bash
|
||||
docker exec hindsight /backups/backup.sh
|
||||
```
|
||||
|
||||
Operations requiring backup:
|
||||
- Running database migrations
|
||||
- Modifying Alembic migration files
|
||||
- Rebuilding Docker images
|
||||
- Resetting or recreating containers
|
||||
- Any schema changes
|
||||
- Bulk data operations
|
||||
|
||||
Backups are stored in `~/hindsight-backups/` on the host.
|
||||
|
||||
To restore:
|
||||
```bash
|
||||
docker exec -it hindsight /backups/restore.sh <backup-file.sql.gz>
|
||||
```
|
||||
|
||||
## Key Conventions
|
||||
|
||||
### Code Quality
|
||||
**Always run the lint script after making Python or TypeScript/Node changes:**
|
||||
```bash
|
||||
./scripts/hooks/lint.sh
|
||||
```
|
||||
This runs the same checks as the pre-commit hook (Ruff for Python, ESLint/Prettier for TypeScript).
|
||||
|
||||
### Memory Banks
|
||||
- Each bank is isolated (no cross-bank data access)
|
||||
- Each bank is an isolated memory store (like a "brain" for one user/agent)
|
||||
- Banks have dispositions (skepticism, literalism, empathy traits 1-5) affecting reflect
|
||||
- Banks can have background context
|
||||
- Bank isolation is strict - no cross-bank data leakage
|
||||
|
||||
### API Design
|
||||
- All endpoints operate on a single bank per request
|
||||
- Multi-bank queries are client responsibility
|
||||
- Multi-bank queries are client responsibility to orchestrate
|
||||
- Disposition traits only affect reflect, not recall
|
||||
|
||||
### Python Style
|
||||
@@ -122,11 +133,35 @@ docker exec -it hindsight /backups/restore.sh <backup-file.sql.gz>
|
||||
- Async throughout (asyncpg, async FastAPI)
|
||||
- Pydantic models for request/response
|
||||
- Ruff for linting (line-length 120)
|
||||
- No Python files at project root - maintain clean directory structure
|
||||
|
||||
### TypeScript Style
|
||||
- Next.js App Router for control plane
|
||||
- Tailwind CSS with shadcn/ui components
|
||||
|
||||
### Adding New API Configuration Flags
|
||||
|
||||
When adding a new environment variable configuration:
|
||||
|
||||
1. **config.py** (`hindsight-api/hindsight_api/config.py`):
|
||||
- Add `ENV_*` constant for the environment variable name
|
||||
- Add `DEFAULT_*` constant for the default value
|
||||
- Add field to `HindsightConfig` dataclass
|
||||
- Add initialization in `from_env()` method
|
||||
|
||||
2. **main.py** (`hindsight-api/hindsight_api/main.py`):
|
||||
- Add field to the manual `HindsightConfig()` constructor call (search for "CLI override")
|
||||
|
||||
3. **Use the config** in code:
|
||||
```python
|
||||
from ...config import get_config
|
||||
config = get_config()
|
||||
value = config.your_new_field
|
||||
```
|
||||
|
||||
4. **Documentation** (`hindsight-docs/docs/developer/configuration.md`):
|
||||
- Add to appropriate section table with Variable, Description, Default
|
||||
|
||||
## Environment Setup
|
||||
|
||||
```bash
|
||||
@@ -136,7 +171,7 @@ cp .env.example .env
|
||||
# Python deps
|
||||
uv sync --directory hindsight-api/
|
||||
|
||||
# Node deps (workspace)
|
||||
# Node deps (uses npm workspaces)
|
||||
npm install
|
||||
```
|
||||
|
||||
@@ -144,3 +179,8 @@ Required env vars:
|
||||
- `HINDSIGHT_API_LLM_PROVIDER`: openai, anthropic, gemini, groq, ollama, lmstudio
|
||||
- `HINDSIGHT_API_LLM_API_KEY`: Your API key
|
||||
- `HINDSIGHT_API_LLM_MODEL`: Model name (e.g., o3-mini, claude-sonnet-4-20250514)
|
||||
|
||||
Optional (uses local models by default):
|
||||
- `HINDSIGHT_API_EMBEDDINGS_PROVIDER`: local (default) or tei
|
||||
- `HINDSIGHT_API_RERANKER_PROVIDER`: local (default) or tei
|
||||
- `HINDSIGHT_API_DATABASE_URL`: External PostgreSQL (uses embedded pg0 by default)
|
||||
|
||||
@@ -110,3 +110,14 @@ API URL for control plane
|
||||
{{- define "hindsight.apiUrl" -}}
|
||||
{{- printf "http://%s-api:%d" (include "hindsight.fullname" .) (.Values.api.service.port | int) }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Get the name of the secret to use
|
||||
*/}}
|
||||
{{- define "hindsight.secretName" -}}
|
||||
{{- if .Values.existingSecret }}
|
||||
{{- .Values.existingSecret }}
|
||||
{{- else }}
|
||||
{{- printf "%s-secret" (include "hindsight.fullname" .) }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -15,7 +15,9 @@ spec:
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
{{- if not .Values.existingSecret }}
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
{{- end }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -37,27 +39,36 @@ spec:
|
||||
- name: http
|
||||
containerPort: {{ .Values.api.service.targetPort }}
|
||||
protocol: TCP
|
||||
{{- if .Values.existingSecret }}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ .Values.existingSecret }}
|
||||
{{- end }}
|
||||
env:
|
||||
- name: HINDSIGHT_API_DATABASE_URL
|
||||
value: {{ include "hindsight.databaseUrl" . | quote }}
|
||||
{{- /* POSTGRES_PASSWORD must be defined before DATABASE_URL for $(VAR) interpolation */}}
|
||||
{{- if not .Values.postgresql.enabled }}
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "hindsight.fullname" . }}-secret
|
||||
name: {{ include "hindsight.secretName" . }}
|
||||
key: postgres-password
|
||||
{{- end }}
|
||||
- name: HINDSIGHT_API_DATABASE_URL
|
||||
value: {{ include "hindsight.databaseUrl" . | quote }}
|
||||
{{- range $key, $value := .Values.api.env }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- /* Only use api.secrets when not using existingSecret (for chart-managed secrets) */}}
|
||||
{{- if not .Values.existingSecret }}
|
||||
{{- range $key, $value := .Values.api.secrets }}
|
||||
- name: {{ $key }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "hindsight.fullname" $ }}-secret
|
||||
name: {{ include "hindsight.secretName" $ }}
|
||||
key: {{ $key }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.api.livenessProbe | nindent 10 }}
|
||||
readinessProbe:
|
||||
|
||||
@@ -15,7 +15,9 @@ spec:
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
{{- if not .Values.existingSecret }}
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
{{- end }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
@@ -37,6 +39,11 @@ spec:
|
||||
- name: http
|
||||
containerPort: {{ .Values.controlPlane.service.targetPort }}
|
||||
protocol: TCP
|
||||
{{- if .Values.existingSecret }}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ .Values.existingSecret }}
|
||||
{{- end }}
|
||||
env:
|
||||
- name: HINDSIGHT_CP_DATAPLANE_API_URL
|
||||
value: {{ include "hindsight.apiUrl" . | quote }}
|
||||
@@ -44,13 +51,16 @@ spec:
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- /* Only use controlPlane.secrets when not using existingSecret (for chart-managed secrets) */}}
|
||||
{{- if not .Values.existingSecret }}
|
||||
{{- range $key, $value := .Values.controlPlane.secrets }}
|
||||
- name: {{ $key }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "hindsight.fullname" $ }}-secret
|
||||
name: {{ include "hindsight.secretName" $ }}
|
||||
key: {{ $key }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.controlPlane.livenessProbe | nindent 10 }}
|
||||
readinessProbe:
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{{- if not .Values.existingSecret }}
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: {{ include "hindsight.fullname" . }}-secret
|
||||
name: {{ include "hindsight.secretName" . }}
|
||||
labels:
|
||||
{{- include "hindsight.labels" . | nindent 4 }}
|
||||
type: Opaque
|
||||
@@ -15,3 +16,4 @@ data:
|
||||
{{- if and (not .Values.postgresql.enabled) .Values.postgresql.external.password }}
|
||||
postgres-password: {{ .Values.postgresql.external.password | b64enc | quote }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
# Chart version - use this to set a consistent image tag across all components
|
||||
version: "0.1.1"
|
||||
|
||||
# Use an existing secret instead of creating one from values
|
||||
# When set, all keys from this secret are injected as environment variables via envFrom
|
||||
# Required keys:
|
||||
# - postgres-password: PostgreSQL password (when postgresql.enabled=false)
|
||||
# Optional keys (any key becomes an env var):
|
||||
# - HINDSIGHT_API_LLM_API_KEY: API key for LLM provider
|
||||
# - Any other env vars you want to inject
|
||||
# existingSecret: "my-hindsight-secret"
|
||||
|
||||
# Global settings
|
||||
replicaCount: 1
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
# Admin CLI for Hindsight
|
||||
@@ -0,0 +1,252 @@
|
||||
"""
|
||||
Hindsight Admin CLI - backup and restore operations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import asyncpg
|
||||
import typer
|
||||
|
||||
from ..config import HindsightConfig
|
||||
from ..pg0 import parse_pg0_url, resolve_database_url
|
||||
|
||||
|
||||
def _fq_table(table: str, schema: str) -> str:
|
||||
"""Get fully-qualified table name with schema prefix."""
|
||||
return f"{schema}.{table}"
|
||||
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(message)s",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = typer.Typer(name="hindsight-admin", help="Hindsight administrative commands")
|
||||
|
||||
# Tables to backup/restore in dependency order
|
||||
# Import must happen in this order due to foreign key constraints
|
||||
BACKUP_TABLES = [
|
||||
"banks",
|
||||
"documents",
|
||||
"entities",
|
||||
"chunks",
|
||||
"memory_units",
|
||||
"unit_entities",
|
||||
"entity_cooccurrences",
|
||||
"memory_links",
|
||||
]
|
||||
|
||||
MANIFEST_VERSION = "1"
|
||||
|
||||
|
||||
async def _backup(database_url: str, output_path: Path, schema: str = "public") -> dict[str, Any]:
|
||||
"""Backup all tables to a zip file using binary COPY protocol."""
|
||||
conn = await asyncpg.connect(database_url)
|
||||
try:
|
||||
tables: dict[str, Any] = {}
|
||||
manifest: dict[str, Any] = {
|
||||
"version": MANIFEST_VERSION,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"schema": schema,
|
||||
"tables": tables,
|
||||
}
|
||||
|
||||
# Use a transaction with REPEATABLE READ isolation to get a consistent
|
||||
# snapshot across all tables. This prevents race conditions where
|
||||
# entity_cooccurrences could reference entities created after the
|
||||
# entities table was backed up.
|
||||
async with conn.transaction(isolation="repeatable_read"):
|
||||
with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for i, table in enumerate(BACKUP_TABLES, 1):
|
||||
typer.echo(f" [{i}/{len(BACKUP_TABLES)}] Backing up {table}...", nl=False)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
|
||||
# Use binary COPY for exact type preservation
|
||||
# asyncpg requires schema_name as separate parameter
|
||||
await conn.copy_from_table(table, schema_name=schema, output=buffer, format="binary")
|
||||
|
||||
data = buffer.getvalue()
|
||||
zf.writestr(f"{table}.bin", data)
|
||||
|
||||
# Get row count for manifest
|
||||
qualified_table = _fq_table(table, schema)
|
||||
row_count = await conn.fetchval(f"SELECT COUNT(*) FROM {qualified_table}")
|
||||
tables[table] = {
|
||||
"rows": row_count,
|
||||
"size_bytes": len(data),
|
||||
}
|
||||
|
||||
typer.echo(f" {row_count} rows")
|
||||
|
||||
zf.writestr("manifest.json", json.dumps(manifest, indent=2))
|
||||
|
||||
return manifest
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _restore(database_url: str, input_path: Path, schema: str = "public") -> dict[str, Any]:
|
||||
"""Restore all tables from a zip file using binary COPY protocol."""
|
||||
conn = await asyncpg.connect(database_url)
|
||||
try:
|
||||
with zipfile.ZipFile(input_path, "r") as zf:
|
||||
# Read and validate manifest
|
||||
manifest: dict[str, Any] = json.loads(zf.read("manifest.json"))
|
||||
if manifest.get("version") != MANIFEST_VERSION:
|
||||
raise ValueError(f"Unsupported backup version: {manifest.get('version')}")
|
||||
|
||||
# Use a transaction for atomic restore - either all tables are
|
||||
# restored or none are, preventing partial/inconsistent state.
|
||||
async with conn.transaction():
|
||||
typer.echo(" Clearing existing data...")
|
||||
# Truncate tables in reverse order (respects FK constraints)
|
||||
for table in reversed(BACKUP_TABLES):
|
||||
qualified_table = _fq_table(table, schema)
|
||||
await conn.execute(f"TRUNCATE TABLE {qualified_table} CASCADE")
|
||||
|
||||
# Restore tables in forward order
|
||||
for i, table in enumerate(BACKUP_TABLES, 1):
|
||||
filename = f"{table}.bin"
|
||||
if filename not in zf.namelist():
|
||||
typer.echo(f" [{i}/{len(BACKUP_TABLES)}] {table}: skipped (not in backup)")
|
||||
continue
|
||||
|
||||
expected_rows = manifest["tables"].get(table, {}).get("rows", "?")
|
||||
typer.echo(f" [{i}/{len(BACKUP_TABLES)}] Restoring {table}... {expected_rows} rows")
|
||||
|
||||
data = zf.read(filename)
|
||||
buffer = io.BytesIO(data)
|
||||
# asyncpg requires schema_name as separate parameter
|
||||
await conn.copy_to_table(table, schema_name=schema, source=buffer, format="binary")
|
||||
|
||||
# Refresh materialized view
|
||||
typer.echo(" Refreshing materialized views...")
|
||||
await conn.execute(f"REFRESH MATERIALIZED VIEW {_fq_table('memory_units_bm25', schema)}")
|
||||
|
||||
return manifest
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
async def _run_backup(db_url: str, output: Path, schema: str = "public") -> dict[str, Any]:
|
||||
"""Resolve database URL and run backup."""
|
||||
is_pg0, instance_name, _ = parse_pg0_url(db_url)
|
||||
if is_pg0:
|
||||
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
|
||||
resolved_url = await resolve_database_url(db_url)
|
||||
return await _backup(resolved_url, output, schema)
|
||||
|
||||
|
||||
async def _run_restore(db_url: str, input_file: Path, schema: str = "public") -> dict[str, Any]:
|
||||
"""Resolve database URL and run restore."""
|
||||
is_pg0, instance_name, _ = parse_pg0_url(db_url)
|
||||
if is_pg0:
|
||||
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
|
||||
resolved_url = await resolve_database_url(db_url)
|
||||
return await _restore(resolved_url, input_file, schema)
|
||||
|
||||
|
||||
@app.command()
|
||||
def backup(
|
||||
output: Path = typer.Argument(..., help="Output file path (.zip)"),
|
||||
schema: str = typer.Option("public", "--schema", "-s", help="Database schema to backup"),
|
||||
):
|
||||
"""Backup the Hindsight database to a zip file."""
|
||||
config = HindsightConfig.from_env()
|
||||
|
||||
if not config.database_url:
|
||||
typer.echo("Error: Database URL not configured.", err=True)
|
||||
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if output.suffix != ".zip":
|
||||
output = output.with_suffix(".zip")
|
||||
|
||||
typer.echo(f"Backing up database (schema: {schema}) to {output}...")
|
||||
|
||||
manifest = asyncio.run(_run_backup(config.database_url, output, schema))
|
||||
|
||||
total_rows = sum(t["rows"] for t in manifest["tables"].values())
|
||||
typer.echo(f"Backed up {total_rows} rows across {len(BACKUP_TABLES)} tables")
|
||||
typer.echo(f"Backup saved to {output}")
|
||||
|
||||
|
||||
@app.command()
|
||||
def restore(
|
||||
input_file: Path = typer.Argument(..., help="Input backup file (.zip)"),
|
||||
schema: str = typer.Option("public", "--schema", "-s", help="Database schema to restore to"),
|
||||
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
||||
):
|
||||
"""Restore the database from a backup file. WARNING: This deletes all existing data."""
|
||||
config = HindsightConfig.from_env()
|
||||
|
||||
if not config.database_url:
|
||||
typer.echo("Error: Database URL not configured.", err=True)
|
||||
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not input_file.exists():
|
||||
typer.echo(f"Error: File not found: {input_file}", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not yes:
|
||||
typer.confirm(
|
||||
"This will DELETE all existing data and replace it with the backup. Continue?",
|
||||
abort=True,
|
||||
)
|
||||
|
||||
typer.echo(f"Restoring database (schema: {schema}) from {input_file}...")
|
||||
|
||||
manifest = asyncio.run(_run_restore(config.database_url, input_file, schema))
|
||||
|
||||
total_rows = sum(t["rows"] for t in manifest["tables"].values())
|
||||
typer.echo(f"Restored {total_rows} rows across {len(BACKUP_TABLES)} tables")
|
||||
typer.echo("Restore complete")
|
||||
|
||||
|
||||
async def _run_migration(db_url: str, schema: str = "public") -> None:
|
||||
"""Resolve database URL and run migrations."""
|
||||
from ..migrations import run_migrations
|
||||
|
||||
is_pg0, instance_name, _ = parse_pg0_url(db_url)
|
||||
if is_pg0:
|
||||
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
|
||||
resolved_url = await resolve_database_url(db_url)
|
||||
run_migrations(resolved_url, schema=schema)
|
||||
|
||||
|
||||
@app.command(name="run-db-migration")
|
||||
def run_db_migration(
|
||||
schema: str = typer.Option("public", "--schema", "-s", help="Database schema to run migrations on"),
|
||||
):
|
||||
"""Run database migrations to the latest version."""
|
||||
config = HindsightConfig.from_env()
|
||||
|
||||
if not config.database_url:
|
||||
typer.echo("Error: Database URL not configured.", err=True)
|
||||
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
typer.echo(f"Running database migrations (schema: {schema})...")
|
||||
|
||||
asyncio.run(_run_migration(config.database_url, schema))
|
||||
|
||||
typer.echo("Database migrations completed successfully")
|
||||
|
||||
|
||||
def main():
|
||||
app()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -36,7 +36,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from hindsight_api import MemoryEngine
|
||||
from hindsight_api.engine.db_utils import acquire_with_retry
|
||||
from hindsight_api.engine.memory_engine import Budget, fq_table
|
||||
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
|
||||
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, TokenUsage
|
||||
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
|
||||
from hindsight_api.metrics import create_metrics_collector, get_metrics_collector, initialize_metrics
|
||||
from hindsight_api.models import RequestContext
|
||||
@@ -364,7 +364,15 @@ class RetainResponse(BaseModel):
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
json_schema_extra={"example": {"success": True, "bank_id": "user123", "items_count": 2, "async": False}},
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"success": True,
|
||||
"bank_id": "user123",
|
||||
"items_count": 2,
|
||||
"async": False,
|
||||
"usage": {"input_tokens": 500, "output_tokens": 100, "total_tokens": 600},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
success: bool
|
||||
@@ -373,6 +381,10 @@ class RetainResponse(BaseModel):
|
||||
is_async: bool = Field(
|
||||
alias="async", serialization_alias="async", description="Whether the operation was processed asynchronously"
|
||||
)
|
||||
usage: TokenUsage | None = Field(
|
||||
default=None,
|
||||
description="Token usage metrics for LLM calls during fact extraction (only present for synchronous operations)",
|
||||
)
|
||||
|
||||
|
||||
class FactsIncludeOptions(BaseModel):
|
||||
@@ -472,6 +484,7 @@ class ReflectResponse(BaseModel):
|
||||
"summary": "AI is transformative",
|
||||
"key_points": ["Used in healthcare", "Discussed recently"],
|
||||
},
|
||||
"usage": {"input_tokens": 1500, "output_tokens": 500, "total_tokens": 2000},
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -482,6 +495,10 @@ class ReflectResponse(BaseModel):
|
||||
default=None,
|
||||
description="Structured output parsed according to the request's response_schema. Only present when response_schema was provided in the request.",
|
||||
)
|
||||
usage: TokenUsage | None = Field(
|
||||
default=None,
|
||||
description="Token usage metrics for LLM calls during reflection.",
|
||||
)
|
||||
|
||||
|
||||
class BanksResponse(BaseModel):
|
||||
@@ -630,6 +647,7 @@ class GraphDataResponse(BaseModel):
|
||||
}
|
||||
],
|
||||
"total_units": 2,
|
||||
"limit": 1000,
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -638,6 +656,7 @@ class GraphDataResponse(BaseModel):
|
||||
edges: list[dict[str, Any]]
|
||||
table_rows: list[dict[str, Any]]
|
||||
total_units: int
|
||||
limit: int
|
||||
|
||||
|
||||
class ListMemoryUnitsResponse(BaseModel):
|
||||
@@ -1049,16 +1068,19 @@ def _register_routes(app: FastAPI):
|
||||
"/v1/default/banks/{bank_id}/graph",
|
||||
response_model=GraphDataResponse,
|
||||
summary="Get memory graph data",
|
||||
description="Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). Limited to 1000 most recent items.",
|
||||
description="Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion).",
|
||||
operation_id="get_graph",
|
||||
tags=["Memory"],
|
||||
)
|
||||
async def api_graph(
|
||||
bank_id: str, type: str | None = None, request_context: RequestContext = Depends(get_request_context)
|
||||
bank_id: str,
|
||||
type: str | None = None,
|
||||
limit: int = 1000,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Get graph data from database, filtered by bank_id and optionally by type."""
|
||||
try:
|
||||
data = await app.state.memory.get_graph_data(bank_id, type, request_context=request_context)
|
||||
data = await app.state.memory.get_graph_data(bank_id, type, limit=limit, request_context=request_context)
|
||||
return data
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
@@ -1290,6 +1312,7 @@ def _register_routes(app: FastAPI):
|
||||
text=core_result.text,
|
||||
based_on=based_on_facts,
|
||||
structured_output=core_result.structured_output,
|
||||
usage=core_result.usage,
|
||||
)
|
||||
|
||||
except OperationValidationError as e:
|
||||
@@ -2016,12 +2039,12 @@ def _register_routes(app: FastAPI):
|
||||
else:
|
||||
# Synchronous processing: wait for completion (record metrics)
|
||||
with metrics.record_operation("retain", bank_id=bank_id):
|
||||
result = await app.state.memory.retain_batch_async(
|
||||
bank_id=bank_id, contents=contents, request_context=request_context
|
||||
result, usage = await app.state.memory.retain_batch_async(
|
||||
bank_id=bank_id, contents=contents, request_context=request_context, return_usage=True
|
||||
)
|
||||
|
||||
return RetainResponse.model_validate(
|
||||
{"success": True, "bank_id": bank_id, "items_count": len(contents), "async": False}
|
||||
{"success": True, "bank_id": bank_id, "items_count": len(contents), "async": False, "usage": usage}
|
||||
)
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
|
||||
@@ -8,7 +8,6 @@ from contextvars import ContextVar
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from hindsight_api import MemoryEngine
|
||||
from hindsight_api.api.http import BankListItem, BankListResponse, BankProfileResponse, DispositionTraits
|
||||
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
@@ -54,7 +53,12 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
|
||||
mcp = FastMCP("hindsight-mcp-server", stateless_http=True)
|
||||
|
||||
@mcp.tool()
|
||||
async def retain(content: str, context: str = "general", bank_id: str | None = None) -> str:
|
||||
async def retain(
|
||||
content: str,
|
||||
context: str = "general",
|
||||
async_processing: bool = True,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Store important information to long-term memory.
|
||||
|
||||
@@ -70,18 +74,28 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
|
||||
Args:
|
||||
content: The fact/memory to store (be specific and include relevant details)
|
||||
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
|
||||
async_processing: If True, queue for background processing and return immediately. If False, wait for completion. Default: True
|
||||
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or get_current_bank_id()
|
||||
if target_bank is None:
|
||||
return "Error: No bank_id configured"
|
||||
await memory.retain_batch_async(
|
||||
bank_id=target_bank,
|
||||
contents=[{"content": content, "context": context}],
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
return f"Memory stored successfully in bank '{target_bank}'"
|
||||
contents = [{"content": content, "context": context}]
|
||||
if async_processing:
|
||||
# Queue for background processing and return immediately
|
||||
result = await memory.submit_async_retain(
|
||||
bank_id=target_bank, contents=contents, request_context=RequestContext()
|
||||
)
|
||||
return f"Memory queued for background processing (operation_id: {result.get('operation_id', 'N/A')})"
|
||||
else:
|
||||
# Wait for completion
|
||||
await memory.retain_batch_async(
|
||||
bank_id=target_bank,
|
||||
contents=contents,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
return f"Memory stored successfully in bank '{target_bank}'"
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing memory: {e}", exc_info=True)
|
||||
return f"Error: {str(e)}"
|
||||
@@ -178,28 +192,15 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
|
||||
"""
|
||||
List all available memory banks.
|
||||
|
||||
Use this to discover banks for orchestration or to find
|
||||
the correct bank_id for cross-bank operations.
|
||||
Use this tool to discover what memory banks exist in the system.
|
||||
Each bank is an isolated memory store (like a separate "brain").
|
||||
|
||||
Returns:
|
||||
JSON object with banks array containing bank_id, name, disposition, background, and timestamps
|
||||
JSON list of banks with their IDs, names, dispositions, and backgrounds.
|
||||
"""
|
||||
try:
|
||||
banks = await memory.list_banks(request_context=RequestContext())
|
||||
bank_items = [
|
||||
BankListItem(
|
||||
bank_id=b.get("bank_id") or b.get("id"),
|
||||
name=b.get("name"),
|
||||
disposition=DispositionTraits(
|
||||
**b.get("disposition", {"skepticism": 3, "literalism": 3, "empathy": 3})
|
||||
),
|
||||
background=b.get("background"),
|
||||
created_at=str(b.get("created_at")) if b.get("created_at") else None,
|
||||
updated_at=str(b.get("updated_at")) if b.get("updated_at") else None,
|
||||
)
|
||||
for b in banks
|
||||
]
|
||||
return BankListResponse(banks=bank_items).model_dump_json(indent=2)
|
||||
return json.dumps({"banks": banks}, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing banks: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}", "banks": []}}'
|
||||
@@ -207,44 +208,38 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
|
||||
@mcp.tool()
|
||||
async def create_bank(bank_id: str, name: str | None = None, background: str | None = None) -> str:
|
||||
"""
|
||||
Create or update a memory bank.
|
||||
Create a new memory bank or get an existing one.
|
||||
|
||||
Use this to create new banks for different agents, sessions, or purposes.
|
||||
Banks are isolated memory stores - each bank has its own memories and personality.
|
||||
Memory banks are isolated stores - each one is like a separate "brain" for a user/agent.
|
||||
Banks are auto-created with default settings if they don't exist.
|
||||
|
||||
Args:
|
||||
bank_id: Unique identifier for the bank (e.g., 'orchestrator-memory', 'agent-1')
|
||||
name: Human-readable name for the bank
|
||||
background: Context about what this bank stores or its purpose
|
||||
bank_id: Unique identifier for the bank (e.g., 'user-123', 'agent-alpha')
|
||||
name: Optional human-friendly name for the bank
|
||||
background: Optional background context about the bank's owner/purpose
|
||||
"""
|
||||
try:
|
||||
# Get or create the bank profile (auto-creates with defaults)
|
||||
await memory.get_bank_profile(bank_id, request_context=RequestContext())
|
||||
|
||||
# Update name and/or background if provided
|
||||
if name is not None or background is not None:
|
||||
await memory.update_bank(bank_id, name=name, background=background, request_context=RequestContext())
|
||||
|
||||
# Get final profile and return using BankProfileResponse model
|
||||
# get_bank_profile auto-creates bank if it doesn't exist
|
||||
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
|
||||
disposition = profile.get("disposition")
|
||||
if hasattr(disposition, "model_dump"):
|
||||
disposition_traits = DispositionTraits(**disposition.model_dump())
|
||||
else:
|
||||
disposition_traits = DispositionTraits(
|
||||
**dict(disposition or {"skepticism": 3, "literalism": 3, "empathy": 3})
|
||||
)
|
||||
|
||||
response = BankProfileResponse(
|
||||
bank_id=bank_id,
|
||||
name=profile.get("name") or "",
|
||||
disposition=disposition_traits,
|
||||
background=profile.get("background") or "",
|
||||
)
|
||||
return response.model_dump_json(indent=2)
|
||||
# Update name/background if provided
|
||||
if name is not None or background is not None:
|
||||
await memory.update_bank(
|
||||
bank_id,
|
||||
name=name,
|
||||
background=background,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
# Fetch updated profile
|
||||
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
|
||||
|
||||
# Serialize disposition if it's a Pydantic model
|
||||
if "disposition" in profile and hasattr(profile["disposition"], "model_dump"):
|
||||
profile["disposition"] = profile["disposition"].model_dump()
|
||||
return json.dumps(profile, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating bank: {e}", exc_info=True)
|
||||
return json.dumps({"error": str(e)})
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from dotenv import find_dotenv, load_dotenv
|
||||
|
||||
# Load .env file, searching current and parent directories (overrides existing env vars)
|
||||
load_dotenv(find_dotenv(usecwd=True), override=True)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Environment variable names
|
||||
@@ -26,6 +31,10 @@ ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
|
||||
ENV_EMBEDDINGS_OPENAI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY"
|
||||
ENV_EMBEDDINGS_OPENAI_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"
|
||||
|
||||
ENV_COHERE_API_KEY = "HINDSIGHT_API_COHERE_API_KEY"
|
||||
ENV_EMBEDDINGS_COHERE_MODEL = "HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL"
|
||||
ENV_RERANKER_COHERE_MODEL = "HINDSIGHT_API_RERANKER_COHERE_MODEL"
|
||||
|
||||
ENV_RERANKER_PROVIDER = "HINDSIGHT_API_RERANKER_PROVIDER"
|
||||
ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
|
||||
ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL"
|
||||
@@ -42,10 +51,16 @@ ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
|
||||
ENV_OBSERVATION_MIN_FACTS = "HINDSIGHT_API_OBSERVATION_MIN_FACTS"
|
||||
ENV_OBSERVATION_TOP_ENTITIES = "HINDSIGHT_API_OBSERVATION_TOP_ENTITIES"
|
||||
|
||||
# Retain settings
|
||||
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
|
||||
|
||||
# Optimization flags
|
||||
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
|
||||
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
|
||||
|
||||
# Database migrations
|
||||
ENV_RUN_MIGRATIONS_ON_STARTUP = "HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP"
|
||||
|
||||
# Default values
|
||||
DEFAULT_DATABASE_URL = "pg0"
|
||||
DEFAULT_LLM_PROVIDER = "openai"
|
||||
@@ -61,6 +76,9 @@ DEFAULT_EMBEDDING_DIMENSION = 384
|
||||
DEFAULT_RERANKER_PROVIDER = "local"
|
||||
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
||||
|
||||
DEFAULT_EMBEDDINGS_COHERE_MODEL = "embed-english-v3.0"
|
||||
DEFAULT_RERANKER_COHERE_MODEL = "rerank-english-v3.0"
|
||||
|
||||
DEFAULT_HOST = "0.0.0.0"
|
||||
DEFAULT_PORT = 8888
|
||||
DEFAULT_LOG_LEVEL = "info"
|
||||
@@ -72,6 +90,12 @@ DEFAULT_MCP_LOCAL_BANK_ID = "mcp"
|
||||
DEFAULT_OBSERVATION_MIN_FACTS = 5 # Min facts required to generate entity observations
|
||||
DEFAULT_OBSERVATION_TOP_ENTITIES = 5 # Max entities to process per retain batch
|
||||
|
||||
# Retain settings
|
||||
DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call
|
||||
|
||||
# Database migrations
|
||||
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
|
||||
|
||||
# Default MCP tool descriptions (can be customized via env vars)
|
||||
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
|
||||
|
||||
@@ -134,10 +158,16 @@ class HindsightConfig:
|
||||
observation_min_facts: int
|
||||
observation_top_entities: int
|
||||
|
||||
# Retain settings
|
||||
retain_max_completion_tokens: int
|
||||
|
||||
# Optimization flags
|
||||
skip_llm_verification: bool
|
||||
lazy_reranker: bool
|
||||
|
||||
# Database migrations
|
||||
run_migrations_on_startup: bool
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "HindsightConfig":
|
||||
"""Create configuration from environment variables."""
|
||||
@@ -174,6 +204,12 @@ class HindsightConfig:
|
||||
observation_top_entities=int(
|
||||
os.getenv(ENV_OBSERVATION_TOP_ENTITIES, str(DEFAULT_OBSERVATION_TOP_ENTITIES))
|
||||
),
|
||||
# Retain settings
|
||||
retain_max_completion_tokens=int(
|
||||
os.getenv(ENV_RETAIN_MAX_COMPLETION_TOKENS, str(DEFAULT_RETAIN_MAX_COMPLETION_TOKENS))
|
||||
),
|
||||
# Database migrations
|
||||
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
|
||||
)
|
||||
|
||||
def get_llm_base_url(self) -> str:
|
||||
@@ -220,6 +256,19 @@ class HindsightConfig:
|
||||
logger.info(f"Graph retriever: {self.graph_retriever}")
|
||||
|
||||
|
||||
# Cached config instance
|
||||
_config_cache: HindsightConfig | None = None
|
||||
|
||||
|
||||
def get_config() -> HindsightConfig:
|
||||
"""Get the current configuration from environment variables."""
|
||||
return HindsightConfig.from_env()
|
||||
"""Get the cached configuration, loading from environment on first call."""
|
||||
global _config_cache
|
||||
if _config_cache is None:
|
||||
_config_cache = HindsightConfig.from_env()
|
||||
return _config_cache
|
||||
|
||||
|
||||
def clear_config_cache() -> None:
|
||||
"""Clear the config cache. Useful for testing or reloading config."""
|
||||
global _config_cache
|
||||
_config_cache = None
|
||||
|
||||
@@ -13,8 +13,11 @@ from abc import ABC, abstractmethod
|
||||
import httpx
|
||||
|
||||
from ..config import (
|
||||
DEFAULT_RERANKER_COHERE_MODEL,
|
||||
DEFAULT_RERANKER_LOCAL_MODEL,
|
||||
DEFAULT_RERANKER_PROVIDER,
|
||||
ENV_COHERE_API_KEY,
|
||||
ENV_RERANKER_COHERE_MODEL,
|
||||
ENV_RERANKER_LOCAL_MODEL,
|
||||
ENV_RERANKER_PROVIDER,
|
||||
ENV_RERANKER_TEI_URL,
|
||||
@@ -278,6 +281,96 @@ class RemoteTEICrossEncoder(CrossEncoderModel):
|
||||
return all_scores
|
||||
|
||||
|
||||
class CohereCrossEncoder(CrossEncoderModel):
|
||||
"""
|
||||
Cohere cross-encoder implementation using the Cohere Rerank API.
|
||||
|
||||
Supports rerank-english-v3.0 and rerank-multilingual-v3.0 models.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
model: str = DEFAULT_RERANKER_COHERE_MODEL,
|
||||
timeout: float = 60.0,
|
||||
):
|
||||
"""
|
||||
Initialize Cohere cross-encoder client.
|
||||
|
||||
Args:
|
||||
api_key: Cohere API key
|
||||
model: Cohere rerank model name (default: rerank-english-v3.0)
|
||||
timeout: Request timeout in seconds (default: 60.0)
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.timeout = timeout
|
||||
self._client = None
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "cohere"
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the Cohere client."""
|
||||
if self._client is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
import cohere
|
||||
except ImportError:
|
||||
raise ImportError("cohere is required for CohereCrossEncoder. Install it with: pip install cohere")
|
||||
|
||||
logger.info(f"Reranker: initializing Cohere provider with model {self.model}")
|
||||
self._client = cohere.Client(api_key=self.api_key, timeout=self.timeout)
|
||||
logger.info("Reranker: Cohere provider initialized")
|
||||
|
||||
def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""
|
||||
Score query-document pairs using the Cohere Rerank API.
|
||||
|
||||
Args:
|
||||
pairs: List of (query, document) tuples to score
|
||||
|
||||
Returns:
|
||||
List of relevance scores
|
||||
"""
|
||||
if self._client is None:
|
||||
raise RuntimeError("Reranker not initialized. Call initialize() first.")
|
||||
|
||||
if not pairs:
|
||||
return []
|
||||
|
||||
# Group pairs by query for efficient batching
|
||||
# Cohere 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]
|
||||
|
||||
response = self._client.rerank(
|
||||
query=query,
|
||||
documents=texts,
|
||||
model=self.model,
|
||||
return_documents=False,
|
||||
)
|
||||
|
||||
# Map scores back to original positions
|
||||
for result in response.results:
|
||||
original_idx = result.index
|
||||
score = result.relevance_score
|
||||
all_scores[indices[original_idx]] = score
|
||||
|
||||
return all_scores
|
||||
|
||||
|
||||
def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
"""
|
||||
Create a CrossEncoderModel instance based on environment variables.
|
||||
@@ -298,5 +391,11 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
model = os.environ.get(ENV_RERANKER_LOCAL_MODEL)
|
||||
model_name = model or DEFAULT_RERANKER_LOCAL_MODEL
|
||||
return LocalSTCrossEncoder(model_name=model_name)
|
||||
elif provider == "cohere":
|
||||
api_key = os.environ.get(ENV_COHERE_API_KEY)
|
||||
if not api_key:
|
||||
raise ValueError(f"{ENV_COHERE_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'cohere'")
|
||||
model = os.environ.get(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL)
|
||||
return CohereCrossEncoder(api_key=api_key, model=model)
|
||||
else:
|
||||
raise ValueError(f"Unknown reranker provider: {provider}. Supported: 'local', 'tei'")
|
||||
raise ValueError(f"Unknown reranker provider: {provider}. Supported: 'local', 'tei', 'cohere'")
|
||||
|
||||
@@ -16,9 +16,12 @@ from abc import ABC, abstractmethod
|
||||
import httpx
|
||||
|
||||
from ..config import (
|
||||
DEFAULT_EMBEDDINGS_COHERE_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
|
||||
DEFAULT_EMBEDDINGS_OPENAI_MODEL,
|
||||
DEFAULT_EMBEDDINGS_PROVIDER,
|
||||
ENV_COHERE_API_KEY,
|
||||
ENV_EMBEDDINGS_COHERE_MODEL,
|
||||
ENV_EMBEDDINGS_LOCAL_MODEL,
|
||||
ENV_EMBEDDINGS_OPENAI_API_KEY,
|
||||
ENV_EMBEDDINGS_OPENAI_MODEL,
|
||||
@@ -409,6 +412,123 @@ class OpenAIEmbeddings(Embeddings):
|
||||
return all_embeddings
|
||||
|
||||
|
||||
class CohereEmbeddings(Embeddings):
|
||||
"""
|
||||
Cohere embeddings implementation using the Cohere API.
|
||||
|
||||
Supports embed-english-v3.0 (1024 dims) and embed-multilingual-v3.0 (1024 dims).
|
||||
|
||||
The embedding dimension is auto-detected from the model at initialization.
|
||||
"""
|
||||
|
||||
# Known dimensions for Cohere embedding models
|
||||
MODEL_DIMENSIONS = {
|
||||
"embed-english-v3.0": 1024,
|
||||
"embed-multilingual-v3.0": 1024,
|
||||
"embed-english-light-v3.0": 384,
|
||||
"embed-multilingual-light-v3.0": 384,
|
||||
"embed-english-v2.0": 4096,
|
||||
"embed-multilingual-v2.0": 768,
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
model: str = DEFAULT_EMBEDDINGS_COHERE_MODEL,
|
||||
batch_size: int = 96,
|
||||
timeout: float = 60.0,
|
||||
input_type: str = "search_document",
|
||||
):
|
||||
"""
|
||||
Initialize Cohere embeddings client.
|
||||
|
||||
Args:
|
||||
api_key: Cohere API key
|
||||
model: Cohere embedding model name (default: embed-english-v3.0)
|
||||
batch_size: Maximum batch size for embedding requests (default: 96, Cohere's limit)
|
||||
timeout: Request timeout in seconds (default: 60.0)
|
||||
input_type: Input type for embeddings (default: search_document).
|
||||
Options: search_document, search_query, classification, clustering
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self.batch_size = batch_size
|
||||
self.timeout = timeout
|
||||
self.input_type = input_type
|
||||
self._client = None
|
||||
self._dimension: int | None = None
|
||||
|
||||
@property
|
||||
def provider_name(self) -> str:
|
||||
return "cohere"
|
||||
|
||||
@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 Cohere client and detect dimension."""
|
||||
if self._client is not None:
|
||||
return
|
||||
|
||||
try:
|
||||
import cohere
|
||||
except ImportError:
|
||||
raise ImportError("cohere is required for CohereEmbeddings. Install it with: pip install cohere")
|
||||
|
||||
logger.info(f"Embeddings: initializing Cohere provider with model {self.model}")
|
||||
self._client = cohere.Client(api_key=self.api_key, timeout=self.timeout)
|
||||
|
||||
# Try to get dimension from known models, otherwise do a test embedding
|
||||
if self.model in self.MODEL_DIMENSIONS:
|
||||
self._dimension = self.MODEL_DIMENSIONS[self.model]
|
||||
else:
|
||||
# Do a test embedding to detect dimension
|
||||
response = self._client.embed(
|
||||
texts=["test"],
|
||||
model=self.model,
|
||||
input_type=self.input_type,
|
||||
)
|
||||
if response.embeddings:
|
||||
self._dimension = len(response.embeddings[0])
|
||||
|
||||
logger.info(f"Embeddings: Cohere provider initialized (model: {self.model}, dim: {self._dimension})")
|
||||
|
||||
def encode(self, texts: list[str]) -> list[list[float]]:
|
||||
"""
|
||||
Generate embeddings using the Cohere API.
|
||||
|
||||
Args:
|
||||
texts: List of text strings to encode
|
||||
|
||||
Returns:
|
||||
List of embedding vectors
|
||||
"""
|
||||
if self._client is None:
|
||||
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
|
||||
|
||||
if not texts:
|
||||
return []
|
||||
|
||||
all_embeddings = []
|
||||
|
||||
# Process in batches
|
||||
for i in range(0, len(texts), self.batch_size):
|
||||
batch = texts[i : i + self.batch_size]
|
||||
|
||||
response = self._client.embed(
|
||||
texts=batch,
|
||||
model=self.model,
|
||||
input_type=self.input_type,
|
||||
)
|
||||
|
||||
all_embeddings.extend(response.embeddings)
|
||||
|
||||
return all_embeddings
|
||||
|
||||
|
||||
def create_embeddings_from_env() -> Embeddings:
|
||||
"""
|
||||
Create an Embeddings instance based on environment variables.
|
||||
@@ -439,5 +559,11 @@ def create_embeddings_from_env() -> Embeddings:
|
||||
)
|
||||
model = os.environ.get(ENV_EMBEDDINGS_OPENAI_MODEL, DEFAULT_EMBEDDINGS_OPENAI_MODEL)
|
||||
return OpenAIEmbeddings(api_key=api_key, model=model)
|
||||
elif provider == "cohere":
|
||||
api_key = os.environ.get(ENV_COHERE_API_KEY)
|
||||
if not api_key:
|
||||
raise ValueError(f"{ENV_COHERE_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'cohere'")
|
||||
model = os.environ.get(ENV_EMBEDDINGS_COHERE_MODEL, DEFAULT_EMBEDDINGS_COHERE_MODEL)
|
||||
return CohereEmbeddings(api_key=api_key, model=model)
|
||||
else:
|
||||
raise ValueError(f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei', 'openai'")
|
||||
raise ValueError(f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei', 'openai', 'cohere'")
|
||||
|
||||
@@ -289,6 +289,7 @@ class MemoryEngineInterface(ABC):
|
||||
bank_id: str,
|
||||
*,
|
||||
fact_type: str | None = None,
|
||||
limit: int = 1000,
|
||||
request_context: "RequestContext",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
@@ -297,10 +298,11 @@ class MemoryEngineInterface(ABC):
|
||||
Args:
|
||||
bank_id: The memory bank ID.
|
||||
fact_type: Filter by fact type.
|
||||
limit: Maximum number of items to return (default: 1000).
|
||||
request_context: Request context for authentication.
|
||||
|
||||
Returns:
|
||||
Dict with nodes, edges, table_rows, total_units.
|
||||
Dict with nodes, edges, table_rows, total_units, limit.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ from ..config import (
|
||||
ENV_LLM_MAX_CONCURRENT,
|
||||
ENV_LLM_TIMEOUT,
|
||||
)
|
||||
from ..metrics import get_metrics_collector
|
||||
from .response_models import TokenUsage
|
||||
|
||||
# Seed applied to every Groq request for deterministic behavior.
|
||||
DEFAULT_LLM_SEED = 4242
|
||||
@@ -174,6 +176,7 @@ class LLMProvider:
|
||||
max_backoff: float = 60.0,
|
||||
skip_validation: bool = False,
|
||||
strict_schema: bool = False,
|
||||
return_usage: bool = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Make an LLM API call with retry logic.
|
||||
@@ -189,9 +192,11 @@ class LLMProvider:
|
||||
max_backoff: Maximum backoff time in seconds.
|
||||
skip_validation: Return raw JSON without Pydantic validation.
|
||||
strict_schema: Use strict JSON schema enforcement (OpenAI only). Guarantees all required fields.
|
||||
return_usage: If True, return tuple (result, TokenUsage) instead of just result.
|
||||
|
||||
Returns:
|
||||
Parsed response if response_format is provided, otherwise text content.
|
||||
If return_usage=False: Parsed response if response_format is provided, otherwise text content.
|
||||
If return_usage=True: Tuple of (result, TokenUsage) with token counts from the LLM call.
|
||||
|
||||
Raises:
|
||||
OutputTooLongError: If output exceeds token limits.
|
||||
@@ -203,7 +208,14 @@ class LLMProvider:
|
||||
# Handle Gemini provider separately
|
||||
if self.provider == "gemini":
|
||||
return await self._call_gemini(
|
||||
messages, response_format, max_retries, initial_backoff, max_backoff, skip_validation, start_time
|
||||
messages,
|
||||
response_format,
|
||||
max_retries,
|
||||
initial_backoff,
|
||||
max_backoff,
|
||||
skip_validation,
|
||||
start_time,
|
||||
return_usage,
|
||||
)
|
||||
|
||||
# Handle Anthropic provider separately
|
||||
@@ -217,6 +229,7 @@ class LLMProvider:
|
||||
max_backoff,
|
||||
skip_validation,
|
||||
start_time,
|
||||
return_usage,
|
||||
)
|
||||
|
||||
# Handle Ollama with native API for structured output (better schema enforcement)
|
||||
@@ -231,6 +244,7 @@ class LLMProvider:
|
||||
max_backoff,
|
||||
skip_validation,
|
||||
start_time,
|
||||
return_usage,
|
||||
)
|
||||
|
||||
call_params = {
|
||||
@@ -379,21 +393,45 @@ class LLMProvider:
|
||||
response = await self._client.chat.completions.create(**call_params)
|
||||
result = response.choices[0].message.content
|
||||
|
||||
# Log slow calls
|
||||
# Record token usage metrics
|
||||
duration = time.time() - start_time
|
||||
usage = response.usage
|
||||
if duration > 10.0:
|
||||
ratio = max(1, usage.completion_tokens) / usage.prompt_tokens
|
||||
input_tokens = usage.prompt_tokens or 0 if usage else 0
|
||||
output_tokens = usage.completion_tokens or 0 if usage else 0
|
||||
total_tokens = usage.total_tokens or 0 if usage else 0
|
||||
|
||||
# Record LLM metrics
|
||||
metrics = get_metrics_collector()
|
||||
metrics.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
duration=duration,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Log slow calls
|
||||
if duration > 10.0 and usage:
|
||||
ratio = max(1, output_tokens) / max(1, input_tokens)
|
||||
cached_tokens = 0
|
||||
if hasattr(usage, "prompt_tokens_details") and usage.prompt_tokens_details:
|
||||
cached_tokens = getattr(usage.prompt_tokens_details, "cached_tokens", 0) or 0
|
||||
cache_info = f", cached_tokens={cached_tokens}" if cached_tokens > 0 else ""
|
||||
logger.info(
|
||||
f"slow llm call: model={self.provider}/{self.model}, "
|
||||
f"input_tokens={usage.prompt_tokens}, output_tokens={usage.completion_tokens}, "
|
||||
f"total_tokens={usage.total_tokens}{cache_info}, time={duration:.3f}s, ratio out/in={ratio:.2f}"
|
||||
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
|
||||
f"total_tokens={total_tokens}{cache_info}, time={duration:.3f}s, ratio out/in={ratio:.2f}"
|
||||
)
|
||||
|
||||
if return_usage:
|
||||
token_usage = TokenUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
return result, token_usage
|
||||
return result
|
||||
|
||||
except LengthFinishReasonError as e:
|
||||
@@ -452,6 +490,7 @@ class LLMProvider:
|
||||
max_backoff: float,
|
||||
skip_validation: bool,
|
||||
start_time: float,
|
||||
return_usage: bool = False,
|
||||
) -> Any:
|
||||
"""Handle Anthropic-specific API calls."""
|
||||
from anthropic import APIConnectionError, APIStatusError, RateLimitError
|
||||
@@ -524,17 +563,39 @@ class LLMProvider:
|
||||
else:
|
||||
result = content
|
||||
|
||||
# Log slow calls
|
||||
# Record metrics and log slow calls
|
||||
duration = time.time() - start_time
|
||||
input_tokens = response.usage.input_tokens or 0 if response.usage else 0
|
||||
output_tokens = response.usage.output_tokens or 0 if response.usage else 0
|
||||
total_tokens = input_tokens + output_tokens
|
||||
|
||||
# Record LLM metrics
|
||||
metrics = get_metrics_collector()
|
||||
metrics.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope="memory",
|
||||
duration=duration,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Log slow calls
|
||||
if duration > 10.0:
|
||||
input_tokens = response.usage.input_tokens
|
||||
output_tokens = response.usage.output_tokens
|
||||
logger.info(
|
||||
f"slow llm call: model={self.provider}/{self.model}, "
|
||||
f"slow llm call: scope=memory, model={self.provider}/{self.model}, "
|
||||
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
|
||||
f"time={duration:.3f}s"
|
||||
)
|
||||
|
||||
if return_usage:
|
||||
token_usage = TokenUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
return result, token_usage
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
@@ -589,6 +650,7 @@ class LLMProvider:
|
||||
max_backoff: float,
|
||||
skip_validation: bool,
|
||||
start_time: float,
|
||||
return_usage: bool = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Call Ollama using native API with JSON schema enforcement.
|
||||
@@ -663,11 +725,39 @@ class LLMProvider:
|
||||
else:
|
||||
raise
|
||||
|
||||
# Extract token usage from Ollama response
|
||||
# Ollama returns prompt_eval_count (input) and eval_count (output)
|
||||
duration = time.time() - start_time
|
||||
input_tokens = result.get("prompt_eval_count", 0) or 0
|
||||
output_tokens = result.get("eval_count", 0) or 0
|
||||
total_tokens = input_tokens + output_tokens
|
||||
|
||||
# Record LLM metrics
|
||||
metrics = get_metrics_collector()
|
||||
metrics.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope="memory",
|
||||
duration=duration,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Validate against Pydantic model or return raw JSON
|
||||
if skip_validation:
|
||||
return json_data
|
||||
validated_result = json_data
|
||||
else:
|
||||
return response_format.model_validate(json_data)
|
||||
validated_result = response_format.model_validate(json_data)
|
||||
|
||||
if return_usage:
|
||||
token_usage = TokenUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=total_tokens,
|
||||
)
|
||||
return validated_result, token_usage
|
||||
return validated_result
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
last_exception = e
|
||||
@@ -710,6 +800,7 @@ class LLMProvider:
|
||||
max_backoff: float,
|
||||
skip_validation: bool,
|
||||
start_time: float,
|
||||
return_usage: bool = False,
|
||||
) -> Any:
|
||||
"""Handle Gemini-specific API calls."""
|
||||
# Convert OpenAI-style messages to Gemini format
|
||||
@@ -786,16 +877,42 @@ class LLMProvider:
|
||||
else:
|
||||
result = content
|
||||
|
||||
# Log slow calls
|
||||
# Record metrics and log slow calls
|
||||
duration = time.time() - start_time
|
||||
if duration > 10.0 and hasattr(response, "usage_metadata") and response.usage_metadata:
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
if hasattr(response, "usage_metadata") and response.usage_metadata:
|
||||
usage = response.usage_metadata
|
||||
input_tokens = usage.prompt_token_count or 0
|
||||
output_tokens = usage.candidates_token_count or 0
|
||||
|
||||
# Record LLM metrics
|
||||
metrics = get_metrics_collector()
|
||||
metrics.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope="memory",
|
||||
duration=duration,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Log slow calls
|
||||
if duration > 10.0 and input_tokens > 0:
|
||||
logger.info(
|
||||
f"slow llm call: model={self.provider}/{self.model}, "
|
||||
f"input_tokens={usage.prompt_token_count}, output_tokens={usage.candidates_token_count}, "
|
||||
f"slow llm call: scope=memory, model={self.provider}/{self.model}, "
|
||||
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
|
||||
f"time={duration:.3f}s"
|
||||
)
|
||||
|
||||
if return_usage:
|
||||
token_usage = TokenUsage(
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
total_tokens=input_tokens + output_tokens,
|
||||
)
|
||||
return result, token_usage
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
|
||||
@@ -18,6 +18,7 @@ from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..config import get_config
|
||||
from ..metrics import get_metrics_collector
|
||||
|
||||
# Context variable for current schema (async-safe, per-task isolation)
|
||||
_current_schema: contextvars.ContextVar[str] = contextvars.ContextVar("current_schema", default="public")
|
||||
@@ -132,11 +133,18 @@ if TYPE_CHECKING:
|
||||
|
||||
from enum import Enum
|
||||
|
||||
from ..pg0 import EmbeddedPostgres
|
||||
from ..pg0 import EmbeddedPostgres, parse_pg0_url
|
||||
from .entity_resolver import EntityResolver
|
||||
from .llm_wrapper import LLMConfig
|
||||
from .query_analyzer import QueryAnalyzer
|
||||
from .response_models import VALID_RECALL_FACT_TYPES, EntityObservation, EntityState, MemoryFact, ReflectResult
|
||||
from .response_models import (
|
||||
VALID_RECALL_FACT_TYPES,
|
||||
EntityObservation,
|
||||
EntityState,
|
||||
MemoryFact,
|
||||
ReflectResult,
|
||||
TokenUsage,
|
||||
)
|
||||
from .response_models import RecallResult as RecallResultModel
|
||||
from .retain import bank_utils, embedding_utils
|
||||
from .retain.types import RetainContentDict
|
||||
@@ -259,31 +267,14 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
memory_llm_base_url = memory_llm_base_url or config.get_llm_base_url() or None
|
||||
# Track pg0 instance (if used)
|
||||
self._pg0: EmbeddedPostgres | None = None
|
||||
self._pg0_instance_name: str | None = None
|
||||
|
||||
# Initialize PostgreSQL connection URL
|
||||
# The actual URL will be set during initialize() after starting the server
|
||||
# Supports: "pg0" (default instance), "pg0://instance-name" (named instance), or regular postgresql:// URL
|
||||
if db_url == "pg0":
|
||||
self._use_pg0 = True
|
||||
self._pg0_instance_name = "hindsight"
|
||||
self._pg0_port = None # Use default port
|
||||
self.db_url = None
|
||||
elif db_url.startswith("pg0://"):
|
||||
self._use_pg0 = True
|
||||
# Parse instance name and optional port: pg0://instance-name or pg0://instance-name:port
|
||||
url_part = db_url[6:] # Remove "pg0://"
|
||||
if ":" in url_part:
|
||||
self._pg0_instance_name, port_str = url_part.rsplit(":", 1)
|
||||
self._pg0_port = int(port_str)
|
||||
else:
|
||||
self._pg0_instance_name = url_part or "hindsight"
|
||||
self._pg0_port = None # Use default port
|
||||
self._use_pg0, self._pg0_instance_name, self._pg0_port = parse_pg0_url(db_url)
|
||||
if self._use_pg0:
|
||||
self.db_url = None
|
||||
else:
|
||||
self._use_pg0 = False
|
||||
self._pg0_instance_name = None
|
||||
self._pg0_port = None
|
||||
self.db_url = db_url
|
||||
|
||||
# Set default base URL if not provided
|
||||
@@ -971,7 +962,8 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
document_id: str | None = None,
|
||||
fact_type_override: str | None = None,
|
||||
confidence_score: float | None = None,
|
||||
) -> list[list[str]]:
|
||||
return_usage: bool = False,
|
||||
):
|
||||
"""
|
||||
Store multiple content items as memory units in ONE batch operation.
|
||||
|
||||
@@ -992,9 +984,11 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
Applies the same document_id to ALL content items that don't specify their own.
|
||||
fact_type_override: Override fact type for all facts ('world', 'experience', 'opinion')
|
||||
confidence_score: Confidence score for opinions (0.0 to 1.0)
|
||||
return_usage: If True, returns tuple of (unit_ids, TokenUsage). Default False for backward compatibility.
|
||||
|
||||
Returns:
|
||||
List of lists of unit IDs (one list per content item)
|
||||
If return_usage=False: List of lists of unit IDs (one list per content item)
|
||||
If return_usage=True: Tuple of (unit_ids, TokenUsage)
|
||||
|
||||
Example (new style - per-content document_id):
|
||||
unit_ids = await memory.retain_batch_async(
|
||||
@@ -1021,6 +1015,8 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
start_time = time.time()
|
||||
|
||||
if not contents:
|
||||
if return_usage:
|
||||
return [], TokenUsage()
|
||||
return []
|
||||
|
||||
# Authenticate tenant and set schema in context (for fq_table())
|
||||
@@ -1050,6 +1046,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Auto-chunk large batches by character count to avoid timeouts and memory issues
|
||||
# Calculate total character count
|
||||
total_chars = sum(len(item.get("content", "")) for item in contents)
|
||||
total_usage = TokenUsage()
|
||||
|
||||
CHARS_PER_BATCH = 600_000
|
||||
|
||||
@@ -1090,7 +1087,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
f"Processing sub-batch {i}/{len(sub_batches)}: {len(sub_batch)} items, {sub_batch_chars:,} chars"
|
||||
)
|
||||
|
||||
sub_results = await self._retain_batch_async_internal(
|
||||
sub_results, sub_usage = await self._retain_batch_async_internal(
|
||||
bank_id=bank_id,
|
||||
contents=sub_batch,
|
||||
document_id=document_id,
|
||||
@@ -1099,6 +1096,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
confidence_score=confidence_score,
|
||||
)
|
||||
all_results.extend(sub_results)
|
||||
total_usage = total_usage + sub_usage
|
||||
|
||||
total_time = time.time() - start_time
|
||||
logger.info(
|
||||
@@ -1107,7 +1105,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
result = all_results
|
||||
else:
|
||||
# Small batch - use internal method directly
|
||||
result = await self._retain_batch_async_internal(
|
||||
result, total_usage = await self._retain_batch_async_internal(
|
||||
bank_id=bank_id,
|
||||
contents=contents,
|
||||
document_id=document_id,
|
||||
@@ -1136,6 +1134,8 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
except Exception as e:
|
||||
logger.warning(f"Post-retain hook error (non-fatal): {e}")
|
||||
|
||||
if return_usage:
|
||||
return result, total_usage
|
||||
return result
|
||||
|
||||
async def _retain_batch_async_internal(
|
||||
@@ -1146,7 +1146,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
is_first_batch: bool = True,
|
||||
fact_type_override: str | None = None,
|
||||
confidence_score: float | None = None,
|
||||
) -> list[list[str]]:
|
||||
) -> tuple[list[list[str]], "TokenUsage"]:
|
||||
"""
|
||||
Internal method for batch processing without chunking logic.
|
||||
|
||||
@@ -1162,6 +1162,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
is_first_batch: Whether this is the first batch (for chunked operations, only delete on first batch)
|
||||
fact_type_override: Override fact type for all facts
|
||||
confidence_score: Confidence score for opinions
|
||||
|
||||
Returns:
|
||||
Tuple of (unit ID lists, token usage for fact extraction)
|
||||
"""
|
||||
# Backpressure: limit concurrent retains to prevent database contention
|
||||
async with self._put_semaphore:
|
||||
@@ -2262,6 +2265,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
bank_id: str | None = None,
|
||||
fact_type: str | None = None,
|
||||
*,
|
||||
limit: int = 1000,
|
||||
request_context: "RequestContext",
|
||||
):
|
||||
"""
|
||||
@@ -2270,10 +2274,11 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
Args:
|
||||
bank_id: Filter by bank ID
|
||||
fact_type: Filter by fact type (world, experience, opinion)
|
||||
limit: Maximum number of items to return (default: 1000)
|
||||
request_context: Request context for authentication.
|
||||
|
||||
Returns:
|
||||
Dict with nodes, edges, and table_rows
|
||||
Dict with nodes, edges, table_rows, total_units, and limit
|
||||
"""
|
||||
await self._authenticate_tenant(request_context)
|
||||
pool = await self._get_pool()
|
||||
@@ -2295,15 +2300,29 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
where_clause = "WHERE " + " AND ".join(query_conditions) if query_conditions else ""
|
||||
|
||||
# Get total count first
|
||||
total_count_result = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT COUNT(*) as total
|
||||
FROM {fq_table("memory_units")}
|
||||
{where_clause}
|
||||
""",
|
||||
*query_params,
|
||||
)
|
||||
total_count = total_count_result["total"] if total_count_result else 0
|
||||
|
||||
# Get units with limit
|
||||
param_count += 1
|
||||
units = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, event_date, context, occurred_start, occurred_end, mentioned_at, document_id, chunk_id, fact_type
|
||||
FROM {fq_table("memory_units")}
|
||||
{where_clause}
|
||||
ORDER BY mentioned_at DESC NULLS LAST, event_date DESC
|
||||
LIMIT 1000
|
||||
LIMIT ${param_count}
|
||||
""",
|
||||
*query_params,
|
||||
limit,
|
||||
)
|
||||
|
||||
# Get links, filtering to only include links between units of the selected agent
|
||||
@@ -2440,7 +2459,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
}
|
||||
)
|
||||
|
||||
return {"nodes": nodes, "edges": edges, "table_rows": table_rows, "total_units": len(units)}
|
||||
return {"nodes": nodes, "edges": edges, "table_rows": table_rows, "total_units": total_count, "limit": limit}
|
||||
|
||||
async def list_memory_units(
|
||||
self,
|
||||
@@ -3144,16 +3163,20 @@ Guidelines:
|
||||
|
||||
# Steps 1-3: Run multi-fact-type search (12-way retrieval: 4 methods × 3 fact types)
|
||||
recall_start = time.time()
|
||||
search_result = await self.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
budget=budget,
|
||||
max_tokens=4096,
|
||||
enable_trace=False,
|
||||
fact_type=["experience", "world", "opinion"],
|
||||
include_entities=True,
|
||||
request_context=request_context,
|
||||
)
|
||||
metrics = get_metrics_collector()
|
||||
with metrics.record_operation(
|
||||
"recall", bank_id=bank_id, source="reflect", budget=budget.value if budget else None
|
||||
):
|
||||
search_result = await self.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
budget=budget,
|
||||
max_tokens=4096,
|
||||
enable_trace=False,
|
||||
fact_type=["experience", "world", "opinion"],
|
||||
include_entities=True,
|
||||
request_context=request_context,
|
||||
)
|
||||
recall_time = time.time() - recall_start
|
||||
|
||||
all_results = search_result.results
|
||||
@@ -3209,7 +3232,7 @@ Guidelines:
|
||||
response_format = JsonSchemaWrapper(response_schema)
|
||||
|
||||
llm_start = time.time()
|
||||
result = await self._llm_config.call(
|
||||
llm_result, usage = await self._llm_config.call(
|
||||
messages=messages,
|
||||
scope="memory_reflect",
|
||||
max_completion_tokens=max_tokens,
|
||||
@@ -3218,17 +3241,18 @@ Guidelines:
|
||||
# Don't enforce strict_schema - not all providers support it and may retry forever
|
||||
# Soft enforcement (schema in prompt + json_object mode) is sufficient
|
||||
strict_schema=False,
|
||||
return_usage=True,
|
||||
)
|
||||
llm_time = time.time() - llm_start
|
||||
|
||||
# Handle response based on whether structured output was requested
|
||||
if response_schema is not None:
|
||||
structured_output = result
|
||||
structured_output = llm_result
|
||||
answer_text = "" # Empty for backward compatibility
|
||||
log_buffer.append(f"[REFLECT {reflect_id}] Structured output generated")
|
||||
else:
|
||||
structured_output = None
|
||||
answer_text = result.strip()
|
||||
answer_text = llm_result.strip()
|
||||
|
||||
# Submit form_opinion task for background processing
|
||||
# Pass tenant_id from request context for internal authentication in background task
|
||||
@@ -3254,6 +3278,7 @@ Guidelines:
|
||||
based_on={"world": world_results, "experience": agent_results, "opinion": opinion_results},
|
||||
new_opinions=[], # Opinions are being extracted asynchronously
|
||||
structured_output=structured_output,
|
||||
usage=usage,
|
||||
)
|
||||
|
||||
# Call post-operation hook if validator is configured
|
||||
|
||||
@@ -14,6 +14,37 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "opinion"])
|
||||
|
||||
|
||||
class TokenUsage(BaseModel):
|
||||
"""
|
||||
Token usage metrics for LLM calls.
|
||||
|
||||
Tracks input/output tokens for a single request to enable
|
||||
per-request cost tracking and monitoring.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"input_tokens": 1500,
|
||||
"output_tokens": 500,
|
||||
"total_tokens": 2000,
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
input_tokens: int = Field(default=0, description="Number of input/prompt tokens consumed")
|
||||
output_tokens: int = Field(default=0, description="Number of output/completion tokens generated")
|
||||
total_tokens: int = Field(default=0, description="Total tokens (input + output)")
|
||||
|
||||
def __add__(self, other: "TokenUsage") -> "TokenUsage":
|
||||
"""Allow aggregating token usage from multiple calls."""
|
||||
return TokenUsage(
|
||||
input_tokens=self.input_tokens + other.input_tokens,
|
||||
output_tokens=self.output_tokens + other.output_tokens,
|
||||
total_tokens=self.total_tokens + other.total_tokens,
|
||||
)
|
||||
|
||||
|
||||
class DispositionTraits(BaseModel):
|
||||
"""
|
||||
Disposition traits for a memory bank.
|
||||
@@ -147,6 +178,7 @@ class ReflectResult(BaseModel):
|
||||
},
|
||||
"new_opinions": ["Machine learning has great potential in healthcare"],
|
||||
"structured_output": {"summary": "ML in healthcare", "confidence": 0.9},
|
||||
"usage": {"input_tokens": 1500, "output_tokens": 500, "total_tokens": 2000},
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -160,6 +192,10 @@ class ReflectResult(BaseModel):
|
||||
default=None,
|
||||
description="Structured output parsed according to the provided response schema. Only present when response_schema was provided.",
|
||||
)
|
||||
usage: TokenUsage | None = Field(
|
||||
default=None,
|
||||
description="Token usage metrics for the LLM calls made during this reflect operation.",
|
||||
)
|
||||
|
||||
|
||||
class Opinion(BaseModel):
|
||||
|
||||
@@ -14,7 +14,9 @@ from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from ...config import get_config
|
||||
from ..llm_wrapper import LLMConfig, OutputTooLongError
|
||||
from ..response_models import TokenUsage
|
||||
|
||||
|
||||
def _infer_temporal_date(fact_text: str, event_date: datetime) -> str | None:
|
||||
@@ -109,7 +111,7 @@ class Fact(BaseModel):
|
||||
|
||||
|
||||
class CausalRelation(BaseModel):
|
||||
"""Causal relationship between facts."""
|
||||
"""Causal relationship between facts (legacy - embedded in each fact)."""
|
||||
|
||||
target_fact_index: int = Field(
|
||||
description="Index of the related fact in the facts array (0-based). "
|
||||
@@ -131,6 +133,36 @@ class CausalRelation(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class TopLevelCausalRelation(BaseModel):
|
||||
"""
|
||||
Causal relationship between two facts (top-level schema).
|
||||
|
||||
This is the preferred format - defined AFTER all facts are extracted,
|
||||
allowing the LLM to see the full list of facts before specifying relationships.
|
||||
"""
|
||||
|
||||
from_fact_index: int = Field(
|
||||
description="Index of the source fact (0-based). The fact that causes/enables/prevents."
|
||||
)
|
||||
to_fact_index: int = Field(
|
||||
description="Index of the target fact (0-based). The fact that is caused/enabled/prevented."
|
||||
)
|
||||
relation_type: Literal["causes", "caused_by", "enables", "prevents"] = Field(
|
||||
description="Type of causal relationship: "
|
||||
"'causes' = source fact directly causes the target fact, "
|
||||
"'caused_by' = source fact was caused by the target fact, "
|
||||
"'enables' = source fact enables/allows the target fact, "
|
||||
"'prevents' = source fact prevents/blocks the target fact"
|
||||
)
|
||||
strength: float = Field(
|
||||
description="Strength of causal relationship (0.0 to 1.0). "
|
||||
"1.0 = direct/strong causation, 0.5 = moderate, 0.3 = weak/indirect",
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
default=1.0,
|
||||
)
|
||||
|
||||
|
||||
class ExtractedFact(BaseModel):
|
||||
"""A single extracted fact with 5 required dimensions for comprehensive capture."""
|
||||
|
||||
@@ -253,9 +285,15 @@ class ExtractedFact(BaseModel):
|
||||
|
||||
|
||||
class FactExtractionResponse(BaseModel):
|
||||
"""Response containing all extracted facts."""
|
||||
"""Response containing all extracted facts and their causal relationships."""
|
||||
|
||||
facts: list[ExtractedFact] = Field(description="List of extracted factual statements")
|
||||
causal_relationships: list[TopLevelCausalRelation] | None = Field(
|
||||
default=None,
|
||||
description="Causal relationships between facts. Define these AFTER listing all facts. "
|
||||
"Each relationship specifies from_fact_index -> to_fact_index with a relation type. "
|
||||
"Indices must be valid (0 to N-1 where N is the number of facts).",
|
||||
)
|
||||
|
||||
|
||||
def chunk_text(text: str, max_chars: int) -> list[str]:
|
||||
@@ -356,7 +394,7 @@ async def _extract_facts_from_chunk(
|
||||
llm_config: "LLMConfig",
|
||||
agent_name: str = None,
|
||||
extract_opinions: bool = False,
|
||||
) -> list[dict[str, str]]:
|
||||
) -> tuple[list[dict[str, str]], TokenUsage]:
|
||||
"""
|
||||
Extract facts from a single chunk (internal helper for parallel processing).
|
||||
|
||||
@@ -377,6 +415,9 @@ async def _extract_facts_from_chunk(
|
||||
|
||||
prompt = f"""Extract facts from text into structured format with FOUR required dimensions - BE EXTREMELY DETAILED.
|
||||
|
||||
LANGUAGE REQUIREMENT: Detect the language of the input text. All extracted facts, entity names, descriptions,
|
||||
and other output MUST be in the SAME language as the input. Do not translate to English if the input is in another language.
|
||||
|
||||
{fact_types_instruction}
|
||||
|
||||
|
||||
@@ -572,7 +613,53 @@ WHAT TO EXTRACT vs SKIP
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
✅ EXTRACT: User preferences (ALWAYS as separate facts!), feelings, plans, events, relationships, achievements
|
||||
❌ SKIP: Greetings, filler ("thanks", "cool"), purely structural statements"""
|
||||
❌ SKIP: Greetings, filler ("thanks", "cool"), purely structural statements
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
CAUSAL RELATIONSHIPS (CRITICAL - DEFINE AFTER ALL FACTS)
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
⚠️ IMPORTANT: Causal relationships are defined at the TOP LEVEL, AFTER listing all facts!
|
||||
|
||||
The `causal_relationships` array goes at the root of your response (NOT inside each fact).
|
||||
This allows you to see all facts first before defining how they relate.
|
||||
|
||||
Format:
|
||||
```json
|
||||
{{
|
||||
"facts": [...all your extracted facts...],
|
||||
"causal_relationships": [
|
||||
{{"from_fact_index": 0, "to_fact_index": 1, "relation_type": "causes", "strength": 0.9}},
|
||||
{{"from_fact_index": 1, "to_fact_index": 2, "relation_type": "enables", "strength": 0.7}}
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
Relationship types:
|
||||
- "causes": Fact A directly causes Fact B (A → B)
|
||||
- "caused_by": Fact A was caused by Fact B (A ← B)
|
||||
- "enables": Fact A enables/allows Fact B to happen
|
||||
- "prevents": Fact A prevents/blocks Fact B from happening
|
||||
|
||||
⚠️ INDEX VALIDATION: If you extract N facts (indices 0 to N-1), both from_fact_index and to_fact_index MUST be in range [0, N-1].
|
||||
|
||||
Example (Event Date: March 15, 2024):
|
||||
Input: "I lost my job in January. Because of that, I couldn't pay rent. So I had to move to a cheaper apartment."
|
||||
|
||||
Facts extracted:
|
||||
- Fact 0: "User lost their job in January due to layoffs"
|
||||
- Fact 1: "User couldn't pay rent because of job loss"
|
||||
- Fact 2: "User moved to a cheaper apartment"
|
||||
|
||||
Causal relationships (at root level):
|
||||
```json
|
||||
"causal_relationships": [
|
||||
{{"from_fact_index": 0, "to_fact_index": 1, "relation_type": "causes", "strength": 1.0}},
|
||||
{{"from_fact_index": 1, "to_fact_index": 2, "relation_type": "causes", "strength": 0.9}}
|
||||
]
|
||||
```
|
||||
|
||||
This creates a chain: Job loss (0) → Can't pay rent (1) → Moved to cheaper apartment (2)"""
|
||||
|
||||
import logging
|
||||
|
||||
@@ -583,6 +670,7 @@ WHAT TO EXTRACT vs SKIP
|
||||
# Retry logic for JSON validation errors
|
||||
max_retries = 2
|
||||
last_error = None
|
||||
config = get_config()
|
||||
|
||||
# Sanitize input text to prevent Unicode encoding errors (e.g., unpaired surrogates)
|
||||
sanitized_chunk = _sanitize_text(chunk)
|
||||
@@ -601,16 +689,19 @@ Context: {sanitized_context}
|
||||
Text:
|
||||
{sanitized_chunk}"""
|
||||
|
||||
usage = TokenUsage() # Track cumulative usage across retries
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
extraction_response_json = await llm_config.call(
|
||||
extraction_response_json, call_usage = await llm_config.call(
|
||||
messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
|
||||
response_format=FactExtractionResponse,
|
||||
scope="memory_extract_facts",
|
||||
temperature=0.1,
|
||||
max_completion_tokens=65000,
|
||||
max_completion_tokens=config.retain_max_completion_tokens,
|
||||
skip_validation=True, # Get raw JSON, we'll validate leniently
|
||||
return_usage=True,
|
||||
)
|
||||
usage = usage + call_usage # Aggregate usage across retries
|
||||
|
||||
# Lenient parsing of facts from raw JSON
|
||||
chunk_facts = []
|
||||
@@ -628,9 +719,12 @@ Text:
|
||||
f"LLM returned non-dict JSON after {max_retries} attempts: {type(extraction_response_json).__name__}. "
|
||||
f"Raw: {str(extraction_response_json)[:500]}"
|
||||
)
|
||||
return []
|
||||
return [], usage
|
||||
|
||||
raw_facts = extraction_response_json.get("facts", [])
|
||||
# Get top-level causal relationships (new schema)
|
||||
top_level_causal_relations = extraction_response_json.get("causal_relationships", [])
|
||||
|
||||
if not raw_facts:
|
||||
logger.debug(
|
||||
f"LLM response missing 'facts' field or returned empty list. "
|
||||
@@ -641,6 +735,47 @@ Text:
|
||||
f"text: {chunk}"
|
||||
)
|
||||
|
||||
# Build a map from fact index to causal relations (from top-level field)
|
||||
# This converts from_fact_index -> [{target_fact_index, relation_type, strength}]
|
||||
causal_relations_by_fact: dict[int, list[dict]] = {}
|
||||
if top_level_causal_relations:
|
||||
num_facts = len(raw_facts)
|
||||
for rel in top_level_causal_relations:
|
||||
if not isinstance(rel, dict):
|
||||
continue
|
||||
from_idx = rel.get("from_fact_index")
|
||||
to_idx = rel.get("to_fact_index")
|
||||
relation_type = rel.get("relation_type")
|
||||
strength = rel.get("strength", 1.0)
|
||||
|
||||
# Validate indices
|
||||
if from_idx is None or to_idx is None or relation_type is None:
|
||||
logger.warning(f"Skipping malformed top-level causal relation: {rel}")
|
||||
continue
|
||||
if from_idx < 0 or from_idx >= num_facts:
|
||||
logger.warning(
|
||||
f"Invalid from_fact_index {from_idx} in top-level causal relation "
|
||||
f"(valid range: 0-{num_facts - 1}). Skipping."
|
||||
)
|
||||
continue
|
||||
if to_idx < 0 or to_idx >= num_facts:
|
||||
logger.warning(
|
||||
f"Invalid to_fact_index {to_idx} in top-level causal relation "
|
||||
f"(valid range: 0-{num_facts - 1}). Skipping."
|
||||
)
|
||||
continue
|
||||
|
||||
# Add to the map for the from_fact_index
|
||||
if from_idx not in causal_relations_by_fact:
|
||||
causal_relations_by_fact[from_idx] = []
|
||||
causal_relations_by_fact[from_idx].append(
|
||||
{
|
||||
"target_fact_index": to_idx,
|
||||
"relation_type": relation_type,
|
||||
"strength": strength,
|
||||
}
|
||||
)
|
||||
|
||||
for i, llm_fact in enumerate(raw_facts):
|
||||
# Skip non-dict entries but track them for retry
|
||||
if not isinstance(llm_fact, dict):
|
||||
@@ -745,19 +880,40 @@ Text:
|
||||
if validated_entities:
|
||||
fact_data["entities"] = validated_entities
|
||||
|
||||
# Add causal relations if present (validate as CausalRelation objects)
|
||||
# Filter out invalid relations (missing required fields)
|
||||
causal_relations = get_value("causal_relations")
|
||||
if causal_relations:
|
||||
validated_relations = []
|
||||
for rel in causal_relations:
|
||||
# Add causal relations from both sources:
|
||||
# 1. Top-level causal_relationships (preferred, new schema)
|
||||
# 2. Per-fact causal_relations (legacy, for backward compatibility)
|
||||
validated_relations = []
|
||||
|
||||
# First, add relations from top-level (already validated above)
|
||||
if i in causal_relations_by_fact:
|
||||
for rel in causal_relations_by_fact[i]:
|
||||
try:
|
||||
validated_relations.append(CausalRelation.model_validate(rel))
|
||||
except Exception as e:
|
||||
logger.warning(f"Invalid top-level causal relation for fact {i}: {rel}: {e}")
|
||||
|
||||
# Then, add any legacy per-fact relations (with index validation)
|
||||
legacy_causal_relations = get_value("causal_relations")
|
||||
if legacy_causal_relations:
|
||||
num_facts = len(raw_facts)
|
||||
for rel in legacy_causal_relations:
|
||||
if isinstance(rel, dict) and "target_fact_index" in rel and "relation_type" in rel:
|
||||
try:
|
||||
validated_relations.append(CausalRelation.model_validate(rel))
|
||||
except Exception as e:
|
||||
logger.warning(f"Invalid causal relation {rel}: {e}")
|
||||
if validated_relations:
|
||||
fact_data["causal_relations"] = validated_relations
|
||||
target_idx = rel.get("target_fact_index")
|
||||
# Validate target index for legacy format too
|
||||
if target_idx is not None and 0 <= target_idx < num_facts:
|
||||
try:
|
||||
validated_relations.append(CausalRelation.model_validate(rel))
|
||||
except Exception as e:
|
||||
logger.warning(f"Invalid causal relation {rel}: {e}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"Invalid target_fact_index {target_idx} in per-fact causal relation "
|
||||
f"from fact {i} (valid range: 0-{num_facts - 1}). Skipping."
|
||||
)
|
||||
|
||||
if validated_relations:
|
||||
fact_data["causal_relations"] = validated_relations
|
||||
|
||||
# Always set mentioned_at to the event_date (when the conversation/document occurred)
|
||||
fact_data["mentioned_at"] = event_date.isoformat()
|
||||
@@ -778,7 +934,7 @@ Text:
|
||||
)
|
||||
continue
|
||||
|
||||
return chunk_facts
|
||||
return chunk_facts, usage
|
||||
|
||||
except BadRequestError as e:
|
||||
last_error = e
|
||||
@@ -805,7 +961,7 @@ async def _extract_facts_with_auto_split(
|
||||
llm_config: LLMConfig,
|
||||
agent_name: str = None,
|
||||
extract_opinions: bool = False,
|
||||
) -> list[dict[str, str]]:
|
||||
) -> tuple[list[dict[str, str]], TokenUsage]:
|
||||
"""
|
||||
Extract facts from a chunk with automatic splitting if output exceeds token limits.
|
||||
|
||||
@@ -823,7 +979,7 @@ async def _extract_facts_with_auto_split(
|
||||
extract_opinions: If True, extract ONLY opinions. If False, extract world and agent facts (no opinions)
|
||||
|
||||
Returns:
|
||||
List of fact dictionaries extracted from the chunk (possibly from sub-chunks)
|
||||
Tuple of (facts list, token usage) extracted from the chunk (possibly from sub-chunks)
|
||||
"""
|
||||
import logging
|
||||
|
||||
@@ -902,12 +1058,14 @@ async def _extract_facts_with_auto_split(
|
||||
|
||||
# Combine results from both halves
|
||||
all_facts = []
|
||||
for sub_result in sub_results:
|
||||
all_facts.extend(sub_result)
|
||||
total_usage = TokenUsage()
|
||||
for sub_facts, sub_usage in sub_results:
|
||||
all_facts.extend(sub_facts)
|
||||
total_usage = total_usage + sub_usage
|
||||
|
||||
logger.info(f"Successfully extracted {len(all_facts)} facts from split chunk {chunk_index + 1}")
|
||||
|
||||
return all_facts
|
||||
return all_facts, total_usage
|
||||
|
||||
|
||||
async def extract_facts_from_text(
|
||||
@@ -917,7 +1075,7 @@ async def extract_facts_from_text(
|
||||
agent_name: str,
|
||||
context: str = "",
|
||||
extract_opinions: bool = False,
|
||||
) -> tuple[list[Fact], list[tuple[str, int]]]:
|
||||
) -> tuple[list[Fact], list[tuple[str, int]], TokenUsage]:
|
||||
"""
|
||||
Extract semantic facts from conversational or narrative text using LLM.
|
||||
|
||||
@@ -936,9 +1094,10 @@ async def extract_facts_from_text(
|
||||
extract_opinions: If True, extract ONLY opinions. If False, extract world and bank facts (no opinions)
|
||||
|
||||
Returns:
|
||||
Tuple of (facts, chunks) where:
|
||||
Tuple of (facts, chunks, usage) where:
|
||||
- facts: List of Fact model instances
|
||||
- chunks: List of tuples (chunk_text, fact_count) for each chunk
|
||||
- usage: Aggregated token usage across all LLM calls
|
||||
"""
|
||||
chunks = chunk_text(text, max_chars=3000)
|
||||
tasks = [
|
||||
@@ -957,10 +1116,12 @@ async def extract_facts_from_text(
|
||||
chunk_results = await asyncio.gather(*tasks)
|
||||
all_facts = []
|
||||
chunk_metadata = [] # [(chunk_text, fact_count), ...]
|
||||
for chunk, chunk_facts in zip(chunks, chunk_results):
|
||||
total_usage = TokenUsage()
|
||||
for chunk, (chunk_facts, chunk_usage) in zip(chunks, chunk_results):
|
||||
all_facts.extend(chunk_facts)
|
||||
chunk_metadata.append((chunk, len(chunk_facts)))
|
||||
return all_facts, chunk_metadata
|
||||
total_usage = total_usage + chunk_usage
|
||||
return all_facts, chunk_metadata, total_usage
|
||||
|
||||
|
||||
# ============================================================================
|
||||
@@ -981,7 +1142,7 @@ SECONDS_PER_FACT = 10
|
||||
|
||||
async def extract_facts_from_contents(
|
||||
contents: list[RetainContent], llm_config, agent_name: str, extract_opinions: bool = False
|
||||
) -> tuple[list[ExtractedFactType], list[ChunkMetadata]]:
|
||||
) -> tuple[list[ExtractedFactType], list[ChunkMetadata], TokenUsage]:
|
||||
"""
|
||||
Extract facts from multiple content items in parallel.
|
||||
|
||||
@@ -998,10 +1159,10 @@ async def extract_facts_from_contents(
|
||||
extract_opinions: If True, extract only opinions; otherwise world/bank facts
|
||||
|
||||
Returns:
|
||||
Tuple of (extracted_facts, chunks_metadata)
|
||||
Tuple of (extracted_facts, chunks_metadata, usage)
|
||||
"""
|
||||
if not contents:
|
||||
return [], []
|
||||
return [], [], TokenUsage()
|
||||
|
||||
# Step 1: Create parallel fact extraction tasks
|
||||
fact_extraction_tasks = []
|
||||
@@ -1024,11 +1185,15 @@ async def extract_facts_from_contents(
|
||||
# Step 3: Flatten and convert to typed objects
|
||||
extracted_facts: list[ExtractedFactType] = []
|
||||
chunks_metadata: list[ChunkMetadata] = []
|
||||
total_usage = TokenUsage()
|
||||
|
||||
global_chunk_idx = 0
|
||||
global_fact_idx = 0
|
||||
|
||||
for content_index, (content, (facts_from_llm, chunks_from_llm)) in enumerate(zip(contents, all_fact_results)):
|
||||
for content_index, (content, (facts_from_llm, chunks_from_llm, content_usage)) in enumerate(
|
||||
zip(contents, all_fact_results)
|
||||
):
|
||||
total_usage = total_usage + content_usage
|
||||
chunk_start_idx = global_chunk_idx
|
||||
|
||||
# Convert chunk tuples to ChunkMetadata objects
|
||||
@@ -1082,7 +1247,7 @@ async def extract_facts_from_contents(
|
||||
# Step 4: Add time offsets to preserve ordering within each content
|
||||
_add_temporal_offsets(extracted_facts, contents)
|
||||
|
||||
return extracted_facts, chunks_metadata
|
||||
return extracted_facts, chunks_metadata, total_usage
|
||||
|
||||
|
||||
def _parse_datetime(date_str: str):
|
||||
|
||||
@@ -18,6 +18,7 @@ def utcnow():
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
from ..response_models import TokenUsage
|
||||
from . import (
|
||||
chunk_storage,
|
||||
deduplication,
|
||||
@@ -47,7 +48,7 @@ async def retain_batch(
|
||||
is_first_batch: bool = True,
|
||||
fact_type_override: str | None = None,
|
||||
confidence_score: float | None = None,
|
||||
) -> list[list[str]]:
|
||||
) -> tuple[list[list[str]], TokenUsage]:
|
||||
"""
|
||||
Process a batch of content through the retain pipeline.
|
||||
|
||||
@@ -67,7 +68,7 @@ async def retain_batch(
|
||||
confidence_score: Confidence score for opinions
|
||||
|
||||
Returns:
|
||||
List of unit ID lists (one list per content item)
|
||||
Tuple of (unit ID lists, token usage for fact extraction)
|
||||
"""
|
||||
start_time = time.time()
|
||||
total_chars = sum(len(item.get("content", "")) for item in contents_dicts)
|
||||
@@ -99,7 +100,7 @@ async def retain_batch(
|
||||
step_start = time.time()
|
||||
extract_opinions = fact_type_override == "opinion"
|
||||
|
||||
extracted_facts, chunks = await fact_extraction.extract_facts_from_contents(
|
||||
extracted_facts, chunks, usage = await fact_extraction.extract_facts_from_contents(
|
||||
contents, llm_config, agent_name, extract_opinions
|
||||
)
|
||||
log_buffer.append(
|
||||
@@ -164,7 +165,7 @@ async def retain_batch(
|
||||
logger.info(
|
||||
f"RETAIN_BATCH COMPLETE: 0 facts extracted from {len(contents)} contents in {total_time:.3f}s (document tracked, no facts)"
|
||||
)
|
||||
return [[] for _ in contents]
|
||||
return [[] for _ in contents], usage
|
||||
|
||||
# Apply fact_type_override if provided
|
||||
if fact_type_override:
|
||||
@@ -344,7 +345,7 @@ async def retain_batch(
|
||||
non_duplicate_facts = deduplication.filter_duplicates(processed_facts, is_duplicate_flags)
|
||||
|
||||
if not non_duplicate_facts:
|
||||
return [[] for _ in contents]
|
||||
return [[] for _ in contents], usage
|
||||
|
||||
# Insert facts (document_id is now stored per-fact)
|
||||
step_start = time.time()
|
||||
@@ -415,7 +416,7 @@ async def retain_batch(
|
||||
|
||||
logger.info("\n" + "\n".join(log_buffer) + "\n")
|
||||
|
||||
return result_unit_ids
|
||||
return result_unit_ids, usage
|
||||
|
||||
|
||||
def _map_results_to_contents(
|
||||
|
||||
@@ -172,7 +172,7 @@ def get_system_message(disposition: DispositionTraits) -> str:
|
||||
" ".join(instructions) if instructions else "Balance your disposition traits when interpreting information."
|
||||
)
|
||||
|
||||
return f"You are a person with your own thoughts, experiences, opinions, and disposition. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {disposition_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting."
|
||||
return f"You are a person with your own thoughts, experiences, opinions, and disposition. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {disposition_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting. IMPORTANT: Detect the language of the question and respond in the SAME language. Do not translate to English if the question is in another language."
|
||||
|
||||
|
||||
async def extract_opinions_from_text(llm_config, text: str, query: str) -> list[Opinion]:
|
||||
|
||||
@@ -49,7 +49,7 @@ async def extract_facts(
|
||||
if not text or not text.strip():
|
||||
return [], []
|
||||
|
||||
facts, chunks = await extract_facts_from_text(
|
||||
facts, chunks, _ = await extract_facts_from_text(
|
||||
text,
|
||||
event_date,
|
||||
context=context,
|
||||
|
||||
@@ -184,8 +184,10 @@ def main():
|
||||
graph_retriever=config.graph_retriever,
|
||||
observation_min_facts=config.observation_min_facts,
|
||||
observation_top_entities=config.observation_top_entities,
|
||||
retain_max_completion_tokens=config.retain_max_completion_tokens,
|
||||
skip_llm_verification=config.skip_llm_verification,
|
||||
lazy_reranker=config.lazy_reranker,
|
||||
run_migrations_on_startup=config.run_migrations_on_startup,
|
||||
)
|
||||
config.configure_logging()
|
||||
if not args.daemon:
|
||||
@@ -211,7 +213,11 @@ def main():
|
||||
logging.info(f"Loaded tenant extension: {tenant_extension.__class__.__name__}")
|
||||
|
||||
# Create MemoryEngine (reads configuration from environment)
|
||||
_memory = MemoryEngine(operation_validator=operation_validator, tenant_extension=tenant_extension)
|
||||
_memory = MemoryEngine(
|
||||
operation_validator=operation_validator,
|
||||
tenant_extension=tenant_extension,
|
||||
run_migrations=config.run_migrations_on_startup,
|
||||
)
|
||||
|
||||
# Set extension context on tenant extension (needed for schema provisioning)
|
||||
if tenant_extension:
|
||||
|
||||
@@ -5,6 +5,7 @@ This module provides metrics for:
|
||||
- Operation latency (retain, recall, reflect) with percentiles
|
||||
- Token usage (input/output) per operation
|
||||
- Per-bank granularity via labels
|
||||
- LLM call latency and token usage with scope dimension
|
||||
"""
|
||||
|
||||
import logging
|
||||
@@ -14,8 +15,54 @@ from contextlib import contextmanager
|
||||
from opentelemetry import metrics
|
||||
from opentelemetry.exporter.prometheus import PrometheusMetricReader
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.view import ExplicitBucketHistogramAggregation, View
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
|
||||
# Custom bucket boundaries for operation duration (in seconds)
|
||||
# Fine granularity in 0-30s range where most operations complete
|
||||
DURATION_BUCKETS = (0.1, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 5.0, 7.5, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0)
|
||||
|
||||
# LLM duration buckets (finer granularity for faster LLM calls)
|
||||
LLM_DURATION_BUCKETS = (0.1, 0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 30.0, 60.0, 120.0)
|
||||
|
||||
|
||||
def get_token_bucket(token_count: int) -> str:
|
||||
"""
|
||||
Convert a token count to a bucket label for use as a dimension.
|
||||
|
||||
This allows analyzing token usage patterns without high-cardinality issues.
|
||||
|
||||
Buckets:
|
||||
- "0-100": Very small requests/responses
|
||||
- "100-500": Small requests/responses
|
||||
- "500-1k": Medium requests/responses
|
||||
- "1k-5k": Large requests/responses
|
||||
- "5k-10k": Very large requests/responses
|
||||
- "10k-50k": Huge requests/responses
|
||||
- "50k+": Extremely large requests/responses
|
||||
|
||||
Args:
|
||||
token_count: Number of tokens
|
||||
|
||||
Returns:
|
||||
Bucket label string
|
||||
"""
|
||||
if token_count < 100:
|
||||
return "0-100"
|
||||
elif token_count < 500:
|
||||
return "100-500"
|
||||
elif token_count < 1000:
|
||||
return "500-1k"
|
||||
elif token_count < 5000:
|
||||
return "1k-5k"
|
||||
elif token_count < 10000:
|
||||
return "5k-10k"
|
||||
elif token_count < 50000:
|
||||
return "10k-50k"
|
||||
else:
|
||||
return "50k+"
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Global meter instance
|
||||
@@ -48,8 +95,22 @@ def initialize_metrics(service_name: str = "hindsight-api", service_version: str
|
||||
# Create Prometheus metric reader
|
||||
prometheus_reader = PrometheusMetricReader()
|
||||
|
||||
# Create meter provider with Prometheus exporter
|
||||
provider = MeterProvider(resource=resource, metric_readers=[prometheus_reader])
|
||||
# Create view with custom bucket boundaries for duration histogram
|
||||
duration_view = View(
|
||||
instrument_name="hindsight.operation.duration",
|
||||
aggregation=ExplicitBucketHistogramAggregation(boundaries=DURATION_BUCKETS),
|
||||
)
|
||||
|
||||
# Create view with custom bucket boundaries for LLM duration histogram
|
||||
llm_duration_view = View(
|
||||
instrument_name="hindsight.llm.duration",
|
||||
aggregation=ExplicitBucketHistogramAggregation(boundaries=LLM_DURATION_BUCKETS),
|
||||
)
|
||||
|
||||
# Create meter provider with Prometheus exporter and custom views
|
||||
provider = MeterProvider(
|
||||
resource=resource, metric_readers=[prometheus_reader], views=[duration_view, llm_duration_view]
|
||||
)
|
||||
|
||||
# Set the global meter provider
|
||||
metrics.set_meter_provider(provider)
|
||||
@@ -71,20 +132,39 @@ class MetricsCollectorBase:
|
||||
"""Base class for metrics collectors."""
|
||||
|
||||
@contextmanager
|
||||
def record_operation(self, operation: str, bank_id: str, budget: str | None = None, max_tokens: int | None = None):
|
||||
"""Context manager to record operation duration and status."""
|
||||
raise NotImplementedError
|
||||
|
||||
def record_tokens(
|
||||
def record_operation(
|
||||
self,
|
||||
operation: str,
|
||||
bank_id: str,
|
||||
input_tokens: int = 0,
|
||||
output_tokens: int = 0,
|
||||
source: str = "api",
|
||||
budget: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
):
|
||||
"""Record token usage for an operation."""
|
||||
"""Context manager to record operation duration and status."""
|
||||
raise NotImplementedError
|
||||
|
||||
def record_llm_call(
|
||||
self,
|
||||
provider: str,
|
||||
model: str,
|
||||
scope: str,
|
||||
duration: float,
|
||||
input_tokens: int = 0,
|
||||
output_tokens: int = 0,
|
||||
success: bool = True,
|
||||
):
|
||||
"""
|
||||
Record metrics for an LLM call.
|
||||
|
||||
Args:
|
||||
provider: LLM provider name (openai, anthropic, gemini, groq, ollama, lmstudio)
|
||||
model: Model name
|
||||
scope: Scope identifier (e.g., "memory", "reflect", "entity_observation")
|
||||
duration: Call duration in seconds
|
||||
input_tokens: Number of input/prompt tokens
|
||||
output_tokens: Number of output/completion tokens
|
||||
success: Whether the call was successful
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@@ -92,20 +172,28 @@ class NoOpMetricsCollector(MetricsCollectorBase):
|
||||
"""No-op metrics collector that does nothing. Used when metrics are disabled."""
|
||||
|
||||
@contextmanager
|
||||
def record_operation(self, operation: str, bank_id: str, budget: str | None = None, max_tokens: int | None = None):
|
||||
"""No-op context manager."""
|
||||
yield
|
||||
|
||||
def record_tokens(
|
||||
def record_operation(
|
||||
self,
|
||||
operation: str,
|
||||
bank_id: str,
|
||||
input_tokens: int = 0,
|
||||
output_tokens: int = 0,
|
||||
source: str = "api",
|
||||
budget: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
):
|
||||
"""No-op token recording."""
|
||||
"""No-op context manager."""
|
||||
yield
|
||||
|
||||
def record_llm_call(
|
||||
self,
|
||||
provider: str,
|
||||
model: str,
|
||||
scope: str,
|
||||
duration: float,
|
||||
input_tokens: int = 0,
|
||||
output_tokens: int = 0,
|
||||
success: bool = True,
|
||||
):
|
||||
"""No-op LLM call recording."""
|
||||
pass
|
||||
|
||||
|
||||
@@ -125,33 +213,52 @@ class MetricsCollector(MetricsCollectorBase):
|
||||
name="hindsight.operation.duration", description="Duration of Hindsight operations in seconds", unit="s"
|
||||
)
|
||||
|
||||
# Token usage counters
|
||||
self.tokens_input = self.meter.create_counter(
|
||||
name="hindsight.tokens.input", description="Number of input tokens consumed", unit="tokens"
|
||||
)
|
||||
|
||||
self.tokens_output = self.meter.create_counter(
|
||||
name="hindsight.tokens.output", description="Number of output tokens generated", unit="tokens"
|
||||
)
|
||||
|
||||
# Operation counter (success/failure)
|
||||
self.operation_total = self.meter.create_counter(
|
||||
name="hindsight.operation.total", description="Total number of operations executed", unit="operations"
|
||||
)
|
||||
|
||||
# LLM call latency histogram (in seconds)
|
||||
# Records duration of LLM API calls with provider, model, and scope dimensions
|
||||
self.llm_duration = self.meter.create_histogram(
|
||||
name="hindsight.llm.duration", description="Duration of LLM API calls in seconds", unit="s"
|
||||
)
|
||||
|
||||
# LLM token usage counters with bucket labels
|
||||
self.llm_tokens_input = self.meter.create_counter(
|
||||
name="hindsight.llm.tokens.input", description="Number of input tokens for LLM calls", unit="tokens"
|
||||
)
|
||||
|
||||
self.llm_tokens_output = self.meter.create_counter(
|
||||
name="hindsight.llm.tokens.output", description="Number of output tokens from LLM calls", unit="tokens"
|
||||
)
|
||||
|
||||
# LLM call counter (success/failure)
|
||||
self.llm_calls_total = self.meter.create_counter(
|
||||
name="hindsight.llm.calls.total", description="Total number of LLM API calls", unit="calls"
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def record_operation(self, operation: str, bank_id: str, budget: str | None = None, max_tokens: int | None = None):
|
||||
def record_operation(
|
||||
self,
|
||||
operation: str,
|
||||
bank_id: str,
|
||||
source: str = "api",
|
||||
budget: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
):
|
||||
"""
|
||||
Context manager to record operation duration and status.
|
||||
|
||||
Usage:
|
||||
with metrics.record_operation("recall", bank_id="user123", budget="mid", max_tokens=4096):
|
||||
with metrics.record_operation("recall", bank_id="user123", source="api", budget="mid", max_tokens=4096):
|
||||
# ... perform operation
|
||||
pass
|
||||
|
||||
Args:
|
||||
operation: Operation name (retain, recall, reflect)
|
||||
operation: Operation name (retain, recall, reflect, entity_observation)
|
||||
bank_id: Memory bank ID
|
||||
source: Source of the operation (api, reflect, internal)
|
||||
budget: Optional budget level (low, mid, high)
|
||||
max_tokens: Optional max tokens for the operation
|
||||
"""
|
||||
@@ -159,6 +266,7 @@ class MetricsCollector(MetricsCollectorBase):
|
||||
attributes = {
|
||||
"operation": operation,
|
||||
"bank_id": bank_id,
|
||||
"source": source,
|
||||
}
|
||||
if budget:
|
||||
attributes["budget"] = budget
|
||||
@@ -181,40 +289,56 @@ class MetricsCollector(MetricsCollectorBase):
|
||||
# Record operation count
|
||||
self.operation_total.add(1, attributes)
|
||||
|
||||
def record_tokens(
|
||||
def record_llm_call(
|
||||
self,
|
||||
operation: str,
|
||||
bank_id: str,
|
||||
provider: str,
|
||||
model: str,
|
||||
scope: str,
|
||||
duration: float,
|
||||
input_tokens: int = 0,
|
||||
output_tokens: int = 0,
|
||||
budget: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
success: bool = True,
|
||||
):
|
||||
"""
|
||||
Record token usage for an operation.
|
||||
Record metrics for an LLM call.
|
||||
|
||||
Args:
|
||||
operation: Operation name (retain, recall, reflect)
|
||||
bank_id: Memory bank ID
|
||||
input_tokens: Number of input tokens
|
||||
output_tokens: Number of output tokens
|
||||
budget: Optional budget level
|
||||
max_tokens: Optional max tokens for the operation
|
||||
provider: LLM provider name (openai, anthropic, gemini, groq, ollama, lmstudio)
|
||||
model: Model name
|
||||
scope: Scope identifier (e.g., "memory", "reflect", "entity_observation")
|
||||
duration: Call duration in seconds
|
||||
input_tokens: Number of input/prompt tokens
|
||||
output_tokens: Number of output/completion tokens
|
||||
success: Whether the call was successful
|
||||
"""
|
||||
attributes = {
|
||||
"operation": operation,
|
||||
"bank_id": bank_id,
|
||||
# Base attributes for all metrics
|
||||
base_attributes = {
|
||||
"provider": provider,
|
||||
"model": model,
|
||||
"scope": scope,
|
||||
"success": str(success).lower(),
|
||||
}
|
||||
if budget:
|
||||
attributes["budget"] = budget
|
||||
if max_tokens:
|
||||
attributes["max_tokens"] = str(max_tokens)
|
||||
|
||||
# Record duration
|
||||
self.llm_duration.record(duration, base_attributes)
|
||||
|
||||
# Record call count
|
||||
self.llm_calls_total.add(1, base_attributes)
|
||||
|
||||
# Record tokens with bucket labels for cardinality control
|
||||
if input_tokens > 0:
|
||||
self.tokens_input.add(input_tokens, attributes)
|
||||
input_attributes = {
|
||||
**base_attributes,
|
||||
"token_bucket": get_token_bucket(input_tokens),
|
||||
}
|
||||
self.llm_tokens_input.add(input_tokens, input_attributes)
|
||||
|
||||
if output_tokens > 0:
|
||||
self.tokens_output.add(output_tokens, attributes)
|
||||
output_attributes = {
|
||||
**base_attributes,
|
||||
"token_bucket": get_token_bucket(output_tokens),
|
||||
}
|
||||
self.llm_tokens_output.add(output_tokens, output_attributes)
|
||||
|
||||
|
||||
# Global metrics collector instance (defaults to no-op)
|
||||
|
||||
@@ -132,3 +132,56 @@ async def stop_embedded_postgres() -> None:
|
||||
global _default_instance
|
||||
if _default_instance:
|
||||
await _default_instance.stop()
|
||||
|
||||
|
||||
def parse_pg0_url(db_url: str) -> tuple[bool, str | None, int | None]:
|
||||
"""
|
||||
Parse a database URL and check if it's a pg0:// embedded database URL.
|
||||
|
||||
Supports:
|
||||
- "pg0" -> default instance "hindsight"
|
||||
- "pg0://instance-name" -> named instance
|
||||
- "pg0://instance-name:port" -> named instance with explicit port
|
||||
- Any other URL (e.g., postgresql://) -> not a pg0 URL
|
||||
|
||||
Args:
|
||||
db_url: The database URL to parse
|
||||
|
||||
Returns:
|
||||
Tuple of (is_pg0, instance_name, port)
|
||||
- is_pg0: True if this is a pg0 URL
|
||||
- instance_name: The instance name (or None if not pg0)
|
||||
- port: The explicit port (or None for auto-assign)
|
||||
"""
|
||||
if db_url == "pg0":
|
||||
return True, "hindsight", None
|
||||
|
||||
if db_url.startswith("pg0://"):
|
||||
url_part = db_url[6:] # Remove "pg0://"
|
||||
if ":" in url_part:
|
||||
instance_name, port_str = url_part.rsplit(":", 1)
|
||||
return True, instance_name or "hindsight", int(port_str)
|
||||
else:
|
||||
return True, url_part or "hindsight", None
|
||||
|
||||
return False, None, None
|
||||
|
||||
|
||||
async def resolve_database_url(db_url: str) -> str:
|
||||
"""
|
||||
Resolve a database URL, handling pg0:// embedded database URLs.
|
||||
|
||||
If the URL is a pg0:// URL, starts the embedded PostgreSQL and returns
|
||||
the actual postgresql:// connection URL. Otherwise, returns the URL unchanged.
|
||||
|
||||
Args:
|
||||
db_url: Database URL (pg0://, pg0, or postgresql://)
|
||||
|
||||
Returns:
|
||||
The resolved postgresql:// connection URL
|
||||
"""
|
||||
is_pg0, instance_name, port = parse_pg0_url(db_url)
|
||||
if is_pg0:
|
||||
pg0 = EmbeddedPostgres(name=instance_name, port=port)
|
||||
return await pg0.ensure_running()
|
||||
return db_url
|
||||
|
||||
@@ -38,6 +38,8 @@ dependencies = [
|
||||
"dateparser>=1.2.2",
|
||||
"google-genai>=1.0.0",
|
||||
"anthropic>=0.40.0",
|
||||
"typer>=0.9.0",
|
||||
"cohere>=5.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -52,6 +54,7 @@ test = [
|
||||
[project.scripts]
|
||||
hindsight-api = "hindsight_api.main:main"
|
||||
hindsight-local-mcp = "hindsight_api.mcp_local:main"
|
||||
hindsight-admin = "hindsight_api.admin.cli:main"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["hindsight_api"]
|
||||
@@ -75,7 +78,7 @@ log_cli = true
|
||||
log_cli_level = "INFO"
|
||||
log_cli_format = "%(asctime)s - %(levelname)s - %(name)s - %(message)s"
|
||||
log_cli_date_format = "%Y-%m-%d %H:%M:%S"
|
||||
addopts = "--timeout 120 -n 8 --durations=10 -v"
|
||||
addopts = "--timeout 120 -n 8 --dist loadgroup --durations=10 -v"
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
log_auto_indent = true
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
"""
|
||||
Tests for admin backup and restore functionality.
|
||||
|
||||
These tests use an isolated schema to avoid interfering with other tests.
|
||||
The backup/restore operations truncate tables, which would cause deadlocks
|
||||
and race conditions if run against the shared public schema.
|
||||
"""
|
||||
|
||||
import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from hindsight_api.admin.cli import _backup, _restore, BACKUP_TABLES
|
||||
from hindsight_api.migrations import run_migrations
|
||||
|
||||
|
||||
# Run these tests sequentially since they do full DB backup/restore
|
||||
pytestmark = pytest.mark.xdist_group(name="backup_restore")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture(scope="function")
|
||||
async def backup_test_schema(pg0_db_url, embeddings):
|
||||
"""Create an isolated schema for backup/restore tests.
|
||||
|
||||
Uses a unique schema name per test invocation to avoid conflicts with
|
||||
parallel test runs or leftover state from interrupted runs.
|
||||
|
||||
Returns a tuple of (db_url, schema_name, fq_helper, embeddings).
|
||||
"""
|
||||
# Initialize embeddings if not already done
|
||||
await embeddings.initialize()
|
||||
|
||||
# Use unique schema name to avoid conflicts
|
||||
schema_name = f"backup_test_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
def _fq(table: str) -> str:
|
||||
"""Get fully-qualified table name in test schema."""
|
||||
return f"{schema_name}.{table}"
|
||||
|
||||
conn = await asyncpg.connect(pg0_db_url)
|
||||
try:
|
||||
await conn.execute(f"CREATE SCHEMA {schema_name}")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
# Run migrations on the isolated schema
|
||||
run_migrations(pg0_db_url, schema=schema_name)
|
||||
|
||||
yield pg0_db_url, schema_name, _fq, embeddings
|
||||
|
||||
# Cleanup after test
|
||||
conn = await asyncpg.connect(pg0_db_url)
|
||||
try:
|
||||
await conn.execute(f"DROP SCHEMA IF EXISTS {schema_name} CASCADE")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_restore_roundtrip(backup_test_schema):
|
||||
"""Test that backup and restore preserves all data correctly."""
|
||||
db_url, schema_name, _fq, embeddings = backup_test_schema
|
||||
bank_id = f"test-backup-{uuid.uuid4().hex[:8]}"
|
||||
conn = await asyncpg.connect(db_url)
|
||||
|
||||
try:
|
||||
# Create a bank
|
||||
await conn.execute(
|
||||
f"INSERT INTO {_fq('banks')} (bank_id) VALUES ($1) ON CONFLICT DO NOTHING",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
# Create some test memory units with embeddings
|
||||
# Convert embedding list to pgvector format string
|
||||
embedding_list = embeddings.encode(["Test content about Alice"])[0]
|
||||
embedding_str = "[" + ",".join(str(x) for x in embedding_list) + "]"
|
||||
for text in [
|
||||
"Alice is a software engineer who loves Python.",
|
||||
"Bob works with Alice on the backend team.",
|
||||
"The team uses PostgreSQL for their database.",
|
||||
]:
|
||||
await conn.execute(
|
||||
f"""INSERT INTO {_fq('memory_units')}
|
||||
(bank_id, text, fact_type, embedding, event_date)
|
||||
VALUES ($1, $2, 'world', $3::vector, NOW())""",
|
||||
bank_id,
|
||||
text,
|
||||
embedding_str,
|
||||
)
|
||||
|
||||
# Get counts before backup
|
||||
counts_before = {}
|
||||
for table in BACKUP_TABLES:
|
||||
counts_before[table] = await conn.fetchval(f"SELECT COUNT(*) FROM {_fq(table)}")
|
||||
|
||||
# Verify we have data
|
||||
assert counts_before["banks"] > 0
|
||||
assert counts_before["memory_units"] > 0
|
||||
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
# Backup to a temp file
|
||||
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f:
|
||||
backup_path = Path(f.name)
|
||||
|
||||
try:
|
||||
manifest = await _backup(db_url, backup_path, schema=schema_name)
|
||||
|
||||
# Verify backup file exists and is valid
|
||||
assert backup_path.exists()
|
||||
assert backup_path.stat().st_size > 0
|
||||
|
||||
# Verify manifest
|
||||
assert manifest["version"] == "1"
|
||||
assert "created_at" in manifest
|
||||
for table in BACKUP_TABLES:
|
||||
assert table in manifest["tables"]
|
||||
assert manifest["tables"][table]["rows"] == counts_before[table]
|
||||
|
||||
# Verify zip contents
|
||||
with zipfile.ZipFile(backup_path, "r") as zf:
|
||||
assert "manifest.json" in zf.namelist()
|
||||
for table in BACKUP_TABLES:
|
||||
assert f"{table}.bin" in zf.namelist()
|
||||
|
||||
# Clear all data
|
||||
conn = await asyncpg.connect(db_url)
|
||||
try:
|
||||
for table in reversed(BACKUP_TABLES):
|
||||
await conn.execute(f"TRUNCATE TABLE {_fq(table)} CASCADE")
|
||||
|
||||
# Verify data is gone
|
||||
for table in BACKUP_TABLES:
|
||||
count = await conn.fetchval(f"SELECT COUNT(*) FROM {_fq(table)}")
|
||||
assert count == 0, f"Table {table} should be empty after truncate"
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
# Restore from backup
|
||||
await _restore(db_url, backup_path, schema=schema_name)
|
||||
|
||||
# Verify counts match original
|
||||
conn = await asyncpg.connect(db_url)
|
||||
try:
|
||||
for table in BACKUP_TABLES:
|
||||
count = await conn.fetchval(f"SELECT COUNT(*) FROM {_fq(table)}")
|
||||
assert count == counts_before[table], f"Table {table} count mismatch after restore"
|
||||
|
||||
# Verify data content is preserved
|
||||
texts = await conn.fetch(
|
||||
f"SELECT text FROM {_fq('memory_units')} WHERE bank_id = $1",
|
||||
bank_id,
|
||||
)
|
||||
text_content = " ".join(r["text"] for r in texts)
|
||||
assert "Alice" in text_content or "software" in text_content
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
if backup_path.exists():
|
||||
backup_path.unlink()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backup_restore_preserves_all_column_types(backup_test_schema):
|
||||
"""Test that all column types are preserved: vectors, UUIDs, timestamps, JSONB."""
|
||||
db_url, schema_name, _fq, embeddings = backup_test_schema
|
||||
bank_id = f"test-types-{uuid.uuid4().hex[:8]}"
|
||||
conn = await asyncpg.connect(db_url)
|
||||
|
||||
try:
|
||||
# Create a bank
|
||||
await conn.execute(
|
||||
f"INSERT INTO {_fq('banks')} (bank_id) VALUES ($1) ON CONFLICT DO NOTHING",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
# Create a memory unit with all column types
|
||||
# Convert embedding list to pgvector format string
|
||||
embedding_list = embeddings.encode(["John Smith engineer"])[0]
|
||||
embedding_str = "[" + ",".join(str(x) for x in embedding_list) + "]"
|
||||
await conn.execute(
|
||||
f"""INSERT INTO {_fq('memory_units')}
|
||||
(bank_id, text, fact_type, embedding, event_date, metadata)
|
||||
VALUES ($1, $2, 'world', $3::vector, NOW(), $4)""",
|
||||
bank_id,
|
||||
"John Smith is a senior engineer at Acme Corp since 2020.",
|
||||
embedding_str,
|
||||
'{"key": "value"}',
|
||||
)
|
||||
|
||||
# Create an entity
|
||||
await conn.execute(
|
||||
f"""INSERT INTO {_fq('entities')}
|
||||
(bank_id, canonical_name, metadata)
|
||||
VALUES ($1, $2, $3)""",
|
||||
bank_id,
|
||||
"John Smith",
|
||||
'{"role": "engineer"}',
|
||||
)
|
||||
|
||||
# Get original data
|
||||
original_unit = await conn.fetchrow(
|
||||
f"""SELECT id, embedding, event_date, created_at, metadata, text
|
||||
FROM {_fq('memory_units')} WHERE bank_id = $1 LIMIT 1""",
|
||||
bank_id,
|
||||
)
|
||||
original_entity = await conn.fetchrow(
|
||||
f"""SELECT id, first_seen, last_seen, metadata, canonical_name
|
||||
FROM {_fq('entities')} WHERE bank_id = $1 LIMIT 1""",
|
||||
bank_id,
|
||||
)
|
||||
original_bank = await conn.fetchrow(
|
||||
f"SELECT bank_id, created_at, updated_at FROM {_fq('banks')} WHERE bank_id = $1",
|
||||
bank_id,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
assert original_unit is not None, "Should have created memory units"
|
||||
assert original_unit["embedding"] is not None, "Should have embedding"
|
||||
assert original_unit["id"] is not None, "Should have UUID"
|
||||
assert original_entity is not None, "Should have created entities"
|
||||
|
||||
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as f:
|
||||
backup_path = Path(f.name)
|
||||
|
||||
try:
|
||||
await _backup(db_url, backup_path, schema=schema_name)
|
||||
|
||||
# Clear all data
|
||||
conn = await asyncpg.connect(db_url)
|
||||
try:
|
||||
for table in reversed(BACKUP_TABLES):
|
||||
await conn.execute(f"TRUNCATE TABLE {_fq(table)} CASCADE")
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
await _restore(db_url, backup_path, schema=schema_name)
|
||||
|
||||
# Verify all column types are preserved exactly
|
||||
conn = await asyncpg.connect(db_url)
|
||||
try:
|
||||
restored_unit = await conn.fetchrow(
|
||||
f"""SELECT id, embedding, event_date, created_at, metadata, text
|
||||
FROM {_fq('memory_units')} WHERE bank_id = $1 LIMIT 1""",
|
||||
bank_id,
|
||||
)
|
||||
restored_entity = await conn.fetchrow(
|
||||
f"""SELECT id, first_seen, last_seen, metadata, canonical_name
|
||||
FROM {_fq('entities')} WHERE bank_id = $1 LIMIT 1""",
|
||||
bank_id,
|
||||
)
|
||||
restored_bank = await conn.fetchrow(
|
||||
f"SELECT bank_id, created_at, updated_at FROM {_fq('banks')} WHERE bank_id = $1",
|
||||
bank_id,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
# Verify memory_units
|
||||
assert restored_unit is not None, "Should have restored memory unit"
|
||||
assert restored_unit["id"] == original_unit["id"], "UUID should match exactly"
|
||||
assert restored_unit["text"] == original_unit["text"], "Text should match"
|
||||
assert list(restored_unit["embedding"]) == list(original_unit["embedding"]), "Vector embedding should match exactly"
|
||||
assert restored_unit["event_date"] == original_unit["event_date"], "Timestamp should match exactly"
|
||||
assert restored_unit["created_at"] == original_unit["created_at"], "Created timestamp should match"
|
||||
assert restored_unit["metadata"] == original_unit["metadata"], "JSONB metadata should match"
|
||||
|
||||
# Verify entities
|
||||
assert restored_entity is not None, "Should have restored entity"
|
||||
assert restored_entity["id"] == original_entity["id"], "Entity UUID should match"
|
||||
assert restored_entity["canonical_name"] == original_entity["canonical_name"], "Entity name should match"
|
||||
assert restored_entity["first_seen"] == original_entity["first_seen"], "Entity first_seen should match"
|
||||
assert restored_entity["last_seen"] == original_entity["last_seen"], "Entity last_seen should match"
|
||||
assert restored_entity["metadata"] == original_entity["metadata"], "Entity metadata should match"
|
||||
|
||||
# Verify banks
|
||||
assert restored_bank is not None, "Should have restored bank"
|
||||
assert restored_bank["bank_id"] == original_bank["bank_id"], "Bank ID should match"
|
||||
assert restored_bank["created_at"] == original_bank["created_at"], "Bank created_at should match"
|
||||
|
||||
finally:
|
||||
if backup_path.exists():
|
||||
backup_path.unlink()
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Test suite for causal relationship extraction.
|
||||
|
||||
Tests that the fact extraction system correctly identifies and validates
|
||||
causal relationships between facts, with valid indices.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api import LLMConfig
|
||||
from hindsight_api.engine.retain.fact_extraction import extract_facts_from_text
|
||||
|
||||
|
||||
class TestCausalRelationships:
|
||||
"""Tests for causal relationship extraction and validation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_causal_chain_extraction(self):
|
||||
"""
|
||||
Test that a clear causal chain is extracted with valid relationships.
|
||||
|
||||
Story: Lost job -> couldn't pay rent -> had to move -> found new apartment
|
||||
|
||||
This is a 4-fact causal chain where each fact causes the next.
|
||||
The extracted causal relations should have valid indices (0-3).
|
||||
"""
|
||||
text = """
|
||||
I lost my job at the tech company in January because of layoffs.
|
||||
Because I lost my job, I couldn't pay my rent anymore.
|
||||
Since I couldn't afford rent, I had to move out of my apartment.
|
||||
After searching for weeks, I finally found a cheaper apartment in Brooklyn.
|
||||
"""
|
||||
|
||||
context = "Personal story about housing change"
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 3, 15),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
)
|
||||
|
||||
assert len(facts) >= 3, f"Should extract at least 3 facts from the causal chain. Got {len(facts)}"
|
||||
|
||||
# Collect all causal relations from all facts
|
||||
all_causal_relations = []
|
||||
for i, fact in enumerate(facts):
|
||||
if fact.causal_relations:
|
||||
for rel in fact.causal_relations:
|
||||
all_causal_relations.append({
|
||||
"from_fact_index": i,
|
||||
"to_fact_index": rel.target_fact_index,
|
||||
"relation_type": rel.relation_type,
|
||||
"strength": rel.strength,
|
||||
"from_fact_text": fact.fact[:50],
|
||||
})
|
||||
|
||||
# Verify that ALL causal relation indices are valid
|
||||
num_facts = len(facts)
|
||||
invalid_relations = []
|
||||
for rel in all_causal_relations:
|
||||
if rel["to_fact_index"] < 0 or rel["to_fact_index"] >= num_facts:
|
||||
invalid_relations.append(rel)
|
||||
|
||||
assert len(invalid_relations) == 0, (
|
||||
f"Found {len(invalid_relations)} causal relations with invalid indices! "
|
||||
f"Valid range is 0-{num_facts - 1}. "
|
||||
f"Invalid relations: {invalid_relations}"
|
||||
)
|
||||
|
||||
# Should have at least some causal relations extracted
|
||||
assert len(all_causal_relations) >= 2, (
|
||||
f"Should extract at least 2 causal relationships from this clear chain. "
|
||||
f"Got {len(all_causal_relations)}: {all_causal_relations}"
|
||||
)
|
||||
|
||||
# Verify relation types are valid
|
||||
valid_types = {"causes", "caused_by", "enables", "prevents"}
|
||||
for rel in all_causal_relations:
|
||||
assert rel["relation_type"] in valid_types, (
|
||||
f"Invalid relation_type '{rel['relation_type']}'. Must be one of {valid_types}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_complex_causal_web(self):
|
||||
"""
|
||||
Test a more complex scenario with multiple interconnected causes.
|
||||
|
||||
This tests the LLM's ability to identify multiple causal links and
|
||||
ensure all referenced indices exist.
|
||||
"""
|
||||
text = """
|
||||
The heavy rain caused flooding in the basement.
|
||||
The flooding damaged the electrical system.
|
||||
Because of the electrical damage, we had to call an electrician.
|
||||
The electrician found that the wiring was old and needed replacement.
|
||||
We decided to renovate the entire basement while fixing the wiring.
|
||||
The renovation took three months and cost $15,000.
|
||||
"""
|
||||
|
||||
context = "Home repair story"
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 6, 1),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
)
|
||||
|
||||
assert len(facts) >= 4, f"Should extract at least 4 facts. Got {len(facts)}"
|
||||
|
||||
# Validate all causal relation indices
|
||||
num_facts = len(facts)
|
||||
for i, fact in enumerate(facts):
|
||||
if fact.causal_relations:
|
||||
for rel in fact.causal_relations:
|
||||
assert 0 <= rel.target_fact_index < num_facts, (
|
||||
f"Fact {i} has causal relation to invalid index {rel.target_fact_index}. "
|
||||
f"Valid range is 0-{num_facts - 1}. "
|
||||
f"Fact text: {fact.fact[:80]}..."
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_self_referencing_causal_relations(self):
|
||||
"""
|
||||
Test that facts don't have causal relations pointing to themselves.
|
||||
"""
|
||||
text = """
|
||||
I started learning Python because I wanted to automate my work tasks.
|
||||
Learning Python led me to discover machine learning.
|
||||
Machine learning fascinated me so much that I changed my career to data science.
|
||||
"""
|
||||
|
||||
context = "Career change story"
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 1, 1),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
)
|
||||
|
||||
# Check no fact references itself
|
||||
for i, fact in enumerate(facts):
|
||||
if fact.causal_relations:
|
||||
for rel in fact.causal_relations:
|
||||
assert rel.target_fact_index != i, (
|
||||
f"Fact {i} has a self-referencing causal relation! "
|
||||
f"Fact text: {fact.fact}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_bidirectional_causal_relationships(self):
|
||||
"""
|
||||
Test that bidirectional causal relationships (causes and caused_by)
|
||||
are handled correctly.
|
||||
"""
|
||||
text = """
|
||||
My promotion at work caused me to move to New York.
|
||||
Moving to New York was caused by my promotion at work.
|
||||
The new role enabled me to lead a team of engineers.
|
||||
"""
|
||||
|
||||
context = "Work promotion story"
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 2, 15),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
)
|
||||
|
||||
num_facts = len(facts)
|
||||
|
||||
# Validate all indices
|
||||
for i, fact in enumerate(facts):
|
||||
if fact.causal_relations:
|
||||
for rel in fact.causal_relations:
|
||||
assert 0 <= rel.target_fact_index < num_facts, (
|
||||
f"Invalid target_fact_index {rel.target_fact_index} in fact {i}. "
|
||||
f"Valid range: 0-{num_facts - 1}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_causal_relation_strength_values(self):
|
||||
"""
|
||||
Test that causal relation strength values are within valid range [0.0, 1.0].
|
||||
"""
|
||||
text = """
|
||||
The stock market crash directly caused the company to lay off employees.
|
||||
The layoffs indirectly led to reduced consumer spending in the area.
|
||||
Reduced spending somewhat affected local businesses.
|
||||
"""
|
||||
|
||||
context = "Economic impact story"
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 4, 1),
|
||||
context=context,
|
||||
llm_config=llm_config,
|
||||
agent_name="TestUser"
|
||||
)
|
||||
|
||||
for i, fact in enumerate(facts):
|
||||
if fact.causal_relations:
|
||||
for rel in fact.causal_relations:
|
||||
assert 0.0 <= rel.strength <= 1.0, (
|
||||
f"Causal relation strength {rel.strength} is outside valid range [0.0, 1.0]. "
|
||||
f"Fact {i}: {fact.fact[:50]}..."
|
||||
)
|
||||
@@ -14,8 +14,8 @@ from datetime import datetime
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from hindsight_api import MemoryEngine, RequestContext
|
||||
from hindsight_api.engine.embeddings import LocalSTEmbeddings, OpenAIEmbeddings
|
||||
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
|
||||
from hindsight_api.engine.embeddings import LocalSTEmbeddings, OpenAIEmbeddings, CohereEmbeddings
|
||||
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder, CohereCrossEncoder
|
||||
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
|
||||
from hindsight_api.extensions import TenantExtension, TenantContext
|
||||
from hindsight_api.migrations import run_migrations, ensure_embedding_dimension
|
||||
@@ -426,3 +426,177 @@ class TestOpenAIEmbeddings:
|
||||
await memory.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Cohere Embeddings Tests
|
||||
# =============================================================================
|
||||
|
||||
|
||||
def has_cohere_api_key() -> bool:
|
||||
"""Check if Cohere API key is available."""
|
||||
return bool(os.environ.get("COHERE_API_KEY"))
|
||||
|
||||
|
||||
def get_cohere_api_key() -> str:
|
||||
"""Get Cohere API key from environment."""
|
||||
return os.environ.get("COHERE_API_KEY", "")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def cohere_embeddings():
|
||||
"""Create Cohere embeddings instance."""
|
||||
if not has_cohere_api_key():
|
||||
pytest.skip("Cohere API key not available (set COHERE_API_KEY)")
|
||||
|
||||
embeddings = CohereEmbeddings(
|
||||
api_key=get_cohere_api_key(),
|
||||
model="embed-english-v3.0",
|
||||
)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(embeddings.initialize())
|
||||
finally:
|
||||
loop.close()
|
||||
return embeddings
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def cohere_cross_encoder():
|
||||
"""Create Cohere cross-encoder instance."""
|
||||
if not has_cohere_api_key():
|
||||
pytest.skip("Cohere API key not available (set COHERE_API_KEY)")
|
||||
|
||||
cross_encoder = CohereCrossEncoder(
|
||||
api_key=get_cohere_api_key(),
|
||||
model="rerank-english-v3.0",
|
||||
)
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(cross_encoder.initialize())
|
||||
finally:
|
||||
loop.close()
|
||||
return cross_encoder
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def cohere_test_schema(pg0_db_url, worker_id, cohere_embeddings):
|
||||
"""Create an isolated schema for Cohere embedding tests."""
|
||||
schema_name = get_test_schema("test_cohere_embed", worker_id)
|
||||
create_isolated_schema(pg0_db_url, schema_name, dimension=cohere_embeddings.dimension)
|
||||
yield pg0_db_url, schema_name
|
||||
drop_schema(pg0_db_url, schema_name)
|
||||
|
||||
|
||||
class TestCohereEmbeddings:
|
||||
"""Tests for Cohere embeddings provider."""
|
||||
|
||||
def test_cohere_embeddings_initialization(self, cohere_embeddings):
|
||||
"""Test that Cohere embeddings initializes correctly."""
|
||||
assert cohere_embeddings.dimension == 1024
|
||||
assert cohere_embeddings.provider_name == "cohere"
|
||||
|
||||
def test_cohere_embeddings_encode(self, cohere_embeddings):
|
||||
"""Test that Cohere embeddings can encode text."""
|
||||
texts = ["Hello, world!", "This is a test."]
|
||||
embeddings = cohere_embeddings.encode(texts)
|
||||
|
||||
assert len(embeddings) == 2
|
||||
assert len(embeddings[0]) == 1024
|
||||
assert len(embeddings[1]) == 1024
|
||||
assert all(isinstance(x, float) for x in embeddings[0])
|
||||
|
||||
|
||||
class TestCohereCrossEncoder:
|
||||
"""Tests for Cohere cross-encoder/reranker."""
|
||||
|
||||
def test_cohere_cross_encoder_initialization(self, cohere_cross_encoder):
|
||||
"""Test that Cohere cross-encoder initializes correctly."""
|
||||
assert cohere_cross_encoder.provider_name == "cohere"
|
||||
|
||||
def test_cohere_cross_encoder_predict(self, cohere_cross_encoder):
|
||||
"""Test that Cohere cross-encoder can score pairs."""
|
||||
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 = 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"
|
||||
|
||||
|
||||
class TestCohereIntegration:
|
||||
"""Integration tests for Cohere embeddings with memory engine."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cohere_embeddings_retain_recall(
|
||||
self,
|
||||
cohere_test_schema,
|
||||
cohere_embeddings,
|
||||
cohere_cross_encoder,
|
||||
query_analyzer,
|
||||
request_context,
|
||||
):
|
||||
"""Test retain and recall operations with Cohere embeddings."""
|
||||
db_url, schema_name = cohere_test_schema
|
||||
test_bank_id = f"cohere_test_{datetime.now().timestamp()}"
|
||||
|
||||
memory = MemoryEngine(
|
||||
db_url=db_url,
|
||||
memory_llm_provider=os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"),
|
||||
memory_llm_api_key=os.getenv("HINDSIGHT_API_LLM_API_KEY"),
|
||||
memory_llm_model=os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"),
|
||||
memory_llm_base_url=os.getenv("HINDSIGHT_API_LLM_BASE_URL") or None,
|
||||
embeddings=cohere_embeddings,
|
||||
cross_encoder=cohere_cross_encoder,
|
||||
query_analyzer=query_analyzer,
|
||||
pool_min_size=1,
|
||||
pool_max_size=3,
|
||||
run_migrations=False,
|
||||
tenant_extension=SchemaTenantExtension(schema_name),
|
||||
)
|
||||
|
||||
try:
|
||||
await memory.initialize()
|
||||
|
||||
# Store some memories
|
||||
await memory.retain_async(
|
||||
bank_id=test_bank_id,
|
||||
content="Alice works as a software engineer at Google.",
|
||||
context="career discussion",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
await memory.retain_async(
|
||||
bank_id=test_bank_id,
|
||||
content="Bob is a data scientist specializing in machine learning.",
|
||||
context="team introductions",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Recall memories
|
||||
result = await memory.recall_async(
|
||||
bank_id=test_bank_id,
|
||||
query="Who works in technology?",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert len(result.results) > 0
|
||||
|
||||
memory_texts = [m.text for m in result.results]
|
||||
assert any(
|
||||
"Alice" in text or "Bob" in text or "software" in text or "data scientist" in text
|
||||
for text in memory_texts
|
||||
), f"Expected to find relevant memories, got: {memory_texts}"
|
||||
|
||||
finally:
|
||||
try:
|
||||
if memory._pool and not memory._pool._closing:
|
||||
await memory.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -43,7 +43,7 @@ Marcus felt anxious about the upcoming interview.
|
||||
context = "Personal journal entry"
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
@@ -75,7 +75,7 @@ The music was so loud I could barely hear myself think.
|
||||
context = "Personal experience"
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
@@ -108,7 +108,7 @@ Maybe we should reconsider the timeline.
|
||||
context = "Team discussion"
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
@@ -141,7 +141,7 @@ I'm unable to attend the conference due to scheduling conflicts.
|
||||
context = "Personal profile discussion"
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
@@ -173,7 +173,7 @@ Unlike last year, we're ahead of schedule.
|
||||
context = "Project review"
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
@@ -206,7 +206,7 @@ She's enthusiastic about the opportunity.
|
||||
context = "Team meeting"
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
@@ -239,7 +239,7 @@ I'm planning to switch careers because I'm not fulfilled in my current role.
|
||||
context = "Personal goals discussion"
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
@@ -276,7 +276,7 @@ Family is the most important thing to her.
|
||||
context = "Personal values discussion"
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
@@ -310,7 +310,7 @@ I prefer presenting in person rather than virtually because I can read the room
|
||||
|
||||
event_date = datetime(2024, 11, 13)
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=event_date,
|
||||
context=context,
|
||||
@@ -366,7 +366,7 @@ I'm planning to visit Tokyo next month.
|
||||
|
||||
event_date = datetime(2024, 11, 13)
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=event_date,
|
||||
context=context,
|
||||
@@ -419,7 +419,7 @@ with a concert surrounded by music, joy and the warm summer breeze.
|
||||
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=event_date,
|
||||
context=context,
|
||||
@@ -488,7 +488,7 @@ Yesterday I went for a morning jog for the first time in a nearby park.
|
||||
|
||||
event_date = datetime(2024, 11, 13)
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=event_date,
|
||||
context=context,
|
||||
@@ -537,7 +537,7 @@ Yesterday I went for a morning jog for the first time in a nearby park.
|
||||
This morning I had coffee with Alice.
|
||||
"""
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=reference_date,
|
||||
llm_config=llm_config,
|
||||
@@ -567,7 +567,7 @@ Yesterday I went for a morning jog for the first time in a nearby park.
|
||||
|
||||
text = "Alice works at Google. She loves Python programming."
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=reference_date,
|
||||
llm_config=llm_config,
|
||||
@@ -594,7 +594,7 @@ Yesterday I went for a morning jog for the first time in a nearby park.
|
||||
Bob will start his vacation on April 1st.
|
||||
"""
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=reference_date,
|
||||
llm_config=llm_config,
|
||||
@@ -645,7 +645,7 @@ great time! Every time I see it, I can't help but smile.
|
||||
|
||||
event_date = datetime(2023, 2, 23)
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=event_date,
|
||||
context=context,
|
||||
@@ -695,7 +695,7 @@ I've learned so much from it.
|
||||
context = "Personal update"
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 11, 13),
|
||||
context=context,
|
||||
@@ -758,7 +758,7 @@ Jamie: Congratulations! I'd love to read it.
|
||||
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=transcript,
|
||||
event_date=datetime(2024, 11, 13),
|
||||
llm_config=llm_config,
|
||||
@@ -803,7 +803,7 @@ We presented our findings to the team yesterday.
|
||||
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=text,
|
||||
event_date=datetime(2024, 11, 13),
|
||||
llm_config=llm_config,
|
||||
@@ -838,7 +838,7 @@ Jamie: [teasing] We'll see who's right, my Niners pick is solid.
|
||||
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=transcript,
|
||||
event_date=datetime(2024, 11, 14),
|
||||
context=context,
|
||||
@@ -897,7 +897,7 @@ so the algorithm learns to box out. See you next week!
|
||||
|
||||
llm_config = LLMConfig.for_memory()
|
||||
|
||||
facts, _ = await extract_facts_from_text(
|
||||
facts, _, _ = await extract_facts_from_text(
|
||||
text=transcript,
|
||||
event_date=datetime(2024, 11, 13),
|
||||
llm_config=llm_config,
|
||||
|
||||
@@ -832,3 +832,134 @@ async def test_reflect_with_max_tokens(api_client):
|
||||
# Verify response has text
|
||||
assert "text" in result
|
||||
assert len(result["text"]) > 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_returns_token_usage(api_client):
|
||||
"""Test that reflect endpoint returns token usage metrics.
|
||||
|
||||
The usage field should contain input_tokens, output_tokens, and total_tokens
|
||||
from the LLM call made during reflection.
|
||||
"""
|
||||
test_bank_id = f"reflect_usage_test_{datetime.now().timestamp()}"
|
||||
|
||||
# Store a memory to reflect on
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/memories",
|
||||
json={
|
||||
"items": [
|
||||
{
|
||||
"content": "The capital of France is Paris.",
|
||||
"context": "geography"
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Call reflect
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/reflect",
|
||||
json={
|
||||
"query": "What is the capital of France?"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
|
||||
# Verify response has text
|
||||
assert "text" in result
|
||||
assert len(result["text"]) > 0
|
||||
|
||||
# Verify usage field exists and has expected structure
|
||||
assert "usage" in result, "Response should include 'usage' field"
|
||||
usage = result["usage"]
|
||||
assert usage is not None, "Usage should not be None for reflect"
|
||||
assert "input_tokens" in usage, "Usage should have 'input_tokens'"
|
||||
assert "output_tokens" in usage, "Usage should have 'output_tokens'"
|
||||
assert "total_tokens" in usage, "Usage should have 'total_tokens'"
|
||||
|
||||
# Verify token counts are valid
|
||||
assert usage["input_tokens"] > 0, f"Expected input_tokens > 0, got {usage['input_tokens']}"
|
||||
assert usage["output_tokens"] >= 0, f"Expected output_tokens >= 0, got {usage['output_tokens']}"
|
||||
assert usage["total_tokens"] == usage["input_tokens"] + usage["output_tokens"]
|
||||
|
||||
print(f"Reflect token usage: input={usage['input_tokens']}, output={usage['output_tokens']}, total={usage['total_tokens']}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retain_returns_token_usage(api_client):
|
||||
"""Test that retain endpoint returns token usage metrics for synchronous operations.
|
||||
|
||||
The usage field should contain input_tokens, output_tokens, and total_tokens
|
||||
from the LLM calls made during fact extraction.
|
||||
"""
|
||||
test_bank_id = f"retain_usage_test_{datetime.now().timestamp()}"
|
||||
|
||||
# Store memory synchronously (async=false is default)
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/memories",
|
||||
json={
|
||||
"items": [
|
||||
{
|
||||
"content": "Alice is a software engineer at TechCorp. She specializes in machine learning.",
|
||||
"context": "team introduction"
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
|
||||
# Verify basic response
|
||||
assert result["success"] is True
|
||||
assert result["items_count"] == 1
|
||||
assert result["async"] is False
|
||||
|
||||
# Verify usage field exists and has expected structure
|
||||
assert "usage" in result, "Response should include 'usage' field"
|
||||
usage = result["usage"]
|
||||
assert usage is not None, "Usage should not be None for synchronous retain"
|
||||
assert "input_tokens" in usage, "Usage should have 'input_tokens'"
|
||||
assert "output_tokens" in usage, "Usage should have 'output_tokens'"
|
||||
assert "total_tokens" in usage, "Usage should have 'total_tokens'"
|
||||
|
||||
# Verify token counts are valid
|
||||
assert usage["input_tokens"] > 0, f"Expected input_tokens > 0, got {usage['input_tokens']}"
|
||||
assert usage["output_tokens"] >= 0, f"Expected output_tokens >= 0, got {usage['output_tokens']}"
|
||||
assert usage["total_tokens"] == usage["input_tokens"] + usage["output_tokens"]
|
||||
|
||||
print(f"Retain token usage: input={usage['input_tokens']}, output={usage['output_tokens']}, total={usage['total_tokens']}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retain_async_no_usage(api_client):
|
||||
"""Test that async retain does not return usage (as it's processed in background).
|
||||
|
||||
When async=true, the usage field should be None since the actual
|
||||
fact extraction happens asynchronously.
|
||||
"""
|
||||
test_bank_id = f"retain_async_no_usage_test_{datetime.now().timestamp()}"
|
||||
|
||||
# Store memory asynchronously
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/memories",
|
||||
json={
|
||||
"async": True,
|
||||
"items": [
|
||||
{
|
||||
"content": "Bob is a data scientist.",
|
||||
"context": "team introduction"
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
|
||||
# Verify async response
|
||||
assert result["success"] is True
|
||||
assert result["async"] is True
|
||||
|
||||
# Usage should be None for async operations
|
||||
assert result.get("usage") is None, "Async retain should not include usage"
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
Test that LLM calls record token metrics via the metrics collector.
|
||||
"""
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from hindsight_api.engine.llm_wrapper import LLMProvider
|
||||
from hindsight_api.metrics import (
|
||||
MetricsCollector,
|
||||
NoOpMetricsCollector,
|
||||
get_metrics_collector,
|
||||
)
|
||||
|
||||
|
||||
def get_groq_api_key() -> str | None:
|
||||
"""Get Groq API key from environment."""
|
||||
return os.getenv("GROQ_API_KEY")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_metrics_recorded_for_groq():
|
||||
"""
|
||||
Test that LLM metrics are recorded when making LLM calls via Groq.
|
||||
Uses openai/gpt-oss-20b as recommended by Hindsight.
|
||||
"""
|
||||
api_key = get_groq_api_key()
|
||||
if not api_key:
|
||||
pytest.skip("Skipping: GROQ_API_KEY not set")
|
||||
|
||||
# Create a mock metrics collector to track record_llm_call calls
|
||||
mock_collector = MagicMock(spec=MetricsCollector)
|
||||
|
||||
with patch("hindsight_api.engine.llm_wrapper.get_metrics_collector", return_value=mock_collector):
|
||||
llm = LLMProvider(
|
||||
provider="groq",
|
||||
api_key=api_key,
|
||||
base_url="",
|
||||
model="openai/gpt-oss-20b",
|
||||
)
|
||||
|
||||
# Make an LLM call with clear instruction
|
||||
response = await llm.call(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant. Always respond."},
|
||||
{"role": "user", "content": "What is 2+2? Reply with just the number."}
|
||||
],
|
||||
max_completion_tokens=50,
|
||||
scope="test_metrics",
|
||||
)
|
||||
|
||||
# Verify record_llm_call was called - this is the main test
|
||||
assert mock_collector.record_llm_call.called, "record_llm_call should have been called"
|
||||
|
||||
# Get the call arguments
|
||||
call_kwargs = mock_collector.record_llm_call.call_args.kwargs
|
||||
|
||||
# Verify the call had correct structure
|
||||
assert call_kwargs["provider"] == "groq", f"Expected provider='groq', got {call_kwargs}"
|
||||
assert call_kwargs["model"] == "openai/gpt-oss-20b", f"Expected model='openai/gpt-oss-20b', got {call_kwargs}"
|
||||
assert call_kwargs["scope"] == "test_metrics", f"Expected scope='test_metrics', got {call_kwargs}"
|
||||
assert call_kwargs["duration"] > 0, f"Expected duration > 0, got {call_kwargs['duration']}"
|
||||
assert call_kwargs["input_tokens"] > 0, f"Expected input_tokens > 0, got {call_kwargs['input_tokens']}"
|
||||
assert call_kwargs["output_tokens"] >= 0, f"Expected output_tokens >= 0, got {call_kwargs['output_tokens']}"
|
||||
assert call_kwargs["success"] is True, f"Expected success=True, got {call_kwargs['success']}"
|
||||
|
||||
print(f"\nLLM metrics recorded:")
|
||||
print(f" provider: {call_kwargs['provider']}")
|
||||
print(f" model: {call_kwargs['model']}")
|
||||
print(f" scope: {call_kwargs['scope']}")
|
||||
print(f" duration: {call_kwargs['duration']:.3f}s")
|
||||
print(f" input_tokens: {call_kwargs['input_tokens']}")
|
||||
print(f" output_tokens: {call_kwargs['output_tokens']}")
|
||||
print(f" response: {response}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_metrics_recorded_for_structured_output():
|
||||
"""
|
||||
Test that LLM metrics are recorded for structured output (JSON) calls.
|
||||
"""
|
||||
api_key = get_groq_api_key()
|
||||
if not api_key:
|
||||
pytest.skip("Skipping: GROQ_API_KEY not set")
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
class SimpleResponse(BaseModel):
|
||||
greeting: str
|
||||
language: str
|
||||
|
||||
mock_collector = MagicMock(spec=MetricsCollector)
|
||||
|
||||
with patch("hindsight_api.engine.llm_wrapper.get_metrics_collector", return_value=mock_collector):
|
||||
llm = LLMProvider(
|
||||
provider="groq",
|
||||
api_key=api_key,
|
||||
base_url="",
|
||||
model="openai/gpt-oss-20b",
|
||||
)
|
||||
|
||||
# Make a structured output call
|
||||
response = await llm.call(
|
||||
messages=[{"role": "user", "content": "Say hello in French. Return greeting and language."}],
|
||||
response_format=SimpleResponse,
|
||||
max_completion_tokens=100,
|
||||
scope="structured_output_test",
|
||||
)
|
||||
|
||||
# Verify structured response
|
||||
assert isinstance(response, SimpleResponse)
|
||||
assert response.greeting is not None
|
||||
assert response.language is not None
|
||||
|
||||
# Verify record_llm_call was called
|
||||
assert mock_collector.record_llm_call.called, "record_llm_call should have been called"
|
||||
|
||||
call_kwargs = mock_collector.record_llm_call.call_args.kwargs
|
||||
assert call_kwargs["input_tokens"] > 0
|
||||
assert call_kwargs["output_tokens"] > 0
|
||||
|
||||
print(f"\nStructured output LLM metrics:")
|
||||
print(f" greeting: {response.greeting}")
|
||||
print(f" language: {response.language}")
|
||||
print(f" input_tokens: {call_kwargs['input_tokens']}")
|
||||
print(f" output_tokens: {call_kwargs['output_tokens']}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_noop_collector_when_metrics_disabled():
|
||||
"""
|
||||
Test that NoOpMetricsCollector is returned when metrics are not initialized.
|
||||
This verifies the fallback behavior doesn't break LLM calls.
|
||||
"""
|
||||
api_key = get_groq_api_key()
|
||||
if not api_key:
|
||||
pytest.skip("Skipping: GROQ_API_KEY not set")
|
||||
|
||||
# Without initializing metrics, get_metrics_collector returns NoOpMetricsCollector
|
||||
collector = get_metrics_collector()
|
||||
assert isinstance(collector, NoOpMetricsCollector), "Should return NoOpMetricsCollector when not initialized"
|
||||
|
||||
# Make an LLM call - should work fine with NoOp collector
|
||||
llm = LLMProvider(
|
||||
provider="groq",
|
||||
api_key=api_key,
|
||||
base_url="",
|
||||
model="openai/gpt-oss-20b",
|
||||
)
|
||||
|
||||
response = await llm.call(
|
||||
messages=[{"role": "user", "content": "Say 'test' in one word."}],
|
||||
max_completion_tokens=50,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
print(f"\nLLM call succeeded with NoOpMetricsCollector: {response}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_usage_returns_tuple():
|
||||
"""
|
||||
Test that return_usage=True returns (result, TokenUsage) tuple.
|
||||
"""
|
||||
from hindsight_api.engine.response_models import TokenUsage
|
||||
|
||||
api_key = get_groq_api_key()
|
||||
if not api_key:
|
||||
pytest.skip("Skipping: GROQ_API_KEY not set")
|
||||
|
||||
llm = LLMProvider(
|
||||
provider="groq",
|
||||
api_key=api_key,
|
||||
base_url="",
|
||||
model="openai/gpt-oss-20b",
|
||||
)
|
||||
|
||||
# Call with return_usage=True
|
||||
result, usage = await llm.call(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "What is 2+2? Reply with just the number."}
|
||||
],
|
||||
max_completion_tokens=50,
|
||||
return_usage=True,
|
||||
)
|
||||
|
||||
# Verify result is the response text
|
||||
assert result is not None
|
||||
assert isinstance(result, str)
|
||||
|
||||
# Verify usage is TokenUsage model with valid counts
|
||||
assert isinstance(usage, TokenUsage)
|
||||
assert usage.input_tokens > 0, f"Expected input_tokens > 0, got {usage.input_tokens}"
|
||||
assert usage.output_tokens >= 0, f"Expected output_tokens >= 0, got {usage.output_tokens}"
|
||||
assert usage.total_tokens == usage.input_tokens + usage.output_tokens
|
||||
|
||||
print(f"\nreturn_usage=True test:")
|
||||
print(f" result: {result}")
|
||||
print(f" usage: {usage}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_return_usage_with_structured_output():
|
||||
"""
|
||||
Test that return_usage=True works with structured output (JSON).
|
||||
"""
|
||||
from pydantic import BaseModel
|
||||
from hindsight_api.engine.response_models import TokenUsage
|
||||
|
||||
api_key = get_groq_api_key()
|
||||
if not api_key:
|
||||
pytest.skip("Skipping: GROQ_API_KEY not set")
|
||||
|
||||
class MathAnswer(BaseModel):
|
||||
answer: int
|
||||
explanation: str
|
||||
|
||||
llm = LLMProvider(
|
||||
provider="groq",
|
||||
api_key=api_key,
|
||||
base_url="",
|
||||
model="openai/gpt-oss-20b",
|
||||
)
|
||||
|
||||
# Call with return_usage=True and structured output
|
||||
result, usage = await llm.call(
|
||||
messages=[{"role": "user", "content": "What is 5+3? Return the answer and a brief explanation."}],
|
||||
response_format=MathAnswer,
|
||||
max_completion_tokens=100,
|
||||
return_usage=True,
|
||||
)
|
||||
|
||||
# Verify result is the parsed response
|
||||
assert isinstance(result, MathAnswer)
|
||||
assert result.answer == 8
|
||||
assert result.explanation is not None
|
||||
|
||||
# Verify usage is TokenUsage model
|
||||
assert isinstance(usage, TokenUsage)
|
||||
assert usage.input_tokens > 0
|
||||
assert usage.output_tokens > 0
|
||||
|
||||
print(f"\nStructured output with return_usage=True:")
|
||||
print(f" result: {result}")
|
||||
print(f" usage: {usage}")
|
||||
@@ -9,6 +9,7 @@ def mock_memory():
|
||||
"""Create a mock MemoryEngine."""
|
||||
memory = MagicMock()
|
||||
memory.retain_batch_async = AsyncMock()
|
||||
memory.submit_async_retain = AsyncMock(return_value={"operation_id": "test-op-123"})
|
||||
memory.recall_async = AsyncMock(return_value=MagicMock(results=[]))
|
||||
return memory
|
||||
|
||||
@@ -44,11 +45,11 @@ async def test_mcp_tools_use_context_bank_id(mock_memory):
|
||||
assert "retain" in tools
|
||||
assert "recall" in tools
|
||||
|
||||
# Test retain with bank_id from context
|
||||
# Test retain with bank_id from context (use async_processing=False for synchronous test)
|
||||
token = _current_bank_id.set("context-bank-id")
|
||||
try:
|
||||
retain_tool = tools["retain"]
|
||||
result = await retain_tool.fn(content="test content", context="test_context")
|
||||
result = await retain_tool.fn(content="test content", context="test_context", async_processing=False)
|
||||
assert "successfully" in result.lower()
|
||||
|
||||
# Verify the memory was called with the context bank_id
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
"""Tests for metrics instrumentation."""
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from hindsight_api.metrics import (
|
||||
MetricsCollector,
|
||||
MetricsCollectorBase,
|
||||
NoOpMetricsCollector,
|
||||
get_metrics_collector,
|
||||
get_token_bucket,
|
||||
create_metrics_collector,
|
||||
initialize_metrics,
|
||||
)
|
||||
|
||||
|
||||
class TestNoOpMetricsCollector:
|
||||
"""Tests for the no-op metrics collector."""
|
||||
|
||||
def test_record_operation_is_noop(self):
|
||||
"""Test that record_operation does nothing."""
|
||||
collector = NoOpMetricsCollector()
|
||||
|
||||
# Should not raise any exception
|
||||
with collector.record_operation("recall", bank_id="test_bank", source="api"):
|
||||
pass
|
||||
|
||||
def test_nested_contexts_work(self):
|
||||
"""Test that nested context managers work correctly."""
|
||||
collector = NoOpMetricsCollector()
|
||||
|
||||
# Nested contexts should work without issues
|
||||
with collector.record_operation("reflect", bank_id="test_bank", source="api"):
|
||||
with collector.record_operation("recall", bank_id="test_bank", source="reflect"):
|
||||
pass
|
||||
|
||||
def test_exception_propagates(self):
|
||||
"""Test that exceptions inside context are propagated."""
|
||||
collector = NoOpMetricsCollector()
|
||||
|
||||
with pytest.raises(ValueError, match="test error"):
|
||||
with collector.record_operation("recall", bank_id="test_bank"):
|
||||
raise ValueError("test error")
|
||||
|
||||
def test_record_llm_call_is_noop(self):
|
||||
"""Test that record_llm_call does nothing."""
|
||||
collector = NoOpMetricsCollector()
|
||||
|
||||
# Should not raise any exception
|
||||
collector.record_llm_call(
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
scope="memory",
|
||||
duration=1.5,
|
||||
input_tokens=100,
|
||||
output_tokens=50,
|
||||
success=True,
|
||||
)
|
||||
|
||||
|
||||
class TestMetricsCollector:
|
||||
"""Tests for the real metrics collector."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_meter(self):
|
||||
"""Create a mock meter for testing."""
|
||||
meter = MagicMock()
|
||||
# Create separate mocks for each histogram (operation_duration, llm_duration)
|
||||
histogram_mocks = [MagicMock(), MagicMock()]
|
||||
meter.create_histogram.side_effect = histogram_mocks
|
||||
# Create separate mocks for each counter
|
||||
# (operation_total, llm_tokens_input, llm_tokens_output, llm_calls_total)
|
||||
counter_mocks = [MagicMock() for _ in range(4)]
|
||||
meter.create_counter.side_effect = counter_mocks
|
||||
return meter
|
||||
|
||||
@pytest.fixture
|
||||
def collector(self, mock_meter):
|
||||
"""Create a MetricsCollector with a mock meter."""
|
||||
with patch("hindsight_api.metrics.get_meter", return_value=mock_meter):
|
||||
return MetricsCollector()
|
||||
|
||||
def test_record_operation_records_duration(self, collector):
|
||||
"""Test that record_operation records duration."""
|
||||
with collector.record_operation("recall", bank_id="test_bank", source="api"):
|
||||
pass
|
||||
|
||||
# Histogram should have been called
|
||||
collector.operation_duration.record.assert_called_once()
|
||||
call_args = collector.operation_duration.record.call_args
|
||||
|
||||
# First arg is duration (should be > 0)
|
||||
duration = call_args[0][0]
|
||||
assert duration >= 0
|
||||
|
||||
# Second arg is attributes dict
|
||||
attributes = call_args[0][1]
|
||||
assert attributes["operation"] == "recall"
|
||||
assert attributes["bank_id"] == "test_bank"
|
||||
assert attributes["source"] == "api"
|
||||
assert attributes["success"] == "true"
|
||||
|
||||
def test_record_operation_records_failure_on_exception(self, collector):
|
||||
"""Test that record_operation records failure when exception occurs."""
|
||||
with pytest.raises(RuntimeError):
|
||||
with collector.record_operation("retain", bank_id="test_bank", source="api"):
|
||||
raise RuntimeError("Test error")
|
||||
|
||||
# Should have recorded with success=false
|
||||
call_args = collector.operation_duration.record.call_args
|
||||
attributes = call_args[0][1]
|
||||
assert attributes["success"] == "false"
|
||||
|
||||
def test_record_operation_with_budget(self, collector):
|
||||
"""Test that budget is included in attributes when provided."""
|
||||
with collector.record_operation("recall", bank_id="test_bank", source="api", budget="mid"):
|
||||
pass
|
||||
|
||||
call_args = collector.operation_duration.record.call_args
|
||||
attributes = call_args[0][1]
|
||||
assert attributes["budget"] == "mid"
|
||||
|
||||
def test_record_operation_with_max_tokens(self, collector):
|
||||
"""Test that max_tokens is included in attributes when provided."""
|
||||
with collector.record_operation("recall", bank_id="test_bank", source="api", max_tokens=4096):
|
||||
pass
|
||||
|
||||
call_args = collector.operation_duration.record.call_args
|
||||
attributes = call_args[0][1]
|
||||
assert attributes["max_tokens"] == "4096"
|
||||
|
||||
def test_record_operation_source_values(self, collector):
|
||||
"""Test different source values: api, reflect, internal."""
|
||||
sources = ["api", "reflect", "internal"]
|
||||
|
||||
for source in sources:
|
||||
collector.operation_duration.record.reset_mock()
|
||||
|
||||
with collector.record_operation("recall", bank_id="test_bank", source=source):
|
||||
pass
|
||||
|
||||
call_args = collector.operation_duration.record.call_args
|
||||
attributes = call_args[0][1]
|
||||
assert attributes["source"] == source
|
||||
|
||||
def test_nested_contexts_track_separately(self, collector):
|
||||
"""Test that nested operations are tracked separately with different sources."""
|
||||
# Simulate reflect (api) calling recall (reflect)
|
||||
with collector.record_operation("reflect", bank_id="test_bank", source="api"):
|
||||
with collector.record_operation("recall", bank_id="test_bank", source="reflect"):
|
||||
pass
|
||||
|
||||
# Should have 2 calls to record
|
||||
assert collector.operation_duration.record.call_count == 2
|
||||
assert collector.operation_total.add.call_count == 2
|
||||
|
||||
# Check the calls
|
||||
calls = collector.operation_duration.record.call_args_list
|
||||
|
||||
# First call should be recall (inner context exits first)
|
||||
recall_attrs = calls[0][0][1]
|
||||
assert recall_attrs["operation"] == "recall"
|
||||
assert recall_attrs["source"] == "reflect"
|
||||
|
||||
# Second call should be reflect (outer context exits last)
|
||||
reflect_attrs = calls[1][0][1]
|
||||
assert reflect_attrs["operation"] == "reflect"
|
||||
assert reflect_attrs["source"] == "api"
|
||||
|
||||
|
||||
class TestGetMetricsCollector:
|
||||
"""Tests for the get_metrics_collector function."""
|
||||
|
||||
def test_returns_noop_by_default(self):
|
||||
"""Test that get_metrics_collector returns NoOpMetricsCollector by default."""
|
||||
# Reset global state
|
||||
import hindsight_api.metrics as metrics_module
|
||||
original_collector = metrics_module._metrics_collector
|
||||
|
||||
try:
|
||||
metrics_module._metrics_collector = NoOpMetricsCollector()
|
||||
collector = get_metrics_collector()
|
||||
assert isinstance(collector, NoOpMetricsCollector)
|
||||
finally:
|
||||
metrics_module._metrics_collector = original_collector
|
||||
|
||||
|
||||
class TestMetricsCollectorBase:
|
||||
"""Tests for the MetricsCollectorBase abstract class."""
|
||||
|
||||
def test_is_abstract(self):
|
||||
"""Test that MetricsCollectorBase methods are abstract."""
|
||||
# Create a class that inherits but doesn't implement
|
||||
class IncompleteCollector(MetricsCollectorBase):
|
||||
pass
|
||||
|
||||
collector = IncompleteCollector()
|
||||
|
||||
# Abstract methods should raise NotImplementedError
|
||||
with pytest.raises(NotImplementedError):
|
||||
with collector.record_operation("test", "test"):
|
||||
pass
|
||||
|
||||
with pytest.raises(NotImplementedError):
|
||||
collector.record_llm_call("test", "test", "test", 1.0)
|
||||
|
||||
|
||||
class TestGetTokenBucket:
|
||||
"""Tests for the get_token_bucket function."""
|
||||
|
||||
def test_bucket_0_100(self):
|
||||
"""Test tokens < 100 return '0-100' bucket."""
|
||||
assert get_token_bucket(0) == "0-100"
|
||||
assert get_token_bucket(50) == "0-100"
|
||||
assert get_token_bucket(99) == "0-100"
|
||||
|
||||
def test_bucket_100_500(self):
|
||||
"""Test tokens 100-499 return '100-500' bucket."""
|
||||
assert get_token_bucket(100) == "100-500"
|
||||
assert get_token_bucket(250) == "100-500"
|
||||
assert get_token_bucket(499) == "100-500"
|
||||
|
||||
def test_bucket_500_1k(self):
|
||||
"""Test tokens 500-999 return '500-1k' bucket."""
|
||||
assert get_token_bucket(500) == "500-1k"
|
||||
assert get_token_bucket(750) == "500-1k"
|
||||
assert get_token_bucket(999) == "500-1k"
|
||||
|
||||
def test_bucket_1k_5k(self):
|
||||
"""Test tokens 1000-4999 return '1k-5k' bucket."""
|
||||
assert get_token_bucket(1000) == "1k-5k"
|
||||
assert get_token_bucket(2500) == "1k-5k"
|
||||
assert get_token_bucket(4999) == "1k-5k"
|
||||
|
||||
def test_bucket_5k_10k(self):
|
||||
"""Test tokens 5000-9999 return '5k-10k' bucket."""
|
||||
assert get_token_bucket(5000) == "5k-10k"
|
||||
assert get_token_bucket(7500) == "5k-10k"
|
||||
assert get_token_bucket(9999) == "5k-10k"
|
||||
|
||||
def test_bucket_10k_50k(self):
|
||||
"""Test tokens 10000-49999 return '10k-50k' bucket."""
|
||||
assert get_token_bucket(10000) == "10k-50k"
|
||||
assert get_token_bucket(25000) == "10k-50k"
|
||||
assert get_token_bucket(49999) == "10k-50k"
|
||||
|
||||
def test_bucket_50k_plus(self):
|
||||
"""Test tokens >= 50000 return '50k+' bucket."""
|
||||
assert get_token_bucket(50000) == "50k+"
|
||||
assert get_token_bucket(100000) == "50k+"
|
||||
assert get_token_bucket(1000000) == "50k+"
|
||||
|
||||
|
||||
class TestLLMMetrics:
|
||||
"""Tests for LLM-specific metrics recording."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_meter(self):
|
||||
"""Create a mock meter for testing."""
|
||||
meter = MagicMock()
|
||||
# Create separate mocks for each histogram (operation_duration, llm_duration)
|
||||
histogram_mocks = [MagicMock(), MagicMock()]
|
||||
meter.create_histogram.side_effect = histogram_mocks
|
||||
# Create separate mocks for each counter
|
||||
# (operation_total, llm_tokens_input, llm_tokens_output, llm_calls_total)
|
||||
counter_mocks = [MagicMock() for _ in range(4)]
|
||||
meter.create_counter.side_effect = counter_mocks
|
||||
return meter
|
||||
|
||||
@pytest.fixture
|
||||
def collector(self, mock_meter):
|
||||
"""Create a MetricsCollector with a mock meter."""
|
||||
with patch("hindsight_api.metrics.get_meter", return_value=mock_meter):
|
||||
return MetricsCollector()
|
||||
|
||||
def test_record_llm_call_records_duration(self, collector):
|
||||
"""Test that record_llm_call records duration."""
|
||||
collector.record_llm_call(
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
scope="memory",
|
||||
duration=1.5,
|
||||
input_tokens=100,
|
||||
output_tokens=50,
|
||||
success=True,
|
||||
)
|
||||
|
||||
# LLM duration histogram should be called
|
||||
collector.llm_duration.record.assert_called_once()
|
||||
call_args = collector.llm_duration.record.call_args
|
||||
|
||||
# First arg is duration
|
||||
assert call_args[0][0] == 1.5
|
||||
|
||||
# Second arg is attributes dict
|
||||
attributes = call_args[0][1]
|
||||
assert attributes["provider"] == "openai"
|
||||
assert attributes["model"] == "gpt-4"
|
||||
assert attributes["scope"] == "memory"
|
||||
assert attributes["success"] == "true"
|
||||
|
||||
def test_record_llm_call_records_failure(self, collector):
|
||||
"""Test that record_llm_call records failure status."""
|
||||
collector.record_llm_call(
|
||||
provider="anthropic",
|
||||
model="claude-3",
|
||||
scope="reflect",
|
||||
duration=0.5,
|
||||
success=False,
|
||||
)
|
||||
|
||||
# Check success is false
|
||||
call_args = collector.llm_duration.record.call_args
|
||||
attributes = call_args[0][1]
|
||||
assert attributes["success"] == "false"
|
||||
|
||||
def test_record_llm_call_records_tokens_with_buckets(self, collector):
|
||||
"""Test that record_llm_call records tokens with bucket labels."""
|
||||
collector.record_llm_call(
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
scope="memory",
|
||||
duration=1.0,
|
||||
input_tokens=2500, # Should be "1k-5k" bucket
|
||||
output_tokens=150, # Should be "100-500" bucket
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Input tokens should be recorded with bucket
|
||||
collector.llm_tokens_input.add.assert_called_once()
|
||||
input_call = collector.llm_tokens_input.add.call_args
|
||||
assert input_call[0][0] == 2500
|
||||
assert input_call[0][1]["token_bucket"] == "1k-5k"
|
||||
|
||||
# Output tokens should be recorded with bucket
|
||||
collector.llm_tokens_output.add.assert_called_once()
|
||||
output_call = collector.llm_tokens_output.add.call_args
|
||||
assert output_call[0][0] == 150
|
||||
assert output_call[0][1]["token_bucket"] == "100-500"
|
||||
|
||||
def test_record_llm_call_skips_zero_tokens(self, collector):
|
||||
"""Test that zero token values don't record."""
|
||||
collector.record_llm_call(
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
scope="memory",
|
||||
duration=1.0,
|
||||
input_tokens=0,
|
||||
output_tokens=0,
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Token counters should not be called
|
||||
collector.llm_tokens_input.add.assert_not_called()
|
||||
collector.llm_tokens_output.add.assert_not_called()
|
||||
|
||||
def test_record_llm_call_increments_call_counter(self, collector):
|
||||
"""Test that record_llm_call increments the call counter."""
|
||||
collector.record_llm_call(
|
||||
provider="gemini",
|
||||
model="gemini-pro",
|
||||
scope="entity_observation",
|
||||
duration=2.0,
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Call counter should be incremented
|
||||
collector.llm_calls_total.add.assert_called_once()
|
||||
call_args = collector.llm_calls_total.add.call_args
|
||||
assert call_args[0][0] == 1
|
||||
assert call_args[0][1]["provider"] == "gemini"
|
||||
assert call_args[0][1]["model"] == "gemini-pro"
|
||||
assert call_args[0][1]["scope"] == "entity_observation"
|
||||
|
||||
def test_record_llm_call_different_scopes(self, collector):
|
||||
"""Test recording LLM calls with different scopes."""
|
||||
scopes = ["memory", "reflect", "entity_observation", "answer"]
|
||||
|
||||
for scope in scopes:
|
||||
collector.llm_duration.record.reset_mock()
|
||||
|
||||
collector.record_llm_call(
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
scope=scope,
|
||||
duration=1.0,
|
||||
success=True,
|
||||
)
|
||||
|
||||
call_args = collector.llm_duration.record.call_args
|
||||
attributes = call_args[0][1]
|
||||
assert attributes["scope"] == scope
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Test multilingual support for retain and reflect operations.
|
||||
|
||||
Tests that the system correctly handles non-English input and produces
|
||||
output in the same language as the input.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
from hindsight_api import RequestContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retain_chinese_content(memory, request_context):
|
||||
"""
|
||||
Test that retain correctly extracts facts from Chinese content
|
||||
and keeps the output in Chinese.
|
||||
|
||||
This test verifies:
|
||||
1. Facts are extracted from Chinese text
|
||||
2. The extracted facts contain Chinese characters
|
||||
3. Entity names are preserved in Chinese
|
||||
"""
|
||||
bank_id = f"test_chinese_retain_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Chinese content about a person and their activities
|
||||
chinese_content = """
|
||||
张伟是一位资深软件工程师,在腾讯工作了五年。他专门研究分布式系统,
|
||||
并领导了公司微服务架构的开发。他以编写干净、文档完善的代码而闻名。
|
||||
|
||||
李明上个月加入团队担任初级开发人员。他正在学习React和Node.js。
|
||||
李明很有热情,在代码审查中提出很好的问题。他最近完成了他的第一个功能,
|
||||
这是一个用户认证流程。
|
||||
|
||||
团队使用Kubernetes进行容器编排,并部署到阿里云。他们遵循敏捷方法论,
|
||||
采用两周冲刺周期。合并前必须进行代码审查。
|
||||
"""
|
||||
|
||||
# Retain the Chinese content
|
||||
unit_ids = await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=chinese_content,
|
||||
context="团队概述", # Chinese context
|
||||
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
logger.info(f"Retained {len(unit_ids)} facts from Chinese content")
|
||||
assert len(unit_ids) > 0, "Should have extracted and stored facts from Chinese content"
|
||||
|
||||
# Recall the facts with a Chinese query
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="告诉我关于张伟的信息", # "Tell me about Zhang Wei"
|
||||
budget=Budget.MID,
|
||||
max_tokens=1000,
|
||||
fact_type=["world"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
logger.info(f"Recalled {len(result.results)} facts")
|
||||
assert len(result.results) > 0, "Should recall facts about Zhang Wei"
|
||||
|
||||
# Verify that the facts contain Chinese characters
|
||||
# At least one fact should mention 张伟 (Zhang Wei) or related Chinese content
|
||||
chinese_facts_found = 0
|
||||
for fact in result.results:
|
||||
logger.info(f"Fact: {fact.text[:100]}...")
|
||||
# Check for common Chinese characters or the name
|
||||
if any(
|
||||
char in fact.text
|
||||
for char in ["张", "伟", "腾讯", "软件", "工程师", "分布式", "系统", "代码"]
|
||||
):
|
||||
chinese_facts_found += 1
|
||||
|
||||
logger.info(f"Found {chinese_facts_found} facts with Chinese content")
|
||||
assert chinese_facts_found > 0, (
|
||||
f"Expected facts to contain Chinese characters, but none found. "
|
||||
f"Facts: {[f.text for f in result.results]}"
|
||||
)
|
||||
|
||||
logger.info("Chinese retain test passed - facts preserved in Chinese")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_chinese_content(memory, request_context):
|
||||
"""
|
||||
Test that reflect correctly generates responses in Chinese
|
||||
when given Chinese facts and a Chinese query.
|
||||
|
||||
This test verifies:
|
||||
1. Reflection produces a response in Chinese
|
||||
2. The response references the Chinese facts
|
||||
3. Opinions are formed and expressed in Chinese
|
||||
"""
|
||||
bank_id = f"test_chinese_reflect_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Store some Chinese facts to give context for opinion formation
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="张伟是一位优秀的软件工程师,完成了五个重大项目。他总是按时交付,代码整洁有良好的文档。",
|
||||
context="绩效评估", # "Performance review"
|
||||
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="李明最近加入团队。他错过了第一个截止日期,代码有很多bug。",
|
||||
context="绩效评估",
|
||||
event_date=datetime(2024, 2, 1, tzinfo=timezone.utc),
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Reflect with a Chinese query
|
||||
query = "谁是更可靠的工程师?" # "Who is a more reliable engineer?"
|
||||
result = await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
budget=Budget.LOW,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
logger.info(f"Reflection answer: {result.text}")
|
||||
|
||||
# Verify we got an answer
|
||||
assert result.text, "Reflection should return an answer"
|
||||
|
||||
# Check that the response contains Chinese characters
|
||||
# The response should be in Chinese, not English
|
||||
chinese_chars_found = sum(1 for char in result.text if "\u4e00" <= char <= "\u9fff")
|
||||
total_chars = len(result.text.replace(" ", "").replace("\n", ""))
|
||||
|
||||
logger.info(f"Chinese characters: {chinese_chars_found}, Total characters: {total_chars}")
|
||||
|
||||
# At least 30% of characters should be Chinese (allowing for numbers, punctuation)
|
||||
chinese_ratio = chinese_chars_found / max(total_chars, 1)
|
||||
assert chinese_ratio > 0.3, (
|
||||
f"Expected response to be in Chinese (>30% Chinese characters), "
|
||||
f"but only {chinese_ratio:.1%} are Chinese. Response: {result.text}"
|
||||
)
|
||||
|
||||
# Check that Chinese names are mentioned
|
||||
assert "张伟" in result.text or "李明" in result.text, (
|
||||
f"Expected response to mention Chinese names 张伟 or 李明. Response: {result.text}"
|
||||
)
|
||||
|
||||
logger.info("Chinese reflect test passed - response generated in Chinese")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retain_japanese_content(memory, request_context):
|
||||
"""
|
||||
Test that retain correctly handles Japanese content.
|
||||
|
||||
This test verifies multilingual support extends beyond Chinese
|
||||
to other non-Latin languages.
|
||||
"""
|
||||
bank_id = f"test_japanese_retain_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Japanese content about a developer
|
||||
japanese_content = """
|
||||
田中さんはソフトウェアエンジニアで、東京のスタートアップで働いています。
|
||||
彼女はPythonとTypeScriptが得意で、毎日コードレビューをしています。
|
||||
先週、新しいAPIを完成させました。
|
||||
"""
|
||||
|
||||
unit_ids = await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=japanese_content,
|
||||
context="チームプロフィール", # "Team profile"
|
||||
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
logger.info(f"Retained {len(unit_ids)} facts from Japanese content")
|
||||
assert len(unit_ids) > 0, "Should have extracted facts from Japanese content"
|
||||
|
||||
# Recall with Japanese query
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="田中さんについて教えてください", # "Tell me about Tanaka-san"
|
||||
budget=Budget.MID,
|
||||
max_tokens=1000,
|
||||
fact_type=["world"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(result.results) > 0, "Should recall facts about Tanaka"
|
||||
|
||||
# Check for Japanese content in facts
|
||||
japanese_facts_found = 0
|
||||
for fact in result.results:
|
||||
logger.info(f"Fact: {fact.text[:100]}...")
|
||||
# Check for Japanese characters (hiragana, katakana, or kanji)
|
||||
if any(
|
||||
("\u3040" <= char <= "\u309f") # Hiragana
|
||||
or ("\u30a0" <= char <= "\u30ff") # Katakana
|
||||
or ("\u4e00" <= char <= "\u9fff") # Kanji
|
||||
for char in fact.text
|
||||
):
|
||||
japanese_facts_found += 1
|
||||
|
||||
assert japanese_facts_found > 0, (
|
||||
f"Expected facts to contain Japanese characters. "
|
||||
f"Facts: {[f.text for f in result.results]}"
|
||||
)
|
||||
|
||||
logger.info("Japanese retain test passed - facts preserved in Japanese")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_language_entities(memory, request_context):
|
||||
"""
|
||||
Test that entity extraction works correctly with mixed language content.
|
||||
|
||||
Some entities (like company names) might be in English while the
|
||||
description is in Chinese.
|
||||
"""
|
||||
bank_id = f"test_mixed_lang_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Mixed language content - Chinese with English company names
|
||||
mixed_content = """
|
||||
王芳在Google北京办公室工作,她是一名高级产品经理。
|
||||
之前她在Microsoft和Amazon工作过。
|
||||
她负责管理YouTube在中国市场的推广策略。
|
||||
"""
|
||||
|
||||
unit_ids = await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=mixed_content,
|
||||
context="员工资料",
|
||||
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(unit_ids) > 0, "Should extract facts from mixed language content"
|
||||
|
||||
# Recall and check entities
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="王芳在哪里工作?", # "Where does Wang Fang work?"
|
||||
budget=Budget.MID,
|
||||
max_tokens=1000,
|
||||
fact_type=["world"],
|
||||
include_entities=True,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(result.results) > 0, "Should recall facts about Wang Fang"
|
||||
|
||||
# Check that both Chinese and English entities are preserved
|
||||
all_text = " ".join(f.text for f in result.results)
|
||||
logger.info(f"Combined facts: {all_text}")
|
||||
|
||||
# Should contain Chinese name and/or English company names
|
||||
has_chinese_name = "王芳" in all_text
|
||||
has_english_company = any(
|
||||
company in all_text for company in ["Google", "Microsoft", "Amazon", "YouTube"]
|
||||
)
|
||||
|
||||
assert has_chinese_name or has_english_company, (
|
||||
f"Expected mixed language entities. Facts: {all_text}"
|
||||
)
|
||||
|
||||
logger.info("Mixed language entity test passed")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -51,6 +51,7 @@ hindsight_client_api/models/reflect_request.py
|
||||
hindsight_client_api/models/reflect_response.py
|
||||
hindsight_client_api/models/retain_request.py
|
||||
hindsight_client_api/models/retain_response.py
|
||||
hindsight_client_api/models/token_usage.py
|
||||
hindsight_client_api/models/update_disposition_request.py
|
||||
hindsight_client_api/models/validation_error.py
|
||||
hindsight_client_api/models/validation_error_loc_inner.py
|
||||
|
||||
@@ -74,6 +74,8 @@ class Hindsight:
|
||||
"""
|
||||
config = hindsight_client_api.Configuration(host=base_url, access_token=api_key)
|
||||
self._api_client = hindsight_client_api.ApiClient(config)
|
||||
if api_key:
|
||||
self._api_client.set_default_header("Authorization", f"Bearer {api_key}")
|
||||
self._memory_api = memory_api.MemoryApi(self._api_client)
|
||||
self._banks_api = banks_api.BanksApi(self._api_client)
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ from hindsight_client_api.models.reflect_request import ReflectRequest
|
||||
from hindsight_client_api.models.reflect_response import ReflectResponse
|
||||
from hindsight_client_api.models.retain_request import RetainRequest
|
||||
from hindsight_client_api.models.retain_response import RetainResponse
|
||||
from hindsight_client_api.models.token_usage import TokenUsage
|
||||
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
|
||||
from hindsight_client_api.models.validation_error import ValidationError
|
||||
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
|
||||
|
||||
@@ -347,6 +347,7 @@ class MemoryApi:
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
type: Optional[StrictStr] = None,
|
||||
limit: Optional[StrictInt] = None,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
@@ -363,12 +364,14 @@ class MemoryApi:
|
||||
) -> GraphDataResponse:
|
||||
"""Get memory graph data
|
||||
|
||||
Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). Limited to 1000 most recent items.
|
||||
Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion).
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param type:
|
||||
:type type: str
|
||||
:param limit:
|
||||
:type limit: int
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
@@ -396,6 +399,7 @@ class MemoryApi:
|
||||
_param = self._get_graph_serialize(
|
||||
bank_id=bank_id,
|
||||
type=type,
|
||||
limit=limit,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
@@ -423,6 +427,7 @@ class MemoryApi:
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
type: Optional[StrictStr] = None,
|
||||
limit: Optional[StrictInt] = None,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
@@ -439,12 +444,14 @@ class MemoryApi:
|
||||
) -> ApiResponse[GraphDataResponse]:
|
||||
"""Get memory graph data
|
||||
|
||||
Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). Limited to 1000 most recent items.
|
||||
Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion).
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param type:
|
||||
:type type: str
|
||||
:param limit:
|
||||
:type limit: int
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
@@ -472,6 +479,7 @@ class MemoryApi:
|
||||
_param = self._get_graph_serialize(
|
||||
bank_id=bank_id,
|
||||
type=type,
|
||||
limit=limit,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
@@ -499,6 +507,7 @@ class MemoryApi:
|
||||
self,
|
||||
bank_id: StrictStr,
|
||||
type: Optional[StrictStr] = None,
|
||||
limit: Optional[StrictInt] = None,
|
||||
authorization: Optional[StrictStr] = None,
|
||||
_request_timeout: Union[
|
||||
None,
|
||||
@@ -515,12 +524,14 @@ class MemoryApi:
|
||||
) -> RESTResponseType:
|
||||
"""Get memory graph data
|
||||
|
||||
Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). Limited to 1000 most recent items.
|
||||
Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion).
|
||||
|
||||
:param bank_id: (required)
|
||||
:type bank_id: str
|
||||
:param type:
|
||||
:type type: str
|
||||
:param limit:
|
||||
:type limit: int
|
||||
:param authorization:
|
||||
:type authorization: str
|
||||
:param _request_timeout: timeout setting for this request. If one
|
||||
@@ -548,6 +559,7 @@ class MemoryApi:
|
||||
_param = self._get_graph_serialize(
|
||||
bank_id=bank_id,
|
||||
type=type,
|
||||
limit=limit,
|
||||
authorization=authorization,
|
||||
_request_auth=_request_auth,
|
||||
_content_type=_content_type,
|
||||
@@ -570,6 +582,7 @@ class MemoryApi:
|
||||
self,
|
||||
bank_id,
|
||||
type,
|
||||
limit,
|
||||
authorization,
|
||||
_request_auth,
|
||||
_content_type,
|
||||
@@ -599,6 +612,10 @@ class MemoryApi:
|
||||
|
||||
_query_params.append(('type', type))
|
||||
|
||||
if limit is not None:
|
||||
|
||||
_query_params.append(('limit', limit))
|
||||
|
||||
# process the header parameters
|
||||
if authorization is not None:
|
||||
_header_params['authorization'] = authorization
|
||||
|
||||
@@ -54,6 +54,7 @@ from hindsight_client_api.models.reflect_request import ReflectRequest
|
||||
from hindsight_client_api.models.reflect_response import ReflectResponse
|
||||
from hindsight_client_api.models.retain_request import RetainRequest
|
||||
from hindsight_client_api.models.retain_response import RetainResponse
|
||||
from hindsight_client_api.models.token_usage import TokenUsage
|
||||
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
|
||||
from hindsight_client_api.models.validation_error import ValidationError
|
||||
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
|
||||
|
||||
@@ -30,7 +30,8 @@ class GraphDataResponse(BaseModel):
|
||||
edges: List[Dict[str, Any]]
|
||||
table_rows: List[Dict[str, Any]]
|
||||
total_units: StrictInt
|
||||
__properties: ClassVar[List[str]] = ["nodes", "edges", "table_rows", "total_units"]
|
||||
limit: StrictInt
|
||||
__properties: ClassVar[List[str]] = ["nodes", "edges", "table_rows", "total_units", "limit"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -86,7 +87,8 @@ class GraphDataResponse(BaseModel):
|
||||
"nodes": obj.get("nodes"),
|
||||
"edges": obj.get("edges"),
|
||||
"table_rows": obj.get("table_rows"),
|
||||
"total_units": obj.get("total_units")
|
||||
"total_units": obj.get("total_units"),
|
||||
"limit": obj.get("limit")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import json
|
||||
from pydantic import BaseModel, ConfigDict, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from hindsight_client_api.models.reflect_fact import ReflectFact
|
||||
from hindsight_client_api.models.token_usage import TokenUsage
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
@@ -30,7 +31,8 @@ class ReflectResponse(BaseModel):
|
||||
text: StrictStr
|
||||
based_on: Optional[List[ReflectFact]] = None
|
||||
structured_output: Optional[Dict[str, Any]] = None
|
||||
__properties: ClassVar[List[str]] = ["text", "based_on", "structured_output"]
|
||||
usage: Optional[TokenUsage] = None
|
||||
__properties: ClassVar[List[str]] = ["text", "based_on", "structured_output", "usage"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -78,11 +80,19 @@ class ReflectResponse(BaseModel):
|
||||
if _item_based_on:
|
||||
_items.append(_item_based_on.to_dict())
|
||||
_dict['based_on'] = _items
|
||||
# override the default output from pydantic by calling `to_dict()` of usage
|
||||
if self.usage:
|
||||
_dict['usage'] = self.usage.to_dict()
|
||||
# set to None if structured_output (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.structured_output is None and "structured_output" in self.model_fields_set:
|
||||
_dict['structured_output'] = None
|
||||
|
||||
# set to None if usage (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.usage is None and "usage" in self.model_fields_set:
|
||||
_dict['usage'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
@@ -97,7 +107,8 @@ class ReflectResponse(BaseModel):
|
||||
_obj = cls.model_validate({
|
||||
"text": obj.get("text"),
|
||||
"based_on": [ReflectFact.from_dict(_item) for _item in obj["based_on"]] if obj.get("based_on") is not None else None,
|
||||
"structured_output": obj.get("structured_output")
|
||||
"structured_output": obj.get("structured_output"),
|
||||
"usage": TokenUsage.from_dict(obj["usage"]) if obj.get("usage") is not None else None
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from hindsight_client_api.models.token_usage import TokenUsage
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
@@ -30,7 +31,8 @@ class RetainResponse(BaseModel):
|
||||
bank_id: StrictStr
|
||||
items_count: StrictInt
|
||||
var_async: StrictBool = Field(description="Whether the operation was processed asynchronously", alias="async")
|
||||
__properties: ClassVar[List[str]] = ["success", "bank_id", "items_count", "async"]
|
||||
usage: Optional[TokenUsage] = None
|
||||
__properties: ClassVar[List[str]] = ["success", "bank_id", "items_count", "async", "usage"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -71,6 +73,14 @@ class RetainResponse(BaseModel):
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# override the default output from pydantic by calling `to_dict()` of usage
|
||||
if self.usage:
|
||||
_dict['usage'] = self.usage.to_dict()
|
||||
# set to None if usage (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.usage is None and "usage" in self.model_fields_set:
|
||||
_dict['usage'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
@@ -86,7 +96,8 @@ class RetainResponse(BaseModel):
|
||||
"success": obj.get("success"),
|
||||
"bank_id": obj.get("bank_id"),
|
||||
"items_count": obj.get("items_count"),
|
||||
"async": obj.get("async")
|
||||
"async": obj.get("async"),
|
||||
"usage": TokenUsage.from_dict(obj["usage"]) if obj.get("usage") is not None else None
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictInt
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class TokenUsage(BaseModel):
|
||||
"""
|
||||
Token usage metrics for LLM calls. Tracks input/output tokens for a single request to enable per-request cost tracking and monitoring.
|
||||
""" # noqa: E501
|
||||
input_tokens: Optional[StrictInt] = Field(default=0, description="Number of input/prompt tokens consumed")
|
||||
output_tokens: Optional[StrictInt] = Field(default=0, description="Number of output/completion tokens generated")
|
||||
total_tokens: Optional[StrictInt] = Field(default=0, description="Total tokens (input + output)")
|
||||
__properties: ClassVar[List[str]] = ["input_tokens", "output_tokens", "total_tokens"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of TokenUsage from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of TokenUsage from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"input_tokens": obj.get("input_tokens") if obj.get("input_tokens") is not None else 0,
|
||||
"output_tokens": obj.get("output_tokens") if obj.get("output_tokens") is not None else 0,
|
||||
"total_tokens": obj.get("total_tokens") if obj.get("total_tokens") is not None else 0
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
Generated
+14
-14
@@ -284,9 +284,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.12"
|
||||
version = "0.4.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386"
|
||||
checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -840,9 +840,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.104"
|
||||
version = "1.0.105"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9695f8df41bb4f3d222c95a67532365f569318332d03d5f3f67f37b20e6ebdf0"
|
||||
checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -915,9 +915,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.42"
|
||||
version = "1.0.43"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f"
|
||||
checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
@@ -1048,9 +1048,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.35"
|
||||
version = "0.23.36"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f"
|
||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
@@ -1208,9 +1208,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.148"
|
||||
version = "1.0.149"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3084b546a1dd6289475996f182a22aba973866ea8e8b02c51d9f46b1336a22da"
|
||||
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
@@ -1641,9 +1641,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.7"
|
||||
version = "2.5.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b"
|
||||
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"idna",
|
||||
@@ -2102,6 +2102,6 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.10"
|
||||
version = "1.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30e0d8dffbae3d840f64bda38e28391faef673a7b5a6017840f2a106c8145868"
|
||||
checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 80
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export const metricsEndpointMetricsGet = <ThrowOnError extends boolean = false>(
|
||||
/**
|
||||
* Get memory graph data
|
||||
*
|
||||
* Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). Limited to 1000 most recent items.
|
||||
* Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion).
|
||||
*/
|
||||
export const getGraph = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetGraphData, ThrowOnError>,
|
||||
|
||||
@@ -570,6 +570,10 @@ export type GraphDataResponse = {
|
||||
* Total Units
|
||||
*/
|
||||
total_units: number;
|
||||
/**
|
||||
* Limit
|
||||
*/
|
||||
limit: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -966,6 +970,10 @@ export type ReflectResponse = {
|
||||
structured_output?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
/**
|
||||
* Token usage metrics for LLM calls during reflection.
|
||||
*/
|
||||
usage?: TokenUsage | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1010,6 +1018,39 @@ export type RetainResponse = {
|
||||
* Whether the operation was processed asynchronously
|
||||
*/
|
||||
async: boolean;
|
||||
/**
|
||||
* Token usage metrics for LLM calls during fact extraction (only present for synchronous operations)
|
||||
*/
|
||||
usage?: TokenUsage | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* TokenUsage
|
||||
*
|
||||
* Token usage metrics for LLM calls.
|
||||
*
|
||||
* Tracks input/output tokens for a single request to enable
|
||||
* per-request cost tracking and monitoring.
|
||||
*/
|
||||
export type TokenUsage = {
|
||||
/**
|
||||
* Input Tokens
|
||||
*
|
||||
* Number of input/prompt tokens consumed
|
||||
*/
|
||||
input_tokens?: number;
|
||||
/**
|
||||
* Output Tokens
|
||||
*
|
||||
* Number of output/completion tokens generated
|
||||
*/
|
||||
output_tokens?: number;
|
||||
/**
|
||||
* Total Tokens
|
||||
*
|
||||
* Total tokens (input + output)
|
||||
*/
|
||||
total_tokens?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1086,6 +1127,10 @@ export type GetGraphData = {
|
||||
* Type
|
||||
*/
|
||||
type?: string | null;
|
||||
/**
|
||||
* Limit
|
||||
*/
|
||||
limit?: number;
|
||||
};
|
||||
url: "/v1/default/banks/{bank_id}/graph";
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"directory": "hindsight-clients/typescript"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "^0.88.0",
|
||||
"@hey-api/openapi-ts": "0.88.0",
|
||||
"@types/jest": "^29.0.0",
|
||||
"@types/node": "^20.0.0",
|
||||
"jest": "^29.0.0",
|
||||
|
||||
@@ -12,12 +12,15 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// Get optional query parameters
|
||||
const type = searchParams.get("type") || searchParams.get("fact_type") || undefined;
|
||||
const limitParam = searchParams.get("limit");
|
||||
const limit = limitParam ? parseInt(limitParam, 10) : undefined;
|
||||
|
||||
const response = await sdk.getGraph({
|
||||
client: lowLevelClient,
|
||||
path: { bank_id: bankId },
|
||||
query: {
|
||||
type: type,
|
||||
limit: limit,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -52,6 +52,9 @@ export function DataView({ factType }: DataViewProps) {
|
||||
const [selectedTableMemory, setSelectedTableMemory] = useState<any>(null);
|
||||
const itemsPerPage = 100;
|
||||
|
||||
// Fetch limit state - how many memories to load from the API
|
||||
const [fetchLimit, setFetchLimit] = useState(1000);
|
||||
|
||||
// Graph controls state
|
||||
const [showLabels, setShowLabels] = useState(true);
|
||||
const [maxNodes, setMaxNodes] = useState<number | undefined>(undefined);
|
||||
@@ -93,7 +96,7 @@ export function DataView({ factType }: DataViewProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const loadData = async () => {
|
||||
const loadData = async (limit?: number) => {
|
||||
if (!currentBank) return;
|
||||
|
||||
setLoading(true);
|
||||
@@ -101,6 +104,7 @@ export function DataView({ factType }: DataViewProps) {
|
||||
const graphData: any = await client.getGraph({
|
||||
bank_id: currentBank,
|
||||
type: factType,
|
||||
limit: limit ?? fetchLimit,
|
||||
});
|
||||
setData(graphData);
|
||||
} catch (error) {
|
||||
@@ -265,9 +269,25 @@ export function DataView({ factType }: DataViewProps) {
|
||||
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{searchQuery
|
||||
? `${filteredTableRows.length} of ${data.total_units} memories`
|
||||
: `${data.total_units} total memories`}
|
||||
{searchQuery ? (
|
||||
`${filteredTableRows.length} of ${data.table_rows?.length ?? 0} loaded memories`
|
||||
) : data.table_rows?.length < data.total_units ? (
|
||||
<span>
|
||||
Showing {data.table_rows?.length ?? 0} of {data.total_units} total memories
|
||||
<button
|
||||
onClick={() => {
|
||||
const newLimit = Math.min(data.total_units, fetchLimit + 1000);
|
||||
setFetchLimit(newLimit);
|
||||
loadData(newLimit);
|
||||
}}
|
||||
className="ml-2 text-primary hover:underline"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
`${data.total_units} total memories`
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 bg-muted rounded-lg p-1">
|
||||
<button
|
||||
|
||||
@@ -109,10 +109,11 @@ export class ControlPlaneClient {
|
||||
/**
|
||||
* Get graph data
|
||||
*/
|
||||
async getGraph(params: { bank_id: string; type?: string }) {
|
||||
async getGraph(params: { bank_id: string; type?: string; limit?: number }) {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append("bank_id", params.bank_id);
|
||||
if (params.type) queryParams.append("type", params.type);
|
||||
if (params.limit) queryParams.append("limit", params.limit.toString());
|
||||
return this.fetchApi(`/api/graph?${queryParams}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ This changelog highlights user-facing changes only. Internal maintenance, CI/CD,
|
||||
|
||||
For full release details, see [GitHub Releases](https://github.com/vectorize-io/hindsight/releases).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
**Features**
|
||||
|
||||
- Add per-request token usage tracking to retain and reflect endpoints for cost monitoring and billing integration.
|
||||
|
||||
## [0.2.0](https://github.com/vectorize-io/hindsight/releases/tag/v0.2.0)
|
||||
|
||||
**Features**
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# Admin CLI
|
||||
|
||||
The `hindsight-admin` CLI provides administrative commands for managing your Hindsight deployment, including database migrations, backup, and restore operations.
|
||||
|
||||
## Installation
|
||||
|
||||
The admin CLI is included with the `hindsight-api` package:
|
||||
|
||||
```bash
|
||||
pip install hindsight-api
|
||||
# or
|
||||
uv add hindsight-api
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### run-db-migration
|
||||
|
||||
Run database migrations to the latest version. This is useful when you want to run migrations separately from API startup (e.g., in CI/CD pipelines or before deploying a new version).
|
||||
|
||||
```bash
|
||||
hindsight-admin run-db-migration [OPTIONS]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `--schema`, `-s` | Database schema to run migrations on | `public` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Run migrations on the default public schema
|
||||
hindsight-admin run-db-migration
|
||||
|
||||
# Run migrations on a specific tenant schema
|
||||
hindsight-admin run-db-migration --schema tenant_acme
|
||||
```
|
||||
|
||||
:::tip Disabling Auto-Migrations
|
||||
To disable automatic migrations on API startup, set `HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP=false`. This is useful when you want to run migrations as a separate step in your deployment pipeline.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
### backup
|
||||
|
||||
Create a backup of all Hindsight data to a zip file.
|
||||
|
||||
```bash
|
||||
hindsight-admin backup OUTPUT [OPTIONS]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `OUTPUT` | Output file path (will add `.zip` extension if not present) |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `--schema`, `-s` | Database schema to backup | `public` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Backup to a file
|
||||
hindsight-admin backup /backups/hindsight-2024-01-15.zip
|
||||
|
||||
# Backup a specific tenant schema
|
||||
hindsight-admin backup /backups/tenant-acme.zip --schema tenant_acme
|
||||
```
|
||||
|
||||
The backup includes:
|
||||
- Memory banks and their configuration
|
||||
- Documents and chunks
|
||||
- Entities and their relationships
|
||||
- Memory units (facts, experiences, opinions, observations)
|
||||
- Entity cooccurrences and memory links
|
||||
|
||||
:::note Consistency
|
||||
Backups are created within a database transaction with `REPEATABLE READ` isolation, ensuring a consistent snapshot across all tables.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
### restore
|
||||
|
||||
Restore data from a backup file. **Warning: This deletes all existing data in the target schema.**
|
||||
|
||||
```bash
|
||||
hindsight-admin restore INPUT [OPTIONS]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `INPUT` | Input backup file (.zip) |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `--schema`, `-s` | Database schema to restore to | `public` |
|
||||
| `--yes`, `-y` | Skip confirmation prompt | `false` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Restore with confirmation prompt
|
||||
hindsight-admin restore /backups/hindsight-2024-01-15.zip
|
||||
|
||||
# Restore without confirmation (for scripts)
|
||||
hindsight-admin restore /backups/hindsight-2024-01-15.zip --yes
|
||||
|
||||
# Restore to a specific tenant schema
|
||||
hindsight-admin restore /backups/tenant-acme.zip --schema tenant_acme --yes
|
||||
```
|
||||
|
||||
:::warning Data Loss
|
||||
Restore will **delete all existing data** in the target schema before importing the backup. Always verify you have a recent backup before performing a restore.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The admin CLI uses the same environment variables as the API service. The most important one is:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_DATABASE_URL` | PostgreSQL connection string | `pg0` (embedded) |
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
# Use a specific database
|
||||
export HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@localhost:5432/hindsight
|
||||
hindsight-admin backup /backups/mybackup.zip
|
||||
```
|
||||
|
||||
@@ -55,6 +55,20 @@ Make sure you've completed the [Quick Start](./quickstart) to install the client
|
||||
| `max_tokens` | int | 4096 | Maximum tokens for the response |
|
||||
| `response_schema` | object | None | JSON Schema for [structured output](#structured-output) |
|
||||
|
||||
### Response Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `text` | string | The generated answer text |
|
||||
| `based_on` | array | Facts used to generate the response |
|
||||
| `structured_output` | object | Parsed structured output (when `response_schema` provided) |
|
||||
| `usage` | TokenUsage | Token usage metrics for the LLM call |
|
||||
|
||||
The `usage` field contains:
|
||||
- `input_tokens`: Number of input/prompt tokens consumed
|
||||
- `output_tokens`: Number of output/completion tokens generated
|
||||
- `total_tokens`: Sum of input and output tokens
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="python" label="Python">
|
||||
<CodeSnippet code={reflectPy} section="reflect-with-params" language="python" />
|
||||
|
||||
@@ -66,6 +66,25 @@ Always provide context and event dates for optimal memory extraction:
|
||||
|
||||
The `timestamp` defaults to the current time if not specified. Providing explicit timestamps enables temporal queries like "What happened last spring?"
|
||||
|
||||
### Response Fields
|
||||
|
||||
The retain response includes:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `success` | bool | Whether the operation succeeded |
|
||||
| `bank_id` | string | The memory bank ID |
|
||||
| `items_count` | int | Number of items processed |
|
||||
| `async` | bool | Whether processed asynchronously |
|
||||
| `usage` | TokenUsage | Token usage metrics for LLM calls (synchronous only) |
|
||||
|
||||
The `usage` field contains token metrics for cost tracking:
|
||||
- `input_tokens`: Tokens consumed by prompts
|
||||
- `output_tokens`: Tokens generated by the LLM
|
||||
- `total_tokens`: Sum of input and output tokens
|
||||
|
||||
Note: `usage` is only present for synchronous operations. Async operations (`async: true`) do not return usage metrics.
|
||||
|
||||
## Batch Ingestion
|
||||
|
||||
Store multiple items in a single request. **Batch ingestion is the recommended approach** as it significantly improves performance by reducing network overhead and allowing Hindsight to optimize the memory extraction process across related content.
|
||||
|
||||
@@ -20,9 +20,18 @@ The API service handles all memory operations (retain, recall, reflect).
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_DATABASE_URL` | PostgreSQL connection string | `pg0` (embedded) |
|
||||
| `HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP` | Run database migrations on API startup | `true` |
|
||||
|
||||
If not provided, the server uses embedded `pg0` — convenient for development but not recommended for production.
|
||||
|
||||
To run migrations manually (e.g., before starting the API), use the admin CLI:
|
||||
|
||||
```bash
|
||||
hindsight-admin run-db-migration
|
||||
# Or for a specific schema:
|
||||
hindsight-admin run-db-migration --schema tenant_acme
|
||||
```
|
||||
|
||||
### LLM Provider
|
||||
|
||||
| Variable | Description | Default |
|
||||
@@ -81,11 +90,13 @@ export HINDSIGHT_API_LLM_MODEL=your-model-name
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_EMBEDDINGS_PROVIDER` | Provider: `local`, `tei`, or `openai` | `local` |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_PROVIDER` | Provider: `local`, `tei`, `openai`, or `cohere` | `local` |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL` | Model for local provider | `BAAI/bge-small-en-v1.5` |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_TEI_URL` | TEI server URL | - |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY` | OpenAI API key (falls back to `HINDSIGHT_API_LLM_API_KEY`) | - |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL` | OpenAI embedding model | `text-embedding-3-small` |
|
||||
| `HINDSIGHT_API_COHERE_API_KEY` | Cohere API key (shared for embeddings and reranker) | - |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL` | Cohere embedding model | `embed-english-v3.0` |
|
||||
|
||||
```bash
|
||||
# Local (default) - uses SentenceTransformers
|
||||
@@ -100,6 +111,11 @@ export HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL=text-embedding-3-small # 1536 dime
|
||||
# TEI - HuggingFace Text Embeddings Inference (recommended for production)
|
||||
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=tei
|
||||
export HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
|
||||
|
||||
# Cohere - cloud-based embeddings
|
||||
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=cohere
|
||||
export HINDSIGHT_API_COHERE_API_KEY=your-api-key
|
||||
export HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL=embed-english-v3.0 # 1024 dimensions
|
||||
```
|
||||
|
||||
#### Embedding Dimensions
|
||||
@@ -122,9 +138,10 @@ Supported OpenAI embedding dimensions:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_RERANKER_PROVIDER` | Provider: `local` or `tei` | `local` |
|
||||
| `HINDSIGHT_API_RERANKER_PROVIDER` | Provider: `local`, `tei`, or `cohere` | `local` |
|
||||
| `HINDSIGHT_API_RERANKER_LOCAL_MODEL` | Model for local provider | `cross-encoder/ms-marco-MiniLM-L-6-v2` |
|
||||
| `HINDSIGHT_API_RERANKER_TEI_URL` | TEI server URL | - |
|
||||
| `HINDSIGHT_API_RERANKER_COHERE_MODEL` | Cohere rerank model | `rerank-english-v3.0` |
|
||||
|
||||
```bash
|
||||
# Local (default) - uses SentenceTransformers CrossEncoder
|
||||
@@ -134,6 +151,11 @@ export HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
|
||||
# TEI - for high-performance inference
|
||||
export HINDSIGHT_API_RERANKER_PROVIDER=tei
|
||||
export HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
|
||||
|
||||
# Cohere - cloud-based reranking
|
||||
export HINDSIGHT_API_RERANKER_PROVIDER=cohere
|
||||
export HINDSIGHT_API_COHERE_API_KEY=your-api-key # shared with embeddings
|
||||
export HINDSIGHT_API_RERANKER_COHERE_MODEL=rerank-english-v3.0
|
||||
```
|
||||
|
||||
### Server
|
||||
@@ -183,6 +205,14 @@ Controls when the system generates entity observations (summaries about entities
|
||||
| `HINDSIGHT_API_OBSERVATION_MIN_FACTS` | Minimum facts about an entity before generating observations | `5` |
|
||||
| `HINDSIGHT_API_OBSERVATION_TOP_ENTITIES` | Max entities to process per retain batch | `5` |
|
||||
|
||||
### Retain
|
||||
|
||||
Controls the retain (memory ingestion) pipeline.
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS` | Max completion tokens for fact extraction LLM calls | `64000` |
|
||||
|
||||
### Local MCP Server
|
||||
|
||||
Configuration for the local MCP server (`hindsight-local-mcp` command).
|
||||
|
||||
@@ -12,17 +12,51 @@ curl http://localhost:8888/metrics
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| `hindsight.operation.duration` | Histogram | operation, bank_id, budget, max_tokens, success | Duration of operations in seconds |
|
||||
| `hindsight.operation.total` | Counter | operation, bank_id, budget, max_tokens, success | Total number of operations executed |
|
||||
| `hindsight.operation.duration` | Histogram | operation, bank_id, source, budget, max_tokens, success | Duration of operations in seconds |
|
||||
| `hindsight.operation.total` | Counter | operation, bank_id, source, budget, max_tokens, success | Total number of operations executed |
|
||||
|
||||
The `operation` label values are: `retain`, `recall`, `reflect`.
|
||||
**Labels:**
|
||||
- `operation`: Operation type (`retain`, `recall`, `reflect`)
|
||||
- `bank_id`: Memory bank identifier
|
||||
- `source`: Where the operation was triggered from (`api`, `reflect`, `internal`)
|
||||
- `budget`: Budget level if specified (`low`, `mid`, `high`)
|
||||
- `max_tokens`: Max tokens if specified
|
||||
- `success`: Whether the operation succeeded (`true`, `false`)
|
||||
|
||||
### Token Metrics
|
||||
The `source` label allows distinguishing between:
|
||||
- `api`: Direct API calls from clients
|
||||
- `reflect`: Internal recall calls made during reflect operations
|
||||
- `internal`: Other internal operations
|
||||
|
||||
### LLM Metrics
|
||||
|
||||
| Metric | Type | Labels | Description |
|
||||
|--------|------|--------|-------------|
|
||||
| `hindsight.tokens.input` | Counter | operation, bank_id, budget, max_tokens | Input tokens consumed |
|
||||
| `hindsight.tokens.output` | Counter | operation, bank_id, budget, max_tokens | Output tokens generated |
|
||||
| `hindsight.llm.duration` | Histogram | provider, model, scope, success | Duration of LLM API calls in seconds |
|
||||
| `hindsight.llm.calls.total` | Counter | provider, model, scope, success | Total number of LLM API calls |
|
||||
| `hindsight.llm.tokens.input` | Counter | provider, model, scope, success, token_bucket | Input tokens for LLM calls |
|
||||
| `hindsight.llm.tokens.output` | Counter | provider, model, scope, success, token_bucket | Output tokens from LLM calls |
|
||||
|
||||
**Labels:**
|
||||
- `provider`: LLM provider (`openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio`)
|
||||
- `model`: Model name (e.g., `gpt-4`, `claude-3-sonnet`)
|
||||
- `scope`: What the LLM call is for (`memory`, `reflect`, `entity_observation`, `answer`)
|
||||
- `success`: Whether the call succeeded (`true`, `false`)
|
||||
- `token_bucket`: Token count bucket for cardinality control (`0-100`, `100-500`, `500-1k`, `1k-5k`, `5k-10k`, `10k-50k`, `50k+`)
|
||||
|
||||
### Histogram Buckets
|
||||
|
||||
Custom bucket boundaries are configured for better percentile accuracy:
|
||||
|
||||
**Operation Duration Buckets (seconds):**
|
||||
```
|
||||
0.1, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 5.0, 7.5, 10.0, 15.0, 20.0, 30.0, 60.0, 120.0
|
||||
```
|
||||
|
||||
**LLM Duration Buckets (seconds):**
|
||||
```
|
||||
0.1, 0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 15.0, 30.0, 60.0, 120.0
|
||||
```
|
||||
|
||||
## Prometheus Configuration
|
||||
|
||||
@@ -32,3 +66,30 @@ scrape_configs:
|
||||
static_configs:
|
||||
- targets: ['localhost:8888']
|
||||
```
|
||||
|
||||
## Example Queries
|
||||
|
||||
### Average operation latency by type
|
||||
```promql
|
||||
rate(hindsight_operation_duration_sum[5m]) / rate(hindsight_operation_duration_count[5m])
|
||||
```
|
||||
|
||||
### LLM calls per minute by provider
|
||||
```promql
|
||||
rate(hindsight_llm_calls_total[1m]) * 60
|
||||
```
|
||||
|
||||
### P95 LLM latency
|
||||
```promql
|
||||
histogram_quantile(0.95, rate(hindsight_llm_duration_bucket[5m]))
|
||||
```
|
||||
|
||||
### Total tokens consumed by model
|
||||
```promql
|
||||
sum by (model) (hindsight_llm_tokens_input_total + hindsight_llm_tokens_output_total)
|
||||
```
|
||||
|
||||
### Internal vs API recall operations
|
||||
```promql
|
||||
sum by (source) (rate(hindsight_operation_total{operation="recall"}[5m]))
|
||||
```
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
---
|
||||
|
||||
# Multilingual Support
|
||||
|
||||
Hindsight automatically detects the language of your input and responds in the same language. This means facts, entities, and reflections are preserved in their original language without translation to English.
|
||||
|
||||
## How It Works
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Chinese Input] --> B[Language Detection]
|
||||
B --> C[Extract Facts in Chinese]
|
||||
C --> D[Chinese Entities]
|
||||
D --> E[Chinese Response]
|
||||
```
|
||||
|
||||
When you retain content or reflect on a query, Hindsight:
|
||||
|
||||
1. **Detects the input language** automatically from the content
|
||||
2. **Extracts facts in the original language** - preserving nuance and meaning
|
||||
3. **Stores entities in their native script** - 张伟 stays 张伟, not "Zhang Wei"
|
||||
4. **Responds in the same language** - queries in Chinese get Chinese answers
|
||||
|
||||
---
|
||||
|
||||
## Retain with Non-English Content
|
||||
|
||||
When you retain content in any language, Hindsight extracts and stores facts in that same language.
|
||||
|
||||
### Example: Chinese Content
|
||||
|
||||
```python
|
||||
from hindsight import Hindsight
|
||||
|
||||
hindsight = Hindsight()
|
||||
|
||||
# Retain Chinese content
|
||||
hindsight.retain(
|
||||
bank_id="user-123",
|
||||
content="""
|
||||
张伟是一位资深软件工程师,在腾讯工作了五年。
|
||||
他专门研究分布式系统,并领导了公司微服务架构的开发。
|
||||
""",
|
||||
context="团队概述"
|
||||
)
|
||||
|
||||
# Query in Chinese - get Chinese results
|
||||
results = hindsight.recall(
|
||||
bank_id="user-123",
|
||||
query="告诉我关于张伟的信息"
|
||||
)
|
||||
|
||||
# Facts are returned in Chinese:
|
||||
# - 张伟是一位资深软件工程师,在腾讯工作了五年
|
||||
# - 张伟专门研究分布式系统,并领导了公司微服务架构的开发
|
||||
```
|
||||
|
||||
### Example: Japanese Content
|
||||
|
||||
```python
|
||||
hindsight.retain(
|
||||
bank_id="user-123",
|
||||
content="""
|
||||
田中さんはソフトウェアエンジニアで、東京のスタートアップで働いています。
|
||||
彼女はPythonとTypeScriptが得意で、毎日コードレビューをしています。
|
||||
""",
|
||||
context="チームプロフィール"
|
||||
)
|
||||
|
||||
# Query in Japanese
|
||||
results = hindsight.recall(
|
||||
bank_id="user-123",
|
||||
query="田中さんについて教えてください"
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reflect with Non-English Queries
|
||||
|
||||
The `reflect` operation also respects the input language, generating thoughtful responses in the same language as the query.
|
||||
|
||||
### Example: Chinese Reflection
|
||||
|
||||
```python
|
||||
# Store facts about team members (in Chinese)
|
||||
hindsight.retain(
|
||||
bank_id="team-eval",
|
||||
content="张伟是一位优秀的软件工程师,完成了五个重大项目。他总是按时交付,代码整洁有良好的文档。",
|
||||
context="绩效评估"
|
||||
)
|
||||
|
||||
hindsight.retain(
|
||||
bank_id="team-eval",
|
||||
content="李明最近加入团队。他错过了第一个截止日期,代码有很多bug。",
|
||||
context="绩效评估"
|
||||
)
|
||||
|
||||
# Reflect in Chinese
|
||||
result = hindsight.reflect(
|
||||
bank_id="team-eval",
|
||||
query="谁是更可靠的工程师?"
|
||||
)
|
||||
|
||||
# Response is in Chinese:
|
||||
# "我认为张伟更可靠。张伟完成了五个重大项目,按时交付,代码质量高..."
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mixed Language Content
|
||||
|
||||
Hindsight handles mixed-language content gracefully, preserving both languages where appropriate.
|
||||
|
||||
### Example: Chinese Text with English Company Names
|
||||
|
||||
```python
|
||||
hindsight.retain(
|
||||
bank_id="user-123",
|
||||
content="""
|
||||
王芳在Google北京办公室工作,她是一名高级产品经理。
|
||||
之前她在Microsoft和Amazon工作过。
|
||||
她负责管理YouTube在中国市场的推广策略。
|
||||
""",
|
||||
context="员工资料"
|
||||
)
|
||||
|
||||
# Facts preserve both languages:
|
||||
# - 王芳在Google北京办公室工作,担任高级产品经理
|
||||
# - 王芳曾在Microsoft和Amazon工作过
|
||||
# - 王芳负责管理YouTube在中国市场的推广策略
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Supported Languages
|
||||
|
||||
**Hindsight's multilingual support depends entirely on your LLM's language capabilities.** Hindsight instructs the LLM to detect the input language and respond in that same language. If your LLM supports a language, Hindsight will work with it.
|
||||
|
||||
Most modern LLMs (GPT-4, Claude, Gemini, Llama 3, etc.) support dozens of languages including:
|
||||
|
||||
- **East Asian**: Chinese (Simplified/Traditional), Japanese, Korean
|
||||
- **European**: Spanish, French, German, Italian, Portuguese, Dutch, Polish, Russian
|
||||
- **Middle Eastern**: Arabic, Hebrew, Turkish
|
||||
- **South Asian**: Hindi, Bengali, Tamil
|
||||
- **Southeast Asian**: Thai, Vietnamese, Indonesian
|
||||
|
||||
**To verify support for your target language**, test your LLM directly with content in that language. If the LLM can understand and generate text in the language, Hindsight will preserve it correctly.
|
||||
|
||||
---
|
||||
|
||||
## Configuring for Multilingual Use
|
||||
|
||||
For optimal multilingual performance, you should configure all three components of the pipeline:
|
||||
|
||||
### 1. LLM (Required)
|
||||
Your LLM must support the target languages. Most modern LLMs do, but verify with your specific model.
|
||||
|
||||
### 2. Embedding Model (Recommended)
|
||||
The default embedding model (`BAAI/bge-small-en-v1.5`) is **English-only**. For multilingual content, use a multilingual embedding model:
|
||||
|
||||
```bash
|
||||
# In your .env file
|
||||
HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-m3
|
||||
```
|
||||
|
||||
**Recommended multilingual embedding models:**
|
||||
| Model | Languages | Notes |
|
||||
|-------|-----------|-------|
|
||||
| `BAAI/bge-m3` | 100+ | Best overall multilingual performance |
|
||||
| `intfloat/multilingual-e5-large` | 100+ | Good alternative |
|
||||
| `sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2` | 50+ | Lighter weight |
|
||||
|
||||
### 3. Reranker Model (Recommended)
|
||||
The default reranker (`cross-encoder/ms-marco-MiniLM-L-6-v2`) is **English-only**. For multilingual content, use a multilingual reranker:
|
||||
|
||||
```bash
|
||||
# In your .env file
|
||||
HINDSIGHT_API_RERANKER_LOCAL_MODEL=BAAI/bge-reranker-v2-m3
|
||||
```
|
||||
|
||||
**Recommended multilingual reranker models:**
|
||||
| Model | Languages | Notes |
|
||||
|-------|-----------|-------|
|
||||
| `BAAI/bge-reranker-v2-m3` | 100+ | Best multilingual reranking |
|
||||
| `cross-encoder/mmarco-mMiniLMv2-L12-H384-v1` | 14 | Lighter alternative |
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Multilingual Models for Non-English Content
|
||||
If you primarily work with non-English content, configure multilingual embedding and reranker models. English-only models will still store your content correctly, but semantic search quality will be degraded.
|
||||
|
||||
### 2. Keep Content in One Language Per Retain Call
|
||||
While mixed content works, keeping each `retain` call in a single language produces more consistent results.
|
||||
|
||||
### 3. Query in the Same Language as Your Content
|
||||
For best results, query using the same language as your stored content. Cross-language queries (e.g., English query for Chinese content) may work but results can vary depending on your embedding model.
|
||||
|
||||
---
|
||||
|
||||
## Technical Details
|
||||
|
||||
Multilingual support is implemented through LLM prompt instructions rather than external language detection libraries. This approach:
|
||||
|
||||
- **Requires no additional dependencies**
|
||||
- **Works with any LLM** that supports multiple languages
|
||||
- **Handles edge cases** like mixed-language content naturally
|
||||
- **Preserves semantic meaning** better than rule-based translation
|
||||
|
||||
The LLM is instructed to:
|
||||
1. Detect the input language
|
||||
2. Extract all facts, entities, and descriptions in that same language
|
||||
3. Never translate to English unless the input is in English
|
||||
@@ -27,6 +27,11 @@ const sidebars: SidebarsConfig = {
|
||||
id: 'developer/reflect',
|
||||
label: 'Reflect',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
id: 'developer/multilingual',
|
||||
label: 'Multilingual',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
id: 'developer/performance',
|
||||
@@ -111,6 +116,11 @@ const sidebars: SidebarsConfig = {
|
||||
id: 'developer/configuration',
|
||||
label: 'Configuration',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
id: 'developer/admin-cli',
|
||||
label: 'Admin CLI',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
id: 'developer/extensions',
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
"Memory"
|
||||
],
|
||||
"summary": "Get memory graph data",
|
||||
"description": "Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). Limited to 1000 most recent items.",
|
||||
"description": "Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion).",
|
||||
"operationId": "get_graph",
|
||||
"parameters": [
|
||||
{
|
||||
@@ -87,6 +87,16 @@
|
||||
"title": "Type"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"default": 1000,
|
||||
"title": "Limit"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "authorization",
|
||||
"in": "header",
|
||||
@@ -2504,6 +2514,10 @@
|
||||
"total_units": {
|
||||
"type": "integer",
|
||||
"title": "Total Units"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"title": "Limit"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -2511,7 +2525,8 @@
|
||||
"nodes",
|
||||
"edges",
|
||||
"table_rows",
|
||||
"total_units"
|
||||
"total_units",
|
||||
"limit"
|
||||
],
|
||||
"title": "GraphDataResponse",
|
||||
"description": "Response model for graph data endpoint.",
|
||||
@@ -2524,6 +2539,7 @@
|
||||
"weight": 0.8
|
||||
}
|
||||
],
|
||||
"limit": 1000,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "1",
|
||||
@@ -3404,6 +3420,17 @@
|
||||
],
|
||||
"title": "Structured Output",
|
||||
"description": "Structured output parsed according to the request's response_schema. Only present when response_schema was provided in the request."
|
||||
},
|
||||
"usage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TokenUsage"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Token usage metrics for LLM calls during reflection."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -3432,7 +3459,12 @@
|
||||
],
|
||||
"summary": "AI is transformative"
|
||||
},
|
||||
"text": "Based on my understanding, AI is a transformative technology..."
|
||||
"text": "Based on my understanding, AI is a transformative technology...",
|
||||
"usage": {
|
||||
"input_tokens": 1500,
|
||||
"output_tokens": 500,
|
||||
"total_tokens": 2000
|
||||
}
|
||||
}
|
||||
},
|
||||
"RetainRequest": {
|
||||
@@ -3491,6 +3523,17 @@
|
||||
"type": "boolean",
|
||||
"title": "Async",
|
||||
"description": "Whether the operation was processed asynchronously"
|
||||
},
|
||||
"usage": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/TokenUsage"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Token usage metrics for LLM calls during fact extraction (only present for synchronous operations)"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -3506,7 +3549,42 @@
|
||||
"async": false,
|
||||
"bank_id": "user123",
|
||||
"items_count": 2,
|
||||
"success": true
|
||||
"success": true,
|
||||
"usage": {
|
||||
"input_tokens": 500,
|
||||
"output_tokens": 100,
|
||||
"total_tokens": 600
|
||||
}
|
||||
}
|
||||
},
|
||||
"TokenUsage": {
|
||||
"properties": {
|
||||
"input_tokens": {
|
||||
"type": "integer",
|
||||
"title": "Input Tokens",
|
||||
"description": "Number of input/prompt tokens consumed",
|
||||
"default": 0
|
||||
},
|
||||
"output_tokens": {
|
||||
"type": "integer",
|
||||
"title": "Output Tokens",
|
||||
"description": "Number of output/completion tokens generated",
|
||||
"default": 0
|
||||
},
|
||||
"total_tokens": {
|
||||
"type": "integer",
|
||||
"title": "Total Tokens",
|
||||
"description": "Total tokens (input + output)",
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "TokenUsage",
|
||||
"description": "Token usage metrics for LLM calls.\n\nTracks input/output tokens for a single request to enable\nper-request cost tracking and monitoring.",
|
||||
"example": {
|
||||
"input_tokens": 1500,
|
||||
"output_tokens": 500,
|
||||
"total_tokens": 2000
|
||||
}
|
||||
},
|
||||
"UpdateDispositionRequest": {
|
||||
|
||||
Generated
+5842
-898
File diff suppressed because it is too large
Load Diff
@@ -8,5 +8,8 @@
|
||||
],
|
||||
"scripts": {
|
||||
"prepare": "./scripts/setup-hooks.sh"
|
||||
},
|
||||
"overrides": {
|
||||
"qs": "^6.14.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,16 +51,16 @@ echo "=================================================="
|
||||
|
||||
RUST_CLIENT_DIR="$CLIENTS_DIR/rust"
|
||||
|
||||
# Clean old generated files
|
||||
# Clean old generated files (keep Cargo.lock for reproducible builds)
|
||||
echo "Cleaning old Rust generated code..."
|
||||
rm -rf "$RUST_CLIENT_DIR/target"
|
||||
rm -f "$RUST_CLIENT_DIR/Cargo.lock"
|
||||
|
||||
# Trigger regeneration by building
|
||||
# Use --locked to ensure reproducible builds from committed Cargo.lock
|
||||
echo "Regenerating Rust client (via build.rs)..."
|
||||
cd "$RUST_CLIENT_DIR"
|
||||
cargo clean
|
||||
cargo build --release
|
||||
cargo build --release --locked
|
||||
|
||||
echo "✓ Rust client generated at $RUST_CLIENT_DIR"
|
||||
echo ""
|
||||
@@ -324,9 +324,11 @@ rm -rf "$TYPESCRIPT_CLIENT_DIR/services"
|
||||
rm -f "$TYPESCRIPT_CLIENT_DIR/index.ts"
|
||||
|
||||
# Generate new client using @hey-api/openapi-ts
|
||||
# Use npm run generate to use the locally installed version (pinned in package.json)
|
||||
# instead of npx --yes which would fetch the latest version
|
||||
echo "Generating from $OPENAPI_SPEC..."
|
||||
cd "$TYPESCRIPT_CLIENT_DIR"
|
||||
npx --yes @hey-api/openapi-ts
|
||||
npm run generate
|
||||
|
||||
echo "✓ TypeScript client generated at $TYPESCRIPT_CLIENT_DIR"
|
||||
echo ""
|
||||
|
||||
@@ -593,6 +593,25 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cohere"
|
||||
version = "5.20.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "fastavro" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "requests" },
|
||||
{ name = "tokenizers" },
|
||||
{ name = "types-requests" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4b/ed/bb02083654bdc089ae4ef1cd7691fd2233f1fd9f32bcbfacc80ff57d9775/cohere-5.20.1.tar.gz", hash = "sha256:50973f63d2c6138ff52ce37d8d6f78ccc539af4e8c43865e960d68e0bf835b6f", size = 180820 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/e3/94eb11ac3ebaaa3a6afb5d2ff23db95d58bc468ae538c388edf49f2f20b5/cohere-5.20.1-py3-none-any.whl", hash = "sha256:d230fd13d95ba92ae927fce3dd497599b169883afc7954fe29b39fb8d5df5fc7", size = 318973 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -836,6 +855,47 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/79/7f5a5e5513e6a737e5fb089d9c59c74d4d24dc24d581d3aa519b326bedda/fastapi_cloud_cli-0.3.1-py3-none-any.whl", hash = "sha256:7d1a98a77791a9d0757886b2ffbf11bcc6b3be93210dd15064be10b216bf7e00", size = 19711 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastavro"
|
||||
version = "1.12.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/65/8b/fa2d3287fd2267be6261d0177c6809a7fa12c5600ddb33490c8dc29e77b2/fastavro-1.12.1.tar.gz", hash = "sha256:2f285be49e45bc047ab2f6bed040bb349da85db3f3c87880e4b92595ea093b2b", size = 1025661 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/e9/31c64b47cefc0951099e7c0c8c8ea1c931edd1350f34d55c27cbfbb08df1/fastavro-1.12.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6b632b713bc5d03928a87d811fa4a11d5f25cd43e79c161e291c7d3f7aa740fd", size = 1016585 },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/76/111560775b548f5d8d828c1b5285ff90e2d2745643fb80ecbf115344eea4/fastavro-1.12.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa7ab3769beadcebb60f0539054c7755f63bd9cf7666e2c15e615ab605f89a8", size = 3404629 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/07/6bb93cb963932146c2b6c5c765903a0a547ad9f0f8b769a4a9aad8c06369/fastavro-1.12.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123fb221df3164abd93f2d042c82f538a1d5a43ce41375f12c91ce1355a9141e", size = 3428594 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/67/8115ec36b584197ea737ec79e3499e1f1b640b288d6c6ee295edd13b80f6/fastavro-1.12.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:632a4e3ff223f834ddb746baae0cc7cee1068eb12c32e4d982c2fee8a5b483d0", size = 3344145 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/9e/a7cebb3af967e62539539897c10138fa0821668ec92525d1be88a9cd3ee6/fastavro-1.12.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e6caf4e7a8717d932a3b1ff31595ad169289bbe1128a216be070d3a8391671", size = 3431942 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/d1/7774ddfb8781c5224294c01a593ebce2ad3289b948061c9701bd1903264d/fastavro-1.12.1-cp311-cp311-win_amd64.whl", hash = "sha256:b91a0fe5a173679a6c02d53ca22dcaad0a2c726b74507e0c1c2e71a7c3f79ef9", size = 450542 },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f0/10bd1a3d08667fa0739e2b451fe90e06df575ec8b8ba5d3135c70555c9bd/fastavro-1.12.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:509818cb24b98a804fc80be9c5fed90f660310ae3d59382fc811bfa187122167", size = 1009057 },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/ad/0d985bc99e1fa9e74c636658000ba38a5cd7f5ab2708e9c62eaf736ecf1a/fastavro-1.12.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:089e155c0c76e0d418d7e79144ce000524dd345eab3bc1e9c5ae69d500f71b14", size = 3391866 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/9e/b4951dc84ebc34aac69afcbfbb22ea4a91080422ec2bfd2c06076ff1d419/fastavro-1.12.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44cbff7518901c91a82aab476fcab13d102e4999499df219d481b9e15f61af34", size = 3458005 },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/f8/5a8df450a9f55ca8441f22ea0351d8c77809fc121498b6970daaaf667a21/fastavro-1.12.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a275e48df0b1701bb764b18a8a21900b24cf882263cb03d35ecdba636bbc830b", size = 3295258 },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/b2/40f25299111d737e58b85696e91138a66c25b7334f5357e7ac2b0e8966f8/fastavro-1.12.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2de72d786eb38be6b16d556b27232b1bf1b2797ea09599507938cdb7a9fe3e7c", size = 3430328 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/07/85157a7c57c5f8b95507d7829b5946561e5ee656ff80e9dd9a757f53ddaf/fastavro-1.12.1-cp312-cp312-win_amd64.whl", hash = "sha256:9090f0dee63fe022ee9cc5147483366cc4171c821644c22da020d6b48f576b4f", size = 444140 },
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/57/26d5efef9182392d5ac9f253953c856ccb66e4c549fd3176a1e94efb05c9/fastavro-1.12.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:78df838351e4dff9edd10a1c41d1324131ffecbadefb9c297d612ef5363c049a", size = 1000599 },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/cb/8ab55b21d018178eb126007a56bde14fd01c0afc11d20b5f2624fe01e698/fastavro-1.12.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:780476c23175d2ae457c52f45b9ffa9d504593499a36cd3c1929662bf5b7b14b", size = 3335933 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/03/9c94ec9bf873eb1ffb0aa694f4e71940154e6e9728ddfdc46046d7e8ced4/fastavro-1.12.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0714b285160fcd515eb0455540f40dd6dac93bdeacdb03f24e8eac3d8aa51f8d", size = 3402066 },
|
||||
{ url = "https://files.pythonhosted.org/packages/75/c8/cb472347c5a584ccb8777a649ebb28278fccea39d005fc7df19996f41df8/fastavro-1.12.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a8bc2dcec5843d499f2489bfe0747999108f78c5b29295d877379f1972a3d41a", size = 3240038 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/77/569ce9474c40304b3a09e109494e020462b83e405545b78069ddba5f614e/fastavro-1.12.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3b1921ac35f3d89090a5816b626cf46e67dbecf3f054131f84d56b4e70496f45", size = 3369398 },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/1f/9589e35e9ea68035385db7bdbf500d36b8891db474063fb1ccc8215ee37c/fastavro-1.12.1-cp313-cp313-win_amd64.whl", hash = "sha256:5aa777b8ee595b50aa084104cd70670bf25a7bbb9fd8bb5d07524b0785ee1699", size = 444220 },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/d2/78435fe737df94bd8db2234b2100f5453737cffd29adee2504a2b013de84/fastavro-1.12.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c3d67c47f177e486640404a56f2f50b165fe892cc343ac3a34673b80cc7f1dd6", size = 1086611 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/be/428f99b10157230ddac77ec8cc167005b29e2bd5cbe228345192bb645f30/fastavro-1.12.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5217f773492bac43dae15ff2931432bce2d7a80be7039685a78d3fab7df910bd", size = 3541001 },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/08/a2eea4f20b85897740efe44887e1ac08f30dfa4bfc3de8962bdcbb21a5a1/fastavro-1.12.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:469fecb25cba07f2e1bfa4c8d008477cd6b5b34a59d48715e1b1a73f6160097d", size = 3432217 },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/bb/b4c620b9eb6e9838c7f7e4b7be0762834443adf9daeb252a214e9ad3178c/fastavro-1.12.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d71c8aa841ef65cfab709a22bb887955f42934bced3ddb571e98fdbdade4c609", size = 3366742 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/d1/e69534ccdd5368350646fea7d93be39e5f77c614cca825c990bd9ca58f67/fastavro-1.12.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b81fc04e85dfccf7c028e0580c606e33aa8472370b767ef058aae2c674a90746", size = 3383743 },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/54/b7b4a0c3fb5fcba38128542da1b26c4e6d69933c923f493548bdfd63ab6a/fastavro-1.12.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:9445da127751ba65975d8e4bdabf36bfcfdad70fc35b2d988e3950cce0ec0e7c", size = 1001377 },
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/4f/0e589089c7df0d8f57d7e5293fdc34efec9a3b758a0d4d0c99a7937e2492/fastavro-1.12.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ed924233272719b5d5a6a0b4d80ef3345fc7e84fc7a382b6232192a9112d38a6", size = 3320401 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/19/260110d56194ae29d7e423a336fccea8bcd103196d00f0b364b732bdb84e/fastavro-1.12.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3616e2f0e1c9265e92954fa099db79c6e7817356d3ff34f4bcc92699ae99697c", size = 3350894 },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/96/58b0411e8be9694d5972bee3167d6c1fd1fdfdf7ce253c1a19a327208f4f/fastavro-1.12.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:cb0337b42fd3c047fcf0e9b7597bd6ad25868de719f29da81eabb6343f08d399", size = 3229644 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/db/38660660eac82c30471d9101f45b3acfdcbadfe42d8f7cdb129459a45050/fastavro-1.12.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:64961ab15b74b7c168717bbece5660e0f3d457837c3cc9d9145181d011199fa7", size = 3329704 },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/a9/1672910f458ecb30b596c9e59e41b7c00309b602a0494341451e92e62747/fastavro-1.12.1-cp314-cp314-win_amd64.whl", hash = "sha256:792356d320f6e757e89f7ac9c22f481e546c886454a6709247f43c0dd7058004", size = 452911 },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8d/2e15d0938ded1891b33eff252e8500605508b799c2e57188a933f0bd744c/fastavro-1.12.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120aaf82ac19d60a1016afe410935fe94728752d9c2d684e267e5b7f0e70f6d9", size = 3541999 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/1c/6dfd082a205be4510543221b734b1191299e6a1810c452b6bc76dfa6968e/fastavro-1.12.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6a3462934b20a74f9ece1daa49c2e4e749bd9a35fa2657b53bf62898fba80f5", size = 3433972 },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/90/9de694625a1a4b727b1ad0958d220cab25a9b6cf7f16a5c7faa9ea7b2261/fastavro-1.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1f81011d54dd47b12437b51dd93a70a9aa17b61307abf26542fc3c13efbc6c51", size = 3368752 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/93/b44f67589e4d439913dab6720f7e3507b0fa8b8e56d06f6fc875ced26afb/fastavro-1.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43ded16b3f4a9f1a42f5970c2aa618acb23ea59c4fcaa06680bdf470b255e5a8", size = 3386636 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastcore"
|
||||
version = "1.8.16"
|
||||
@@ -1191,6 +1251,7 @@ dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "anthropic" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "cohere" },
|
||||
{ name = "dateparser" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "fastmcp" },
|
||||
@@ -1215,6 +1276,7 @@ dependencies = [
|
||||
{ name = "tiktoken" },
|
||||
{ name = "torch" },
|
||||
{ name = "transformers" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn" },
|
||||
{ name = "wsproto" },
|
||||
]
|
||||
@@ -1245,6 +1307,7 @@ requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.17.1" },
|
||||
{ name = "anthropic", specifier = ">=0.40.0" },
|
||||
{ name = "asyncpg", specifier = ">=0.29.0" },
|
||||
{ name = "cohere", specifier = ">=5.0.0" },
|
||||
{ name = "dateparser", specifier = ">=1.2.2" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.120.3" },
|
||||
{ name = "fastmcp", specifier = ">=2.3.0" },
|
||||
@@ -1274,6 +1337,7 @@ requires-dist = [
|
||||
{ name = "tiktoken", specifier = ">=0.12.0" },
|
||||
{ name = "torch", specifier = ">=2.0.0" },
|
||||
{ name = "transformers", specifier = ">=4.30.0,<4.46.0" },
|
||||
{ name = "typer", specifier = ">=0.9.0" },
|
||||
{ name = "uvicorn", specifier = ">=0.38.0" },
|
||||
{ name = "wsproto", specifier = ">=1.0.0" },
|
||||
]
|
||||
@@ -4365,6 +4429,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/64/7713ffe4b5983314e9d436a90d5bd4f63b6054e2aca783a3cfc44cb95bbf/typer-0.20.0-py3-none-any.whl", hash = "sha256:5b463df6793ec1dca6213a3cf4c0f03bc6e322ac5e16e13ddd622a889489784a", size = 47028 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-requests"
|
||||
version = "2.32.4.20260107"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/f3/a0663907082280664d745929205a89d41dffb29e89a50f753af7d57d0a96/types_requests-2.32.4.20260107.tar.gz", hash = "sha256:018a11ac158f801bfa84857ddec1650750e393df8a004a8a9ae2a9bec6fcb24f", size = 23165 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/12/709ea261f2bf91ef0a26a9eed20f2623227a8ed85610c1e54c5805692ecb/types_requests-2.32.4.20260107-py3-none-any.whl", hash = "sha256:b703fe72f8ce5b31ef031264fe9395cac8f46a04661a79f7ed31a80fb308730d", size = 20676 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
|
||||
Reference in New Issue
Block a user