Compare commits
20
Commits
build-ci
...
traceability
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c7bdae665 | ||
|
|
0e329d3236 | ||
|
|
f817039786 | ||
|
|
f166741975 | ||
|
|
23f21fbe6a | ||
|
|
39da7fd2cd | ||
|
|
9036cb4cd5 | ||
|
|
521fa49b35 | ||
|
|
51ffe25748 | ||
|
|
4b30b3a315 | ||
|
|
6797cdb79f | ||
|
|
b0a9a5b8c5 | ||
|
|
58e996a250 | ||
|
|
0fe75e42b8 | ||
|
|
9943957fb7 | ||
|
|
03f47e29c8 | ||
|
|
1240b82629 | ||
|
|
08f1cda3bf | ||
|
|
a3a9d7b37d | ||
|
|
c2607d7699 |
@@ -50,3 +50,18 @@ HINDSIGHT_API_LOG_LEVEL=info
|
||||
# HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
|
||||
# For TEI provider:
|
||||
# HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
|
||||
|
||||
# Observability & Tracing (Optional - disabled by default)
|
||||
# Enable OpenTelemetry tracing for LLM calls (GenAI semantic conventions)
|
||||
# HINDSIGHT_API_OTEL_TRACES_ENABLED=true
|
||||
#
|
||||
# Local development with Grafana LGTM stack (recommended - see scripts/dev/grafana/README.md)
|
||||
# HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
#
|
||||
# Cloud backends (Grafana Cloud, Langfuse, DataDog, etc.)
|
||||
# HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=https://your-backend-url
|
||||
# HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer your-token"
|
||||
#
|
||||
# Custom service name and environment (optional, defaults: hindsight-api, development)
|
||||
# HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production
|
||||
# HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production
|
||||
|
||||
@@ -334,8 +334,9 @@ jobs:
|
||||
push: false
|
||||
load: ${{ matrix.variant == 'slim' }}
|
||||
tags: hindsight-${{ matrix.name }}:test
|
||||
cache-from: type=gha,scope=${{ matrix.name }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.name }}
|
||||
# Removed GitHub Actions cache (type=gha) - it frequently returns 502 errors
|
||||
# causing buildx to fail with "failed to parse error response 502"
|
||||
# Build will be slower but more reliable
|
||||
|
||||
# Only test slim variants to save disk space (they're much smaller)
|
||||
# Slim variants require external embedding providers
|
||||
|
||||
@@ -45,6 +45,7 @@ cd hindsight-control-plane && npm run dev
|
||||
./scripts/dev/start-docs.sh
|
||||
```
|
||||
|
||||
|
||||
### Generating Clients/OpenAPI
|
||||
```bash
|
||||
# Regenerate OpenAPI spec after API changes (REQUIRED after changing endpoints)
|
||||
|
||||
@@ -48,40 +48,35 @@ If you need more control over how and when your agent stores and recalls memorie
|
||||
### Docker (recommended)
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=your-key
|
||||
export OPENAI_API_KEY=sk-xxx
|
||||
|
||||
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
|
||||
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
|
||||
-e HINDSIGHT_API_LLM_MODEL=o3-mini \
|
||||
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
```
|
||||
|
||||
>API: http://localhost:8888
|
||||
>UI: http://localhost:9999
|
||||
|
||||
You can modify the LLM provider by setting `HINDSIGHT_API_LLM_PROVIDER`. Valid options are `openai`, `anthropic`, `gemini`, `groq`, `ollama`, and `lmstudio`. The documentation provides more details on [supported models](https://hindsight.vectorize.io/developer/models).
|
||||
|
||||
### Docker compose
|
||||
|
||||
|
||||
### Docker (external PostgreSQL)
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-xxx
|
||||
export HINDSIGHT_DB_PASSWORD=choose-a-password
|
||||
cd docker/docker-compose
|
||||
|
||||
# edit the docker compose file with your favorite editor
|
||||
nano docker-compose.yaml
|
||||
|
||||
# start hindsight with an external PostgeSQL
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
```bash
|
||||
# stop and cleanup the pg volume with the optional parameter -v
|
||||
docker compose down -v
|
||||
docker compose up
|
||||
```
|
||||
|
||||
|
||||
>API: http://localhost:8888
|
||||
>UI: http://localhost:9999
|
||||
|
||||
API: http://localhost:8888
|
||||
UI: http://localhost:9999
|
||||
|
||||
Install client:
|
||||
### Client
|
||||
|
||||
```bash
|
||||
pip install hindsight-client -U
|
||||
@@ -89,7 +84,7 @@ pip install hindsight-client -U
|
||||
npm install @vectorize-io/hindsight-client
|
||||
```
|
||||
|
||||
Python example:
|
||||
#### Python
|
||||
|
||||
```python
|
||||
from hindsight_client import Hindsight
|
||||
@@ -106,7 +101,29 @@ client.recall(bank_id="my-bank", query="What does Alice do?")
|
||||
client.reflect(bank_id="my-bank", query="Tell me about Alice")
|
||||
```
|
||||
|
||||
### Python (embedded, no Docker)
|
||||
#### Node.js / TypeScript
|
||||
|
||||
```bash
|
||||
npm install @vectorize-io/hindsight-client
|
||||
```
|
||||
|
||||
```javascript
|
||||
const { HindsightClient } = require('@vectorize-io/hindsight-client');
|
||||
|
||||
const main = async () => {
|
||||
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
|
||||
|
||||
await client.retain('my-bank', 'Alice loves hiking in Yosemite');
|
||||
|
||||
const results = await client.recall('my-bank', 'What does Alice like?');
|
||||
console.log(results);
|
||||
}
|
||||
|
||||
main();
|
||||
```
|
||||
|
||||
|
||||
### Python Embedded (no server required)
|
||||
|
||||
```bash
|
||||
pip install hindsight-all -U
|
||||
@@ -126,26 +143,6 @@ with HindsightServer(
|
||||
results = client.recall(bank_id="my-bank", query="Where does Alice work?")
|
||||
```
|
||||
|
||||
### Node.js / TypeScript
|
||||
|
||||
```bash
|
||||
npm install @vectorize-io/hindsight-client
|
||||
```
|
||||
|
||||
```javascript
|
||||
const { HindsightClient } = require('@vectorize-io/hindsight-client');
|
||||
|
||||
const example = async () => {
|
||||
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
|
||||
|
||||
await client.retain('my-bank', 'Alice loves hiking in Yosemite');
|
||||
|
||||
const results = await client.recall('my-bank', 'What does Alice like?');
|
||||
console.log(results);
|
||||
}
|
||||
|
||||
example();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,135 +0,0 @@
|
||||
# Docker Testing
|
||||
|
||||
Scripts for testing Hindsight Docker images locally and in CI.
|
||||
|
||||
## Scripts
|
||||
|
||||
### `test-image.sh`
|
||||
|
||||
General-purpose Docker image test script. Starts a container and verifies it becomes healthy.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
./docker/test-image.sh <image> [target]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
- `image` - Docker image to test (e.g., `hindsight:test`, `ghcr.io/vectorize-io/hindsight:latest`)
|
||||
- `target` - Optional: `cp-only` for control plane, `api-only` for API, or `standalone` (default)
|
||||
|
||||
**Environment Variables:**
|
||||
- `GROQ_API_KEY` - Required for API/standalone images
|
||||
- `HINDSIGHT_API_LLM_PROVIDER` - LLM provider (default: `groq`)
|
||||
- `HINDSIGHT_API_LLM_MODEL` - LLM model (default: `llama-3.3-70b-versatile`)
|
||||
- `HINDSIGHT_API_EMBEDDINGS_PROVIDER` - Embeddings provider (for slim images)
|
||||
- `HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY` - OpenAI API key for embeddings
|
||||
- `HINDSIGHT_API_RERANKER_PROVIDER` - Reranker provider (for slim images)
|
||||
- `HINDSIGHT_API_COHERE_API_KEY` - Cohere API key for reranking
|
||||
- `SMOKE_TEST_TIMEOUT` - Timeout in seconds (default: 120)
|
||||
|
||||
**Examples:**
|
||||
|
||||
Test a full image (with local ML models):
|
||||
```bash
|
||||
export GROQ_API_KEY=gsk_xxx
|
||||
./docker/test-image.sh hindsight:test
|
||||
```
|
||||
|
||||
Test a slim image (with external providers):
|
||||
```bash
|
||||
export GROQ_API_KEY=gsk_xxx
|
||||
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
|
||||
export HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxx
|
||||
export HINDSIGHT_API_RERANKER_PROVIDER=cohere
|
||||
export HINDSIGHT_API_COHERE_API_KEY=xxx
|
||||
./docker/test-image.sh hindsight-slim:test
|
||||
```
|
||||
|
||||
### `test-slim-local.sh`
|
||||
|
||||
Convenience wrapper for testing slim images locally. Automatically configures external providers.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
# Set API keys
|
||||
export GROQ_API_KEY=gsk_xxx
|
||||
export OPENAI_API_KEY=sk-xxx
|
||||
export COHERE_API_KEY=xxx
|
||||
|
||||
# Run test
|
||||
./docker/test-slim-local.sh [image]
|
||||
```
|
||||
|
||||
**Or inline:**
|
||||
```bash
|
||||
GROQ_API_KEY=gsk_xxx \
|
||||
OPENAI_API_KEY=sk-xxx \
|
||||
COHERE_API_KEY=xxx \
|
||||
./docker/test-slim-local.sh hindsight-slim:test
|
||||
```
|
||||
|
||||
This script:
|
||||
- ✅ Validates API keys are set
|
||||
- ✅ Configures OpenAI embeddings automatically
|
||||
- ✅ Configures Cohere reranking automatically
|
||||
- ✅ Calls `test-image.sh` with the right configuration
|
||||
|
||||
## Building and Testing Locally
|
||||
|
||||
### Build a slim image
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg INCLUDE_LOCAL_MODELS=false \
|
||||
--build-arg PRELOAD_ML_MODELS=false \
|
||||
--target standalone \
|
||||
-t hindsight-slim:test \
|
||||
-f docker/standalone/Dockerfile \
|
||||
.
|
||||
```
|
||||
|
||||
### Test the slim image
|
||||
|
||||
```bash
|
||||
# With API keys
|
||||
export GROQ_API_KEY=gsk_xxx
|
||||
export OPENAI_API_KEY=sk-xxx
|
||||
export COHERE_API_KEY=xxx
|
||||
|
||||
# Run test
|
||||
./docker/test-slim-local.sh hindsight-slim:test
|
||||
```
|
||||
|
||||
## Expected Output
|
||||
|
||||
**Successful test:**
|
||||
```
|
||||
Starting smoke test for: hindsight-slim:test
|
||||
Target: standalone
|
||||
Health endpoint: http://localhost:8888/health
|
||||
Timeout: 120s
|
||||
|
||||
Starting container...
|
||||
Waiting for health endpoint at http://localhost:8888/health...
|
||||
Still waiting... (10s)
|
||||
Still waiting... (20s)
|
||||
|
||||
Container is healthy after 25s
|
||||
|
||||
=== Health Response ===
|
||||
{
|
||||
"status": "healthy",
|
||||
"database": "connected"
|
||||
}
|
||||
|
||||
Smoke test PASSED
|
||||
```
|
||||
|
||||
## CI Integration
|
||||
|
||||
These scripts are used in CI to validate Docker images on every PR:
|
||||
|
||||
- `.github/workflows/test.yml` - Runs `test-image.sh` for slim variants with OpenAI/Cohere
|
||||
- `.github/workflows/release.yml` - Can optionally run smoke tests during release
|
||||
|
||||
See the workflows for the exact configuration.
|
||||
@@ -35,44 +35,12 @@ services:
|
||||
hindsight:
|
||||
image: ghcr.io/vectorize-io/hindsight:${HINDSIGHT_VERSION:-latest}
|
||||
container_name: hindsight-app
|
||||
pull_policy: always
|
||||
ports:
|
||||
- "8888:8888"
|
||||
- "9999:9999"
|
||||
environment:
|
||||
# LLM-configuration for Grog
|
||||
# - HINDSIGHT_API_LLM_PROVIDER=groq
|
||||
# - HINDSIGHT_API_LLM_API_KEY=${GROG_API_KEY?Please set the GROG_API_KEY env variable}
|
||||
# - HINDSIGHT_API_LLM_MODEL=${HINDSIGHT_API_LLM_MODEL:-openai/gpt-oss-20b}
|
||||
|
||||
# LLM-configuration for OpenAI
|
||||
# - HINDSIGHT_API_LLM_PROVIDER=openai
|
||||
# - HINDSIGHT_API_LLM_API_KEY=${OPENAI_API_KEY?Please set the OPENAI_API_KEY env variable}
|
||||
# - HINDSIGHT_API_LLM_MODEL=${HINDSIGHT_API_LLM_MODEL:-gpt-4o}
|
||||
|
||||
# Gemini
|
||||
# - HINDSIGHT_API_LLM_PROVIDER=gemini
|
||||
# - HINDSIGHT_API_LLM_API_KEY=${GEMINI_API_KEY?Please set the GEMINI_API_KEY env variable}
|
||||
# - HINDSIGHT_API_LLM_MODEL=${HINDSIGHT_API_LLM_MODEL:-gemini-2.0-flash}
|
||||
|
||||
# Anthropic
|
||||
# - HINDSIGHT_API_LLM_PROVIDER=anthropic
|
||||
# - HINDSIGHT_API_LLM_API_KEY=${ANTHROPIC_API_KEY?Please set the ANTHROPIC_API_KEY env variable}
|
||||
# - HINDSIGHT_API_LLM_MODEL=${HINDSIGHT_API_LLM_MODEL:-claude-sonnet-4-20250514}
|
||||
|
||||
# LLM-configuration for Ollama (local, no API key)
|
||||
# - HINDSIGHT_API_LLM_PROVIDER=ollama
|
||||
# - HINDSIGHT_API_LLM_BASE_URL=${HINDSIGHT_API_LLM_BASE_URL:-http://127.0.0.1:11434/v1}
|
||||
# - HINDSIGHT_API_LLM_MODEL=${HINDSIGHT_API_LLM_MODEL:-llama3.2}
|
||||
|
||||
|
||||
# Configuration for the external Postgres database
|
||||
- HINDSIGHT_API_LLM_API_KEY=${OPENAI_API_KEY?Please set the OPENAI_API_KEY env variable}
|
||||
- HINDSIGHT_API_DATABASE_URL=postgresql://${HINDSIGHT_DB_USER:-hindsight_user}:${HINDSIGHT_DB_PASSWORD:?Please set the HINDSIGHT_DB_PASSWORD env variable}@db:5432/${HINDSIGHT_DB_NAME:-hindsight_db}
|
||||
# use public schema, otherwise the app start fails (2026-02-06)
|
||||
- HINDSIGHT_API_DATABASE_SCHEMA=public
|
||||
|
||||
# disable if you don't want automatic migrations on startup
|
||||
- HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP=true
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: hindsight
|
||||
description: Hindsight helm chart
|
||||
type: application
|
||||
version: 0.4.9
|
||||
appVersion: "0.4.9"
|
||||
version: 0.4.10
|
||||
appVersion: "0.4.10"
|
||||
keywords:
|
||||
- ai
|
||||
- memory
|
||||
|
||||
@@ -33,7 +33,7 @@ spec:
|
||||
- name: api
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 10 }}
|
||||
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Values.version }}"
|
||||
image: "{{ .Values.api.image.repository }}:{{ .Values.api.image.tag | default .Values.version | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.api.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
@@ -60,6 +60,9 @@ spec:
|
||||
- name: HINDSIGHT_API_WORKER_ENABLED
|
||||
value: "false"
|
||||
{{- end }}
|
||||
{{- /* Explicitly set port to override K8s service discovery env var (HINDSIGHT_API_PORT) */}}
|
||||
- name: HINDSIGHT_API_PORT
|
||||
value: {{ .Values.api.service.targetPort | quote }}
|
||||
{{- range $key, $value := .Values.api.env }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
@@ -84,7 +87,7 @@ spec:
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
{{- with (.Values.api.affinity | default .Values.affinity) }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -33,7 +33,7 @@ spec:
|
||||
- name: control-plane
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 10 }}
|
||||
image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.controlPlane.image.tag | default .Values.version }}"
|
||||
image: "{{ .Values.controlPlane.image.repository }}:{{ .Values.controlPlane.image.tag | default .Values.version | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.controlPlane.image.pullPolicy }}
|
||||
ports:
|
||||
- name: http
|
||||
@@ -71,7 +71,7 @@ spec:
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
{{- with (.Values.controlPlane.affinity | default .Values.affinity) }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
{{- if and .Values.api.enabled .Values.api.podDisruptionBudget.enabled }}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "hindsight.fullname" . }}-api
|
||||
labels:
|
||||
{{- include "hindsight.api.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if .Values.api.podDisruptionBudget.minAvailable }}
|
||||
minAvailable: {{ .Values.api.podDisruptionBudget.minAvailable }}
|
||||
{{- end }}
|
||||
{{- if .Values.api.podDisruptionBudget.maxUnavailable }}
|
||||
maxUnavailable: {{ .Values.api.podDisruptionBudget.maxUnavailable }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "hindsight.api.selectorLabels" . | nindent 6 }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and .Values.controlPlane.enabled .Values.controlPlane.podDisruptionBudget.enabled }}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "hindsight.fullname" . }}-control-plane
|
||||
labels:
|
||||
{{- include "hindsight.controlPlane.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if .Values.controlPlane.podDisruptionBudget.minAvailable }}
|
||||
minAvailable: {{ .Values.controlPlane.podDisruptionBudget.minAvailable }}
|
||||
{{- end }}
|
||||
{{- if .Values.controlPlane.podDisruptionBudget.maxUnavailable }}
|
||||
maxUnavailable: {{ .Values.controlPlane.podDisruptionBudget.maxUnavailable }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "hindsight.controlPlane.selectorLabels" . | nindent 6 }}
|
||||
{{- end }}
|
||||
---
|
||||
{{- if and .Values.worker.enabled .Values.worker.podDisruptionBudget.enabled }}
|
||||
apiVersion: policy/v1
|
||||
kind: PodDisruptionBudget
|
||||
metadata:
|
||||
name: {{ include "hindsight.fullname" . }}-worker
|
||||
labels:
|
||||
{{- include "hindsight.worker.labels" . | nindent 4 }}
|
||||
spec:
|
||||
{{- if .Values.worker.podDisruptionBudget.minAvailable }}
|
||||
minAvailable: {{ .Values.worker.podDisruptionBudget.minAvailable }}
|
||||
{{- end }}
|
||||
{{- if .Values.worker.podDisruptionBudget.maxUnavailable }}
|
||||
maxUnavailable: {{ .Values.worker.podDisruptionBudget.maxUnavailable }}
|
||||
{{- end }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "hindsight.worker.selectorLabels" . | nindent 6 }}
|
||||
{{- end }}
|
||||
@@ -32,7 +32,7 @@ spec:
|
||||
- name: worker
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 10 }}
|
||||
image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag | default .Values.version }}"
|
||||
image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag | default .Values.version | default .Chart.AppVersion }}"
|
||||
imagePullPolicy: {{ .Values.worker.image.pullPolicy }}
|
||||
command: ["hindsight-worker"]
|
||||
ports:
|
||||
@@ -99,7 +99,7 @@ spec:
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
{{- with (.Values.worker.affinity | default .Values.affinity) }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
# Default values for hindsight
|
||||
|
||||
# Chart version - use this to set a consistent image tag across all components
|
||||
version: "0.1.1"
|
||||
# Global version override - use this to set a consistent image tag across all components
|
||||
# If not set, defaults to Chart.appVersion from Chart.yaml
|
||||
# version: ""
|
||||
|
||||
# Use an existing secret instead of creating one from values
|
||||
# When set, all keys from this secret are injected as environment variables via envFrom
|
||||
@@ -57,6 +58,15 @@ api:
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
# Pod disruption budget
|
||||
podDisruptionBudget:
|
||||
enabled: false
|
||||
minAvailable: 1
|
||||
# maxUnavailable: 1
|
||||
|
||||
# Pod affinity/anti-affinity (overrides global affinity for this component)
|
||||
# affinity: {}
|
||||
|
||||
# Environment variables
|
||||
env:
|
||||
#HINDSIGHT_API_LLM_PROVIDER: "groq"
|
||||
@@ -75,7 +85,7 @@ worker:
|
||||
image:
|
||||
repository: ghcr.io/vectorize-io/hindsight-api
|
||||
pullPolicy: IfNotPresent
|
||||
# tag defaults to .Values.version if not specified
|
||||
# tag: "" # defaults to .Values.version, then Chart.appVersion if not specified
|
||||
|
||||
service:
|
||||
# Service for metrics scraping (headless for StatefulSet)
|
||||
@@ -121,6 +131,15 @@ worker:
|
||||
# HTTP port for metrics/health (matches service.targetPort)
|
||||
HINDSIGHT_API_WORKER_HTTP_PORT: "8889"
|
||||
|
||||
# Pod disruption budget
|
||||
podDisruptionBudget:
|
||||
enabled: false
|
||||
minAvailable: 1
|
||||
# maxUnavailable: 1
|
||||
|
||||
# Pod affinity/anti-affinity (overrides global affinity for this component)
|
||||
# affinity: {}
|
||||
|
||||
# Secret environment variables (inherited from api.secrets if not specified)
|
||||
secrets: {}
|
||||
|
||||
@@ -164,6 +183,15 @@ controlPlane:
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
# Pod disruption budget
|
||||
podDisruptionBudget:
|
||||
enabled: false
|
||||
minAvailable: 1
|
||||
# maxUnavailable: 1
|
||||
|
||||
# Pod affinity/anti-affinity (overrides global affinity for this component)
|
||||
# affinity: {}
|
||||
|
||||
# Environment variables
|
||||
env:
|
||||
NODE_ENV: "production"
|
||||
@@ -262,7 +290,7 @@ nodeSelector: {}
|
||||
# Tolerations
|
||||
tolerations: []
|
||||
|
||||
# Affinity
|
||||
# Affinity (applied to all components unless overridden per-component)
|
||||
affinity: {}
|
||||
|
||||
# Autoscaling
|
||||
|
||||
@@ -46,4 +46,4 @@ __all__ = [
|
||||
"RemoteTEICrossEncoder",
|
||||
"LLMConfig",
|
||||
]
|
||||
__version__ = "0.4.9"
|
||||
__version__ = "0.4.10"
|
||||
|
||||
@@ -1400,6 +1400,26 @@ def create_app(
|
||||
app.state.prometheus_reader = None
|
||||
# Metrics collector is already initialized as no-op by default
|
||||
|
||||
# Initialize OpenTelemetry tracing if enabled
|
||||
if config.otel_traces_enabled:
|
||||
if not config.otel_exporter_otlp_endpoint:
|
||||
logging.warning("OTEL tracing enabled but no endpoint configured. Tracing disabled.")
|
||||
else:
|
||||
from hindsight_api.tracing import create_span_recorder, initialize_tracing
|
||||
|
||||
try:
|
||||
initialize_tracing(
|
||||
service_name=config.otel_service_name,
|
||||
endpoint=config.otel_exporter_otlp_endpoint,
|
||||
headers=config.otel_exporter_otlp_headers,
|
||||
deployment_environment=config.otel_deployment_environment,
|
||||
)
|
||||
create_span_recorder()
|
||||
logging.info("OpenTelemetry tracing enabled and configured")
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to initialize tracing: {e}")
|
||||
logging.warning("Continuing without tracing")
|
||||
|
||||
# Startup: Initialize database and memory system (migrations run inside initialize if enabled)
|
||||
if initialize_memory:
|
||||
await memory.initialize()
|
||||
|
||||
@@ -108,6 +108,13 @@ ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
|
||||
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
|
||||
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
|
||||
|
||||
# OpenTelemetry tracing configuration
|
||||
ENV_OTEL_TRACES_ENABLED = "HINDSIGHT_API_OTEL_TRACES_ENABLED"
|
||||
ENV_OTEL_EXPORTER_OTLP_ENDPOINT = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT"
|
||||
ENV_OTEL_EXPORTER_OTLP_HEADERS = "HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS"
|
||||
ENV_OTEL_SERVICE_NAME = "HINDSIGHT_API_OTEL_SERVICE_NAME"
|
||||
ENV_OTEL_DEPLOYMENT_ENVIRONMENT = "HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT"
|
||||
|
||||
# Vertex AI configuration
|
||||
ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
|
||||
ENV_LLM_VERTEXAI_REGION = "HINDSIGHT_API_LLM_VERTEXAI_REGION"
|
||||
@@ -251,6 +258,11 @@ DEFAULT_WORKER_CONSOLIDATION_MAX_SLOTS = 2 # Max concurrent consolidation tasks
|
||||
# Reflect agent settings
|
||||
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
|
||||
|
||||
# OpenTelemetry tracing configuration
|
||||
DEFAULT_OTEL_TRACES_ENABLED = False # Disabled by default for backward compatibility
|
||||
DEFAULT_OTEL_SERVICE_NAME = "hindsight-api"
|
||||
DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT = "development"
|
||||
|
||||
# Default MCP tool descriptions (can be customized via env vars)
|
||||
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
|
||||
|
||||
@@ -447,6 +459,13 @@ class HindsightConfig:
|
||||
# Reflect agent settings
|
||||
reflect_max_iterations: int
|
||||
|
||||
# OpenTelemetry tracing configuration
|
||||
otel_traces_enabled: bool
|
||||
otel_exporter_otlp_endpoint: str | None
|
||||
otel_exporter_otlp_headers: str | None
|
||||
otel_service_name: str
|
||||
otel_deployment_environment: str
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate configuration values and raise errors for invalid combinations."""
|
||||
# RETAIN_MAX_COMPLETION_TOKENS must be greater than RETAIN_CHUNK_SIZE
|
||||
@@ -646,6 +665,13 @@ class HindsightConfig:
|
||||
),
|
||||
# Reflect agent settings
|
||||
reflect_max_iterations=int(os.getenv(ENV_REFLECT_MAX_ITERATIONS, str(DEFAULT_REFLECT_MAX_ITERATIONS))),
|
||||
# OpenTelemetry tracing configuration
|
||||
otel_traces_enabled=os.getenv(ENV_OTEL_TRACES_ENABLED, str(DEFAULT_OTEL_TRACES_ENABLED)).lower()
|
||||
in ("true", "1", "yes"),
|
||||
otel_exporter_otlp_endpoint=os.getenv(ENV_OTEL_EXPORTER_OTLP_ENDPOINT) or None,
|
||||
otel_exporter_otlp_headers=os.getenv(ENV_OTEL_EXPORTER_OTLP_HEADERS) or None,
|
||||
otel_service_name=os.getenv(ENV_OTEL_SERVICE_NAME, DEFAULT_OTEL_SERVICE_NAME),
|
||||
otel_deployment_environment=os.getenv(ENV_OTEL_DEPLOYMENT_ENVIRONMENT, DEFAULT_OTEL_DEPLOYMENT_ENVIRONMENT),
|
||||
)
|
||||
config.validate()
|
||||
return config
|
||||
|
||||
@@ -426,94 +426,109 @@ async def _process_memory(
|
||||
Returns:
|
||||
Dict with action summary: created/updated/merged counts
|
||||
"""
|
||||
from ...tracing import get_tracer, is_tracing_enabled
|
||||
|
||||
fact_text = memory["text"]
|
||||
memory_id = memory["id"]
|
||||
fact_tags = memory.get("tags") or []
|
||||
|
||||
# Find related observations using the full recall system
|
||||
# SECURITY: Pass tags to ensure observations don't leak across security boundaries
|
||||
t0 = time.time()
|
||||
related_observations = await _find_related_observations(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
query=fact_text,
|
||||
request_context=request_context,
|
||||
tags=fact_tags, # Pass source memory's tags for security
|
||||
)
|
||||
if perf:
|
||||
perf.record_timing("recall", time.time() - t0)
|
||||
# Create parent span for this memory's consolidation
|
||||
tracer = get_tracer()
|
||||
if is_tracing_enabled():
|
||||
consolidation_span = tracer.start_span("hindsight.consolidation")
|
||||
consolidation_span.set_attribute("hindsight.memory_id", str(memory_id))
|
||||
consolidation_span.set_attribute("hindsight.bank_id", bank_id)
|
||||
else:
|
||||
consolidation_span = None
|
||||
|
||||
# Single LLM call handles ALL cases (with or without existing observations)
|
||||
# Note: Tags are NOT passed to LLM - they are handled algorithmically
|
||||
t0 = time.time()
|
||||
actions = await _consolidate_with_llm(
|
||||
memory_engine=memory_engine,
|
||||
fact_text=fact_text,
|
||||
observations=related_observations, # Can be empty list
|
||||
mission=mission,
|
||||
)
|
||||
if perf:
|
||||
perf.record_timing("llm", time.time() - t0)
|
||||
try:
|
||||
# Find related observations using the full recall system
|
||||
# SECURITY: Pass tags to ensure observations don't leak across security boundaries
|
||||
t0 = time.time()
|
||||
related_observations = await _find_related_observations(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
query=fact_text,
|
||||
request_context=request_context,
|
||||
tags=fact_tags, # Pass source memory's tags for security
|
||||
)
|
||||
if perf:
|
||||
perf.record_timing("recall", time.time() - t0)
|
||||
|
||||
if not actions:
|
||||
# LLM returned empty array - fact is purely ephemeral, skip
|
||||
return {"action": "skipped", "reason": "no_durable_knowledge"}
|
||||
# Single LLM call handles ALL cases (with or without existing observations)
|
||||
# Note: Tags are NOT passed to LLM - they are handled algorithmically
|
||||
t0 = time.time()
|
||||
actions = await _consolidate_with_llm(
|
||||
memory_engine=memory_engine,
|
||||
fact_text=fact_text,
|
||||
observations=related_observations, # Can be empty list
|
||||
mission=mission,
|
||||
)
|
||||
if perf:
|
||||
perf.record_timing("llm", time.time() - t0)
|
||||
|
||||
# Execute all actions and collect results
|
||||
results = []
|
||||
for action in actions:
|
||||
action_type = action.get("action")
|
||||
if action_type == "update":
|
||||
result = await _execute_update_action(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
memory_id=memory_id,
|
||||
action=action,
|
||||
observations=related_observations,
|
||||
source_fact_tags=fact_tags, # Pass source fact's tags for security
|
||||
source_occurred_start=memory.get("occurred_start"),
|
||||
source_occurred_end=memory.get("occurred_end"),
|
||||
source_mentioned_at=memory.get("mentioned_at"),
|
||||
perf=perf,
|
||||
)
|
||||
results.append(result)
|
||||
elif action_type == "create":
|
||||
result = await _execute_create_action(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
memory_id=memory_id,
|
||||
action=action,
|
||||
source_fact_tags=fact_tags, # Pass source fact's tags for security
|
||||
event_date=memory.get("event_date"),
|
||||
occurred_start=memory.get("occurred_start"),
|
||||
occurred_end=memory.get("occurred_end"),
|
||||
mentioned_at=memory.get("mentioned_at"),
|
||||
perf=perf,
|
||||
)
|
||||
results.append(result)
|
||||
if not actions:
|
||||
# LLM returned empty array - fact is purely ephemeral, skip
|
||||
return {"action": "skipped", "reason": "no_durable_knowledge"}
|
||||
|
||||
if not results:
|
||||
# No valid actions executed
|
||||
return {"action": "skipped", "reason": "no_valid_actions"}
|
||||
# Execute all actions and collect results
|
||||
results = []
|
||||
for action in actions:
|
||||
action_type = action.get("action")
|
||||
if action_type == "update":
|
||||
result = await _execute_update_action(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
memory_id=memory_id,
|
||||
action=action,
|
||||
observations=related_observations,
|
||||
source_fact_tags=fact_tags, # Pass source fact's tags for security
|
||||
source_occurred_start=memory.get("occurred_start"),
|
||||
source_occurred_end=memory.get("occurred_end"),
|
||||
source_mentioned_at=memory.get("mentioned_at"),
|
||||
perf=perf,
|
||||
)
|
||||
results.append(result)
|
||||
elif action_type == "create":
|
||||
result = await _execute_create_action(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
memory_id=memory_id,
|
||||
action=action,
|
||||
source_fact_tags=fact_tags, # Pass source fact's tags for security
|
||||
event_date=memory.get("event_date"),
|
||||
occurred_start=memory.get("occurred_start"),
|
||||
occurred_end=memory.get("occurred_end"),
|
||||
mentioned_at=memory.get("mentioned_at"),
|
||||
perf=perf,
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
# Summarize results
|
||||
created = sum(1 for r in results if r.get("action") == "created")
|
||||
updated = sum(1 for r in results if r.get("action") == "updated")
|
||||
merged = sum(1 for r in results if r.get("action") == "merged")
|
||||
if not results:
|
||||
# No valid actions executed
|
||||
return {"action": "skipped", "reason": "no_valid_actions"}
|
||||
|
||||
if len(results) == 1:
|
||||
return results[0]
|
||||
# Summarize results
|
||||
created = sum(1 for r in results if r.get("action") == "created")
|
||||
updated = sum(1 for r in results if r.get("action") == "updated")
|
||||
merged = sum(1 for r in results if r.get("action") == "merged")
|
||||
|
||||
return {
|
||||
"action": "multiple",
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"merged": merged,
|
||||
"total_actions": len(results),
|
||||
}
|
||||
if len(results) == 1:
|
||||
return results[0]
|
||||
|
||||
return {
|
||||
"action": "multiple",
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"merged": merged,
|
||||
"total_actions": len(results),
|
||||
}
|
||||
finally:
|
||||
if consolidation_span:
|
||||
consolidation_span.end()
|
||||
|
||||
|
||||
async def _execute_update_action(
|
||||
@@ -733,22 +748,37 @@ async def _find_related_observations(
|
||||
# Use recall to find related observations with token budget
|
||||
# max_tokens naturally limits how many observations are returned
|
||||
from ...config import get_config
|
||||
from ...tracing import get_tracer, is_tracing_enabled
|
||||
|
||||
config = get_config()
|
||||
|
||||
# SECURITY: Use all_strict matching if tags provided to prevent cross-scope consolidation
|
||||
tags_match = "all_strict" if tags else "any"
|
||||
|
||||
recall_result = await memory_engine.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
|
||||
fact_type=["observation"], # Only retrieve observations
|
||||
request_context=request_context,
|
||||
tags=tags, # Filter by source memory's tags
|
||||
tags_match=tags_match, # Use strict matching for security
|
||||
_quiet=True, # Suppress logging
|
||||
)
|
||||
# Create span for recall operation within consolidation
|
||||
tracer = get_tracer()
|
||||
if is_tracing_enabled():
|
||||
recall_span = tracer.start_span("hindsight.consolidation_recall")
|
||||
recall_span.set_attribute("hindsight.bank_id", bank_id)
|
||||
recall_span.set_attribute("hindsight.query", query[:100]) # Truncate for brevity
|
||||
recall_span.set_attribute("hindsight.fact_type", "observation")
|
||||
else:
|
||||
recall_span = None
|
||||
|
||||
try:
|
||||
recall_result = await memory_engine.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
|
||||
fact_type=["observation"], # Only retrieve observations
|
||||
request_context=request_context,
|
||||
tags=tags, # Filter by source memory's tags
|
||||
tags_match=tags_match, # Use strict matching for security
|
||||
_quiet=True, # Suppress logging
|
||||
)
|
||||
finally:
|
||||
if recall_span:
|
||||
recall_span.end()
|
||||
|
||||
# If no observations returned, return empty list
|
||||
if not recall_result.results:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -84,7 +84,7 @@ class AnthropicLLM(LLMInterface):
|
||||
messages=test_messages,
|
||||
max_completion_tokens=10,
|
||||
temperature=0.0,
|
||||
scope="test",
|
||||
scope="verification",
|
||||
max_retries=0,
|
||||
)
|
||||
logger.info("Anthropic connection verified successfully")
|
||||
@@ -223,6 +223,24 @@ class AnthropicLLM(LLMInterface):
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Record trace span
|
||||
from hindsight_api.tracing import _serialize_for_span, get_span_recorder
|
||||
|
||||
finish_reason = response.stop_reason if hasattr(response, "stop_reason") else None
|
||||
span_recorder = get_span_recorder()
|
||||
span_recorder.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
messages=messages,
|
||||
response_content=_serialize_for_span(result),
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
duration=duration,
|
||||
finish_reason=finish_reason,
|
||||
error=None,
|
||||
)
|
||||
|
||||
# Log slow calls
|
||||
if duration > 10.0:
|
||||
logger.info(
|
||||
@@ -397,16 +415,41 @@ class AnthropicLLM(LLMInterface):
|
||||
|
||||
# Record metrics
|
||||
metrics = get_metrics_collector()
|
||||
duration = time.time() - start_time
|
||||
metrics.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
duration=time.time() - start_time,
|
||||
duration=duration,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Record OpenTelemetry span
|
||||
from hindsight_api.tracing import get_span_recorder
|
||||
|
||||
span_recorder = get_span_recorder()
|
||||
# Convert LLMToolCall objects to dicts for span recording
|
||||
tool_calls_dict = (
|
||||
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
|
||||
if tool_calls
|
||||
else None
|
||||
)
|
||||
span_recorder.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
messages=messages,
|
||||
response_content=content,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
duration=duration,
|
||||
finish_reason=finish_reason,
|
||||
error=None,
|
||||
tool_calls=tool_calls_dict,
|
||||
)
|
||||
|
||||
return LLMToolCallResult(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
|
||||
@@ -95,7 +95,7 @@ class ClaudeCodeLLM(LLMInterface):
|
||||
messages=test_messages,
|
||||
max_completion_tokens=10,
|
||||
temperature=0.0,
|
||||
scope="test",
|
||||
scope="verification",
|
||||
max_retries=0,
|
||||
)
|
||||
logger.info("Claude Code connection verified successfully")
|
||||
@@ -237,6 +237,23 @@ class ClaudeCodeLLM(LLMInterface):
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Record trace span
|
||||
from hindsight_api.tracing import get_span_recorder
|
||||
|
||||
span_recorder = get_span_recorder()
|
||||
span_recorder.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
messages=messages,
|
||||
response_content=result if isinstance(result, str) else json.dumps(result),
|
||||
input_tokens=estimated_input,
|
||||
output_tokens=estimated_output,
|
||||
duration=duration,
|
||||
finish_reason=None,
|
||||
error=None,
|
||||
)
|
||||
|
||||
# Log slow calls
|
||||
if duration > 10.0:
|
||||
logger.info(
|
||||
|
||||
@@ -136,6 +136,7 @@ class CodexLLM(LLMInterface):
|
||||
max_retries=2,
|
||||
initial_backoff=0.5,
|
||||
max_backoff=2.0,
|
||||
scope="verification",
|
||||
)
|
||||
logger.info(f"Codex LLM verified: {self.model}")
|
||||
except Exception as e:
|
||||
@@ -261,6 +262,26 @@ class CodexLLM(LLMInterface):
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Record trace span
|
||||
from hindsight_api.tracing import get_span_recorder
|
||||
|
||||
# Estimate tokens for tracing
|
||||
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
|
||||
estimated_output = len(content) // 4
|
||||
span_recorder = get_span_recorder()
|
||||
span_recorder.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
messages=messages,
|
||||
response_content=result if isinstance(result, str) else json.dumps(result),
|
||||
input_tokens=estimated_input,
|
||||
output_tokens=estimated_output,
|
||||
duration=duration,
|
||||
finish_reason=None,
|
||||
error=None,
|
||||
)
|
||||
|
||||
if return_usage:
|
||||
# Codex doesn't provide token counts, estimate based on content
|
||||
estimated_input = sum(len(m.get("content", "")) for m in messages) // 4
|
||||
@@ -504,6 +525,28 @@ class CodexLLM(LLMInterface):
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Record OpenTelemetry span
|
||||
from hindsight_api.tracing import get_span_recorder
|
||||
|
||||
span_recorder = get_span_recorder()
|
||||
# Convert LLMToolCall objects to dicts for span recording
|
||||
tool_calls_dict = (
|
||||
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls] if tool_calls else None
|
||||
)
|
||||
span_recorder.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
messages=messages,
|
||||
response_content=content,
|
||||
input_tokens=0, # Codex doesn't provide token counts
|
||||
output_tokens=0,
|
||||
duration=duration,
|
||||
finish_reason="tool_calls" if tool_calls else "stop",
|
||||
error=None,
|
||||
tool_calls=tool_calls_dict,
|
||||
)
|
||||
|
||||
return LLMToolCallResult(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
|
||||
@@ -136,6 +136,7 @@ class GeminiLLM(LLMInterface):
|
||||
max_retries=2,
|
||||
initial_backoff=0.5,
|
||||
max_backoff=2.0,
|
||||
scope="verification",
|
||||
)
|
||||
logger.info(f"{self.provider.upper()} connection verified successfully")
|
||||
except Exception as e:
|
||||
@@ -275,6 +276,29 @@ class GeminiLLM(LLMInterface):
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Record trace span
|
||||
from hindsight_api.tracing import get_span_recorder
|
||||
|
||||
finish_reason = None
|
||||
if hasattr(response, "candidates") and response.candidates:
|
||||
if hasattr(response.candidates[0], "finish_reason"):
|
||||
finish_reason = str(response.candidates[0].finish_reason)
|
||||
span_recorder = get_span_recorder()
|
||||
from hindsight_api.tracing import _serialize_for_span
|
||||
|
||||
span_recorder.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
messages=messages,
|
||||
response_content=_serialize_for_span(result),
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
duration=duration,
|
||||
finish_reason=finish_reason,
|
||||
error=None,
|
||||
)
|
||||
|
||||
# Log slow calls
|
||||
if duration > 10.0 and input_tokens > 0:
|
||||
logger.info(
|
||||
@@ -466,6 +490,30 @@ class GeminiLLM(LLMInterface):
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Record OpenTelemetry span
|
||||
from hindsight_api.tracing import get_span_recorder
|
||||
|
||||
span_recorder = get_span_recorder()
|
||||
# Convert LLMToolCall objects to dicts for span recording
|
||||
tool_calls_dict = (
|
||||
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
|
||||
if tool_calls
|
||||
else None
|
||||
)
|
||||
span_recorder.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
messages=messages,
|
||||
response_content=content,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
duration=duration,
|
||||
finish_reason=finish_reason,
|
||||
error=None,
|
||||
tool_calls=tool_calls_dict,
|
||||
)
|
||||
|
||||
return LLMToolCallResult(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
|
||||
@@ -129,6 +129,23 @@ class MockLLM(LLMInterface):
|
||||
if self._mock_exception is not None:
|
||||
raise self._mock_exception
|
||||
|
||||
# Record trace span (minimal for mock provider)
|
||||
from hindsight_api.tracing import get_span_recorder
|
||||
|
||||
span_recorder = get_span_recorder()
|
||||
span_recorder.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
messages=messages,
|
||||
response_content="mock response",
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
duration=0.001, # Mock calls are instant
|
||||
finish_reason="stop",
|
||||
error=None,
|
||||
)
|
||||
|
||||
# Return mock response
|
||||
if self._mock_response is not None:
|
||||
result = self._mock_response
|
||||
@@ -192,20 +209,50 @@ class MockLLM(LLMInterface):
|
||||
if self._mock_exception is not None:
|
||||
raise self._mock_exception
|
||||
|
||||
# Record OpenTelemetry span
|
||||
from hindsight_api.tracing import get_span_recorder
|
||||
|
||||
span_recorder = get_span_recorder()
|
||||
|
||||
if self._mock_response is not None:
|
||||
if isinstance(self._mock_response, LLMToolCallResult):
|
||||
return self._mock_response
|
||||
# Allow setting just tool calls as a list
|
||||
if isinstance(self._mock_response, list):
|
||||
return LLMToolCallResult(
|
||||
result = self._mock_response
|
||||
elif isinstance(self._mock_response, list):
|
||||
# Allow setting just tool calls as a list
|
||||
result = LLMToolCallResult(
|
||||
tool_calls=[
|
||||
LLMToolCall(id=f"mock_{i}", name=tc["name"], arguments=tc.get("arguments", {}))
|
||||
for i, tc in enumerate(self._mock_response)
|
||||
],
|
||||
finish_reason="tool_calls",
|
||||
)
|
||||
else:
|
||||
result = LLMToolCallResult(content="mock response", finish_reason="stop")
|
||||
else:
|
||||
result = LLMToolCallResult(content="mock response", finish_reason="stop")
|
||||
|
||||
return LLMToolCallResult(content="mock response", finish_reason="stop")
|
||||
# Record span with mock values
|
||||
# Convert LLMToolCall objects to dicts for span recording
|
||||
tool_calls_dict = (
|
||||
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in result.tool_calls]
|
||||
if result.tool_calls
|
||||
else None
|
||||
)
|
||||
span_recorder.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
messages=messages,
|
||||
response_content=result.content,
|
||||
input_tokens=10, # Mock value
|
||||
output_tokens=5, # Mock value
|
||||
duration=0.1, # Mock value
|
||||
finish_reason=result.finish_reason,
|
||||
error=None,
|
||||
tool_calls=tool_calls_dict,
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up resources (no-op for mock provider)."""
|
||||
|
||||
@@ -130,6 +130,7 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
max_retries=2,
|
||||
initial_backoff=0.5,
|
||||
max_backoff=2.0,
|
||||
scope="verification",
|
||||
)
|
||||
logger.info(f"Connection verified: {self.provider}/{self.model}")
|
||||
except Exception as e:
|
||||
@@ -368,6 +369,24 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Record trace span
|
||||
from hindsight_api.tracing import _serialize_for_span, get_span_recorder
|
||||
|
||||
finish_reason = response.choices[0].finish_reason if response.choices else None
|
||||
span_recorder = get_span_recorder()
|
||||
span_recorder.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
messages=messages,
|
||||
response_content=_serialize_for_span(result),
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
duration=duration,
|
||||
finish_reason=finish_reason,
|
||||
error=None,
|
||||
)
|
||||
|
||||
# Log slow calls
|
||||
if duration > 10.0 and usage:
|
||||
ratio = max(1, output_tokens) / max(1, input_tokens)
|
||||
@@ -556,6 +575,30 @@ class OpenAICompatibleLLM(LLMInterface):
|
||||
success=True,
|
||||
)
|
||||
|
||||
# Record OpenTelemetry span
|
||||
from hindsight_api.tracing import get_span_recorder
|
||||
|
||||
span_recorder = get_span_recorder()
|
||||
# Convert LLMToolCall objects to dicts for span recording
|
||||
tool_calls_dict = (
|
||||
[{"id": tc.id, "name": tc.name, "arguments": tc.arguments} for tc in tool_calls]
|
||||
if tool_calls
|
||||
else None
|
||||
)
|
||||
span_recorder.record_llm_call(
|
||||
provider=self.provider,
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
messages=messages,
|
||||
response_content=content,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
duration=duration,
|
||||
finish_reason=finish_reason,
|
||||
error=None,
|
||||
tool_calls=tool_calls_dict,
|
||||
)
|
||||
|
||||
return LLMToolCallResult(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
|
||||
@@ -402,7 +402,7 @@ async def run_reflect_agent(
|
||||
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
scope="reflect_agent_final",
|
||||
scope="reflect",
|
||||
max_completion_tokens=max_tokens,
|
||||
return_usage=True,
|
||||
)
|
||||
@@ -447,7 +447,7 @@ async def run_reflect_agent(
|
||||
result = await llm_config.call_with_tools(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
scope="reflect_agent",
|
||||
scope="reflect_tool_call",
|
||||
tool_choice="required" if iteration == 0 else "auto", # Force tool use on first iteration
|
||||
)
|
||||
llm_duration = int((time.time() - llm_start) * 1000)
|
||||
@@ -479,7 +479,7 @@ async def run_reflect_agent(
|
||||
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
scope="reflect_agent_final",
|
||||
scope="reflect",
|
||||
max_completion_tokens=max_tokens,
|
||||
return_usage=True,
|
||||
)
|
||||
@@ -550,7 +550,7 @@ async def run_reflect_agent(
|
||||
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
scope="reflect_agent_final",
|
||||
scope="reflect",
|
||||
max_completion_tokens=max_tokens,
|
||||
return_usage=True,
|
||||
)
|
||||
@@ -617,23 +617,30 @@ async def run_reflect_agent(
|
||||
)
|
||||
continue
|
||||
|
||||
# Process done tool
|
||||
return await _process_done_tool(
|
||||
done_call,
|
||||
available_memory_ids,
|
||||
available_mental_model_ids,
|
||||
available_observation_ids,
|
||||
iteration + 1,
|
||||
total_tools_called,
|
||||
tool_trace,
|
||||
_get_llm_trace(),
|
||||
_get_usage(),
|
||||
_log_completion,
|
||||
reflect_id,
|
||||
directives_applied=directives_applied,
|
||||
llm_config=llm_config,
|
||||
response_schema=response_schema,
|
||||
)
|
||||
# Process done tool - wrap with tool call span
|
||||
from hindsight_api.tracing import get_tracer
|
||||
|
||||
tracer = get_tracer()
|
||||
span_name = "hindsight.reflect_tool_call"
|
||||
with tracer.start_as_current_span(span_name) as span:
|
||||
span.set_attribute("hindsight.scope", "reflect_tool_call")
|
||||
span.set_attribute("hindsight.operation", "reflect_tool_call")
|
||||
return await _process_done_tool(
|
||||
done_call,
|
||||
available_memory_ids,
|
||||
available_mental_model_ids,
|
||||
available_observation_ids,
|
||||
iteration + 1,
|
||||
total_tools_called,
|
||||
tool_trace,
|
||||
_get_llm_trace(),
|
||||
_get_usage(),
|
||||
_log_completion,
|
||||
reflect_id,
|
||||
directives_applied=directives_applied,
|
||||
llm_config=llm_config,
|
||||
response_schema=response_schema,
|
||||
)
|
||||
|
||||
# Execute other tools in parallel (exclude done tool in all its format variants)
|
||||
other_tools = [tc for tc in result.tool_calls if not _is_done_tool(tc.name)]
|
||||
@@ -842,17 +849,67 @@ async def _execute_tool_with_timing(
|
||||
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
|
||||
) -> tuple[dict[str, Any], int]:
|
||||
"""Execute a tool call and return result with timing."""
|
||||
start = time.time()
|
||||
result = await _execute_tool(
|
||||
tc.name,
|
||||
tc.arguments,
|
||||
search_mental_models_fn,
|
||||
search_observations_fn,
|
||||
recall_fn,
|
||||
expand_fn,
|
||||
)
|
||||
duration_ms = int((time.time() - start) * 1000)
|
||||
return result, duration_ms
|
||||
from hindsight_api.tracing import get_tracer
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
# Create span for tool execution
|
||||
tracer = get_tracer()
|
||||
# Normalize tool name for span
|
||||
normalized_name = _normalize_tool_name(tc.name)
|
||||
span_name = f"hindsight.reflect_tool_exec.{normalized_name}"
|
||||
|
||||
# Calculate timestamps
|
||||
start_time_ns = time.time_ns()
|
||||
|
||||
with tracer.start_as_current_span(
|
||||
span_name,
|
||||
start_time=start_time_ns,
|
||||
end_on_exit=False,
|
||||
) as span:
|
||||
# Set attributes
|
||||
span.set_attribute("hindsight.tool.name", normalized_name)
|
||||
span.set_attribute("hindsight.tool.id", tc.id)
|
||||
span.set_attribute("hindsight.tool.arguments", json.dumps(tc.arguments))
|
||||
|
||||
try:
|
||||
result = await _execute_tool(
|
||||
tc.name,
|
||||
tc.arguments,
|
||||
search_mental_models_fn,
|
||||
search_observations_fn,
|
||||
recall_fn,
|
||||
expand_fn,
|
||||
)
|
||||
|
||||
# Set success attributes
|
||||
if isinstance(result, dict) and "error" in result:
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
|
||||
span.set_status(Status(StatusCode.ERROR, result["error"]))
|
||||
else:
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
|
||||
span.set_status(Status(StatusCode.OK))
|
||||
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
span.set_attribute("hindsight.tool.duration_ms", duration_ms)
|
||||
|
||||
# End span with correct timestamp
|
||||
end_time_ns = time.time_ns()
|
||||
span.end(end_time=end_time_ns)
|
||||
|
||||
return result, duration_ms
|
||||
except Exception as e:
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
|
||||
span.set_status(Status(StatusCode.ERROR, str(e)))
|
||||
span.record_exception(e)
|
||||
duration_ms = int((time.time() - start_time) * 1000)
|
||||
span.set_attribute("hindsight.tool.duration_ms", duration_ms)
|
||||
end_time_ns = time.time_ns()
|
||||
span.end(end_time=end_time_ns)
|
||||
raise
|
||||
|
||||
|
||||
async def _execute_tool(
|
||||
|
||||
@@ -802,7 +802,7 @@ Text:
|
||||
extraction_response_json, call_usage = await llm_config.call(
|
||||
messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
|
||||
response_format=response_schema,
|
||||
scope="memory_extract_facts",
|
||||
scope="retain_extract_facts",
|
||||
temperature=0.1,
|
||||
max_completion_tokens=config.retain_max_completion_tokens,
|
||||
max_retries=max_retries,
|
||||
|
||||
@@ -8,6 +8,16 @@ This extension enables multi-tenant memory isolation for applications using
|
||||
Supabase Auth - each authenticated user's memories are stored in a separate
|
||||
schema, ensuring complete data isolation.
|
||||
|
||||
Features:
|
||||
- Local JWT Verification: Validates tokens locally using JWKS public keys
|
||||
(no network call per request)
|
||||
- Automatic Schema Isolation: Each user gets {prefix}_{user_id} schema
|
||||
- Zero User Management: Leverages your existing Supabase Auth setup
|
||||
- Production Ready: Includes health checks, timeouts, key rotation handling,
|
||||
and error handling
|
||||
- Built-in: Ships with Hindsight, no extra installation needed
|
||||
- Legacy Support: Falls back to /auth/v1/user endpoint for HS256 projects
|
||||
|
||||
JWT Verification Strategy:
|
||||
By default, JWTs are verified locally using public keys from the Supabase
|
||||
JWKS endpoint (/auth/v1/.well-known/jwks.json). This is the Supabase-recommended
|
||||
|
||||
@@ -242,6 +242,11 @@ def main():
|
||||
worker_consolidation_max_slots=config.worker_consolidation_max_slots,
|
||||
reflect_max_iterations=config.reflect_max_iterations,
|
||||
mental_model_refresh_concurrency=config.mental_model_refresh_concurrency,
|
||||
otel_traces_enabled=config.otel_traces_enabled,
|
||||
otel_exporter_otlp_endpoint=config.otel_exporter_otlp_endpoint,
|
||||
otel_exporter_otlp_headers=config.otel_exporter_otlp_headers,
|
||||
otel_service_name=config.otel_service_name,
|
||||
otel_deployment_environment=config.otel_deployment_environment,
|
||||
)
|
||||
config.configure_logging()
|
||||
if not args.daemon:
|
||||
|
||||
@@ -24,6 +24,7 @@ from alembic import command
|
||||
from alembic.config import Config
|
||||
from alembic.script.revision import ResolutionError
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from .utils import mask_network_location
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
"""
|
||||
OpenTelemetry distributed tracing instrumentation for Hindsight API.
|
||||
|
||||
This module provides tracing for:
|
||||
- LLM API calls with full prompts/completions following GenAI semantic conventions
|
||||
- Token usage and model information
|
||||
- Error tracking and finish reasons
|
||||
|
||||
Tracing is conditional and disabled by default. When enabled, traces are exported
|
||||
to Langfuse (or any OTLP-compatible backend) via OTLP HTTP protocol.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
||||
from opentelemetry.trace import Status, StatusCode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _serialize_for_span(obj: Any) -> str:
|
||||
"""Serialize an object for span recording, handling Pydantic models."""
|
||||
if isinstance(obj, str):
|
||||
return obj
|
||||
if hasattr(obj, "model_dump_json"):
|
||||
# Pydantic v2 model
|
||||
return obj.model_dump_json()
|
||||
if hasattr(obj, "json"):
|
||||
# Pydantic v1 model
|
||||
return obj.json()
|
||||
if hasattr(obj, "model_dump"):
|
||||
# Pydantic v2 model - convert to dict then json
|
||||
return json.dumps(obj.model_dump())
|
||||
if hasattr(obj, "dict"):
|
||||
# Pydantic v1 model - convert to dict then json
|
||||
return json.dumps(obj.dict())
|
||||
# Fallback to json.dumps for dicts and other types
|
||||
return json.dumps(obj)
|
||||
|
||||
|
||||
# No-op tracer for when tracing is disabled
|
||||
class NoOpTracer:
|
||||
"""No-op tracer that provides the same interface as OpenTelemetry Tracer but does nothing."""
|
||||
|
||||
def start_as_current_span(self, name: str, **kwargs):
|
||||
"""Return a no-op context manager that yields a NoOpSpan."""
|
||||
from contextlib import contextmanager
|
||||
|
||||
@contextmanager
|
||||
def noop_span_context():
|
||||
yield NoOpSpan()
|
||||
|
||||
return noop_span_context()
|
||||
|
||||
def start_span(self, name: str, **kwargs):
|
||||
"""Return a no-op span."""
|
||||
return NoOpSpan()
|
||||
|
||||
|
||||
class NoOpSpan:
|
||||
"""No-op span that provides the same interface as OpenTelemetry Span but does nothing."""
|
||||
|
||||
def set_attribute(self, key: str, value: Any) -> None:
|
||||
"""No-op."""
|
||||
pass
|
||||
|
||||
def set_status(self, status: Any) -> None:
|
||||
"""No-op."""
|
||||
pass
|
||||
|
||||
def record_exception(self, exception: Exception) -> None:
|
||||
"""No-op."""
|
||||
pass
|
||||
|
||||
def add_event(self, name: str, attributes: dict | None = None) -> None:
|
||||
"""No-op."""
|
||||
pass
|
||||
|
||||
def end(self, end_time: int | None = None) -> None:
|
||||
"""No-op."""
|
||||
pass
|
||||
|
||||
|
||||
# Global tracer instance
|
||||
_tracer: trace.Tracer | NoOpTracer = NoOpTracer()
|
||||
_tracing_enabled: bool = False
|
||||
|
||||
|
||||
# GenAI semantic convention attribute names (based on v1.37 spec)
|
||||
class GenAIAttributes:
|
||||
"""GenAI semantic convention attribute names."""
|
||||
|
||||
# Operation and provider
|
||||
OPERATION_NAME = "gen_ai.operation.name"
|
||||
PROVIDER_NAME = "gen_ai.provider.name"
|
||||
|
||||
# Model information
|
||||
REQUEST_MODEL = "gen_ai.request.model"
|
||||
RESPONSE_MODEL = "gen_ai.response.model"
|
||||
|
||||
# Token usage
|
||||
USAGE_INPUT_TOKENS = "gen_ai.usage.input_tokens"
|
||||
USAGE_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
|
||||
|
||||
# Messages and prompts
|
||||
SYSTEM_INSTRUCTIONS = "gen_ai.system_instructions"
|
||||
INPUT_MESSAGES = "gen_ai.input.messages"
|
||||
OUTPUT_MESSAGES = "gen_ai.output.messages"
|
||||
|
||||
# Response metadata
|
||||
FINISH_REASONS = "gen_ai.response.finish_reasons"
|
||||
|
||||
# Error tracking
|
||||
ERROR_TYPE = "error.type"
|
||||
|
||||
|
||||
# Provider name mapping (Hindsight internal -> GenAI semantic convention)
|
||||
PROVIDER_NAME_MAPPING = {
|
||||
"openai": "openai",
|
||||
"anthropic": "anthropic",
|
||||
"gemini": "google",
|
||||
"vertexai": "google",
|
||||
"groq": "groq",
|
||||
"ollama": "ollama",
|
||||
"lmstudio": "lmstudio",
|
||||
"openai-codex": "openai",
|
||||
"claude-code": "anthropic",
|
||||
"mock": "mock",
|
||||
}
|
||||
|
||||
|
||||
def initialize_tracing(
|
||||
service_name: str,
|
||||
endpoint: str,
|
||||
headers: Optional[str] = None,
|
||||
deployment_environment: str = "development",
|
||||
) -> None:
|
||||
"""
|
||||
Initialize OpenTelemetry tracing with OTLP exporter.
|
||||
|
||||
Args:
|
||||
service_name: Name of the service for resource attributes
|
||||
endpoint: OTLP endpoint URL (e.g., https://cloud.langfuse.com/api/public/otel)
|
||||
headers: Optional headers in format "key1=value1,key2=value2"
|
||||
deployment_environment: Deployment environment (e.g., development, staging, production)
|
||||
"""
|
||||
global _tracer, _tracing_enabled
|
||||
|
||||
# Create resource with service information
|
||||
resource = Resource.create(
|
||||
{
|
||||
"service.name": service_name,
|
||||
"service.version": "0.4.8", # Could import from __version__
|
||||
"deployment.environment.name": deployment_environment,
|
||||
}
|
||||
)
|
||||
|
||||
# Parse headers
|
||||
headers_dict = {}
|
||||
if headers:
|
||||
for pair in headers.split(","):
|
||||
if "=" in pair:
|
||||
key, value = pair.split("=", 1)
|
||||
headers_dict[key.strip()] = value.strip()
|
||||
|
||||
# Create OTLP HTTP exporter
|
||||
# Note: Langfuse expects /v1/traces path appended to base endpoint
|
||||
otlp_endpoint = endpoint if endpoint.endswith("/v1/traces") else f"{endpoint}/v1/traces"
|
||||
otlp_exporter = OTLPSpanExporter(
|
||||
endpoint=otlp_endpoint,
|
||||
headers=headers_dict,
|
||||
)
|
||||
|
||||
# Create tracer provider with batch processor
|
||||
provider = TracerProvider(resource=resource)
|
||||
provider.add_span_processor(BatchSpanProcessor(otlp_exporter))
|
||||
|
||||
# Set global tracer provider
|
||||
trace.set_tracer_provider(provider)
|
||||
|
||||
# Get tracer for this application
|
||||
_tracer = trace.get_tracer(__name__)
|
||||
_tracing_enabled = True
|
||||
|
||||
logger.info(f"Tracing initialized: endpoint={otlp_endpoint}, service={service_name}")
|
||||
|
||||
|
||||
def get_tracer() -> trace.Tracer | NoOpTracer:
|
||||
"""
|
||||
Get the global tracer instance.
|
||||
|
||||
Returns a no-op tracer if tracing is disabled, so callers don't need to check for None.
|
||||
This improves code readability by allowing direct use without null checks.
|
||||
"""
|
||||
return _tracer
|
||||
|
||||
|
||||
def create_operation_span(operation: str, bank_id: str | None = None):
|
||||
"""
|
||||
Create a parent span for a Hindsight operation (retain, reflect, consolidation, etc.).
|
||||
|
||||
This creates the span hierarchy:
|
||||
- hindsight.{operation} (parent)
|
||||
- chat {model} (child LLM calls)
|
||||
|
||||
Args:
|
||||
operation: Operation name (retain, reflect, consolidation, mental_model_refresh)
|
||||
bank_id: Optional bank ID for context
|
||||
|
||||
Returns:
|
||||
Span context manager
|
||||
"""
|
||||
if not _tracing_enabled or _tracer is None:
|
||||
# Return a no-op context manager
|
||||
from contextlib import nullcontext
|
||||
|
||||
return nullcontext()
|
||||
|
||||
span_name = f"hindsight.{operation}"
|
||||
span = _tracer.start_as_current_span(span_name)
|
||||
|
||||
# Add operation-specific attributes
|
||||
if span and hasattr(span, "set_attribute"):
|
||||
span.set_attribute("hindsight.operation", operation)
|
||||
if bank_id:
|
||||
span.set_attribute("hindsight.bank_id", bank_id)
|
||||
|
||||
return span
|
||||
|
||||
|
||||
def is_tracing_enabled() -> bool:
|
||||
"""Check if tracing is enabled."""
|
||||
return _tracing_enabled
|
||||
|
||||
|
||||
# Maximum content length before truncation (to stay within span size limits)
|
||||
MAX_CONTENT_LENGTH = 100_000 # characters
|
||||
|
||||
|
||||
def _truncate_content(content: str) -> str:
|
||||
"""Truncate content if too large for span."""
|
||||
if len(content) > MAX_CONTENT_LENGTH:
|
||||
return content[:MAX_CONTENT_LENGTH] + f"\n\n[TRUNCATED: {len(content) - MAX_CONTENT_LENGTH} chars omitted]"
|
||||
return content
|
||||
|
||||
|
||||
class LLMSpanRecorder:
|
||||
"""
|
||||
Records OpenTelemetry spans for LLM calls following GenAI semantic conventions.
|
||||
"""
|
||||
|
||||
def __init__(self, tracer: trace.Tracer):
|
||||
self.tracer = tracer
|
||||
|
||||
def record_llm_call(
|
||||
self,
|
||||
provider: str,
|
||||
model: str,
|
||||
scope: str,
|
||||
messages: list[dict[str, str]],
|
||||
response_content: Optional[str],
|
||||
input_tokens: int,
|
||||
output_tokens: int,
|
||||
duration: float,
|
||||
finish_reason: Optional[str] = None,
|
||||
error: Optional[Exception] = None,
|
||||
tool_calls: Optional[list[dict[str, Any]]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Record a completed LLM call as a span with GenAI semantic conventions.
|
||||
|
||||
This creates a span AFTER the call completes, using timestamps to
|
||||
set the correct start/end times. This approach works better with
|
||||
the existing sync metrics recording pattern.
|
||||
|
||||
Args:
|
||||
provider: Hindsight provider name
|
||||
model: Model name
|
||||
scope: Scope identifier (memory, reflect, consolidation, etc.)
|
||||
messages: Input messages (chat history)
|
||||
response_content: Response text from LLM
|
||||
input_tokens: Input token count
|
||||
output_tokens: Output token count
|
||||
duration: Call duration in seconds
|
||||
finish_reason: Reason the model stopped (stop, length, tool_calls, etc.)
|
||||
error: Exception if call failed
|
||||
tool_calls: List of tool calls made (for function calling)
|
||||
"""
|
||||
try:
|
||||
# Map provider name to GenAI semantic convention
|
||||
genai_provider = PROVIDER_NAME_MAPPING.get(provider.lower(), provider.lower())
|
||||
|
||||
# Determine operation name based on scope/context
|
||||
operation_name = "chat" # Default for GenAI semantic conventions
|
||||
|
||||
# Create span name: "hindsight.{scope}" for consistency with parent spans
|
||||
# Model info is available in span attributes (gen_ai.request.model)
|
||||
if scope:
|
||||
span_name = f"hindsight.{scope}"
|
||||
else:
|
||||
# Fallback to chat {model} if no scope provided
|
||||
span_name = f"{operation_name} {model}"
|
||||
|
||||
# Calculate timestamps
|
||||
end_time_ns = time.time_ns()
|
||||
start_time_ns = end_time_ns - int(duration * 1_000_000_000)
|
||||
|
||||
# Create span with explicit timestamps
|
||||
with self.tracer.start_as_current_span(
|
||||
span_name,
|
||||
start_time=start_time_ns,
|
||||
end_on_exit=False, # We'll set end time manually
|
||||
) as span:
|
||||
# Set required attributes
|
||||
span.set_attribute(GenAIAttributes.OPERATION_NAME, operation_name)
|
||||
span.set_attribute(GenAIAttributes.PROVIDER_NAME, genai_provider)
|
||||
span.set_attribute(GenAIAttributes.REQUEST_MODEL, model)
|
||||
span.set_attribute(GenAIAttributes.RESPONSE_MODEL, model)
|
||||
span.set_attribute(GenAIAttributes.USAGE_INPUT_TOKENS, input_tokens)
|
||||
span.set_attribute(GenAIAttributes.USAGE_OUTPUT_TOKENS, output_tokens)
|
||||
|
||||
# Add custom attributes for Hindsight context
|
||||
span.set_attribute("hindsight.scope", scope)
|
||||
span.set_attribute("hindsight.provider.internal", provider)
|
||||
|
||||
# Add tool call information if present
|
||||
if tool_calls:
|
||||
span.set_attribute("gen_ai.tool_calls.count", len(tool_calls))
|
||||
# Add tool names as comma-separated list
|
||||
tool_names = [tc.get("name", "") for tc in tool_calls]
|
||||
span.set_attribute("gen_ai.tool_calls.names", ",".join(tool_names))
|
||||
|
||||
# Format messages for GenAI conventions (as JSON)
|
||||
input_messages_json = self._format_messages(messages)
|
||||
output_messages_json = self._format_output(response_content, finish_reason)
|
||||
|
||||
# Extract system instructions if present
|
||||
system_instructions = self._extract_system_instructions(messages)
|
||||
|
||||
# Add event with prompts/completions following v1.37 conventions
|
||||
event_attrs = {}
|
||||
if input_messages_json:
|
||||
event_attrs[GenAIAttributes.INPUT_MESSAGES] = input_messages_json
|
||||
if output_messages_json:
|
||||
event_attrs[GenAIAttributes.OUTPUT_MESSAGES] = output_messages_json
|
||||
if system_instructions:
|
||||
event_attrs[GenAIAttributes.SYSTEM_INSTRUCTIONS] = system_instructions
|
||||
if finish_reason:
|
||||
event_attrs[GenAIAttributes.FINISH_REASONS] = json.dumps([finish_reason])
|
||||
|
||||
span.add_event(
|
||||
"gen_ai.client.inference.operation.details",
|
||||
attributes=event_attrs,
|
||||
)
|
||||
|
||||
# Add individual tool call events with details
|
||||
if tool_calls:
|
||||
for i, tc in enumerate(tool_calls):
|
||||
tool_event_attrs = {
|
||||
"tool.name": tc.get("name", ""),
|
||||
"tool.id": tc.get("id", ""),
|
||||
"tool.arguments": json.dumps(tc.get("arguments", {})),
|
||||
}
|
||||
span.add_event(f"gen_ai.tool_call.{i}", attributes=tool_event_attrs)
|
||||
|
||||
# Handle errors
|
||||
if error:
|
||||
span.set_status(Status(StatusCode.ERROR, str(error)))
|
||||
span.set_attribute(GenAIAttributes.ERROR_TYPE, type(error).__name__)
|
||||
span.record_exception(error)
|
||||
else:
|
||||
span.set_status(Status(StatusCode.OK))
|
||||
|
||||
# Set end time
|
||||
span.end(end_time=end_time_ns)
|
||||
|
||||
except Exception as e:
|
||||
# Don't let tracing errors break LLM calls
|
||||
logger.error(f"Failed to record LLM span: {e}", exc_info=True)
|
||||
|
||||
def _format_messages(self, messages: list[dict[str, str]]) -> str:
|
||||
"""
|
||||
Format messages into GenAI semantic convention format (JSON array).
|
||||
|
||||
Returns JSON string representation of message array.
|
||||
"""
|
||||
try:
|
||||
formatted = []
|
||||
for msg in messages:
|
||||
content = msg.get("content", "")
|
||||
# Truncate if needed
|
||||
if isinstance(content, str):
|
||||
content = _truncate_content(content)
|
||||
|
||||
formatted.append(
|
||||
{
|
||||
"role": msg.get("role", "user"),
|
||||
"content": content,
|
||||
}
|
||||
)
|
||||
|
||||
return json.dumps(formatted)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to format input messages: {e}")
|
||||
return "[]"
|
||||
|
||||
def _format_output(
|
||||
self,
|
||||
content: Optional[str],
|
||||
finish_reason: Optional[str],
|
||||
) -> str:
|
||||
"""Format output message into GenAI semantic convention format."""
|
||||
try:
|
||||
if content is None:
|
||||
return "[]"
|
||||
|
||||
# Truncate if needed
|
||||
if isinstance(content, str):
|
||||
content = _truncate_content(content)
|
||||
|
||||
return json.dumps(
|
||||
[
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": content,
|
||||
}
|
||||
]
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to format output message: {e}")
|
||||
return "[]"
|
||||
|
||||
def _extract_system_instructions(self, messages: list[dict[str, str]]) -> Optional[str]:
|
||||
"""Extract system instructions from messages if present."""
|
||||
try:
|
||||
for msg in messages:
|
||||
if msg.get("role") == "system":
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str):
|
||||
return _truncate_content(content)
|
||||
return str(content)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to extract system instructions: {e}")
|
||||
return None
|
||||
|
||||
|
||||
class NoOpLLMSpanRecorder:
|
||||
"""No-op span recorder for when tracing is disabled."""
|
||||
|
||||
def record_llm_call(self, **kwargs) -> None:
|
||||
"""No-op."""
|
||||
pass
|
||||
|
||||
|
||||
# Global span recorder instance
|
||||
_span_recorder: Optional[LLMSpanRecorder] = None
|
||||
|
||||
|
||||
def get_span_recorder() -> LLMSpanRecorder | NoOpLLMSpanRecorder:
|
||||
"""Get the global span recorder (NoOp if tracing disabled)."""
|
||||
if _span_recorder is None:
|
||||
return NoOpLLMSpanRecorder()
|
||||
return _span_recorder
|
||||
|
||||
|
||||
def create_span_recorder() -> LLMSpanRecorder:
|
||||
"""Create and set the global span recorder."""
|
||||
global _span_recorder
|
||||
tracer = get_tracer()
|
||||
if tracer is None:
|
||||
raise RuntimeError("Tracing not initialized. Call initialize_tracing() first.")
|
||||
_span_recorder = LLMSpanRecorder(tracer)
|
||||
return _span_recorder
|
||||
@@ -1,5 +1,6 @@
|
||||
from urllib.parse import urlparse, urlunparse
|
||||
|
||||
|
||||
def mask_network_location(url):
|
||||
if not url:
|
||||
return url
|
||||
@@ -9,4 +10,4 @@ def mask_network_location(url):
|
||||
masked_network_location += f":{parsed_url.port}"
|
||||
if parsed_url.username or parsed_url.password:
|
||||
masked_network_location = f"***:***@{masked_network_location}"
|
||||
return urlunparse(parsed_url._replace(netloc=masked_network_location))
|
||||
return urlunparse(parsed_url._replace(netloc=masked_network_location))
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api"
|
||||
version = "0.4.9"
|
||||
version = "0.4.10"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -33,6 +33,8 @@ dependencies = [
|
||||
"opentelemetry-sdk>=1.20.0",
|
||||
"opentelemetry-instrumentation-fastapi>=0.41b0",
|
||||
"opentelemetry-exporter-prometheus>=0.41b0",
|
||||
"opentelemetry-exporter-otlp-proto-http>=1.20.0",
|
||||
"opentelemetry-semantic-conventions>=0.41b0",
|
||||
"dateparser>=1.2.2",
|
||||
"google-genai>=1.0.0",
|
||||
"google-auth>=2.0.0",
|
||||
|
||||
@@ -852,3 +852,61 @@ class TestMentalModelRefreshTagSecurity:
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
async def test_refresh_mental_model_with_directives(self, memory: MemoryEngine, request_context):
|
||||
"""Test that refreshing a mental model with directives works correctly."""
|
||||
bank_id = f"test-refresh-directives-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Ensure bank exists
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
# Create a directive
|
||||
directive = await memory.create_directive(
|
||||
bank_id=bank_id,
|
||||
name="Response Style",
|
||||
content="Always be concise and professional",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Create a concept mental model to refresh
|
||||
concept = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Team Info",
|
||||
source_query="Team information summary",
|
||||
content="Initial team information",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Add some memories
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{"content": "Alice is the team lead and handles project planning."},
|
||||
{"content": "Bob is a senior engineer who mentors junior developers."},
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Wait for retain to complete
|
||||
await memory.wait_for_background_tasks()
|
||||
|
||||
# Refresh the concept mental model (this should include directive in based_on)
|
||||
refreshed = await memory.refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=concept["id"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Wait for background tasks to complete
|
||||
await memory.wait_for_background_tasks()
|
||||
|
||||
# Verify the refresh completed without errors
|
||||
assert refreshed is not None
|
||||
assert refreshed["content"] is not None
|
||||
|
||||
# Get the updated mental model
|
||||
updated = await memory.get_mental_model(bank_id, concept["id"], request_context=request_context)
|
||||
assert updated["content"] != "Initial team information"
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""
|
||||
Test to verify reflect operation creates proper span hierarchy.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_creates_child_spans(memory, request_context):
|
||||
"""Test that reflect operation creates child LLM spans."""
|
||||
from datetime import datetime, timezone
|
||||
from hindsight_api.tracing import initialize_tracing, get_span_recorder, create_span_recorder
|
||||
|
||||
# Initialize tracing with a mock endpoint
|
||||
initialize_tracing(
|
||||
service_name="test-hindsight",
|
||||
endpoint="http://localhost:4318",
|
||||
deployment_environment="test"
|
||||
)
|
||||
|
||||
# Create span recorder
|
||||
recorder = create_span_recorder()
|
||||
|
||||
bank_id = f"test-reflect-hierarchy-{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Add some memories
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Paris is the capital of France",
|
||||
context="Geography",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Run reflect
|
||||
result = await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="What is the capital of France?",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
print(f"Reflect result: {result.text[:100]}")
|
||||
print(f"Usage: {result.usage}")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -0,0 +1,407 @@
|
||||
"""
|
||||
Unit tests for OpenTelemetry tracing instrumentation.
|
||||
|
||||
Tests the tracing module's ability to record LLM calls with GenAI semantic conventions.
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.tracing import (
|
||||
PROVIDER_NAME_MAPPING,
|
||||
GenAIAttributes,
|
||||
LLMSpanRecorder,
|
||||
NoOpLLMSpanRecorder,
|
||||
_truncate_content,
|
||||
create_operation_span,
|
||||
initialize_tracing,
|
||||
is_tracing_enabled,
|
||||
)
|
||||
|
||||
|
||||
def test_provider_name_mapping():
|
||||
"""Test that provider names are correctly mapped to GenAI conventions."""
|
||||
assert PROVIDER_NAME_MAPPING["openai"] == "openai"
|
||||
assert PROVIDER_NAME_MAPPING["anthropic"] == "anthropic"
|
||||
assert PROVIDER_NAME_MAPPING["gemini"] == "google"
|
||||
assert PROVIDER_NAME_MAPPING["vertexai"] == "google"
|
||||
assert PROVIDER_NAME_MAPPING["groq"] == "groq"
|
||||
assert PROVIDER_NAME_MAPPING["ollama"] == "ollama"
|
||||
assert PROVIDER_NAME_MAPPING["openai-codex"] == "openai"
|
||||
assert PROVIDER_NAME_MAPPING["claude-code"] == "anthropic"
|
||||
|
||||
|
||||
def test_truncate_content_short():
|
||||
"""Test that short content is not truncated."""
|
||||
content = "This is a short message"
|
||||
result = _truncate_content(content)
|
||||
assert result == content
|
||||
|
||||
|
||||
def test_truncate_content_long():
|
||||
"""Test that long content is truncated."""
|
||||
content = "x" * 150000 # Exceeds MAX_CONTENT_LENGTH
|
||||
result = _truncate_content(content)
|
||||
assert len(result) < len(content)
|
||||
assert "[TRUNCATED:" in result
|
||||
assert result.startswith("x" * 100)
|
||||
|
||||
|
||||
def test_noop_span_recorder():
|
||||
"""Test that NoOpLLMSpanRecorder doesn't raise errors."""
|
||||
recorder = NoOpLLMSpanRecorder()
|
||||
# Should not raise any errors
|
||||
recorder.record_llm_call(
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
scope="test",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
response_content="test response",
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
duration=1.0,
|
||||
)
|
||||
|
||||
|
||||
def test_llm_span_recorder_format_messages():
|
||||
"""Test message formatting to GenAI convention."""
|
||||
mock_tracer = MagicMock()
|
||||
recorder = LLMSpanRecorder(mock_tracer)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are helpful"},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
|
||||
result = recorder._format_messages(messages)
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert len(parsed) == 2
|
||||
assert parsed[0]["role"] == "system"
|
||||
assert parsed[0]["content"] == "You are helpful"
|
||||
assert parsed[1]["role"] == "user"
|
||||
assert parsed[1]["content"] == "Hello"
|
||||
|
||||
|
||||
def test_llm_span_recorder_format_output():
|
||||
"""Test output formatting to GenAI convention."""
|
||||
mock_tracer = MagicMock()
|
||||
recorder = LLMSpanRecorder(mock_tracer)
|
||||
|
||||
result = recorder._format_output("Hello world", "stop")
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert len(parsed) == 1
|
||||
assert parsed[0]["role"] == "assistant"
|
||||
assert parsed[0]["content"] == "Hello world"
|
||||
|
||||
|
||||
def test_llm_span_recorder_format_output_none():
|
||||
"""Test output formatting with None content."""
|
||||
mock_tracer = MagicMock()
|
||||
recorder = LLMSpanRecorder(mock_tracer)
|
||||
|
||||
result = recorder._format_output(None, None)
|
||||
parsed = json.loads(result)
|
||||
|
||||
assert parsed == []
|
||||
|
||||
|
||||
def test_llm_span_recorder_extract_system_instructions():
|
||||
"""Test system instruction extraction."""
|
||||
mock_tracer = MagicMock()
|
||||
recorder = LLMSpanRecorder(mock_tracer)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "You are helpful"},
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
|
||||
result = recorder._extract_system_instructions(messages)
|
||||
assert result == "You are helpful"
|
||||
|
||||
|
||||
def test_llm_span_recorder_extract_system_instructions_none():
|
||||
"""Test system instruction extraction with no system message."""
|
||||
mock_tracer = MagicMock()
|
||||
recorder = LLMSpanRecorder(mock_tracer)
|
||||
|
||||
messages = [
|
||||
{"role": "user", "content": "Hello"},
|
||||
]
|
||||
|
||||
result = recorder._extract_system_instructions(messages)
|
||||
assert result is None
|
||||
|
||||
|
||||
@patch("hindsight_api.tracing.time")
|
||||
def test_llm_span_recorder_record_success(mock_time):
|
||||
"""Test successful LLM call recording."""
|
||||
# Mock time
|
||||
mock_time.time_ns.return_value = 1000000000000 # 1 second in nanoseconds
|
||||
|
||||
# Create mock tracer and span
|
||||
mock_span = MagicMock()
|
||||
mock_tracer = MagicMock()
|
||||
mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
|
||||
|
||||
recorder = LLMSpanRecorder(mock_tracer)
|
||||
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
response_content = "Hi there!"
|
||||
|
||||
recorder.record_llm_call(
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
scope="test",
|
||||
messages=messages,
|
||||
response_content=response_content,
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
duration=1.5,
|
||||
finish_reason="stop",
|
||||
error=None,
|
||||
)
|
||||
|
||||
# Verify span was created with correct name (hindsight.{scope})
|
||||
mock_tracer.start_as_current_span.assert_called_once()
|
||||
call_args = mock_tracer.start_as_current_span.call_args
|
||||
assert call_args[0][0] == "hindsight.test"
|
||||
|
||||
# Verify attributes were set
|
||||
assert mock_span.set_attribute.called
|
||||
attribute_calls = {call[0][0]: call[0][1] for call in mock_span.set_attribute.call_args_list}
|
||||
|
||||
assert attribute_calls[GenAIAttributes.OPERATION_NAME] == "chat"
|
||||
assert attribute_calls[GenAIAttributes.PROVIDER_NAME] == "openai"
|
||||
assert attribute_calls[GenAIAttributes.REQUEST_MODEL] == "gpt-4"
|
||||
assert attribute_calls[GenAIAttributes.RESPONSE_MODEL] == "gpt-4"
|
||||
assert attribute_calls[GenAIAttributes.USAGE_INPUT_TOKENS] == 10
|
||||
assert attribute_calls[GenAIAttributes.USAGE_OUTPUT_TOKENS] == 5
|
||||
assert attribute_calls["hindsight.scope"] == "test"
|
||||
|
||||
# Verify event was added
|
||||
mock_span.add_event.assert_called_once()
|
||||
event_call = mock_span.add_event.call_args
|
||||
assert event_call[0][0] == "gen_ai.client.inference.operation.details"
|
||||
|
||||
# Verify status was set to OK
|
||||
mock_span.set_status.assert_called()
|
||||
|
||||
# Verify span was ended
|
||||
mock_span.end.assert_called_once()
|
||||
|
||||
|
||||
@patch("hindsight_api.tracing.time")
|
||||
def test_llm_span_recorder_record_error(mock_time):
|
||||
"""Test error LLM call recording."""
|
||||
# Mock time
|
||||
mock_time.time_ns.return_value = 1000000000000
|
||||
|
||||
# Create mock tracer and span
|
||||
mock_span = MagicMock()
|
||||
mock_tracer = MagicMock()
|
||||
mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
|
||||
|
||||
recorder = LLMSpanRecorder(mock_tracer)
|
||||
|
||||
messages = [{"role": "user", "content": "Hello"}]
|
||||
error = ValueError("Test error")
|
||||
|
||||
recorder.record_llm_call(
|
||||
provider="anthropic",
|
||||
model="claude-3",
|
||||
scope="test",
|
||||
messages=messages,
|
||||
response_content=None,
|
||||
input_tokens=10,
|
||||
output_tokens=0,
|
||||
duration=0.5,
|
||||
finish_reason=None,
|
||||
error=error,
|
||||
)
|
||||
|
||||
# Verify error status was set
|
||||
mock_span.set_status.assert_called()
|
||||
status_call = mock_span.set_status.call_args[0][0]
|
||||
assert status_call.status_code.name == "ERROR"
|
||||
|
||||
# Verify error type attribute was set
|
||||
attribute_calls = {call[0][0]: call[0][1] for call in mock_span.set_attribute.call_args_list}
|
||||
assert attribute_calls[GenAIAttributes.ERROR_TYPE] == "ValueError"
|
||||
|
||||
# Verify exception was recorded
|
||||
mock_span.record_exception.assert_called_once_with(error)
|
||||
|
||||
|
||||
@patch("hindsight_api.tracing.time")
|
||||
def test_llm_span_recorder_provider_mapping(mock_time):
|
||||
"""Test that provider names are mapped correctly."""
|
||||
mock_time.time_ns.return_value = 1000000000000
|
||||
|
||||
mock_span = MagicMock()
|
||||
mock_tracer = MagicMock()
|
||||
mock_tracer.start_as_current_span.return_value.__enter__.return_value = mock_span
|
||||
|
||||
recorder = LLMSpanRecorder(mock_tracer)
|
||||
|
||||
# Test gemini -> google mapping
|
||||
recorder.record_llm_call(
|
||||
provider="gemini",
|
||||
model="gemini-pro",
|
||||
scope="test",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
response_content="test",
|
||||
input_tokens=5,
|
||||
output_tokens=3,
|
||||
duration=1.0,
|
||||
)
|
||||
|
||||
attribute_calls = {call[0][0]: call[0][1] for call in mock_span.set_attribute.call_args_list}
|
||||
assert attribute_calls[GenAIAttributes.PROVIDER_NAME] == "google"
|
||||
|
||||
|
||||
# ==================== Parent Span Tests ====================
|
||||
|
||||
|
||||
def test_create_operation_span_disabled():
|
||||
"""Test that create_operation_span returns no-op when tracing is disabled."""
|
||||
# Tracing should be disabled by default
|
||||
assert not is_tracing_enabled()
|
||||
|
||||
# Should return a no-op context manager
|
||||
span = create_operation_span("test_operation", "test_bank_id")
|
||||
|
||||
# Should be usable as context manager without errors
|
||||
with span:
|
||||
pass
|
||||
|
||||
|
||||
@patch("hindsight_api.tracing._tracer")
|
||||
@patch("hindsight_api.tracing._tracing_enabled", True)
|
||||
def test_create_operation_span_enabled(mock_tracer):
|
||||
"""Test that create_operation_span creates a span when tracing is enabled."""
|
||||
# Mock the tracer
|
||||
mock_span = MagicMock()
|
||||
mock_tracer.start_as_current_span.return_value = mock_span
|
||||
|
||||
# Create operation span
|
||||
span = create_operation_span("retain", "bank123")
|
||||
|
||||
# Verify span was created with correct name
|
||||
mock_tracer.start_as_current_span.assert_called_once_with("hindsight.retain")
|
||||
|
||||
# Verify attributes were set
|
||||
mock_span.set_attribute.assert_any_call("hindsight.operation", "retain")
|
||||
mock_span.set_attribute.assert_any_call("hindsight.bank_id", "bank123")
|
||||
|
||||
|
||||
@patch("hindsight_api.tracing._tracer")
|
||||
@patch("hindsight_api.tracing._tracing_enabled", True)
|
||||
def test_create_operation_span_no_bank_id(mock_tracer):
|
||||
"""Test that create_operation_span works without bank_id."""
|
||||
mock_span = MagicMock()
|
||||
mock_tracer.start_as_current_span.return_value = mock_span
|
||||
|
||||
# Create operation span without bank_id
|
||||
span = create_operation_span("consolidation")
|
||||
|
||||
# Verify span was created
|
||||
mock_tracer.start_as_current_span.assert_called_once_with("hindsight.consolidation")
|
||||
|
||||
# Verify only operation attribute was set (not bank_id)
|
||||
assert mock_span.set_attribute.call_count == 1
|
||||
mock_span.set_attribute.assert_called_once_with("hindsight.operation", "consolidation")
|
||||
|
||||
|
||||
@patch("hindsight_api.tracing._tracer")
|
||||
@patch("hindsight_api.tracing._tracing_enabled", True)
|
||||
def test_create_operation_span_all_operations(mock_tracer):
|
||||
"""Test that all 4 operations can create parent spans."""
|
||||
mock_span = MagicMock()
|
||||
mock_tracer.start_as_current_span.return_value = mock_span
|
||||
|
||||
operations = ["retain", "consolidation", "reflect", "mental_model_refresh"]
|
||||
|
||||
for operation in operations:
|
||||
mock_tracer.reset_mock()
|
||||
mock_span.reset_mock()
|
||||
|
||||
span = create_operation_span(operation, "test_bank")
|
||||
|
||||
# Verify span was created with correct name
|
||||
mock_tracer.start_as_current_span.assert_called_once_with(f"hindsight.{operation}")
|
||||
|
||||
# Verify attributes
|
||||
mock_span.set_attribute.assert_any_call("hindsight.operation", operation)
|
||||
mock_span.set_attribute.assert_any_call("hindsight.bank_id", "test_bank")
|
||||
|
||||
|
||||
@patch("hindsight_api.tracing.time")
|
||||
@patch("hindsight_api.tracing._tracer")
|
||||
@patch("hindsight_api.tracing._tracing_enabled", True)
|
||||
def test_parent_child_span_hierarchy(mock_tracer, mock_time):
|
||||
"""Test that child LLM spans are created under parent operation spans."""
|
||||
mock_time.time_ns.return_value = 1000000000000
|
||||
|
||||
# Create mock parent span
|
||||
mock_parent_span = MagicMock()
|
||||
mock_parent_span.__enter__ = MagicMock(return_value=mock_parent_span)
|
||||
mock_parent_span.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
# Create mock child span
|
||||
mock_child_span = MagicMock()
|
||||
|
||||
# Mock tracer to return parent span first, then child span
|
||||
mock_tracer.start_as_current_span.side_effect = [
|
||||
mock_parent_span, # Parent span
|
||||
MagicMock(__enter__=MagicMock(return_value=mock_child_span), __exit__=MagicMock(return_value=False)), # Child
|
||||
]
|
||||
|
||||
# Create parent operation span
|
||||
with create_operation_span("retain", "bank123"):
|
||||
# Simulate creating a child LLM span
|
||||
recorder = LLMSpanRecorder(mock_tracer)
|
||||
recorder.record_llm_call(
|
||||
provider="openai",
|
||||
model="gpt-4",
|
||||
scope="retain_extract_facts",
|
||||
messages=[{"role": "user", "content": "test"}],
|
||||
response_content="response",
|
||||
input_tokens=10,
|
||||
output_tokens=5,
|
||||
duration=1.0,
|
||||
)
|
||||
|
||||
# Verify both parent and child spans were created
|
||||
assert mock_tracer.start_as_current_span.call_count == 2
|
||||
|
||||
# Verify parent span was created first
|
||||
first_call = mock_tracer.start_as_current_span.call_args_list[0]
|
||||
assert first_call[0][0] == "hindsight.retain"
|
||||
|
||||
# Verify child span was created second (hindsight.{scope})
|
||||
second_call = mock_tracer.start_as_current_span.call_args_list[1]
|
||||
assert second_call[0][0] == "hindsight.retain_extract_facts"
|
||||
|
||||
|
||||
@patch("hindsight_api.tracing._tracer")
|
||||
@patch("hindsight_api.tracing._tracing_enabled", True)
|
||||
def test_operation_span_context_manager(mock_tracer):
|
||||
"""Test that operation spans work as context managers."""
|
||||
mock_span = MagicMock()
|
||||
mock_span.__enter__ = MagicMock(return_value=mock_span)
|
||||
mock_span.__exit__ = MagicMock(return_value=False)
|
||||
mock_tracer.start_as_current_span.return_value = mock_span
|
||||
|
||||
# Use span as context manager
|
||||
with create_operation_span("reflect", "bank456"):
|
||||
# Do some work
|
||||
pass
|
||||
|
||||
# Verify span lifecycle
|
||||
mock_tracer.start_as_current_span.assert_called_once()
|
||||
mock_span.__enter__.assert_called_once()
|
||||
mock_span.__exit__.assert_called_once()
|
||||
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Integration tests for OpenTelemetry tracing with memory engine operations.
|
||||
|
||||
Tests that parent spans are correctly created for retain, consolidation, reflect,
|
||||
and mental_model_refresh operations.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("hindsight_api.engine.memory_engine.create_operation_span")
|
||||
async def test_retain_creates_parent_span(mock_create_span, memory, request_context):
|
||||
"""Test that retain operation creates a parent span."""
|
||||
# Setup
|
||||
mock_span = MagicMock()
|
||||
mock_span.__enter__ = MagicMock(return_value=mock_span)
|
||||
mock_span.__exit__ = MagicMock(return_value=False)
|
||||
mock_create_span.return_value = mock_span
|
||||
|
||||
bank_id = f"test-retain-{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Execute retain (automatically creates bank if needed)
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Test memory for tracing",
|
||||
context="Test context",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Verify parent span was created
|
||||
mock_create_span.assert_called()
|
||||
call_args = mock_create_span.call_args
|
||||
assert call_args[0][0] == "retain" # operation name
|
||||
assert call_args[0][1] == bank_id # bank_id
|
||||
|
||||
# Verify span was used as context manager
|
||||
mock_span.__enter__.assert_called()
|
||||
mock_span.__exit__.assert_called()
|
||||
finally:
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("hindsight_api.engine.memory_engine.create_operation_span")
|
||||
async def test_consolidation_creates_parent_span(mock_create_span, memory, request_context):
|
||||
"""Test that consolidation operation creates a parent span."""
|
||||
# Setup
|
||||
mock_span = MagicMock()
|
||||
mock_span.__enter__ = MagicMock(return_value=mock_span)
|
||||
mock_span.__exit__ = MagicMock(return_value=False)
|
||||
mock_create_span.return_value = mock_span
|
||||
|
||||
bank_id = f"test-consolidation-{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Execute consolidation (bank will be created automatically)
|
||||
await memory.run_consolidation(
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Verify parent span was created
|
||||
mock_create_span.assert_called()
|
||||
call_args = mock_create_span.call_args
|
||||
assert call_args[0][0] == "consolidation"
|
||||
assert call_args[0][1] == bank_id
|
||||
|
||||
# Verify span was used as context manager
|
||||
mock_span.__enter__.assert_called()
|
||||
mock_span.__exit__.assert_called()
|
||||
finally:
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("hindsight_api.engine.memory_engine.create_operation_span")
|
||||
async def test_reflect_creates_parent_span(mock_create_span, memory, request_context):
|
||||
"""Test that reflect operation creates a parent span."""
|
||||
# Setup
|
||||
mock_span = MagicMock()
|
||||
mock_span.__enter__ = MagicMock(return_value=mock_span)
|
||||
mock_span.__exit__ = MagicMock(return_value=False)
|
||||
mock_create_span.return_value = mock_span
|
||||
|
||||
bank_id = f"test-reflect-{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Add some memories first
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Paris is the capital of France",
|
||||
context="Geography fact",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Reset mock to clear retain call
|
||||
mock_create_span.reset_mock()
|
||||
|
||||
# Execute reflect
|
||||
await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="What is the capital of France?",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Verify parent span was created
|
||||
mock_create_span.assert_called()
|
||||
call_args = mock_create_span.call_args
|
||||
assert call_args[0][0] == "reflect"
|
||||
assert call_args[0][1] == bank_id
|
||||
|
||||
# Verify span was used as context manager
|
||||
mock_span.__enter__.assert_called()
|
||||
mock_span.__exit__.assert_called()
|
||||
finally:
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("hindsight_api.engine.memory_engine.create_operation_span")
|
||||
async def test_retain_batch_creates_single_parent_span(mock_create_span, memory, request_context):
|
||||
"""Test that batch retain creates one parent span for the entire batch."""
|
||||
# Setup
|
||||
mock_span = MagicMock()
|
||||
mock_span.__enter__ = MagicMock(return_value=mock_span)
|
||||
mock_span.__exit__ = MagicMock(return_value=False)
|
||||
mock_create_span.return_value = mock_span
|
||||
|
||||
bank_id = f"test-batch-{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Execute batch retain with multiple items
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{"content": "Memory 1", "context": "Context 1"},
|
||||
{"content": "Memory 2", "context": "Context 2"},
|
||||
{"content": "Memory 3", "context": "Context 3"},
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Verify parent span was created only once for the entire batch
|
||||
assert mock_create_span.call_count == 1
|
||||
call_args = mock_create_span.call_args
|
||||
assert call_args[0][0] == "retain"
|
||||
assert call_args[0][1] == bank_id
|
||||
finally:
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("hindsight_api.tracing._tracing_enabled", False)
|
||||
@patch("hindsight_api.engine.memory_engine.create_operation_span")
|
||||
async def test_operations_work_when_tracing_disabled(mock_create_span, memory, request_context):
|
||||
"""Test that operations work correctly when tracing is disabled."""
|
||||
# Setup - create_operation_span should return a no-op context manager
|
||||
from contextlib import nullcontext
|
||||
|
||||
mock_create_span.return_value = nullcontext()
|
||||
|
||||
bank_id = f"test-no-trace-{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# All operations should work without errors
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Test memory",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
await memory.run_consolidation(
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="Test query",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Verify no errors occurred and spans were attempted to be created
|
||||
assert mock_create_span.call_count >= 3
|
||||
finally:
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
Comprehensive tracing span verification tests.
|
||||
|
||||
Verifies that all memory engine operations create correct parent and child spans
|
||||
with proper attributes and hierarchy.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skip(reason="Background consolidation causes StopIteration - need to investigate separately")
|
||||
@patch("hindsight_api.tracing._tracing_enabled", True)
|
||||
@patch("hindsight_api.tracing._tracer")
|
||||
async def test_recall_span_hierarchy(mock_tracer, memory, request_context):
|
||||
"""Test that recall creates proper parent and child spans."""
|
||||
# Setup mock spans
|
||||
mock_recall_span = MagicMock()
|
||||
mock_recall_span.__enter__ = MagicMock(return_value=mock_recall_span)
|
||||
mock_recall_span.__exit__ = MagicMock(return_value=False)
|
||||
|
||||
mock_embedding_span = MagicMock()
|
||||
mock_retrieval_span = MagicMock()
|
||||
mock_fusion_span = MagicMock()
|
||||
mock_rerank_span = MagicMock()
|
||||
|
||||
# Mock tracer to return spans in sequence
|
||||
mock_tracer.start_as_current_span.side_effect = [mock_recall_span]
|
||||
mock_tracer.start_span.side_effect = [
|
||||
mock_embedding_span,
|
||||
mock_retrieval_span,
|
||||
mock_fusion_span,
|
||||
mock_rerank_span,
|
||||
]
|
||||
|
||||
bank_id = f"test-recall-{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Add some memories first
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Paris is the capital of France",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Wait a bit for any background tasks to settle
|
||||
import asyncio
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
# Reset mocks after retain
|
||||
mock_tracer.reset_mock()
|
||||
mock_recall_span.reset_mock()
|
||||
|
||||
# Execute recall
|
||||
await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="What is the capital of France?",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Verify parent span was created with start_as_current_span
|
||||
assert mock_tracer.start_as_current_span.called
|
||||
parent_call = mock_tracer.start_as_current_span.call_args
|
||||
assert parent_call[0][0] == "hindsight.recall"
|
||||
|
||||
# Verify parent span attributes were set
|
||||
recall_attrs = {call[0][0]: call[0][1] for call in mock_recall_span.set_attribute.call_args_list}
|
||||
assert "hindsight.bank_id" in recall_attrs
|
||||
assert recall_attrs["hindsight.bank_id"] == bank_id
|
||||
assert "hindsight.query" in recall_attrs
|
||||
assert "hindsight.fact_types" in recall_attrs
|
||||
assert "hindsight.thinking_budget" in recall_attrs
|
||||
assert "hindsight.max_tokens" in recall_attrs
|
||||
|
||||
# Verify child spans were created (if tracing is enabled)
|
||||
if mock_tracer.start_span.called:
|
||||
child_spans = [call[0][0] for call in mock_tracer.start_span.call_args_list]
|
||||
assert "hindsight.recall_embedding" in child_spans
|
||||
assert "hindsight.recall_retrieval" in child_spans
|
||||
assert "hindsight.recall_fusion" in child_spans
|
||||
assert "hindsight.recall_rerank" in child_spans
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mental_model_refresh_span_exists(memory, request_context):
|
||||
"""Test that mental model refresh functionality exists (span creation tested via unit tests)."""
|
||||
# This test verifies that refresh_mental_model method exists and can be called
|
||||
# The actual span creation is tested in unit tests with proper mocking
|
||||
bank_id = f"test-mmr-{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Just verify the method exists - it will return None if no mental model found
|
||||
result = await memory.refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id="non-existent-id",
|
||||
request_context=request_context,
|
||||
)
|
||||
# Result will be None since mental model doesn't exist
|
||||
assert result is None
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
try:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_consolidation_child_spans(memory, request_context):
|
||||
"""Test that consolidation creates child spans for its operations."""
|
||||
bank_id = f"test-cons-child-{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Add memories to consolidate
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="The Eiffel Tower is in Paris",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Paris is the capital of France",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Run consolidation (this will create parent + child spans)
|
||||
await memory.run_consolidation(
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Note: We can't easily verify the child spans without mocking the tracer,
|
||||
# but we can verify that consolidation completes successfully
|
||||
# The actual span creation is tested in unit tests
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_tool_call_spans(memory, request_context):
|
||||
"""Test that reflect creates tool call spans (not reflect_generation)."""
|
||||
bank_id = f"test-reflect-tools-{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Add some memories
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Machine learning is a subset of AI",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Execute reflect (will create reflect_tool_call spans)
|
||||
result = await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="What is machine learning?",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Verify reflect completed successfully
|
||||
assert result.text
|
||||
assert len(result.text) > 0
|
||||
|
||||
# The span names are verified via unit tests with mocked tracers
|
||||
# This integration test ensures the operation completes successfully
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_all_operations_create_spans(memory, request_context):
|
||||
"""Comprehensive test that all operations create their respective spans."""
|
||||
bank_id = f"test-all-ops-{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# 1. Retain operation
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Test memory for comprehensive span test",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# 2. Recall operation
|
||||
await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="test memory",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# 3. Reflect operation
|
||||
await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="What can you tell me about the test?",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# 4. Consolidation operation
|
||||
await memory.run_consolidation(
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# All operations completed successfully
|
||||
# Span hierarchy verification is done in unit tests with mocked tracers
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("hindsight_api.tracing._tracing_enabled", True)
|
||||
@patch("hindsight_api.tracing._tracer")
|
||||
async def test_recall_span_attributes(mock_tracer, memory, request_context):
|
||||
"""Verify that recall spans have all required attributes."""
|
||||
# Setup mock span
|
||||
mock_span = MagicMock()
|
||||
mock_span.__enter__ = MagicMock(return_value=mock_span)
|
||||
mock_span.__exit__ = MagicMock(return_value=False)
|
||||
mock_tracer.start_as_current_span.return_value = mock_span
|
||||
|
||||
bank_id = f"test-attrs-{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Add memory
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Test content for attributes",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Reset mock
|
||||
mock_span.reset_mock()
|
||||
|
||||
# Execute recall with specific parameters
|
||||
await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="test query for attributes",
|
||||
fact_type=["world", "experience"],
|
||||
max_tokens=2048,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Collect all attributes set on the span
|
||||
attrs = {call[0][0]: call[0][1] for call in mock_span.set_attribute.call_args_list}
|
||||
|
||||
# Verify required attributes
|
||||
assert "hindsight.bank_id" in attrs
|
||||
assert "hindsight.query" in attrs
|
||||
assert "hindsight.fact_types" in attrs
|
||||
assert "hindsight.max_tokens" in attrs
|
||||
assert "hindsight.thinking_budget" in attrs
|
||||
|
||||
# Verify attribute values
|
||||
assert attrs["hindsight.bank_id"] == bank_id
|
||||
assert "test query" in attrs["hindsight.query"]
|
||||
assert attrs["hindsight.max_tokens"] == 2048
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hindsight-cli"
|
||||
version = "0.4.9"
|
||||
version = "0.4.10"
|
||||
edition = "2021"
|
||||
authors = ["Hindsight Team"]
|
||||
description = "A beautiful CLI for Hindsight - semantic memory system"
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
@@ -489,7 +489,7 @@ class Configuration:
|
||||
return "Python SDK Debug Report:\n"\
|
||||
"OS: {env}\n"\
|
||||
"Python Version: {pyversion}\n"\
|
||||
"Version of the API: 0.4.9\n"\
|
||||
"Version of the API: 0.4.10\n"\
|
||||
"SDK Package Version: 0.0.7".\
|
||||
format(env=sys.platform, pyversion=sys.version)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.4.9
|
||||
The version of the OpenAPI document: 0.4.10
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user