Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
10bff9a1da | ||
|
|
3fa39f3385 | ||
|
|
7d95a002c7 | ||
|
|
83ca669011 | ||
|
|
e798979733 | ||
|
|
43f9a8bec2 | ||
|
|
f641b30d83 | ||
|
|
90be7c6829 | ||
|
|
6eec83b20d | ||
|
|
dd1e0986a1 | ||
|
|
69dec8ec34 | ||
|
|
888b50de12 | ||
|
|
fb7be3eced | ||
|
|
4499254f6d | ||
|
|
9943957fb7 | ||
|
|
03f47e29c8 |
@@ -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
|
||||
|
||||
+3
-1
@@ -53,4 +53,6 @@ hindsight-clients/rust/target
|
||||
whats-next.md
|
||||
TASK.md
|
||||
# Changelog is now tracked in hindsight-docs/src/pages/changelog.md
|
||||
# CHANGELOG.md
|
||||
# CHANGELOG.md
|
||||
|
||||
blog-post*
|
||||
@@ -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)
|
||||
|
||||
@@ -42,6 +42,16 @@ If you need more control over how and when your agent stores and recalls memorie
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
> 🤖 **Using a coding agent?** Install the Hindsight documentation skill for instant access to docs while you code:
|
||||
> ```bash
|
||||
> npx skills add https://github.com/vectorize-io/hindsight --skill hindsight-docs
|
||||
> ```
|
||||
> Works with Claude Code, Cursor, and other AI coding assistants.
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Quick Start
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
# Set to false when using external providers (TEI, OpenAI, Cohere)
|
||||
# PRELOAD_ML_MODELS=true/false - Pre-download ML models during build (default: true)
|
||||
# Only effective when INCLUDE_LOCAL_MODELS=true
|
||||
# NOTE: tiktoken encodings are ALWAYS preloaded (required for air-gapped deployments)
|
||||
#
|
||||
# Examples:
|
||||
# docker build -t hindsight . # Both (standalone)
|
||||
@@ -167,6 +168,28 @@ USER hindsight
|
||||
|
||||
ENV PATH="/app/api/.venv/bin:${PATH}"
|
||||
|
||||
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
|
||||
# Tiktoken is a core runtime dependency, not an optional ML model
|
||||
RUN MAX_RETRIES=3; \
|
||||
RETRY_DELAY=5; \
|
||||
for i in $(seq 1 $MAX_RETRIES); do \
|
||||
echo "Attempt $i/$MAX_RETRIES: Downloading tiktoken encoding..."; \
|
||||
/app/api/.venv/bin/python -c "\
|
||||
import tiktoken; \
|
||||
print('Downloading cl100k_base encoding...'); \
|
||||
tiktoken.get_encoding('cl100k_base'); \
|
||||
print('Tiktoken encoding cached successfully')" && break; \
|
||||
if [ $i -lt $MAX_RETRIES ]; then \
|
||||
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
|
||||
sleep $RETRY_DELAY; \
|
||||
RETRY_DELAY=$((RETRY_DELAY * 2)); \
|
||||
fi; \
|
||||
done; \
|
||||
if [ $i -eq $MAX_RETRIES ]; then \
|
||||
echo "ERROR: Failed to download tiktoken encoding after $MAX_RETRIES attempts"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Pre-download ML models to avoid runtime download (conditional)
|
||||
# Only runs if both PRELOAD_ML_MODELS=true AND INCLUDE_LOCAL_MODELS=true
|
||||
# Includes retry logic with exponential backoff for transient network failures
|
||||
@@ -185,7 +208,6 @@ print('Downloading embedding model...'); \
|
||||
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
|
||||
print('Downloading cross-encoder model...'); \
|
||||
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \
|
||||
print('Downloading tiktoken encoding...'); import tiktoken; tiktoken.get_encoding('cl100k_base'); \
|
||||
print('Models cached successfully')" && break; \
|
||||
if [ $i -lt $MAX_RETRIES ]; then \
|
||||
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
|
||||
@@ -297,6 +319,28 @@ USER hindsight
|
||||
|
||||
ENV PATH="/app/api/.venv/bin:${PATH}"
|
||||
|
||||
# Pre-download tiktoken encoding (ALWAYS - required for token counting even in air-gapped envs)
|
||||
# Tiktoken is a core runtime dependency, not an optional ML model
|
||||
RUN MAX_RETRIES=3; \
|
||||
RETRY_DELAY=5; \
|
||||
for i in $(seq 1 $MAX_RETRIES); do \
|
||||
echo "Attempt $i/$MAX_RETRIES: Downloading tiktoken encoding..."; \
|
||||
/app/api/.venv/bin/python -c "\
|
||||
import tiktoken; \
|
||||
print('Downloading cl100k_base encoding...'); \
|
||||
tiktoken.get_encoding('cl100k_base'); \
|
||||
print('Tiktoken encoding cached successfully')" && break; \
|
||||
if [ $i -lt $MAX_RETRIES ]; then \
|
||||
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
|
||||
sleep $RETRY_DELAY; \
|
||||
RETRY_DELAY=$((RETRY_DELAY * 2)); \
|
||||
fi; \
|
||||
done; \
|
||||
if [ $i -eq $MAX_RETRIES ]; then \
|
||||
echo "ERROR: Failed to download tiktoken encoding after $MAX_RETRIES attempts"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
# Pre-download ML models to avoid runtime download (conditional)
|
||||
# Only runs if both PRELOAD_ML_MODELS=true AND INCLUDE_LOCAL_MODELS=true
|
||||
# Includes retry logic with exponential backoff for transient network failures
|
||||
@@ -315,7 +359,6 @@ print('Downloading embedding model...'); \
|
||||
SentenceTransformer('BAAI/bge-small-en-v1.5'); \
|
||||
print('Downloading cross-encoder model...'); \
|
||||
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2'); \
|
||||
print('Downloading tiktoken encoding...'); import tiktoken; tiktoken.get_encoding('cl100k_base'); \
|
||||
print('Models cached successfully')" && break; \
|
||||
if [ $i -lt $MAX_RETRIES ]; then \
|
||||
echo "Attempt $i failed, retrying in ${RETRY_DELAY}s..."; \
|
||||
|
||||
@@ -127,6 +127,38 @@ API URL for control plane
|
||||
{{- printf "http://%s-api:%d" (include "hindsight.fullname" .) (.Values.api.service.port | int) }}
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
TEI reranker labels
|
||||
*/}}
|
||||
{{- define "hindsight.tei.reranker.labels" -}}
|
||||
{{ include "hindsight.labels" . }}
|
||||
app.kubernetes.io/component: tei-reranker
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
TEI reranker selector labels
|
||||
*/}}
|
||||
{{- define "hindsight.tei.reranker.selectorLabels" -}}
|
||||
{{ include "hindsight.selectorLabels" . }}
|
||||
app.kubernetes.io/component: tei-reranker
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
TEI embedding labels
|
||||
*/}}
|
||||
{{- define "hindsight.tei.embedding.labels" -}}
|
||||
{{ include "hindsight.labels" . }}
|
||||
app.kubernetes.io/component: tei-embedding
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
TEI embedding selector labels
|
||||
*/}}
|
||||
{{- define "hindsight.tei.embedding.selectorLabels" -}}
|
||||
{{ include "hindsight.selectorLabels" . }}
|
||||
app.kubernetes.io/component: tei-embedding
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Get the name of the secret to use
|
||||
*/}}
|
||||
|
||||
@@ -67,6 +67,18 @@ spec:
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- if .Values.tei.reranker.enabled }}
|
||||
- name: HINDSIGHT_API_RERANKER_PROVIDER
|
||||
value: "tei"
|
||||
- name: HINDSIGHT_API_RERANKER_TEI_URL
|
||||
value: "http://{{ include "hindsight.fullname" . }}-tei-reranker:{{ .Values.tei.reranker.port }}"
|
||||
{{- end }}
|
||||
{{- if .Values.tei.embedding.enabled }}
|
||||
- name: HINDSIGHT_API_EMBEDDINGS_PROVIDER
|
||||
value: "tei"
|
||||
- name: HINDSIGHT_API_EMBEDDINGS_TEI_URL
|
||||
value: "http://{{ include "hindsight.fullname" . }}-tei-embedding:{{ .Values.tei.embedding.port }}"
|
||||
{{- end }}
|
||||
{{- /* Only use api.secrets when not using existingSecret (for chart-managed secrets) */}}
|
||||
{{- if not .Values.existingSecret }}
|
||||
{{- range $key, $value := .Values.api.secrets }}
|
||||
@@ -87,7 +99,7 @@ spec:
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
{{- with (.Values.api.affinity | default .Values.affinity) }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
|
||||
@@ -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 }}
|
||||
@@ -0,0 +1,76 @@
|
||||
{{- if .Values.tei.embedding.enabled }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "hindsight.fullname" . }}-tei-embedding
|
||||
labels:
|
||||
{{- include "hindsight.tei.embedding.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.tei.embedding.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "hindsight.tei.embedding.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "hindsight.tei.embedding.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
serviceAccountName: {{ include "hindsight.serviceAccountName" . }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: tei-embedding
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 10 }}
|
||||
image: "{{ .Values.tei.embedding.image.repository }}:{{ .Values.tei.embedding.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.tei.embedding.image.pullPolicy }}
|
||||
args:
|
||||
- "--model-id"
|
||||
- {{ .Values.tei.embedding.model | quote }}
|
||||
- "--hostname"
|
||||
- "0.0.0.0"
|
||||
{{- range .Values.tei.embedding.args }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.tei.embedding.port }}
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: PORT
|
||||
value: {{ .Values.tei.embedding.port | quote }}
|
||||
{{- range $key, $value := .Values.tei.embedding.env }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.tei.embedding.livenessProbe | nindent 10 }}
|
||||
readinessProbe:
|
||||
{{- toYaml .Values.tei.embedding.readinessProbe | nindent 10 }}
|
||||
resources:
|
||||
{{- toYaml .Values.tei.embedding.resources | nindent 10 }}
|
||||
volumeMounts:
|
||||
- name: model-cache
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: model-cache
|
||||
emptyDir: {}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,17 @@
|
||||
{{- if .Values.tei.embedding.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "hindsight.fullname" . }}-tei-embedding
|
||||
labels:
|
||||
{{- include "hindsight.tei.embedding.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: {{ .Values.tei.embedding.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "hindsight.tei.embedding.selectorLabels" . | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,76 @@
|
||||
{{- if .Values.tei.reranker.enabled }}
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "hindsight.fullname" . }}-tei-reranker
|
||||
labels:
|
||||
{{- include "hindsight.tei.reranker.labels" . | nindent 4 }}
|
||||
spec:
|
||||
replicas: {{ .Values.tei.reranker.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "hindsight.tei.reranker.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "hindsight.tei.reranker.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
serviceAccountName: {{ include "hindsight.serviceAccountName" . }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: tei-reranker
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 10 }}
|
||||
image: "{{ .Values.tei.reranker.image.repository }}:{{ .Values.tei.reranker.image.tag }}"
|
||||
imagePullPolicy: {{ .Values.tei.reranker.image.pullPolicy }}
|
||||
args:
|
||||
- "--model-id"
|
||||
- {{ .Values.tei.reranker.model | quote }}
|
||||
- "--hostname"
|
||||
- "0.0.0.0"
|
||||
{{- range .Values.tei.reranker.args }}
|
||||
- {{ . | quote }}
|
||||
{{- end }}
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.tei.reranker.port }}
|
||||
protocol: TCP
|
||||
env:
|
||||
- name: PORT
|
||||
value: {{ .Values.tei.reranker.port | quote }}
|
||||
{{- range $key, $value := .Values.tei.reranker.env }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.tei.reranker.livenessProbe | nindent 10 }}
|
||||
readinessProbe:
|
||||
{{- toYaml .Values.tei.reranker.readinessProbe | nindent 10 }}
|
||||
resources:
|
||||
{{- toYaml .Values.tei.reranker.resources | nindent 10 }}
|
||||
volumeMounts:
|
||||
- name: model-cache
|
||||
mountPath: /data
|
||||
volumes:
|
||||
- name: model-cache
|
||||
emptyDir: {}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,17 @@
|
||||
{{- if .Values.tei.reranker.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "hindsight.fullname" . }}-tei-reranker
|
||||
labels:
|
||||
{{- include "hindsight.tei.reranker.labels" . | nindent 4 }}
|
||||
spec:
|
||||
type: ClusterIP
|
||||
ports:
|
||||
- port: {{ .Values.tei.reranker.port }}
|
||||
targetPort: http
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "hindsight.tei.reranker.selectorLabels" . | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -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 }}
|
||||
|
||||
+106
-1
@@ -58,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"
|
||||
@@ -122,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: {}
|
||||
|
||||
@@ -165,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"
|
||||
@@ -263,9 +290,87 @@ nodeSelector: {}
|
||||
# Tolerations
|
||||
tolerations: []
|
||||
|
||||
# Affinity
|
||||
# Affinity (applied to all components unless overridden per-component)
|
||||
affinity: {}
|
||||
|
||||
# TEI (Text Embeddings Inference) - optional standalone deployments
|
||||
# for reranking and/or embedding models
|
||||
tei:
|
||||
reranker:
|
||||
enabled: false
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: ghcr.io/huggingface/text-embeddings-inference
|
||||
tag: cpu-1.8.3
|
||||
pullPolicy: IfNotPresent
|
||||
model: "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
||||
port: 8090
|
||||
args:
|
||||
- "--auto-truncate"
|
||||
env:
|
||||
PAYLOAD_LIMIT: "10000000"
|
||||
MAX_CLIENT_BATCH_SIZE: "256"
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8090
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8090
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
embedding:
|
||||
enabled: false
|
||||
replicaCount: 1
|
||||
image:
|
||||
repository: ghcr.io/huggingface/text-embeddings-inference
|
||||
tag: cpu-1.8.3
|
||||
pullPolicy: IfNotPresent
|
||||
model: "sentence-transformers/all-MiniLM-L6-v2"
|
||||
port: 8091
|
||||
args: []
|
||||
env:
|
||||
PAYLOAD_LIMIT: "10000000"
|
||||
MAX_CLIENT_BATCH_SIZE: "256"
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 2Gi
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8091
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 6
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8091
|
||||
initialDelaySeconds: 15
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
# Autoscaling
|
||||
autoscaling:
|
||||
enabled: false
|
||||
|
||||
@@ -6,7 +6,6 @@ Provides both HTTP REST API and MCP (Model Context Protocol) server.
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
@@ -46,14 +45,14 @@ def create_app(
|
||||
# Both HTTP and MCP
|
||||
app = create_app(memory, mcp_api_enabled=True)
|
||||
"""
|
||||
mcp_app = None
|
||||
mcp_servers = None
|
||||
|
||||
# Create MCP app first if enabled (we need its lifespan for chaining)
|
||||
# Create MCP servers first if enabled (we need their lifespans for chaining)
|
||||
if mcp_api_enabled:
|
||||
try:
|
||||
from .mcp import create_mcp_app
|
||||
from .mcp import MCPMiddleware, create_mcp_servers
|
||||
|
||||
mcp_app = create_mcp_app(memory=memory)
|
||||
mcp_servers = create_mcp_servers(memory=memory)
|
||||
except ImportError as e:
|
||||
logger.error(f"MCP server requested but dependencies not available: {e}")
|
||||
logger.error("Install with: pip install hindsight-api[mcp]")
|
||||
@@ -70,11 +69,9 @@ def create_app(
|
||||
app = FastAPI(title="Hindsight API", version="0.0.7")
|
||||
logger.info("HTTP REST API disabled")
|
||||
|
||||
# Mount MCP server and chain its lifespan if enabled
|
||||
if mcp_app is not None:
|
||||
# Get both MCP apps' underlying Starlette apps for lifespan access
|
||||
multi_bank_starlette_app = mcp_app.multi_bank_app
|
||||
single_bank_starlette_app = mcp_app.single_bank_app
|
||||
# Add MCP middleware and chain its lifespan if enabled
|
||||
if mcp_servers is not None:
|
||||
multi_bank_server, single_bank_server, multi_bank_starlette_app, single_bank_starlette_app = mcp_servers
|
||||
|
||||
# Store the original lifespan
|
||||
original_lifespan = app.router.lifespan_context
|
||||
@@ -94,8 +91,19 @@ def create_app(
|
||||
# Replace the app's lifespan with the chained version
|
||||
app.router.lifespan_context = chained_lifespan
|
||||
|
||||
# Mount the MCP middleware
|
||||
app.mount(mcp_mount_path, mcp_app)
|
||||
# Add MCP as a wrapping middleware — intercepts /mcp* requests directly,
|
||||
# passes everything else through to the FastAPI app. No Starlette Mount
|
||||
# means no 307 redirect for /mcp (no trailing slash).
|
||||
app.add_middleware(
|
||||
MCPMiddleware,
|
||||
memory=memory,
|
||||
prefix=mcp_mount_path,
|
||||
multi_bank_app=multi_bank_starlette_app,
|
||||
single_bank_app=single_bank_starlette_app,
|
||||
multi_bank_server=multi_bank_server,
|
||||
single_bank_server=single_bank_server,
|
||||
)
|
||||
|
||||
logger.info(f"MCP server enabled at {mcp_mount_path}/")
|
||||
|
||||
return app
|
||||
|
||||
@@ -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()
|
||||
@@ -2334,23 +2354,6 @@ def _register_routes(app: FastAPI):
|
||||
):
|
||||
"""Get a mental model by ID."""
|
||||
try:
|
||||
# Pre-operation validation hook
|
||||
validator = app.state.memory._operation_validator
|
||||
if validator:
|
||||
from hindsight_api.extensions.operation_validator import MentalModelGetContext
|
||||
|
||||
ctx = MentalModelGetContext(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
validation = await validator.validate_mental_model_get(ctx)
|
||||
if not validation.allowed:
|
||||
raise OperationValidationError(
|
||||
validation.reason or "Operation not allowed",
|
||||
status_code=validation.status_code,
|
||||
)
|
||||
|
||||
mental_model = await app.state.memory.get_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model_id,
|
||||
@@ -2359,25 +2362,6 @@ def _register_routes(app: FastAPI):
|
||||
if mental_model is None:
|
||||
raise HTTPException(status_code=404, detail=f"Mental model '{mental_model_id}' not found")
|
||||
|
||||
# Post-operation hook
|
||||
if validator:
|
||||
from hindsight_api.extensions.operation_validator import MentalModelGetResult
|
||||
|
||||
content = mental_model.get("content", "")
|
||||
output_tokens = len(content) // 4 if content else 0
|
||||
|
||||
result_ctx = MentalModelGetResult(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=request_context,
|
||||
output_tokens=output_tokens,
|
||||
success=True,
|
||||
)
|
||||
try:
|
||||
await validator.on_mental_model_get_complete(result_ctx)
|
||||
except Exception as hook_err:
|
||||
logger.warning(f"Post-mental-model-get hook error (non-fatal): {hook_err}")
|
||||
|
||||
return MentalModelResponse(**mental_model)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
@@ -2407,23 +2391,6 @@ def _register_routes(app: FastAPI):
|
||||
):
|
||||
"""Create a mental model (async - returns operation_id)."""
|
||||
try:
|
||||
# Pre-operation validation hook
|
||||
validator = app.state.memory._operation_validator
|
||||
if validator:
|
||||
from hindsight_api.extensions.operation_validator import MentalModelRefreshContext
|
||||
|
||||
ctx = MentalModelRefreshContext(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=None, # Not yet created
|
||||
request_context=request_context,
|
||||
)
|
||||
validation = await validator.validate_mental_model_refresh(ctx)
|
||||
if not validation.allowed:
|
||||
raise OperationValidationError(
|
||||
validation.reason or "Operation not allowed",
|
||||
status_code=validation.status_code,
|
||||
)
|
||||
|
||||
# 1. Create the mental model with placeholder content
|
||||
mental_model = await app.state.memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
@@ -2471,23 +2438,6 @@ def _register_routes(app: FastAPI):
|
||||
):
|
||||
"""Refresh a mental model by re-running its source query (async)."""
|
||||
try:
|
||||
# Pre-operation validation hook
|
||||
validator = app.state.memory._operation_validator
|
||||
if validator:
|
||||
from hindsight_api.extensions.operation_validator import MentalModelRefreshContext
|
||||
|
||||
ctx = MentalModelRefreshContext(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
validation = await validator.validate_mental_model_refresh(ctx)
|
||||
if not validation.allowed:
|
||||
raise OperationValidationError(
|
||||
validation.reason or "Operation not allowed",
|
||||
status_code=validation.status_code,
|
||||
)
|
||||
|
||||
result = await app.state.memory.submit_async_refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model_id,
|
||||
|
||||
@@ -43,6 +43,10 @@ _current_bank_id: ContextVar[str | None] = ContextVar("current_bank_id", default
|
||||
# Context variable to hold the current API key (for tenant auth propagation)
|
||||
_current_api_key: ContextVar[str | None] = ContextVar("current_api_key", default=None)
|
||||
|
||||
# Context variables for tenant_id and api_key_id (set by authenticate, used by usage metering)
|
||||
_current_tenant_id: ContextVar[str | None] = ContextVar("current_tenant_id", default=None)
|
||||
_current_api_key_id: ContextVar[str | None] = ContextVar("current_api_key_id", default=None)
|
||||
|
||||
|
||||
def get_current_bank_id() -> str | None:
|
||||
"""Get the current bank_id from context."""
|
||||
@@ -54,6 +58,16 @@ def get_current_api_key() -> str | None:
|
||||
return _current_api_key.get()
|
||||
|
||||
|
||||
def get_current_tenant_id() -> str | None:
|
||||
"""Get the current tenant_id from context."""
|
||||
return _current_tenant_id.get()
|
||||
|
||||
|
||||
def get_current_api_key_id() -> str | None:
|
||||
"""Get the current api_key_id from context."""
|
||||
return _current_api_key_id.get()
|
||||
|
||||
|
||||
def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
|
||||
"""
|
||||
Create and configure the Hindsight MCP server.
|
||||
@@ -73,8 +87,22 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
|
||||
config = MCPToolsConfig(
|
||||
bank_id_resolver=get_current_bank_id,
|
||||
api_key_resolver=get_current_api_key, # Propagate API key for tenant auth
|
||||
tenant_id_resolver=get_current_tenant_id, # Propagate tenant_id for usage metering
|
||||
api_key_id_resolver=get_current_api_key_id, # Propagate api_key_id for usage metering
|
||||
include_bank_id_param=multi_bank,
|
||||
tools=None if multi_bank else {"retain", "recall", "reflect"}, # Scoped tools for single-bank mode
|
||||
tools=None
|
||||
if multi_bank
|
||||
else {
|
||||
"retain",
|
||||
"recall",
|
||||
"reflect",
|
||||
"list_mental_models",
|
||||
"get_mental_model",
|
||||
"create_mental_model",
|
||||
"update_mental_model",
|
||||
"delete_mental_model",
|
||||
"refresh_mental_model",
|
||||
}, # Scoped tools for single-bank mode (excludes bank management: list_banks, create_bank)
|
||||
retain_fire_and_forget=False, # HTTP MCP supports sync/async modes
|
||||
)
|
||||
|
||||
@@ -90,7 +118,10 @@ def create_mcp_server(memory: MemoryEngine, multi_bank: bool = True) -> FastMCP:
|
||||
|
||||
|
||||
class MCPMiddleware:
|
||||
"""ASGI middleware that handles authentication and routes to appropriate MCP server.
|
||||
"""ASGI middleware that intercepts MCP requests and routes to appropriate MCP server.
|
||||
|
||||
This middleware wraps the main FastAPI app and intercepts requests matching the
|
||||
configured prefix (default: /mcp). Non-MCP requests pass through to the inner app.
|
||||
|
||||
Authentication:
|
||||
1. If HINDSIGHT_API_MCP_AUTH_TOKEN is set (legacy), validates against that token
|
||||
@@ -121,27 +152,33 @@ class MCPMiddleware:
|
||||
--header "X-Bank-Id: my-bank" --header "Authorization: Bearer <token>"
|
||||
"""
|
||||
|
||||
def __init__(self, app, memory: MemoryEngine):
|
||||
def __init__(
|
||||
self,
|
||||
app,
|
||||
memory: MemoryEngine,
|
||||
prefix: str = "/mcp",
|
||||
multi_bank_app=None,
|
||||
single_bank_app=None,
|
||||
multi_bank_server=None,
|
||||
single_bank_server=None,
|
||||
):
|
||||
self.app = app
|
||||
self.prefix = prefix
|
||||
self.memory = memory
|
||||
self.tenant_extension = memory._tenant_extension
|
||||
|
||||
# Create two server instances:
|
||||
# 1. Multi-bank server (for /mcp/ root endpoint)
|
||||
self.multi_bank_server = create_mcp_server(memory, multi_bank=True)
|
||||
self.multi_bank_app = self.multi_bank_server.http_app(path="/")
|
||||
|
||||
# 2. Single-bank server (for /mcp/{bank_id}/ endpoints)
|
||||
self.single_bank_server = create_mcp_server(memory, multi_bank=False)
|
||||
self.single_bank_app = self.single_bank_server.http_app(path="/")
|
||||
|
||||
# Backward compatibility: expose multi_bank_app as mcp_app
|
||||
self.mcp_app = self.multi_bank_app
|
||||
|
||||
# Expose the lifespan for the parent app to chain (use multi-bank as default)
|
||||
self.lifespan = (
|
||||
self.multi_bank_app.lifespan_handler if hasattr(self.multi_bank_app, "lifespan_handler") else None
|
||||
)
|
||||
if multi_bank_app and single_bank_app:
|
||||
# Pre-created servers (used when called via add_middleware from create_app)
|
||||
self.multi_bank_app = multi_bank_app
|
||||
self.single_bank_app = single_bank_app
|
||||
self.multi_bank_server = multi_bank_server
|
||||
self.single_bank_server = single_bank_server
|
||||
else:
|
||||
# Create servers internally (for direct construction / tests)
|
||||
self.multi_bank_server = create_mcp_server(memory, multi_bank=True)
|
||||
self.multi_bank_app = self.multi_bank_server.http_app(path="/")
|
||||
self.single_bank_server = create_mcp_server(memory, multi_bank=False)
|
||||
self.single_bank_app = self.single_bank_server.http_app(path="/")
|
||||
|
||||
def _get_header(self, scope: dict, name: str) -> str | None:
|
||||
"""Extract a header value from ASGI scope."""
|
||||
@@ -153,9 +190,20 @@ class MCPMiddleware:
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.multi_bank_app(scope, receive, send)
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get("path", "")
|
||||
|
||||
# Check if this is an MCP request (matches prefix)
|
||||
if not (path == self.prefix or path.startswith(self.prefix + "/")):
|
||||
# Not an MCP request — pass through to the inner app
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
# Strip prefix from path
|
||||
path = path[len(self.prefix) :] or "/"
|
||||
|
||||
# Extract auth token from header (for tenant auth propagation)
|
||||
auth_header = self._get_header(scope, "Authorization")
|
||||
auth_token: str | None = None
|
||||
@@ -165,6 +213,8 @@ class MCPMiddleware:
|
||||
|
||||
# Authenticate: check legacy MCP_AUTH_TOKEN first, then TenantExtension
|
||||
tenant_context = None
|
||||
auth_tenant_id: str | None = None
|
||||
auth_api_key_id: str | None = None
|
||||
if MCP_AUTH_TOKEN:
|
||||
# Legacy authentication mode - validate against static token
|
||||
if not auth_token:
|
||||
@@ -178,7 +228,11 @@ class MCPMiddleware:
|
||||
else:
|
||||
# Use TenantExtension.authenticate_mcp() for auth
|
||||
try:
|
||||
tenant_context = await self.tenant_extension.authenticate_mcp(RequestContext(api_key=auth_token))
|
||||
auth_context = RequestContext(api_key=auth_token)
|
||||
tenant_context = await self.tenant_extension.authenticate_mcp(auth_context)
|
||||
# Capture tenant_id and api_key_id set by authenticate() for usage metering
|
||||
auth_tenant_id = auth_context.tenant_id
|
||||
auth_api_key_id = auth_context.api_key_id
|
||||
except AuthenticationError as e:
|
||||
await self._send_error(send, 401, str(e))
|
||||
return
|
||||
@@ -188,36 +242,15 @@ class MCPMiddleware:
|
||||
_current_schema.set(tenant_context.schema_name) if tenant_context and tenant_context.schema_name else None
|
||||
)
|
||||
|
||||
path = scope.get("path", "")
|
||||
|
||||
# Strip any mount prefix (e.g., /mcp) that FastAPI might not have stripped
|
||||
root_path = scope.get("root_path", "")
|
||||
if root_path and path.startswith(root_path):
|
||||
path = path[len(root_path) :] or "/"
|
||||
|
||||
# Also handle case where mount path wasn't stripped (e.g., /mcp/...)
|
||||
if path.startswith("/mcp/"):
|
||||
path = path[4:] # Remove /mcp prefix
|
||||
elif path == "/mcp":
|
||||
path = "/"
|
||||
|
||||
# Ensure path has leading slash (needed after stripping mount path)
|
||||
if path and not path.startswith("/"):
|
||||
path = "/" + path
|
||||
|
||||
# Try to get bank_id from header first (for Claude Code compatibility)
|
||||
bank_id = self._get_header(scope, "X-Bank-Id")
|
||||
bank_id_from_path = False
|
||||
|
||||
# MCP endpoint paths that should not be treated as bank_ids
|
||||
MCP_ENDPOINTS = {"sse", "messages"}
|
||||
|
||||
# If no header, try to extract from path: /{bank_id}/...
|
||||
new_path = path
|
||||
if not bank_id and path.startswith("/") and len(path) > 1:
|
||||
parts = path[1:].split("/", 1)
|
||||
# Don't treat MCP endpoints as bank_ids
|
||||
if parts[0] and parts[0] not in MCP_ENDPOINTS:
|
||||
if parts[0]:
|
||||
# First segment looks like a bank_id
|
||||
bank_id = parts[0]
|
||||
bank_id_from_path = True
|
||||
@@ -233,19 +266,32 @@ class MCPMiddleware:
|
||||
# - Header/env bank_id → multi-bank app (bank_id param, all tools)
|
||||
target_app = self.single_bank_app if bank_id_from_path else self.multi_bank_app
|
||||
|
||||
# Set bank_id and api_key context
|
||||
# Set bank_id, api_key, tenant_id, and api_key_id context
|
||||
bank_id_token = _current_bank_id.set(bank_id)
|
||||
# Store the auth token for tenant extension to validate
|
||||
api_key_token = _current_api_key.set(auth_token) if auth_token else None
|
||||
# Store tenant_id and api_key_id from authentication for usage metering
|
||||
tenant_id_token = _current_tenant_id.set(auth_tenant_id) if auth_tenant_id else None
|
||||
api_key_id_token = _current_api_key_id.set(auth_api_key_id) if auth_api_key_id else None
|
||||
try:
|
||||
new_scope = scope.copy()
|
||||
new_scope["path"] = new_path
|
||||
# Clear root_path since we're passing directly to the app
|
||||
new_scope["root_path"] = ""
|
||||
|
||||
# Wrap send to rewrite the SSE endpoint URL to include bank_id if using path-based routing
|
||||
# Wrap send to rewrite the SSE endpoint URL to include bank_id if using path-based routing.
|
||||
# Only rewrite SSE (text/event-stream) responses to avoid corrupting tool results
|
||||
# that might contain the literal string "data: /messages".
|
||||
is_sse_response = False
|
||||
|
||||
async def send_wrapper(message):
|
||||
if message["type"] == "http.response.body" and bank_id_from_path:
|
||||
nonlocal is_sse_response
|
||||
if message["type"] == "http.response.start":
|
||||
for header_name, header_value in message.get("headers", []):
|
||||
if header_name == b"content-type" and b"text/event-stream" in header_value:
|
||||
is_sse_response = True
|
||||
break
|
||||
if message["type"] == "http.response.body" and bank_id_from_path and is_sse_response:
|
||||
body = message.get("body", b"")
|
||||
if body and b"/messages" in body:
|
||||
# Rewrite /messages to /{bank_id}/messages in SSE endpoint event
|
||||
@@ -258,6 +304,10 @@ class MCPMiddleware:
|
||||
_current_bank_id.reset(bank_id_token)
|
||||
if api_key_token is not None:
|
||||
_current_api_key.reset(api_key_token)
|
||||
if tenant_id_token is not None:
|
||||
_current_tenant_id.reset(tenant_id_token)
|
||||
if api_key_id_token is not None:
|
||||
_current_api_key_id.reset(api_key_id_token)
|
||||
if schema_token is not None:
|
||||
_current_schema.reset(schema_token)
|
||||
|
||||
@@ -279,30 +329,19 @@ class MCPMiddleware:
|
||||
)
|
||||
|
||||
|
||||
def create_mcp_app(memory: MemoryEngine):
|
||||
"""
|
||||
Create an ASGI app that handles MCP requests with dynamic tool exposure.
|
||||
def create_mcp_servers(memory: MemoryEngine):
|
||||
"""Create multi-bank and single-bank MCP servers and their Starlette apps.
|
||||
|
||||
Authentication:
|
||||
Uses the TenantExtension from the MemoryEngine (same auth as REST API).
|
||||
|
||||
Two modes based on URL structure:
|
||||
|
||||
1. Single-bank mode (recommended for agent isolation):
|
||||
- URL: /mcp/{bank_id}/
|
||||
- Tools: retain, recall, reflect (no bank_id parameter)
|
||||
- Example: claude mcp add --transport http my-agent http://localhost:8888/mcp/my-agent-bank/
|
||||
|
||||
2. Multi-bank mode (for cross-bank operations):
|
||||
- URL: /mcp/
|
||||
- Tools: retain, recall, reflect, list_banks, create_bank (all with bank_id parameter)
|
||||
- Bank ID from: X-Bank-Id header or HINDSIGHT_MCP_BANK_ID env var (default: "default")
|
||||
- Example: claude mcp add --transport http hindsight http://localhost:8888/mcp --header "X-Bank-Id: my-bank"
|
||||
|
||||
Args:
|
||||
memory: MemoryEngine instance
|
||||
Returns the servers and apps separately so lifespans can be chained before
|
||||
the middleware wraps the main app.
|
||||
|
||||
Returns:
|
||||
ASGI application
|
||||
Tuple of (multi_bank_server, single_bank_server, multi_bank_app, single_bank_app)
|
||||
"""
|
||||
return MCPMiddleware(None, memory)
|
||||
multi_bank_server = create_mcp_server(memory, multi_bank=True)
|
||||
multi_bank_app = multi_bank_server.http_app(path="/")
|
||||
|
||||
single_bank_server = create_mcp_server(memory, multi_bank=False)
|
||||
single_bank_app = single_bank_server.http_app(path="/")
|
||||
|
||||
return multi_bank_server, single_bank_server, multi_bank_app, single_bank_app
|
||||
|
||||
@@ -66,27 +66,40 @@ ENV_CONSOLIDATION_LLM_TIMEOUT = "HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT"
|
||||
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
|
||||
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
|
||||
ENV_EMBEDDINGS_LOCAL_FORCE_CPU = "HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"
|
||||
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = "HINDSIGHT_API_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE"
|
||||
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
|
||||
ENV_EMBEDDINGS_OPENAI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY"
|
||||
ENV_EMBEDDINGS_OPENAI_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"
|
||||
ENV_EMBEDDINGS_OPENAI_BASE_URL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL"
|
||||
|
||||
ENV_COHERE_API_KEY = "HINDSIGHT_API_COHERE_API_KEY"
|
||||
# Cohere configuration (separate for embeddings and reranker)
|
||||
ENV_EMBEDDINGS_COHERE_API_KEY = "HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY"
|
||||
ENV_EMBEDDINGS_COHERE_MODEL = "HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL"
|
||||
ENV_EMBEDDINGS_COHERE_BASE_URL = "HINDSIGHT_API_EMBEDDINGS_COHERE_BASE_URL"
|
||||
ENV_RERANKER_COHERE_API_KEY = "HINDSIGHT_API_RERANKER_COHERE_API_KEY"
|
||||
ENV_RERANKER_COHERE_MODEL = "HINDSIGHT_API_RERANKER_COHERE_MODEL"
|
||||
ENV_RERANKER_COHERE_BASE_URL = "HINDSIGHT_API_RERANKER_COHERE_BASE_URL"
|
||||
|
||||
# LiteLLM gateway configuration (for embeddings and reranker via LiteLLM proxy)
|
||||
# Deprecated: Legacy shared Cohere API key (for backward compatibility)
|
||||
ENV_COHERE_API_KEY = "HINDSIGHT_API_COHERE_API_KEY"
|
||||
|
||||
# LiteLLM configuration (separate for embeddings and reranker)
|
||||
ENV_EMBEDDINGS_LITELLM_API_BASE = "HINDSIGHT_API_EMBEDDINGS_LITELLM_API_BASE"
|
||||
ENV_EMBEDDINGS_LITELLM_API_KEY = "HINDSIGHT_API_EMBEDDINGS_LITELLM_API_KEY"
|
||||
ENV_EMBEDDINGS_LITELLM_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL"
|
||||
ENV_RERANKER_LITELLM_API_BASE = "HINDSIGHT_API_RERANKER_LITELLM_API_BASE"
|
||||
ENV_RERANKER_LITELLM_API_KEY = "HINDSIGHT_API_RERANKER_LITELLM_API_KEY"
|
||||
ENV_RERANKER_LITELLM_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_MODEL"
|
||||
|
||||
# Deprecated: Legacy shared LiteLLM config (for backward compatibility)
|
||||
ENV_LITELLM_API_BASE = "HINDSIGHT_API_LITELLM_API_BASE"
|
||||
ENV_LITELLM_API_KEY = "HINDSIGHT_API_LITELLM_API_KEY"
|
||||
ENV_EMBEDDINGS_LITELLM_MODEL = "HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL"
|
||||
ENV_RERANKER_LITELLM_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_MODEL"
|
||||
|
||||
ENV_RERANKER_PROVIDER = "HINDSIGHT_API_RERANKER_PROVIDER"
|
||||
ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
|
||||
ENV_RERANKER_LOCAL_FORCE_CPU = "HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"
|
||||
ENV_RERANKER_LOCAL_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT"
|
||||
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE = "HINDSIGHT_API_RERANKER_LOCAL_TRUST_REMOTE_CODE"
|
||||
ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL"
|
||||
ENV_RERANKER_TEI_BATCH_SIZE = "HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE"
|
||||
ENV_RERANKER_TEI_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT"
|
||||
@@ -108,6 +121,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"
|
||||
@@ -183,6 +203,7 @@ DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = None # Optional, uses ADC if not set
|
||||
DEFAULT_EMBEDDINGS_PROVIDER = "local"
|
||||
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
|
||||
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
|
||||
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE = False # Security: disabled by default, required for some models
|
||||
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
|
||||
DEFAULT_EMBEDDING_DIMENSION = 384
|
||||
|
||||
@@ -190,6 +211,9 @@ DEFAULT_RERANKER_PROVIDER = "local"
|
||||
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
||||
DEFAULT_RERANKER_LOCAL_FORCE_CPU = False # Force CPU mode for local reranker (avoids MPS/XPC issues on macOS)
|
||||
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound reranking to prevent thrashing
|
||||
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE = (
|
||||
False # Security: disabled by default, required for some models like jina-reranker-v2
|
||||
)
|
||||
DEFAULT_RERANKER_TEI_BATCH_SIZE = 128
|
||||
DEFAULT_RERANKER_TEI_MAX_CONCURRENT = 8
|
||||
DEFAULT_RERANKER_MAX_CANDIDATES = 300
|
||||
@@ -251,6 +275,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.
|
||||
|
||||
@@ -381,20 +410,32 @@ class HindsightConfig:
|
||||
embeddings_provider: str
|
||||
embeddings_local_model: str
|
||||
embeddings_local_force_cpu: bool
|
||||
embeddings_local_trust_remote_code: bool
|
||||
embeddings_tei_url: str | None
|
||||
embeddings_openai_base_url: str | None
|
||||
embeddings_cohere_api_key: str | None
|
||||
embeddings_cohere_model: str
|
||||
embeddings_cohere_base_url: str | None
|
||||
embeddings_litellm_api_base: str
|
||||
embeddings_litellm_api_key: str | None
|
||||
embeddings_litellm_model: str
|
||||
|
||||
# Reranker
|
||||
reranker_provider: str
|
||||
reranker_local_model: str
|
||||
reranker_local_force_cpu: bool
|
||||
reranker_local_max_concurrent: int
|
||||
reranker_local_trust_remote_code: bool
|
||||
reranker_tei_url: str | None
|
||||
reranker_tei_batch_size: int
|
||||
reranker_tei_max_concurrent: int
|
||||
reranker_max_candidates: int
|
||||
reranker_cohere_api_key: str | None
|
||||
reranker_cohere_model: str
|
||||
reranker_cohere_base_url: str | None
|
||||
reranker_litellm_api_base: str
|
||||
reranker_litellm_api_key: str | None
|
||||
reranker_litellm_model: str
|
||||
|
||||
# Server
|
||||
host: str
|
||||
@@ -447,6 +488,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
|
||||
@@ -567,9 +615,21 @@ class HindsightConfig:
|
||||
ENV_EMBEDDINGS_LOCAL_FORCE_CPU, str(DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU)
|
||||
).lower()
|
||||
in ("true", "1"),
|
||||
embeddings_local_trust_remote_code=os.getenv(
|
||||
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE)
|
||||
).lower()
|
||||
in ("true", "1"),
|
||||
embeddings_tei_url=os.getenv(ENV_EMBEDDINGS_TEI_URL),
|
||||
embeddings_openai_base_url=os.getenv(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None,
|
||||
# Cohere embeddings (with backward-compatible fallback to shared API key)
|
||||
embeddings_cohere_api_key=os.getenv(ENV_EMBEDDINGS_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
|
||||
embeddings_cohere_model=os.getenv(ENV_EMBEDDINGS_COHERE_MODEL, DEFAULT_EMBEDDINGS_COHERE_MODEL),
|
||||
embeddings_cohere_base_url=os.getenv(ENV_EMBEDDINGS_COHERE_BASE_URL) or None,
|
||||
# LiteLLM embeddings (with backward-compatible fallback to shared config)
|
||||
embeddings_litellm_api_base=os.getenv(ENV_EMBEDDINGS_LITELLM_API_BASE)
|
||||
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
|
||||
embeddings_litellm_api_key=os.getenv(ENV_EMBEDDINGS_LITELLM_API_KEY) or os.getenv(ENV_LITELLM_API_KEY),
|
||||
embeddings_litellm_model=os.getenv(ENV_EMBEDDINGS_LITELLM_MODEL, DEFAULT_EMBEDDINGS_LITELLM_MODEL),
|
||||
# Reranker
|
||||
reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER),
|
||||
reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL),
|
||||
@@ -580,13 +640,25 @@ class HindsightConfig:
|
||||
reranker_local_max_concurrent=int(
|
||||
os.getenv(ENV_RERANKER_LOCAL_MAX_CONCURRENT, str(DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT))
|
||||
),
|
||||
reranker_local_trust_remote_code=os.getenv(
|
||||
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE, str(DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE)
|
||||
).lower()
|
||||
in ("true", "1"),
|
||||
reranker_tei_url=os.getenv(ENV_RERANKER_TEI_URL),
|
||||
reranker_tei_batch_size=int(os.getenv(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE))),
|
||||
reranker_tei_max_concurrent=int(
|
||||
os.getenv(ENV_RERANKER_TEI_MAX_CONCURRENT, str(DEFAULT_RERANKER_TEI_MAX_CONCURRENT))
|
||||
),
|
||||
reranker_max_candidates=int(os.getenv(ENV_RERANKER_MAX_CANDIDATES, str(DEFAULT_RERANKER_MAX_CANDIDATES))),
|
||||
# Cohere reranker (with backward-compatible fallback to shared API key)
|
||||
reranker_cohere_api_key=os.getenv(ENV_RERANKER_COHERE_API_KEY) or os.getenv(ENV_COHERE_API_KEY),
|
||||
reranker_cohere_model=os.getenv(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL),
|
||||
reranker_cohere_base_url=os.getenv(ENV_RERANKER_COHERE_BASE_URL) or None,
|
||||
# LiteLLM reranker (with backward-compatible fallback to shared config)
|
||||
reranker_litellm_api_base=os.getenv(ENV_RERANKER_LITELLM_API_BASE)
|
||||
or os.getenv(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE),
|
||||
reranker_litellm_api_key=os.getenv(ENV_RERANKER_LITELLM_API_KEY) or os.getenv(ENV_LITELLM_API_KEY),
|
||||
reranker_litellm_model=os.getenv(ENV_RERANKER_LITELLM_MODEL, DEFAULT_RERANKER_LITELLM_MODEL),
|
||||
# Server
|
||||
host=os.getenv(ENV_HOST, DEFAULT_HOST),
|
||||
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
|
||||
@@ -646,6 +718,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:
|
||||
|
||||
@@ -24,20 +24,18 @@ from ..config import (
|
||||
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
|
||||
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
|
||||
DEFAULT_RERANKER_LOCAL_MODEL,
|
||||
DEFAULT_RERANKER_LOCAL_TRUST_REMOTE_CODE,
|
||||
DEFAULT_RERANKER_PROVIDER,
|
||||
DEFAULT_RERANKER_TEI_BATCH_SIZE,
|
||||
DEFAULT_RERANKER_TEI_MAX_CONCURRENT,
|
||||
ENV_COHERE_API_KEY,
|
||||
ENV_LITELLM_API_BASE,
|
||||
ENV_LITELLM_API_KEY,
|
||||
ENV_RERANKER_COHERE_BASE_URL,
|
||||
ENV_RERANKER_COHERE_API_KEY,
|
||||
ENV_RERANKER_COHERE_MODEL,
|
||||
ENV_RERANKER_FLASHRANK_CACHE_DIR,
|
||||
ENV_RERANKER_FLASHRANK_MODEL,
|
||||
ENV_RERANKER_LITELLM_MODEL,
|
||||
ENV_RERANKER_LOCAL_FORCE_CPU,
|
||||
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
|
||||
ENV_RERANKER_LOCAL_MODEL,
|
||||
ENV_RERANKER_LOCAL_TRUST_REMOTE_CODE,
|
||||
ENV_RERANKER_PROVIDER,
|
||||
ENV_RERANKER_TEI_BATCH_SIZE,
|
||||
ENV_RERANKER_TEI_MAX_CONCURRENT,
|
||||
@@ -102,7 +100,13 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
_executor: ThreadPoolExecutor | None = None
|
||||
_max_concurrent: int = 4 # Limit concurrent CPU-bound reranking calls
|
||||
|
||||
def __init__(self, model_name: str | None = None, max_concurrent: int = 4, force_cpu: bool = False):
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str | None = None,
|
||||
max_concurrent: int = 4,
|
||||
force_cpu: bool = False,
|
||||
trust_remote_code: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize local SentenceTransformers cross-encoder.
|
||||
|
||||
@@ -113,9 +117,13 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
Higher values may cause CPU thrashing under load.
|
||||
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
|
||||
Default: False
|
||||
trust_remote_code: Allow loading models with custom code (security risk).
|
||||
Required for some models like jina-reranker-v2-base-multilingual.
|
||||
Default: False (disabled for security)
|
||||
"""
|
||||
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
|
||||
self.force_cpu = force_cpu
|
||||
self.trust_remote_code = trust_remote_code
|
||||
self._model = None
|
||||
LocalSTCrossEncoder._max_concurrent = max_concurrent
|
||||
|
||||
@@ -181,6 +189,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
self.model_name,
|
||||
device=device,
|
||||
model_kwargs={"low_cpu_mem_usage": False},
|
||||
trust_remote_code=self.trust_remote_code,
|
||||
)
|
||||
finally:
|
||||
# Restore original logging level
|
||||
@@ -847,23 +856,27 @@ def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
model_name=config.reranker_local_model,
|
||||
max_concurrent=config.reranker_local_max_concurrent,
|
||||
force_cpu=config.reranker_local_force_cpu,
|
||||
trust_remote_code=config.reranker_local_trust_remote_code,
|
||||
)
|
||||
elif provider == "cohere":
|
||||
api_key = os.environ.get(ENV_COHERE_API_KEY)
|
||||
api_key = config.reranker_cohere_api_key
|
||||
if not api_key:
|
||||
raise ValueError(f"{ENV_COHERE_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'cohere'")
|
||||
model = os.environ.get(ENV_RERANKER_COHERE_MODEL, DEFAULT_RERANKER_COHERE_MODEL)
|
||||
base_url = os.environ.get(ENV_RERANKER_COHERE_BASE_URL) or None
|
||||
return CohereCrossEncoder(api_key=api_key, model=model, base_url=base_url)
|
||||
raise ValueError(f"{ENV_RERANKER_COHERE_API_KEY} is required when {ENV_RERANKER_PROVIDER} is 'cohere'")
|
||||
return CohereCrossEncoder(
|
||||
api_key=api_key,
|
||||
model=config.reranker_cohere_model,
|
||||
base_url=config.reranker_cohere_base_url,
|
||||
)
|
||||
elif provider == "flashrank":
|
||||
model = os.environ.get(ENV_RERANKER_FLASHRANK_MODEL, DEFAULT_RERANKER_FLASHRANK_MODEL)
|
||||
cache_dir = os.environ.get(ENV_RERANKER_FLASHRANK_CACHE_DIR, DEFAULT_RERANKER_FLASHRANK_CACHE_DIR)
|
||||
return FlashRankCrossEncoder(model_name=model, cache_dir=cache_dir)
|
||||
elif provider == "litellm":
|
||||
api_base = os.environ.get(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE)
|
||||
api_key = os.environ.get(ENV_LITELLM_API_KEY)
|
||||
model = os.environ.get(ENV_RERANKER_LITELLM_MODEL, DEFAULT_RERANKER_LITELLM_MODEL)
|
||||
return LiteLLMCrossEncoder(api_base=api_base, api_key=api_key, model=model)
|
||||
return LiteLLMCrossEncoder(
|
||||
api_base=config.reranker_litellm_api_base,
|
||||
api_key=config.reranker_litellm_api_key,
|
||||
model=config.reranker_litellm_model,
|
||||
)
|
||||
elif provider == "rrf":
|
||||
return RRFPassthroughCrossEncoder()
|
||||
else:
|
||||
|
||||
@@ -21,22 +21,19 @@ from ..config import (
|
||||
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
|
||||
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
|
||||
DEFAULT_EMBEDDINGS_OPENAI_MODEL,
|
||||
DEFAULT_EMBEDDINGS_PROVIDER,
|
||||
DEFAULT_LITELLM_API_BASE,
|
||||
ENV_COHERE_API_KEY,
|
||||
ENV_EMBEDDINGS_COHERE_BASE_URL,
|
||||
ENV_EMBEDDINGS_COHERE_MODEL,
|
||||
ENV_EMBEDDINGS_LITELLM_MODEL,
|
||||
ENV_EMBEDDINGS_COHERE_API_KEY,
|
||||
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
|
||||
ENV_EMBEDDINGS_LOCAL_MODEL,
|
||||
ENV_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE,
|
||||
ENV_EMBEDDINGS_OPENAI_API_KEY,
|
||||
ENV_EMBEDDINGS_OPENAI_BASE_URL,
|
||||
ENV_EMBEDDINGS_OPENAI_MODEL,
|
||||
ENV_EMBEDDINGS_PROVIDER,
|
||||
ENV_EMBEDDINGS_TEI_URL,
|
||||
ENV_LITELLM_API_BASE,
|
||||
ENV_LITELLM_API_KEY,
|
||||
ENV_LLM_API_KEY,
|
||||
)
|
||||
|
||||
@@ -95,7 +92,7 @@ class LocalSTEmbeddings(Embeddings):
|
||||
The embedding dimension is auto-detected from the model.
|
||||
"""
|
||||
|
||||
def __init__(self, model_name: str | None = None, force_cpu: bool = False):
|
||||
def __init__(self, model_name: str | None = None, force_cpu: bool = False, trust_remote_code: bool = False):
|
||||
"""
|
||||
Initialize local SentenceTransformers embeddings.
|
||||
|
||||
@@ -104,9 +101,13 @@ class LocalSTEmbeddings(Embeddings):
|
||||
Default: BAAI/bge-small-en-v1.5
|
||||
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
|
||||
Default: False
|
||||
trust_remote_code: Allow loading models with custom code (security risk).
|
||||
Required for some models with custom architectures.
|
||||
Default: False (disabled for security)
|
||||
"""
|
||||
self.model_name = model_name or DEFAULT_EMBEDDINGS_LOCAL_MODEL
|
||||
self.force_cpu = force_cpu
|
||||
self.trust_remote_code = trust_remote_code
|
||||
self._model = None
|
||||
self._dimension: int | None = None
|
||||
|
||||
@@ -176,6 +177,7 @@ class LocalSTEmbeddings(Embeddings):
|
||||
self.model_name,
|
||||
device=device,
|
||||
model_kwargs={"low_cpu_mem_usage": False},
|
||||
trust_remote_code=self.trust_remote_code,
|
||||
)
|
||||
finally:
|
||||
# Restore original logging level
|
||||
@@ -741,6 +743,7 @@ def create_embeddings_from_env() -> Embeddings:
|
||||
return LocalSTEmbeddings(
|
||||
model_name=config.embeddings_local_model,
|
||||
force_cpu=config.embeddings_local_force_cpu,
|
||||
trust_remote_code=config.embeddings_local_trust_remote_code,
|
||||
)
|
||||
elif provider == "openai":
|
||||
# Use dedicated embeddings API key, or fall back to LLM API key
|
||||
@@ -754,17 +757,20 @@ def create_embeddings_from_env() -> Embeddings:
|
||||
base_url = os.environ.get(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None
|
||||
return OpenAIEmbeddings(api_key=api_key, model=model, base_url=base_url)
|
||||
elif provider == "cohere":
|
||||
api_key = os.environ.get(ENV_COHERE_API_KEY)
|
||||
api_key = config.embeddings_cohere_api_key
|
||||
if not api_key:
|
||||
raise ValueError(f"{ENV_COHERE_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'cohere'")
|
||||
model = os.environ.get(ENV_EMBEDDINGS_COHERE_MODEL, DEFAULT_EMBEDDINGS_COHERE_MODEL)
|
||||
base_url = os.environ.get(ENV_EMBEDDINGS_COHERE_BASE_URL) or None
|
||||
return CohereEmbeddings(api_key=api_key, model=model, base_url=base_url)
|
||||
raise ValueError(f"{ENV_EMBEDDINGS_COHERE_API_KEY} is required when {ENV_EMBEDDINGS_PROVIDER} is 'cohere'")
|
||||
return CohereEmbeddings(
|
||||
api_key=api_key,
|
||||
model=config.embeddings_cohere_model,
|
||||
base_url=config.embeddings_cohere_base_url,
|
||||
)
|
||||
elif provider == "litellm":
|
||||
api_base = os.environ.get(ENV_LITELLM_API_BASE, DEFAULT_LITELLM_API_BASE)
|
||||
api_key = os.environ.get(ENV_LITELLM_API_KEY)
|
||||
model = os.environ.get(ENV_EMBEDDINGS_LITELLM_MODEL, DEFAULT_EMBEDDINGS_LITELLM_MODEL)
|
||||
return LiteLLMEmbeddings(api_base=api_base, api_key=api_key, model=model)
|
||||
return LiteLLMEmbeddings(
|
||||
api_base=config.embeddings_litellm_api_base,
|
||||
api_key=config.embeddings_litellm_api_key,
|
||||
model=config.embeddings_litellm_model,
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei', 'openai', 'cohere', 'litellm'"
|
||||
|
||||
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,
|
||||
|
||||
@@ -132,6 +132,10 @@ class RetainResult:
|
||||
unit_ids: list[list[str]] # List of unit IDs per content item
|
||||
success: bool = True
|
||||
error: str | None = None
|
||||
# Actual LLM token usage (populated by engine when available)
|
||||
llm_input_tokens: int | None = None
|
||||
llm_output_tokens: int | None = None
|
||||
llm_total_tokens: int | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -197,18 +197,30 @@ def main():
|
||||
embeddings_provider=config.embeddings_provider,
|
||||
embeddings_local_model=config.embeddings_local_model,
|
||||
embeddings_local_force_cpu=config.embeddings_local_force_cpu,
|
||||
embeddings_local_trust_remote_code=config.embeddings_local_trust_remote_code,
|
||||
embeddings_tei_url=config.embeddings_tei_url,
|
||||
embeddings_openai_base_url=config.embeddings_openai_base_url,
|
||||
embeddings_cohere_api_key=config.embeddings_cohere_api_key,
|
||||
embeddings_cohere_model=config.embeddings_cohere_model,
|
||||
embeddings_cohere_base_url=config.embeddings_cohere_base_url,
|
||||
embeddings_litellm_api_base=config.embeddings_litellm_api_base,
|
||||
embeddings_litellm_api_key=config.embeddings_litellm_api_key,
|
||||
embeddings_litellm_model=config.embeddings_litellm_model,
|
||||
reranker_provider=config.reranker_provider,
|
||||
reranker_local_model=config.reranker_local_model,
|
||||
reranker_local_force_cpu=config.reranker_local_force_cpu,
|
||||
reranker_local_max_concurrent=config.reranker_local_max_concurrent,
|
||||
reranker_local_trust_remote_code=config.reranker_local_trust_remote_code,
|
||||
reranker_tei_url=config.reranker_tei_url,
|
||||
reranker_tei_batch_size=config.reranker_tei_batch_size,
|
||||
reranker_tei_max_concurrent=config.reranker_tei_max_concurrent,
|
||||
reranker_max_candidates=config.reranker_max_candidates,
|
||||
reranker_cohere_api_key=config.reranker_cohere_api_key,
|
||||
reranker_cohere_model=config.reranker_cohere_model,
|
||||
reranker_cohere_base_url=config.reranker_cohere_base_url,
|
||||
reranker_litellm_api_base=config.reranker_litellm_api_base,
|
||||
reranker_litellm_api_key=config.reranker_litellm_api_key,
|
||||
reranker_litellm_model=config.reranker_litellm_model,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
log_level=args.log_level,
|
||||
@@ -242,6 +254,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:
|
||||
|
||||
@@ -35,6 +35,12 @@ class MCPToolsConfig:
|
||||
# How to resolve API key for tenant auth (optional)
|
||||
api_key_resolver: Callable[[], str | None] | None = None
|
||||
|
||||
# How to resolve tenant_id for usage metering (set by MCP middleware after auth)
|
||||
tenant_id_resolver: Callable[[], str | None] | None = None
|
||||
|
||||
# How to resolve api_key_id for usage metering (set by MCP middleware after auth)
|
||||
api_key_id_resolver: Callable[[], str | None] | None = None
|
||||
|
||||
# Whether to include bank_id as a parameter on tools (for multi-bank support)
|
||||
include_bank_id_param: bool = False
|
||||
|
||||
@@ -50,13 +56,15 @@ class MCPToolsConfig:
|
||||
|
||||
|
||||
def _get_request_context(config: MCPToolsConfig) -> RequestContext:
|
||||
"""Create RequestContext with API key from resolver if available.
|
||||
"""Create RequestContext with auth details from resolvers.
|
||||
|
||||
This enables tenant auth to work with MCP tools by propagating
|
||||
the Bearer token from the MCP middleware to the memory engine.
|
||||
This enables tenant auth and usage metering to work with MCP tools by propagating
|
||||
the authentication results from the MCP middleware to the memory engine.
|
||||
"""
|
||||
api_key = config.api_key_resolver() if config.api_key_resolver else None
|
||||
return RequestContext(api_key=api_key)
|
||||
tenant_id = config.tenant_id_resolver() if config.tenant_id_resolver else None
|
||||
api_key_id = config.api_key_id_resolver() if config.api_key_id_resolver else None
|
||||
return RequestContext(api_key=api_key, tenant_id=tenant_id, api_key_id=api_key_id)
|
||||
|
||||
|
||||
def parse_timestamp(timestamp: str) -> datetime | None:
|
||||
@@ -119,7 +127,19 @@ def register_mcp_tools(
|
||||
memory: MemoryEngine instance
|
||||
config: Tool configuration
|
||||
"""
|
||||
tools_to_register = config.tools or {"retain", "recall", "reflect", "list_banks", "create_bank"}
|
||||
tools_to_register = config.tools or {
|
||||
"retain",
|
||||
"recall",
|
||||
"reflect",
|
||||
"list_banks",
|
||||
"create_bank",
|
||||
"list_mental_models",
|
||||
"get_mental_model",
|
||||
"create_mental_model",
|
||||
"update_mental_model",
|
||||
"delete_mental_model",
|
||||
"refresh_mental_model",
|
||||
}
|
||||
|
||||
if "retain" in tools_to_register:
|
||||
_register_retain(mcp, memory, config)
|
||||
@@ -136,6 +156,25 @@ def register_mcp_tools(
|
||||
if "create_bank" in tools_to_register:
|
||||
_register_create_bank(mcp, memory, config)
|
||||
|
||||
# Mental model tools
|
||||
if "list_mental_models" in tools_to_register:
|
||||
_register_list_mental_models(mcp, memory, config)
|
||||
|
||||
if "get_mental_model" in tools_to_register:
|
||||
_register_get_mental_model(mcp, memory, config)
|
||||
|
||||
if "create_mental_model" in tools_to_register:
|
||||
_register_create_mental_model(mcp, memory, config)
|
||||
|
||||
if "update_mental_model" in tools_to_register:
|
||||
_register_update_mental_model(mcp, memory, config)
|
||||
|
||||
if "delete_mental_model" in tools_to_register:
|
||||
_register_delete_mental_model(mcp, memory, config)
|
||||
|
||||
if "refresh_mental_model" in tools_to_register:
|
||||
_register_refresh_mental_model(mcp, memory, config)
|
||||
|
||||
|
||||
def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the retain tool."""
|
||||
@@ -511,3 +550,567 @@ def _register_create_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsCo
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating bank: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
|
||||
def _validate_mental_model_inputs(
|
||||
name: str | None = None, source_query: str | None = None, max_tokens: int | None = None
|
||||
) -> str | None:
|
||||
"""Validate mental model inputs, returning an error message or None if valid."""
|
||||
if name is not None and not name.strip():
|
||||
return "name cannot be empty"
|
||||
if source_query is not None and not source_query.strip():
|
||||
return "source_query cannot be empty"
|
||||
if max_tokens is not None and (max_tokens < 256 or max_tokens > 8192):
|
||||
return f"max_tokens must be between 256 and 8192, got {max_tokens}"
|
||||
return None
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# MENTAL MODEL TOOLS
|
||||
# =========================================================================
|
||||
|
||||
|
||||
def _register_list_mental_models(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the list_mental_models tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool()
|
||||
async def list_mental_models(
|
||||
tags: list[str] | None = None,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
List mental models (pinned reflections) for a memory bank.
|
||||
|
||||
Mental models are living documents that stay current by periodically re-running
|
||||
a source query through reflect. Use them to maintain up-to-date summaries,
|
||||
preferences, or synthesized knowledge.
|
||||
|
||||
Args:
|
||||
tags: Optional tags to filter by (returns models matching any tag)
|
||||
bank_id: Optional bank to list from (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured", "items": []}'
|
||||
|
||||
models = await memory.list_mental_models(
|
||||
bank_id=target_bank,
|
||||
tags=tags,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
return json.dumps({"items": models}, indent=2, default=str)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing mental models: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}", "items": []}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool()
|
||||
async def list_mental_models(
|
||||
tags: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
List mental models (pinned reflections) for this memory bank.
|
||||
|
||||
Mental models are living documents that stay current by periodically re-running
|
||||
a source query through reflect. Use them to maintain up-to-date summaries,
|
||||
preferences, or synthesized knowledge.
|
||||
|
||||
Args:
|
||||
tags: Optional tags to filter by (returns models matching any tag)
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured", "items": []}
|
||||
|
||||
models = await memory.list_mental_models(
|
||||
bank_id=target_bank,
|
||||
tags=tags,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
return {"items": models}
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing mental models: {e}", exc_info=True)
|
||||
return {"error": str(e), "items": []}
|
||||
|
||||
|
||||
def _register_get_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the get_mental_model tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool()
|
||||
async def get_mental_model(
|
||||
mental_model_id: str,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Get a specific mental model by ID.
|
||||
|
||||
Returns the full mental model including its generated content, source query,
|
||||
and metadata. Use list_mental_models first to discover available model IDs.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to retrieve
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
model = await memory.get_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
if model is None:
|
||||
return json.dumps({"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"})
|
||||
return json.dumps(model, indent=2, default=str)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting mental model: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool()
|
||||
async def get_mental_model(
|
||||
mental_model_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Get a specific mental model by ID.
|
||||
|
||||
Returns the full mental model including its generated content, source query,
|
||||
and metadata. Use list_mental_models first to discover available model IDs.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to retrieve
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
model = await memory.get_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
if model is None:
|
||||
return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}
|
||||
return model
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting mental model: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _register_create_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the create_mental_model tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool()
|
||||
async def create_mental_model(
|
||||
name: str,
|
||||
source_query: str,
|
||||
mental_model_id: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
max_tokens: int = 2048,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a new mental model (pinned reflection).
|
||||
|
||||
A mental model is a living document generated by running the source_query through
|
||||
reflect. The content is auto-generated asynchronously - use the returned operation_id
|
||||
to track progress.
|
||||
|
||||
EXAMPLES:
|
||||
- name="Coding Preferences", source_query="What coding patterns and tools does the user prefer?"
|
||||
- name="Project Goals", source_query="What are the user's current project goals and priorities?"
|
||||
- name="Communication Style", source_query="How does the user prefer to communicate?"
|
||||
|
||||
Args:
|
||||
name: Human-readable name for the mental model
|
||||
source_query: The query to run through reflect to generate content
|
||||
mental_model_id: Optional custom ID (alphanumeric lowercase with hyphens). Auto-generated if not provided.
|
||||
tags: Optional tags for scoped visibility filtering
|
||||
max_tokens: Maximum tokens for generated content (256-8192, default: 2048)
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
validation_error = _validate_mental_model_inputs(
|
||||
name=name, source_query=source_query, max_tokens=max_tokens
|
||||
)
|
||||
if validation_error:
|
||||
return json.dumps({"error": validation_error})
|
||||
|
||||
request_context = _get_request_context(config)
|
||||
|
||||
# Create with placeholder content
|
||||
model = await memory.create_mental_model(
|
||||
bank_id=target_bank,
|
||||
name=name,
|
||||
source_query=source_query,
|
||||
content="Generating content...",
|
||||
mental_model_id=mental_model_id,
|
||||
tags=tags,
|
||||
max_tokens=max_tokens,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Schedule async refresh to generate actual content
|
||||
result = await memory.submit_async_refresh_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=model["id"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
return json.dumps(
|
||||
{
|
||||
"mental_model_id": model["id"],
|
||||
"operation_id": result["operation_id"],
|
||||
"status": "created",
|
||||
"message": f"Mental model '{name}' created. Content is being generated asynchronously.",
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating mental model: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool()
|
||||
async def create_mental_model(
|
||||
name: str,
|
||||
source_query: str,
|
||||
mental_model_id: str | None = None,
|
||||
tags: list[str] | None = None,
|
||||
max_tokens: int = 2048,
|
||||
) -> dict:
|
||||
"""
|
||||
Create a new mental model (pinned reflection).
|
||||
|
||||
A mental model is a living document generated by running the source_query through
|
||||
reflect. The content is auto-generated asynchronously - use the returned operation_id
|
||||
to track progress.
|
||||
|
||||
EXAMPLES:
|
||||
- name="Coding Preferences", source_query="What coding patterns and tools does the user prefer?"
|
||||
- name="Project Goals", source_query="What are the user's current project goals and priorities?"
|
||||
- name="Communication Style", source_query="How does the user prefer to communicate?"
|
||||
|
||||
Args:
|
||||
name: Human-readable name for the mental model
|
||||
source_query: The query to run through reflect to generate content
|
||||
mental_model_id: Optional custom ID (alphanumeric lowercase with hyphens). Auto-generated if not provided.
|
||||
tags: Optional tags for scoped visibility filtering
|
||||
max_tokens: Maximum tokens for generated content (256-8192, default: 2048)
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
validation_error = _validate_mental_model_inputs(
|
||||
name=name, source_query=source_query, max_tokens=max_tokens
|
||||
)
|
||||
if validation_error:
|
||||
return {"error": validation_error}
|
||||
|
||||
request_context = _get_request_context(config)
|
||||
|
||||
model = await memory.create_mental_model(
|
||||
bank_id=target_bank,
|
||||
name=name,
|
||||
source_query=source_query,
|
||||
content="Generating content...",
|
||||
mental_model_id=mental_model_id,
|
||||
tags=tags,
|
||||
max_tokens=max_tokens,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
result = await memory.submit_async_refresh_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=model["id"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
return {
|
||||
"mental_model_id": model["id"],
|
||||
"operation_id": result["operation_id"],
|
||||
"status": "created",
|
||||
"message": f"Mental model '{name}' created. Content is being generated asynchronously.",
|
||||
}
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating mental model: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _register_update_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the update_mental_model tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool()
|
||||
async def update_mental_model(
|
||||
mental_model_id: str,
|
||||
name: str | None = None,
|
||||
source_query: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
tags: list[str] | None = None,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Update a mental model's metadata.
|
||||
|
||||
Changes the name, source query, or tags of an existing mental model.
|
||||
To regenerate the content, use refresh_mental_model after updating the source query.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to update
|
||||
name: New name (leave None to keep current)
|
||||
source_query: New source query (leave None to keep current)
|
||||
max_tokens: New max tokens for content generation (256-8192, leave None to keep current)
|
||||
tags: New tags (leave None to keep current)
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
validation_error = _validate_mental_model_inputs(
|
||||
name=name, source_query=source_query, max_tokens=max_tokens
|
||||
)
|
||||
if validation_error:
|
||||
return json.dumps({"error": validation_error})
|
||||
|
||||
model = await memory.update_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
name=name,
|
||||
source_query=source_query,
|
||||
max_tokens=max_tokens,
|
||||
tags=tags,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
if model is None:
|
||||
return json.dumps({"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"})
|
||||
return json.dumps(model, indent=2, default=str)
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating mental model: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool()
|
||||
async def update_mental_model(
|
||||
mental_model_id: str,
|
||||
name: str | None = None,
|
||||
source_query: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Update a mental model's metadata.
|
||||
|
||||
Changes the name, source query, or tags of an existing mental model.
|
||||
To regenerate the content, use refresh_mental_model after updating the source query.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to update
|
||||
name: New name (leave None to keep current)
|
||||
source_query: New source query (leave None to keep current)
|
||||
max_tokens: New max tokens for content generation (256-8192, leave None to keep current)
|
||||
tags: New tags (leave None to keep current)
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
validation_error = _validate_mental_model_inputs(
|
||||
name=name, source_query=source_query, max_tokens=max_tokens
|
||||
)
|
||||
if validation_error:
|
||||
return {"error": validation_error}
|
||||
|
||||
model = await memory.update_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
name=name,
|
||||
source_query=source_query,
|
||||
max_tokens=max_tokens,
|
||||
tags=tags,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
if model is None:
|
||||
return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}
|
||||
return model
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating mental model: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _register_delete_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the delete_mental_model tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_mental_model(
|
||||
mental_model_id: str,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Delete a mental model.
|
||||
|
||||
Permanently removes a mental model and its generated content.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to delete
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
deleted = await memory.delete_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
if not deleted:
|
||||
return json.dumps({"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"})
|
||||
return json.dumps({"status": "deleted", "mental_model_id": mental_model_id})
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting mental model: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool()
|
||||
async def delete_mental_model(
|
||||
mental_model_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Delete a mental model.
|
||||
|
||||
Permanently removes a mental model and its generated content.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to delete
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
deleted = await memory.delete_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
if not deleted:
|
||||
return {"error": f"Mental model '{mental_model_id}' not found in bank '{target_bank}'"}
|
||||
return {"status": "deleted", "mental_model_id": mental_model_id}
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting mental model: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
def _register_refresh_mental_model(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the refresh_mental_model tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool()
|
||||
async def refresh_mental_model(
|
||||
mental_model_id: str,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Refresh a mental model by re-running its source query.
|
||||
|
||||
Schedules an async task to re-run the source query through reflect and update the
|
||||
mental model's content with fresh results. Use this after adding new memories or
|
||||
when the mental model's content may be stale.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to refresh
|
||||
bank_id: Optional bank (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return '{"error": "No bank_id configured"}'
|
||||
|
||||
result = await memory.submit_async_refresh_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
return json.dumps(
|
||||
{
|
||||
"operation_id": result["operation_id"],
|
||||
"status": "queued",
|
||||
"message": f"Refresh queued for mental model '{mental_model_id}'.",
|
||||
}
|
||||
)
|
||||
except ValueError as e:
|
||||
return json.dumps({"error": str(e)})
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing mental model: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool()
|
||||
async def refresh_mental_model(
|
||||
mental_model_id: str,
|
||||
) -> dict:
|
||||
"""
|
||||
Refresh a mental model by re-running its source query.
|
||||
|
||||
Schedules an async task to re-run the source query through reflect and update the
|
||||
mental model's content with fresh results. Use this after adding new memories or
|
||||
when the mental model's content may be stale.
|
||||
|
||||
Args:
|
||||
mental_model_id: The ID of the mental model to refresh
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured"}
|
||||
|
||||
result = await memory.submit_async_refresh_mental_model(
|
||||
bank_id=target_bank,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=_get_request_context(config),
|
||||
)
|
||||
return {
|
||||
"operation_id": result["operation_id"],
|
||||
"status": "queued",
|
||||
"message": f"Refresh queued for mental model '{mental_model_id}'.",
|
||||
}
|
||||
except ValueError as e:
|
||||
return {"error": str(e)}
|
||||
except Exception as e:
|
||||
logger.error(f"Error refreshing mental model: {e}", exc_info=True)
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -20,7 +20,8 @@ class RequestContext:
|
||||
api_key: str | None = None
|
||||
api_key_id: str | None = None # UUID of the API key used for authentication
|
||||
tenant_id: str | None = None # Tenant identifier (set by extension after auth)
|
||||
internal: bool = False # True for background/internal operations (not user-visible)
|
||||
internal: bool = False # True for background/internal operations (skips extension auth)
|
||||
user_initiated: bool = False # True for async operations that originated from a user request
|
||||
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -353,6 +353,14 @@ class TestOperationHooksParameters:
|
||||
assert post_result.error is None
|
||||
assert post_result.unit_ids == result # Should match the return value
|
||||
|
||||
# Verify actual LLM token usage is populated
|
||||
assert post_result.llm_input_tokens is not None
|
||||
assert post_result.llm_input_tokens > 0
|
||||
assert post_result.llm_output_tokens is not None
|
||||
assert post_result.llm_output_tokens > 0
|
||||
assert post_result.llm_total_tokens is not None
|
||||
assert post_result.llm_total_tokens == post_result.llm_input_tokens + post_result.llm_output_tokens
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_pre_hook_receives_all_parameters(self, memory_with_tracking_validator):
|
||||
"""Pre-recall hook receives all user-provided parameters."""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Integration test for MCP endpoint routing.
|
||||
|
||||
This test verifies that /mcp/ and /mcp/{bank_id}/ expose different tool sets.
|
||||
This test verifies that /mcp/ and /mcp/{bank_id}/ expose different tool sets,
|
||||
and that URLs with or without trailing slashes both work (no 307 redirect).
|
||||
"""
|
||||
|
||||
import httpx
|
||||
@@ -39,12 +40,18 @@ async def test_mcp_endpoint_routing_integration(memory):
|
||||
|
||||
multi_tools = {t.name for t in multi_result.tools}
|
||||
|
||||
# Multi-bank should have all tools including bank management
|
||||
# Multi-bank should have all tools including bank management and mental models
|
||||
assert "retain" in multi_tools
|
||||
assert "recall" in multi_tools
|
||||
assert "reflect" in multi_tools
|
||||
assert "list_banks" in multi_tools, "Multi-bank should expose list_banks"
|
||||
assert "create_bank" in multi_tools, "Multi-bank should expose create_bank"
|
||||
assert "list_mental_models" in multi_tools, "Multi-bank should expose list_mental_models"
|
||||
assert "create_mental_model" in multi_tools, "Multi-bank should expose create_mental_model"
|
||||
assert "get_mental_model" in multi_tools, "Multi-bank should expose get_mental_model"
|
||||
assert "update_mental_model" in multi_tools, "Multi-bank should expose update_mental_model"
|
||||
assert "delete_mental_model" in multi_tools, "Multi-bank should expose delete_mental_model"
|
||||
assert "refresh_mental_model" in multi_tools, "Multi-bank should expose refresh_mental_model"
|
||||
|
||||
# Multi-bank retain should have bank_id parameter
|
||||
retain_tool = next((t for t in multi_result.tools if t.name == "retain"), None)
|
||||
@@ -64,10 +71,12 @@ async def test_mcp_endpoint_routing_integration(memory):
|
||||
|
||||
single_tools = {t.name for t in single_result.tools}
|
||||
|
||||
# Single-bank should only have scoped tools (no bank management)
|
||||
# Single-bank should have scoped tools including mental models (no bank management)
|
||||
assert "retain" in single_tools
|
||||
assert "recall" in single_tools
|
||||
assert "reflect" in single_tools
|
||||
assert "list_mental_models" in single_tools, "Single-bank should expose list_mental_models"
|
||||
assert "create_mental_model" in single_tools, "Single-bank should expose create_mental_model"
|
||||
assert "list_banks" not in single_tools, "Single-bank should NOT expose list_banks"
|
||||
assert "create_bank" not in single_tools, "Single-bank should NOT expose create_bank"
|
||||
|
||||
@@ -76,3 +85,196 @@ async def test_mcp_endpoint_routing_integration(memory):
|
||||
assert retain_tool is not None
|
||||
single_params = set(retain_tool.inputSchema.get("properties", {}).keys())
|
||||
assert "bank_id" not in single_params, "Single-bank retain should NOT have bank_id parameter"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_no_trailing_slash_works(memory):
|
||||
"""Test that /mcp (no trailing slash) discovers tools without 307 redirect.
|
||||
|
||||
Starlette's Mount class redirects /mcp to /mcp/ with a 307 Temporary Redirect.
|
||||
Many MCP clients don't follow POST redirects, causing 0 tools to be discovered.
|
||||
MCPMiddleware wraps the app directly (no Mount), so the redirect never happens.
|
||||
"""
|
||||
from hindsight_api.api import create_app
|
||||
|
||||
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
from httpx import ASGITransport
|
||||
|
||||
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
|
||||
# /mcp (no slash) should work the same as /mcp/
|
||||
async with streamable_http_client("http://test/mcp", http_client=http_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
result = await session.list_tools()
|
||||
|
||||
tools = {t.name for t in result.tools}
|
||||
assert len(tools) >= 11, f"Expected at least 11 tools from /mcp, got {len(tools)}: {tools}"
|
||||
assert "retain" in tools
|
||||
assert "recall" in tools
|
||||
assert "list_banks" in tools
|
||||
|
||||
# /mcp/my-bank (single-bank, no slash) should also work
|
||||
async with streamable_http_client("http://test/mcp/my-bank", http_client=http_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
result = await session.list_tools()
|
||||
|
||||
tools = {t.name for t in result.tools}
|
||||
assert "retain" in tools
|
||||
assert "list_banks" not in tools, "Single-bank /mcp/my-bank should NOT expose list_banks"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tool_execution_through_client(memory):
|
||||
"""Test that tools can be called (not just discovered) through the MCP client.
|
||||
|
||||
This verifies the full pipeline: HTTP → middleware → FastMCP → tool → engine → response.
|
||||
Previous tests only checked tool discovery (list_tools), not actual execution.
|
||||
"""
|
||||
from httpx import ASGITransport
|
||||
|
||||
from hindsight_api.api import create_app
|
||||
|
||||
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
|
||||
async with streamable_http_client("http://test/mcp/", http_client=http_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
|
||||
# Execute list_banks tool
|
||||
result = await session.call_tool("list_banks", arguments={})
|
||||
assert result is not None
|
||||
assert len(result.content) > 0
|
||||
# The result text should be valid JSON with a "banks" key
|
||||
import json
|
||||
|
||||
response_text = result.content[0].text
|
||||
parsed = json.loads(response_text)
|
||||
assert "banks" in parsed
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_mental_model_validation_through_client(memory):
|
||||
"""Test that input validation works through the real MCP transport.
|
||||
|
||||
Verifies that invalid inputs return error messages without crashing,
|
||||
and that the engine is never called with invalid data.
|
||||
"""
|
||||
from httpx import ASGITransport
|
||||
|
||||
from hindsight_api.api import create_app
|
||||
|
||||
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
|
||||
async with streamable_http_client("http://test/mcp/", http_client=http_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
|
||||
# Test: empty name should return validation error
|
||||
import json
|
||||
|
||||
result = await session.call_tool(
|
||||
"create_mental_model",
|
||||
arguments={"name": "", "source_query": "test query"},
|
||||
)
|
||||
assert result is not None
|
||||
parsed = json.loads(result.content[0].text)
|
||||
assert "error" in parsed
|
||||
assert "name cannot be empty" in parsed["error"]
|
||||
|
||||
# Test: max_tokens out of range should return validation error
|
||||
result = await session.call_tool(
|
||||
"create_mental_model",
|
||||
arguments={"name": "Test", "source_query": "test query", "max_tokens": 0},
|
||||
)
|
||||
parsed = json.loads(result.content[0].text)
|
||||
assert "error" in parsed
|
||||
assert "max_tokens must be between 256 and 8192" in parsed["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_bank_named_sse_routes_to_single_bank(memory):
|
||||
"""Test that a bank named 'sse' routes to single-bank mode.
|
||||
|
||||
Regression test: the old MCP_ENDPOINTS blocklist prevented banks named 'sse'
|
||||
or 'messages' from being accessed via path routing. They fell through to
|
||||
multi-bank mode instead.
|
||||
"""
|
||||
from httpx import ASGITransport
|
||||
|
||||
from hindsight_api.api import create_app
|
||||
|
||||
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
|
||||
async with streamable_http_client("http://test/mcp/sse/", http_client=http_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
result = await session.list_tools()
|
||||
tools = {t.name for t in result.tools}
|
||||
|
||||
# Should be single-bank mode (no bank management tools)
|
||||
assert "retain" in tools
|
||||
assert "recall" in tools
|
||||
assert "list_banks" not in tools, "Bank 'sse' should route to single-bank mode"
|
||||
assert "create_bank" not in tools
|
||||
|
||||
# retain should NOT have bank_id parameter (single-bank mode)
|
||||
retain_tool = next(t for t in result.tools if t.name == "retain")
|
||||
params = set(retain_tool.inputSchema.get("properties", {}).keys())
|
||||
assert "bank_id" not in params
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_bank_named_messages_routes_to_single_bank(memory):
|
||||
"""Test that a bank named 'messages' routes to single-bank mode.
|
||||
|
||||
Same regression test as test_mcp_bank_named_sse_routes_to_single_bank but for 'messages'.
|
||||
"""
|
||||
from httpx import ASGITransport
|
||||
|
||||
from hindsight_api.api import create_app
|
||||
|
||||
app = create_app(memory, mcp_api_enabled=True, initialize_memory=False)
|
||||
|
||||
async with app.router.lifespan_context(app):
|
||||
async with httpx.AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as http_client:
|
||||
async with streamable_http_client("http://test/mcp/messages/", http_client=http_client) as (
|
||||
read_stream,
|
||||
write_stream,
|
||||
_,
|
||||
):
|
||||
async with ClientSession(read_stream, write_stream) as session:
|
||||
await session.initialize()
|
||||
result = await session.list_tools()
|
||||
tools = {t.name for t in result.tools}
|
||||
|
||||
assert "retain" in tools
|
||||
assert "list_banks" not in tools, "Bank 'messages' should route to single-bank mode"
|
||||
|
||||
@@ -165,5 +165,5 @@ class TestMCPExtensionIntegration:
|
||||
assert "create_bank" in tools
|
||||
# Extension tool also present
|
||||
assert "test_extension_tool" in tools
|
||||
# Total: 5 core + 1 extension = 6 tools
|
||||
assert len(tools) == 6
|
||||
# At least 11 core + 1 extension = 12 tools (may grow as new tools are added)
|
||||
assert len(tools) >= 12
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Test MCP server routing with dynamic bank_id."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory():
|
||||
@@ -17,7 +18,7 @@ def mock_memory():
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_context_variable():
|
||||
"""Test that context variable works correctly."""
|
||||
from hindsight_api.api.mcp import get_current_bank_id, _current_bank_id
|
||||
from hindsight_api.api.mcp import _current_bank_id, get_current_bank_id
|
||||
|
||||
# Initially None
|
||||
assert get_current_bank_id() is None
|
||||
@@ -36,7 +37,7 @@ async def test_mcp_context_variable():
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tools_use_context_bank_id(mock_memory):
|
||||
"""Test that MCP tools use bank_id from context."""
|
||||
from hindsight_api.api.mcp import create_mcp_server, _current_bank_id
|
||||
from hindsight_api.api.mcp import _current_bank_id, create_mcp_server
|
||||
|
||||
mcp_server = create_mcp_server(mock_memory)
|
||||
|
||||
@@ -62,6 +63,7 @@ async def test_mcp_tools_use_context_bank_id(mock_memory):
|
||||
|
||||
def test_path_parsing_logic():
|
||||
"""Test the path parsing logic for bank_id extraction."""
|
||||
|
||||
def parse_path(path):
|
||||
"""Simulate the path parsing logic from MCPMiddleware."""
|
||||
if not path.startswith("/") or len(path) <= 1:
|
||||
@@ -102,7 +104,7 @@ def test_path_parsing_logic():
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_key_context_variable():
|
||||
"""Test that API key context variable works correctly."""
|
||||
from hindsight_api.api.mcp import get_current_api_key, _current_api_key
|
||||
from hindsight_api.api.mcp import _current_api_key, get_current_api_key
|
||||
|
||||
# Initially None
|
||||
assert get_current_api_key() is None
|
||||
@@ -121,7 +123,7 @@ async def test_api_key_context_variable():
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tools_propagate_api_key(mock_memory):
|
||||
"""Test that MCP tools propagate API key to RequestContext."""
|
||||
from hindsight_api.api.mcp import create_mcp_server, _current_bank_id, _current_api_key
|
||||
from hindsight_api.api.mcp import _current_api_key, _current_bank_id, create_mcp_server
|
||||
|
||||
mcp_server = create_mcp_server(mock_memory)
|
||||
tools = mcp_server._tool_manager._tools
|
||||
@@ -143,21 +145,99 @@ async def test_mcp_tools_propagate_api_key(mock_memory):
|
||||
_current_api_key.reset(api_key_token)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tenant_id_context_variable():
|
||||
"""Test that tenant_id and api_key_id context variables work correctly."""
|
||||
from hindsight_api.api.mcp import (
|
||||
_current_api_key_id,
|
||||
_current_tenant_id,
|
||||
get_current_api_key_id,
|
||||
get_current_tenant_id,
|
||||
)
|
||||
|
||||
# Initially None
|
||||
assert get_current_tenant_id() is None
|
||||
assert get_current_api_key_id() is None
|
||||
|
||||
# Set and verify
|
||||
tenant_token = _current_tenant_id.set("org-123")
|
||||
key_id_token = _current_api_key_id.set("key-456")
|
||||
try:
|
||||
assert get_current_tenant_id() == "org-123"
|
||||
assert get_current_api_key_id() == "key-456"
|
||||
finally:
|
||||
_current_tenant_id.reset(tenant_token)
|
||||
_current_api_key_id.reset(key_id_token)
|
||||
|
||||
# Back to None after reset
|
||||
assert get_current_tenant_id() is None
|
||||
assert get_current_api_key_id() is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_tools_propagate_tenant_id_and_api_key_id(mock_memory):
|
||||
"""Test that MCP tools propagate tenant_id and api_key_id to RequestContext.
|
||||
|
||||
This is the critical test for usage metering: the UsageMeteringValidator reads
|
||||
request_context.tenant_id to identify the org for billing. Without this,
|
||||
MCP operations get tenant_id="unknown" and billing is skipped entirely.
|
||||
"""
|
||||
from hindsight_api.api.mcp import (
|
||||
_current_api_key,
|
||||
_current_api_key_id,
|
||||
_current_bank_id,
|
||||
_current_tenant_id,
|
||||
create_mcp_server,
|
||||
)
|
||||
|
||||
mcp_server = create_mcp_server(mock_memory)
|
||||
tools = mcp_server._tool_manager._tools
|
||||
|
||||
# Set all context vars (simulating what MCPMiddleware does after authenticate_mcp)
|
||||
bank_token = _current_bank_id.set("test-bank")
|
||||
api_key_token = _current_api_key.set("hsk_test_key")
|
||||
tenant_token = _current_tenant_id.set("org-billing-123")
|
||||
key_id_token = _current_api_key_id.set("key-uuid-456")
|
||||
try:
|
||||
retain_tool = tools["retain"]
|
||||
await retain_tool.fn(content="test content", context="test_context", async_processing=False)
|
||||
|
||||
# Verify the RequestContext passed to memory engine has all auth fields
|
||||
mock_memory.retain_batch_async.assert_called_once()
|
||||
request_context = mock_memory.retain_batch_async.call_args.kwargs["request_context"]
|
||||
assert request_context.api_key == "hsk_test_key"
|
||||
assert request_context.tenant_id == "org-billing-123"
|
||||
assert request_context.api_key_id == "key-uuid-456"
|
||||
finally:
|
||||
_current_bank_id.reset(bank_token)
|
||||
_current_api_key.reset(api_key_token)
|
||||
_current_tenant_id.reset(tenant_token)
|
||||
_current_api_key_id.reset(key_id_token)
|
||||
|
||||
|
||||
def test_multi_bank_mode_exposes_all_tools(mock_memory):
|
||||
"""Test that multi-bank mode exposes all tools including bank management."""
|
||||
"""Test that multi-bank mode exposes all tools including bank management and mental models."""
|
||||
from hindsight_api.api.mcp import create_mcp_server
|
||||
|
||||
# Create server in multi-bank mode (default)
|
||||
mcp_server = create_mcp_server(mock_memory, multi_bank=True)
|
||||
tools = mcp_server._tool_manager._tools
|
||||
|
||||
# Should have all tools
|
||||
# Core tools
|
||||
assert "retain" in tools
|
||||
assert "recall" in tools
|
||||
assert "reflect" in tools
|
||||
assert "list_banks" in tools
|
||||
assert "create_bank" in tools
|
||||
|
||||
# Mental model tools
|
||||
assert "list_mental_models" in tools
|
||||
assert "get_mental_model" in tools
|
||||
assert "create_mental_model" in tools
|
||||
assert "update_mental_model" in tools
|
||||
assert "delete_mental_model" in tools
|
||||
assert "refresh_mental_model" in tools
|
||||
|
||||
|
||||
def test_single_bank_mode_excludes_bank_management_tools(mock_memory):
|
||||
"""Test that single-bank mode only exposes bank-scoped tools."""
|
||||
@@ -167,11 +247,19 @@ def test_single_bank_mode_excludes_bank_management_tools(mock_memory):
|
||||
mcp_server = create_mcp_server(mock_memory, multi_bank=False)
|
||||
tools = mcp_server._tool_manager._tools
|
||||
|
||||
# Should only have bank-scoped tools
|
||||
# Should have bank-scoped tools
|
||||
assert "retain" in tools
|
||||
assert "recall" in tools
|
||||
assert "reflect" in tools
|
||||
|
||||
# Mental model tools should also be present (they're bank-scoped)
|
||||
assert "list_mental_models" in tools
|
||||
assert "get_mental_model" in tools
|
||||
assert "create_mental_model" in tools
|
||||
assert "update_mental_model" in tools
|
||||
assert "delete_mental_model" in tools
|
||||
assert "refresh_mental_model" in tools
|
||||
|
||||
# Should NOT have bank management tools
|
||||
assert "list_banks" not in tools
|
||||
assert "create_bank" not in tools
|
||||
@@ -179,46 +267,56 @@ def test_single_bank_mode_excludes_bank_management_tools(mock_memory):
|
||||
|
||||
def test_multi_bank_mode_tools_have_bank_id_param(mock_memory):
|
||||
"""Test that multi-bank mode tools include bank_id parameter."""
|
||||
from hindsight_api.api.mcp import create_mcp_server
|
||||
import inspect
|
||||
|
||||
from hindsight_api.api.mcp import create_mcp_server
|
||||
|
||||
mcp_server = create_mcp_server(mock_memory, multi_bank=True)
|
||||
tools = mcp_server._tool_manager._tools
|
||||
|
||||
# Check that tools have bank_id parameter
|
||||
retain_tool = tools["retain"]
|
||||
retain_sig = inspect.signature(retain_tool.fn)
|
||||
assert "bank_id" in retain_sig.parameters
|
||||
|
||||
recall_tool = tools["recall"]
|
||||
recall_sig = inspect.signature(recall_tool.fn)
|
||||
assert "bank_id" in recall_sig.parameters
|
||||
|
||||
reflect_tool = tools["reflect"]
|
||||
reflect_sig = inspect.signature(reflect_tool.fn)
|
||||
assert "bank_id" in reflect_sig.parameters
|
||||
# All bank-scoped tools should have bank_id parameter in multi-bank mode
|
||||
bank_scoped_tools = [
|
||||
"retain",
|
||||
"recall",
|
||||
"reflect",
|
||||
"list_mental_models",
|
||||
"get_mental_model",
|
||||
"create_mental_model",
|
||||
"update_mental_model",
|
||||
"delete_mental_model",
|
||||
"refresh_mental_model",
|
||||
]
|
||||
for tool_name in bank_scoped_tools:
|
||||
tool = tools[tool_name]
|
||||
sig = inspect.signature(tool.fn)
|
||||
assert "bank_id" in sig.parameters, f"{tool_name} should have bank_id param in multi-bank mode"
|
||||
|
||||
|
||||
def test_single_bank_mode_tools_no_bank_id_param(mock_memory):
|
||||
"""Test that single-bank mode tools do NOT include bank_id parameter."""
|
||||
from hindsight_api.api.mcp import create_mcp_server
|
||||
import inspect
|
||||
|
||||
from hindsight_api.api.mcp import create_mcp_server
|
||||
|
||||
mcp_server = create_mcp_server(mock_memory, multi_bank=False)
|
||||
tools = mcp_server._tool_manager._tools
|
||||
|
||||
# Check that tools do NOT have bank_id parameter
|
||||
retain_tool = tools["retain"]
|
||||
retain_sig = inspect.signature(retain_tool.fn)
|
||||
assert "bank_id" not in retain_sig.parameters
|
||||
|
||||
recall_tool = tools["recall"]
|
||||
recall_sig = inspect.signature(recall_tool.fn)
|
||||
assert "bank_id" not in recall_sig.parameters
|
||||
|
||||
reflect_tool = tools["reflect"]
|
||||
reflect_sig = inspect.signature(reflect_tool.fn)
|
||||
assert "bank_id" not in reflect_sig.parameters
|
||||
# No bank-scoped tool should have bank_id parameter in single-bank mode
|
||||
bank_scoped_tools = [
|
||||
"retain",
|
||||
"recall",
|
||||
"reflect",
|
||||
"list_mental_models",
|
||||
"get_mental_model",
|
||||
"create_mental_model",
|
||||
"update_mental_model",
|
||||
"delete_mental_model",
|
||||
"refresh_mental_model",
|
||||
]
|
||||
for tool_name in bank_scoped_tools:
|
||||
tool = tools[tool_name]
|
||||
sig = inspect.signature(tool.fn)
|
||||
assert "bank_id" not in sig.parameters, f"{tool_name} should NOT have bank_id param in single-bank mode"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -242,19 +340,26 @@ async def test_middleware_handles_both_endpoints(mock_memory):
|
||||
assert "recall" in multi_bank_tools
|
||||
assert "list_banks" in multi_bank_tools
|
||||
assert "create_bank" in multi_bank_tools
|
||||
assert "list_mental_models" in multi_bank_tools
|
||||
assert "create_mental_model" in multi_bank_tools
|
||||
|
||||
# Single-bank should only have scoped tools
|
||||
assert "retain" in single_bank_tools
|
||||
assert "recall" in single_bank_tools
|
||||
assert "list_mental_models" in single_bank_tools
|
||||
assert "create_mental_model" in single_bank_tools
|
||||
assert "list_banks" not in single_bank_tools
|
||||
assert "create_bank" not in single_bank_tools
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_routing_logic_from_url_path():
|
||||
"""Test that routing correctly selects server based on URL structure."""
|
||||
"""Test that routing correctly selects server based on URL structure.
|
||||
|
||||
Simulates the path parsing logic from MCPMiddleware.__call__ after the
|
||||
prefix has been stripped. Any first path segment is treated as a bank_id.
|
||||
"""
|
||||
from hindsight_api.api.mcp import MCPMiddleware
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
# Mock memory
|
||||
mock_memory = MagicMock()
|
||||
@@ -263,28 +368,23 @@ async def test_routing_logic_from_url_path():
|
||||
middleware = MCPMiddleware(None, mock_memory)
|
||||
|
||||
# Simulate different URL patterns and verify routing
|
||||
# Path is what remains after stripping the /mcp prefix
|
||||
test_cases = [
|
||||
# (path_after_stripping_mcp, expected_bank_id_from_path, expected_bank_id, description)
|
||||
# (path_after_prefix_strip, expected_bank_id_from_path, expected_bank_id, description)
|
||||
("/alice/messages", True, "alice", "Bank ID in path with endpoint"),
|
||||
("/my-agent-123/", True, "my-agent-123", "Bank ID in path with trailing slash"),
|
||||
("ciccio/messages", True, "ciccio", "Bank ID without leading slash (after mount strip)"),
|
||||
("bob", True, "bob", "Bank ID only, no leading slash"),
|
||||
("/messages", False, None, "MCP endpoint, no bank ID"),
|
||||
("/sse/", True, "sse", "Bank named 'sse' routes to single-bank"),
|
||||
("/messages/", True, "messages", "Bank named 'messages' routes to single-bank"),
|
||||
("/", False, None, "Root path, no bank ID"),
|
||||
]
|
||||
|
||||
for path, expected_bank_from_path, expected_bank_id, description in test_cases:
|
||||
# Simulate the path parsing logic with leading slash normalization
|
||||
if path and not path.startswith("/"):
|
||||
path = "/" + path
|
||||
|
||||
bank_id = None
|
||||
bank_id_from_path = False
|
||||
MCP_ENDPOINTS = {"sse", "messages"}
|
||||
|
||||
if path.startswith("/") and len(path) > 1:
|
||||
parts = path[1:].split("/", 1)
|
||||
if parts[0] and parts[0] not in MCP_ENDPOINTS:
|
||||
if parts[0]:
|
||||
bank_id = parts[0]
|
||||
bank_id_from_path = True
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
"""Tests for the shared MCP tools module."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.mcp_tools import build_content_dict, parse_timestamp
|
||||
from hindsight_api.mcp_tools import (
|
||||
MCPToolsConfig,
|
||||
_validate_mental_model_inputs,
|
||||
build_content_dict,
|
||||
parse_timestamp,
|
||||
register_mcp_tools,
|
||||
)
|
||||
|
||||
|
||||
class TestParseTimestamp:
|
||||
@@ -61,3 +68,579 @@ class TestBuildContentDict:
|
||||
result, error = build_content_dict("test content", "test_context", None)
|
||||
assert error is None
|
||||
assert "event_date" not in result
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Mental Model MCP Tool Tests
|
||||
# =========================================================================
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_memory():
|
||||
"""Create a mock MemoryEngine with mental model methods."""
|
||||
memory = MagicMock()
|
||||
memory.list_mental_models = AsyncMock(
|
||||
return_value=[
|
||||
{"id": "mm-1", "name": "Coding Prefs", "source_query": "coding preferences?", "content": "Prefers Python"},
|
||||
{"id": "mm-2", "name": "Goals", "source_query": "current goals?", "content": "Ship v2"},
|
||||
]
|
||||
)
|
||||
memory.get_mental_model = AsyncMock(
|
||||
return_value={
|
||||
"id": "mm-1",
|
||||
"name": "Coding Prefs",
|
||||
"source_query": "coding preferences?",
|
||||
"content": "Prefers Python",
|
||||
}
|
||||
)
|
||||
memory.create_mental_model = AsyncMock(return_value={"id": "mm-new"})
|
||||
memory.submit_async_refresh_mental_model = AsyncMock(return_value={"operation_id": "op-123"})
|
||||
memory.update_mental_model = AsyncMock(
|
||||
return_value={
|
||||
"id": "mm-1",
|
||||
"name": "Updated Name",
|
||||
"source_query": "new query?",
|
||||
"content": "Updated",
|
||||
}
|
||||
)
|
||||
memory.delete_mental_model = AsyncMock(return_value=True)
|
||||
return memory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_server_with_mental_models(mock_memory):
|
||||
"""Create a FastMCP server with mental model tools registered (multi-bank mode)."""
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("test", stateless_http=True)
|
||||
config = MCPToolsConfig(
|
||||
bank_id_resolver=lambda: "test-bank",
|
||||
include_bank_id_param=True,
|
||||
tools={
|
||||
"list_mental_models",
|
||||
"get_mental_model",
|
||||
"create_mental_model",
|
||||
"update_mental_model",
|
||||
"delete_mental_model",
|
||||
"refresh_mental_model",
|
||||
},
|
||||
)
|
||||
register_mcp_tools(mcp, mock_memory, config)
|
||||
return mcp
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_server_single_bank(mock_memory):
|
||||
"""Create a FastMCP server with mental model tools registered (single-bank mode)."""
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("test")
|
||||
config = MCPToolsConfig(
|
||||
bank_id_resolver=lambda: "fixed-bank",
|
||||
include_bank_id_param=False,
|
||||
tools={
|
||||
"list_mental_models",
|
||||
"get_mental_model",
|
||||
"create_mental_model",
|
||||
"update_mental_model",
|
||||
"delete_mental_model",
|
||||
"refresh_mental_model",
|
||||
},
|
||||
)
|
||||
register_mcp_tools(mcp, mock_memory, config)
|
||||
return mcp
|
||||
|
||||
|
||||
class TestMentalModelToolRegistration:
|
||||
"""Test that mental model tools are registered correctly."""
|
||||
|
||||
def test_tools_registered_multi_bank(self, mcp_server_with_mental_models):
|
||||
tools = mcp_server_with_mental_models._tool_manager._tools
|
||||
expected = {
|
||||
"list_mental_models",
|
||||
"get_mental_model",
|
||||
"create_mental_model",
|
||||
"update_mental_model",
|
||||
"delete_mental_model",
|
||||
"refresh_mental_model",
|
||||
}
|
||||
assert expected == set(tools.keys())
|
||||
|
||||
def test_tools_registered_single_bank(self, mcp_server_single_bank):
|
||||
tools = mcp_server_single_bank._tool_manager._tools
|
||||
expected = {
|
||||
"list_mental_models",
|
||||
"get_mental_model",
|
||||
"create_mental_model",
|
||||
"update_mental_model",
|
||||
"delete_mental_model",
|
||||
"refresh_mental_model",
|
||||
}
|
||||
assert expected == set(tools.keys())
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_mental_models_propagates_request_context(self, mock_memory):
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("test", stateless_http=True)
|
||||
config = MCPToolsConfig(
|
||||
bank_id_resolver=lambda: "test-bank",
|
||||
api_key_resolver=lambda: "test-api-key",
|
||||
include_bank_id_param=True,
|
||||
tools={"list_mental_models"},
|
||||
)
|
||||
register_mcp_tools(mcp, mock_memory, config)
|
||||
await _tools(mcp)["list_mental_models"].fn()
|
||||
request_context = mock_memory.list_mental_models.call_args.kwargs["request_context"]
|
||||
assert request_context.api_key == "test-api-key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_mental_model_propagates_request_context(self, mock_memory):
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("test", stateless_http=True)
|
||||
config = MCPToolsConfig(
|
||||
bank_id_resolver=lambda: "test-bank",
|
||||
api_key_resolver=lambda: "test-api-key",
|
||||
include_bank_id_param=True,
|
||||
tools={"create_mental_model"},
|
||||
)
|
||||
register_mcp_tools(mcp, mock_memory, config)
|
||||
await _tools(mcp)["create_mental_model"].fn(name="Test", source_query="query")
|
||||
request_context = mock_memory.create_mental_model.call_args.kwargs["request_context"]
|
||||
assert request_context.api_key == "test-api-key"
|
||||
|
||||
def test_mental_model_tools_in_default_set(self):
|
||||
"""Mental model tools should be in the default tools set when config.tools is None."""
|
||||
from fastmcp import FastMCP
|
||||
|
||||
memory = MagicMock()
|
||||
# Mock all engine methods that tools reference
|
||||
memory.retain_batch_async = AsyncMock()
|
||||
memory.submit_async_retain = AsyncMock(return_value={"operation_id": "op"})
|
||||
memory.recall_async = AsyncMock(return_value=MagicMock(results=[]))
|
||||
memory.reflect_async = AsyncMock()
|
||||
memory.list_banks = AsyncMock(return_value=[])
|
||||
memory.get_bank_profile = AsyncMock(return_value={})
|
||||
memory.update_bank = AsyncMock()
|
||||
memory.list_mental_models = AsyncMock(return_value=[])
|
||||
memory.get_mental_model = AsyncMock()
|
||||
memory.create_mental_model = AsyncMock()
|
||||
memory.submit_async_refresh_mental_model = AsyncMock()
|
||||
memory.update_mental_model = AsyncMock()
|
||||
memory.delete_mental_model = AsyncMock()
|
||||
|
||||
mcp = FastMCP("test", stateless_http=True)
|
||||
config = MCPToolsConfig(
|
||||
bank_id_resolver=lambda: "bank",
|
||||
include_bank_id_param=True,
|
||||
tools=None, # Default - all tools
|
||||
)
|
||||
register_mcp_tools(mcp, memory, config)
|
||||
tools = mcp._tool_manager._tools
|
||||
assert "list_mental_models" in tools
|
||||
assert "create_mental_model" in tools
|
||||
assert "refresh_mental_model" in tools
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def no_bank_mcp_server(mock_memory):
|
||||
"""Create a multi-bank MCP server where bank_id_resolver returns None."""
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("test", stateless_http=True)
|
||||
config = MCPToolsConfig(
|
||||
bank_id_resolver=lambda: None,
|
||||
include_bank_id_param=True,
|
||||
tools={
|
||||
"list_mental_models",
|
||||
"get_mental_model",
|
||||
"create_mental_model",
|
||||
"update_mental_model",
|
||||
"delete_mental_model",
|
||||
"refresh_mental_model",
|
||||
},
|
||||
)
|
||||
register_mcp_tools(mcp, mock_memory, config)
|
||||
return mcp
|
||||
|
||||
|
||||
def _tools(mcp_server):
|
||||
"""Helper to get tools dict from MCP server."""
|
||||
return mcp_server._tool_manager._tools
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestListMentalModels:
|
||||
async def test_list_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
result = await _tools(mcp_server_with_mental_models)["list_mental_models"].fn()
|
||||
assert '"mm-1"' in result
|
||||
assert '"mm-2"' in result
|
||||
mock_memory.list_mental_models.assert_called_once()
|
||||
assert mock_memory.list_mental_models.call_args.kwargs["bank_id"] == "test-bank"
|
||||
|
||||
async def test_list_with_bank_id_override(self, mcp_server_with_mental_models, mock_memory):
|
||||
"""Explicit bank_id should override the resolver."""
|
||||
await _tools(mcp_server_with_mental_models)["list_mental_models"].fn(bank_id="other-bank")
|
||||
assert mock_memory.list_mental_models.call_args.kwargs["bank_id"] == "other-bank"
|
||||
|
||||
async def test_list_with_tags(self, mcp_server_with_mental_models, mock_memory):
|
||||
await _tools(mcp_server_with_mental_models)["list_mental_models"].fn(tags=["work"])
|
||||
assert mock_memory.list_mental_models.call_args.kwargs["tags"] == ["work"]
|
||||
|
||||
async def test_list_single_bank(self, mcp_server_single_bank, mock_memory):
|
||||
result = await _tools(mcp_server_single_bank)["list_mental_models"].fn()
|
||||
assert isinstance(result, dict)
|
||||
assert len(result["items"]) == 2
|
||||
assert mock_memory.list_mental_models.call_args.kwargs["bank_id"] == "fixed-bank"
|
||||
|
||||
async def test_list_no_bank_returns_error(self, no_bank_mcp_server):
|
||||
result = await _tools(no_bank_mcp_server)["list_mental_models"].fn()
|
||||
assert "error" in result
|
||||
|
||||
async def test_list_engine_error_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
mock_memory.list_mental_models.side_effect = RuntimeError("DB connection lost")
|
||||
result = await _tools(mcp_server_with_mental_models)["list_mental_models"].fn()
|
||||
assert "error" in result
|
||||
assert "DB connection lost" in result
|
||||
|
||||
async def test_list_engine_error_single_bank(self, mcp_server_single_bank, mock_memory):
|
||||
mock_memory.list_mental_models.side_effect = RuntimeError("DB connection lost")
|
||||
result = await _tools(mcp_server_single_bank)["list_mental_models"].fn()
|
||||
assert isinstance(result, dict)
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetMentalModel:
|
||||
async def test_get_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
result = await _tools(mcp_server_with_mental_models)["get_mental_model"].fn(mental_model_id="mm-1")
|
||||
assert '"mm-1"' in result
|
||||
assert mock_memory.get_mental_model.call_args.kwargs["mental_model_id"] == "mm-1"
|
||||
|
||||
async def test_get_with_bank_id_override(self, mcp_server_with_mental_models, mock_memory):
|
||||
await _tools(mcp_server_with_mental_models)["get_mental_model"].fn(mental_model_id="mm-1", bank_id="other-bank")
|
||||
assert mock_memory.get_mental_model.call_args.kwargs["bank_id"] == "other-bank"
|
||||
|
||||
async def test_get_not_found_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
mock_memory.get_mental_model.return_value = None
|
||||
result = await _tools(mcp_server_with_mental_models)["get_mental_model"].fn(mental_model_id="missing")
|
||||
assert "not found" in result
|
||||
|
||||
async def test_get_not_found_single_bank(self, mcp_server_single_bank, mock_memory):
|
||||
mock_memory.get_mental_model.return_value = None
|
||||
result = await _tools(mcp_server_single_bank)["get_mental_model"].fn(mental_model_id="missing")
|
||||
assert isinstance(result, dict)
|
||||
assert "not found" in result["error"]
|
||||
|
||||
async def test_get_single_bank(self, mcp_server_single_bank, mock_memory):
|
||||
result = await _tools(mcp_server_single_bank)["get_mental_model"].fn(mental_model_id="mm-1")
|
||||
assert isinstance(result, dict)
|
||||
assert result["id"] == "mm-1"
|
||||
|
||||
async def test_get_no_bank_returns_error(self, no_bank_mcp_server):
|
||||
result = await _tools(no_bank_mcp_server)["get_mental_model"].fn(mental_model_id="mm-1")
|
||||
assert "error" in result
|
||||
|
||||
async def test_get_engine_error(self, mcp_server_with_mental_models, mock_memory):
|
||||
mock_memory.get_mental_model.side_effect = RuntimeError("DB error")
|
||||
result = await _tools(mcp_server_with_mental_models)["get_mental_model"].fn(mental_model_id="mm-1")
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateMentalModel:
|
||||
async def test_create_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
result = await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(
|
||||
name="Test Model",
|
||||
source_query="What are the user's preferences?",
|
||||
)
|
||||
assert '"mm-new"' in result
|
||||
assert '"op-123"' in result
|
||||
mock_memory.create_mental_model.assert_called_once()
|
||||
call_kwargs = mock_memory.create_mental_model.call_args.kwargs
|
||||
assert call_kwargs["name"] == "Test Model"
|
||||
assert call_kwargs["source_query"] == "What are the user's preferences?"
|
||||
assert call_kwargs["content"] == "Generating content..."
|
||||
# Verify async refresh was scheduled
|
||||
mock_memory.submit_async_refresh_mental_model.assert_called_once()
|
||||
assert mock_memory.submit_async_refresh_mental_model.call_args.kwargs["mental_model_id"] == "mm-new"
|
||||
|
||||
async def test_create_with_custom_id(self, mcp_server_with_mental_models, mock_memory):
|
||||
await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(
|
||||
name="Test", source_query="query", mental_model_id="custom-id"
|
||||
)
|
||||
assert mock_memory.create_mental_model.call_args.kwargs["mental_model_id"] == "custom-id"
|
||||
|
||||
async def test_create_with_tags_and_max_tokens(self, mcp_server_with_mental_models, mock_memory):
|
||||
await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(
|
||||
name="Test", source_query="query", tags=["work", "coding"], max_tokens=4096
|
||||
)
|
||||
call_kwargs = mock_memory.create_mental_model.call_args.kwargs
|
||||
assert call_kwargs["tags"] == ["work", "coding"]
|
||||
assert call_kwargs["max_tokens"] == 4096
|
||||
|
||||
async def test_create_with_bank_id_override(self, mcp_server_with_mental_models, mock_memory):
|
||||
await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(
|
||||
name="Test", source_query="query", bank_id="other-bank"
|
||||
)
|
||||
assert mock_memory.create_mental_model.call_args.kwargs["bank_id"] == "other-bank"
|
||||
assert mock_memory.submit_async_refresh_mental_model.call_args.kwargs["bank_id"] == "other-bank"
|
||||
|
||||
async def test_create_single_bank(self, mcp_server_single_bank, mock_memory):
|
||||
result = await _tools(mcp_server_single_bank)["create_mental_model"].fn(name="Test", source_query="query")
|
||||
assert isinstance(result, dict)
|
||||
assert result["mental_model_id"] == "mm-new"
|
||||
assert result["operation_id"] == "op-123"
|
||||
|
||||
async def test_create_no_bank_returns_error(self, no_bank_mcp_server):
|
||||
result = await _tools(no_bank_mcp_server)["create_mental_model"].fn(name="Test", source_query="query")
|
||||
assert "error" in result
|
||||
|
||||
async def test_create_value_error_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
"""ValueError from engine (e.g. invalid ID format) should return error, not crash."""
|
||||
mock_memory.create_mental_model.side_effect = ValueError("ID must be alphanumeric lowercase")
|
||||
result = await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(
|
||||
name="Test", source_query="query", mental_model_id="INVALID!!"
|
||||
)
|
||||
assert "alphanumeric" in result
|
||||
|
||||
async def test_create_value_error_single_bank(self, mcp_server_single_bank, mock_memory):
|
||||
mock_memory.create_mental_model.side_effect = ValueError("ID must be alphanumeric lowercase")
|
||||
result = await _tools(mcp_server_single_bank)["create_mental_model"].fn(
|
||||
name="Test", source_query="query", mental_model_id="INVALID!!"
|
||||
)
|
||||
assert isinstance(result, dict)
|
||||
assert "alphanumeric" in result["error"]
|
||||
|
||||
async def test_create_engine_error(self, mcp_server_with_mental_models, mock_memory):
|
||||
mock_memory.create_mental_model.side_effect = RuntimeError("DB error")
|
||||
result = await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(
|
||||
name="Test", source_query="query"
|
||||
)
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateMentalModel:
|
||||
async def test_update_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
result = await _tools(mcp_server_with_mental_models)["update_mental_model"].fn(
|
||||
mental_model_id="mm-1", name="Updated Name"
|
||||
)
|
||||
assert '"Updated Name"' in result
|
||||
call_kwargs = mock_memory.update_mental_model.call_args.kwargs
|
||||
assert call_kwargs["name"] == "Updated Name"
|
||||
assert call_kwargs["source_query"] is None # Not updated
|
||||
|
||||
async def test_update_multiple_fields(self, mcp_server_with_mental_models, mock_memory):
|
||||
await _tools(mcp_server_with_mental_models)["update_mental_model"].fn(
|
||||
mental_model_id="mm-1", name="New Name", source_query="new query?", tags=["updated"], max_tokens=4096
|
||||
)
|
||||
call_kwargs = mock_memory.update_mental_model.call_args.kwargs
|
||||
assert call_kwargs["name"] == "New Name"
|
||||
assert call_kwargs["source_query"] == "new query?"
|
||||
assert call_kwargs["tags"] == ["updated"]
|
||||
assert call_kwargs["max_tokens"] == 4096
|
||||
|
||||
async def test_update_with_bank_id_override(self, mcp_server_with_mental_models, mock_memory):
|
||||
await _tools(mcp_server_with_mental_models)["update_mental_model"].fn(
|
||||
mental_model_id="mm-1", name="X", bank_id="other-bank"
|
||||
)
|
||||
assert mock_memory.update_mental_model.call_args.kwargs["bank_id"] == "other-bank"
|
||||
|
||||
async def test_update_not_found_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
mock_memory.update_mental_model.return_value = None
|
||||
result = await _tools(mcp_server_with_mental_models)["update_mental_model"].fn(
|
||||
mental_model_id="missing", name="X"
|
||||
)
|
||||
assert "not found" in result
|
||||
|
||||
async def test_update_single_bank(self, mcp_server_single_bank, mock_memory):
|
||||
result = await _tools(mcp_server_single_bank)["update_mental_model"].fn(mental_model_id="mm-1", name="Updated")
|
||||
assert isinstance(result, dict)
|
||||
assert mock_memory.update_mental_model.call_args.kwargs["bank_id"] == "fixed-bank"
|
||||
|
||||
async def test_update_not_found_single_bank(self, mcp_server_single_bank, mock_memory):
|
||||
mock_memory.update_mental_model.return_value = None
|
||||
result = await _tools(mcp_server_single_bank)["update_mental_model"].fn(mental_model_id="missing", name="X")
|
||||
assert isinstance(result, dict)
|
||||
assert "not found" in result["error"]
|
||||
|
||||
async def test_update_no_bank_returns_error(self, no_bank_mcp_server):
|
||||
result = await _tools(no_bank_mcp_server)["update_mental_model"].fn(mental_model_id="mm-1", name="X")
|
||||
assert "error" in result
|
||||
|
||||
async def test_update_engine_error(self, mcp_server_with_mental_models, mock_memory):
|
||||
mock_memory.update_mental_model.side_effect = RuntimeError("DB error")
|
||||
result = await _tools(mcp_server_with_mental_models)["update_mental_model"].fn(mental_model_id="mm-1", name="X")
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDeleteMentalModel:
|
||||
async def test_delete_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
result = await _tools(mcp_server_with_mental_models)["delete_mental_model"].fn(mental_model_id="mm-1")
|
||||
assert '"deleted"' in result
|
||||
assert mock_memory.delete_mental_model.call_args.kwargs["mental_model_id"] == "mm-1"
|
||||
|
||||
async def test_delete_with_bank_id_override(self, mcp_server_with_mental_models, mock_memory):
|
||||
await _tools(mcp_server_with_mental_models)["delete_mental_model"].fn(
|
||||
mental_model_id="mm-1", bank_id="other-bank"
|
||||
)
|
||||
assert mock_memory.delete_mental_model.call_args.kwargs["bank_id"] == "other-bank"
|
||||
|
||||
async def test_delete_not_found_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
mock_memory.delete_mental_model.return_value = False
|
||||
result = await _tools(mcp_server_with_mental_models)["delete_mental_model"].fn(mental_model_id="missing")
|
||||
assert "not found" in result
|
||||
|
||||
async def test_delete_not_found_single_bank(self, mcp_server_single_bank, mock_memory):
|
||||
mock_memory.delete_mental_model.return_value = False
|
||||
result = await _tools(mcp_server_single_bank)["delete_mental_model"].fn(mental_model_id="missing")
|
||||
assert isinstance(result, dict)
|
||||
assert "not found" in result["error"]
|
||||
|
||||
async def test_delete_single_bank(self, mcp_server_single_bank, mock_memory):
|
||||
result = await _tools(mcp_server_single_bank)["delete_mental_model"].fn(mental_model_id="mm-1")
|
||||
assert isinstance(result, dict)
|
||||
assert result["status"] == "deleted"
|
||||
|
||||
async def test_delete_no_bank_returns_error(self, no_bank_mcp_server):
|
||||
result = await _tools(no_bank_mcp_server)["delete_mental_model"].fn(mental_model_id="mm-1")
|
||||
assert "error" in result
|
||||
|
||||
async def test_delete_engine_error(self, mcp_server_with_mental_models, mock_memory):
|
||||
mock_memory.delete_mental_model.side_effect = RuntimeError("DB error")
|
||||
result = await _tools(mcp_server_with_mental_models)["delete_mental_model"].fn(mental_model_id="mm-1")
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestRefreshMentalModel:
|
||||
async def test_refresh_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
result = await _tools(mcp_server_with_mental_models)["refresh_mental_model"].fn(mental_model_id="mm-1")
|
||||
assert '"op-123"' in result
|
||||
assert '"queued"' in result
|
||||
|
||||
async def test_refresh_with_bank_id_override(self, mcp_server_with_mental_models, mock_memory):
|
||||
await _tools(mcp_server_with_mental_models)["refresh_mental_model"].fn(
|
||||
mental_model_id="mm-1", bank_id="other-bank"
|
||||
)
|
||||
assert mock_memory.submit_async_refresh_mental_model.call_args.kwargs["bank_id"] == "other-bank"
|
||||
|
||||
async def test_refresh_not_found_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
mock_memory.submit_async_refresh_mental_model.side_effect = ValueError("Mental model 'missing' not found")
|
||||
result = await _tools(mcp_server_with_mental_models)["refresh_mental_model"].fn(mental_model_id="missing")
|
||||
assert "not found" in result
|
||||
|
||||
async def test_refresh_not_found_single_bank(self, mcp_server_single_bank, mock_memory):
|
||||
mock_memory.submit_async_refresh_mental_model.side_effect = ValueError("not found")
|
||||
result = await _tools(mcp_server_single_bank)["refresh_mental_model"].fn(mental_model_id="missing")
|
||||
assert isinstance(result, dict)
|
||||
assert "not found" in result["error"]
|
||||
|
||||
async def test_refresh_single_bank(self, mcp_server_single_bank, mock_memory):
|
||||
result = await _tools(mcp_server_single_bank)["refresh_mental_model"].fn(mental_model_id="mm-1")
|
||||
assert isinstance(result, dict)
|
||||
assert result["operation_id"] == "op-123"
|
||||
|
||||
async def test_refresh_no_bank_returns_error(self, no_bank_mcp_server):
|
||||
result = await _tools(no_bank_mcp_server)["refresh_mental_model"].fn(mental_model_id="mm-1")
|
||||
assert "error" in result
|
||||
|
||||
async def test_refresh_engine_error(self, mcp_server_with_mental_models, mock_memory):
|
||||
mock_memory.submit_async_refresh_mental_model.side_effect = RuntimeError("DB error")
|
||||
result = await _tools(mcp_server_with_mental_models)["refresh_mental_model"].fn(mental_model_id="mm-1")
|
||||
assert "error" in result
|
||||
|
||||
|
||||
class TestValidateMentalModelInputs:
|
||||
"""Tests for the _validate_mental_model_inputs helper."""
|
||||
|
||||
def test_valid_inputs(self):
|
||||
assert _validate_mental_model_inputs(name="Test", source_query="query", max_tokens=2048) is None
|
||||
|
||||
def test_none_inputs(self):
|
||||
assert _validate_mental_model_inputs() is None
|
||||
|
||||
def test_empty_name(self):
|
||||
result = _validate_mental_model_inputs(name="")
|
||||
assert result == "name cannot be empty"
|
||||
|
||||
def test_whitespace_name(self):
|
||||
result = _validate_mental_model_inputs(name=" ")
|
||||
assert result == "name cannot be empty"
|
||||
|
||||
def test_empty_source_query(self):
|
||||
result = _validate_mental_model_inputs(source_query="")
|
||||
assert result == "source_query cannot be empty"
|
||||
|
||||
def test_whitespace_source_query(self):
|
||||
result = _validate_mental_model_inputs(source_query=" \t ")
|
||||
assert result == "source_query cannot be empty"
|
||||
|
||||
def test_max_tokens_too_low(self):
|
||||
result = _validate_mental_model_inputs(max_tokens=0)
|
||||
assert "max_tokens must be between 256 and 8192" in result
|
||||
|
||||
def test_max_tokens_too_high(self):
|
||||
result = _validate_mental_model_inputs(max_tokens=10000)
|
||||
assert "max_tokens must be between 256 and 8192" in result
|
||||
|
||||
def test_max_tokens_at_lower_bound(self):
|
||||
assert _validate_mental_model_inputs(max_tokens=256) is None
|
||||
|
||||
def test_max_tokens_at_upper_bound(self):
|
||||
assert _validate_mental_model_inputs(max_tokens=8192) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestMentalModelInputValidation:
|
||||
"""Tests that validation is applied in create/update tools before engine calls."""
|
||||
|
||||
async def test_create_empty_name_returns_error_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
result = await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(name="", source_query="query")
|
||||
assert "name cannot be empty" in result
|
||||
mock_memory.create_mental_model.assert_not_called()
|
||||
|
||||
async def test_create_empty_source_query_returns_error_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
result = await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(name="Test", source_query="")
|
||||
assert "source_query cannot be empty" in result
|
||||
mock_memory.create_mental_model.assert_not_called()
|
||||
|
||||
async def test_create_max_tokens_too_low_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
result = await _tools(mcp_server_with_mental_models)["create_mental_model"].fn(
|
||||
name="Test", source_query="query", max_tokens=0
|
||||
)
|
||||
assert "max_tokens must be between 256 and 8192" in result
|
||||
mock_memory.create_mental_model.assert_not_called()
|
||||
|
||||
async def test_create_max_tokens_too_high_single_bank(self, mcp_server_single_bank, mock_memory):
|
||||
result = await _tools(mcp_server_single_bank)["create_mental_model"].fn(
|
||||
name="Test", source_query="query", max_tokens=10000
|
||||
)
|
||||
assert isinstance(result, dict)
|
||||
assert "max_tokens must be between 256 and 8192" in result["error"]
|
||||
mock_memory.create_mental_model.assert_not_called()
|
||||
|
||||
async def test_update_empty_name_returns_error_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
result = await _tools(mcp_server_with_mental_models)["update_mental_model"].fn(mental_model_id="mm-1", name="")
|
||||
assert "name cannot be empty" in result
|
||||
mock_memory.update_mental_model.assert_not_called()
|
||||
|
||||
async def test_update_empty_name_returns_error_single_bank(self, mcp_server_single_bank, mock_memory):
|
||||
result = await _tools(mcp_server_single_bank)["update_mental_model"].fn(mental_model_id="mm-1", name=" ")
|
||||
assert isinstance(result, dict)
|
||||
assert "name cannot be empty" in result["error"]
|
||||
mock_memory.update_mental_model.assert_not_called()
|
||||
|
||||
async def test_not_found_error_includes_bank_id_multi_bank(self, mcp_server_with_mental_models, mock_memory):
|
||||
mock_memory.get_mental_model.return_value = None
|
||||
result = await _tools(mcp_server_with_mental_models)["get_mental_model"].fn(mental_model_id="missing")
|
||||
assert "test-bank" in result
|
||||
|
||||
async def test_not_found_error_includes_bank_id_single_bank(self, mcp_server_single_bank, mock_memory):
|
||||
mock_memory.get_mental_model.return_value = None
|
||||
result = await _tools(mcp_server_single_bank)["get_mental_model"].fn(mental_model_id="missing")
|
||||
assert isinstance(result, dict)
|
||||
assert "fixed-bank" in result["error"]
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1,123 @@
|
||||
# How We Solved Memory Conflicts in Hindsight
|
||||
One of the hardest problems we tackled in Hindsight was dealing with contradictions. When you're building a memory system for AI agents, reality isn't static. It evolves.
|
||||
|
||||
A CRM agent might learn that "Acme Corp is a key prospect" in January, then encounter "Acme Corp is now a paying customer" in March. Naive approaches either lose the history or drown in duplicate facts.
|
||||
|
||||
We needed a system that could handle this gracefully. Here's how we built it.
|
||||
|
||||
## **The Core Problem: Facts vs. Knowledge**
|
||||
|
||||
Early on, we made a distinction that shaped the way we store and receive memories: raw facts aren't the same as consolidated knowledge. Facts are immediate observations; what an agent learns in a single interaction. Knowledge is durable understanding extracted from those facts over time.
|
||||
|
||||
Our consolidation pipeline runs as a background job after new memories are retained, transforming ephemeral facts into lasting knowledge. The key insight is that we don't just store the latest information, we track how knowledge evolves.
|
||||
|
||||

|
||||
|
||||
## **Finding Related Information**
|
||||
|
||||
When a new fact comes in, we first need to find existing observations that might conflict with it. This happens in our `_find_related_observations` function, which uses the full recall system with:
|
||||
|
||||
* **Semantic similarity** via embeddings to surface conceptually related observations
|
||||
* **Token budget** that naturally limits comparison scope via `consolidation_max_tokens`
|
||||
* **Security-aware filtering** using strict tag matching to prevent cross-user information leakage
|
||||
|
||||
```python
|
||||
recall_result = await memory_engine.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
max_tokens=config.consolidation_max_tokens,
|
||||
fact_type=["observation"],
|
||||
tags=tags,
|
||||
tags_match="all_strict",
|
||||
)
|
||||
```
|
||||
|
||||
## **LLM-Powered Conflict Analysis**
|
||||
|
||||
The real work happens in `_consolidate_with_llm`, where a single LLM call analyzes the new fact against existing observations. We provide rich context: the text of existing observations, their proof counts (how many supporting facts), and source memories. We organize those memories into a time series with dates and order them chronologically.
|
||||
|
||||
This lets the model make informed decisions about whether new information is redundant, contradictory, or represents a genuine state change.
|
||||
|
||||
## **Three Merge Strategies**
|
||||
|
||||
Our consolidation prompt defines three core merge rules:
|
||||
|
||||
**Redundant information:** When the same information is worded differently, we update the existing observation. "Acme Corp is a prospect" plus "Acme Corp is a potential customer" just becomes a single, cleaner observation.
|
||||
|
||||
**Direct contradictions:** When opposite information exists about the same topic, we preserve both states with temporal markers. The critical rule: updated text must capture both states. We don't overwrite old information. Instead, we try to create a temporal narrative that explains how the facts change over time. When no clear explanation exists, we consider the most recent data point to be up-to-date.
|
||||
|
||||
**State updates:** When new information replaces old state, we explicitly capture the transition with phrases like "used to," "now," or "changed from X to Y." We never just state the new fact, we capture the evolution.
|
||||
|
||||
## **Preserving Business Relationship History**
|
||||
|
||||
Consider how this handles an evolving business relationship. An agent learns these facts over six months:
|
||||
|
||||
1. January: "Acme Corp expressed interest in our enterprise tier"
|
||||
2. February: "Met with Acme Corp's CTO to discuss integration requirements"
|
||||
3. April: "Acme Corp signed a $50K annual contract"
|
||||
4. September: "Acme Corp upgraded to the $150K tier after expanding to 3 regions"
|
||||
|
||||

|
||||
|
||||
A naive system might just keep the latest fact: "Acme Corp is on a $150K contract." Useful, but you've lost the relationship arc.
|
||||
|
||||
Our system consolidates this into something like: "Acme Corp progressed from prospect (January) to $50K customer (April), then upgraded to $150K tier in September after regional expansion."
|
||||
|
||||
The full journey is preserved. An agent can answer "How did we land Acme Corp?" without losing the relationship history that makes that question meaningful.
|
||||
|
||||
## **Temporal Metadata**
|
||||
|
||||
We maintain temporal metadata for each observation:
|
||||
|
||||
```sql
|
||||
occurred_start = LEAST(occurred_start, COALESCE($7, occurred_start))
|
||||
occurred_end = GREATEST(occurred_end, COALESCE($8, occurred_end))
|
||||
mentioned_at = GREATEST(mentioned_at, COALESCE($9, mentioned_at))
|
||||
```
|
||||
|
||||
This ensures `occurred_start` keeps the earliest time something was true, `occurred_end` tracks the most recent observation, and `mentioned_at` records when it was last referenced.
|
||||
|
||||
## **History as an Audit Trail**
|
||||
|
||||
Every observation maintains a complete change history:
|
||||
|
||||
```python
|
||||
history.append({
|
||||
"previous_text": model["text"],
|
||||
"changed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"reason": reason,
|
||||
"source_memory_id": str(memory_id),
|
||||
})
|
||||
```
|
||||
|
||||
This audit trail lets the system explain how knowledge evolved, trace back to source facts that caused updates, and provide reasoning for why information changed. When an agent says "Acme Corp is a $150K customer," it can also explain how it knows that and what changed along the way.
|
||||
|
||||

|
||||
|
||||
## **Security at the Boundary**
|
||||
|
||||
One design decision we're particularly happy with: tag-based security boundaries during consolidation.
|
||||
|
||||
New observations inherit their source fact's tags. When updating existing observations, tags merge (union) so all contributors can see the knowledge they helped create. Consolidation only happens within the same security scope. Strict matching prevents information leakage while still allowing collaborative knowledge building.
|
||||
|
||||
```python
|
||||
existing_tags = set(model.get("tags", []) or [])
|
||||
source_tags = set(source_fact_tags or [])
|
||||
merged_tags = list(existing_tags | source_tags)
|
||||
```
|
||||
|
||||
## **Durable Knowledge, Not Ephemeral State**
|
||||
|
||||
A principle that significantly reduced false conflicts: we distinguish between durable knowledge and ephemeral state.
|
||||
|
||||
Good consolidation extracts lasting facts. "User visited Acme Corp at Room 105" becomes "Acme Corp is located in Room 105." But "User is currently in Room 105" isn't tracked because ephemeral position data changes constantly.
|
||||
|
||||
This focus on durability means the system isn't constantly flagging temporary state changes as conflicts.
|
||||
|
||||
## **Why This Matters**
|
||||
|
||||
Handling contradictory information in agent memory doesn't require picking winners and losers. By tracking temporal evolution, preserving history, and consolidating intelligently, we built a system that maintains nuanced understanding of how knowledge changes.
|
||||
|
||||
For agents that need to track changing preferences, understand temporal relationships, maintain audit trails, and build trust through explainable updates.
|
||||
|
||||
This approach allows Hindsight to provide more than just memory storage. It can provide context that improves with every interaction.
|
||||
@@ -269,15 +269,16 @@ export HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF=120.0 # Cap at 2min instead of 1m
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_EMBEDDINGS_PROVIDER` | Provider: `local`, `tei`, `openai`, `cohere`, or `litellm` | `local` |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL` | Model for local provider | `BAAI/bge-small-en-v1.5` |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE` | Allow loading models with custom code (security risk, disabled by default) | `false` |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_TEI_URL` | TEI server URL | - |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY` | OpenAI API key (falls back to `HINDSIGHT_API_LLM_API_KEY`) | - |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL` | OpenAI embedding model | `text-embedding-3-small` |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_OPENAI_BASE_URL` | Custom base URL for OpenAI-compatible API (e.g., Azure OpenAI) | - |
|
||||
| `HINDSIGHT_API_COHERE_API_KEY` | Cohere API key (shared for embeddings and reranker) | - |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY` | Cohere API key for embeddings | - |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL` | Cohere embedding model | `embed-english-v3.0` |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_COHERE_BASE_URL` | Custom base URL for Cohere-compatible API (e.g., Azure-hosted) | - |
|
||||
| `HINDSIGHT_API_LITELLM_API_BASE` | LiteLLM proxy base URL (shared for embeddings and reranker) | `http://localhost:4000` |
|
||||
| `HINDSIGHT_API_LITELLM_API_KEY` | LiteLLM proxy API key (optional, depends on proxy config) | - |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_API_BASE` | LiteLLM proxy base URL for embeddings | `http://localhost:4000` |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_API_KEY` | LiteLLM proxy API key for embeddings (optional, depends on proxy config) | - |
|
||||
| `HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL` | LiteLLM embedding model (use provider prefix, e.g., `cohere/embed-english-v3.0`) | `text-embedding-3-small` |
|
||||
|
||||
```bash
|
||||
@@ -285,6 +286,11 @@ export HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF=120.0 # Cap at 2min instead of 1m
|
||||
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
|
||||
export HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
|
||||
|
||||
# Local with custom model requiring trust_remote_code
|
||||
# WARNING: Only enable trust_remote_code for models you trust (security risk)
|
||||
# export HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=your-custom-model
|
||||
# export HINDSIGHT_API_EMBEDDINGS_LOCAL_TRUST_REMOTE_CODE=true
|
||||
|
||||
# OpenAI - cloud-based embeddings
|
||||
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=openai
|
||||
export HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY=sk-xxxxxxxxxxxx # or reuses HINDSIGHT_API_LLM_API_KEY
|
||||
@@ -302,19 +308,19 @@ export HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
|
||||
|
||||
# Cohere - cloud-based embeddings
|
||||
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=cohere
|
||||
export HINDSIGHT_API_COHERE_API_KEY=your-api-key
|
||||
export HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY=your-api-key
|
||||
export HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL=embed-english-v3.0 # 1024 dimensions
|
||||
|
||||
# Azure-hosted Cohere - embeddings via custom endpoint
|
||||
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=cohere
|
||||
export HINDSIGHT_API_COHERE_API_KEY=your-azure-api-key
|
||||
export HINDSIGHT_API_EMBEDDINGS_COHERE_API_KEY=your-azure-api-key
|
||||
export HINDSIGHT_API_EMBEDDINGS_COHERE_MODEL=embed-english-v3.0
|
||||
export HINDSIGHT_API_EMBEDDINGS_COHERE_BASE_URL=https://your-azure-cohere-endpoint.com
|
||||
|
||||
# LiteLLM proxy - unified gateway for multiple providers
|
||||
export HINDSIGHT_API_EMBEDDINGS_PROVIDER=litellm
|
||||
export HINDSIGHT_API_LITELLM_API_BASE=http://localhost:4000
|
||||
export HINDSIGHT_API_LITELLM_API_KEY=your-litellm-key # optional
|
||||
export HINDSIGHT_API_EMBEDDINGS_LITELLM_API_BASE=http://localhost:4000
|
||||
export HINDSIGHT_API_EMBEDDINGS_LITELLM_API_KEY=your-litellm-key # optional
|
||||
export HINDSIGHT_API_EMBEDDINGS_LITELLM_MODEL=text-embedding-3-small # or cohere/embed-english-v3.0
|
||||
```
|
||||
|
||||
@@ -341,11 +347,15 @@ Supported OpenAI embedding dimensions:
|
||||
| `HINDSIGHT_API_RERANKER_PROVIDER` | Provider: `local`, `tei`, `cohere`, `flashrank`, `litellm`, or `rrf` | `local` |
|
||||
| `HINDSIGHT_API_RERANKER_LOCAL_MODEL` | Model for local provider | `cross-encoder/ms-marco-MiniLM-L-6-v2` |
|
||||
| `HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT` | Max concurrent local reranking (prevents CPU thrashing under load) | `4` |
|
||||
| `HINDSIGHT_API_RERANKER_LOCAL_TRUST_REMOTE_CODE` | Allow loading models with custom code (security risk, disabled by default) | `false` |
|
||||
| `HINDSIGHT_API_RERANKER_TEI_URL` | TEI server URL | - |
|
||||
| `HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE` | Batch size for TEI reranking | `128` |
|
||||
| `HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT` | Max concurrent TEI reranking requests | `8` |
|
||||
| `HINDSIGHT_API_RERANKER_COHERE_API_KEY` | Cohere API key for reranking | - |
|
||||
| `HINDSIGHT_API_RERANKER_COHERE_MODEL` | Cohere rerank model | `rerank-english-v3.0` |
|
||||
| `HINDSIGHT_API_RERANKER_COHERE_BASE_URL` | Custom base URL for Cohere-compatible API (e.g., Azure-hosted) | - |
|
||||
| `HINDSIGHT_API_RERANKER_LITELLM_API_BASE` | LiteLLM proxy base URL for reranking | `http://localhost:4000` |
|
||||
| `HINDSIGHT_API_RERANKER_LITELLM_API_KEY` | LiteLLM proxy API key for reranking (optional, depends on proxy config) | - |
|
||||
| `HINDSIGHT_API_RERANKER_LITELLM_MODEL` | LiteLLM rerank model (use provider prefix, e.g., `cohere/rerank-english-v3.0`) | `cohere/rerank-english-v3.0` |
|
||||
| `HINDSIGHT_API_RERANKER_FLASHRANK_MODEL` | FlashRank model for fast CPU-based reranking | `ms-marco-MiniLM-L-12-v2` |
|
||||
| `HINDSIGHT_API_RERANKER_FLASHRANK_CACHE_DIR` | Cache directory for FlashRank models | System default |
|
||||
@@ -355,25 +365,31 @@ Supported OpenAI embedding dimensions:
|
||||
export HINDSIGHT_API_RERANKER_PROVIDER=local
|
||||
export HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
|
||||
|
||||
# Local with custom model requiring trust_remote_code (e.g., jina-reranker-v2)
|
||||
# WARNING: Only enable trust_remote_code for models you trust (security risk)
|
||||
export HINDSIGHT_API_RERANKER_PROVIDER=local
|
||||
export HINDSIGHT_API_RERANKER_LOCAL_MODEL=jinaai/jina-reranker-v2-base-multilingual
|
||||
export HINDSIGHT_API_RERANKER_LOCAL_TRUST_REMOTE_CODE=true
|
||||
|
||||
# TEI - for high-performance inference
|
||||
export HINDSIGHT_API_RERANKER_PROVIDER=tei
|
||||
export HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
|
||||
|
||||
# Cohere - cloud-based reranking
|
||||
export HINDSIGHT_API_RERANKER_PROVIDER=cohere
|
||||
export HINDSIGHT_API_COHERE_API_KEY=your-api-key # shared with embeddings
|
||||
export HINDSIGHT_API_RERANKER_COHERE_API_KEY=your-api-key
|
||||
export HINDSIGHT_API_RERANKER_COHERE_MODEL=rerank-english-v3.0
|
||||
|
||||
# Azure-hosted Cohere - reranking via custom endpoint
|
||||
export HINDSIGHT_API_RERANKER_PROVIDER=cohere
|
||||
export HINDSIGHT_API_COHERE_API_KEY=your-azure-api-key
|
||||
export HINDSIGHT_API_RERANKER_COHERE_API_KEY=your-azure-api-key
|
||||
export HINDSIGHT_API_RERANKER_COHERE_MODEL=rerank-english-v3.0
|
||||
export HINDSIGHT_API_RERANKER_COHERE_BASE_URL=https://your-azure-cohere-endpoint.com
|
||||
|
||||
# LiteLLM proxy - unified gateway for multiple reranking providers
|
||||
export HINDSIGHT_API_RERANKER_PROVIDER=litellm
|
||||
export HINDSIGHT_API_LITELLM_API_BASE=http://localhost:4000
|
||||
export HINDSIGHT_API_LITELLM_API_KEY=your-litellm-key # optional
|
||||
export HINDSIGHT_API_RERANKER_LITELLM_API_BASE=http://localhost:4000
|
||||
export HINDSIGHT_API_RERANKER_LITELLM_API_KEY=your-litellm-key # optional
|
||||
export HINDSIGHT_API_RERANKER_LITELLM_MODEL=cohere/rerank-english-v3.0 # or voyage/rerank-2, together_ai/...
|
||||
```
|
||||
|
||||
@@ -558,6 +574,74 @@ await memory.initialize()
|
||||
|
||||
---
|
||||
|
||||
## Observability & Tracing
|
||||
|
||||
Hindsight provides OpenTelemetry-based observability for LLM calls, conforming to GenAI semantic conventions.
|
||||
|
||||
### OpenTelemetry Tracing
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_OTEL_TRACES_ENABLED` | Enable distributed tracing for LLM calls | `false` |
|
||||
| `HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT` | OTLP endpoint URL (e.g., Grafana LGTM, Langfuse, etc.) | - |
|
||||
| `HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS` | Headers for OTLP exporter (format: "key1=value1,key2=value2") | - |
|
||||
| `HINDSIGHT_API_OTEL_SERVICE_NAME` | Service name for traces | `hindsight-api` |
|
||||
| `HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT` | Deployment environment name (e.g., development, staging, production) | `development` |
|
||||
|
||||
**Features:**
|
||||
- Full prompts and completions recorded as events
|
||||
- Token usage tracking (input/output)
|
||||
- Model and provider information
|
||||
- Error tracking with finish reasons
|
||||
- Conforms to OpenTelemetry GenAI semantic conventions v1.37+
|
||||
|
||||
**OTLP-Compatible Backends:**
|
||||
|
||||
The tracing implementation uses standard OTLP HTTP protocol, so it works with any OTLP-compatible backend:
|
||||
- **Grafana LGTM** (Recommended for local dev): All-in-one stack with Tempo traces, Loki logs, Mimir metrics, and Grafana UI
|
||||
- **Langfuse**: LLM-focused observability and analytics
|
||||
- **OpenLIT**: Built-in LLM dashboards, cost tracking
|
||||
- **DataDog, New Relic, Honeycomb**: Commercial platforms
|
||||
|
||||
**Example Configuration:**
|
||||
|
||||
```bash
|
||||
# Enable tracing
|
||||
export HINDSIGHT_API_OTEL_TRACES_ENABLED=true
|
||||
|
||||
# Configure endpoint (example: OpenLIT Cloud)
|
||||
export HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=https://otlp.openlit.io
|
||||
export HINDSIGHT_API_OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer olit-xxx"
|
||||
|
||||
# Optional: Custom service name and environment
|
||||
export HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-production
|
||||
export HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=production
|
||||
```
|
||||
|
||||
**Local Development:**
|
||||
|
||||
For local development, we recommend the Grafana LGTM stack which provides traces, metrics, and logs in a single container:
|
||||
|
||||
```bash
|
||||
./scripts/dev/start-grafana.sh
|
||||
```
|
||||
|
||||
See `scripts/dev/grafana/README.md` for detailed setup instructions.
|
||||
|
||||
Other options: See `scripts/dev/openlit/README.md` for OpenLIT or `scripts/dev/jaeger/README.md` for standalone Jaeger.
|
||||
|
||||
### Metrics
|
||||
|
||||
Hindsight exposes Prometheus metrics at the `/metrics` endpoint, including:
|
||||
- LLM call duration and token usage
|
||||
- Operation duration (retain/recall/reflect)
|
||||
- HTTP request metrics
|
||||
- Database connection pool metrics
|
||||
|
||||
Metrics are always enabled and available at `http://localhost:8888/metrics`.
|
||||
|
||||
---
|
||||
|
||||
## Control Plane
|
||||
|
||||
The Control Plane is the web UI for managing memory banks.
|
||||
|
||||
@@ -1,22 +1,30 @@
|
||||
# Monitoring
|
||||
|
||||
Hindsight provides comprehensive monitoring through Prometheus metrics and pre-built Grafana dashboards.
|
||||
Hindsight provides comprehensive observability through Prometheus metrics, OpenTelemetry distributed tracing, and pre-built Grafana dashboards.
|
||||
|
||||
## Local Development
|
||||
|
||||
For local metrics visualization, a convenience script downloads and runs Prometheus and Grafana:
|
||||
For local observability, use the Grafana LGTM (Loki, Grafana, Tempo, Mimir) all-in-one stack:
|
||||
|
||||
```bash
|
||||
./scripts/dev/start-monitoring.sh
|
||||
```
|
||||
|
||||
This will start:
|
||||
- **Grafana**: http://localhost:8890 (anonymous access enabled)
|
||||
- **Prometheus**: http://localhost:8889
|
||||
- **API Metrics**: http://localhost:8888/metrics
|
||||
This starts a single Docker container providing:
|
||||
- **Grafana UI**: http://localhost:3000 (anonymous admin access)
|
||||
- **Traces (Tempo)**: OTLP endpoint at http://localhost:4318 (HTTP) and http://localhost:4317 (gRPC)
|
||||
- **Metrics (Prometheus/Mimir)**: Scrapes http://localhost:8888/metrics automatically
|
||||
- **Logs (Loki)**: Available for log aggregation
|
||||
- **Pre-built Dashboards**: Hindsight Operations, LLM Metrics, API Service
|
||||
|
||||
**Enable tracing in your API:**
|
||||
```bash
|
||||
export HINDSIGHT_API_OTEL_TRACES_ENABLED=true
|
||||
export HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
```
|
||||
|
||||
:::note Production Deployment
|
||||
The local monitoring script is for development only. In production, you need to install and configure Prometheus and Grafana separately, then point Prometheus to scrape your Hindsight API's `/metrics` endpoint.
|
||||
The local monitoring stack is for development only. In production, deploy Grafana LGTM separately or use commercial platforms (Grafana Cloud, DataDog, New Relic, etc.).
|
||||
:::
|
||||
|
||||
## Grafana Dashboards
|
||||
@@ -197,3 +205,66 @@ hindsight_db_pool_size - hindsight_db_pool_idle
|
||||
```promql
|
||||
rate(hindsight_process_cpu_seconds{type="user"}[1m])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Distributed Tracing
|
||||
|
||||
Hindsight supports OpenTelemetry distributed tracing for memory operations and LLM calls, following GenAI semantic conventions v1.37+.
|
||||
|
||||
### Configuration
|
||||
|
||||
See [Configuration - OpenTelemetry Tracing](./configuration#opentelemetry-tracing) for environment variables.
|
||||
|
||||
**Quick Start:**
|
||||
```bash
|
||||
# Enable tracing
|
||||
export HINDSIGHT_API_OTEL_TRACES_ENABLED=true
|
||||
export HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
|
||||
# View traces with Grafana LGTM (local dev)
|
||||
./scripts/dev/start-monitoring.sh
|
||||
# Open http://localhost:3000 → Explore → Tempo
|
||||
```
|
||||
|
||||
Supports any OTLP-compatible backend (Grafana LGTM, Langfuse, OpenLIT, DataDog, New Relic, Honeycomb, etc.).
|
||||
|
||||
### Span Hierarchy
|
||||
|
||||
**Parent Spans (Operations):**
|
||||
- `hindsight.retain` - Memory ingestion
|
||||
- `hindsight.recall` - Memory retrieval
|
||||
- `hindsight.recall_embedding` - Query embedding
|
||||
- `hindsight.recall_retrieval` - Parallel search (semantic, BM25, graph, temporal)
|
||||
- `hindsight.recall_fusion` - Reciprocal Rank Fusion
|
||||
- `hindsight.recall_rerank` - Cross-encoder reranking
|
||||
- `hindsight.reflect` - Agentic reasoning
|
||||
- `hindsight.reflect_tool_call` - Tool execution (recall, lookup, etc.)
|
||||
- `hindsight.consolidation` - Observation synthesis
|
||||
- `hindsight.mental_model_refresh` - Mental model updates
|
||||
|
||||
**Child Spans (LLM Calls):**
|
||||
- Named by scope (e.g., `hindsight.memory`, `hindsight.reflect`)
|
||||
- Contain full prompts/completions as events
|
||||
- Follow GenAI semantic conventions for attributes
|
||||
|
||||
### Span Attributes
|
||||
|
||||
**Operation Spans:**
|
||||
- `hindsight.operation` - Operation type
|
||||
- `hindsight.bank_id` - Memory bank ID
|
||||
- `hindsight.query` - Query text (truncated to 100 chars)
|
||||
- `hindsight.fact_types` - Fact types for recall
|
||||
- `hindsight.thinking_budget` - Budget allocation
|
||||
- `hindsight.max_tokens` - Token limit
|
||||
|
||||
**LLM Spans (GenAI Semantic Conventions):**
|
||||
- `gen_ai.operation.name` - Always `"chat"`
|
||||
- `gen_ai.provider.name` - Provider (`openai`, `anthropic`, `google`, etc.)
|
||||
- `gen_ai.request.model` - Model name
|
||||
- `gen_ai.usage.input_tokens` - Input tokens
|
||||
- `gen_ai.usage.output_tokens` - Output tokens
|
||||
- `hindsight.scope` - LLM call purpose (`memory`, `reflect`, `consolidation`, etc.)
|
||||
|
||||
**Events:**
|
||||
- `gen_ai.client.inference.operation.details` - Full prompts and completions
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
}
|
||||
|
||||
[data-theme='dark'] .cardDescription {
|
||||
color: var(--ifm-color-emphasis-700);
|
||||
color: var(--ifm-color-emphasis-600);
|
||||
}
|
||||
|
||||
.cardTags {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
.container {
|
||||
margin: 0 0 20px 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.banner {
|
||||
background: linear-gradient(135deg, #0074d9 0%, #005db0 100%);
|
||||
border-radius: 8px;
|
||||
padding: 10px 14px;
|
||||
color: white;
|
||||
box-shadow: 0 2px 8px rgba(0, 116, 217, 0.12);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
transition: box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.banner:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 116, 217, 0.2);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.commandWrapper {
|
||||
margin-top: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.command {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, Consolas, monospace;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #e6f7f8;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
height: 28px;
|
||||
width: 28px;
|
||||
}
|
||||
|
||||
.copyButton:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.copyButton:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.copyButton svg {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Dark mode - make it stand out even more */
|
||||
html[data-theme='dark'] .banner {
|
||||
box-shadow: 0 4px 12px rgba(0, 116, 217, 0.2),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
html[data-theme='dark'] .banner:hover {
|
||||
box-shadow: 0 6px 16px rgba(0, 116, 217, 0.3),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
/* Light mode adjustments */
|
||||
html[data-theme='light'] .banner {
|
||||
box-shadow: 0 4px 12px rgba(0, 116, 217, 0.15),
|
||||
0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
html[data-theme='light'] .banner:hover {
|
||||
box-shadow: 0 6px 16px rgba(0, 116, 217, 0.25),
|
||||
0 4px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import React, { useState } from 'react';
|
||||
import styles from './SkillBanner.module.css';
|
||||
|
||||
export default function SkillBanner(): JSX.Element {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const command = 'npx skills add https://github.com/vectorize-io/hindsight --skill hindsight-docs';
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(command);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.banner}>
|
||||
<div className={styles.icon}>🤖</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.title}>
|
||||
Using a coding agent? Install the docs skill for instant access
|
||||
</div>
|
||||
<div className={styles.commandWrapper}>
|
||||
<code className={styles.command}>
|
||||
{command}
|
||||
</code>
|
||||
<button
|
||||
className={styles.copyButton}
|
||||
onClick={handleCopy}
|
||||
aria-label="Copy command"
|
||||
title={copied ? 'Copied!' : 'Copy to clipboard'}
|
||||
>
|
||||
{copied ? (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="20 6 9 17 4 12"></polyline>
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
.toastContainer {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 9999;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: opacity 0.3s ease, transform 0.3s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toastContainer.show {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.toast {
|
||||
background: linear-gradient(135deg, #0074d9 0%, #005db0 100%);
|
||||
color: white;
|
||||
border-radius: 12px;
|
||||
padding: 16px 20px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
max-width: 420px;
|
||||
min-width: 320px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 24px;
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.command {
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'SF Mono', Monaco, Consolas, monospace;
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #e6f7f8;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 24px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s ease;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
opacity: 1;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.closeButton:active {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
/* Mobile responsive */
|
||||
@media (max-width: 768px) {
|
||||
.toastContainer {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.command {
|
||||
font-size: 11px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark mode adjustments (if needed) */
|
||||
html[data-theme='dark'] .toast {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4),
|
||||
0 0 0 1px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import styles from './SkillToast.module.css';
|
||||
|
||||
const STORAGE_KEY = 'hindsight-skill-toast-dismissed';
|
||||
|
||||
export default function SkillToast(): JSX.Element | null {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Check if user has already dismissed the toast
|
||||
const dismissed = localStorage.getItem(STORAGE_KEY);
|
||||
|
||||
if (!dismissed) {
|
||||
// Show toast after a short delay
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
setIsAnimating(true);
|
||||
}, 1500);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsAnimating(false);
|
||||
setTimeout(() => {
|
||||
setIsVisible(false);
|
||||
localStorage.setItem(STORAGE_KEY, 'true');
|
||||
}, 300); // Match animation duration
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className={`${styles.toastContainer} ${isAnimating ? styles.show : ''}`}>
|
||||
<div className={styles.toast}>
|
||||
<div className={styles.icon}>🤖</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.title}>Building with a coding agent?</div>
|
||||
<div className={styles.message}>
|
||||
Install the Hindsight documentation skill for faster development:
|
||||
</div>
|
||||
<code className={styles.command}>
|
||||
curl -fsSL https://hindsight.vectorize.io/get-skill | bash
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
className={styles.closeButton}
|
||||
onClick={handleDismiss}
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import Breadcrumbs from '@theme-original/DocBreadcrumbs';
|
||||
import type BreadcrumbsType from '@theme/DocBreadcrumbs';
|
||||
import type {WrapperProps} from '@docusaurus/types';
|
||||
import SkillBanner from '@site/src/components/SkillBanner';
|
||||
|
||||
type Props = WrapperProps<typeof BreadcrumbsType>;
|
||||
|
||||
export default function BreadcrumbsWrapper(props: Props): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<Breadcrumbs {...props} />
|
||||
<SkillBanner />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import Sidebar from '@theme-original/DocPage/Layout/Sidebar';
|
||||
import type SidebarType from '@theme/DocPage/Layout/Sidebar';
|
||||
import type {WrapperProps} from '@docusaurus/types';
|
||||
import {useLocation} from '@docusaurus/router';
|
||||
import SkillBanner from '@site/src/components/SkillBanner';
|
||||
|
||||
type Props = WrapperProps<typeof SidebarType>;
|
||||
|
||||
@@ -15,5 +16,10 @@ export default function SidebarWrapper(props: Props): JSX.Element | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <Sidebar {...props} />;
|
||||
return (
|
||||
<>
|
||||
<SkillBanner />
|
||||
<Sidebar {...props} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react';
|
||||
import Content from '@theme-original/DocSidebar/Desktop/Content';
|
||||
import type ContentType from '@theme/DocSidebar/Desktop/Content';
|
||||
import type {WrapperProps} from '@docusaurus/types';
|
||||
|
||||
type Props = WrapperProps<typeof ContentType>;
|
||||
|
||||
export default function ContentWrapper(props: Props): JSX.Element {
|
||||
return <Content {...props} />;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 541 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 698 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 390 KiB |
@@ -270,6 +270,7 @@ function getPluginConfig(api: MoltbotPluginAPI): PluginConfig {
|
||||
// Dynamic bank ID options (default: enabled)
|
||||
dynamicBankId: config.dynamicBankId !== false,
|
||||
bankIdPrefix: config.bankIdPrefix,
|
||||
excludeProviders: Array.isArray(config.excludeProviders) ? config.excludeProviders : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -549,6 +550,12 @@ export default function (api: MoltbotPluginAPI) {
|
||||
}
|
||||
currentAgentContext = ctx;
|
||||
|
||||
// Check if this provider is excluded
|
||||
if (ctx?.messageProvider && pluginConfig.excludeProviders?.includes(ctx.messageProvider)) {
|
||||
console.log(`[Hindsight] Skipping recall for excluded provider: ${ctx.messageProvider}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Derive bank ID from context
|
||||
const bankId = deriveBankId(ctx, pluginConfig);
|
||||
console.log(`[Hindsight] before_agent_start - bank: ${bankId}, channel: ${ctx?.messageProvider}/${ctx?.channelId}`);
|
||||
@@ -647,6 +654,12 @@ User message: ${prompt}
|
||||
// Use context from this hook, or fall back to context captured in before_agent_start
|
||||
const effectiveCtx = ctx || currentAgentContext;
|
||||
|
||||
// Check if this provider is excluded
|
||||
if (effectiveCtx?.messageProvider && pluginConfig.excludeProviders?.includes(effectiveCtx.messageProvider)) {
|
||||
console.log(`[Hindsight] Skipping retain for excluded provider: ${effectiveCtx.messageProvider}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Derive bank ID from context
|
||||
const bankId = deriveBankId(effectiveCtx, pluginConfig);
|
||||
console.log(`[Hindsight Hook] agent_end triggered - bank: ${bankId}`);
|
||||
|
||||
@@ -42,6 +42,7 @@ export interface PluginConfig {
|
||||
hindsightApiToken?: string; // API token for external Hindsight API authentication
|
||||
dynamicBankId?: boolean; // Enable per-channel memory banks (default: true)
|
||||
bankIdPrefix?: string; // Prefix for bank IDs (e.g. 'prod' -> 'prod-slack-C123')
|
||||
excludeProviders?: string[]; // Message providers to exclude from recall/retain (e.g. ['telegram', 'discord'])
|
||||
}
|
||||
|
||||
export interface ServiceConfig {
|
||||
|
||||
Generated
+8
@@ -13,7 +13,11 @@
|
||||
},
|
||||
"hindsight-clients/typescript": {
|
||||
"name": "@vectorize-io/hindsight-client",
|
||||
<<<<<<< HEAD
|
||||
"version": "0.4.9",
|
||||
=======
|
||||
"version": "0.4.8",
|
||||
>>>>>>> 23f916f (feat: add otel traceability)
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "0.88.0",
|
||||
@@ -131,7 +135,11 @@
|
||||
},
|
||||
"hindsight-control-plane": {
|
||||
"name": "@vectorize-io/hindsight-control-plane",
|
||||
<<<<<<< HEAD
|
||||
"version": "0.4.9",
|
||||
=======
|
||||
"version": "0.4.8",
|
||||
>>>>>>> 23f916f (feat: add otel traceability)
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# Hindsight Monitoring Stack
|
||||
|
||||
Docker-based monitoring stack using **Grafana LGTM** (Loki, Grafana, Tempo, Mimir) for complete observability.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Start the monitoring stack
|
||||
./scripts/dev/start-monitoring.sh
|
||||
|
||||
# Or manually with docker-compose
|
||||
cd scripts/dev/monitoring && docker-compose up -d
|
||||
```
|
||||
|
||||
## Access
|
||||
|
||||
- **Grafana UI**: http://localhost:3000
|
||||
- No login required (anonymous admin enabled for dev)
|
||||
|
||||
## Features
|
||||
|
||||
- **Traces**: OpenTelemetry traces with GenAI semantic conventions (Tempo)
|
||||
- **Metrics**: Prometheus scraping of Hindsight API `/metrics` endpoint
|
||||
- **Logs**: Loki log aggregation (future)
|
||||
- **Dashboards**: Pre-configured dashboards from `monitoring/grafana/dashboards/`:
|
||||
- Hindsight Operations
|
||||
- Hindsight LLM Metrics
|
||||
- Hindsight API Service
|
||||
|
||||
## Configure Hindsight API
|
||||
|
||||
Set these environment variables in your `.env`:
|
||||
|
||||
```bash
|
||||
# Enable tracing
|
||||
HINDSIGHT_API_OTEL_TRACES_ENABLED=true
|
||||
|
||||
# Grafana Tempo OTLP endpoint (HTTP)
|
||||
HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
|
||||
|
||||
# Optional: Custom service name
|
||||
HINDSIGHT_API_OTEL_SERVICE_NAME=hindsight-api
|
||||
|
||||
# Optional: Deployment environment
|
||||
HINDSIGHT_API_OTEL_DEPLOYMENT_ENVIRONMENT=development
|
||||
```
|
||||
|
||||
## View Data
|
||||
|
||||
### Traces
|
||||
1. Open http://localhost:3000
|
||||
2. Go to **Explore** (compass icon)
|
||||
3. Select **Tempo** as data source
|
||||
4. Click "Search" to see recent traces
|
||||
|
||||
### Metrics & Dashboards
|
||||
1. Open http://localhost:3000
|
||||
2. Go to **Dashboards** (dashboard icon)
|
||||
3. Browse the Hindsight folder
|
||||
|
||||
### Raw Metrics
|
||||
- Prometheus metrics: http://localhost:8888/metrics
|
||||
- PromQL queries: Explore → Prometheus
|
||||
|
||||
## Ports
|
||||
|
||||
| Port | Service |
|
||||
|------|---------|
|
||||
| 3000 | Grafana UI |
|
||||
| 4317 | OTLP gRPC endpoint |
|
||||
| 4318 | OTLP HTTP endpoint |
|
||||
|
||||
## Stop
|
||||
|
||||
```bash
|
||||
cd scripts/dev/monitoring && docker-compose down
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Single Container**: Grafana LGTM (~515MB) provides all observability components
|
||||
- **Auto-provisioned Dashboards**: Dashboards from `monitoring/grafana/dashboards/` are automatically loaded
|
||||
- **Prometheus Scraping**: Configured to scrape Hindsight API at `host.docker.internal:8888/metrics` every 5 seconds
|
||||
- **Network**: Uses `hindsight-network` (shared with API for future service-to-service tracing)
|
||||
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
grafana-lgtm:
|
||||
image: grafana/otel-lgtm:latest
|
||||
container_name: hindsight-monitoring
|
||||
ports:
|
||||
# Grafana UI
|
||||
- "3000:3000"
|
||||
# OTLP gRPC (traces)
|
||||
- "4317:4317"
|
||||
# OTLP HTTP (traces)
|
||||
- "4318:4318"
|
||||
environment:
|
||||
- GF_AUTH_ANONYMOUS_ENABLED=true
|
||||
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
|
||||
- GF_AUTH_DISABLE_LOGIN_FORM=true
|
||||
volumes:
|
||||
# Mount Prometheus config for scraping Hindsight API metrics (override LGTM default)
|
||||
- ./prometheus.yml:/otel-lgtm/prometheus.yaml:ro
|
||||
# Mount Hindsight dashboards to LGTM directory (where default dashboards are)
|
||||
- ../../../monitoring/grafana/dashboards/hindsight-operations.json:/otel-lgtm/hindsight-operations.json:ro
|
||||
- ../../../monitoring/grafana/dashboards/hindsight-llm.json:/otel-lgtm/hindsight-llm.json:ro
|
||||
- ../../../monitoring/grafana/dashboards/hindsight-api-service.json:/otel-lgtm/hindsight-api-service.json:ro
|
||||
# Mount custom dashboard provisioning config that includes Hindsight dashboards
|
||||
- ./grafana-dashboards.yaml:/otel-lgtm/grafana/conf/provisioning/dashboards/grafana-dashboards.yaml:ro
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- hindsight
|
||||
extra_hosts:
|
||||
# Allow container to reach host services (Hindsight API on localhost:8888)
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
networks:
|
||||
hindsight:
|
||||
name: hindsight-network
|
||||
external: true
|
||||
@@ -0,0 +1,36 @@
|
||||
apiVersion: 1
|
||||
|
||||
providers:
|
||||
# Default LGTM dashboards
|
||||
- name: "RED Metrics (classic histogram)"
|
||||
type: file
|
||||
options:
|
||||
path: /otel-lgtm/grafana-dashboard-red-metrics-classic.json
|
||||
foldersFromFilesStructure: false
|
||||
- name: "RED Metrics (exponential/native histogram)"
|
||||
type: file
|
||||
options:
|
||||
path: /otel-lgtm/grafana-dashboard-red-metrics-native.json
|
||||
foldersFromFilesStructure: false
|
||||
- name: "JVM Metrics"
|
||||
type: file
|
||||
options:
|
||||
path: /otel-lgtm/grafana-dashboard-jvm-metrics.json
|
||||
foldersFromFilesStructure: false
|
||||
|
||||
# Hindsight dashboards
|
||||
- name: "Hindsight Operations"
|
||||
type: file
|
||||
options:
|
||||
path: /otel-lgtm/hindsight-operations.json
|
||||
foldersFromFilesStructure: false
|
||||
- name: "Hindsight LLM"
|
||||
type: file
|
||||
options:
|
||||
path: /otel-lgtm/hindsight-llm.json
|
||||
foldersFromFilesStructure: false
|
||||
- name: "Hindsight API Service"
|
||||
type: file
|
||||
options:
|
||||
path: /otel-lgtm/hindsight-api-service.json
|
||||
foldersFromFilesStructure: false
|
||||
@@ -0,0 +1,30 @@
|
||||
# Prometheus configuration for Grafana LGTM with Hindsight API scraping
|
||||
---
|
||||
global:
|
||||
scrape_interval: 5s
|
||||
evaluation_interval: 5s
|
||||
scrape_native_histograms: true
|
||||
|
||||
# OTLP receiver configuration (from LGTM default)
|
||||
otlp:
|
||||
keep_identifying_resource_attributes: true
|
||||
promote_resource_attributes:
|
||||
- service.instance.id
|
||||
- service.name
|
||||
- service.namespace
|
||||
- service.version
|
||||
- deployment.environment
|
||||
- deployment.environment.name
|
||||
- host.name
|
||||
|
||||
storage:
|
||||
tsdb:
|
||||
out_of_order_time_window: 10m
|
||||
|
||||
# Scrape configs for pulling metrics from Hindsight API
|
||||
scrape_configs:
|
||||
- job_name: 'hindsight-api'
|
||||
static_configs:
|
||||
- targets: ['host.docker.internal:8888']
|
||||
metrics_path: '/metrics'
|
||||
scrape_interval: 5s
|
||||
+34
-202
@@ -1,222 +1,54 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Script to start Prometheus and Grafana for Hindsight metrics
|
||||
# This provides a single command for the full monitoring stack
|
||||
# Script to start the Hindsight monitoring stack with Grafana LGTM
|
||||
# Provides traces (Tempo), metrics (Prometheus/Mimir), logs (Loki), and dashboards (Grafana)
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
|
||||
MONITORING_DATA_DIR="$PROJECT_ROOT/.monitoring"
|
||||
API_PORT="${API_PORT:-8888}"
|
||||
PROMETHEUS_PORT="${PROMETHEUS_PORT:-8889}"
|
||||
GRAFANA_PORT="${GRAFANA_PORT:-8890}"
|
||||
|
||||
# Versions
|
||||
PROMETHEUS_VERSION="2.48.0"
|
||||
GRAFANA_VERSION="10.2.2"
|
||||
|
||||
# Detect OS and architecture
|
||||
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||
ARCH=$(uname -m)
|
||||
|
||||
case "$OS" in
|
||||
darwin) OS_NAME="darwin" ;;
|
||||
linux) OS_NAME="linux" ;;
|
||||
*) echo "Unsupported OS: $OS"; exit 1 ;;
|
||||
esac
|
||||
|
||||
case "$ARCH" in
|
||||
x86_64) ARCH_NAME="amd64" ;;
|
||||
arm64|aarch64) ARCH_NAME="arm64" ;;
|
||||
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
|
||||
esac
|
||||
|
||||
# Prometheus paths
|
||||
PROMETHEUS_DIR="$MONITORING_DATA_DIR/prometheus"
|
||||
PROMETHEUS_ARCHIVE="prometheus-${PROMETHEUS_VERSION}.${OS_NAME}-${ARCH_NAME}.tar.gz"
|
||||
PROMETHEUS_URL="https://github.com/prometheus/prometheus/releases/download/v${PROMETHEUS_VERSION}/${PROMETHEUS_ARCHIVE}"
|
||||
PROMETHEUS_BIN="$PROMETHEUS_DIR/prometheus-${PROMETHEUS_VERSION}.${OS_NAME}-${ARCH_NAME}/prometheus"
|
||||
|
||||
# Grafana paths
|
||||
GRAFANA_DIR="$MONITORING_DATA_DIR/grafana"
|
||||
GRAFANA_ARCHIVE="grafana-${GRAFANA_VERSION}.${OS_NAME}-${ARCH_NAME}.tar.gz"
|
||||
GRAFANA_URL="https://dl.grafana.com/oss/release/${GRAFANA_ARCHIVE}"
|
||||
GRAFANA_HOME="$GRAFANA_DIR/grafana-v${GRAFANA_VERSION}"
|
||||
GRAFANA_BIN="$GRAFANA_HOME/bin/grafana"
|
||||
|
||||
# Cleanup function
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "Shutting down monitoring stack..."
|
||||
|
||||
if [ -n "$PROM_PID" ] && kill -0 "$PROM_PID" 2>/dev/null; then
|
||||
kill "$PROM_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ -n "$GRAFANA_PID" ] && kill -0 "$GRAFANA_PID" 2>/dev/null; then
|
||||
kill "$GRAFANA_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "Monitoring stack stopped"
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup SIGINT SIGTERM
|
||||
|
||||
# Download Prometheus if needed
|
||||
if [ ! -f "$PROMETHEUS_BIN" ]; then
|
||||
echo "Downloading Prometheus ${PROMETHEUS_VERSION}..."
|
||||
mkdir -p "$PROMETHEUS_DIR"
|
||||
cd "$PROMETHEUS_DIR"
|
||||
curl -sL -o "$PROMETHEUS_ARCHIVE" "$PROMETHEUS_URL"
|
||||
tar xzf "$PROMETHEUS_ARCHIVE"
|
||||
rm "$PROMETHEUS_ARCHIVE"
|
||||
echo "Prometheus ready"
|
||||
fi
|
||||
|
||||
# Download Grafana if needed
|
||||
if [ ! -f "$GRAFANA_BIN" ]; then
|
||||
echo "Downloading Grafana ${GRAFANA_VERSION}..."
|
||||
mkdir -p "$GRAFANA_DIR"
|
||||
cd "$GRAFANA_DIR"
|
||||
curl -sL -o "$GRAFANA_ARCHIVE" "$GRAFANA_URL"
|
||||
tar xzf "$GRAFANA_ARCHIVE"
|
||||
rm "$GRAFANA_ARCHIVE"
|
||||
echo "Grafana ready"
|
||||
fi
|
||||
|
||||
# Create Prometheus config
|
||||
mkdir -p "$PROMETHEUS_DIR"
|
||||
cat > "$PROMETHEUS_DIR/prometheus.yml" <<EOF
|
||||
global:
|
||||
scrape_interval: 5s
|
||||
evaluation_interval: 5s
|
||||
|
||||
scrape_configs:
|
||||
- job_name: 'hindsight-api'
|
||||
scrape_interval: 5s
|
||||
static_configs:
|
||||
- targets: ['localhost:$API_PORT']
|
||||
metrics_path: '/metrics'
|
||||
EOF
|
||||
|
||||
# Create Grafana provisioning directories
|
||||
GRAFANA_PROV_DIR="$GRAFANA_DIR/provisioning"
|
||||
mkdir -p "$GRAFANA_PROV_DIR/datasources"
|
||||
mkdir -p "$GRAFANA_PROV_DIR/dashboards"
|
||||
mkdir -p "$GRAFANA_DIR/dashboards"
|
||||
mkdir -p "$GRAFANA_DIR/data"
|
||||
|
||||
# Copy dashboards from project root monitoring directory
|
||||
cp "$PROJECT_ROOT/monitoring/grafana/dashboards/"*.json "$GRAFANA_DIR/dashboards/"
|
||||
|
||||
# Create Grafana datasource config
|
||||
cat > "$GRAFANA_PROV_DIR/datasources/prometheus.yaml" <<EOF
|
||||
apiVersion: 1
|
||||
datasources:
|
||||
- name: Prometheus
|
||||
type: prometheus
|
||||
access: proxy
|
||||
url: http://localhost:$PROMETHEUS_PORT
|
||||
isDefault: true
|
||||
editable: false
|
||||
uid: prometheus
|
||||
EOF
|
||||
|
||||
# Create Grafana dashboard provisioning config
|
||||
cat > "$GRAFANA_PROV_DIR/dashboards/dashboards.yaml" <<EOF
|
||||
apiVersion: 1
|
||||
providers:
|
||||
- name: 'Hindsight'
|
||||
orgId: 1
|
||||
folder: 'Hindsight'
|
||||
folderUid: 'hindsight'
|
||||
type: file
|
||||
disableDeletion: false
|
||||
updateIntervalSeconds: 10
|
||||
allowUiUpdates: true
|
||||
options:
|
||||
path: $GRAFANA_DIR/dashboards
|
||||
EOF
|
||||
|
||||
# Create Grafana config
|
||||
cat > "$GRAFANA_DIR/grafana.ini" <<EOF
|
||||
[server]
|
||||
http_port = $GRAFANA_PORT
|
||||
root_url = http://localhost:$GRAFANA_PORT
|
||||
|
||||
[security]
|
||||
admin_user = admin
|
||||
admin_password = admin
|
||||
disable_initial_admin_creation = false
|
||||
|
||||
[auth.anonymous]
|
||||
enabled = true
|
||||
org_name = Main Org.
|
||||
org_role = Viewer
|
||||
|
||||
[paths]
|
||||
data = $GRAFANA_DIR/data
|
||||
logs = $GRAFANA_DIR/logs
|
||||
plugins = $GRAFANA_DIR/plugins
|
||||
provisioning = $GRAFANA_PROV_DIR
|
||||
|
||||
[log]
|
||||
mode = console
|
||||
level = warn
|
||||
|
||||
[dashboards]
|
||||
default_home_dashboard_path = $GRAFANA_DIR/dashboards/hindsight-operations.json
|
||||
EOF
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
echo ""
|
||||
echo "=================================="
|
||||
echo " Hindsight Monitoring Stack"
|
||||
echo "=================================="
|
||||
echo "🚀 Starting Hindsight Monitoring Stack (Grafana LGTM)"
|
||||
echo ""
|
||||
echo " Grafana: http://localhost:$GRAFANA_PORT"
|
||||
echo " Prometheus: http://localhost:$PROMETHEUS_PORT"
|
||||
echo " API Metrics: http://localhost:$API_PORT/metrics"
|
||||
echo ""
|
||||
echo " Dashboards:"
|
||||
echo " - Hindsight Operations"
|
||||
echo " - Hindsight LLM Metrics"
|
||||
echo " - Hindsight API Service"
|
||||
echo ""
|
||||
echo "=================================="
|
||||
echo "This provides:"
|
||||
echo " • OpenTelemetry traces (Tempo)"
|
||||
echo " • Metrics (Prometheus/Mimir)"
|
||||
echo " • Logs (Loki)"
|
||||
echo " • Dashboards (Grafana)"
|
||||
echo ""
|
||||
|
||||
# Check if API is running
|
||||
if ! curl -s "http://localhost:$API_PORT/metrics" > /dev/null 2>&1; then
|
||||
echo "WARNING: Hindsight API not detected at localhost:$API_PORT"
|
||||
echo " Start the API first: ./scripts/dev/start-api.sh"
|
||||
echo "⚠️ WARNING: Hindsight API not detected at localhost:$API_PORT"
|
||||
echo " Start the API first: ./scripts/dev/start-api.sh"
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# Start Prometheus in background
|
||||
cd "$(dirname "$PROMETHEUS_BIN")"
|
||||
"$PROMETHEUS_BIN" \
|
||||
--config.file="$PROMETHEUS_DIR/prometheus.yml" \
|
||||
--storage.tsdb.path="$PROMETHEUS_DIR/data" \
|
||||
--web.console.templates="$(dirname "$PROMETHEUS_BIN")/consoles" \
|
||||
--web.console.libraries="$(dirname "$PROMETHEUS_BIN")/console_libraries" \
|
||||
--web.listen-address="0.0.0.0:$PROMETHEUS_PORT" \
|
||||
--web.enable-lifecycle \
|
||||
--log.level=warn &
|
||||
PROM_PID=$!
|
||||
|
||||
# Start Grafana in background
|
||||
cd "$GRAFANA_HOME"
|
||||
"$GRAFANA_BIN" server \
|
||||
--homepath="$GRAFANA_HOME" \
|
||||
--config="$GRAFANA_DIR/grafana.ini" &
|
||||
GRAFANA_PID=$!
|
||||
|
||||
echo "Monitoring stack running. Press Ctrl+C to stop."
|
||||
echo "Access Grafana UI: http://localhost:3000"
|
||||
echo " (no login required for dev - anonymous admin enabled)"
|
||||
echo ""
|
||||
echo "Dashboards available:"
|
||||
echo " • Hindsight Operations"
|
||||
echo " • Hindsight LLM Metrics"
|
||||
echo " • Hindsight API Service"
|
||||
echo ""
|
||||
echo "Configure Hindsight API for tracing:"
|
||||
echo " export HINDSIGHT_API_OTEL_TRACES_ENABLED=true"
|
||||
echo " export HINDSIGHT_API_OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318"
|
||||
echo ""
|
||||
echo "OTLP Endpoints:"
|
||||
echo " • HTTP: http://localhost:4318"
|
||||
echo " • gRPC: http://localhost:4317"
|
||||
echo ""
|
||||
echo "View:"
|
||||
echo " • Traces: http://localhost:3000 → Explore → Tempo"
|
||||
echo " • Metrics: http://localhost:3000 → Dashboards"
|
||||
echo " • Raw Metrics: http://localhost:$API_PORT/metrics"
|
||||
echo ""
|
||||
echo "Press Ctrl+C to stop"
|
||||
echo ""
|
||||
|
||||
# Wait for processes
|
||||
wait "$PROM_PID" "$GRAFANA_PID" 2>/dev/null || true
|
||||
|
||||
# If we get here, clean up
|
||||
cleanup
|
||||
docker-compose up
|
||||
|
||||
Executable
+280
@@ -0,0 +1,280 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# Generate agent skill from Hindsight documentation
|
||||
# Converts docs/ to skills/hindsight-docs/ for AI agent consumption
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
ROOT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
DOCS_DIR="$ROOT_DIR/hindsight-docs/docs"
|
||||
EXAMPLES_DIR="$ROOT_DIR/hindsight-docs/examples"
|
||||
SKILL_DIR="$ROOT_DIR/skills/hindsight-docs"
|
||||
REFS_DIR="$SKILL_DIR/references"
|
||||
|
||||
# Colors
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m'
|
||||
|
||||
print_info() {
|
||||
echo -e "${GREEN}[INFO]${NC} $1"
|
||||
}
|
||||
|
||||
print_warn() {
|
||||
echo -e "${YELLOW}[WARN]${NC} $1"
|
||||
}
|
||||
|
||||
print_info "Generating Hindsight documentation skill..."
|
||||
|
||||
# Clean and recreate skill directory
|
||||
rm -rf "$SKILL_DIR"
|
||||
mkdir -p "$REFS_DIR"
|
||||
|
||||
# Process markdown files
|
||||
process_file() {
|
||||
local src_file="$1"
|
||||
local rel_path="${src_file#$DOCS_DIR/}"
|
||||
local dest_file="$REFS_DIR/$rel_path"
|
||||
|
||||
# Create destination directory
|
||||
mkdir -p "$(dirname "$dest_file")"
|
||||
|
||||
# Process the file
|
||||
if [[ "$src_file" == *.mdx ]]; then
|
||||
# Change .mdx to .md
|
||||
dest_file="${dest_file%.mdx}.md"
|
||||
print_info "Converting: $rel_path"
|
||||
convert_mdx_to_md "$src_file" "$dest_file"
|
||||
else
|
||||
print_info "Copying: $rel_path"
|
||||
cp "$src_file" "$dest_file"
|
||||
fi
|
||||
}
|
||||
|
||||
# Convert MDX to Markdown by:
|
||||
# 1. Removing import statements
|
||||
# 2. Replacing JSX components with markdown equivalents
|
||||
# 3. Inlining code examples from example files
|
||||
convert_mdx_to_md() {
|
||||
local src="$1"
|
||||
local dest="$2"
|
||||
|
||||
# Use Python for more robust processing
|
||||
python3 - "$src" "$dest" "$EXAMPLES_DIR" <<'PYTHON'
|
||||
import sys
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
src_file = Path(sys.argv[1])
|
||||
dest_file = Path(sys.argv[2])
|
||||
examples_dir = Path(sys.argv[3])
|
||||
|
||||
content = src_file.read_text()
|
||||
original_content = content # Keep original for import searches
|
||||
|
||||
# Remove frontmatter
|
||||
content = re.sub(r'^---\n.*?\n---\n', '', content, flags=re.DOTALL)
|
||||
|
||||
# Remove import statements
|
||||
content = re.sub(r'^import .*?;?\n', '', content, flags=re.MULTILINE)
|
||||
|
||||
# Extract code example inlining: <CodeSnippet code={varName} section="..." language="..." />
|
||||
# Replace with actual code content from examples directory
|
||||
def inline_code_snippet(match):
|
||||
var_name = match.group(1)
|
||||
section = match.group(2)
|
||||
language = match.group(3)
|
||||
|
||||
# Find the import that loaded this variable - search in original content
|
||||
import_match = re.search(rf"import {var_name} from '!!raw-loader!@site/(.+?)';", original_content)
|
||||
if not import_match:
|
||||
return f"```{language}\n# Could not find import for: {var_name}\n```"
|
||||
|
||||
# Load the example file
|
||||
# The import path is like "examples/api/quickstart.py", but examples_dir already points to examples/
|
||||
example_rel_path = import_match.group(1)
|
||||
# Strip "examples/" prefix if present since examples_dir already includes it
|
||||
if example_rel_path.startswith("examples/"):
|
||||
example_rel_path = example_rel_path[len("examples/"):]
|
||||
example_path = examples_dir / example_rel_path
|
||||
|
||||
if not example_path.exists():
|
||||
return f"```{language}\n# Example file not found: {example_path}\n```"
|
||||
|
||||
example_content = example_path.read_text()
|
||||
|
||||
# Extract section if specified - examples use comment markers like # [docs:section] or // [docs:section]
|
||||
if section:
|
||||
# Try various comment formats: #, //, etc.
|
||||
# Pattern: (comment) [docs:section] ... (comment) [/docs:section]
|
||||
section_pattern = rf"(?:^|\n)(?:#|//)\s*\[docs:{re.escape(section)}\]\n(.*?)\n(?:#|//)\s*\[/docs:{re.escape(section)}\]"
|
||||
section_match = re.search(section_pattern, example_content, re.DOTALL | re.MULTILINE)
|
||||
|
||||
if not section_match:
|
||||
# Try alternative # section-start / # section-end format
|
||||
section_pattern = rf"(?:^|\n)#\s*{re.escape(section)}-start\n(.*?)\n#\s*{re.escape(section)}-end"
|
||||
section_match = re.search(section_pattern, example_content, re.DOTALL | re.MULTILINE)
|
||||
|
||||
if section_match:
|
||||
example_content = section_match.group(1).strip()
|
||||
else:
|
||||
return f"```{language}\n# Section '{section}' not found in {example_rel_path}\n```"
|
||||
|
||||
return f"```{language}\n{example_content}\n```"
|
||||
|
||||
content = re.sub(
|
||||
r'<CodeSnippet code=\{(\w+)\} section="([^"]+)" language="([^"]+)" />',
|
||||
inline_code_snippet,
|
||||
content
|
||||
)
|
||||
|
||||
# Convert <Tabs> to markdown sections
|
||||
# Replace <Tabs> ... </Tabs> with markdown headers
|
||||
content = re.sub(r'<Tabs>\s*', '', content)
|
||||
content = re.sub(r'</Tabs>\s*', '', content)
|
||||
|
||||
# Convert <TabItem value="x" label="Y"> to ### Y
|
||||
content = re.sub(r'<TabItem value="[^"]*" label="([^"]+)">', r'### \1\n', content)
|
||||
content = re.sub(r'</TabItem>', '', content)
|
||||
|
||||
# Convert :::tip, :::warning, :::note to markdown blockquotes
|
||||
content = re.sub(r':::tip (.+?)\n', r'> **💡 \1**\n> \n', content)
|
||||
content = re.sub(r':::warning (.+?)\n', r'> **⚠️ \1**\n> \n', content)
|
||||
content = re.sub(r':::note (.+?)\n', r'> **📝 \1**\n> \n', content)
|
||||
content = re.sub(r':::\s*\n', '', content)
|
||||
|
||||
# Clean up extra blank lines
|
||||
content = re.sub(r'\n{3,}', '\n\n', content)
|
||||
|
||||
dest_file.write_text(content)
|
||||
PYTHON
|
||||
}
|
||||
|
||||
# Find and process all markdown files
|
||||
print_info "Processing documentation files..."
|
||||
find "$DOCS_DIR" -type f \( -name "*.md" -o -name "*.mdx" \) | while read -r file; do
|
||||
process_file "$file"
|
||||
done
|
||||
|
||||
# Generate SKILL.md
|
||||
print_info "Generating SKILL.md..."
|
||||
cat > "$SKILL_DIR/SKILL.md" <<'EOF'
|
||||
---
|
||||
name: hindsight-docs
|
||||
description: Complete Hindsight documentation for AI agents. Use this to learn about Hindsight architecture, APIs, configuration, and best practices.
|
||||
---
|
||||
|
||||
# Hindsight Documentation Skill
|
||||
|
||||
Complete technical documentation for Hindsight - a biomimetic memory system for AI agents.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when you need to:
|
||||
- Understand Hindsight architecture and core concepts
|
||||
- Learn about retain/recall/reflect operations
|
||||
- Configure memory banks and dispositions
|
||||
- Set up the Hindsight API server (Docker, Kubernetes, pip)
|
||||
- Integrate with Python/Node.js/Rust SDKs
|
||||
- Understand retrieval strategies (semantic, BM25, graph, temporal)
|
||||
- Debug issues or optimize performance
|
||||
- Review API endpoints and parameters
|
||||
- Find cookbook examples and recipes
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
All documentation is in `references/` organized by category:
|
||||
|
||||
```
|
||||
references/
|
||||
├── developer/
|
||||
│ ├── api/ # Core operations: retain, recall, reflect, memory banks
|
||||
│ └── *.md # Architecture, configuration, deployment, performance
|
||||
├── sdks/
|
||||
│ ├── *.md # Python, Node.js, CLI, embedded
|
||||
│ └── integrations/ # LiteLLM, AI SDK, OpenClaw, MCP, skills
|
||||
└── cookbook/
|
||||
├── recipes/ # Usage patterns and examples
|
||||
└── applications/ # Full application demos
|
||||
```
|
||||
|
||||
## How to Find Documentation
|
||||
|
||||
### 1. Find Files by Pattern (use Glob tool)
|
||||
|
||||
```bash
|
||||
# Core API operations
|
||||
references/developer/api/*.md
|
||||
|
||||
# SDK documentation
|
||||
references/sdks/*.md
|
||||
references/sdks/integrations/*.md
|
||||
|
||||
# Cookbook examples
|
||||
references/cookbook/recipes/*.md
|
||||
references/cookbook/applications/*.md
|
||||
|
||||
# Find specific topics
|
||||
references/**/configuration.md
|
||||
references/**/*python*.md
|
||||
references/**/*deployment*.md
|
||||
```
|
||||
|
||||
### 2. Search Content (use Grep tool)
|
||||
|
||||
```bash
|
||||
# Search for concepts
|
||||
pattern: "disposition" # Memory bank configuration
|
||||
pattern: "graph retrieval" # Graph-based search
|
||||
pattern: "helm install" # Kubernetes deployment
|
||||
pattern: "document_id" # Document management
|
||||
pattern: "HINDSIGHT_API_" # Environment variables
|
||||
|
||||
# Search in specific areas
|
||||
path: references/developer/api/
|
||||
pattern: "POST /v1" # Find API endpoints
|
||||
|
||||
path: references/cookbook/
|
||||
pattern: "def |async def " # Find Python examples
|
||||
```
|
||||
|
||||
### 3. Read Full Documentation (use Read tool)
|
||||
|
||||
```
|
||||
references/developer/api/retain.md
|
||||
references/sdks/python.md
|
||||
references/cookbook/recipes/per-user-memory.md
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Memory Banks**: Isolated memory stores (one per user/agent)
|
||||
- **Retain**: Store memories (auto-extracts facts/entities/relationships)
|
||||
- **Recall**: Retrieve memories (4 parallel strategies: semantic, BM25, graph, temporal)
|
||||
- **Reflect**: Disposition-aware reasoning using memories
|
||||
- **document_id**: Groups messages in a conversation (upsert on same ID)
|
||||
- **Dispositions**: Skepticism, literalism, empathy traits (1-5) affecting reflect
|
||||
- **Mental Models**: Consolidated knowledge synthesized from facts
|
||||
|
||||
## Notes
|
||||
|
||||
- Code examples are inlined from working examples
|
||||
- Configuration uses `HINDSIGHT_API_*` environment variables
|
||||
- Database migrations run automatically on startup
|
||||
- Multi-bank queries require client-side orchestration
|
||||
- Use `document_id` for conversation evolution (same ID = upsert)
|
||||
|
||||
---
|
||||
|
||||
**Auto-generated** from `hindsight-docs/docs/`. Run `./scripts/generate-docs-skill.sh` to update.
|
||||
EOF
|
||||
|
||||
print_info "✓ Generated skill at: $SKILL_DIR"
|
||||
print_info "✓ Documentation files: $(find "$REFS_DIR" -type f | wc -l | tr -d ' ')"
|
||||
print_info "✓ SKILL.md created with search guidance"
|
||||
|
||||
echo ""
|
||||
print_info "Usage:"
|
||||
echo " - Agents can use Glob to find files: references/developer/api/*.md"
|
||||
echo " - Agents can use Grep to search content: pattern='disposition'"
|
||||
echo " - Agents can use Read to view full docs"
|
||||
@@ -120,6 +120,11 @@ else
|
||||
print_warn " To update ${MAJOR_MINOR} docs, use patch releases (e.g., ${MAJOR_MINOR}.1)"
|
||||
fi
|
||||
|
||||
# Generate documentation skill for AI agents
|
||||
echo ""
|
||||
print_info "Generating documentation skill for AI agents..."
|
||||
"$SCRIPT_DIR/generate-docs-skill.sh"
|
||||
|
||||
echo ""
|
||||
print_info "Next steps:"
|
||||
echo " 1. Review changes: git diff $DOCS_DIR"
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
name: hindsight-docs
|
||||
description: Complete Hindsight documentation for AI agents. Use this to learn about Hindsight architecture, APIs, configuration, and best practices.
|
||||
---
|
||||
|
||||
# Hindsight Documentation Skill
|
||||
|
||||
Complete technical documentation for Hindsight - a biomimetic memory system for AI agents.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
Use this skill when you need to:
|
||||
- Understand Hindsight architecture and core concepts
|
||||
- Learn about retain/recall/reflect operations
|
||||
- Configure memory banks and dispositions
|
||||
- Set up the Hindsight API server (Docker, Kubernetes, pip)
|
||||
- Integrate with Python/Node.js/Rust SDKs
|
||||
- Understand retrieval strategies (semantic, BM25, graph, temporal)
|
||||
- Debug issues or optimize performance
|
||||
- Review API endpoints and parameters
|
||||
- Find cookbook examples and recipes
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
All documentation is in `references/` organized by category:
|
||||
|
||||
```
|
||||
references/
|
||||
├── developer/
|
||||
│ ├── api/ # Core operations: retain, recall, reflect, memory banks
|
||||
│ └── *.md # Architecture, configuration, deployment, performance
|
||||
├── sdks/
|
||||
│ ├── *.md # Python, Node.js, CLI, embedded
|
||||
│ └── integrations/ # LiteLLM, AI SDK, OpenClaw, MCP, skills
|
||||
└── cookbook/
|
||||
├── recipes/ # Usage patterns and examples
|
||||
└── applications/ # Full application demos
|
||||
```
|
||||
|
||||
## How to Find Documentation
|
||||
|
||||
### 1. Find Files by Pattern (use Glob tool)
|
||||
|
||||
```bash
|
||||
# Core API operations
|
||||
references/developer/api/*.md
|
||||
|
||||
# SDK documentation
|
||||
references/sdks/*.md
|
||||
references/sdks/integrations/*.md
|
||||
|
||||
# Cookbook examples
|
||||
references/cookbook/recipes/*.md
|
||||
references/cookbook/applications/*.md
|
||||
|
||||
# Find specific topics
|
||||
references/**/configuration.md
|
||||
references/**/*python*.md
|
||||
references/**/*deployment*.md
|
||||
```
|
||||
|
||||
### 2. Search Content (use Grep tool)
|
||||
|
||||
```bash
|
||||
# Search for concepts
|
||||
pattern: "disposition" # Memory bank configuration
|
||||
pattern: "graph retrieval" # Graph-based search
|
||||
pattern: "helm install" # Kubernetes deployment
|
||||
pattern: "document_id" # Document management
|
||||
pattern: "HINDSIGHT_API_" # Environment variables
|
||||
|
||||
# Search in specific areas
|
||||
path: references/developer/api/
|
||||
pattern: "POST /v1" # Find API endpoints
|
||||
|
||||
path: references/cookbook/
|
||||
pattern: "def |async def " # Find Python examples
|
||||
```
|
||||
|
||||
### 3. Read Full Documentation (use Read tool)
|
||||
|
||||
```
|
||||
references/developer/api/retain.md
|
||||
references/sdks/python.md
|
||||
references/cookbook/recipes/per-user-memory.md
|
||||
```
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **Memory Banks**: Isolated memory stores (one per user/agent)
|
||||
- **Retain**: Store memories (auto-extracts facts/entities/relationships)
|
||||
- **Recall**: Retrieve memories (4 parallel strategies: semantic, BM25, graph, temporal)
|
||||
- **Reflect**: Disposition-aware reasoning using memories
|
||||
- **document_id**: Groups messages in a conversation (upsert on same ID)
|
||||
- **Dispositions**: Skepticism, literalism, empathy traits (1-5) affecting reflect
|
||||
- **Mental Models**: Consolidated knowledge synthesized from facts
|
||||
|
||||
## Notes
|
||||
|
||||
- Code examples are inlined from working examples
|
||||
- Configuration uses `HINDSIGHT_API_*` environment variables
|
||||
- Database migrations run automatically on startup
|
||||
- Multi-bank queries require client-side orchestration
|
||||
- Use `document_id` for conversation evolution (same ID = upsert)
|
||||
|
||||
---
|
||||
|
||||
**Auto-generated** from `hindsight-docs/docs/`. Run `./scripts/generate-docs-skill.sh` to update.
|
||||
@@ -0,0 +1,120 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Chat Memory App
|
||||
|
||||
|
||||
:::info Complete Application
|
||||
This is a complete, runnable application demonstrating Hindsight integration.
|
||||
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/chat-memory)
|
||||
:::
|
||||
|
||||
|
||||
A demo chat application that uses Groq's `qwen/qwen3-32b` model with Hindsight for persistent per-user memory.
|
||||
|
||||
## Features
|
||||
|
||||
- 🧠 **Persistent Memory**: Each user gets their own memory bank that remembers conversations
|
||||
- 🚀 **Fast AI**: Powered by Groq's high-speed inference
|
||||
- 🎯 **Per-User Context**: Isolated memory per user with automatic context retrieval
|
||||
- 💬 **Real-time Chat**: Instant responses with memory-augmented context
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Start Hindsight API
|
||||
|
||||
First, start the Hindsight API server using Docker:
|
||||
|
||||
```bash
|
||||
export GROQ_API_KEY=your_groq_api_key_here
|
||||
|
||||
# Start Hindsight with Groq as the LLM provider
|
||||
docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
|
||||
-e HINDSIGHT_API_LLM_PROVIDER=groq \
|
||||
-e HINDSIGHT_API_LLM_API_KEY=$GROQ_API_KEY \
|
||||
-e HINDSIGHT_API_LLM_MODEL="openai/gpt-oss-20b" \
|
||||
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
```
|
||||
|
||||
- **API**: http://localhost:8888
|
||||
- **Control Plane UI**: http://localhost:9999
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
Copy your Groq API key to the environment file:
|
||||
|
||||
```bash
|
||||
# Update .env.local with your Groq API key
|
||||
echo "GROQ_API_KEY=your_groq_api_key_here" > .env.local
|
||||
echo "HINDSIGHT_API_URL=http://localhost:8888" >> .env.local
|
||||
```
|
||||
|
||||
If you don't have one, you can get a free Groq API key here: https://console.groq.com/home
|
||||
|
||||
### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4. Run the App
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Open http://localhost:3000 in your browser.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **User Identity**: Each browser session gets a unique user ID
|
||||
2. **Memory Bank Creation**: First message creates a personal memory bank in Hindsight
|
||||
3. **Context Retrieval**: Before responding, relevant memories are retrieved
|
||||
4. **Memory Augmented Response**: Groq generates responses with memory context
|
||||
5. **Conversation Storage**: Each conversation is stored for future context
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
User Message
|
||||
↓
|
||||
Next.js API Route (/api/chat)
|
||||
↓
|
||||
Hindsight.recall() → Get relevant memories
|
||||
↓
|
||||
Groq API → Generate response with memory context
|
||||
↓
|
||||
Hindsight.retain() → Store conversation
|
||||
↓
|
||||
Response to User
|
||||
```
|
||||
|
||||
## Memory Bank Structure
|
||||
|
||||
Each user gets their own isolated memory bank with:
|
||||
- **Name**: "Chat Memory for [userId]"
|
||||
- **Background**: Conversational AI assistant context
|
||||
- **Disposition**: Empathetic (4), Low Skepticism (2), Balanced Literalism (3)
|
||||
|
||||
## Try It Out
|
||||
|
||||
1. **First Conversation**: Tell the assistant about yourself
|
||||
- "Hi! I'm a software engineer from San Francisco. I love Python and machine learning."
|
||||
|
||||
2. **Second Conversation**: Ask what it remembers
|
||||
- "What do you know about me?"
|
||||
- "What programming languages do I like?"
|
||||
|
||||
3. **Context Building**: Continue sharing preferences
|
||||
- "I prefer VS Code over other editors"
|
||||
- "I'm working on a React project"
|
||||
|
||||
4. **Memory Verification**: Visit the Hindsight Control Plane at http://localhost:9999 to see stored memories
|
||||
|
||||
## Development
|
||||
|
||||
- **Groq Model**: Uses `qwen/qwen3-32b` for fast, high-quality responses
|
||||
- **Memory Storage**: Automatic conversation retention with context categorization
|
||||
- **Memory Retrieval**: Semantic search with 2048 token budget for relevant context
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
# Deliveryman Demo
|
||||
|
||||
|
||||
:::info Complete Application
|
||||
This is a complete, runnable application demonstrating Hindsight integration.
|
||||
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/deliveryman-demo)
|
||||
:::
|
||||
|
||||
|
||||
A delivery agent simulation that demonstrates Hindsight's long-term memory capabilities. An AI agent navigates a multi-building office complex to deliver packages, learning employee locations and optimal paths over time through mental models.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.11+
|
||||
- Node.js 18+
|
||||
- [uv](https://docs.astral.sh/uv/) (Python package manager)
|
||||
|
||||
## Setup (Fresh Environment)
|
||||
|
||||
### 1. Clone Repositories
|
||||
|
||||
```bash
|
||||
# Clone Hindsight (memory engine)
|
||||
git clone https://github.com/anthropics/hindsight.git
|
||||
|
||||
# Clone the cookbook (contains this demo)
|
||||
git clone https://github.com/anthropics/hindsight-cookbook.git
|
||||
```
|
||||
|
||||
### 2. Start Hindsight API
|
||||
|
||||
```bash
|
||||
cd hindsight
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with your LLM configuration:
|
||||
|
||||
```bash
|
||||
HINDSIGHT_API_LLM_PROVIDER=groq
|
||||
HINDSIGHT_API_LLM_API_KEY=<your-groq-api-key>
|
||||
HINDSIGHT_API_LLM_MODEL=openai/gpt-oss-120b
|
||||
HINDSIGHT_API_HOST=0.0.0.0
|
||||
HINDSIGHT_API_PORT=8888
|
||||
HINDSIGHT_API_ENABLE_OBSERVATIONS=true
|
||||
|
||||
# Retain extraction settings (improves employee/location extraction)
|
||||
HINDSIGHT_API_RETAIN_EXTRACTION_MODE=custom
|
||||
HINDSIGHT_API_RETAIN_CUSTOM_INSTRUCTIONS="Delivery agent. Remember employee locations, building layout, and optimal paths."
|
||||
|
||||
# Embedded database storage
|
||||
PG0_DATA_DIR=/tmp/hindsight-data
|
||||
```
|
||||
|
||||
Start the API:
|
||||
|
||||
```bash
|
||||
./scripts/dev/start-api.sh
|
||||
# Runs on http://localhost:8888
|
||||
```
|
||||
|
||||
### 3. Start Hindsight Control Plane (Optional)
|
||||
|
||||
The control plane provides a web UI for inspecting memory banks, facts, and mental models.
|
||||
|
||||
```bash
|
||||
cd hindsight
|
||||
./scripts/dev/start-control-plane.sh
|
||||
# Runs on a dynamic port (check terminal output)
|
||||
```
|
||||
|
||||
### 4. Start Demo Backend
|
||||
|
||||
```bash
|
||||
cd hindsight-cookbook/deliveryman-demo/backend
|
||||
|
||||
# Create virtual environment and install dependencies
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
Create `backend/.env`:
|
||||
|
||||
```bash
|
||||
OPENAI_API_KEY=<your-openai-api-key>
|
||||
GROQ_API_KEY=<your-groq-api-key>
|
||||
HINDSIGHT_API_URL=http://localhost:8888
|
||||
LLM_MODEL=openai/gpt-4o
|
||||
```
|
||||
|
||||
Start the backend:
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
# Or manually:
|
||||
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --ws wsproto --reload
|
||||
```
|
||||
|
||||
**Note:** The `--ws wsproto` flag is required for WebSocket support. Without it, connections will fail with error 1006.
|
||||
|
||||
### 5. Start Demo Frontend
|
||||
|
||||
```bash
|
||||
cd hindsight-cookbook/deliveryman-demo/frontend
|
||||
npm install
|
||||
npm run dev
|
||||
# Runs on http://localhost:5173
|
||||
```
|
||||
|
||||
### 6. Open the Demo
|
||||
|
||||
Navigate to http://localhost:5173 in your browser.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. The agent receives a delivery task (e.g., "Deliver Package #3954 to Victor Huang")
|
||||
2. It navigates a multi-building complex with floors, elevators, and sky bridges
|
||||
3. Along the way it encounters employees and learns their locations
|
||||
4. After each delivery, the conversation is sent to Hindsight via the **retain** API
|
||||
5. Hindsight extracts facts (employee locations, building layout) and builds **mental models**
|
||||
6. On subsequent deliveries, the agent queries Hindsight to recall what it learned
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser (5173) → Frontend (React + Phaser)
|
||||
↓ WebSocket
|
||||
Backend (8000) → FastAPI + Delivery Agent
|
||||
↓ HTTP
|
||||
Hindsight API (8888) → Memory Engine + PostgreSQL
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---------|----------|
|
||||
| WebSocket error 1006 | Restart backend with `--ws wsproto` flag |
|
||||
| Mental models missing employees | Check `HINDSIGHT_API_RETAIN_EXTRACTION_MODE=custom` is set |
|
||||
| Hindsight connection refused | Verify Hindsight API is running on port 8888 |
|
||||
| Frontend shows "Disconnected" | Check backend is running on port 8000 |
|
||||
@@ -0,0 +1,206 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
# Memory Approaches Comparison Demo
|
||||
|
||||
|
||||
:::info Complete Application
|
||||
This is a complete, runnable application demonstrating Hindsight integration.
|
||||
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/hindsight-litellm-demo)
|
||||
:::
|
||||
|
||||
|
||||
Interactive Streamlit app comparing three memory approaches for LLM applications:
|
||||
|
||||
1. **No Memory** - Each query is independent (baseline)
|
||||
2. **Full Conversation History** - Pass entire conversation (truncated to simulate context limits)
|
||||
3. **Hindsight Memory** - Intelligent semantic memory retrieval
|
||||
|
||||
This demo showcases how Hindsight's semantic memory outperforms traditional approaches, especially as conversations grow longer.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# 1. Set your OpenAI API key
|
||||
export OPENAI_API_KEY=your-key
|
||||
|
||||
# 2. Start Hindsight server
|
||||
docker run -d -p 8888:8888 -p 9999:9999 \
|
||||
-e HINDSIGHT_API_LLM_PROVIDER=openai \
|
||||
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
|
||||
# 3. Run the demo
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Then open http://localhost:8501 in your browser.
|
||||
|
||||
## What This Demo Shows
|
||||
|
||||
### The Problem with Traditional Approaches
|
||||
|
||||
| Approach | How it Works | Limitation |
|
||||
|----------|--------------|------------|
|
||||
| **No Memory** | Each query standalone | Forgets everything between messages |
|
||||
| **Full History** | Pass all messages to LLM | Token limits cause truncation - loses early context |
|
||||
| **Hindsight** | Semantic retrieval of relevant facts | Retrieves what's relevant regardless of when it was said |
|
||||
|
||||
### Key Insight
|
||||
|
||||
After 5-10 messages, watch the **Full Conversation History** column start losing early context due to truncation (artificially set to 4 messages to demonstrate this quickly). Meanwhile, **Hindsight Memory** can still recall facts from the beginning because it uses semantic retrieval rather than sequential history.
|
||||
|
||||
## Testing the Demo
|
||||
|
||||
1. **Introduce yourself**:
|
||||
- "Hi, I'm Sarah, a data scientist at Netflix"
|
||||
- "I prefer Python and love machine learning"
|
||||
|
||||
2. **Have several exchanges** about different topics
|
||||
|
||||
3. **Test recall**:
|
||||
- "What programming language should I use?"
|
||||
- "What do you know about me?"
|
||||
|
||||
Watch how the three columns respond differently as the conversation grows.
|
||||
|
||||
## Features
|
||||
|
||||
- **Side-by-side comparison** of all three approaches
|
||||
- **Debug panels** showing what context each approach uses
|
||||
- **Memory explorer** to search Hindsight memories directly
|
||||
- **Configurable settings** for history truncation, max memories, etc.
|
||||
- **Multi-provider support** via LiteLLM (OpenAI, Anthropic, Groq)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- Hindsight server running (Docker recommended)
|
||||
- At least one LLM API key (OpenAI recommended)
|
||||
|
||||
## Setup
|
||||
|
||||
### Using run.sh (Recommended)
|
||||
|
||||
```bash
|
||||
# Set API key
|
||||
export OPENAI_API_KEY=your-key
|
||||
|
||||
# Start Hindsight, then run:
|
||||
./run.sh
|
||||
```
|
||||
|
||||
The script will check and install dependencies automatically.
|
||||
|
||||
### Manual Setup
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
pip install streamlit litellm
|
||||
|
||||
# Install Hindsight packages
|
||||
pip install hindsight-client hindsight-litellm
|
||||
|
||||
# Run the app
|
||||
streamlit run app.py
|
||||
```
|
||||
|
||||
### Starting Hindsight Server
|
||||
|
||||
```bash
|
||||
docker run -d -p 8888:8888 -p 9999:9999 \
|
||||
-e HINDSIGHT_API_LLM_PROVIDER=openai \
|
||||
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
|
||||
# Verify it's running
|
||||
curl http://localhost:8888/health
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Sidebar Options
|
||||
|
||||
**Model Selection:**
|
||||
- Provider: OpenAI, Anthropic, Groq
|
||||
- Model: Various models per provider
|
||||
- Custom model ID support
|
||||
|
||||
**Full History Config:**
|
||||
- Max Messages to Keep (default: 4 to demonstrate truncation)
|
||||
|
||||
**Hindsight Config:**
|
||||
- API URL (default: http://localhost:8888)
|
||||
- Bank ID and Entity ID for memory isolation
|
||||
- Max Memories to retrieve
|
||||
- Recall Budget (low/mid/high)
|
||||
|
||||
**Generation Settings:**
|
||||
- Temperature
|
||||
- Max Tokens
|
||||
- System Prompt
|
||||
|
||||
## Supported Models
|
||||
|
||||
### OpenAI
|
||||
- gpt-4o, gpt-4o-mini, gpt-4-turbo, gpt-4, gpt-3.5-turbo
|
||||
|
||||
### Anthropic
|
||||
- claude-3-5-sonnet-20241022, claude-3-5-haiku-20241022
|
||||
- claude-3-opus-20240229, claude-3-sonnet-20240229
|
||||
|
||||
### Groq
|
||||
- groq/llama-3.1-70b-versatile, groq/llama-3.1-8b-instant
|
||||
- groq/mixtral-8x7b-32768
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
# Required
|
||||
export OPENAI_API_KEY=sk-...
|
||||
|
||||
# Optional (for other providers)
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
export GROQ_API_KEY=gsk_...
|
||||
|
||||
# Optional
|
||||
export HINDSIGHT_URL=http://localhost:8888
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Hindsight server not responding
|
||||
|
||||
```bash
|
||||
# Check if running
|
||||
curl http://localhost:8888/health
|
||||
|
||||
# Start with Docker
|
||||
docker run -d -p 8888:8888 -p 9999:9999 \
|
||||
-e HINDSIGHT_API_LLM_PROVIDER=openai \
|
||||
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
```
|
||||
|
||||
### hindsight-litellm not installed
|
||||
|
||||
```bash
|
||||
pip install hindsight-litellm
|
||||
```
|
||||
|
||||
### API key errors
|
||||
|
||||
Make sure the appropriate API key is set:
|
||||
```bash
|
||||
export OPENAI_API_KEY=your-key
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- [Hindsight](https://github.com/vectorize-io/hindsight) - Memory infrastructure for AI applications
|
||||
- [hindsight-litellm](https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/litellm) - LiteLLM integration package
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
# Tool Learning Demo
|
||||
|
||||
|
||||
:::info Complete Application
|
||||
This is a complete, runnable application demonstrating Hindsight integration.
|
||||
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/hindsight-tool-learning-demo)
|
||||
:::
|
||||
|
||||
|
||||
An interactive Streamlit demo showing how Hindsight helps LLMs learn which tool to use when tool names are ambiguous.
|
||||
|
||||
## The Problem
|
||||
|
||||
When building AI agents with tool/function calling, tool names and descriptions aren't always clear. An LLM might randomly select between similarly-named tools, leading to incorrect behavior.
|
||||
|
||||
## The Scenario
|
||||
|
||||
This demo simulates a **customer service routing system** with two channels:
|
||||
|
||||
| Tool | Description (What the LLM sees) | Actual Purpose (Hidden) |
|
||||
|------|--------------------------------|------------------------|
|
||||
| `route_to_channel_alpha` | "Routes to channel Alpha for appropriate request types" | Financial issues (refunds, billing, payments) |
|
||||
| `route_to_channel_omega` | "Routes to channel Omega for appropriate request types" | Technical issues (bugs, features, errors) |
|
||||
|
||||
The descriptions are **intentionally vague**! Without prior knowledge, the LLM must guess which channel handles what.
|
||||
|
||||
## The Solution: Learning with Hindsight
|
||||
|
||||
With Hindsight memory:
|
||||
1. **Store routing feedback** about which channel handles which request type
|
||||
2. **Retrieve learned knowledge** when making routing decisions
|
||||
3. **Consistently route correctly** based on past experience
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **Hindsight Server** running (Docker):
|
||||
```bash
|
||||
docker run -d -p 8888:8888 -p 9999:9999 \
|
||||
-e HINDSIGHT_API_LLM_PROVIDER=openai \
|
||||
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
|
||||
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
```
|
||||
|
||||
2. **OpenAI API Key**:
|
||||
```bash
|
||||
export OPENAI_API_KEY=your-key-here
|
||||
```
|
||||
|
||||
### Run the Demo
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
streamlit run app.py
|
||||
```
|
||||
|
||||
## How to Use the Demo
|
||||
|
||||
### Step 1: Test Without Memory (Baseline)
|
||||
|
||||
1. Select a **Financial Request** (e.g., "I need a refund...")
|
||||
2. Click **Route Request**
|
||||
3. Observe: The "Without Hindsight" column may route incorrectly
|
||||
|
||||
### Step 2: Route First Customer and Learn
|
||||
|
||||
1. Route a customer → Both LLMs route simultaneously
|
||||
2. Feedback is automatically stored to Hindsight
|
||||
3. Wait ~5 seconds for Hindsight to index the memory
|
||||
|
||||
### Step 3: Test With Memory
|
||||
|
||||
1. Select another request (financial or technical)
|
||||
2. Click **Route Request**
|
||||
3. Observe: The "With Hindsight" column should now route correctly!
|
||||
|
||||
### Step 4: View Statistics
|
||||
|
||||
- See accuracy comparison between "Without Memory" vs "With Hindsight"
|
||||
- Review test history to see the improvement over time
|
||||
|
||||
## Demo Features
|
||||
|
||||
- **Side-by-side comparison**: See routing results with and without memory
|
||||
- **Pre-defined test requests**: Financial and technical scenarios
|
||||
- **Custom requests**: Enter your own customer requests
|
||||
- **Memory Explorer**: Query stored routing knowledge directly
|
||||
- **Live statistics**: Track accuracy improvement
|
||||
|
||||
## Key Insight
|
||||
|
||||
> Even when tool names and descriptions don't reveal their purpose, Hindsight allows the LLM to **learn from experience** which tool to use for which type of request.
|
||||
|
||||
This is especially valuable for:
|
||||
- Enterprise systems with legacy tool names
|
||||
- Multi-tenant systems where tools have generic names
|
||||
- Agents that need to learn organization-specific workflows
|
||||
|
||||
## Configuration
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Model | gpt-4o-mini | LLM model for routing decisions |
|
||||
| Temperature (No Memory) | 0.7 | Randomness for baseline tests |
|
||||
| Hindsight API URL | http://localhost:8888 | Hindsight server URL |
|
||||
|
||||
## Files
|
||||
|
||||
- `app.py` - Main Streamlit application
|
||||
- `requirements.txt` - Python dependencies
|
||||
- `run.sh` - Launch script with dependency checking
|
||||
- `README.md` - This file
|
||||
@@ -0,0 +1,315 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
---
|
||||
|
||||
# OpenAI Agent + Hindsight Memory Integration
|
||||
|
||||
|
||||
:::info Complete Application
|
||||
This is a complete, runnable application demonstrating Hindsight integration.
|
||||
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/openai-fitness-coach)
|
||||
:::
|
||||
|
||||
|
||||
A fitness coach example demonstrating how to use **OpenAI Agents** with **Hindsight as a memory backend**.
|
||||
|
||||
## What This Demonstrates
|
||||
|
||||
This example showcases:
|
||||
|
||||
- **OpenAI Assistants** handling conversation logic
|
||||
- **Hindsight** providing sophisticated memory storage & retrieval
|
||||
- **Function calling** to bridge them together
|
||||
- **Streaming responses** for real-time interaction (enabled by default)
|
||||
- **Bidirectional memory** - both user data AND coach observations stored
|
||||
- **System-level post-processing** - automatic opinion storage for reliability
|
||||
- **Temporal-semantic memory** queries via function tools
|
||||
- **Enhanced preference learning** - coach learns and respects user likes/dislikes
|
||||
- **Real-world integration pattern** for adding memory to AI agents
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
User: "I ran 5K today, don't like tempo runs"
|
||||
|
|
||||
OpenAI Assistant
|
||||
|
|
||||
Function Call: store_memory(workout + preference)
|
||||
|
|
||||
Hindsight API (stores as world/agent)
|
||||
|
|
||||
OpenAI Assistant: "What should I focus on?"
|
||||
|
|
||||
Function Call: retrieve_memories("workouts and preferences")
|
||||
|
|
||||
Hindsight API (returns workouts + preferences)
|
||||
|
|
||||
OpenAI Assistant (analyzes, gives advice)
|
||||
|
|
||||
Function Call: store_memory(advice as opinion)
|
||||
|
|
||||
Hindsight API (stores coach's observation)
|
||||
|
|
||||
Personalized Answer
|
||||
```
|
||||
|
||||
## Key Difference from Standard Demo
|
||||
|
||||
| Component | Standard Demo | OpenAI Integration |
|
||||
|-----------|---------------|-------------------|
|
||||
| **Conversation** | Hindsight `/think` endpoint | OpenAI Assistant API |
|
||||
| **Memory** | Hindsight (built-in) | Hindsight (via function calling) |
|
||||
| **LLM** | Configured in Hindsight | OpenAI GPT-4 |
|
||||
| **Opinion Formation** | Automatic in `/think` | Explicit via `store_memory(type="opinion")` |
|
||||
| **Best For** | Hindsight-native apps | Integrating memory into existing OpenAI agents |
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Prerequisites
|
||||
|
||||
1. **OpenAI API Key**
|
||||
```bash
|
||||
export OPENAI_API_KEY=your_openai_api_key
|
||||
```
|
||||
|
||||
2. **Hindsight API running**
|
||||
```bash
|
||||
# Follow Hindsight setup instructions to start the API
|
||||
# Default: http://localhost:8888
|
||||
```
|
||||
|
||||
3. **Install dependencies**
|
||||
```bash
|
||||
pip install openai requests
|
||||
```
|
||||
|
||||
### Run the Conversational Demo
|
||||
|
||||
```bash
|
||||
cd openai-fitness-coach
|
||||
export OPENAI_API_KEY=your_key_here
|
||||
python demo_conversational.py
|
||||
```
|
||||
|
||||
The demo showcases:
|
||||
1. **Natural language workout logging** - Tell the coach what you did conversationally
|
||||
2. **Preference learning** - Express likes/dislikes and watch the coach adapt
|
||||
3. **Goal tracking** - Set goals, track progress, achieve milestones
|
||||
4. **Bidirectional memory** - Both your activities AND coach's advice are stored
|
||||
5. **Streaming responses** - See responses appear in real-time
|
||||
6. **7 interactive phases** - From goal setting to achievement recognition
|
||||
|
||||
The demo uses a separate agent (`fitness-coach-demo`) to avoid mixing with real data.
|
||||
|
||||
## Usage
|
||||
|
||||
### Chat with Your Coach
|
||||
|
||||
**Interactive mode:**
|
||||
```bash
|
||||
python openai_coach.py
|
||||
```
|
||||
|
||||
**Single question:**
|
||||
```bash
|
||||
python openai_coach.py "What did I do for training this week?"
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Memory Tools (`memory_tools.py`)
|
||||
|
||||
Defines function tools that the OpenAI Agent can call:
|
||||
|
||||
```python
|
||||
retrieve_memories(query, fact_types, top_k)
|
||||
search_workouts(after_date, before_date, workout_type)
|
||||
get_nutrition_summary(after_date, before_date)
|
||||
get_user_goals()
|
||||
get_coach_opinions(about)
|
||||
```
|
||||
|
||||
Each function makes API calls to Hindsight to fetch relevant memories.
|
||||
|
||||
### 2. OpenAI Agent (`openai_coach.py`)
|
||||
|
||||
Creates an OpenAI Assistant with:
|
||||
- Fitness coaching instructions
|
||||
- Access to memory function tools
|
||||
- Conversation management
|
||||
|
||||
When you ask a question:
|
||||
1. User message is sent to OpenAI Assistant
|
||||
2. Assistant decides which memory functions to call
|
||||
3. Functions fetch data from Hindsight
|
||||
4. Assistant generates response using retrieved context
|
||||
|
||||
### 3. Function Calling Flow
|
||||
|
||||
```python
|
||||
# User asks: "What did I run this week?"
|
||||
|
||||
# OpenAI Assistant decides to call:
|
||||
search_workouts(
|
||||
after_date="2024-11-18",
|
||||
workout_type="running"
|
||||
)
|
||||
|
||||
# Function retrieves from Hindsight:
|
||||
{
|
||||
"results": [
|
||||
{"text": "User completed 45-minute cardio workout: running..."},
|
||||
{"text": "User completed 60-minute cardio workout: running..."}
|
||||
]
|
||||
}
|
||||
|
||||
# OpenAI Assistant generates response:
|
||||
"This week you've done two runs: a 45-minute run on Monday
|
||||
and a longer 60-minute run on Wednesday. Great consistency!"
|
||||
```
|
||||
|
||||
## Example Questions
|
||||
|
||||
Try asking:
|
||||
|
||||
```bash
|
||||
python openai_coach.py "What does my training look like this week?"
|
||||
python openai_coach.py "Based on my workouts, should I rest today?"
|
||||
python openai_coach.py "How is my nutrition supporting my goals?"
|
||||
python openai_coach.py "What's my progress toward my goal?"
|
||||
python openai_coach.py "Compare my training this month to last month"
|
||||
```
|
||||
|
||||
The agent will automatically:
|
||||
1. Identify what memories it needs
|
||||
2. Call the appropriate function tools
|
||||
3. Retrieve data from Hindsight
|
||||
4. Generate a personalized response
|
||||
|
||||
## Memory Types Retrieved
|
||||
|
||||
The OpenAI Agent can retrieve different memory types from Hindsight:
|
||||
|
||||
- **World Facts** (`fact_type: "world"`): Workouts, meals, activities
|
||||
- **Agent Facts** (`fact_type: "agent"`): Goals, intentions
|
||||
- **Opinions** (`fact_type: "opinion"`): Coach's observations about patterns
|
||||
|
||||
## Customization
|
||||
|
||||
### Add New Function Tools
|
||||
|
||||
Edit `memory_tools.py` to add new capabilities:
|
||||
|
||||
```python
|
||||
def get_weekly_summary(week_offset: int = 0):
|
||||
"""Get a summary of a specific week."""
|
||||
# Implementation
|
||||
pass
|
||||
|
||||
# Add to MEMORY_TOOLS list
|
||||
MEMORY_TOOLS.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weekly_summary",
|
||||
"description": "Get training summary for a specific week",
|
||||
# ... parameters
|
||||
}
|
||||
})
|
||||
|
||||
# Add to FUNCTION_MAP
|
||||
FUNCTION_MAP["get_weekly_summary"] = get_weekly_summary
|
||||
```
|
||||
|
||||
### Modify Assistant Instructions
|
||||
|
||||
Edit `openai_coach.py` to change the coach's personality or behavior:
|
||||
|
||||
```python
|
||||
assistant = client.beta.assistants.create(
|
||||
name="Your Custom Coach",
|
||||
instructions="Your custom instructions here...",
|
||||
model="gpt-4o-mini",
|
||||
tools=MEMORY_TOOLS
|
||||
)
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
This pattern works for any application that needs memory:
|
||||
|
||||
1. **Customer Support Agents** - Remember past conversations and issues
|
||||
2. **Personal Assistants** - Remember preferences, schedules, past decisions
|
||||
3. **Educational Tutors** - Track learning progress over time
|
||||
4. **Health Coaches** - Monitor habits, progress, goals (like this example)
|
||||
5. **Sales Assistants** - Remember customer interactions and preferences
|
||||
|
||||
## Integration Pattern
|
||||
|
||||
**To add Hindsight memory to your own OpenAI Agent:**
|
||||
|
||||
1. Define function tools that call Hindsight API
|
||||
2. Register them with your OpenAI Assistant
|
||||
3. Implement function handlers to execute Hindsight queries
|
||||
4. Let OpenAI Assistant decide when to retrieve memories
|
||||
|
||||
The key benefit: **Separation of concerns**
|
||||
- OpenAI = Conversation logic
|
||||
- Hindsight = Memory storage, retrieval, temporal queries, entity linking
|
||||
|
||||
## When to Use This vs. Standard Hindsight
|
||||
|
||||
**Use OpenAI + Hindsight (this example) when:**
|
||||
- You want OpenAI's conversation capabilities
|
||||
- You're already using OpenAI Agents
|
||||
- You want explicit control over when to retrieve memories
|
||||
- You want to combine Hindsight with other OpenAI features
|
||||
|
||||
**Use Hindsight directly when:**
|
||||
- You want a complete memory-first solution
|
||||
- You want automatic memory retrieval and opinion formation
|
||||
- You want to use different LLM providers (not just OpenAI)
|
||||
- You want the `/think` endpoint's integrated approach
|
||||
|
||||
## Learning Points
|
||||
|
||||
After running this demo, you'll understand:
|
||||
|
||||
1. How to add sophisticated memory to any OpenAI Agent
|
||||
2. How function calling bridges LLMs and memory systems
|
||||
3. How temporal-semantic queries work via function tools
|
||||
4. Real-world pattern for LLM + memory integration
|
||||
|
||||
## Core Files
|
||||
|
||||
- `demo_conversational.py` - Conversational demo showcasing preference learning and goal tracking
|
||||
- `openai_coach.py` - OpenAI Assistant wrapper with streaming and memory integration
|
||||
- `memory_tools.py` - Function calling tools that bridge to Hindsight API
|
||||
- `.openai_assistant_id` - Saved assistant ID (auto-generated, gitignored)
|
||||
|
||||
## Common Issues
|
||||
|
||||
**"OPENAI_API_KEY not set"**
|
||||
```bash
|
||||
export OPENAI_API_KEY=your_api_key_here
|
||||
```
|
||||
|
||||
**"Agent not found"**
|
||||
- Make sure the Hindsight fitness-coach agent exists
|
||||
|
||||
**"Connection refused"**
|
||||
- Make sure Hindsight API is running on localhost:8888
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Run the demo to see it in action
|
||||
2. Try chatting with the coach: `python openai_coach.py`
|
||||
3. Log your own workouts and meals
|
||||
4. Experiment with different questions
|
||||
5. Add custom function tools for your use case
|
||||
|
||||
---
|
||||
|
||||
**Built with:**
|
||||
- OpenAI Assistants API
|
||||
- Hindsight (temporal-semantic memory)
|
||||
- Function calling for integration
|
||||
@@ -0,0 +1,371 @@
|
||||
---
|
||||
sidebar_position: 6
|
||||
---
|
||||
|
||||
# Sanity CMS Blog Memory
|
||||
|
||||
|
||||
:::info Complete Application
|
||||
This is a complete, runnable application demonstrating Hindsight integration.
|
||||
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/sanity-blog-memory)
|
||||
:::
|
||||
|
||||
|
||||
A Hindsight cookbook recipe demonstrating how to sync blog posts from **Sanity CMS** to Hindsight agent memory, enabling semantic search, temporal queries, and AI-powered content insights.
|
||||
|
||||
## Features
|
||||
|
||||
- **Blog Post Sync**: Automatically sync all blog posts from Sanity to Hindsight
|
||||
- **Document-based Upsert**: Idempotent syncing with `document_id` - re-running sync updates existing content
|
||||
- **Semantic Search**: Find related content using natural language queries
|
||||
- **Temporal Queries**: Ask "What did I write in January 2025?"
|
||||
- **Reflect for Insights**: Generate AI-powered analysis of your blog content
|
||||
- **Related Content Discovery**: Power "Related Posts" features with semantic similarity
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ │ │ │ │ │
|
||||
│ Sanity CMS │───────▶│ Sync Script │───────▶│ Hindsight │
|
||||
│ (Content) │ GROQ │ (TypeScript) │ HTTP │ (Memory) │
|
||||
│ │ │ │ │ │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ │
|
||||
│ Your App │
|
||||
│ - Recall │
|
||||
│ - Reflect │
|
||||
│ │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Start Hindsight
|
||||
|
||||
Choose your preferred LLM provider:
|
||||
|
||||
**Option A: Using Docker Compose (Recommended)**
|
||||
|
||||
```bash
|
||||
# Set your API key
|
||||
export OPENAI_API_KEY=sk-...
|
||||
# OR
|
||||
export GOOGLE_API_KEY=... # Gemini (free tier available)
|
||||
# OR
|
||||
export GROQ_API_KEY=... # Groq (free tier available)
|
||||
|
||||
# Start Hindsight
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
**Option B: Using Docker directly**
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
|
||||
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=gpt-4o-mini \
|
||||
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
```
|
||||
|
||||
- **API**: http://localhost:8888
|
||||
- **Control Plane UI**: http://localhost:9999
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
```bash
|
||||
# Copy example config
|
||||
cp .env.example .env
|
||||
|
||||
# Edit with your values
|
||||
nano .env
|
||||
```
|
||||
|
||||
Required settings:
|
||||
```bash
|
||||
# Hindsight
|
||||
HINDSIGHT_API_URL=http://localhost:8888
|
||||
HINDSIGHT_BANK_ID=blog-memory
|
||||
|
||||
# Sanity CMS
|
||||
SANITY_PROJECT_ID=your-project-id
|
||||
SANITY_DATASET=production
|
||||
```
|
||||
|
||||
### 3. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 4. Sync Your Blog Posts
|
||||
|
||||
```bash
|
||||
npm run sync
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
=======================================
|
||||
Sanity -> Hindsight Blog Sync
|
||||
=======================================
|
||||
|
||||
Setting up memory bank...
|
||||
Memory bank "blog-memory" ready
|
||||
|
||||
Fetching posts from Sanity CMS...
|
||||
Found 10 posts to sync
|
||||
|
||||
Syncing posts to Hindsight...
|
||||
[1/10] "Why I Chose Qwik"... done
|
||||
[2/10] "Building AI Agents"... done
|
||||
...
|
||||
|
||||
=======================================
|
||||
Sync Complete
|
||||
=======================================
|
||||
Synced: 10 posts
|
||||
```
|
||||
|
||||
### 5. Query Your Content
|
||||
|
||||
```bash
|
||||
npm run query
|
||||
```
|
||||
|
||||
## Query Examples
|
||||
|
||||
### Semantic Search
|
||||
|
||||
Find related content using natural language:
|
||||
|
||||
```typescript
|
||||
import { recallMemory } from './hindsight-client.js';
|
||||
|
||||
// Find posts about AI agents
|
||||
const result = await recallMemory('AI agents and automation', {
|
||||
budget: 'mid',
|
||||
maxTokens: 2048,
|
||||
});
|
||||
|
||||
console.log(`Found ${result.results.length} relevant posts`);
|
||||
```
|
||||
|
||||
### Temporal Queries
|
||||
|
||||
Ask about content from specific time periods:
|
||||
|
||||
```typescript
|
||||
// Posts from January 2025
|
||||
const result = await recallMemory('What did I write about in January 2025?', {
|
||||
queryTimestamp: '2025-01-31T23:59:59Z',
|
||||
});
|
||||
```
|
||||
|
||||
### Reflect for Insights
|
||||
|
||||
Generate AI-powered analysis of your content:
|
||||
|
||||
```typescript
|
||||
import { reflectOnMemory } from './hindsight-client.js';
|
||||
|
||||
// Analyze blog themes
|
||||
const insights = await reflectOnMemory(
|
||||
'What are the main themes of my blog? What topics do I write about most?',
|
||||
{ budget: 'high' }
|
||||
);
|
||||
|
||||
console.log(insights.text);
|
||||
```
|
||||
|
||||
### Related Content Discovery
|
||||
|
||||
Power your "Related Posts" feature:
|
||||
|
||||
```typescript
|
||||
// Find posts similar to a specific article
|
||||
const related = await recallMemory(
|
||||
'Find posts related to "Why I Chose Qwik for My Personal Website"',
|
||||
{ budget: 'mid' }
|
||||
);
|
||||
```
|
||||
|
||||
## Memory Structure
|
||||
|
||||
Each blog post is stored with rich metadata for optimal recall:
|
||||
|
||||
```
|
||||
# Blog Post: {title}
|
||||
|
||||
**Published:** {date}
|
||||
**URL:** {base_url}/blog/{slug}
|
||||
**Tags:** {tags}
|
||||
**Reading Time:** {reading_time}
|
||||
|
||||
## Description
|
||||
{description}
|
||||
|
||||
## Content
|
||||
{full_content}
|
||||
```
|
||||
|
||||
Key features:
|
||||
- **document_id**: `post:{slug}` - Enables upsert on re-sync
|
||||
- **context**: `blog-post` - Categorizes the memory type
|
||||
- **timestamp**: Post publication date - Enables temporal queries
|
||||
|
||||
## Use Cases
|
||||
|
||||
### 1. AI-Powered Blog Search
|
||||
|
||||
Replace keyword search with semantic understanding:
|
||||
|
||||
```typescript
|
||||
// Old: keyword matching
|
||||
const results = posts.filter(p => p.title.includes('React'));
|
||||
|
||||
// New: semantic understanding
|
||||
const result = await recallMemory('frontend framework tutorials');
|
||||
```
|
||||
|
||||
### 2. Content Recommendation Engine
|
||||
|
||||
Generate personalized recommendations:
|
||||
|
||||
```typescript
|
||||
const recommendations = await reflectOnMemory(
|
||||
'Based on a reader interested in "AI automation", recommend related posts'
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Writing Assistant
|
||||
|
||||
Get topic suggestions based on your existing content:
|
||||
|
||||
```typescript
|
||||
const suggestions = await reflectOnMemory(
|
||||
'What topics should I write about next? What gaps exist in my content?'
|
||||
);
|
||||
```
|
||||
|
||||
### 4. Content Analytics
|
||||
|
||||
Analyze your blog's evolution:
|
||||
|
||||
```typescript
|
||||
const analysis = await reflectOnMemory(
|
||||
'How have my writing topics evolved over the past year?'
|
||||
);
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_URL` | Hindsight API endpoint | `http://localhost:8888` |
|
||||
| `HINDSIGHT_BANK_ID` | Memory bank identifier | `blog-memory` |
|
||||
| `SANITY_PROJECT_ID` | Your Sanity project ID | (required) |
|
||||
| `SANITY_DATASET` | Sanity dataset name | `production` |
|
||||
| `SANITY_API_TOKEN` | Sanity API token (for private datasets) | (none) |
|
||||
| `SANITY_API_VERSION` | Sanity API version | `2024-01-09` |
|
||||
| `SITE_URL` | Your blog's base URL | `https://example.com` |
|
||||
|
||||
### Memory Bank Disposition
|
||||
|
||||
The memory bank is configured with disposition traits optimized for blog content:
|
||||
|
||||
```typescript
|
||||
{
|
||||
skepticism: 2, // Trusting - blog content is authoritative
|
||||
literalism: 4, // Literal - exact content matters
|
||||
empathy: 3, // Balanced
|
||||
}
|
||||
```
|
||||
|
||||
## Extending for Other CMS Platforms
|
||||
|
||||
This pattern can be adapted for any CMS. The key components:
|
||||
|
||||
### 1. CMS Client
|
||||
|
||||
Replace `sanity-client.ts` with your CMS:
|
||||
|
||||
```typescript
|
||||
// contentful-client.ts
|
||||
import { createClient } from 'contentful';
|
||||
|
||||
export async function getAllPosts(): Promise<BlogPost[]> {
|
||||
const client = createClient({...});
|
||||
const entries = await client.getEntries({ content_type: 'blogPost' });
|
||||
return entries.items.map(transformPost);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Content Transformation
|
||||
|
||||
Ensure your content is formatted for semantic search:
|
||||
|
||||
```typescript
|
||||
function formatPostContent(post: BlogPost): string {
|
||||
return `# ${post.title}
|
||||
|
||||
**Published:** ${post.date}
|
||||
...
|
||||
${post.content}`;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Document ID Strategy
|
||||
|
||||
Use a consistent document ID for upsert behavior:
|
||||
|
||||
```typescript
|
||||
await retainBlogPost(content, {
|
||||
documentId: `post:${post.slug}`, // Unique, stable identifier
|
||||
timestamp: post.date,
|
||||
});
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Connection refused" error
|
||||
|
||||
Make sure Hindsight is running:
|
||||
```bash
|
||||
docker compose up -d
|
||||
curl http://localhost:8888/health
|
||||
```
|
||||
|
||||
### "No posts found" during sync
|
||||
|
||||
Check your Sanity configuration:
|
||||
```bash
|
||||
# Verify project ID
|
||||
echo $SANITY_PROJECT_ID
|
||||
|
||||
# Test GROQ query
|
||||
npx sanity query '*[_type == "post"][0..2]{title}'
|
||||
```
|
||||
|
||||
### Slow recall/reflect responses
|
||||
|
||||
This is normal for the first query as Hindsight builds embeddings. Subsequent queries are faster. Use `budget: 'low'` for faster responses at the cost of recall quality.
|
||||
|
||||
## Resources
|
||||
|
||||
- [Hindsight Documentation](https://hindsight.vectorize.io/)
|
||||
- [Hindsight GitHub](https://github.com/vectorize-io/hindsight)
|
||||
- [Sanity CMS Documentation](https://www.sanity.io/docs)
|
||||
- [Hindsight Cookbook](https://github.com/vectorize-io/hindsight-cookbook)
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
@@ -0,0 +1,276 @@
|
||||
---
|
||||
sidebar_position: 7
|
||||
---
|
||||
|
||||
# Stance Tracker
|
||||
|
||||
|
||||
:::info Complete Application
|
||||
This is a complete, runnable application demonstrating Hindsight integration.
|
||||
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/stancetracker)
|
||||
:::
|
||||
|
||||
|
||||
An AI-powered application that tracks political candidates' stances on issues over time using Hindsight memory system and web scraping.
|
||||
|
||||
## Features
|
||||
|
||||
- **Geographic Targeting**: Track stances by country, state/province, and city
|
||||
- **Multi-Candidate Tracking**: Monitor multiple candidates simultaneously
|
||||
- **Temporal Analysis**: Historical stance tracking with configurable time ranges
|
||||
- **Automated Scraping**: Periodic content collection with configurable frequencies (hourly/daily/weekly)
|
||||
- **Stance Change Detection**: Automatic detection and highlighting of position changes
|
||||
- **Interactive Timeline**: Visual graph showing stance evolution with reference callouts
|
||||
- **Source Attribution**: All stances linked to verified sources with excerpts
|
||||
|
||||
## Architecture
|
||||
|
||||
### Memory System (Hindsight Integration)
|
||||
|
||||
This app uses the Hindsight memory system from `github.com/vectorize-io/hindsight`:
|
||||
|
||||
1. **Banks**: Each scraper agent has its own memory bank
|
||||
2. **Retain**: Stores candidate statements and web scraping results
|
||||
3. **Recall**: Semantic search to retrieve relevant memories
|
||||
4. **Reflect**: Generates contextual analysis using stored memories
|
||||
5. **Temporal Search**: Queries memories within specific time periods
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Frontend**: Next.js 16, React, TypeScript, TailwindCSS
|
||||
- **Visualization**: Recharts for timeline graphs
|
||||
- **Backend**: Next.js API routes
|
||||
- **Memory**: Hindsight (from github.com/vectorize-io/hindsight)
|
||||
- **Database**: JSON file storage (no database required)
|
||||
- **Web Search**: Tavily API
|
||||
- **LLM**: OpenAI/Anthropic/Groq (configurable)
|
||||
- **Scheduling**: node-cron
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Hindsight API** running (from github.com/vectorize-io/hindsight)
|
||||
2. **API Keys**:
|
||||
- Tavily API key (for web search)
|
||||
- LLM provider API key (OpenAI, Anthropic, or Groq)
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Install Dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure Environment
|
||||
|
||||
Copy `.env.example` to `.env` and fill in your credentials:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env`:
|
||||
|
||||
```env
|
||||
# Hindsight API (from github.com/vectorize-io/hindsight)
|
||||
HINDSIGHT_API_URL=http://localhost:8888
|
||||
|
||||
# Tavily API (for web search)
|
||||
TAVILY_API_KEY=your_tavily_api_key_here
|
||||
|
||||
# LLM Provider
|
||||
LLM_PROVIDER=openai # or anthropic, groq
|
||||
LLM_API_KEY=your_llm_api_key_here
|
||||
LLM_MODEL=gpt-4-turbo-preview
|
||||
```
|
||||
|
||||
### 3. Start Hindsight
|
||||
|
||||
Clone and run Hindsight from github.com/vectorize-io/hindsight:
|
||||
|
||||
```bash
|
||||
# Clone and run github.com/vectorize-io/hindsight
|
||||
cd /path/to/hindsight
|
||||
cargo run --bin hindsight-server
|
||||
```
|
||||
|
||||
Verify Hindsight is running at `http://localhost:8888`
|
||||
|
||||
### 4. Run the Application
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Visit `http://localhost:3000`
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating a Tracking Session
|
||||
|
||||
1. **Set Location**: Enter country (required), state/province, and city (optional)
|
||||
2. **Choose Topic**: Specify the issue to track (e.g., "Climate Change Policy")
|
||||
3. **Add Candidates**: Enter names of candidates/politicians to track
|
||||
4. **Configure Time Range**: Set historical start/end dates for initial analysis
|
||||
5. **Set Frequency**: Choose how often to check for updates (hourly/daily/weekly)
|
||||
6. **Start Tracking**: Click "Start Tracking" to begin
|
||||
|
||||
### Viewing Results
|
||||
|
||||
- **Timeline Graph**: Shows confidence levels of each candidate's stance over time
|
||||
- **Stance Changes**: Red circles on the graph indicate detected position changes
|
||||
- **Click Points**: Click any point to see detailed stance information and sources
|
||||
- **Source Links**: Each stance includes links to original references
|
||||
|
||||
### Managing Sessions
|
||||
|
||||
- **Pause/Resume**: Temporarily stop or restart tracking
|
||||
- **Run Now**: Trigger an immediate update outside the schedule
|
||||
- **Status**: View current session status and frequency
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Sessions
|
||||
|
||||
- `POST /api/sessions` - Create new tracking session
|
||||
- `GET /api/sessions?id={id}` - Get session details
|
||||
- `GET /api/sessions` - List all sessions
|
||||
- `PATCH /api/sessions` - Update session status
|
||||
|
||||
### Stances
|
||||
|
||||
- `POST /api/stances` - Process candidate stance
|
||||
- `GET /api/stances?sessionId={id}&candidate={name}` - Get stances
|
||||
|
||||
### Scheduler
|
||||
|
||||
- `POST /api/scheduler` - Control session scheduling
|
||||
- Actions: `start`, `stop`, `run`
|
||||
|
||||
## Hindsight Integration Examples
|
||||
|
||||
### 1. Storing Memories
|
||||
|
||||
```typescript
|
||||
// Store web scraping results
|
||||
await hindsightClient.retain(bankId, articleContent, {
|
||||
context: 'web_search_result',
|
||||
timestamp: articleDate,
|
||||
metadata: { url: articleUrl }
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Semantic Search
|
||||
|
||||
```typescript
|
||||
// Search for relevant memories
|
||||
const results = await hindsightClient.recall(bankId, query, {
|
||||
budget: 'high',
|
||||
maxTokens: 8192
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Temporal Filtering
|
||||
|
||||
```typescript
|
||||
// Query memories up to a specific point in time
|
||||
const results = await hindsightClient.recall(bankId, query, {
|
||||
queryTimestamp: '2024-12-01T00:00:00Z'
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Contextual Analysis
|
||||
|
||||
```typescript
|
||||
// Generate analysis using stored memories
|
||||
const response = await hindsightClient.reflect(bankId,
|
||||
'What is the candidate\'s stance on this issue?',
|
||||
{ budget: 'high' }
|
||||
);
|
||||
```
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Vercel Deployment
|
||||
|
||||
```bash
|
||||
# Install Vercel CLI
|
||||
npm i -g vercel
|
||||
|
||||
# Deploy
|
||||
vercel
|
||||
|
||||
# Set environment variables in Vercel dashboard:
|
||||
# - HINDSIGHT_API_URL
|
||||
# - TAVILY_API_KEY
|
||||
# - LLM_PROVIDER
|
||||
# - LLM_API_KEY
|
||||
# - LLM_MODEL
|
||||
```
|
||||
|
||||
**Note**: The `data/` directory for JSON storage will be ephemeral on Vercel. For production, consider using a persistent database or object storage.
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
stancetracker/
|
||||
├── app/
|
||||
│ ├── api/ # API routes
|
||||
│ ├── globals.css # Global styles
|
||||
│ ├── layout.tsx # Root layout
|
||||
│ └── page.tsx # Main page
|
||||
├── components/ # React components
|
||||
├── lib/
|
||||
│ ├── db/ # JSON database utilities
|
||||
│ ├── hindsight-client.ts # Hindsight API client
|
||||
│ ├── llm-client.ts # LLM provider client
|
||||
│ ├── web-scraper.ts # Tavily web scraper
|
||||
│ ├── scraper-agent.ts # Content scraper
|
||||
│ ├── rag-system.ts # Memory retrieval
|
||||
│ ├── stance-extractor.ts # Stance analysis
|
||||
│ ├── stance-pipeline.ts # Main pipeline
|
||||
│ └── scheduler.ts # Job scheduling
|
||||
└── types/ # TypeScript types
|
||||
```
|
||||
|
||||
### Adding New LLM Providers
|
||||
|
||||
Edit `lib/llm-client.ts` and add a new method:
|
||||
|
||||
```typescript
|
||||
private async newProviderComplete(messages, options) {
|
||||
// Implementation
|
||||
}
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
- **Web Search**: Uses Tavily API which has rate limits
|
||||
- **Source Verification**: Manual verification recommended for critical applications
|
||||
- **Stance Extraction**: LLM-based, subject to model limitations
|
||||
- **Storage**: JSON file storage is not suitable for high-scale production use
|
||||
- **Rate Limits**: Respect API rate limits for Tavily, Hindsight, and LLM providers
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- [ ] Real-time social media monitoring
|
||||
- [ ] Speech/video transcription analysis
|
||||
- [ ] Multi-language support
|
||||
- [ ] Sentiment analysis integration
|
||||
- [ ] Comparative analysis dashboard
|
||||
- [ ] Export to CSV/PDF
|
||||
- [ ] Email notifications for stance changes
|
||||
- [ ] Public API for third-party integrations
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions, please check:
|
||||
- Hindsight documentation: `github.com/vectorize-io/hindsight/README.md`
|
||||
- Tavily API docs: https://tavily.com/
|
||||
- Project issues: Create an issue in the repository
|
||||
@@ -0,0 +1,122 @@
|
||||
---
|
||||
sidebar_position: 8
|
||||
---
|
||||
|
||||
# Hindsight AI SDK - Personal Chef
|
||||
|
||||
|
||||
:::info Complete Application
|
||||
This is a complete, runnable application demonstrating Hindsight integration.
|
||||
[**View source on GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/tree/main/applications/taste-ai)
|
||||
:::
|
||||
|
||||
|
||||
A personal food assistant demonstrating three key Hindsight integrations using the [Vercel AI SDK v6](https://sdk.vercel.ai/docs).
|
||||
|
||||
## Architecture: Single Bank with User Tags
|
||||
|
||||
This demo uses a **single Hindsight bank** (`taste-ai`) for all users, with each user's data tagged using `user:${username}`.
|
||||
|
||||
```typescript
|
||||
// All users share the same bank
|
||||
const BANK_ID = 'taste-ai';
|
||||
|
||||
// Each memory is tagged with the user
|
||||
await hindsightTools.retain.execute({
|
||||
bankId: BANK_ID,
|
||||
content: userData,
|
||||
tags: [`user:${username}`],
|
||||
});
|
||||
```
|
||||
|
||||
This architecture enables:
|
||||
- **Per-user queries**: Filter by `user:alice` to get personalized results
|
||||
- **Aggregated insights**: Query across all users to find popular recipes or common dietary patterns
|
||||
- **Simplified management**: One bank to maintain instead of per-user banks
|
||||
|
||||
## Three Hindsight Integrations
|
||||
|
||||
### 1. Meal Suggestions with Memory Recall & Reflection
|
||||
|
||||
Uses `recall` and `reflect` tools with AI SDK's agent-based approach to gather personalized context.
|
||||
|
||||
```typescript
|
||||
const contextResult = await generateText({
|
||||
model: llmModel,
|
||||
tools: {
|
||||
recall: hindsightTools.recall,
|
||||
reflect: hindsightTools.reflect,
|
||||
},
|
||||
toolChoice: 'auto',
|
||||
prompt: `You are gathering context for personalized ${mealType} recipe suggestions.
|
||||
|
||||
Use the recall tool to search for the user's food preferences, dislikes, and recent meals.
|
||||
Then use the reflect tool to analyze their dietary patterns and restrictions.
|
||||
|
||||
After gathering context, summarize their preferences and recent eating patterns.`,
|
||||
});
|
||||
```
|
||||
|
||||
The AI agent autonomously:
|
||||
- Searches memory for cuisine preferences and dietary restrictions
|
||||
- Analyzes recent protein consumption for variety
|
||||
- Identifies foods to avoid
|
||||
|
||||
### 2. Goal Progress Tracking with Mental Models
|
||||
|
||||
Uses mental models to automatically maintain updated insights about user progress.
|
||||
|
||||
```typescript
|
||||
// Create a mental model that auto-refreshes after new meals
|
||||
await hindsightTools.createMentalModel.execute({
|
||||
bankId: BANK_ID,
|
||||
mentalModelId: getMentalModelId(username, 'goals'),
|
||||
name: `${username}'s Goal Progress`,
|
||||
sourceQuery: `Analyze ${username}'s dietary goals and eating patterns.
|
||||
Describe their progress towards their stated goals (weight loss, muscle gain, etc.).`,
|
||||
tags: [`user:${username}`],
|
||||
autoRefresh: true, // Refreshes automatically after consolidation
|
||||
});
|
||||
|
||||
// Query the mental model for current insights
|
||||
const result = await hindsightTools.queryMentalModel.execute({
|
||||
bankId: BANK_ID,
|
||||
mentalModelId: mentalModelId,
|
||||
});
|
||||
```
|
||||
|
||||
Mental models automatically:
|
||||
- Track progress towards dietary goals
|
||||
- Update after each new meal is logged
|
||||
- Provide fresh insights without manual refresh
|
||||
|
||||
### 3. Language Enforcement with Directives
|
||||
|
||||
Uses directives to ensure all responses match user's language preference.
|
||||
|
||||
```typescript
|
||||
await hindsightClient.createDirective(BANK_ID, {
|
||||
name: `${username}'s Language Preference`,
|
||||
content: `Always respond in ${language}. All suggestions must be in ${language}.`,
|
||||
priority: 100,
|
||||
tags: [`user:${username}`, 'directive:language'],
|
||||
});
|
||||
```
|
||||
|
||||
Directives are automatically injected when mental models generate insights, ensuring consistent language across all interactions.
|
||||
|
||||
## Running the Demo
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
- Hindsight server running at `http://localhost:8888` (or set `HINDSIGHT_URL`)
|
||||
- Node.js 18+
|
||||
|
||||
## Learn More
|
||||
|
||||
- [Hindsight AI SDK on npm](https://www.npmjs.com/package/@vectorize-io/hindsight-ai-sdk)
|
||||
- [AI SDK Documentation](https://sdk.vercel.ai/docs)
|
||||
@@ -0,0 +1,138 @@
|
||||
|
||||
|
||||
<div className="cookbook-page">
|
||||
|
||||
# Cookbook
|
||||
|
||||
Learn how to build with Hindsight through practical examples:
|
||||
|
||||
- **[Recipes](#recipes)** - Step-by-step guides and patterns for common use cases
|
||||
- **[Applications](#applications)** - Complete, runnable applications demonstrating Hindsight integration
|
||||
|
||||
<RecipeCarousel
|
||||
title="Recipes"
|
||||
items={[
|
||||
{
|
||||
title: "Hindsight Quickstart",
|
||||
href: "/cookbook/recipes/quickstart",
|
||||
description: "Learn the basics: retain, recall, and reflect",
|
||||
tags: { sdk: "hindsight-client", topic: "Quick Start" }
|
||||
},
|
||||
{
|
||||
title: "Per-User Memory",
|
||||
href: "/cookbook/recipes/per-user-memory",
|
||||
description: "Build a chatbot with per-user memory isolation",
|
||||
tags: { sdk: "hindsight-client", topic: "Learning" }
|
||||
},
|
||||
{
|
||||
title: "Support Agent with Shared Knowledge",
|
||||
href: "/cookbook/recipes/support-agent-shared-knowledge",
|
||||
description: "Combine per-user memory with shared product documentation",
|
||||
tags: { sdk: "hindsight-client", topic: "Learning" }
|
||||
},
|
||||
{
|
||||
title: "Memory with LiteLLM",
|
||||
href: "/cookbook/recipes/litellm-memory-demo",
|
||||
description: "Add automatic memory to any LLM app using LiteLLM callbacks",
|
||||
tags: { sdk: "hindsight-litellm", topic: "Quick Start" }
|
||||
},
|
||||
{
|
||||
title: "Routing Tool Learning",
|
||||
href: "/cookbook/recipes/tool-learning-demo",
|
||||
description: "Teach an LLM which tool to use through feedback and memory",
|
||||
tags: { sdk: "hindsight-litellm", topic: "Learning" }
|
||||
},
|
||||
{
|
||||
title: "Fitness Coach with Hindsight Memory",
|
||||
href: "/cookbook/recipes/fitness_tracker",
|
||||
description: "Track workouts, diet, and progress with a personalized fitness coach",
|
||||
tags: { sdk: "hindsight-client", topic: "Recommendation" }
|
||||
},
|
||||
{
|
||||
title: "Healthcare Assistant with Hindsight Memory",
|
||||
href: "/cookbook/recipes/healthcare_assistant",
|
||||
description: "A supportive chatbot that remembers patient history and preferences",
|
||||
tags: { sdk: "hindsight-client", topic: "Recommendation" }
|
||||
},
|
||||
{
|
||||
title: "Movie Recommendation Assistant with Hindsight Memory",
|
||||
href: "/cookbook/recipes/movie_recommendation",
|
||||
description: "Get personalized movie recommendations that improve over time",
|
||||
tags: { sdk: "hindsight-client", topic: "Recommendation" }
|
||||
},
|
||||
{
|
||||
title: "Personal AI Assistant with Hindsight Memory",
|
||||
href: "/cookbook/recipes/personal_assistant",
|
||||
description: "A general-purpose assistant that remembers your life and preferences",
|
||||
tags: { sdk: "hindsight-client", topic: "Recommendation" }
|
||||
},
|
||||
{
|
||||
title: "Personalized Search Agent with Hindsight Memory",
|
||||
href: "/cookbook/recipes/personalized_search",
|
||||
description: "Search assistant that learns your location, diet, and lifestyle",
|
||||
tags: { sdk: "hindsight-client", topic: "Recommendation" }
|
||||
},
|
||||
{
|
||||
title: "Study Buddy with Hindsight Memory",
|
||||
href: "/cookbook/recipes/study_buddy",
|
||||
description: "Track study sessions, identify knowledge gaps, and get personalized review suggestions",
|
||||
tags: { sdk: "hindsight-client", topic: "Learning" }
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
<RecipeCarousel
|
||||
title="Applications"
|
||||
items={[
|
||||
{
|
||||
title: "Chat Memory App",
|
||||
href: "/cookbook/applications/chat-memory",
|
||||
description: "Real-time chat app with per-user memory using Groq and Hindsight",
|
||||
tags: { sdk: "hindsight-client", topic: "Chat" }
|
||||
},
|
||||
{
|
||||
title: "Deliveryman Demo",
|
||||
href: "/cookbook/applications/deliveryman-demo",
|
||||
description: "Delivery agent simulation demonstrating learning through mental models",
|
||||
tags: { sdk: "hindsight-client", topic: "Learning" }
|
||||
},
|
||||
{
|
||||
title: "Memory Approaches Comparison Demo",
|
||||
href: "/cookbook/applications/hindsight-litellm-demo",
|
||||
description: "Interactive comparison of memory approaches: none, full history, and semantic retrieval",
|
||||
tags: { sdk: "hindsight-litellm", topic: "Quick Start" }
|
||||
},
|
||||
{
|
||||
title: "Tool Learning Demo",
|
||||
href: "/cookbook/applications/hindsight-tool-learning-demo",
|
||||
description: "Show how Hindsight helps LLMs learn which tool to use when names are ambiguous",
|
||||
tags: { sdk: "hindsight-litellm", topic: "Learning" }
|
||||
},
|
||||
{
|
||||
title: "OpenAI Agent + Hindsight Memory Integration",
|
||||
href: "/cookbook/applications/openai-fitness-coach",
|
||||
description: "Fitness coach using OpenAI Assistants with Hindsight as memory backend",
|
||||
tags: { sdk: "hindsight-client", topic: "Recommendation" }
|
||||
},
|
||||
{
|
||||
title: "Sanity CMS Blog Memory",
|
||||
href: "/cookbook/applications/sanity-blog-memory",
|
||||
description: "Sync Sanity CMS blog posts to Hindsight for semantic search and AI insights",
|
||||
tags: { sdk: "hindsight-client", topic: "Learning" }
|
||||
},
|
||||
{
|
||||
title: "Stance Tracker",
|
||||
href: "/cookbook/applications/stancetracker",
|
||||
description: "Track political candidates' stances over time with automated web scraping",
|
||||
tags: { sdk: "hindsight-client", topic: "Recommendation" }
|
||||
},
|
||||
{
|
||||
title: "Hindsight AI SDK - Personal Chef",
|
||||
href: "/cookbook/applications/taste-ai",
|
||||
description: "Personal food assistant with AI SDK v6 showcasing recall, mental models, and directives",
|
||||
tags: { sdk: "@vectorize-io/hindsight-ai-sdk", topic: "Recommendation" }
|
||||
}
|
||||
]}
|
||||
/>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,306 @@
|
||||
---
|
||||
sidebar_position: 6
|
||||
---
|
||||
|
||||
# Fitness Coach with Hindsight Memory
|
||||
|
||||
|
||||
:::tip Run this notebook
|
||||
This recipe is available as an interactive Jupyter notebook.
|
||||
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/fitness_tracker.ipynb)
|
||||
:::
|
||||
|
||||
|
||||
A personalized fitness assistant that tracks your workouts, diet, recovery, and progress over time to give contextual advice.
|
||||
|
||||
## Features
|
||||
- Logs workout sessions with exercises and weights
|
||||
- Tracks meals and dietary preferences
|
||||
- Monitors recovery and sleep patterns
|
||||
- Provides personalized training advice
|
||||
|
||||
## Prerequisites
|
||||
- OpenAI API key
|
||||
- Hindsight running locally via Docker (see setup below)
|
||||
|
||||
## Start Hindsight Locally
|
||||
|
||||
Before running this notebook, start Hindsight in a terminal:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-openai-api-key"
|
||||
|
||||
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=gpt-4o-mini \
|
||||
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
```
|
||||
|
||||
## 1. Install Dependencies
|
||||
|
||||
|
||||
```python
|
||||
!pip install -q hindsight-client openai nest-asyncio
|
||||
```
|
||||
|
||||
## 2. Configure OpenAI API Key
|
||||
|
||||
Enter your OpenAI API key when prompted (used by both Hindsight and the demo).
|
||||
|
||||
|
||||
```python
|
||||
import getpass
|
||||
import os
|
||||
|
||||
# Set OpenAI API key (used by both Hindsight and the demo)
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter your OpenAI API key: ")
|
||||
|
||||
print("API key configured!")
|
||||
```
|
||||
|
||||
## 3. Initialize Clients
|
||||
|
||||
|
||||
```python
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
|
||||
from datetime import datetime
|
||||
from openai import OpenAI
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
# Initialize Hindsight client (connects to local Docker instance)
|
||||
hindsight = Hindsight(
|
||||
base_url=os.getenv("HINDSIGHT_BASE_URL", "http://localhost:8888"),
|
||||
)
|
||||
|
||||
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||
|
||||
USER_ID = "fitness-user-demo"
|
||||
|
||||
print("Clients initialized!")
|
||||
```
|
||||
|
||||
## 4. Define Helper Functions
|
||||
|
||||
|
||||
```python
|
||||
def log_workout(workout_details: str) -> str:
|
||||
"""Log a workout session with timestamp."""
|
||||
today = datetime.now().strftime("%B %d, %Y")
|
||||
hindsight.retain(
|
||||
bank_id=USER_ID,
|
||||
content=f"{today} - WORKOUT LOG: {workout_details}",
|
||||
metadata={"category": "workout", "date": today},
|
||||
)
|
||||
return f"Logged workout for {today}: {workout_details}"
|
||||
|
||||
|
||||
def log_meal(meal_details: str) -> str:
|
||||
"""Log a meal with timestamp."""
|
||||
today = datetime.now().strftime("%B %d, %Y")
|
||||
hindsight.retain(
|
||||
bank_id=USER_ID,
|
||||
content=f"{today} - MEAL LOG: {meal_details}",
|
||||
metadata={"category": "nutrition", "date": today},
|
||||
)
|
||||
return f"Logged meal for {today}: {meal_details}"
|
||||
|
||||
|
||||
def log_recovery(recovery_details: str) -> str:
|
||||
"""Log recovery information (sleep, soreness, etc.)."""
|
||||
today = datetime.now().strftime("%B %d, %Y")
|
||||
hindsight.retain(
|
||||
bank_id=USER_ID,
|
||||
content=f"{today} - RECOVERY LOG: {recovery_details}",
|
||||
metadata={"category": "recovery", "date": today},
|
||||
)
|
||||
return f"Logged recovery for {today}: {recovery_details}"
|
||||
|
||||
|
||||
def store_user_profile(profile_info: str) -> str:
|
||||
"""Store user profile information."""
|
||||
hindsight.retain(
|
||||
bank_id=USER_ID,
|
||||
content=f"USER PROFILE: {profile_info}",
|
||||
metadata={"category": "profile"},
|
||||
)
|
||||
return f"Stored profile info: {profile_info}"
|
||||
|
||||
|
||||
def fitness_coach(user_query: str) -> str:
|
||||
"""Get personalized fitness advice based on query and user history."""
|
||||
memories = hindsight.recall(
|
||||
bank_id=USER_ID,
|
||||
query=f"fitness workout diet recovery goals {user_query}",
|
||||
budget="high",
|
||||
)
|
||||
|
||||
memory_context = ""
|
||||
if memories and memories.results:
|
||||
memory_context = "\n".join(f"- {m.text}" for m in memories.results[:10])
|
||||
|
||||
system_prompt = f"""You are a knowledgeable and supportive fitness coach.
|
||||
You have access to the user's workout history, diet logs, recovery notes, and personal profile.
|
||||
|
||||
What you know about this user:
|
||||
{memory_context if memory_context else "No history recorded yet."}
|
||||
|
||||
Provide personalized, actionable advice based on their:
|
||||
- Training history and progress
|
||||
- Dietary preferences and restrictions
|
||||
- Recovery patterns
|
||||
- Personal goals
|
||||
|
||||
Be encouraging but realistic. Reference their specific history when relevant."""
|
||||
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_query},
|
||||
],
|
||||
temperature=0.7,
|
||||
max_tokens=600,
|
||||
)
|
||||
|
||||
advice = response.choices[0].message.content
|
||||
|
||||
hindsight.retain(
|
||||
bank_id=USER_ID,
|
||||
content=f"User asked: {user_query}\nCoach advised: {advice[:200]}...",
|
||||
metadata={"category": "coaching"},
|
||||
)
|
||||
|
||||
return advice
|
||||
|
||||
|
||||
def get_progress_report() -> str:
|
||||
"""Generate a progress report based on workout history."""
|
||||
report = hindsight.reflect(
|
||||
bank_id=USER_ID,
|
||||
query="""Analyze this user's fitness journey:
|
||||
1. How consistent have they been with workouts?
|
||||
2. What progress have they made (weight lifted, exercises)?
|
||||
3. How is their recovery and sleep?
|
||||
4. What dietary patterns do you notice?
|
||||
5. What should they focus on next?""",
|
||||
budget="high",
|
||||
)
|
||||
return report.text if hasattr(report, 'text') else str(report)
|
||||
|
||||
print("Helper functions defined!")
|
||||
```
|
||||
|
||||
## 5. Set Up User Profile
|
||||
|
||||
|
||||
```python
|
||||
print("Setting up user profile...")
|
||||
|
||||
profile_data = [
|
||||
"Name: Anish, Age: 26, Height: 5'10\", Weight: 72kg",
|
||||
"Goal: Building lean muscle, started gym 6 months ago",
|
||||
"Routine: Push-pull-legs split, 5x per week",
|
||||
"Rest days: Wednesday and Sunday",
|
||||
"Dietary restriction: Mild lactose intolerance, uses almond milk",
|
||||
"Health note: Occasional knee pain, avoids deep squats",
|
||||
"Supplements: Whey protein (lactose-free), magnesium",
|
||||
"Sleep: Aims for 7+ hours, performance drops under 6 hours",
|
||||
]
|
||||
|
||||
for info in profile_data:
|
||||
store_user_profile(info)
|
||||
print(f" Stored: {info[:50]}...")
|
||||
```
|
||||
|
||||
## 6. Log Workout History
|
||||
|
||||
|
||||
```python
|
||||
print("Logging workout history...")
|
||||
|
||||
workouts = [
|
||||
"Push day: Bench press 3x8 @ 60kg, overhead press 4x12, tricep dips 3x10. Felt strong.",
|
||||
"Pull day: Deadlift 3x5 @ 80kg, barbell rows 4x10, bicep curls 3x12. Good session.",
|
||||
"Leg day: Leg press 4x12, hamstring curls 3x12, glute bridges 3x15. Knee felt okay.",
|
||||
]
|
||||
|
||||
for workout in workouts:
|
||||
print(f" {log_workout(workout)[:60]}...")
|
||||
|
||||
print("\nLogging recent meals...")
|
||||
meals = [
|
||||
"Post-workout: Whey shake with almond milk, banana, oats",
|
||||
"Dinner: Grilled chicken, brown rice, steamed vegetables",
|
||||
"Snack: Greek yogurt (lactose-free) with berries",
|
||||
]
|
||||
|
||||
for meal in meals:
|
||||
print(f" {log_meal(meal)[:60]}...")
|
||||
|
||||
print("\nLogging recovery notes...")
|
||||
recovery = [
|
||||
"Slept 7.5 hours, feeling well rested",
|
||||
"Some DOMS in legs from yesterday, using turmeric milk",
|
||||
]
|
||||
|
||||
for note in recovery:
|
||||
print(f" {log_recovery(note)[:60]}...")
|
||||
```
|
||||
|
||||
## 7. Talk to Your Fitness Coach
|
||||
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
print("=" * 60)
|
||||
print(" Talking to your fitness coach...")
|
||||
print("=" * 60)
|
||||
|
||||
queries = [
|
||||
"How much was I lifting for bench press recently?",
|
||||
"I slept poorly last night (only 5 hours). What should I do for today's workout?",
|
||||
"Suggest a post-workout meal that works with my dietary restrictions.",
|
||||
"My knee has been bothering me more. Any exercise modifications?",
|
||||
]
|
||||
|
||||
for query in queries:
|
||||
print(f"\nUser: {query}")
|
||||
print("-" * 40)
|
||||
response = fitness_coach(query)
|
||||
print(f"Coach: {response}")
|
||||
time.sleep(1)
|
||||
```
|
||||
|
||||
## 8. Generate Progress Report
|
||||
|
||||
|
||||
```python
|
||||
print("=" * 60)
|
||||
print(" Progress Report")
|
||||
print("=" * 60)
|
||||
print(get_progress_report())
|
||||
```
|
||||
|
||||
## 9. Try Your Own Query
|
||||
|
||||
|
||||
```python
|
||||
your_query = "What exercises should I do today?" # Change this!
|
||||
|
||||
print(f"You: {your_query}")
|
||||
print("-" * 40)
|
||||
print(f"Coach: {fitness_coach(your_query)}")
|
||||
```
|
||||
|
||||
## 10. Cleanup
|
||||
|
||||
|
||||
```python
|
||||
hindsight.close()
|
||||
print("Client connection closed.")
|
||||
```
|
||||
@@ -0,0 +1,299 @@
|
||||
---
|
||||
sidebar_position: 7
|
||||
---
|
||||
|
||||
# Healthcare Assistant with Hindsight Memory
|
||||
|
||||
|
||||
:::tip Run this notebook
|
||||
This recipe is available as an interactive Jupyter notebook.
|
||||
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/healthcare_assistant.ipynb)
|
||||
:::
|
||||
|
||||
|
||||
A supportive healthcare chatbot that remembers patient history, symptoms, medications, and preferences to provide personalized guidance.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
**This is a demo application and should NOT be used for actual medical advice. Always consult qualified healthcare professionals.**
|
||||
|
||||
## Features
|
||||
- Tracks symptoms, medications, and allergies
|
||||
- Maintains patient history across conversations
|
||||
- Provides health information and wellness tips
|
||||
- Schedules appointments
|
||||
|
||||
## Prerequisites
|
||||
- OpenAI API key
|
||||
- Hindsight running locally via Docker (see setup below)
|
||||
|
||||
## Start Hindsight Locally
|
||||
|
||||
Before running this notebook, start Hindsight in a terminal:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-openai-api-key"
|
||||
|
||||
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=gpt-4o-mini \
|
||||
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
```
|
||||
|
||||
## 1. Install Dependencies
|
||||
|
||||
|
||||
```python
|
||||
!pip install -q hindsight-client openai nest-asyncio
|
||||
```
|
||||
|
||||
## 2. Configure OpenAI API Key
|
||||
|
||||
Enter your OpenAI API key when prompted (used by both Hindsight and the demo).
|
||||
|
||||
|
||||
```python
|
||||
import getpass
|
||||
import os
|
||||
|
||||
# Set OpenAI API key (used by both Hindsight and the demo)
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter your OpenAI API key: ")
|
||||
|
||||
print("API key configured!")
|
||||
```
|
||||
|
||||
## 3. Initialize Clients
|
||||
|
||||
|
||||
```python
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
|
||||
from datetime import datetime
|
||||
import random
|
||||
from openai import OpenAI
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
# Initialize Hindsight client (connects to local Docker instance)
|
||||
hindsight = Hindsight(
|
||||
base_url=os.getenv("HINDSIGHT_BASE_URL", "http://localhost:8888"),
|
||||
)
|
||||
|
||||
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||
|
||||
PATIENT_ID = "patient-demo"
|
||||
|
||||
def get_patient_bank_id(patient_id: str) -> str:
|
||||
return f"patient-{patient_id}"
|
||||
|
||||
print("Clients initialized!")
|
||||
```
|
||||
|
||||
## 4. Define Helper Functions
|
||||
|
||||
|
||||
```python
|
||||
def store_patient_info(patient_id: str, info: str, category: str = "general") -> str:
|
||||
"""Store patient information."""
|
||||
bank_id = get_patient_bank_id(patient_id)
|
||||
today = datetime.now().strftime("%B %d, %Y")
|
||||
|
||||
hindsight.retain(
|
||||
bank_id=bank_id,
|
||||
content=f"{today} - {category.upper()}: {info}",
|
||||
metadata={"category": category, "date": today},
|
||||
)
|
||||
|
||||
return f"Recorded {category}: {info}"
|
||||
|
||||
|
||||
def get_patient_history(patient_id: str, query: str) -> str:
|
||||
"""Retrieve relevant patient history."""
|
||||
bank_id = get_patient_bank_id(patient_id)
|
||||
|
||||
memories = hindsight.recall(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
budget="high",
|
||||
)
|
||||
|
||||
if memories and memories.results:
|
||||
return "\n".join(f"- {m.text}" for m in memories.results[:10])
|
||||
return "No relevant history found."
|
||||
|
||||
|
||||
def healthcare_chat(patient_id: str, user_message: str) -> str:
|
||||
"""Chat with the healthcare assistant."""
|
||||
bank_id = get_patient_bank_id(patient_id)
|
||||
|
||||
history = get_patient_history(
|
||||
patient_id,
|
||||
f"symptoms medications allergies conditions {user_message}"
|
||||
)
|
||||
|
||||
system_prompt = f"""You are a supportive healthcare assistant chatbot.
|
||||
|
||||
IMPORTANT DISCLAIMERS:
|
||||
- You are NOT a doctor and cannot provide medical diagnoses
|
||||
- Always recommend consulting healthcare professionals for serious concerns
|
||||
- Never prescribe medications or suggest stopping prescribed treatments
|
||||
|
||||
Your role:
|
||||
- Listen empathetically to patient concerns
|
||||
- Remember and reference their medical history
|
||||
- Provide general health information and wellness tips
|
||||
- Help track symptoms over time
|
||||
- Remind about medications and appointments
|
||||
- Suggest when to seek professional care
|
||||
|
||||
Patient History:
|
||||
{history}
|
||||
|
||||
Guidelines:
|
||||
- Be warm and supportive
|
||||
- Ask clarifying questions when needed
|
||||
- Reference their history when relevant
|
||||
- Flag any concerning symptoms for professional review"""
|
||||
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
temperature=0.7,
|
||||
max_tokens=600,
|
||||
)
|
||||
|
||||
answer = response.choices[0].message.content
|
||||
|
||||
hindsight.retain(
|
||||
bank_id=bank_id,
|
||||
content=f"Patient concern: {user_message}\nGuidance provided: {answer[:200]}...",
|
||||
metadata={"category": "consultation"},
|
||||
)
|
||||
|
||||
return answer
|
||||
|
||||
|
||||
def get_health_summary(patient_id: str) -> str:
|
||||
"""Generate a health summary for the patient."""
|
||||
bank_id = get_patient_bank_id(patient_id)
|
||||
|
||||
summary = hindsight.reflect(
|
||||
bank_id=bank_id,
|
||||
query="""Summarize this patient's health profile:
|
||||
1. Known conditions and diagnoses
|
||||
2. Current medications
|
||||
3. Allergies and sensitivities
|
||||
4. Recent symptoms reported
|
||||
5. Lifestyle factors mentioned
|
||||
6. Any patterns or trends in their health""",
|
||||
budget="high",
|
||||
)
|
||||
return summary.text if hasattr(summary, 'text') else str(summary)
|
||||
|
||||
|
||||
def schedule_appointment(patient_id: str, appointment_type: str, preferred_time: str) -> str:
|
||||
"""Schedule an appointment (demo)."""
|
||||
confirmation_id = f"APT-{random.randint(10000, 99999)}"
|
||||
|
||||
store_patient_info(
|
||||
patient_id,
|
||||
f"Appointment scheduled: {appointment_type} - Preferred time: {preferred_time} - Confirmation: {confirmation_id}",
|
||||
category="appointment"
|
||||
)
|
||||
|
||||
return f"Appointment requested: {appointment_type}\nPreferred time: {preferred_time}\nConfirmation ID: {confirmation_id}\n\nA staff member will confirm the exact time within 24 hours."
|
||||
|
||||
print("Helper functions defined!")
|
||||
```
|
||||
|
||||
## 5. Set Up Patient Profile
|
||||
|
||||
|
||||
```python
|
||||
print("Setting up patient profile...")
|
||||
|
||||
patient_info = [
|
||||
("Age: 45, Male, Height: 5'11\", Weight: 185 lbs", "demographics"),
|
||||
("Allergy: Penicillin - causes hives", "allergies"),
|
||||
("Allergy: Shellfish - causes throat swelling", "allergies"),
|
||||
("Current medication: Lisinopril 10mg daily for blood pressure", "medications"),
|
||||
("Current medication: Metformin 500mg twice daily for Type 2 diabetes", "medications"),
|
||||
("Condition: Diagnosed with Type 2 diabetes in 2020", "conditions"),
|
||||
("Condition: Mild hypertension, well-controlled", "conditions"),
|
||||
("Family history: Father had heart disease", "family_history"),
|
||||
("Lifestyle: Sedentary job, trying to exercise more", "lifestyle"),
|
||||
]
|
||||
|
||||
for info, category in patient_info:
|
||||
result = store_patient_info(PATIENT_ID, info, category)
|
||||
print(f" {result}")
|
||||
```
|
||||
|
||||
## 6. Healthcare Chat
|
||||
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
print("=" * 60)
|
||||
print(" Healthcare Chat")
|
||||
print("=" * 60)
|
||||
|
||||
conversations = [
|
||||
"Hi, I've been having headaches for the past few days. Should I be worried?",
|
||||
"The headaches are mostly in the afternoon. I've also been feeling more tired than usual.",
|
||||
"I've been checking my blood sugar and it's been a bit higher lately, around 140-150 fasting.",
|
||||
"Can you remind me what allergies I have? I'm going to a new restaurant.",
|
||||
]
|
||||
|
||||
for message in conversations:
|
||||
print(f"\nPatient: {message}")
|
||||
print("-" * 40)
|
||||
response = healthcare_chat(PATIENT_ID, message)
|
||||
print(f"Assistant: {response}")
|
||||
time.sleep(1)
|
||||
```
|
||||
|
||||
## 7. Schedule Appointment
|
||||
|
||||
|
||||
```python
|
||||
print("=" * 60)
|
||||
print(" Scheduling Appointment")
|
||||
print("=" * 60)
|
||||
print(schedule_appointment(PATIENT_ID, "General checkup", "Next Tuesday afternoon"))
|
||||
```
|
||||
|
||||
## 8. Health Summary
|
||||
|
||||
|
||||
```python
|
||||
print("=" * 60)
|
||||
print(" Patient Health Summary")
|
||||
print("=" * 60)
|
||||
print(get_health_summary(PATIENT_ID))
|
||||
```
|
||||
|
||||
## 9. Try Your Own Question
|
||||
|
||||
|
||||
```python
|
||||
your_question = "Should I adjust my Metformin dose?" # Change this!
|
||||
|
||||
print(f"You: {your_question}")
|
||||
print("-" * 40)
|
||||
print(f"Assistant: {healthcare_chat(PATIENT_ID, your_question)}")
|
||||
```
|
||||
|
||||
## 10. Cleanup
|
||||
|
||||
|
||||
```python
|
||||
hindsight.close()
|
||||
print("Client connection closed.")
|
||||
```
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
sidebar_position: 4
|
||||
---
|
||||
|
||||
# Memory with LiteLLM
|
||||
|
||||
|
||||
:::tip Run this notebook
|
||||
This recipe is available as an interactive Jupyter notebook.
|
||||
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/04-litellm-memory-demo.ipynb)
|
||||
:::
|
||||
|
||||
|
||||
This notebook demonstrates how to add persistent memory to any LLM app using the `hindsight-litellm` package. Memory storage and injection happen automatically via LiteLLM callbacks - no manual memory management needed!
|
||||
|
||||
**Key features demonstrated:**
|
||||
1. `configure()` + `enable()` - Set up automatic memory integration
|
||||
2. Automatic storage - Conversations are stored after each LLM call
|
||||
3. Automatic injection - Relevant memories are injected into prompts
|
||||
|
||||
The `hindsight-litellm` package hooks into LiteLLM's callback system to:
|
||||
- Store each conversation after successful LLM responses
|
||||
- Inject relevant memories into the system prompt before LLM calls
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Make sure you have Hindsight running:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=your-key
|
||||
|
||||
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
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
```python
|
||||
!pip install hindsight-litellm litellm nest_asyncio python-dotenv -U -q
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
```python
|
||||
import os
|
||||
import uuid
|
||||
import time
|
||||
import logging
|
||||
import nest_asyncio
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Apply nest_asyncio for Jupyter compatibility
|
||||
nest_asyncio.apply()
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.getLogger("LiteLLM").setLevel(logging.WARNING)
|
||||
logging.getLogger("LiteLLM Router").setLevel(logging.WARNING)
|
||||
logging.getLogger("LiteLLM Proxy").setLevel(logging.WARNING)
|
||||
|
||||
# Import hindsight_litellm
|
||||
import hindsight_litellm
|
||||
|
||||
# Configuration
|
||||
HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
|
||||
|
||||
# Check for API key
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("Warning: OPENAI_API_KEY not set")
|
||||
```
|
||||
|
||||
## Configure and Enable Automatic Memory
|
||||
|
||||
This is all you need! After this, all LiteLLM calls will automatically:
|
||||
- Have relevant memories injected into the prompt
|
||||
- Store conversations to Hindsight after the response
|
||||
|
||||
|
||||
```python
|
||||
# Generate a unique bank_id for this demo session
|
||||
bank_id = f"demo-{uuid.uuid4().hex[:8]}"
|
||||
print(f"Using bank_id: {bank_id}")
|
||||
|
||||
# Configure and enable hindsight
|
||||
hindsight_litellm.configure(
|
||||
hindsight_api_url=HINDSIGHT_API_URL,
|
||||
bank_id=bank_id,
|
||||
store_conversations=True, # Automatically store conversations
|
||||
inject_memories=True, # Automatically inject relevant memories
|
||||
verbose=True, # Enable logging to debug memory operations
|
||||
)
|
||||
hindsight_litellm.enable()
|
||||
|
||||
print("Hindsight memory integration enabled!")
|
||||
```
|
||||
|
||||
## Conversation 1: User Introduces Themselves
|
||||
|
||||
In this first conversation, the user shares some information about themselves. This will be automatically stored to Hindsight memory.
|
||||
|
||||
|
||||
```python
|
||||
user_message_1 = "Hi! I'm Alex and I work at Google as a software engineer. I love Python and machine learning."
|
||||
print(f"User: {user_message_1}\n")
|
||||
|
||||
# Use hindsight_litellm.completion() directly
|
||||
response_1 = hindsight_litellm.completion(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": user_message_1}
|
||||
],
|
||||
)
|
||||
|
||||
assistant_response_1 = response_1.choices[0].message.content
|
||||
print(f"Assistant: {assistant_response_1}")
|
||||
print("\n(Conversation automatically stored to Hindsight)")
|
||||
```
|
||||
|
||||
## Wait for Memory Processing
|
||||
|
||||
Hindsight needs a few seconds to process and extract facts from the conversation.
|
||||
|
||||
|
||||
```python
|
||||
print("Waiting 12 seconds for memory processing...")
|
||||
time.sleep(12)
|
||||
print("Done!")
|
||||
```
|
||||
|
||||
## Conversation 2: Test Memory-Augmented Response
|
||||
|
||||
Now we start a fresh conversation and ask what the assistant remembers. The memories from the previous conversation will be automatically injected into the prompt!
|
||||
|
||||
|
||||
```python
|
||||
user_message_2 = "What do you know about me? What programming language should I use for my next project?"
|
||||
print(f"User: {user_message_2}\n")
|
||||
|
||||
# Memories are automatically injected before this call!
|
||||
response_2 = hindsight_litellm.completion(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": user_message_2}
|
||||
],
|
||||
)
|
||||
|
||||
print(f"Assistant: {response_2.choices[0].message.content}")
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
The assistant should have remembered that Alex:
|
||||
- Works at Google as a software engineer
|
||||
- Loves Python and machine learning
|
||||
|
||||
And it should have recommended Python based on that knowledge!
|
||||
|
||||
|
||||
```python
|
||||
print(f"Memories stored in bank: {bank_id}")
|
||||
print(f"View in UI: http://localhost:9999/banks/{bank_id}")
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
|
||||
```python
|
||||
hindsight_litellm.cleanup()
|
||||
|
||||
# Optional: delete the bank
|
||||
import requests
|
||||
response = requests.delete(f"{HINDSIGHT_API_URL}/v1/default/banks/{bank_id}")
|
||||
print(f"Deleted bank: {response.json()}")
|
||||
```
|
||||
@@ -0,0 +1,246 @@
|
||||
---
|
||||
sidebar_position: 8
|
||||
---
|
||||
|
||||
# Movie Recommendation Assistant with Hindsight Memory
|
||||
|
||||
|
||||
:::tip Run this notebook
|
||||
This recipe is available as an interactive Jupyter notebook.
|
||||
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/movie_recommendation.ipynb)
|
||||
:::
|
||||
|
||||
|
||||
A personalized movie recommender that remembers your preferences, watch history, and tastes to give better suggestions over time.
|
||||
|
||||
## Features
|
||||
- Remembers favorite genres, directors, and actors
|
||||
- Tracks movies you've watched and enjoyed
|
||||
- Provides contextual recommendations based on mood
|
||||
|
||||
## Prerequisites
|
||||
- OpenAI API key
|
||||
- Hindsight running locally via Docker (see setup below)
|
||||
|
||||
## Start Hindsight Locally
|
||||
|
||||
Before running this notebook, start Hindsight in a terminal:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-openai-api-key"
|
||||
|
||||
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=gpt-4o-mini \
|
||||
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
```
|
||||
|
||||
## 1. Install Dependencies
|
||||
|
||||
|
||||
```python
|
||||
!pip install -q hindsight-client openai nest-asyncio
|
||||
```
|
||||
|
||||
## 2. Configure OpenAI API Key
|
||||
|
||||
Enter your OpenAI API key when prompted (used by both Hindsight and the demo).
|
||||
|
||||
|
||||
```python
|
||||
import getpass
|
||||
import os
|
||||
|
||||
# Set OpenAI API key (used by both Hindsight and the demo)
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter your OpenAI API key: ")
|
||||
|
||||
print("API key configured!")
|
||||
```
|
||||
|
||||
## 3. Initialize Clients
|
||||
|
||||
|
||||
```python
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
|
||||
from openai import OpenAI
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
# Initialize Hindsight client (connects to local Docker instance)
|
||||
hindsight = Hindsight(
|
||||
base_url=os.getenv("HINDSIGHT_BASE_URL", "http://localhost:8888"),
|
||||
)
|
||||
|
||||
# Initialize OpenAI client
|
||||
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||
|
||||
# Unique identifier for this user's memory bank
|
||||
USER_ID = "movie-fan-demo"
|
||||
|
||||
print("Clients initialized!")
|
||||
```
|
||||
|
||||
## 4. Define Helper Functions
|
||||
|
||||
These functions demonstrate the three core Hindsight operations:
|
||||
- **retain()**: Store memories
|
||||
- **recall()**: Retrieve relevant memories
|
||||
- **reflect()**: Synthesize insights from memories
|
||||
|
||||
|
||||
```python
|
||||
def get_recommendation(user_query: str) -> str:
|
||||
"""
|
||||
Get a movie recommendation based on user query and remembered preferences.
|
||||
"""
|
||||
# Recall relevant memories about this user's movie preferences
|
||||
memories = hindsight.recall(
|
||||
bank_id=USER_ID,
|
||||
query=f"movie preferences tastes genres {user_query}",
|
||||
budget="mid",
|
||||
)
|
||||
|
||||
# Build context from memories
|
||||
memory_context = ""
|
||||
if memories and memories.results:
|
||||
memory_context = "\n".join(
|
||||
f"- {m.text}" for m in memories.results[:5]
|
||||
)
|
||||
|
||||
# Generate recommendation with context
|
||||
system_prompt = f"""You are a helpful movie recommendation assistant.
|
||||
You remember the user's preferences and past conversations to give personalized suggestions.
|
||||
|
||||
What you know about this user:
|
||||
{memory_context if memory_context else "No previous preferences recorded yet."}
|
||||
|
||||
Give thoughtful, personalized recommendations based on their tastes.
|
||||
If they mention new preferences, acknowledge them."""
|
||||
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_query},
|
||||
],
|
||||
temperature=0.7,
|
||||
max_tokens=500,
|
||||
)
|
||||
|
||||
recommendation = response.choices[0].message.content
|
||||
|
||||
# Store this interaction for future context
|
||||
hindsight.retain(
|
||||
bank_id=USER_ID,
|
||||
content=f"User asked: {user_query}\nRecommendation given: {recommendation}",
|
||||
metadata={"category": "movie_recommendation"},
|
||||
)
|
||||
|
||||
return recommendation
|
||||
|
||||
|
||||
def store_preference(preference: str) -> None:
|
||||
"""Store an explicit user preference."""
|
||||
hindsight.retain(
|
||||
bank_id=USER_ID,
|
||||
content=f"User preference: {preference}",
|
||||
metadata={"category": "preference"},
|
||||
)
|
||||
print(f"Stored preference: {preference}")
|
||||
|
||||
|
||||
def get_preference_summary() -> str:
|
||||
"""Get a summary of what we know about the user's movie tastes."""
|
||||
summary = hindsight.reflect(
|
||||
bank_id=USER_ID,
|
||||
query="Summarize this user's movie preferences, favorite genres, actors they like, and movies they've mentioned enjoying or disliking.",
|
||||
budget="high",
|
||||
)
|
||||
return summary.text if hasattr(summary, 'text') else str(summary)
|
||||
|
||||
print("Helper functions defined!")
|
||||
```
|
||||
|
||||
## 5. Run the Demo
|
||||
|
||||
Watch how the assistant learns and remembers preferences across conversations.
|
||||
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
print("=" * 60)
|
||||
print(" Movie Recommendation Assistant with Memory")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Simulate a conversation over time
|
||||
conversations = [
|
||||
"I'm looking for a movie to watch tonight. Any suggestions?",
|
||||
"I really loved Inception and Interstellar. Christopher Nolan is amazing!",
|
||||
"Can you suggest something similar to those? I like mind-bending plots.",
|
||||
"Actually, I'm not in the mood for something heavy. Something lighter?",
|
||||
"I watched The Grand Budapest Hotel last week and loved it!",
|
||||
"What should I watch tonight? Remember what I like!",
|
||||
]
|
||||
|
||||
for i, query in enumerate(conversations, 1):
|
||||
print(f"\n[Conversation {i}]")
|
||||
print(f"User: {query}")
|
||||
print("-" * 40)
|
||||
|
||||
response = get_recommendation(query)
|
||||
print(f"Assistant: {response}")
|
||||
print()
|
||||
|
||||
time.sleep(1)
|
||||
```
|
||||
|
||||
## 6. View Learned Preferences
|
||||
|
||||
Use `reflect()` to synthesize what Hindsight has learned about your movie tastes.
|
||||
|
||||
|
||||
```python
|
||||
print("=" * 60)
|
||||
print(" What I've learned about your movie tastes:")
|
||||
print("=" * 60)
|
||||
print(get_preference_summary())
|
||||
```
|
||||
|
||||
## 7. Try Your Own Queries
|
||||
|
||||
Experiment with your own movie preferences!
|
||||
|
||||
|
||||
```python
|
||||
# Try your own query!
|
||||
your_query = "I'm in the mood for a sci-fi thriller" # Change this!
|
||||
|
||||
print(f"You: {your_query}")
|
||||
print("-" * 40)
|
||||
print(f"Assistant: {get_recommendation(your_query)}")
|
||||
```
|
||||
|
||||
## 8. Cleanup
|
||||
|
||||
Close the Hindsight client connection.
|
||||
|
||||
|
||||
```python
|
||||
hindsight.close()
|
||||
print("Client connection closed.")
|
||||
```
|
||||
|
||||
|
||||
```python
|
||||
|
||||
```
|
||||
|
||||
|
||||
```python
|
||||
|
||||
```
|
||||
@@ -0,0 +1,247 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
---
|
||||
|
||||
# Per-User Memory
|
||||
|
||||
|
||||
:::tip Run this notebook
|
||||
This recipe is available as an interactive Jupyter notebook.
|
||||
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/02-per-user-memory.ipynb)
|
||||
:::
|
||||
|
||||
|
||||
The simplest pattern: give your agent persistent memory for each user. The agent remembers past conversations, user preferences, and context across sessions.
|
||||
|
||||
## The Problem
|
||||
|
||||
Without memory, every conversation starts from scratch:
|
||||
|
||||
```
|
||||
Session 1: "I prefer dark mode and use Python"
|
||||
Session 2: "What's my preferred language?" → Agent doesn't know
|
||||
```
|
||||
|
||||
## The Solution: One Bank Per User
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ User A Bank │ │ User B Bank │ │ User C Bank │
|
||||
│ │ │ │ │ │
|
||||
│ - Conversations│ │ - Conversations│ │ - Conversations│
|
||||
│ - Preferences │ │ - Preferences │ │ - Preferences │
|
||||
│ - Context │ │ - Context │ │ - Context │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
100% isolated 100% isolated 100% isolated
|
||||
```
|
||||
|
||||
Each user gets their own memory bank. Complete isolation, simple mental model.
|
||||
|
||||
|
||||
```python
|
||||
!pip install hindsight-client nest_asyncio openai python-dotenv -U
|
||||
```
|
||||
|
||||
## 1. Create a Bank When User Signs Up
|
||||
|
||||
|
||||
```python
|
||||
# Jupyter notebooks already run an asyncio event loop. The hindsight client
|
||||
# uses loop.run_until_complete() internally, but Python doesn't allow nested
|
||||
# event loops by default. nest_asyncio patches this to allow nesting.
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from openai import OpenAI as OpenAIClient
|
||||
|
||||
# Load environment variables from .env file
|
||||
# Copy .env.example to .env and fill in your values
|
||||
load_dotenv()
|
||||
|
||||
# Configuration (override with env vars if set)
|
||||
HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
|
||||
HINDSIGHT_UI_URL = os.getenv("HINDSIGHT_UI_URL", "http://localhost:9999")
|
||||
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
client = Hindsight(base_url=HINDSIGHT_API_URL)
|
||||
llm = OpenAIClient() # Uses OPENAI_API_KEY from .env
|
||||
|
||||
def on_user_signup(user_id: str):
|
||||
client.create_bank(
|
||||
bank_id=f"user-{user_id}",
|
||||
name=f"Memory for {user_id}"
|
||||
)
|
||||
print(f"View bank: {HINDSIGHT_UI_URL}/banks/user-{user_id}?view=documents")
|
||||
```
|
||||
|
||||
## 2. Manage Conversation Sessions
|
||||
|
||||
Use `document_id` to group messages belonging to the same conversation. When you retain with the same `document_id`, Hindsight replaces the previous version (upsert behavior), keeping the memory up-to-date as the conversation evolves.
|
||||
|
||||
|
||||
```python
|
||||
import uuid
|
||||
import json
|
||||
|
||||
class ConversationSession:
|
||||
def __init__(self, user_id: str):
|
||||
self.user_id = user_id
|
||||
self.session_id = str(uuid.uuid4()) # Unique ID for this conversation
|
||||
self.messages = []
|
||||
|
||||
def add_message(self, role: str, content: str):
|
||||
self.messages.append({"role": role, "content": content})
|
||||
|
||||
def save(self, client: Hindsight):
|
||||
"""Save the entire conversation. Replaces previous version if session_id exists."""
|
||||
# Convert messages to string format for retain
|
||||
content = "\n".join([f"{m['role']}: {m['content']}" for m in self.messages])
|
||||
client.retain(
|
||||
bank_id=f"user-{self.user_id}",
|
||||
content=content,
|
||||
document_id=self.session_id # Same ID = upsert (replace old version)
|
||||
)
|
||||
```
|
||||
|
||||
## 3. Recall Context Before Responding
|
||||
|
||||
|
||||
```python
|
||||
def get_context(user_id: str, query: str):
|
||||
result = client.recall(
|
||||
bank_id=f"user-{user_id}",
|
||||
query=query
|
||||
)
|
||||
return result.results
|
||||
```
|
||||
|
||||
## 4. Complete Agent Loop
|
||||
|
||||
|
||||
```python
|
||||
def format_results(results):
|
||||
"""Format recall results for the prompt."""
|
||||
if not results:
|
||||
return "No relevant memories found."
|
||||
return "\n".join([f"- {r.text}" for r in results])
|
||||
|
||||
def format_messages(messages):
|
||||
"""Format conversation messages for the prompt."""
|
||||
return "\n".join([f"{m['role']}: {m['content']}" for m in messages])
|
||||
|
||||
def handle_message(session: ConversationSession, user_message: str):
|
||||
# 1. Add user message to session
|
||||
session.add_message("user", user_message)
|
||||
|
||||
# 2. Recall relevant context from past conversations
|
||||
context = client.recall(
|
||||
bank_id=f"user-{session.user_id}",
|
||||
query=user_message
|
||||
)
|
||||
|
||||
# 3. Build system prompt with memory
|
||||
system_prompt = f"""You are a helpful assistant with memory of past conversations.
|
||||
|
||||
## What you remember about this user
|
||||
{format_results(context.results)}
|
||||
|
||||
Respond helpfully and reference relevant memories when appropriate."""
|
||||
|
||||
# 4. Generate response using OpenAI
|
||||
response = llm.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
*[{"role": m["role"], "content": m["content"]} for m in session.messages]
|
||||
]
|
||||
)
|
||||
assistant_response = response.choices[0].message.content
|
||||
|
||||
# 5. Add assistant response to session
|
||||
session.add_message("assistant", assistant_response)
|
||||
|
||||
# 6. Save the updated conversation (upserts based on session_id)
|
||||
session.save(client)
|
||||
|
||||
print(f"User: {user_message}")
|
||||
print(f"Assistant: {assistant_response}\n")
|
||||
|
||||
return assistant_response
|
||||
```
|
||||
|
||||
## 5. Starting a New Conversation
|
||||
|
||||
|
||||
```python
|
||||
# Create the user's bank
|
||||
on_user_signup("alice")
|
||||
|
||||
# Each new conversation gets a new session with a unique ID
|
||||
session = ConversationSession(user_id="alice")
|
||||
|
||||
# Multiple exchanges in the same conversation
|
||||
handle_message(session, "Hi! I'm working on a Python project")
|
||||
handle_message(session, "Can you help me with async/await?")
|
||||
|
||||
# View the stored conversation in the UI.
|
||||
# Each message updates the same document (via document_id), so you'll see
|
||||
# the full conversation history in a single document rather than separate entries.
|
||||
print(f"\nView documents: {HINDSIGHT_UI_URL}/banks/user-alice?view=documents")
|
||||
```
|
||||
|
||||
## How Document ID Works
|
||||
|
||||
The `document_id` parameter is key to managing evolving conversations:
|
||||
|
||||
| Scenario | Behavior |
|
||||
|----------|----------|
|
||||
| First retain with `document_id="session_123"` | Creates new document |
|
||||
| Retain again with same `document_id="session_123"` | **Replaces** previous version (upsert) |
|
||||
| Retain with different `document_id="session_456"` | Creates separate document |
|
||||
| Retain without `document_id` | Creates new document each time |
|
||||
|
||||
This upsert behavior means:
|
||||
- You always retain the **full conversation** state
|
||||
- Facts are re-extracted from the complete conversation
|
||||
- No duplicate or stale facts from old versions
|
||||
- Memory stays consistent as conversations evolve
|
||||
|
||||
## What Gets Remembered
|
||||
|
||||
Hindsight automatically extracts and connects:
|
||||
|
||||
- **Facts**: "User prefers Python", "User is building a CLI tool"
|
||||
- **Entities**: People, projects, technologies mentioned
|
||||
- **Relationships**: How entities relate to each other
|
||||
- **Temporal context**: When things happened
|
||||
|
||||
You don't need to manually extract or structure this - just retain the conversations.
|
||||
|
||||
## When to Use This Pattern
|
||||
|
||||
**Good fit:**
|
||||
- Chatbots and assistants
|
||||
- Personal AI companions
|
||||
- Any 1:1 user-to-agent interaction
|
||||
|
||||
**Consider adding shared knowledge if:**
|
||||
- You have product docs or FAQs to reference
|
||||
- Multiple users need access to the same information
|
||||
- See the Support Agent with Shared Knowledge notebook
|
||||
|
||||
## Cleanup
|
||||
|
||||
Delete the banks created during this notebook:
|
||||
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
# Delete the user-alice bank
|
||||
response = requests.delete(f"{HINDSIGHT_API_URL}/v1/default/banks/user-alice")
|
||||
print(f"Deleted user-alice: {response.json()}")
|
||||
```
|
||||
@@ -0,0 +1,266 @@
|
||||
---
|
||||
sidebar_position: 9
|
||||
---
|
||||
|
||||
# Personal AI Assistant with Hindsight Memory
|
||||
|
||||
|
||||
:::tip Run this notebook
|
||||
This recipe is available as an interactive Jupyter notebook.
|
||||
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/personal_assistant.ipynb)
|
||||
:::
|
||||
|
||||
|
||||
A general-purpose personal assistant that remembers your preferences, schedule, family, work context, and past conversations.
|
||||
|
||||
## Features
|
||||
- Remembers family, work, and personal details
|
||||
- Tracks preferences and habits
|
||||
- Helps with scheduling and reminders
|
||||
- Maintains context across conversations
|
||||
|
||||
## Prerequisites
|
||||
- OpenAI API key
|
||||
- Hindsight running locally via Docker (see setup below)
|
||||
|
||||
## Start Hindsight Locally
|
||||
|
||||
Before running this notebook, start Hindsight in a terminal:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-openai-api-key"
|
||||
|
||||
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=gpt-4o-mini \
|
||||
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
```
|
||||
|
||||
## 1. Install Dependencies
|
||||
|
||||
|
||||
```python
|
||||
!pip install -q hindsight-client openai nest-asyncio
|
||||
```
|
||||
|
||||
## 2. Configure OpenAI API Key
|
||||
|
||||
Enter your OpenAI API key when prompted (used by both Hindsight and the demo).
|
||||
|
||||
|
||||
```python
|
||||
import getpass
|
||||
import os
|
||||
|
||||
# Set OpenAI API key (used by both Hindsight and the demo)
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter your OpenAI API key: ")
|
||||
|
||||
print("API key configured!")
|
||||
```
|
||||
|
||||
## 3. Initialize Clients
|
||||
|
||||
|
||||
```python
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
|
||||
from datetime import datetime
|
||||
from openai import OpenAI
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
# Initialize Hindsight client (connects to local Docker instance)
|
||||
hindsight = Hindsight(
|
||||
base_url=os.getenv("HINDSIGHT_BASE_URL", "http://localhost:8888"),
|
||||
)
|
||||
|
||||
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||
|
||||
USER_ID = "assistant-user-demo"
|
||||
|
||||
print("Clients initialized!")
|
||||
```
|
||||
|
||||
## 4. Define Helper Functions
|
||||
|
||||
|
||||
```python
|
||||
def remember(info: str, category: str = "general") -> str:
|
||||
"""Store information to remember."""
|
||||
today = datetime.now().strftime("%B %d, %Y")
|
||||
|
||||
hindsight.retain(
|
||||
bank_id=USER_ID,
|
||||
content=f"{today}: {info}",
|
||||
metadata={"category": category, "date": today},
|
||||
)
|
||||
|
||||
return f"I'll remember: {info}"
|
||||
|
||||
|
||||
def recall_context(query: str) -> str:
|
||||
"""Recall relevant memories for context."""
|
||||
memories = hindsight.recall(
|
||||
bank_id=USER_ID,
|
||||
query=query,
|
||||
budget="high",
|
||||
)
|
||||
|
||||
if memories and memories.results:
|
||||
return "\n".join(f"- {m.text}" for m in memories.results[:8])
|
||||
return ""
|
||||
|
||||
|
||||
def chat(user_message: str) -> str:
|
||||
"""Chat with the personal assistant."""
|
||||
context = recall_context(user_message)
|
||||
|
||||
system_prompt = f"""You are a helpful personal AI assistant with long-term memory.
|
||||
You remember the user's preferences, schedule, family, work context, and past conversations.
|
||||
|
||||
What you remember about this user:
|
||||
{context if context else "No memories recorded yet."}
|
||||
|
||||
Your capabilities:
|
||||
- Remember things when asked ("Remember that...", "Don't forget...")
|
||||
- Recall past information ("What did I tell you about...", "When is...")
|
||||
- Provide personalized suggestions based on known preferences
|
||||
- Help with scheduling and reminders
|
||||
- Have natural conversations while maintaining context
|
||||
|
||||
Be helpful, proactive, and reference relevant memories naturally."""
|
||||
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
temperature=0.7,
|
||||
max_tokens=500,
|
||||
)
|
||||
|
||||
answer = response.choices[0].message.content
|
||||
|
||||
# Check if user is asking to remember something
|
||||
lower_msg = user_message.lower()
|
||||
if any(phrase in lower_msg for phrase in ["remember that", "don't forget", "remind me", "note that"]):
|
||||
hindsight.retain(
|
||||
bank_id=USER_ID,
|
||||
content=f"User asked to remember: {user_message}",
|
||||
metadata={"category": "reminder"},
|
||||
)
|
||||
|
||||
# Store the interaction
|
||||
hindsight.retain(
|
||||
bank_id=USER_ID,
|
||||
content=f"Conversation - User: {user_message[:100]} | Assistant: {answer[:100]}",
|
||||
metadata={"category": "conversation"},
|
||||
)
|
||||
|
||||
return answer
|
||||
|
||||
|
||||
def get_summary(topic: str = None) -> str:
|
||||
"""Get a summary of memories."""
|
||||
query = f"Summarize what you know about {topic}" if topic else \
|
||||
"Summarize everything you know about this user"
|
||||
|
||||
summary = hindsight.reflect(
|
||||
bank_id=USER_ID,
|
||||
query=query,
|
||||
budget="high",
|
||||
)
|
||||
return summary.text if hasattr(summary, 'text') else str(summary)
|
||||
|
||||
print("Helper functions defined!")
|
||||
```
|
||||
|
||||
## 5. Build Context
|
||||
|
||||
|
||||
```python
|
||||
print("Building context...")
|
||||
|
||||
initial_context = [
|
||||
("My name is Alex and I work as a product manager at TechCorp", "personal"),
|
||||
("My wife's name is Sarah and we have two kids: Emma (7) and Jack (4)", "family"),
|
||||
("I prefer morning meetings and try to keep afternoons for deep work", "preference"),
|
||||
("My mom's birthday is March 15th", "event"),
|
||||
("I'm trying to read more - currently reading 'Atomic Habits'", "hobby"),
|
||||
("I have a weekly team standup every Monday at 10am", "schedule"),
|
||||
("I'm allergic to cats", "health"),
|
||||
("My favorite coffee is a flat white with oat milk", "preference"),
|
||||
("I'm training for a half marathon in April", "goal"),
|
||||
]
|
||||
|
||||
for info, category in initial_context:
|
||||
result = remember(info, category)
|
||||
print(f" {result}")
|
||||
```
|
||||
|
||||
## 6. Have a Conversation
|
||||
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
print("=" * 60)
|
||||
print(" Conversation")
|
||||
print("=" * 60)
|
||||
|
||||
conversations = [
|
||||
"Hey, what's my wife's name again?",
|
||||
"Remember that my Q1 review is next Thursday at 2pm",
|
||||
"I need a gift idea for my mom's birthday",
|
||||
"What time is my Monday standup?",
|
||||
"Can you recommend a coffee order for me?",
|
||||
"What books am I reading?",
|
||||
]
|
||||
|
||||
for message in conversations:
|
||||
print(f"\nAlex: {message}")
|
||||
print("-" * 40)
|
||||
response = chat(message)
|
||||
print(f"Assistant: {response}")
|
||||
time.sleep(1)
|
||||
```
|
||||
|
||||
## 7. View Summary
|
||||
|
||||
|
||||
```python
|
||||
print("=" * 60)
|
||||
print(" What I Know About You")
|
||||
print("=" * 60)
|
||||
print(get_summary())
|
||||
```
|
||||
|
||||
|
||||
```python
|
||||
print("=" * 60)
|
||||
print(" Your Family")
|
||||
print("=" * 60)
|
||||
print(get_summary("family"))
|
||||
```
|
||||
|
||||
## 8. Try Your Own Message
|
||||
|
||||
|
||||
```python
|
||||
your_message = "What should I focus on this month with my training?" # Change this!
|
||||
|
||||
print(f"You: {your_message}")
|
||||
print("-" * 40)
|
||||
print(f"Assistant: {chat(your_message)}")
|
||||
```
|
||||
|
||||
## 9. Cleanup
|
||||
|
||||
|
||||
```python
|
||||
hindsight.close()
|
||||
print("Client connection closed.")
|
||||
```
|
||||
@@ -0,0 +1,299 @@
|
||||
---
|
||||
sidebar_position: 10
|
||||
---
|
||||
|
||||
# Personalized Search Agent with Hindsight Memory
|
||||
|
||||
|
||||
:::tip Run this notebook
|
||||
This recipe is available as an interactive Jupyter notebook.
|
||||
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/personalized_search.ipynb)
|
||||
:::
|
||||
|
||||
|
||||
A search assistant that learns your preferences, location, dietary needs, and lifestyle to provide contextually relevant search results.
|
||||
|
||||
## Features
|
||||
- Learns location, dietary restrictions, and lifestyle
|
||||
- Personalizes search queries based on context
|
||||
- Remembers past searches and preferences
|
||||
- Integrates with Tavily for real web search (optional)
|
||||
|
||||
## Prerequisites
|
||||
- OpenAI API key
|
||||
- Hindsight running locally via Docker (see setup below)
|
||||
- Tavily API key (optional, for real web search)
|
||||
|
||||
## Start Hindsight Locally
|
||||
|
||||
Before running this notebook, start Hindsight in a terminal:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-openai-api-key"
|
||||
|
||||
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=gpt-4o-mini \
|
||||
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
```
|
||||
|
||||
## 1. Install Dependencies
|
||||
|
||||
|
||||
```python
|
||||
# Tavily is optional - demo works with simulated results if not installed
|
||||
!pip install -q hindsight-client openai tavily-python nest-asyncio
|
||||
```
|
||||
|
||||
## 2. Configure API Keys
|
||||
|
||||
Enter your API keys when prompted. Tavily is optional - press Enter to skip for simulated search results.
|
||||
|
||||
|
||||
```python
|
||||
import getpass
|
||||
import os
|
||||
|
||||
# Set OpenAI API key (used by both Hindsight and the demo)
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter your OpenAI API key: ")
|
||||
|
||||
# Tavily is optional - for real web search
|
||||
if not os.getenv("TAVILY_API_KEY"):
|
||||
tavily_key = getpass.getpass("Enter your Tavily API key (or press Enter to skip): ")
|
||||
if tavily_key:
|
||||
os.environ["TAVILY_API_KEY"] = tavily_key
|
||||
|
||||
print("API keys configured!")
|
||||
```
|
||||
|
||||
## 3. Initialize Clients
|
||||
|
||||
|
||||
```python
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
|
||||
from openai import OpenAI
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
# Initialize Hindsight client (connects to local Docker instance)
|
||||
hindsight = Hindsight(
|
||||
base_url=os.getenv("HINDSIGHT_BASE_URL", "http://localhost:8888"),
|
||||
)
|
||||
|
||||
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||
|
||||
# Optional: Tavily for real web search
|
||||
try:
|
||||
from tavily import TavilyClient
|
||||
tavily = TavilyClient(api_key=os.getenv("TAVILY_API_KEY"))
|
||||
HAS_TAVILY = True
|
||||
print("Tavily configured - using real web search!")
|
||||
except (ImportError, Exception) as e:
|
||||
HAS_TAVILY = False
|
||||
print("Note: Using simulated search results (Tavily not configured)")
|
||||
|
||||
USER_ID = "search-user-demo"
|
||||
|
||||
print("Clients initialized!")
|
||||
```
|
||||
|
||||
## 4. Define Helper Functions
|
||||
|
||||
|
||||
```python
|
||||
def store_preference(preference: str) -> str:
|
||||
"""Store a user preference."""
|
||||
hindsight.retain(
|
||||
bank_id=USER_ID,
|
||||
content=f"User preference: {preference}",
|
||||
metadata={"category": "preference"},
|
||||
)
|
||||
return f"Learned: {preference}"
|
||||
|
||||
|
||||
def store_interaction(query: str, response: str) -> None:
|
||||
"""Store a search interaction."""
|
||||
hindsight.retain(
|
||||
bank_id=USER_ID,
|
||||
content=f"Search query: {query}\nResult highlights: {response[:200]}",
|
||||
metadata={"category": "search_history"},
|
||||
)
|
||||
|
||||
|
||||
def get_user_context(query: str) -> str:
|
||||
"""Retrieve relevant user context."""
|
||||
memories = hindsight.recall(
|
||||
bank_id=USER_ID,
|
||||
query=f"preferences location dietary lifestyle {query}",
|
||||
budget="mid",
|
||||
)
|
||||
|
||||
if memories and memories.results:
|
||||
return "\n".join(f"- {m.text}" for m in memories.results[:6])
|
||||
return ""
|
||||
|
||||
|
||||
def personalized_search(query: str) -> str:
|
||||
"""Perform a personalized search."""
|
||||
user_context = get_user_context(query)
|
||||
|
||||
enhancement_prompt = f"""Given this user's preferences and the search query, suggest how to enhance the search.
|
||||
|
||||
User preferences:
|
||||
{user_context if user_context else "No preferences recorded yet."}
|
||||
|
||||
Search query: {query}
|
||||
|
||||
Return a JSON object with:
|
||||
- "enhanced_query": The improved search query incorporating relevant preferences
|
||||
- "filters": Any specific filters to apply (e.g., "vegetarian", "within 5 miles")
|
||||
- "reasoning": Brief explanation of personalizations applied"""
|
||||
|
||||
enhancement = openai_client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": enhancement_prompt}],
|
||||
temperature=0.3,
|
||||
max_tokens=300,
|
||||
)
|
||||
|
||||
enhanced_info = enhancement.choices[0].message.content
|
||||
|
||||
# Perform the search
|
||||
if HAS_TAVILY:
|
||||
search_results = tavily.search(
|
||||
query=query,
|
||||
search_depth="advanced",
|
||||
max_results=5,
|
||||
)
|
||||
results_text = "\n".join(
|
||||
f"- {r['title']}: {r['content'][:150]}..."
|
||||
for r in search_results.get('results', [])
|
||||
)
|
||||
else:
|
||||
results_text = f"[Simulated search results for: {query}]"
|
||||
|
||||
response_prompt = f"""Based on the search results and user preferences, provide a personalized summary.
|
||||
|
||||
User preferences:
|
||||
{user_context if user_context else "No preferences recorded yet."}
|
||||
|
||||
Query: {query}
|
||||
|
||||
Search enhancement applied:
|
||||
{enhanced_info}
|
||||
|
||||
Search results:
|
||||
{results_text}
|
||||
|
||||
Provide a helpful, personalized response that takes into account their preferences."""
|
||||
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[{"role": "user", "content": response_prompt}],
|
||||
temperature=0.7,
|
||||
max_tokens=500,
|
||||
)
|
||||
|
||||
answer = response.choices[0].message.content
|
||||
store_interaction(query, answer)
|
||||
|
||||
return answer
|
||||
|
||||
|
||||
def get_preference_profile() -> str:
|
||||
"""Get a summary of the user's preference profile."""
|
||||
profile = hindsight.reflect(
|
||||
bank_id=USER_ID,
|
||||
query="""Summarize what we know about this user:
|
||||
- Location and neighborhood
|
||||
- Dietary preferences and restrictions
|
||||
- Work style and schedule
|
||||
- Hobbies and interests
|
||||
- Family situation
|
||||
- Shopping preferences""",
|
||||
budget="high",
|
||||
)
|
||||
return profile.text if hasattr(profile, 'text') else str(profile)
|
||||
|
||||
print("Helper functions defined!")
|
||||
```
|
||||
|
||||
## 5. Build User Profile
|
||||
|
||||
|
||||
```python
|
||||
print("Learning user preferences...")
|
||||
|
||||
preferences = [
|
||||
"Lives in San Francisco, Mission District",
|
||||
"Works remotely as a software engineer",
|
||||
"Vegetarian, prefers organic food when possible",
|
||||
"Has a 5-year-old daughter named Emma",
|
||||
"Enjoys hiking and outdoor activities on weekends",
|
||||
"Prefers quiet coffee shops for remote work",
|
||||
"Lactose intolerant, uses oat milk",
|
||||
"Interested in sustainable and eco-friendly products",
|
||||
"Usually free on Tuesday and Thursday afternoons",
|
||||
"Husband is allergic to nuts",
|
||||
]
|
||||
|
||||
for pref in preferences:
|
||||
result = store_preference(pref)
|
||||
print(f" {result}")
|
||||
```
|
||||
|
||||
## 6. Personalized Search Results
|
||||
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
print("=" * 60)
|
||||
print(" Personalized Search Results")
|
||||
print("=" * 60)
|
||||
|
||||
searches = [
|
||||
"Find a good coffee shop for working remotely",
|
||||
"Restaurant recommendations for a family dinner",
|
||||
"Birthday gift ideas for a 5-year-old",
|
||||
]
|
||||
|
||||
for query in searches:
|
||||
print(f"\nSearch: {query}")
|
||||
print("-" * 40)
|
||||
result = personalized_search(query)
|
||||
print(result)
|
||||
time.sleep(1)
|
||||
```
|
||||
|
||||
## 7. View Preference Profile
|
||||
|
||||
|
||||
```python
|
||||
print("=" * 60)
|
||||
print(" User Preference Profile")
|
||||
print("=" * 60)
|
||||
print(get_preference_profile())
|
||||
```
|
||||
|
||||
## 8. Try Your Own Search
|
||||
|
||||
|
||||
```python
|
||||
your_search = "Best hiking trails near me" # Change this!
|
||||
|
||||
print(f"Search: {your_search}")
|
||||
print("-" * 40)
|
||||
print(personalized_search(your_search))
|
||||
```
|
||||
|
||||
## 9. Cleanup
|
||||
|
||||
|
||||
```python
|
||||
hindsight.close()
|
||||
print("Client connection closed.")
|
||||
```
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
---
|
||||
|
||||
# Hindsight Quickstart
|
||||
|
||||
|
||||
:::tip Run this notebook
|
||||
This recipe is available as an interactive Jupyter notebook.
|
||||
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/01-quickstart.ipynb)
|
||||
:::
|
||||
|
||||
|
||||
This notebook covers the basics of using Hindsight:
|
||||
- **Retain**: Store information in memory
|
||||
- **Recall**: Retrieve memories matching a query
|
||||
- **Reflect**: Generate insights from memories
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Make sure you have Hindsight running. The easiest way is via Docker:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=your-key
|
||||
|
||||
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
|
||||
|
||||
## Installation
|
||||
|
||||
Install the Hindsight Python client:
|
||||
|
||||
|
||||
```python
|
||||
!pip install hindsight-client nest_asyncio python-dotenv -U
|
||||
```
|
||||
|
||||
## Connect to Hindsight
|
||||
|
||||
|
||||
```python
|
||||
# Jupyter notebooks already run an asyncio event loop. The hindsight client
|
||||
# uses loop.run_until_complete() internally, but Python doesn't allow nested
|
||||
# event loops by default. nest_asyncio patches this to allow nesting.
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
# Copy .env.example to .env and fill in your values
|
||||
load_dotenv()
|
||||
|
||||
# Configuration (override with env vars if set)
|
||||
HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
|
||||
HINDSIGHT_UI_URL = os.getenv("HINDSIGHT_UI_URL", "http://localhost:9999")
|
||||
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
client = Hindsight(base_url=HINDSIGHT_API_URL)
|
||||
```
|
||||
|
||||
## Retain: Store Information
|
||||
|
||||
The `retain` operation is used to push new memories into Hindsight. It tells Hindsight to _retain_ the information you pass in.
|
||||
|
||||
Behind the scenes, the retain operation uses an LLM to extract key facts, temporal data, entities, and relationships.
|
||||
|
||||
|
||||
```python
|
||||
# Simple retain
|
||||
client.retain(
|
||||
bank_id="my-bank",
|
||||
content="Alice works at Google as a software engineer"
|
||||
)
|
||||
|
||||
# View the stored document in the UI:
|
||||
print(f"View documents: {HINDSIGHT_UI_URL}/banks/my-bank?view=documents")
|
||||
```
|
||||
|
||||
|
||||
```python
|
||||
# Retain with context and timestamp
|
||||
client.retain(
|
||||
bank_id="my-bank",
|
||||
content="Alice got promoted to senior engineer",
|
||||
context="career update",
|
||||
timestamp="2025-06-15T10:00:00Z"
|
||||
)
|
||||
```
|
||||
|
||||
## Recall: Retrieve Memories
|
||||
|
||||
The `recall` operation retrieves memories matching a query. It performs 4 retrieval strategies in parallel:
|
||||
- **Semantic**: Vector similarity
|
||||
- **Keyword**: BM25 exact matching
|
||||
- **Graph**: Entity/temporal/causal links
|
||||
- **Temporal**: Time range filtering
|
||||
|
||||
|
||||
```python
|
||||
# Simple recall
|
||||
results = client.recall(bank_id="my-bank", query="What does Alice do?")
|
||||
|
||||
print("Memories:")
|
||||
for r in results.results:
|
||||
print(f" - {r.text}")
|
||||
```
|
||||
|
||||
|
||||
```python
|
||||
# Temporal recall
|
||||
results = client.recall(bank_id="my-bank", query="What happened in June?")
|
||||
|
||||
print("Memories:")
|
||||
for r in results.results:
|
||||
print(f" - {r.text}")
|
||||
```
|
||||
|
||||
## Reflect: Generate Insights
|
||||
|
||||
The `reflect` operation performs a more thorough analysis of existing memories. This allows the agent to form new connections between memories which are then persisted as opinions and/or observations.
|
||||
|
||||
Example use cases:
|
||||
- An AI Project Manager reflecting on what risks need to be mitigated
|
||||
- A Sales Agent reflecting on why certain outreach messages have gotten responses
|
||||
- A Support Agent reflecting on opportunities where customers have unanswered questions
|
||||
|
||||
|
||||
```python
|
||||
response = client.reflect(bank_id="my-bank", query="What should I know about Alice?")
|
||||
print(response)
|
||||
```
|
||||
|
||||
## Memory Types
|
||||
|
||||
Hindsight organizes memory into four networks to mimic human memory:
|
||||
|
||||
- **World**: Facts about the world ("The stove gets hot")
|
||||
- **Experiences**: Agent's own experiences ("I touched the stove and it really hurt")
|
||||
- **Opinion**: Beliefs with confidence scores ("I shouldn't touch the stove again" - .99 confidence)
|
||||
- **Observation**: Complex mental models derived by reflecting on facts and experiences
|
||||
|
||||
## Cleanup
|
||||
|
||||
Delete the bank created during this notebook:
|
||||
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
response = requests.delete(f"{HINDSIGHT_API_URL}/v1/default/banks/my-bank")
|
||||
print(f"Deleted my-bank: {response.json()}")
|
||||
```
|
||||
@@ -0,0 +1,335 @@
|
||||
---
|
||||
sidebar_position: 11
|
||||
---
|
||||
|
||||
# Study Buddy with Hindsight Memory
|
||||
|
||||
|
||||
:::tip Run this notebook
|
||||
This recipe is available as an interactive Jupyter notebook.
|
||||
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/study_buddy.ipynb)
|
||||
:::
|
||||
|
||||
|
||||
A personalized study assistant that tracks what you've learned, identifies knowledge gaps, and helps with spaced repetition.
|
||||
|
||||
## Features
|
||||
- Tracks study sessions and topics covered
|
||||
- Monitors confidence levels per topic
|
||||
- Identifies knowledge gaps
|
||||
- Suggests topics for spaced repetition review
|
||||
|
||||
## Prerequisites
|
||||
- OpenAI API key
|
||||
- Hindsight running locally via Docker (see setup below)
|
||||
|
||||
## Start Hindsight Locally
|
||||
|
||||
Before running this notebook, start Hindsight in a terminal:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-openai-api-key"
|
||||
|
||||
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=gpt-4o-mini \
|
||||
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
```
|
||||
|
||||
## 1. Install Dependencies
|
||||
|
||||
|
||||
```python
|
||||
!pip install -q hindsight-client openai nest-asyncio
|
||||
```
|
||||
|
||||
## 2. Configure OpenAI API Key
|
||||
|
||||
Enter your OpenAI API key when prompted (used by both Hindsight and the demo).
|
||||
|
||||
|
||||
```python
|
||||
import getpass
|
||||
import os
|
||||
|
||||
# Set OpenAI API key (used by both Hindsight and the demo)
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
os.environ["OPENAI_API_KEY"] = getpass.getpass("Enter your OpenAI API key: ")
|
||||
|
||||
print("API key configured!")
|
||||
```
|
||||
|
||||
## 3. Initialize Clients
|
||||
|
||||
|
||||
```python
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
|
||||
from datetime import datetime
|
||||
from openai import OpenAI
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
# Initialize Hindsight client (connects to local Docker instance)
|
||||
hindsight = Hindsight(
|
||||
base_url=os.getenv("HINDSIGHT_BASE_URL", "http://localhost:8888"),
|
||||
)
|
||||
|
||||
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
|
||||
|
||||
USER_ID = "student-demo"
|
||||
|
||||
print("Clients initialized!")
|
||||
```
|
||||
|
||||
## 4. Define Helper Functions
|
||||
|
||||
|
||||
```python
|
||||
def record_study_session(topic: str, notes: str, confidence: str = "medium") -> str:
|
||||
"""Record a study session with topic, notes, and self-assessed confidence."""
|
||||
today = datetime.now().strftime("%B %d, %Y")
|
||||
|
||||
content = f"""{today} - STUDY SESSION
|
||||
Topic: {topic}
|
||||
Confidence Level: {confidence}
|
||||
Notes: {notes}"""
|
||||
|
||||
hindsight.retain(
|
||||
bank_id=USER_ID,
|
||||
content=content,
|
||||
metadata={
|
||||
"category": "study_session",
|
||||
"topic": topic,
|
||||
"confidence": confidence,
|
||||
"date": today,
|
||||
},
|
||||
)
|
||||
|
||||
return f"Recorded study session on '{topic}' (confidence: {confidence})"
|
||||
|
||||
|
||||
def record_question(topic: str, question: str, understood: bool) -> str:
|
||||
"""Record a question asked during study."""
|
||||
today = datetime.now().strftime("%B %d, %Y")
|
||||
|
||||
content = f"""{today} - QUESTION
|
||||
Topic: {topic}
|
||||
Question: {question}
|
||||
Understood: {"Yes" if understood else "No - needs review"}"""
|
||||
|
||||
hindsight.retain(
|
||||
bank_id=USER_ID,
|
||||
content=content,
|
||||
metadata={
|
||||
"category": "question",
|
||||
"topic": topic,
|
||||
"understood": str(understood),
|
||||
},
|
||||
)
|
||||
|
||||
return f"Recorded question on '{topic}'"
|
||||
|
||||
|
||||
def study_buddy(user_query: str) -> str:
|
||||
"""Interact with the study buddy."""
|
||||
memories = hindsight.recall(
|
||||
bank_id=USER_ID,
|
||||
query=f"study session topic notes questions {user_query}",
|
||||
budget="high",
|
||||
)
|
||||
|
||||
memory_context = ""
|
||||
if memories and memories.results:
|
||||
memory_context = "\n".join(f"- {m.text}" for m in memories.results[:8])
|
||||
|
||||
system_prompt = f"""You are a helpful study buddy and tutor.
|
||||
You have access to the student's study history, including:
|
||||
- Topics they've studied and their notes
|
||||
- Their self-assessed confidence levels
|
||||
- Questions they've asked and whether they understood the answers
|
||||
|
||||
Study History:
|
||||
{memory_context if memory_context else "No study history recorded yet."}
|
||||
|
||||
Your role:
|
||||
1. Answer questions about topics they're studying
|
||||
2. Identify knowledge gaps based on their history
|
||||
3. Suggest topics to review (spaced repetition)
|
||||
4. Provide encouragement and study tips
|
||||
5. Connect new concepts to things they've already learned
|
||||
|
||||
Be supportive and pedagogical. Reference their previous learning when relevant."""
|
||||
|
||||
response = openai_client.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_query},
|
||||
],
|
||||
temperature=0.7,
|
||||
max_tokens=800,
|
||||
)
|
||||
|
||||
answer = response.choices[0].message.content
|
||||
|
||||
hindsight.retain(
|
||||
bank_id=USER_ID,
|
||||
content=f"Student asked: {user_query}\nExplanation given: {answer[:300]}...",
|
||||
metadata={"category": "tutoring"},
|
||||
)
|
||||
|
||||
return answer
|
||||
|
||||
|
||||
def get_review_suggestions() -> str:
|
||||
"""Get suggestions for topics to review."""
|
||||
suggestions = hindsight.reflect(
|
||||
bank_id=USER_ID,
|
||||
query="""Analyze this student's study history and suggest:
|
||||
1. Topics with low confidence that need more review
|
||||
2. Topics studied a while ago that should be revisited
|
||||
3. Questions that weren't fully understood
|
||||
4. Connections between topics they might have missed
|
||||
|
||||
Prioritize by what would most improve their understanding.""",
|
||||
budget="high",
|
||||
)
|
||||
return suggestions.text if hasattr(suggestions, 'text') else str(suggestions)
|
||||
|
||||
|
||||
def get_knowledge_summary(topic: str = None) -> str:
|
||||
"""Get a summary of what the student knows."""
|
||||
query = f"Summarize what this student knows about {topic}" if topic else \
|
||||
"Summarize this student's overall knowledge and progress"
|
||||
|
||||
summary = hindsight.reflect(
|
||||
bank_id=USER_ID,
|
||||
query=query,
|
||||
budget="high",
|
||||
)
|
||||
return summary.text if hasattr(summary, 'text') else str(summary)
|
||||
|
||||
print("Helper functions defined!")
|
||||
```
|
||||
|
||||
## 5. Record Study Sessions
|
||||
|
||||
|
||||
```python
|
||||
print("Recording study sessions...")
|
||||
|
||||
sessions = [
|
||||
{
|
||||
"topic": "Classical Mechanics - Newton's Laws",
|
||||
"notes": "Covered F=ma, action-reaction pairs, inertia. Solved problems on inclined planes.",
|
||||
"confidence": "high",
|
||||
},
|
||||
{
|
||||
"topic": "Classical Mechanics - Conservation of Momentum",
|
||||
"notes": "Elastic vs inelastic collisions. Struggled with 2D collision problems.",
|
||||
"confidence": "low",
|
||||
},
|
||||
{
|
||||
"topic": "Classical Mechanics - Generalized Coordinates",
|
||||
"notes": "Introduction to Lagrangian mechanics. Degrees of freedom concept.",
|
||||
"confidence": "medium",
|
||||
},
|
||||
{
|
||||
"topic": "Waves - Simple Harmonic Motion",
|
||||
"notes": "SHM equations, period, frequency. Connected to springs and pendulums.",
|
||||
"confidence": "high",
|
||||
},
|
||||
{
|
||||
"topic": "Waves - Frequency Domain",
|
||||
"notes": "Started Fourier transforms. Math is confusing, need more practice.",
|
||||
"confidence": "low",
|
||||
},
|
||||
]
|
||||
|
||||
for session in sessions:
|
||||
result = record_study_session(**session)
|
||||
print(f" {result}")
|
||||
```
|
||||
|
||||
## 6. Record Questions
|
||||
|
||||
|
||||
```python
|
||||
print("Recording questions...")
|
||||
|
||||
questions = [
|
||||
("Conservation of Momentum", "Why is momentum conserved in collisions?", True),
|
||||
("Conservation of Momentum", "How do I solve 2D collision problems?", False),
|
||||
("Generalized Coordinates", "What's the advantage of Lagrangian over Newtonian?", True),
|
||||
("Frequency Domain", "When do I use Fourier transforms vs Laplace?", False),
|
||||
]
|
||||
|
||||
for topic, question, understood in questions:
|
||||
result = record_question(topic, question, understood)
|
||||
print(f" {result}")
|
||||
```
|
||||
|
||||
## 7. Interactive Study Session
|
||||
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
print("=" * 60)
|
||||
print(" Study Session")
|
||||
print("=" * 60)
|
||||
|
||||
queries = [
|
||||
"Can you explain generalized coordinates again? I remember we covered it but I'm fuzzy on the details.",
|
||||
"What topics should I review before my exam next week?",
|
||||
"I'm still confused about 2D collision problems. Can you walk me through an example?",
|
||||
]
|
||||
|
||||
for query in queries:
|
||||
print(f"\nStudent: {query}")
|
||||
print("-" * 40)
|
||||
response = study_buddy(query)
|
||||
print(f"Study Buddy: {response}")
|
||||
time.sleep(1)
|
||||
```
|
||||
|
||||
## 8. Get Review Suggestions
|
||||
|
||||
|
||||
```python
|
||||
print("=" * 60)
|
||||
print(" Recommended Review Topics")
|
||||
print("=" * 60)
|
||||
print(get_review_suggestions())
|
||||
```
|
||||
|
||||
## 9. Knowledge Summary
|
||||
|
||||
|
||||
```python
|
||||
print("=" * 60)
|
||||
print(" Knowledge Summary")
|
||||
print("=" * 60)
|
||||
print(get_knowledge_summary())
|
||||
```
|
||||
|
||||
## 10. Try Your Own Question
|
||||
|
||||
|
||||
```python
|
||||
your_question = "What are my biggest knowledge gaps right now?" # Change this!
|
||||
|
||||
print(f"You: {your_question}")
|
||||
print("-" * 40)
|
||||
print(f"Study Buddy: {study_buddy(your_question)}")
|
||||
```
|
||||
|
||||
## 11. Cleanup
|
||||
|
||||
|
||||
```python
|
||||
hindsight.close()
|
||||
print("Client connection closed.")
|
||||
```
|
||||
@@ -0,0 +1,315 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
# Support Agent with Shared Knowledge
|
||||
|
||||
|
||||
:::tip Run this notebook
|
||||
This recipe is available as an interactive Jupyter notebook.
|
||||
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/03-support-agent-shared-knowledge.ipynb)
|
||||
:::
|
||||
|
||||
|
||||
This pattern shows how to build a support agent that combines **per-user memory** with **shared product knowledge** (RAG), giving users personalized support while leveraging a single source of truth for documentation.
|
||||
|
||||
## The Problem
|
||||
|
||||
You're building a support agent that needs to:
|
||||
- Remember each user's history, preferences, and past issues
|
||||
- Access shared product documentation
|
||||
- Keep user data completely isolated from other users
|
||||
|
||||
A naive approach would index product docs into each user's memory bank, but this is expensive and wasteful (N copies for N users).
|
||||
|
||||
## The Solution: Multi-Bank Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ User A Bank │ │ User B Bank │ │ Shared Docs │
|
||||
│ │ │ │ │ Bank │
|
||||
│ - Conversations│ │ - Conversations│ │ │
|
||||
│ - Preferences │ │ - Preferences │ │ - Product docs │
|
||||
│ - Past issues │ │ - Past issues │ │ - FAQs │
|
||||
│ - Solutions │ │ - Solutions │ │ - Guides │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
└───────────────────────┴───────────────────────┘
|
||||
│
|
||||
Agent queries
|
||||
multiple banks
|
||||
```
|
||||
|
||||
**Key benefits:**
|
||||
- Product docs indexed once, shared by all users
|
||||
- User memory is 100% isolated
|
||||
- Simple mental model, no complex filtering
|
||||
|
||||
|
||||
```python
|
||||
!pip install hindsight-client nest_asyncio openai python-dotenv -U
|
||||
```
|
||||
|
||||
## 1. Set Up Memory Banks
|
||||
|
||||
Create three types of banks:
|
||||
|
||||
|
||||
```python
|
||||
# Jupyter notebooks already run an asyncio event loop. The hindsight client
|
||||
# uses loop.run_until_complete() internally, but Python doesn't allow nested
|
||||
# event loops by default. nest_asyncio patches this to allow nesting.
|
||||
import nest_asyncio
|
||||
nest_asyncio.apply()
|
||||
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from openai import OpenAI as OpenAIClient
|
||||
|
||||
# Load environment variables from .env file
|
||||
# Copy .env.example to .env and fill in your values
|
||||
load_dotenv()
|
||||
|
||||
# Configuration (override with env vars if set)
|
||||
HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
|
||||
HINDSIGHT_UI_URL = os.getenv("HINDSIGHT_UI_URL", "http://localhost:9999")
|
||||
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
client = Hindsight(base_url=HINDSIGHT_API_URL)
|
||||
llm = OpenAIClient() # Uses OPENAI_API_KEY from .env
|
||||
|
||||
# Shared knowledge bank (created once)
|
||||
shared_bank = client.create_bank(
|
||||
bank_id="product-docs",
|
||||
name="Product Documentation"
|
||||
)
|
||||
|
||||
# Per-user banks (created when user signs up)
|
||||
def create_user_bank(user_id: str):
|
||||
return client.create_bank(
|
||||
bank_id=f"user-{user_id}",
|
||||
name=f"Memory for {user_id}"
|
||||
)
|
||||
```
|
||||
|
||||
## 2. Index Product Documentation
|
||||
|
||||
Index your product docs into the shared bank (do this once, or on doc updates):
|
||||
|
||||
|
||||
```python
|
||||
# Index product documentation - retain each doc separately
|
||||
client.retain(
|
||||
bank_id="product-docs",
|
||||
content="# Pricing Tiers\n\nBasic: $10/mo, Pro: $25/mo, Enterprise: Contact us"
|
||||
)
|
||||
|
||||
client.retain(
|
||||
bank_id="product-docs",
|
||||
content="# Getting Started\n\nTo set up your account, visit the dashboard and click 'New Project'"
|
||||
)
|
||||
|
||||
# View the stored documents in the UI:
|
||||
print(f"View documents: {HINDSIGHT_UI_URL}/banks/product-docs?view=documents")
|
||||
```
|
||||
|
||||
## 3. Store User Conversations
|
||||
|
||||
After each support interaction, retain it in the user's bank:
|
||||
|
||||
|
||||
```python
|
||||
def save_conversation(user_id: str, messages: list):
|
||||
# Convert messages to string format
|
||||
content = "\n".join([f"{m['role']}: {m['content']}" for m in messages])
|
||||
client.retain(
|
||||
bank_id=f"user-{user_id}",
|
||||
content=content
|
||||
)
|
||||
```
|
||||
|
||||
## 4. Query Multiple Banks at Support Time
|
||||
|
||||
When handling a user query, retrieve context from both banks:
|
||||
|
||||
|
||||
```python
|
||||
def get_support_context(user_id: str, query: str):
|
||||
# Get user's personal context
|
||||
user_context = client.recall(
|
||||
bank_id=f"user-{user_id}",
|
||||
query=query
|
||||
)
|
||||
|
||||
# Get relevant product documentation
|
||||
docs_context = client.recall(
|
||||
bank_id="product-docs",
|
||||
query=query
|
||||
)
|
||||
|
||||
return {
|
||||
"user_history": user_context.results,
|
||||
"documentation": docs_context.results
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Build the Agent Prompt
|
||||
|
||||
Combine both contexts in your agent's prompt:
|
||||
|
||||
|
||||
```python
|
||||
def format_results(results):
|
||||
"""Format recall results for the prompt."""
|
||||
if not results:
|
||||
return "No relevant information found."
|
||||
return "\n".join([f"- {r.text}" for r in results])
|
||||
|
||||
def build_prompt(query: str, context: dict) -> str:
|
||||
return f"""You are a helpful support agent.
|
||||
|
||||
## User's History
|
||||
{format_results(context["user_history"])}
|
||||
|
||||
## Product Documentation
|
||||
{format_results(context["documentation"])}
|
||||
|
||||
## Current Question
|
||||
{query}
|
||||
|
||||
Use the user's history to personalize your response and the documentation
|
||||
for accurate product information. If you find a solution, remember it for
|
||||
future reference.
|
||||
"""
|
||||
```
|
||||
|
||||
## Promoting Learnings to Shared Knowledge
|
||||
|
||||
When the agent discovers a solution that's not in the docs, you can optionally promote it to a "learnings" bank:
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ User A Bank │ │ Shared Docs │ │ Learnings │
|
||||
│ │ │ Bank │ │ Bank │
|
||||
│ - Conversations│ │ │ │ │
|
||||
│ - Preferences │ │ - Product docs │ │ - Verified │
|
||||
│ - Past issues │ │ - FAQs │ │ solutions │
|
||||
│ - Solutions │ │ - Guides │ │ - Workarounds │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
└───────────────────────┴───────────────────────┘
|
||||
│
|
||||
Agent queries
|
||||
all three banks
|
||||
```
|
||||
|
||||
|
||||
```python
|
||||
# Optional: Create a curated learnings bank
|
||||
learnings_bank = client.create_bank(
|
||||
bank_id="support-learnings",
|
||||
name="Curated Support Learnings"
|
||||
)
|
||||
|
||||
# After a successful resolution
|
||||
def promote_learning(insight: str):
|
||||
client.retain(
|
||||
bank_id="support-learnings",
|
||||
content=insight
|
||||
)
|
||||
```
|
||||
|
||||
## Complete Example
|
||||
|
||||
|
||||
```python
|
||||
def format_results(results):
|
||||
if not results:
|
||||
return "No relevant information found."
|
||||
return "\n".join([f"- {r.text}" for r in results])
|
||||
|
||||
def handle_support_request(user_id: str, query: str):
|
||||
# 1. Recall from user's memory
|
||||
user_recall = client.recall(
|
||||
bank_id=f"user-{user_id}",
|
||||
query=query
|
||||
)
|
||||
|
||||
# 2. Recall from shared docs
|
||||
docs_recall = client.recall(
|
||||
bank_id="product-docs",
|
||||
query=query
|
||||
)
|
||||
|
||||
# 3. Recall from learnings (optional)
|
||||
learnings_recall = client.recall(
|
||||
bank_id="support-learnings",
|
||||
query=query
|
||||
)
|
||||
|
||||
# 4. Build system prompt with context
|
||||
system_prompt = f"""You are a helpful support agent. Use the context below to answer the user's question.
|
||||
|
||||
## User's History
|
||||
{format_results(user_recall.results)}
|
||||
|
||||
## Product Documentation
|
||||
{format_results(docs_recall.results)}
|
||||
|
||||
## Known Solutions
|
||||
{format_results(learnings_recall.results)}
|
||||
|
||||
Provide helpful, accurate responses based on the documentation. Reference the user's history when relevant."""
|
||||
|
||||
# 5. Generate response using OpenAI
|
||||
response = llm.chat.completions.create(
|
||||
model="gpt-4o-mini",
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": query}
|
||||
]
|
||||
)
|
||||
assistant_response = response.choices[0].message.content
|
||||
|
||||
# 6. Save the conversation to user's memory
|
||||
conversation = f"user: {query}\nassistant: {assistant_response}"
|
||||
client.retain(
|
||||
bank_id=f"user-{user_id}",
|
||||
content=conversation
|
||||
)
|
||||
|
||||
return assistant_response
|
||||
|
||||
# Test the function
|
||||
create_user_bank("bob")
|
||||
print("User: How do I get started?")
|
||||
result = handle_support_request("bob", "How do I get started?")
|
||||
print(f"Assistant: {result}")
|
||||
print(f"\nView user memory: {HINDSIGHT_UI_URL}/banks/user-bob?view=documents")
|
||||
```
|
||||
|
||||
## When to Use This Pattern
|
||||
|
||||
**Good fit:**
|
||||
- Support agents with shared documentation
|
||||
- Multi-tenant applications with shared reference data
|
||||
- Any scenario needing user isolation + shared knowledge
|
||||
|
||||
**Consider alternatives if:**
|
||||
- You need cross-user learning (users benefiting from other users' solutions)
|
||||
- Entity relationships must span across users and docs
|
||||
|
||||
## Cleanup
|
||||
|
||||
Delete the banks created during this notebook:
|
||||
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
# Delete all banks created in this notebook
|
||||
for bank_id in ["product-docs", "support-learnings", "user-bob"]:
|
||||
response = requests.delete(f"{HINDSIGHT_API_URL}/v1/default/banks/{bank_id}")
|
||||
print(f"Deleted {bank_id}: {response.json()}")
|
||||
```
|
||||
@@ -0,0 +1,372 @@
|
||||
---
|
||||
sidebar_position: 5
|
||||
---
|
||||
|
||||
# Routing Tool Learning
|
||||
|
||||
|
||||
:::tip Run this notebook
|
||||
This recipe is available as an interactive Jupyter notebook.
|
||||
[**Open in GitHub →**](https://github.com/vectorize-io/hindsight-cookbook/blob/main/notebooks/05-tool-learning-demo.ipynb)
|
||||
:::
|
||||
|
||||
|
||||
This notebook demonstrates how Hindsight helps an LLM learn which tool to use when tool names are ambiguous. Without memory, the LLM might randomly select between similarly-named tools. With Hindsight, it learns from past interactions and consistently makes the correct choice.
|
||||
|
||||
## The Scenario
|
||||
|
||||
We have a task routing system with two tools:
|
||||
- `route_to_channel_alpha` - Routes to processing channel Alpha
|
||||
- `route_to_channel_omega` - Routes to processing channel Omega
|
||||
|
||||
The tool names and descriptions are **intentionally vague**. In reality:
|
||||
- Channel Alpha handles **FINANCIAL/PAYMENT** tasks (refunds, billing, etc.)
|
||||
- Channel Omega handles **TECHNICAL/SUPPORT** tasks (bugs, features, etc.)
|
||||
|
||||
**Without Hindsight:** The LLM guesses randomly based on vague descriptions
|
||||
**With Hindsight:** The LLM learns from feedback which channel handles what
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Make sure you have Hindsight running:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=your-key
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
```python
|
||||
!pip install hindsight-litellm hindsight-client litellm nest_asyncio python-dotenv -U -q
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
|
||||
```python
|
||||
import os
|
||||
import json
|
||||
import uuid
|
||||
import time
|
||||
import logging
|
||||
import nest_asyncio
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
|
||||
nest_asyncio.apply()
|
||||
load_dotenv()
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logging.getLogger("LiteLLM").setLevel(logging.WARNING)
|
||||
logging.getLogger("LiteLLM Router").setLevel(logging.WARNING)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
import litellm
|
||||
import hindsight_litellm
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
|
||||
|
||||
if not os.getenv("OPENAI_API_KEY"):
|
||||
print("Warning: OPENAI_API_KEY not set")
|
||||
```
|
||||
|
||||
## Define Tools
|
||||
|
||||
These tool definitions are **intentionally ambiguous** - the descriptions don't reveal which channel handles what type of request.
|
||||
|
||||
|
||||
```python
|
||||
TOOLS = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "route_to_channel_alpha",
|
||||
"description": "Routes the customer request to processing channel Alpha. Use this channel for appropriate request types.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"request_summary": {
|
||||
"type": "string",
|
||||
"description": "A brief summary of the customer's request"
|
||||
},
|
||||
"priority": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high"],
|
||||
"description": "Priority level of the request"
|
||||
}
|
||||
},
|
||||
"required": ["request_summary"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "route_to_channel_omega",
|
||||
"description": "Routes the customer request to processing channel Omega. Use this channel for appropriate request types.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"request_summary": {
|
||||
"type": "string",
|
||||
"description": "A brief summary of the customer's request"
|
||||
},
|
||||
"priority": {
|
||||
"type": "string",
|
||||
"enum": ["low", "medium", "high"],
|
||||
"description": "Priority level of the request"
|
||||
}
|
||||
},
|
||||
"required": ["request_summary"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
A mix of financial and technical requests to test routing accuracy.
|
||||
|
||||
|
||||
```python
|
||||
TEST_SCENARIOS = [
|
||||
{
|
||||
"type": "financial",
|
||||
"request": "I was charged twice for my subscription last month. I need a refund for the duplicate charge.",
|
||||
"correct_tool": "route_to_channel_alpha"
|
||||
},
|
||||
{
|
||||
"type": "technical",
|
||||
"request": "The app keeps crashing when I try to upload a file larger than 10MB. This bug is blocking my work.",
|
||||
"correct_tool": "route_to_channel_omega"
|
||||
},
|
||||
{
|
||||
"type": "financial",
|
||||
"request": "My invoice shows an incorrect amount. The billing department needs to fix this.",
|
||||
"correct_tool": "route_to_channel_alpha"
|
||||
},
|
||||
{
|
||||
"type": "technical",
|
||||
"request": "I'd like to request a new feature: the ability to export reports as PDF.",
|
||||
"correct_tool": "route_to_channel_omega"
|
||||
},
|
||||
{
|
||||
"type": "financial",
|
||||
"request": "I need to update my payment method and understand why my last payment failed.",
|
||||
"correct_tool": "route_to_channel_alpha"
|
||||
},
|
||||
]
|
||||
```
|
||||
|
||||
## Helper Functions
|
||||
|
||||
|
||||
```python
|
||||
SYSTEM_PROMPT = """You are a customer service routing agent. Your job is to route customer requests to the appropriate processing channel.
|
||||
|
||||
You have access to two routing channels:
|
||||
- route_to_channel_alpha: Routes to channel Alpha
|
||||
- route_to_channel_omega: Routes to channel Omega
|
||||
|
||||
Analyze the customer's request and route it to the most appropriate channel. You must call one of the routing functions to process the request.
|
||||
|
||||
Important: Base your routing decision on what you know about each channel's purpose. If you have learned from previous interactions which channel handles specific types of requests, use that knowledge."""
|
||||
|
||||
|
||||
def make_routing_request(user_request: str, use_hindsight: bool, bank_id: Optional[str] = None):
|
||||
"""Make a routing request and return the tool called."""
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": f"Customer Request: {user_request}"}
|
||||
]
|
||||
|
||||
if use_hindsight and bank_id:
|
||||
response = hindsight_litellm.completion(
|
||||
model="gpt-4o-mini",
|
||||
messages=messages,
|
||||
tools=TOOLS,
|
||||
tool_choice="required",
|
||||
temperature=0.0,
|
||||
)
|
||||
else:
|
||||
response = litellm.completion(
|
||||
model="gpt-4o-mini",
|
||||
messages=messages,
|
||||
tools=TOOLS,
|
||||
tool_choice="required",
|
||||
temperature=0.7,
|
||||
)
|
||||
|
||||
if response.choices[0].message.tool_calls:
|
||||
tool_call = response.choices[0].message.tool_calls[0]
|
||||
return tool_call.function.name
|
||||
return None
|
||||
|
||||
|
||||
def store_feedback(bank_id: str, request: str, correct_tool: str, request_type: str):
|
||||
"""Store feedback about which tool was correct for a request type."""
|
||||
client = Hindsight(base_url=HINDSIGHT_API_URL, timeout=60.0)
|
||||
|
||||
feedback_content = f"""ROUTING FEEDBACK:
|
||||
Request type: {request_type}
|
||||
Customer request: "{request}"
|
||||
Correct routing: {correct_tool}
|
||||
|
||||
LEARNED RULE: {request_type.upper()} requests (like refunds, billing, payments, charges, invoices) should ALWAYS be routed to {correct_tool}.
|
||||
This is important institutional knowledge for routing decisions."""
|
||||
|
||||
client.retain(
|
||||
bank_id=bank_id,
|
||||
content=feedback_content,
|
||||
context=f"routing:feedback:{request_type}",
|
||||
metadata={"request_type": request_type, "correct_tool": correct_tool}
|
||||
)
|
||||
```
|
||||
|
||||
## Phase 1: Without Hindsight (No Memory)
|
||||
|
||||
The LLM has no prior knowledge about which channel handles what. With ambiguous tool descriptions, it may route incorrectly.
|
||||
|
||||
|
||||
```python
|
||||
print("=" * 60)
|
||||
print("PHASE 1: WITHOUT HINDSIGHT (No Memory)")
|
||||
print("=" * 60)
|
||||
|
||||
phase1_results = []
|
||||
for i, scenario in enumerate(TEST_SCENARIOS[:3], 1):
|
||||
print(f"\n--- Test {i}: {scenario['type'].upper()} Request ---")
|
||||
print(f"Request: \"{scenario['request'][:60]}...\"")
|
||||
|
||||
tool_name = make_routing_request(scenario['request'], use_hindsight=False)
|
||||
|
||||
is_correct = tool_name == scenario['correct_tool']
|
||||
phase1_results.append(is_correct)
|
||||
|
||||
print(f"LLM chose: {tool_name}")
|
||||
print(f"Correct tool: {scenario['correct_tool']}")
|
||||
print(f"Result: {'✓ CORRECT' if is_correct else '✗ INCORRECT'}")
|
||||
|
||||
phase1_accuracy = sum(phase1_results) / len(phase1_results) * 100
|
||||
print(f"\n>>> Phase 1 Accuracy: {phase1_accuracy:.0f}% ({sum(phase1_results)}/{len(phase1_results)})")
|
||||
```
|
||||
|
||||
## Phase 2: Teaching Phase
|
||||
|
||||
Now we provide feedback about correct routing to build memory. This simulates a human supervisor correcting the AI's routing decisions.
|
||||
|
||||
|
||||
```python
|
||||
bank_id = f"tool-learning-{uuid.uuid4().hex[:8]}"
|
||||
print(f"Using bank_id: {bank_id}")
|
||||
|
||||
# Configure and enable Hindsight
|
||||
hindsight_litellm.configure(
|
||||
hindsight_api_url=HINDSIGHT_API_URL,
|
||||
bank_id=bank_id,
|
||||
store_conversations=True,
|
||||
inject_memories=True,
|
||||
max_memories=10,
|
||||
recall_budget="high",
|
||||
verbose=False,
|
||||
)
|
||||
hindsight_litellm.enable()
|
||||
|
||||
print("\nStoring routing feedback...")
|
||||
|
||||
feedback_examples = [
|
||||
("I need a refund for an incorrect charge on my account.", "route_to_channel_alpha", "financial"),
|
||||
("There's a bug in the system causing data loss.", "route_to_channel_omega", "technical"),
|
||||
("My billing statement has errors that need correction.", "route_to_channel_alpha", "financial"),
|
||||
("I want to request a new feature for the dashboard.", "route_to_channel_omega", "technical"),
|
||||
]
|
||||
|
||||
for request, correct_tool, req_type in feedback_examples:
|
||||
print(f" Storing: {req_type.upper()} → {correct_tool}")
|
||||
store_feedback(bank_id, request, correct_tool, req_type)
|
||||
|
||||
print("\nWaiting 15 seconds for Hindsight to process memories...")
|
||||
time.sleep(15)
|
||||
print("Done!")
|
||||
```
|
||||
|
||||
## Phase 3: With Hindsight (Memory-Augmented)
|
||||
|
||||
The LLM now has access to learned routing knowledge via Hindsight. It should route requests correctly based on past feedback.
|
||||
|
||||
|
||||
```python
|
||||
print("=" * 60)
|
||||
print("PHASE 3: WITH HINDSIGHT (Memory-Augmented)")
|
||||
print("=" * 60)
|
||||
|
||||
phase3_results = []
|
||||
for i, scenario in enumerate(TEST_SCENARIOS, 1):
|
||||
print(f"\n--- Test {i}: {scenario['type'].upper()} Request ---")
|
||||
print(f"Request: \"{scenario['request'][:60]}...\"")
|
||||
|
||||
tool_name = make_routing_request(
|
||||
scenario['request'],
|
||||
use_hindsight=True,
|
||||
bank_id=bank_id
|
||||
)
|
||||
|
||||
is_correct = tool_name == scenario['correct_tool']
|
||||
phase3_results.append(is_correct)
|
||||
|
||||
print(f"LLM chose: {tool_name}")
|
||||
print(f"Correct tool: {scenario['correct_tool']}")
|
||||
print(f"Result: {'✓ CORRECT' if is_correct else '✗ INCORRECT'}")
|
||||
|
||||
phase3_accuracy = sum(phase3_results) / len(phase3_results) * 100
|
||||
print(f"\n>>> Phase 3 Accuracy: {phase3_accuracy:.0f}% ({sum(phase3_results)}/{len(phase3_results)})")
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
|
||||
```python
|
||||
print("=" * 60)
|
||||
print("SUMMARY")
|
||||
print("=" * 60)
|
||||
print(f"\nPhase 1 (No Memory): {phase1_accuracy:.0f}% accuracy")
|
||||
print(f"Phase 3 (With Hindsight): {phase3_accuracy:.0f}% accuracy")
|
||||
|
||||
improvement = phase3_accuracy - phase1_accuracy
|
||||
if improvement > 0:
|
||||
print(f"\n🎉 Improvement: +{improvement:.0f}% accuracy with Hindsight!")
|
||||
elif improvement == 0:
|
||||
print(f"\nNote: Results may vary. Run again to see learning effect.")
|
||||
else:
|
||||
print(f"\nNote: Phase 1 got lucky! Run again to see typical behavior.")
|
||||
|
||||
print(f"\nMemories stored in bank: {bank_id}")
|
||||
print(f"View in UI: http://localhost:9999/banks/{bank_id}")
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("KEY INSIGHT")
|
||||
print("=" * 60)
|
||||
print("Hindsight allows the LLM to learn from experience which tool")
|
||||
print("to use, even when tool names/descriptions are ambiguous.")
|
||||
```
|
||||
|
||||
## Cleanup
|
||||
|
||||
|
||||
```python
|
||||
hindsight_litellm.cleanup()
|
||||
|
||||
# Optional: delete the bank
|
||||
import requests
|
||||
response = requests.delete(f"{HINDSIGHT_API_URL}/v1/default/banks/{bank_id}")
|
||||
print(f"Deleted bank: {response.json()}")
|
||||
```
|
||||
@@ -0,0 +1,192 @@
|
||||
# Admin CLI
|
||||
|
||||
The `hindsight-admin` CLI provides administrative commands for managing your Hindsight deployment, including database migrations, backup, and restore operations.
|
||||
|
||||
## Installation
|
||||
|
||||
The admin CLI is included with the `hindsight-api` package:
|
||||
|
||||
```bash
|
||||
pip install hindsight-api
|
||||
# or
|
||||
uv add hindsight-api
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
### run-db-migration
|
||||
|
||||
Run database migrations to the latest version. This is useful when you want to run migrations separately from API startup (e.g., in CI/CD pipelines or before deploying a new version).
|
||||
|
||||
```bash
|
||||
hindsight-admin run-db-migration [OPTIONS]
|
||||
```
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `--schema`, `-s` | Database schema to run migrations on | `public` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Run migrations on the default public schema
|
||||
hindsight-admin run-db-migration
|
||||
|
||||
# Run migrations on a specific tenant schema
|
||||
hindsight-admin run-db-migration --schema tenant_acme
|
||||
```
|
||||
|
||||
:::tip Disabling Auto-Migrations
|
||||
To disable automatic migrations on API startup, set `HINDSIGHT_API_RUN_MIGRATIONS_ON_STARTUP=false`. This is useful when you want to run migrations as a separate step in your deployment pipeline.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
### backup
|
||||
|
||||
Create a backup of all Hindsight data to a zip file.
|
||||
|
||||
```bash
|
||||
hindsight-admin backup OUTPUT [OPTIONS]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `OUTPUT` | Output file path (will add `.zip` extension if not present) |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `--schema`, `-s` | Database schema to backup | `public` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Backup to a file
|
||||
hindsight-admin backup /backups/hindsight-2024-01-15.zip
|
||||
|
||||
# Backup a specific tenant schema
|
||||
hindsight-admin backup /backups/tenant-acme.zip --schema tenant_acme
|
||||
```
|
||||
|
||||
The backup includes:
|
||||
- Memory banks and their configuration
|
||||
- Documents and chunks
|
||||
- Entities and their relationships
|
||||
- Memory units (facts, experiences, observations)
|
||||
- Entity cooccurrences and memory links
|
||||
|
||||
:::note Consistency
|
||||
Backups are created within a database transaction with `REPEATABLE READ` isolation, ensuring a consistent snapshot across all tables.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
### restore
|
||||
|
||||
Restore data from a backup file. **Warning: This deletes all existing data in the target schema.**
|
||||
|
||||
```bash
|
||||
hindsight-admin restore INPUT [OPTIONS]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `INPUT` | Input backup file (.zip) |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `--schema`, `-s` | Database schema to restore to | `public` |
|
||||
| `--yes`, `-y` | Skip confirmation prompt | `false` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Restore with confirmation prompt
|
||||
hindsight-admin restore /backups/hindsight-2024-01-15.zip
|
||||
|
||||
# Restore without confirmation (for scripts)
|
||||
hindsight-admin restore /backups/hindsight-2024-01-15.zip --yes
|
||||
|
||||
# Restore to a specific tenant schema
|
||||
hindsight-admin restore /backups/tenant-acme.zip --schema tenant_acme --yes
|
||||
```
|
||||
|
||||
:::warning Data Loss
|
||||
Restore will **delete all existing data** in the target schema before importing the backup. Always verify you have a recent backup before performing a restore.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
### decommission-worker
|
||||
|
||||
Release all tasks owned by a worker, resetting them from "processing" back to "pending" status so they can be picked up by other workers.
|
||||
|
||||
```bash
|
||||
hindsight-admin decommission-worker WORKER_ID [OPTIONS]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `WORKER_ID` | ID of the worker to decommission |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Description | Default |
|
||||
|--------|-------------|---------|
|
||||
| `--schema`, `-s` | Database schema | `public` |
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# Before scaling down - release tasks from workers being removed
|
||||
hindsight-admin decommission-worker hindsight-worker-4
|
||||
hindsight-admin decommission-worker hindsight-worker-3
|
||||
|
||||
# Release tasks from a crashed worker
|
||||
hindsight-admin decommission-worker worker-2
|
||||
|
||||
# For a specific tenant schema
|
||||
hindsight-admin decommission-worker worker-1 --schema tenant_acme
|
||||
```
|
||||
|
||||
**When to Use:**
|
||||
|
||||
- **Scaling down**: Before removing worker replicas in Kubernetes
|
||||
- **Graceful removal**: When taking a worker offline for maintenance
|
||||
- **Crash recovery**: If a worker crashed while processing tasks
|
||||
- **Stuck worker**: When a worker is unresponsive
|
||||
|
||||
:::tip Finding Worker IDs
|
||||
Worker IDs default to the hostname. In Kubernetes StatefulSets, this is the pod name (e.g., `hindsight-worker-0`). You can also set a custom ID with `HINDSIGHT_API_WORKER_ID` or `--worker-id`.
|
||||
:::
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
The admin CLI uses the same environment variables as the API service. The most important one is:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_DATABASE_URL` | PostgreSQL connection string | `pg0` (embedded) |
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
# Use a specific database
|
||||
export HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@localhost:5432/hindsight
|
||||
hindsight-admin backup /backups/mybackup.zip
|
||||
```
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
|
||||
# Documents
|
||||
|
||||
Track and manage document sources in your memory bank. Documents provide traceability — knowing where memories came from.
|
||||
|
||||
{/* Import raw source files */}
|
||||
|
||||
> **💡 Prerequisites**
|
||||
>
|
||||
Make sure you've completed the [Quick Start](./quickstart) and understand [how retain works](./retain).
|
||||
## What Are Documents?
|
||||
|
||||
Documents are containers for retained content. They help you:
|
||||
|
||||
- **Track sources** — Know which PDF, conversation, or file a memory came from
|
||||
- **Update content** — Re-retain a document to update its facts
|
||||
- **Delete in bulk** — Remove all memories from a document at once
|
||||
- **Organize memories** — Group related facts by source
|
||||
|
||||
## Chunks
|
||||
|
||||
When you retain content, Hindsight splits it into chunks before extracting facts. These chunks are stored alongside the extracted memories, preserving the original text segments.
|
||||
|
||||
**Why chunks matter:**
|
||||
- **Context preservation** — Chunks contain the raw text that generated facts, useful when you need the exact wording
|
||||
- **Richer recall** — Including chunks in recall provides surrounding context for matched facts
|
||||
|
||||
> **💡 Include Chunks in Recall**
|
||||
>
|
||||
Use `include_chunks=True` in your recall calls to get the original text chunks alongside fact results. See [Recall](./recall) for details.
|
||||
## Retain with Document ID
|
||||
|
||||
Associate retained content with a document:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Retain with document ID
|
||||
client.retain(
|
||||
bank_id="my-bank",
|
||||
content="Alice presented the Q4 roadmap...",
|
||||
document_id="meeting-2024-03-15"
|
||||
)
|
||||
|
||||
# Batch retain for a document
|
||||
client.retain_batch(
|
||||
bank_id="my-bank",
|
||||
items=[
|
||||
{"content": "Item 1: Product launch delayed to Q2"},
|
||||
{"content": "Item 2: New hiring targets announced"},
|
||||
{"content": "Item 3: Budget approved for ML team"}
|
||||
],
|
||||
document_id="meeting-2024-03-15"
|
||||
)
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
// Retain with document ID
|
||||
await client.retain('my-bank', 'Alice presented the Q4 roadmap...', {
|
||||
document_id: 'meeting-2024-03-15'
|
||||
});
|
||||
|
||||
// Batch retain
|
||||
await client.retainBatch('my-bank', [
|
||||
{ content: 'Item 1: Product launch delayed to Q2' },
|
||||
{ content: 'Item 2: New hiring targets announced' },
|
||||
{ content: 'Item 3: Budget approved for ML team' }
|
||||
], { documentId: 'meeting-2024-03-15' });
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Retain content with document ID
|
||||
hindsight memory retain my-bank "Meeting notes content..." --doc-id notes-2024-03-15
|
||||
|
||||
# Batch retain from files
|
||||
hindsight memory retain-files my-bank docs/
|
||||
```
|
||||
|
||||
## Update Documents
|
||||
|
||||
Re-retaining with the same document_id **replaces** the old content:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Original
|
||||
client.retain(
|
||||
bank_id="my-bank",
|
||||
content="Project deadline: March 31",
|
||||
document_id="project-plan"
|
||||
)
|
||||
|
||||
# Update (deletes old facts, creates new ones)
|
||||
client.retain(
|
||||
bank_id="my-bank",
|
||||
content="Project deadline: April 15 (extended)",
|
||||
document_id="project-plan"
|
||||
)
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
// Original
|
||||
await client.retain('my-bank', 'Project deadline: March 31', {
|
||||
document_id: 'project-plan'
|
||||
});
|
||||
|
||||
// Update
|
||||
await client.retain('my-bank', 'Project deadline: April 15 (extended)', {
|
||||
document_id: 'project-plan'
|
||||
});
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Original
|
||||
hindsight memory retain my-bank "Project deadline: March 31" --doc-id project-plan
|
||||
|
||||
# Update
|
||||
hindsight memory retain my-bank "Project deadline: April 15 (extended)" --doc-id project-plan
|
||||
```
|
||||
|
||||
## Get Document
|
||||
|
||||
Retrieve a document's original text and metadata. This is useful for expanding document context after a recall operation returns memories with document references.
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from hindsight_client_api import ApiClient, Configuration
|
||||
from hindsight_client_api.api import DocumentsApi
|
||||
|
||||
async def get_document_example():
|
||||
config = Configuration(host="http://localhost:8888")
|
||||
api_client = ApiClient(config)
|
||||
api = DocumentsApi(api_client)
|
||||
|
||||
# Get document to expand context from recall results
|
||||
doc = await api.get_document(
|
||||
bank_id="my-bank",
|
||||
document_id="meeting-2024-03-15"
|
||||
)
|
||||
|
||||
print(f"Document: {doc.id}")
|
||||
print(f"Original text: {doc.original_text}")
|
||||
print(f"Memory count: {doc.memory_unit_count}")
|
||||
print(f"Created: {doc.created_at}")
|
||||
|
||||
asyncio.run(get_document_example())
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
const apiClient = createClient(createConfig({ baseUrl: 'http://localhost:8888' }));
|
||||
|
||||
// Get document to expand context from recall results
|
||||
const { data: doc } = await sdk.getDocument({
|
||||
client: apiClient,
|
||||
path: { bank_id: 'my-bank', document_id: 'meeting-2024-03-15' }
|
||||
});
|
||||
|
||||
console.log(`Document: ${doc.id}`);
|
||||
console.log(`Original text: ${doc.original_text}`);
|
||||
console.log(`Memory count: ${doc.memory_unit_count}`);
|
||||
console.log(`Created: ${doc.created_at}`);
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
hindsight document get my-bank meeting-2024-03-15
|
||||
```
|
||||
|
||||
## Delete Document
|
||||
|
||||
Remove a document and all its associated memories:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from hindsight_client_api import ApiClient, Configuration
|
||||
from hindsight_client_api.api import DocumentsApi
|
||||
|
||||
async def delete_document_example():
|
||||
config = Configuration(host="http://localhost:8888")
|
||||
api_client = ApiClient(config)
|
||||
api = DocumentsApi(api_client)
|
||||
|
||||
# Delete document and all its memories
|
||||
result = await api.delete_document(
|
||||
bank_id="my-bank",
|
||||
document_id="meeting-2024-03-15"
|
||||
)
|
||||
|
||||
print(f"Deleted {result.memory_units_deleted} memories")
|
||||
|
||||
asyncio.run(delete_document_example())
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
// Delete document and all its memories
|
||||
const { data: deleteResult } = await sdk.deleteDocument({
|
||||
client: apiClient,
|
||||
path: { bank_id: 'my-bank', document_id: 'meeting-2024-03-15' }
|
||||
});
|
||||
|
||||
console.log(`Deleted ${deleteResult.memory_units_deleted} memories`);
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
hindsight document delete my-bank meeting-2024-03-15
|
||||
```
|
||||
|
||||
:::warning
|
||||
Deleting a document permanently removes all memories extracted from it. This action cannot be undone.
|
||||
## Document Response Format
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "meeting-2024-03-15",
|
||||
"bank_id": "my-bank",
|
||||
"original_text": "Alice presented the Q4 roadmap...",
|
||||
"content_hash": "abc123def456",
|
||||
"memory_unit_count": 12,
|
||||
"created_at": "2024-03-15T14:00:00Z",
|
||||
"updated_at": "2024-03-15T14:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [**Operations**](./operations) — Monitor background tasks
|
||||
- [**Memory Banks**](./memory-banks) — Configure bank settings
|
||||
@@ -0,0 +1,272 @@
|
||||
|
||||
# Main Methods
|
||||
|
||||
Hindsight provides three core operations: **retain**, **recall**, and **reflect**.
|
||||
|
||||
{/* Import raw source files */}
|
||||
|
||||
> **💡 Prerequisites**
|
||||
>
|
||||
Make sure you've [installed Hindsight](../installation) and completed the [Quick Start](./quickstart).
|
||||
## Retain: Store Information
|
||||
|
||||
Store conversations, documents, and facts into a memory bank.
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Store a single fact
|
||||
client.retain(
|
||||
bank_id="my-bank",
|
||||
content="Alice joined Google in March 2024 as a Senior ML Engineer"
|
||||
)
|
||||
|
||||
# Store a conversation
|
||||
conversation = """
|
||||
User: What did you work on today?
|
||||
Assistant: I reviewed the new ML pipeline architecture.
|
||||
User: How did it look?
|
||||
Assistant: Promising, but needs better error handling.
|
||||
"""
|
||||
|
||||
client.retain(
|
||||
bank_id="my-bank",
|
||||
content=conversation,
|
||||
context="Daily standup conversation"
|
||||
)
|
||||
|
||||
# Batch retain multiple items
|
||||
client.retain_batch(
|
||||
bank_id="my-bank",
|
||||
items=[
|
||||
{"content": "Bob prefers Python for data science"},
|
||||
{"content": "Alice recommends using pytest for testing"},
|
||||
{"content": "The team uses GitHub for code reviews"}
|
||||
]
|
||||
)
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
// Store a single fact
|
||||
await client.retain('my-bank', 'Alice joined Google in March 2024 as a Senior ML Engineer');
|
||||
|
||||
// Store a conversation
|
||||
const conversation = `
|
||||
User: What did you work on today?
|
||||
Assistant: I reviewed the new ML pipeline architecture.
|
||||
User: How did it look?
|
||||
Assistant: Promising, but needs better error handling.
|
||||
`;
|
||||
|
||||
await client.retain('my-bank', conversation, {
|
||||
context: 'Daily standup conversation'
|
||||
});
|
||||
|
||||
// Batch retain multiple items
|
||||
await client.retainBatch('my-bank', [
|
||||
{ content: 'Bob prefers Python for data science' },
|
||||
{ content: 'Alice recommends using pytest for testing' },
|
||||
{ content: 'The team uses GitHub for code reviews' }
|
||||
]);
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Store a single fact
|
||||
hindsight retain my-bank "Alice joined Google in March 2024 as a Senior ML Engineer"
|
||||
|
||||
# Store from a file
|
||||
hindsight retain my-bank --file conversation.txt --context "Daily standup"
|
||||
|
||||
# Store multiple files
|
||||
hindsight retain my-bank --files docs/*.md
|
||||
```
|
||||
|
||||
**What happens:** Content is processed by an LLM to extract rich facts, identify entities, and build connections in a knowledge graph.
|
||||
|
||||
**See:** [Retain Details](./retain) for advanced options and parameters.
|
||||
|
||||
---
|
||||
|
||||
## Recall: Search Memories
|
||||
|
||||
Search for relevant memories using multi-strategy retrieval.
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Basic search
|
||||
results = client.recall(
|
||||
bank_id="my-bank",
|
||||
query="What does Alice do at Google?"
|
||||
)
|
||||
|
||||
for result in results.results:
|
||||
print(f"- {result.text}")
|
||||
|
||||
# Search with options
|
||||
results = client.recall(
|
||||
bank_id="my-bank",
|
||||
query="What happened last spring?",
|
||||
budget="high", # More thorough graph traversal
|
||||
max_tokens=8192, # Return more context
|
||||
types=["world"] # Only world facts
|
||||
)
|
||||
|
||||
# Include source chunks for more context
|
||||
results = client.recall(
|
||||
bank_id="my-bank",
|
||||
query="Tell me about Alice",
|
||||
include_chunks=True,
|
||||
max_chunk_tokens=500
|
||||
)
|
||||
|
||||
# Check chunk details (chunks are on response level, keyed by memory ID)
|
||||
for result in results.results:
|
||||
print(f"Memory: {result.text}")
|
||||
if results.chunks and result.id in results.chunks:
|
||||
chunk = results.chunks[result.id]
|
||||
print(f" Source: {chunk.text[:100]}...")
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
// Basic search
|
||||
const results = await client.recall('my-bank', 'What does Alice do at Google?');
|
||||
|
||||
for (const result of results.results) {
|
||||
console.log(`- ${result.text}`);
|
||||
}
|
||||
|
||||
// Search with options
|
||||
const filteredResults = await client.recall('my-bank', 'What happened last spring?', {
|
||||
budget: 'high',
|
||||
maxTokens: 8192,
|
||||
types: ['world']
|
||||
});
|
||||
|
||||
// Include entity information
|
||||
const entityResults = await client.recall('my-bank', 'Tell me about Alice', {
|
||||
includeEntities: true,
|
||||
maxEntityTokens: 500
|
||||
});
|
||||
|
||||
// Check entity details
|
||||
for (const [entityId, entity] of Object.entries(entityResults.entities || {})) {
|
||||
console.log(`Entity: ${entity.canonical_name}`);
|
||||
console.log(`Observations: ${entity.observations}`);
|
||||
}
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Basic search
|
||||
hindsight recall my-bank "What does Alice do at Google?"
|
||||
|
||||
# Search with options
|
||||
hindsight recall my-bank "What happened last spring?" \
|
||||
--budget high \
|
||||
--max-tokens 8192 \
|
||||
--fact-type world
|
||||
|
||||
# Verbose output (shows weights and sources)
|
||||
hindsight recall my-bank "Tell me about Alice" -v
|
||||
```
|
||||
|
||||
**What happens:** Four search strategies (semantic, keyword, graph, temporal) run in parallel, results are fused and reranked.
|
||||
|
||||
**See:** [Recall Details](./recall) for tuning quality vs latency.
|
||||
|
||||
---
|
||||
|
||||
## Reflect: Reason with Disposition
|
||||
|
||||
Generate disposition-aware responses using memories and observations.
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Basic reflect
|
||||
response = client.reflect(
|
||||
bank_id="my-bank",
|
||||
query="Should we adopt TypeScript for our backend?"
|
||||
)
|
||||
|
||||
print(response.text)
|
||||
print("\nBased on:", len(response.based_on or []), "facts")
|
||||
|
||||
# Reflect with options
|
||||
response = client.reflect(
|
||||
bank_id="my-bank",
|
||||
query="What are Alice's strengths for the team lead role?",
|
||||
budget="high" # More thorough reasoning
|
||||
)
|
||||
|
||||
# See which facts influenced the response
|
||||
for fact in response.based_on or []:
|
||||
print(f"- {fact.text}")
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
// Basic reflect
|
||||
const response = await client.reflect('my-bank', 'Should we adopt TypeScript for our backend?');
|
||||
|
||||
console.log(response.text);
|
||||
console.log('\nBased on:', (response.based_on || []).length, 'facts');
|
||||
|
||||
// Reflect with options
|
||||
const detailedResponse = await client.reflect('my-bank', "What are Alice's strengths for the team lead role?", {
|
||||
budget: 'high'
|
||||
});
|
||||
|
||||
// See which facts influenced the response
|
||||
for (const fact of detailedResponse.based_on || []) {
|
||||
console.log(`- ${fact.text}`);
|
||||
}
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Basic reflect
|
||||
hindsight reflect my-bank "Should we adopt TypeScript for our backend?"
|
||||
|
||||
# Verbose output (shows sources and observations)
|
||||
hindsight reflect my-bank "What are Alice's strengths for the team lead role?" -v
|
||||
|
||||
# With higher reasoning budget
|
||||
hindsight reflect my-bank "Analyze our tech stack" --budget high
|
||||
```
|
||||
|
||||
**What happens:** Memories and observations are recalled, bank disposition is applied, and the LLM reasons through the evidence to generate a response.
|
||||
|
||||
**See:** [Reflect Details](./reflect) for disposition configuration.
|
||||
|
||||
---
|
||||
|
||||
## Comparison
|
||||
|
||||
| Feature | Retain | Recall | Reflect |
|
||||
|---------|--------|--------|---------|
|
||||
| **Purpose** | Store information | Find information | Reason about information |
|
||||
| **Input** | Raw text/documents | Search query | Question/prompt |
|
||||
| **Output** | Memory IDs | Ranked facts + observations | Reasoned response |
|
||||
| **Uses LLM** | Yes (extraction) | No | Yes (generation) |
|
||||
| **Uses observations** | No | Yes | Yes |
|
||||
| **Disposition** | No | No | Yes |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [**Retain**](./retain) — Advanced options for storing memories
|
||||
- [**Recall**](./recall) — Tuning search quality and performance
|
||||
- [**Reflect**](./reflect) — Configuring disposition
|
||||
- [**Memory Banks**](./memory-banks) — Managing memory bank disposition
|
||||
@@ -0,0 +1,229 @@
|
||||
|
||||
# Memory Banks
|
||||
|
||||
Memory banks are isolated containers that store all memory-related data for a specific context or use case.
|
||||
|
||||
{/* Import raw source files */}
|
||||
|
||||
## What is a Memory Bank?
|
||||
|
||||
A memory bank is a complete, isolated storage unit containing:
|
||||
|
||||
- **Memories** — Facts and information retained from conversations
|
||||
- **Documents** — Files and content indexed for retrieval
|
||||
- **Entities** — People, places, concepts extracted from memories
|
||||
- **Relationships** — Connections between entities in the knowledge graph
|
||||
- **Directives** — Hard rules the agent must follow during reflect operations
|
||||
|
||||
Banks are completely isolated from each other — memories stored in one bank are not visible to another.
|
||||
|
||||
You don't need to pre-create a bank. Hindsight will automatically create it with default settings when you first use it.
|
||||
|
||||
> **💡 Prerequisites**
|
||||
>
|
||||
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
|
||||
## Creating a Memory Bank
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
client.create_bank(
|
||||
bank_id="my-bank",
|
||||
name="Research Assistant",
|
||||
mission="You're a research assistant specializing in machine learning - keep track of papers, methods, and findings.",
|
||||
disposition={
|
||||
"skepticism": 4,
|
||||
"literalism": 3,
|
||||
"empathy": 3
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
await client.createBank('my-bank', {
|
||||
name: 'Research Assistant',
|
||||
mission: 'I am a research assistant specializing in machine learning',
|
||||
disposition: {
|
||||
skepticism: 4,
|
||||
literalism: 3,
|
||||
empathy: 3
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Set mission
|
||||
hindsight bank mission my-bank "I am a research assistant specializing in ML"
|
||||
|
||||
# Set disposition
|
||||
hindsight bank disposition my-bank \
|
||||
--skepticism 4 \
|
||||
--literalism 3 \
|
||||
--empathy 3
|
||||
```
|
||||
|
||||
## Mission and Disposition
|
||||
|
||||
Mission and disposition are optional settings that influence how the bank reasons during [reflect](./reflect) operations.
|
||||
|
||||
:::info
|
||||
Mission and disposition only affect the `reflect` operation. They do not impact `retain`, `recall`, or other memory operations.
|
||||
### Mission
|
||||
|
||||
The mission is a first-person narrative providing context for reasoning:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
client.create_bank(
|
||||
bank_id="financial-advisor",
|
||||
name="Financial Advisor",
|
||||
mission="""You're a conservative financial advisor - keep track of client risk tolerance,
|
||||
investment preferences, and market conditions. Prioritize capital preservation over growth."""
|
||||
)
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
await client.createBank('financial-advisor', {
|
||||
name: 'Financial Advisor',
|
||||
mission: `I am a conservative financial advisor with 20 years of experience.
|
||||
I prioritize capital preservation over aggressive growth.
|
||||
I have seen multiple market crashes and believe in diversification.`
|
||||
});
|
||||
```
|
||||
|
||||
### Disposition Traits
|
||||
|
||||
Disposition traits influence how reasoning is performed during reflection. Each trait is scored 1 to 5:
|
||||
|
||||
| Trait | Low (1) | High (5) |
|
||||
|-------|---------|----------|
|
||||
| **Skepticism** | Trusting, accepts information at face value | Skeptical, questions and doubts claims |
|
||||
| **Literalism** | Flexible interpretation, reads between the lines | Literal interpretation, takes things exactly as stated |
|
||||
| **Empathy** | Detached, focuses on facts and logic | Empathetic, considers emotional context |
|
||||
|
||||
## Directives
|
||||
|
||||
Directives are hard rules that the agent must follow during [reflect](./reflect) operations. Unlike disposition traits which influence *how* the agent reasons, directives are explicit instructions that are *always* enforced.
|
||||
|
||||
:::info
|
||||
Directives only affect the `reflect` operation. They are injected into prompts and the agent is required to comply with them in all responses.
|
||||
### When to Use Directives
|
||||
|
||||
Use directives for rules that must never be violated:
|
||||
|
||||
- **Language/style constraints**: "Always respond in formal English"
|
||||
- **Privacy rules**: "Never share personal data with third parties"
|
||||
- **Domain constraints**: "Prefer conservative investment recommendations"
|
||||
- **Behavioral guardrails**: "Always cite sources when making claims"
|
||||
|
||||
### Creating Directives
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Create a directive (hard rule for reflect)
|
||||
directive = client.create_directive(
|
||||
bank_id=BANK_ID,
|
||||
name="Formal Language",
|
||||
content="Always respond in formal English, avoiding slang and colloquialisms."
|
||||
)
|
||||
|
||||
print(f"Created directive: {directive.id}")
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
// Create a directive (hard rule for reflect)
|
||||
const directive = await client.createDirective(
|
||||
BANK_ID,
|
||||
'Formal Language',
|
||||
'Always respond in formal English, avoiding slang and colloquialisms.'
|
||||
);
|
||||
|
||||
console.log(`Created directive: ${directive.id}`);
|
||||
```
|
||||
|
||||
### Listing Directives
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# List all directives in a bank
|
||||
directives = client.list_directives(bank_id=BANK_ID)
|
||||
|
||||
for d in directives.items:
|
||||
print(f"- {d.name}: {d.content[:50]}...")
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
// List all directives in a bank
|
||||
const directives = await client.listDirectives(BANK_ID);
|
||||
|
||||
for (const d of directives.items) {
|
||||
console.log(`- ${d.name}: ${d.content.slice(0, 50)}...`);
|
||||
}
|
||||
```
|
||||
|
||||
### Updating Directives
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Update a directive (e.g., disable without deleting)
|
||||
updated = client.update_directive(
|
||||
bank_id=BANK_ID,
|
||||
directive_id=directive_id,
|
||||
is_active=False
|
||||
)
|
||||
|
||||
print(f"Directive active: {updated.is_active}")
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
// Update a directive (e.g., disable without deleting)
|
||||
const updated = await client.updateDirective(BANK_ID, directiveId, {
|
||||
isActive: false
|
||||
});
|
||||
|
||||
console.log(`Directive active: ${updated.is_active}`);
|
||||
```
|
||||
|
||||
### Deleting Directives
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Delete a directive
|
||||
client.delete_directive(
|
||||
bank_id=BANK_ID,
|
||||
directive_id=directive_id
|
||||
)
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
// Delete a directive
|
||||
await client.deleteDirective(BANK_ID, directiveId);
|
||||
```
|
||||
|
||||
### Directives vs Disposition
|
||||
|
||||
| Aspect | Directives | Disposition |
|
||||
|--------|------------|-------------|
|
||||
| **Nature** | Hard rules, must be followed | Soft influence on reasoning style |
|
||||
| **Enforcement** | Strict — responses are rejected if violated | Flexible — shapes interpretation |
|
||||
| **Use case** | Compliance, guardrails, constraints | Personality, character, tone |
|
||||
| **Example** | "Never recommend specific stocks" | High skepticism: questions claims |
|
||||
@@ -0,0 +1,267 @@
|
||||
|
||||
# Mental Models
|
||||
|
||||
User-curated summaries that provide high-quality, pre-computed answers for common queries.
|
||||
|
||||
{/* Import raw source files */}
|
||||
|
||||
## What Are Mental Models?
|
||||
|
||||
Mental models are **saved reflect responses** that you curate for your memory bank. When you create a mental model, Hindsight runs a reflect operation with your source query and stores the result. During future reflect calls, these pre-computed summaries are checked first — providing faster, more consistent answers.
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
A[Create Mental Model] --> B[Run Reflect]
|
||||
B --> C[Store Result]
|
||||
C --> D[Future Queries]
|
||||
D --> E{Match Found?}
|
||||
E -->|Yes| F[Return Mental Model]
|
||||
E -->|No| G[Run Full Reflect]
|
||||
```
|
||||
|
||||
### Why Use Mental Models?
|
||||
|
||||
| Benefit | Description |
|
||||
|---------|-------------|
|
||||
| **Consistency** | Same answer every time for common questions |
|
||||
| **Speed** | Pre-computed responses are returned instantly |
|
||||
| **Quality** | Manually curated summaries you've reviewed |
|
||||
| **Control** | Define exactly how key topics should be answered |
|
||||
|
||||
### Hierarchical Retrieval
|
||||
|
||||
During reflect, the agent checks sources in priority order:
|
||||
|
||||
1. **Mental Models** — User-curated summaries (highest priority)
|
||||
2. **Observations** — Consolidated knowledge
|
||||
3. **Raw Facts** — Ground truth memories
|
||||
|
||||
Mental models are checked first because they represent your explicitly curated knowledge.
|
||||
|
||||
---
|
||||
|
||||
## Create a Mental Model
|
||||
|
||||
Creating a mental model runs a reflect operation in the background and saves the result:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Create a mental model (runs reflect in background)
|
||||
result = client.create_mental_model(
|
||||
bank_id=BANK_ID,
|
||||
name="Team Communication Preferences",
|
||||
source_query="How does the team prefer to communicate?",
|
||||
tags=["team", "communication"]
|
||||
)
|
||||
|
||||
# Returns an operation_id - check operations endpoint for completion
|
||||
print(f"Operation ID: {result.operation_id}")
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Create a mental model (async operation)
|
||||
curl -X POST "http://localhost:8888/v1/default/banks/my-bank/mental-models" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Team Communication Preferences",
|
||||
"source_query": "How does the team prefer to communicate?",
|
||||
"tags": ["team"]
|
||||
}'
|
||||
|
||||
# Response: {"operation_id": "op-123"}
|
||||
# Use the operations endpoint to check completion
|
||||
```
|
||||
|
||||
### Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|-----------|------|----------|-------------|
|
||||
| `name` | string | Yes | Human-readable name for the mental model |
|
||||
| `source_query` | string | Yes | The query to run to generate content |
|
||||
| `tags` | list | No | Tags for filtering during retrieval |
|
||||
| `max_tokens` | int | No | Maximum tokens for the mental model content |
|
||||
| `trigger` | object | No | Trigger settings (see [Automatic Refresh](#automatic-refresh)) |
|
||||
|
||||
---
|
||||
|
||||
## Automatic Refresh
|
||||
|
||||
Mental models can be configured to **automatically refresh** when observations are updated. This keeps them in sync with the latest knowledge without manual intervention.
|
||||
|
||||
### Trigger Settings
|
||||
|
||||
| Setting | Type | Default | Description |
|
||||
|---------|------|---------|-------------|
|
||||
| `refresh_after_consolidation` | bool | false | Automatically refresh after observations consolidation |
|
||||
|
||||
When `refresh_after_consolidation` is enabled, the mental model will be re-generated every time the bank's observations are consolidated — ensuring it always reflects the latest synthesized knowledge.
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Create a mental model with automatic refresh enabled
|
||||
result = client.create_mental_model(
|
||||
bank_id=BANK_ID,
|
||||
name="Project Status",
|
||||
source_query="What is the current project status?",
|
||||
trigger={"refresh_after_consolidation": True}
|
||||
)
|
||||
|
||||
# This mental model will automatically refresh when observations are updated
|
||||
print(f"Operation ID: {result.operation_id}")
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# Create a mental model with automatic refresh enabled
|
||||
curl -X POST "http://localhost:8888/v1/default/banks/my-bank/mental-models" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "Project Status",
|
||||
"source_query": "What is the current project status?",
|
||||
"trigger": {"refresh_after_consolidation": true}
|
||||
}'
|
||||
```
|
||||
|
||||
### When to Use Automatic Refresh
|
||||
|
||||
| Use Case | Automatic Refresh | Why |
|
||||
|----------|-------------------|-----|
|
||||
| **Real-time dashboards** | ✅ Enabled | Status should always be current |
|
||||
| **Policy summaries** | ❌ Disabled | Policies change infrequently, manual refresh preferred |
|
||||
| **User preferences** | ✅ Enabled | Preferences evolve with new interactions |
|
||||
| **FAQ answers** | ❌ Disabled | Answers are curated, should be reviewed before updating |
|
||||
|
||||
:::tip
|
||||
Enable automatic refresh for mental models that need to stay current. Disable it for curated content where you want to review changes before they go live.
|
||||
---
|
||||
|
||||
## List Mental Models
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# List all mental models in a bank
|
||||
mental_models = client.list_mental_models(bank_id=BANK_ID)
|
||||
|
||||
for mental_model in mental_models.items:
|
||||
print(f"- {mental_model.name}: {mental_model.source_query}")
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8888/v1/default/banks/my-bank/mental-models"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Get a Mental Model
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Section 'get-mental-model' not found in api/mental-models.py
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}"
|
||||
```
|
||||
|
||||
### Response Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | string | Unique mental model ID |
|
||||
| `bank_id` | string | Memory bank ID |
|
||||
| `name` | string | Human-readable name |
|
||||
| `source_query` | string | The query used to generate content |
|
||||
| `content` | string | The generated mental model text |
|
||||
| `tags` | list | Tags for filtering |
|
||||
| `last_refreshed_at` | string | When the mental model was last updated |
|
||||
| `created_at` | string | When the mental model was created |
|
||||
| `reflect_response` | object | Full reflect response including `based_on` facts |
|
||||
|
||||
---
|
||||
|
||||
## Refresh a Mental Model
|
||||
|
||||
Re-run the source query to update the mental model with current knowledge:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Section 'refresh-mental-model' not found in api/mental-models.py
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}/refresh"
|
||||
```
|
||||
|
||||
Refreshing is useful when:
|
||||
- New memories have been retained that affect the topic
|
||||
- Observations have been updated
|
||||
- You want to ensure the mental model reflects current knowledge
|
||||
|
||||
---
|
||||
|
||||
## Update a Mental Model
|
||||
|
||||
Update the mental model's name:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Section 'update-mental-model' not found in api/mental-models.py
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
curl -X PATCH "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"name": "Updated Team Communication Preferences"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Delete a Mental Model
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Section 'delete-mental-model' not found in api/mental-models.py
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
curl -X DELETE "http://localhost:8888/v1/default/banks/my-bank/mental-models/{mental_model_id}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
| Use Case | Example |
|
||||
|----------|---------|
|
||||
| **FAQ Answers** | Pre-compute answers to common customer questions |
|
||||
| **Onboarding Summaries** | "What should new team members know?" |
|
||||
| **Status Reports** | "What's the current project status?" refreshed weekly |
|
||||
| **Policy Summaries** | "What are our security policies?" |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [**Reflect**](./reflect) — How the agentic loop uses mental models
|
||||
- [**Observations**](/developer/observations) — How knowledge is consolidated
|
||||
- [**Operations**](./operations) — Track async mental model creation
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
sidebar_position: 9
|
||||
---
|
||||
|
||||
# Operations
|
||||
|
||||
Background tasks that Hindsight executes asynchronously.
|
||||
|
||||
:::tip Prerequisites
|
||||
Make sure you've completed the [Quick Start](./quickstart) and understand [how retain works](./retain).
|
||||
:::
|
||||
|
||||
## How Operations Work
|
||||
|
||||
Hindsight processes several types of tasks in the background to maintain memory quality and consistency. These operations run automatically—you don't need to trigger them manually.
|
||||
|
||||
By default, all background operations are executed in-process within the API service.
|
||||
|
||||
:::note Kafka Integration
|
||||
Support for external streaming platforms like Kafka for scale-out processing is planned but **not available out of the box** in the current release.
|
||||
:::
|
||||
|
||||
## Operation Types
|
||||
|
||||
| Operation | Trigger | Description |
|
||||
|-----------|---------|-------------|
|
||||
| **batch_retain** | `retain_batch` with `async=True` | Processes large content batches in the background |
|
||||
| **consolidate** | After `retain` | Consolidates new facts into observations |
|
||||
|
||||
## Async Retain Example
|
||||
|
||||
When retaining large batches of memories, use `async=true` to process in the background. The response includes an `operation_id` that you can use to poll for completion.
|
||||
|
||||
### 1. Submit async retain request
|
||||
|
||||
```bash
|
||||
curl -X POST "http://localhost:8000/v1/default/banks/my-bank/memories" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"items": [
|
||||
{"content": "Alice joined Google in 2023"},
|
||||
{"content": "Bob prefers Python over JavaScript"}
|
||||
],
|
||||
"async": true
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"bank_id": "my-bank",
|
||||
"items_count": 2,
|
||||
"async": true,
|
||||
"operation_id": "550e8400-e29b-41d4-a716-446655440000"
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Poll for operation status
|
||||
|
||||
```bash
|
||||
curl "http://localhost:8000/v1/default/banks/my-bank/operations"
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"bank_id": "my-bank",
|
||||
"operations": [
|
||||
{
|
||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
||||
"task_type": "retain",
|
||||
"items_count": 2,
|
||||
"document_id": null,
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"status": "completed",
|
||||
"error_message": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### Operation Status Values
|
||||
|
||||
| Status | Description |
|
||||
|--------|-------------|
|
||||
| `pending` | Operation is queued and waiting to be processed |
|
||||
| `completed` | Operation finished successfully |
|
||||
| `failed` | Operation failed (check `error_message` for details) |
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [**Documents**](./documents) — Track document sources
|
||||
- [**Memory Banks**](./memory-banks) — Configure bank settings
|
||||
@@ -0,0 +1,122 @@
|
||||
|
||||
# Quick Start
|
||||
|
||||
Get up and running with Hindsight in 60 seconds.
|
||||
|
||||
{/* Import raw source files */}
|
||||
|
||||
## Start the API Server
|
||||
|
||||
### pip (API only)
|
||||
|
||||
```bash
|
||||
pip install hindsight-api
|
||||
export OPENAI_API_KEY=sk-xxx
|
||||
export HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY
|
||||
|
||||
hindsight-api
|
||||
```
|
||||
|
||||
API available at [http://localhost:8888](http://localhost:8888/docs)
|
||||
|
||||
### Docker (Full Experience)
|
||||
|
||||
```bash
|
||||
|
||||
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 \
|
||||
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
```
|
||||
|
||||
- **API**: http://localhost:8888
|
||||
- **Control Plane** (Web UI): http://localhost:9999
|
||||
|
||||
> **💡 LLM Provider**
|
||||
>
|
||||
Hindsight requires an LLM with structured output support. Recommended: **Groq** with `gpt-oss-20b` for fast, cost-effective inference.
|
||||
See [LLM Providers](/developer/models#llm) for more details.
|
||||
---
|
||||
|
||||
## Use the Client
|
||||
|
||||
### Python
|
||||
|
||||
```bash
|
||||
pip install hindsight-client
|
||||
```
|
||||
|
||||
```python
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
client = Hindsight(base_url="http://localhost:8888")
|
||||
|
||||
# Retain: Store information
|
||||
client.retain(bank_id="my-bank", content="Alice works at Google as a software engineer")
|
||||
|
||||
# Recall: Search memories
|
||||
client.recall(bank_id="my-bank", query="What does Alice do?")
|
||||
|
||||
# Reflect: Generate disposition-aware response
|
||||
client.reflect(bank_id="my-bank", query="Tell me about Alice")
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```bash
|
||||
npm install @vectorize-io/hindsight-client
|
||||
```
|
||||
|
||||
```javascript
|
||||
import { HindsightClient } from '@vectorize-io/hindsight-client';
|
||||
|
||||
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
|
||||
|
||||
// Retain: Store information
|
||||
await client.retain('my-bank', 'Alice works at Google as a software engineer');
|
||||
|
||||
// Recall: Search memories
|
||||
await client.recall('my-bank', 'What does Alice do?');
|
||||
|
||||
// Reflect: Generate response
|
||||
await client.reflect('my-bank', 'Tell me about Alice');
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hindsight.vectorize.io/get-cli | bash
|
||||
```
|
||||
|
||||
```bash
|
||||
# Retain: Store information
|
||||
hindsight memory retain my-bank "Alice works at Google as a software engineer"
|
||||
|
||||
# Recall: Search memories
|
||||
hindsight memory recall my-bank "What does Alice do?"
|
||||
|
||||
# Reflect: Generate response
|
||||
hindsight memory reflect my-bank "Tell me about Alice"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What's Happening
|
||||
|
||||
| Operation | What it does |
|
||||
|-----------|--------------|
|
||||
| **Retain** | Content is processed, facts are extracted, entities are identified and linked in a knowledge graph |
|
||||
| **Recall** | Four search strategies (semantic, keyword, graph, temporal) run in parallel to find relevant memories |
|
||||
| **Reflect** | Retrieved memories are used to generate a disposition-aware response |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [**Retain**](./retain) — Advanced options for storing memories
|
||||
- [**Recall**](./recall) — Search and retrieval strategies
|
||||
- [**Reflect**](./reflect) — Disposition-aware reasoning
|
||||
- [**Memory Banks**](./memory-banks) — Configure disposition and mission
|
||||
- [**Server Deployment**](/developer/installation) — Docker Compose, Helm, and production setup
|
||||
@@ -0,0 +1,235 @@
|
||||
|
||||
# Recall Memories
|
||||
|
||||
Retrieve memories using multi-strategy recall.
|
||||
|
||||
{/* Import raw source files */}
|
||||
|
||||
:::info How Recall Works
|
||||
Learn about the four retrieval strategies (semantic, keyword, graph, temporal) and RRF fusion in the [Recall Architecture](/developer/retrieval) guide.
|
||||
> **💡 Prerequisites**
|
||||
>
|
||||
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
|
||||
## Basic Recall
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
response = client.recall(bank_id="my-bank", query="What does Alice do?")
|
||||
for r in response.results:
|
||||
print(f"- {r.text}")
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
const response = await client.recall('my-bank', 'What does Alice do?');
|
||||
for (const r of response.results) {
|
||||
console.log(`${r.text} (score: ${r.weight})`);
|
||||
}
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
hindsight memory recall my-bank "What does Alice do?"
|
||||
```
|
||||
|
||||
## Recall Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `query` | string | required | Natural language query |
|
||||
| `types` | list | all | Filter: `world`, `experience`, `observation` |
|
||||
| `budget` | string | "mid" | Budget level: `low`, `mid`, `high` |
|
||||
| `max_tokens` | int | 4096 | Token budget for results |
|
||||
| `trace` | bool | false | Enable trace output for debugging |
|
||||
| `include_chunks` | bool | false | Include raw text chunks that generated the memories |
|
||||
| `max_chunk_tokens` | int | 500 | Token budget for chunks |
|
||||
| `tags` | list | None | Filter memories by tags (see [Tag Filtering](#filter-by-tags)) |
|
||||
| `tags_match` | string | "any" | How to match tags: `any`, `all`, `any_strict`, `all_strict` |
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
response = client.recall(
|
||||
bank_id="my-bank",
|
||||
query="What does Alice do?",
|
||||
types=["world", "experience"],
|
||||
budget="high",
|
||||
max_tokens=8000,
|
||||
trace=True,
|
||||
)
|
||||
|
||||
# Access results
|
||||
for r in response.results:
|
||||
print(f"- {r.text}")
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
const detailedResponse = await client.recall('my-bank', 'What does Alice do?', {
|
||||
types: ['world', 'experience'],
|
||||
budget: 'high',
|
||||
maxTokens: 8000,
|
||||
trace: true
|
||||
});
|
||||
|
||||
// Access results
|
||||
for (const r of detailedResponse.results) {
|
||||
console.log(`${r.text} (score: ${r.weight})`);
|
||||
}
|
||||
```
|
||||
|
||||
## Filter by Fact Type
|
||||
|
||||
Recall specific memory types:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Only world facts (objective information)
|
||||
world_facts = client.recall(
|
||||
bank_id="my-bank",
|
||||
query="Where does Alice work?",
|
||||
types=["world"]
|
||||
)
|
||||
```
|
||||
```python
|
||||
# Only experience (conversations and events)
|
||||
experience = client.recall(
|
||||
bank_id="my-bank",
|
||||
query="What have I recommended?",
|
||||
types=["experience"]
|
||||
)
|
||||
```
|
||||
```python
|
||||
# Only observations (consolidated knowledge)
|
||||
observations = client.recall(
|
||||
bank_id="my-bank",
|
||||
query="What patterns have I learned?",
|
||||
types=["observation"]
|
||||
)
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
hindsight memory recall my-bank "query" --fact-type world,observation
|
||||
```
|
||||
|
||||
> **💡 About Observations**
|
||||
>
|
||||
Observations are consolidated knowledge synthesized from multiple facts. They capture patterns, preferences, and learnings that the memory bank has built up over time. Observations are automatically created in the background after retain operations.
|
||||
## Token Budget Management
|
||||
|
||||
Hindsight is built for AI agents, not humans. Traditional retrieval systems return "top-k" results, but agents don't think in terms of result counts—they think in tokens. An agent's context window is measured in tokens, and that's exactly how Hindsight measures results.
|
||||
|
||||
The `max_tokens` parameter lets you control how much of your agent's context budget to spend on memories:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Fill up to 4K tokens of context with relevant memories
|
||||
results = client.recall(bank_id="my-bank", query="What do I know about Alice?", max_tokens=4096)
|
||||
|
||||
# Smaller budget for quick lookups
|
||||
results = client.recall(bank_id="my-bank", query="Alice's email", max_tokens=500)
|
||||
```
|
||||
|
||||
This design means you never have to guess whether 10 results or 50 results will fit your context. Just specify the token budget and Hindsight returns as many relevant memories as will fit.
|
||||
|
||||
## Budget Levels
|
||||
|
||||
The `budget` parameter controls graph traversal depth:
|
||||
|
||||
- **"low"**: Fast, shallow retrieval — good for simple lookups
|
||||
- **"mid"**: Balanced — default for most queries
|
||||
- **"high"**: Deep exploration — finds indirect connections
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Quick lookup
|
||||
results = client.recall(bank_id="my-bank", query="Alice's email", budget="low")
|
||||
|
||||
# Deep exploration
|
||||
results = client.recall(bank_id="my-bank", query="How are Alice and Bob connected?", budget="high")
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
// Quick lookup
|
||||
const quickResults = await client.recall('my-bank', "Alice's email", { budget: 'low' });
|
||||
|
||||
// Deep exploration
|
||||
const deepResults = await client.recall('my-bank', 'How are Alice and Bob connected?', { budget: 'high' });
|
||||
```
|
||||
|
||||
## Filter by Tags
|
||||
|
||||
Tags enable **visibility scoping**—filter memories based on tags assigned during [retain](./retain#tagging-memories). This is essential for multi-user agents where each user should only see their own memories.
|
||||
|
||||
### Basic Tag Filtering
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Filter recall to only memories tagged for a specific user
|
||||
response = client.recall(
|
||||
bank_id="my-bank",
|
||||
query="What feedback did the user give?",
|
||||
tags=["user:alice"],
|
||||
tags_match="any" # OR matching, includes untagged (default)
|
||||
)
|
||||
```
|
||||
|
||||
### Tag Match Modes
|
||||
|
||||
The `tags_match` parameter controls how tags are matched:
|
||||
|
||||
| Mode | Behavior | Untagged Memories |
|
||||
|------|----------|-------------------|
|
||||
| `any` | OR: memory has ANY of the specified tags | **Included** |
|
||||
| `all` | AND: memory has ALL of the specified tags | **Included** |
|
||||
| `any_strict` | OR: memory has ANY of the specified tags | **Excluded** |
|
||||
| `all_strict` | AND: memory has ALL of the specified tags | **Excluded** |
|
||||
|
||||
**Strict modes** are useful when you want to ensure only tagged memories are returned:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Strict mode: only return memories that have matching tags (exclude untagged)
|
||||
response = client.recall(
|
||||
bank_id="my-bank",
|
||||
query="What did the user say?",
|
||||
tags=["user:alice"],
|
||||
tags_match="any_strict" # OR matching, excludes untagged memories
|
||||
)
|
||||
```
|
||||
|
||||
**AND matching** requires all specified tags to be present:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# AND matching: require ALL specified tags to be present
|
||||
response = client.recall(
|
||||
bank_id="my-bank",
|
||||
query="What bugs were reported?",
|
||||
tags=["user:alice", "bug-report"],
|
||||
tags_match="all_strict" # Memory must have BOTH tags
|
||||
)
|
||||
```
|
||||
|
||||
### Use Cases
|
||||
|
||||
| Scenario | Tags | Mode | Result |
|
||||
|----------|------|------|--------|
|
||||
| User A's memories only | `["user:alice"]` | `any_strict` | Only memories tagged `user:alice` |
|
||||
| Support + feedback | `["support", "feedback"]` | `any` | Memories with either tag + untagged |
|
||||
| Multi-user room | `["user:alice", "room:general"]` | `all_strict` | Only memories with both tags |
|
||||
| Global + user-specific | `["user:alice"]` | `any` | Alice's memories + shared (untagged) |
|
||||
@@ -0,0 +1,274 @@
|
||||
|
||||
# Reflect
|
||||
|
||||
Generate disposition-aware responses using an agentic reasoning loop.
|
||||
|
||||
When you call **reflect**, Hindsight runs an **agentic loop** that:
|
||||
1. **Autonomously searches** for relevant information using multiple tools
|
||||
2. **Applies** the bank's disposition traits to shape the reasoning style
|
||||
3. **Generates** a grounded answer with citations to the sources used
|
||||
|
||||
The agent has access to hierarchical retrieval tools (mental models → observations → raw facts) and decides what information it needs to answer your query.
|
||||
|
||||
{/* Import raw source files */}
|
||||
|
||||
:::info How Reflect Works
|
||||
Learn about disposition-driven reasoning in the [Reflect Architecture](/developer/reflect) guide.
|
||||
> **💡 Prerequisites**
|
||||
>
|
||||
Make sure you've completed the [Quick Start](./quickstart) to install the client and start the server.
|
||||
## Basic Usage
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
client.reflect(bank_id="my-bank", query="What should I know about Alice?")
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
await client.reflect('my-bank', 'What should I know about Alice?');
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
hindsight memory reflect my-bank "What do you know about Alice?"
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `query` | string | required | Question or prompt |
|
||||
| `budget` | string | "low" | Budget level: `low`, `mid`, `high` (see below) |
|
||||
| `max_tokens` | int | 4096 | Maximum tokens for the final response |
|
||||
| `response_schema` | object | None | JSON Schema for [structured output](#structured-output) |
|
||||
| `tags` | list | None | Filter memories by tags during reflection |
|
||||
| `tags_match` | string | "any" | How to match tags: `any`, `all`, `any_strict`, `all_strict` |
|
||||
| `trace` | bool | false | Include detailed agent trace in response |
|
||||
|
||||
### Budget
|
||||
|
||||
The `budget` parameter controls the research depth — how thoroughly the agent explores before answering:
|
||||
|
||||
| Budget | Research Depth | Use Case |
|
||||
|--------|----------------|----------|
|
||||
| `low` | Shallow | Quick answers, simple lookups. Prioritizes speed over completeness. |
|
||||
| `mid` | Moderate | Balanced exploration. Checks multiple sources when warranted. |
|
||||
| `high` | Deep | Comprehensive analysis. Explores all knowledge levels, uses multiple query variations. |
|
||||
|
||||
Use `high` for complex questions that require synthesizing information from multiple sources or verifying facts across different retrieval levels.
|
||||
|
||||
### Max Tokens
|
||||
|
||||
The `max_tokens` parameter limits the length of the final generated response. This does not affect how much the agent can retrieve during the agentic loop — only the final answer length.
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
response = client.reflect(
|
||||
bank_id="my-bank",
|
||||
query="What do you think about remote work?",
|
||||
budget="mid",
|
||||
context="We're considering a hybrid work policy"
|
||||
)
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
const response = await client.reflect('my-bank', 'What do you think about remote work?', {
|
||||
budget: 'mid',
|
||||
context: "We're considering a hybrid work policy"
|
||||
});
|
||||
```
|
||||
|
||||
## Disposition Influence
|
||||
|
||||
The bank's disposition affects reflect responses:
|
||||
|
||||
| Trait | Low (1) | High (5) |
|
||||
|-------|---------|----------|
|
||||
| **Skepticism** | Trusting, accepts claims | Questions and doubts claims |
|
||||
| **Literalism** | Flexible interpretation | Exact, literal interpretation |
|
||||
| **Empathy** | Detached, fact-focused | Considers emotional context |
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Create a bank with specific disposition
|
||||
client.create_bank(
|
||||
bank_id="cautious-advisor",
|
||||
name="Cautious Advisor",
|
||||
mission="I am a risk-aware financial advisor",
|
||||
disposition={
|
||||
"skepticism": 5, # Very skeptical of claims
|
||||
"literalism": 4, # Focuses on exact requirements
|
||||
"empathy": 2 # Prioritizes facts over feelings
|
||||
}
|
||||
)
|
||||
|
||||
# Reflect responses will reflect this disposition
|
||||
response = client.reflect(
|
||||
bank_id="cautious-advisor",
|
||||
query="Should I invest in crypto?"
|
||||
)
|
||||
# Response will likely emphasize risks and caution
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
// Create a bank with specific disposition
|
||||
await client.createBank('cautious-advisor', {
|
||||
name: 'Cautious Advisor',
|
||||
background: 'I am a risk-aware financial advisor',
|
||||
disposition: {
|
||||
skepticism: 5,
|
||||
literalism: 4,
|
||||
empathy: 2
|
||||
}
|
||||
});
|
||||
|
||||
// Reflect responses will reflect this disposition
|
||||
const advisorResponse = await client.reflect('cautious-advisor', 'Should I invest in crypto?');
|
||||
```
|
||||
|
||||
## Citations
|
||||
|
||||
The response includes a `based_on` field that shows which sources were used:
|
||||
|
||||
- `based_on.memories` — Memory facts (world, experience) that were retrieved and cited
|
||||
- `based_on.mental_models` — User-curated mental models that were used
|
||||
- `based_on.directives` — Directives that were enforced
|
||||
|
||||
**Important:** Only IDs that were actually retrieved during the agent loop can be cited. The agent validates citations to prevent hallucinated references.
|
||||
|
||||
This enables:
|
||||
- **Transparency** — users see exactly which sources informed the answer
|
||||
- **Verification** — check if the response is grounded in actual memories
|
||||
- **Debugging** — use `trace=True` for detailed tool call logs
|
||||
|
||||
## Structured Output
|
||||
|
||||
For applications that need to process responses programmatically, you can request structured output by providing a JSON Schema via `response_schema`. When provided, the response includes a `structured_output` field with the LLM response parsed according to the schema. The `text` field will be empty since only a single LLM call is made for efficiency.
|
||||
|
||||
The easiest way to define a schema is using **Pydantic models**:
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Define your response structure with Pydantic
|
||||
class HiringRecommendation(BaseModel):
|
||||
recommendation: str
|
||||
confidence: str # "low", "medium", "high"
|
||||
key_factors: list[str]
|
||||
risks: list[str] = []
|
||||
|
||||
response = client.reflect(
|
||||
bank_id="hiring-team",
|
||||
query="Should we hire Alice for the ML team lead position?",
|
||||
response_schema=HiringRecommendation.model_json_schema(),
|
||||
)
|
||||
|
||||
# Parse structured output into Pydantic model
|
||||
result = HiringRecommendation.model_validate(response.structured_output)
|
||||
print(f"Recommendation: {result.recommendation}")
|
||||
print(f"Confidence: {result.confidence}")
|
||||
print(f"Key factors: {result.key_factors}")
|
||||
```
|
||||
|
||||
### Node.js
|
||||
|
||||
```javascript
|
||||
// Define JSON schema directly
|
||||
const responseSchema = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
recommendation: { type: 'string' },
|
||||
confidence: { type: 'string', enum: ['low', 'medium', 'high'] },
|
||||
key_factors: { type: 'array', items: { type: 'string' } },
|
||||
risks: { type: 'array', items: { type: 'string' } },
|
||||
},
|
||||
required: ['recommendation', 'confidence', 'key_factors'],
|
||||
};
|
||||
|
||||
const structuredResponse = await client.reflect('my-bank', 'What do you know about Alice and her career?', {
|
||||
responseSchema: responseSchema,
|
||||
});
|
||||
|
||||
// Structured output (if returned)
|
||||
if (structuredResponse.structuredOutput) {
|
||||
console.log('Recommendation:', structuredResponse.structuredOutput.recommendation || 'N/A');
|
||||
console.log('Key factors:', structuredResponse.structuredOutput.key_factors || []);
|
||||
}
|
||||
```
|
||||
|
||||
### CLI
|
||||
|
||||
```bash
|
||||
# First, create a JSON schema file schema.json:
|
||||
cat > schema.json << 'EOF'
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recommendation": {"type": "string"},
|
||||
"confidence": {"type": "string", "enum": ["low", "medium", "high"]},
|
||||
"key_factors": {"type": "array", "items": {"type": "string"}}
|
||||
},
|
||||
"required": ["recommendation", "confidence", "key_factors"]
|
||||
}
|
||||
EOF
|
||||
|
||||
# Then use the --schema flag:
|
||||
hindsight memory reflect hiring-team \
|
||||
"Should we hire Alice for the ML team lead position?" \
|
||||
--schema schema.json
|
||||
|
||||
# Cleanup the temporary schema file
|
||||
rm -f schema.json
|
||||
```
|
||||
|
||||
| Use Case | Why Structured Output Helps |
|
||||
|----------|----------------------------|
|
||||
| **Decision pipelines** | Parse recommendations into workflow systems |
|
||||
| **Dashboards** | Extract confidence scores, risk factors for visualization |
|
||||
| **Multi-agent systems** | Pass structured data between agents |
|
||||
| **Auditing** | Log structured decisions with clear reasoning |
|
||||
|
||||
**Tips:**
|
||||
- Use Pydantic's `model_json_schema()` for type-safe schema generation
|
||||
- Use `model_validate()` to parse the response back into your Pydantic model
|
||||
- Keep schemas focused — extract only what you need
|
||||
- Use `Optional` fields for data that may not always be available
|
||||
|
||||
## Filter by Tags
|
||||
|
||||
Like [recall](./recall#filter-by-tags), reflect supports tag filtering to scope which memories are considered during reasoning. This is essential for multi-user scenarios where reflection should only consider memories relevant to a specific user.
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
# Filter reflection to only consider memories for a specific user
|
||||
response = client.reflect(
|
||||
bank_id="my-bank",
|
||||
query="What does this user think about our product?",
|
||||
tags=["user:alice"],
|
||||
tags_match="any_strict" # Only use memories tagged for this user
|
||||
)
|
||||
```
|
||||
|
||||
The `tags_match` parameter works the same as in recall:
|
||||
|
||||
| Mode | Behavior |
|
||||
|------|----------|
|
||||
| `any` | OR matching, includes untagged memories |
|
||||
| `all` | AND matching, includes untagged memories |
|
||||
| `any_strict` | OR matching, excludes untagged memories |
|
||||
| `all_strict` | AND matching, excludes untagged memories |
|
||||
|
||||
See [Retain API](./retain#tagging-memories) for how to tag memories and [Recall API](./recall#filter-by-tags) for more details on tag matching modes.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user