Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f2fa9ae1d | ||
|
|
7039ab44a7 | ||
|
|
1a2969724d | ||
|
|
3ce8e557e4 | ||
|
|
90e370ef35 | ||
|
|
084242a6dd | ||
|
|
83f44c4b41 | ||
|
|
7bdb8fc2e3 | ||
|
|
5b52a84fff | ||
|
|
f3c5a9c1c2 | ||
|
|
5832b907c6 | ||
|
|
50fa2ed090 | ||
|
|
522b71aab8 | ||
|
|
31b5c5845d | ||
|
|
c0ca9b027e | ||
|
|
1d4879a206 | ||
|
|
8e39cb7bc8 | ||
|
|
b378f6852f | ||
|
|
9c2df9d89f | ||
|
|
ec2231799e | ||
|
|
aebef9408b | ||
|
|
66abad61b8 | ||
|
|
9db64ecda3 | ||
|
|
ddaa5f5f1b | ||
|
|
87d4a36509 | ||
|
|
0bf85a3435 | ||
|
|
16b85a4faa | ||
|
|
4c792400c1 | ||
|
|
0284595909 | ||
|
|
fe4ed1db73 |
@@ -875,6 +875,66 @@ jobs:
|
||||
echo "=== API Server Logs ==="
|
||||
cat /tmp/api-server.log || echo "No API server log found"
|
||||
|
||||
test-upgrade:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: groq
|
||||
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # Full history needed for git clone of tags
|
||||
|
||||
- name: Fetch tags
|
||||
run: git fetch --tags
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Cache HuggingFace models
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/huggingface
|
||||
key: ${{ runner.os }}-huggingface-${{ hashFiles('hindsight-api/pyproject.toml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-huggingface-
|
||||
|
||||
- name: Install hindsight-dev dependencies
|
||||
working-directory: ./hindsight-dev
|
||||
run: uv sync --frozen --extra test --index-strategy unsafe-best-match
|
||||
|
||||
- name: Install current hindsight-api
|
||||
working-directory: ./hindsight-api
|
||||
run: uv sync --frozen --index-strategy unsafe-best-match
|
||||
|
||||
- name: Pre-download models
|
||||
working-directory: ./hindsight-api
|
||||
run: |
|
||||
uv run python -c "
|
||||
from sentence_transformers import SentenceTransformer, CrossEncoder
|
||||
print('Downloading embedding model...')
|
||||
SentenceTransformer('BAAI/bge-small-en-v1.5')
|
||||
print('Downloading cross-encoder model...')
|
||||
CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
|
||||
print('Models downloaded successfully')
|
||||
"
|
||||
|
||||
- name: Run upgrade tests
|
||||
working-directory: ./hindsight-dev
|
||||
run: uv run pytest upgrade_tests/ -v --tb=short
|
||||
|
||||
verify-generated-files:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
|
||||
@@ -7,8 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
Hindsight is an agent memory system that provides long-term memory for AI agents using biomimetic data structures. Memories are organized as:
|
||||
- **World facts**: General knowledge ("The sky is blue")
|
||||
- **Experience facts**: Personal experiences ("I visited Paris in 2023")
|
||||
- **Opinion facts**: Beliefs with confidence scores ("Paris is beautiful" - 0.9 confidence)
|
||||
- **Observations**: Complex mental models derived from reflection
|
||||
- **Mental models**: Consolidated knowledge synthesized from facts ("User prefers functional programming patterns")
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -101,7 +100,7 @@ cd hindsight-control-plane && npm run dev
|
||||
Main operations:
|
||||
- **Retain**: Store memories, extracts facts/entities/relationships
|
||||
- **Recall**: Retrieve memories via 4 parallel strategies (semantic, BM25, graph, temporal) + reranking
|
||||
- **Reflect**: Deep analysis forming new opinions/observations (disposition-aware)
|
||||
- **Reflect**: Disposition-aware reasoning using memories and mental models.
|
||||
|
||||
### Database
|
||||
PostgreSQL with pgvector. Schema managed via Alembic migrations in `hindsight-api/hindsight_api/alembic/`. Migrations run automatically on API startup.
|
||||
|
||||
@@ -80,6 +80,22 @@ Control plane selector labels
|
||||
app.kubernetes.io/component: control-plane
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Worker labels
|
||||
*/}}
|
||||
{{- define "hindsight.worker.labels" -}}
|
||||
{{ include "hindsight.labels" . }}
|
||||
app.kubernetes.io/component: worker
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Worker selector labels
|
||||
*/}}
|
||||
{{- define "hindsight.worker.selectorLabels" -}}
|
||||
{{ include "hindsight.selectorLabels" . }}
|
||||
app.kubernetes.io/component: worker
|
||||
{{- end }}
|
||||
|
||||
{{/*
|
||||
Create the name of the service account to use
|
||||
*/}}
|
||||
|
||||
@@ -55,6 +55,11 @@ spec:
|
||||
{{- end }}
|
||||
- name: HINDSIGHT_API_DATABASE_URL
|
||||
value: {{ include "hindsight.databaseUrl" . | quote }}
|
||||
{{- /* Disable internal worker when dedicated workers are enabled */}}
|
||||
{{- if .Values.worker.enabled }}
|
||||
- name: HINDSIGHT_API_WORKER_ENABLED
|
||||
value: "false"
|
||||
{{- end }}
|
||||
{{- range $key, $value := .Values.api.env }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
{{- if .Values.worker.enabled }}
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: {{ include "hindsight.fullname" . }}-worker
|
||||
labels:
|
||||
{{- include "hindsight.worker.labels" . | nindent 4 }}
|
||||
{{- if .Values.podAnnotations }}
|
||||
annotations:
|
||||
{{- /* Common Prometheus annotations for metrics scraping */}}
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: {{ .Values.worker.service.port | quote }}
|
||||
prometheus.io/path: "/metrics"
|
||||
{{- end }}
|
||||
spec:
|
||||
# Headless service for StatefulSet (enables stable DNS names like worker-0.worker.namespace)
|
||||
clusterIP: None
|
||||
ports:
|
||||
- port: {{ .Values.worker.service.port }}
|
||||
targetPort: {{ .Values.worker.service.targetPort }}
|
||||
protocol: TCP
|
||||
name: http
|
||||
selector:
|
||||
{{- include "hindsight.worker.selectorLabels" . | nindent 4 }}
|
||||
{{- end }}
|
||||
@@ -0,0 +1,110 @@
|
||||
{{- if .Values.worker.enabled }}
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: {{ include "hindsight.fullname" . }}-worker
|
||||
labels:
|
||||
{{- include "hindsight.worker.labels" . | nindent 4 }}
|
||||
spec:
|
||||
serviceName: {{ include "hindsight.fullname" . }}-worker
|
||||
replicas: {{ .Values.worker.replicaCount }}
|
||||
selector:
|
||||
matchLabels:
|
||||
{{- include "hindsight.worker.selectorLabels" . | nindent 6 }}
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
{{- if not .Values.existingSecret }}
|
||||
checksum/secret: {{ include (print $.Template.BasePath "/secret.yaml") . | sha256sum }}
|
||||
{{- end }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
labels:
|
||||
{{- include "hindsight.worker.selectorLabels" . | nindent 8 }}
|
||||
spec:
|
||||
{{- if .Values.serviceAccount.create }}
|
||||
serviceAccountName: {{ include "hindsight.serviceAccountName" . }}
|
||||
{{- end }}
|
||||
securityContext:
|
||||
{{- toYaml .Values.podSecurityContext | nindent 8 }}
|
||||
containers:
|
||||
- name: worker
|
||||
securityContext:
|
||||
{{- toYaml .Values.securityContext | nindent 10 }}
|
||||
image: "{{ .Values.worker.image.repository }}:{{ .Values.worker.image.tag | default .Values.version }}"
|
||||
imagePullPolicy: {{ .Values.worker.image.pullPolicy }}
|
||||
command: ["hindsight-worker"]
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: {{ .Values.worker.service.targetPort }}
|
||||
protocol: TCP
|
||||
{{- if .Values.existingSecret }}
|
||||
envFrom:
|
||||
- secretRef:
|
||||
name: {{ .Values.existingSecret }}
|
||||
{{- end }}
|
||||
env:
|
||||
{{- /* POSTGRES_PASSWORD must be defined before DATABASE_URL for $(VAR) interpolation */}}
|
||||
{{- if not .Values.postgresql.enabled }}
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "hindsight.secretName" . }}
|
||||
key: postgres-password
|
||||
{{- end }}
|
||||
- name: HINDSIGHT_API_DATABASE_URL
|
||||
value: {{ include "hindsight.databaseUrl" . | quote }}
|
||||
{{- /* Worker ID uses pod name (StatefulSet provides stable names like worker-0, worker-1) */}}
|
||||
- name: HINDSIGHT_API_WORKER_ID
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
{{- /* Inherit LLM config from api.env */}}
|
||||
{{- range $key, $value := .Values.api.env }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- /* Worker-specific env vars */}}
|
||||
{{- range $key, $value := .Values.worker.env }}
|
||||
- name: {{ $key }}
|
||||
value: {{ $value | quote }}
|
||||
{{- end }}
|
||||
{{- /* Only use secrets when not using existingSecret */}}
|
||||
{{- if not .Values.existingSecret }}
|
||||
{{- /* Inherit secrets from api.secrets */}}
|
||||
{{- range $key, $value := .Values.api.secrets }}
|
||||
- name: {{ $key }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "hindsight.secretName" $ }}
|
||||
key: {{ $key }}
|
||||
{{- end }}
|
||||
{{- /* Worker-specific secrets (can override api.secrets) */}}
|
||||
{{- range $key, $value := .Values.worker.secrets }}
|
||||
- name: {{ $key }}
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: {{ include "hindsight.secretName" $ }}
|
||||
key: {{ $key }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
livenessProbe:
|
||||
{{- toYaml .Values.worker.livenessProbe | nindent 10 }}
|
||||
readinessProbe:
|
||||
{{- toYaml .Values.worker.readinessProbe | nindent 10 }}
|
||||
resources:
|
||||
{{- toYaml .Values.worker.resources | nindent 10 }}
|
||||
{{- with .Values.nodeSelector }}
|
||||
nodeSelector:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.affinity }}
|
||||
affinity:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.tolerations }}
|
||||
tolerations:
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- end }}
|
||||
@@ -67,6 +67,63 @@ api:
|
||||
# HINDSIGHT_API_LLM_API_KEY: "your-api-key"
|
||||
# HINDSIGHT_API_LLM_BASE_URL: "https://api.groq.com/openai/v1"
|
||||
|
||||
# Worker settings (distributed task processing)
|
||||
# When enabled, dedicated worker pods process tasks and the API's internal worker is disabled
|
||||
worker:
|
||||
enabled: false
|
||||
replicaCount: 2
|
||||
image:
|
||||
repository: ghcr.io/vectorize-io/hindsight-api
|
||||
pullPolicy: IfNotPresent
|
||||
# tag defaults to .Values.version if not specified
|
||||
|
||||
service:
|
||||
# Service for metrics scraping (headless for StatefulSet)
|
||||
port: 8889
|
||||
targetPort: 8889
|
||||
|
||||
# Resource limits and requests
|
||||
resources:
|
||||
limits:
|
||||
cpu: 2000m
|
||||
memory: 4Gi
|
||||
requests:
|
||||
cpu: 500m
|
||||
memory: 1Gi
|
||||
|
||||
# Liveness and readiness probes
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8889
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: 8889
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
timeoutSeconds: 3
|
||||
failureThreshold: 3
|
||||
|
||||
# Worker-specific environment variables
|
||||
env:
|
||||
# Poll interval in milliseconds (how often to check for new tasks)
|
||||
HINDSIGHT_API_WORKER_POLL_INTERVAL_MS: "500"
|
||||
# Number of tasks to claim per poll cycle
|
||||
HINDSIGHT_API_WORKER_BATCH_SIZE: "10"
|
||||
# Max retries before marking a task as failed
|
||||
HINDSIGHT_API_WORKER_MAX_RETRIES: "3"
|
||||
# HTTP port for metrics/health (matches service.targetPort)
|
||||
HINDSIGHT_API_WORKER_HTTP_PORT: "8889"
|
||||
|
||||
# Secret environment variables (inherited from api.secrets if not specified)
|
||||
secrets: {}
|
||||
|
||||
# Image settings for control plane
|
||||
controlPlane:
|
||||
enabled: true
|
||||
|
||||
@@ -244,6 +244,65 @@ def run_db_migration(
|
||||
typer.echo("Database migrations completed successfully")
|
||||
|
||||
|
||||
async def _decommission_worker(db_url: str, worker_id: str, schema: str = "public") -> int:
|
||||
"""Release all tasks owned by a worker, setting them back to pending status."""
|
||||
is_pg0, instance_name, _ = parse_pg0_url(db_url)
|
||||
if is_pg0:
|
||||
typer.echo(f"Starting embedded PostgreSQL (instance: {instance_name})...")
|
||||
resolved_url = await resolve_database_url(db_url)
|
||||
|
||||
conn = await asyncpg.connect(resolved_url)
|
||||
try:
|
||||
table = _fq_table("async_operations", schema)
|
||||
result = await conn.fetch(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now()
|
||||
WHERE worker_id = $1 AND status = 'processing'
|
||||
RETURNING operation_id
|
||||
""",
|
||||
worker_id,
|
||||
)
|
||||
return len(result)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@app.command(name="decommission-worker")
|
||||
def decommission_worker(
|
||||
worker_id: str = typer.Argument(..., help="Worker ID to decommission"),
|
||||
schema: str = typer.Option("public", "--schema", "-s", help="Database schema"),
|
||||
yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"),
|
||||
):
|
||||
"""Release all tasks owned by a worker (sets status back to pending).
|
||||
|
||||
Use this command when a worker has crashed or been removed without graceful shutdown.
|
||||
All tasks that were being processed by the worker will be released back to the queue
|
||||
so other workers can pick them up.
|
||||
"""
|
||||
config = HindsightConfig.from_env()
|
||||
|
||||
if not config.database_url:
|
||||
typer.echo("Error: Database URL not configured.", err=True)
|
||||
typer.echo("Set HINDSIGHT_API_DATABASE_URL environment variable.", err=True)
|
||||
raise typer.Exit(1)
|
||||
|
||||
if not yes:
|
||||
typer.confirm(
|
||||
f"This will release all tasks owned by worker '{worker_id}' back to pending. Continue?",
|
||||
abort=True,
|
||||
)
|
||||
|
||||
typer.echo(f"Decommissioning worker '{worker_id}' (schema: {schema})...")
|
||||
|
||||
count = asyncio.run(_decommission_worker(config.database_url, worker_id, schema))
|
||||
|
||||
if count > 0:
|
||||
typer.echo(f"Released {count} task(s) from worker '{worker_id}'")
|
||||
else:
|
||||
typer.echo(f"No tasks found for worker '{worker_id}'")
|
||||
|
||||
|
||||
def main():
|
||||
app()
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"""add_worker_columns
|
||||
|
||||
Revision ID: l7g8h9i0j1k2
|
||||
Revises: k6f7g8h9i0j1
|
||||
Create Date: 2026-01-19 00:00:00.000000
|
||||
|
||||
This migration adds columns to async_operations for distributed worker support:
|
||||
- worker_id: ID of the worker that claimed the task
|
||||
- claimed_at: When the task was claimed
|
||||
- retry_count: Number of retry attempts
|
||||
- task_payload: The serialized task dictionary
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import context, op
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "l7g8h9i0j1k2"
|
||||
down_revision: str | Sequence[str] | None = "k6f7g8h9i0j1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (required for multi-tenant support)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add worker columns to async_operations."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Add worker_id column (ID of worker that claimed the task)
|
||||
op.add_column(
|
||||
"async_operations",
|
||||
sa.Column("worker_id", sa.Text(), nullable=True),
|
||||
schema=context.config.get_main_option("target_schema") or None,
|
||||
)
|
||||
|
||||
# Add claimed_at column (when task was claimed by worker)
|
||||
op.add_column(
|
||||
"async_operations",
|
||||
sa.Column("claimed_at", postgresql.TIMESTAMP(timezone=True), nullable=True),
|
||||
schema=context.config.get_main_option("target_schema") or None,
|
||||
)
|
||||
|
||||
# Add retry_count column (number of retry attempts)
|
||||
op.add_column(
|
||||
"async_operations",
|
||||
sa.Column("retry_count", sa.Integer(), server_default="0", nullable=False),
|
||||
schema=context.config.get_main_option("target_schema") or None,
|
||||
)
|
||||
|
||||
# Add task_payload column (serialized task dictionary)
|
||||
op.add_column(
|
||||
"async_operations",
|
||||
sa.Column(
|
||||
"task_payload",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
),
|
||||
schema=context.config.get_main_option("target_schema") or None,
|
||||
)
|
||||
|
||||
# Add index for efficient worker polling (pending tasks ordered by creation time)
|
||||
op.execute(
|
||||
f"CREATE INDEX idx_async_operations_pending_claim ON {schema}async_operations (status, created_at) "
|
||||
f"WHERE status = 'pending' AND task_payload IS NOT NULL"
|
||||
)
|
||||
|
||||
# Add index for finding tasks by worker_id (for decommissioning)
|
||||
op.execute(
|
||||
f"CREATE INDEX idx_async_operations_worker_id ON {schema}async_operations (worker_id) WHERE worker_id IS NOT NULL"
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove worker columns from async_operations."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Drop indexes
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}idx_async_operations_pending_claim")
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}idx_async_operations_worker_id")
|
||||
|
||||
# Drop columns
|
||||
op.drop_column(
|
||||
"async_operations",
|
||||
"task_payload",
|
||||
schema=context.config.get_main_option("target_schema") or None,
|
||||
)
|
||||
op.drop_column(
|
||||
"async_operations",
|
||||
"retry_count",
|
||||
schema=context.config.get_main_option("target_schema") or None,
|
||||
)
|
||||
op.drop_column(
|
||||
"async_operations",
|
||||
"claimed_at",
|
||||
schema=context.config.get_main_option("target_schema") or None,
|
||||
)
|
||||
op.drop_column(
|
||||
"async_operations",
|
||||
"worker_id",
|
||||
schema=context.config.get_main_option("target_schema") or None,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
"""mental_model_id_to_text
|
||||
|
||||
Revision ID: m8h9i0j1k2l3
|
||||
Revises: l7g8h9i0j1k2
|
||||
Create Date: 2026-01-19 00:00:00.000000
|
||||
|
||||
This migration changes the mental_models.id column from VARCHAR(64) to TEXT
|
||||
to support longer model IDs (e.g., entity names that exceed 64 characters).
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "m8h9i0j1k2l3"
|
||||
down_revision: str | Sequence[str] | None = "l7g8h9i0j1k2"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (required for multi-tenant support)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Change mental_models.id from VARCHAR(64) to TEXT."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Alter the id column type from VARCHAR(64) to TEXT
|
||||
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE TEXT")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Revert mental_models.id from TEXT to VARCHAR(64)."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Note: This may fail if any id values exceed 64 characters
|
||||
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE VARCHAR(64)")
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
"""learnings_and_pinned_reflections
|
||||
|
||||
Revision ID: n9i0j1k2l3m4
|
||||
Revises: m8h9i0j1k2l3
|
||||
Create Date: 2026-01-21 00:00:00.000000
|
||||
|
||||
This migration:
|
||||
1. Creates the 'learnings' table for automatic bottom-up consolidation
|
||||
2. Creates the 'pinned_reflections' table for user-curated living documents
|
||||
3. Adds consolidation tracking columns to the 'banks' table
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "n9i0j1k2l3m4"
|
||||
down_revision: str | Sequence[str] | None = "m8h9i0j1k2l3"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (required for multi-tenant support)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Create learnings and pinned_reflections tables."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# 1. Create learnings table
|
||||
op.execute(f"""
|
||||
CREATE TABLE {schema}learnings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bank_id VARCHAR(64) NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
proof_count INT NOT NULL DEFAULT 1,
|
||||
history JSONB DEFAULT '[]'::jsonb,
|
||||
mission_context VARCHAR(64),
|
||||
pre_mission_change BOOLEAN DEFAULT FALSE,
|
||||
embedding vector(384),
|
||||
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
# Add foreign key constraint
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}learnings
|
||||
ADD CONSTRAINT fk_learnings_bank_id
|
||||
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
|
||||
""")
|
||||
|
||||
# Indexes for learnings
|
||||
op.execute(f"CREATE INDEX idx_learnings_bank_id ON {schema}learnings(bank_id)")
|
||||
op.execute(f"""
|
||||
CREATE INDEX idx_learnings_embedding ON {schema}learnings
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
""")
|
||||
op.execute(f"CREATE INDEX idx_learnings_tags ON {schema}learnings USING GIN(tags)")
|
||||
|
||||
# Full-text search for learnings
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}learnings ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('english', text)) STORED
|
||||
""")
|
||||
op.execute(f"CREATE INDEX idx_learnings_text_search ON {schema}learnings USING gin(search_vector)")
|
||||
|
||||
# 2. Create pinned_reflections table
|
||||
op.execute(f"""
|
||||
CREATE TABLE {schema}pinned_reflections (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bank_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(256) NOT NULL,
|
||||
source_query TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
embedding vector(384),
|
||||
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
|
||||
last_refreshed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
# Add foreign key constraint
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}pinned_reflections
|
||||
ADD CONSTRAINT fk_pinned_reflections_bank_id
|
||||
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
|
||||
""")
|
||||
|
||||
# Indexes for pinned_reflections
|
||||
op.execute(f"CREATE INDEX idx_pinned_reflections_bank_id ON {schema}pinned_reflections(bank_id)")
|
||||
op.execute(f"""
|
||||
CREATE INDEX idx_pinned_reflections_embedding ON {schema}pinned_reflections
|
||||
USING hnsw (embedding vector_cosine_ops)
|
||||
""")
|
||||
op.execute(f"CREATE INDEX idx_pinned_reflections_tags ON {schema}pinned_reflections USING GIN(tags)")
|
||||
|
||||
# Full-text search for pinned_reflections
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}pinned_reflections ADD COLUMN search_vector tsvector
|
||||
GENERATED ALWAYS AS (to_tsvector('english', COALESCE(name, '') || ' ' || content)) STORED
|
||||
""")
|
||||
op.execute(f"""
|
||||
CREATE INDEX idx_pinned_reflections_text_search ON {schema}pinned_reflections
|
||||
USING gin(search_vector)
|
||||
""")
|
||||
|
||||
# 3. Add consolidation tracking columns to banks table
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}banks
|
||||
ADD COLUMN IF NOT EXISTS last_consolidated_at TIMESTAMP WITH TIME ZONE
|
||||
""")
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}banks
|
||||
ADD COLUMN IF NOT EXISTS mission_changed_at TIMESTAMP WITH TIME ZONE
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop learnings and pinned_reflections tables."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Drop tables
|
||||
op.execute(f"DROP TABLE IF EXISTS {schema}learnings CASCADE")
|
||||
op.execute(f"DROP TABLE IF EXISTS {schema}pinned_reflections CASCADE")
|
||||
|
||||
# Remove columns from banks
|
||||
op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS last_consolidated_at")
|
||||
op.execute(f"ALTER TABLE {schema}banks DROP COLUMN IF EXISTS mission_changed_at")
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
"""migrate_mental_models_data
|
||||
|
||||
Revision ID: o0j1k2l3m4n5
|
||||
Revises: n9i0j1k2l3m4
|
||||
Create Date: 2026-01-21 00:00:00.000000
|
||||
|
||||
This migration:
|
||||
1. Migrates existing 'pinned' mental models to the new 'pinned_reflections' table
|
||||
2. Migrates existing 'learned' mental models to the new 'learnings' table
|
||||
3. Deletes non-directive mental models (structural, emergent, pinned, learned)
|
||||
4. Drops the mental_model_versions table (no longer used)
|
||||
5. Adds a CHECK constraint that only 'directive' subtype is allowed
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "o0j1k2l3m4n5"
|
||||
down_revision: str | Sequence[str] | None = "n9i0j1k2l3m4"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (required for multi-tenant support)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Migrate data and clean up old mental models."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# 1. Migrate 'pinned' mental models to pinned_reflections
|
||||
# For pinned models, the first observation's content becomes the pinned reflection content
|
||||
op.execute(f"""
|
||||
INSERT INTO {schema}pinned_reflections (bank_id, name, source_query, content, tags, created_at)
|
||||
SELECT
|
||||
bank_id,
|
||||
name,
|
||||
description AS source_query,
|
||||
COALESCE(
|
||||
observations->'observations'->0->>'content',
|
||||
description,
|
||||
''
|
||||
) AS content,
|
||||
tags,
|
||||
created_at
|
||||
FROM {schema}mental_models
|
||||
WHERE subtype = 'pinned'
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
# 2. Migrate 'learned' mental models to learnings
|
||||
# Each observation in a learned model becomes a separate learning
|
||||
op.execute(f"""
|
||||
INSERT INTO {schema}learnings (bank_id, text, proof_count, tags, created_at)
|
||||
SELECT
|
||||
mm.bank_id,
|
||||
obs->>'content' AS text,
|
||||
GREATEST(1, COALESCE(jsonb_array_length(obs->'evidence'), 1)) AS proof_count,
|
||||
mm.tags,
|
||||
mm.created_at
|
||||
FROM {schema}mental_models mm,
|
||||
LATERAL jsonb_array_elements(mm.observations->'observations') AS obs
|
||||
WHERE mm.subtype = 'learned'
|
||||
AND obs->>'content' IS NOT NULL
|
||||
AND obs->>'content' != ''
|
||||
ON CONFLICT DO NOTHING
|
||||
""")
|
||||
|
||||
# 3. Delete all non-directive mental models (they've been migrated or are obsolete)
|
||||
op.execute(f"""
|
||||
DELETE FROM {schema}mental_models
|
||||
WHERE subtype != 'directive'
|
||||
""")
|
||||
|
||||
# 4. Drop the mental_model_versions table (no longer used)
|
||||
op.execute(f"DROP TABLE IF EXISTS {schema}mental_model_versions CASCADE")
|
||||
|
||||
# 5. Drop old constraints and add new one that only allows 'directive'
|
||||
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype")
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD CONSTRAINT ck_mental_models_subtype CHECK (subtype = 'directive')
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Reverse the migration (data migration is one-way, so this just removes constraints)."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Remove the directive-only constraint
|
||||
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype")
|
||||
|
||||
# Re-create mental_model_versions table
|
||||
op.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {schema}mental_model_versions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
bank_id VARCHAR(64) NOT NULL,
|
||||
model_id VARCHAR(128) NOT NULL,
|
||||
version INT NOT NULL,
|
||||
observations JSONB NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
op.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS idx_mm_versions_lookup ON {schema}mental_model_versions(bank_id, model_id, version DESC)"
|
||||
)
|
||||
|
||||
# Note: Data migration cannot be reversed - pinned_reflections and learnings data remains
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
"""new_knowledge_architecture
|
||||
|
||||
Revision ID: p1k2l3m4n5o6
|
||||
Revises: o0j1k2l3m4n5
|
||||
Create Date: 2026-01-21 00:00:00.000000
|
||||
|
||||
This migration implements the new knowledge architecture:
|
||||
1. Drops the 'learnings' table (mental models are now in memory_units)
|
||||
2. Renames 'pinned_reflections' to 'reflections'
|
||||
3. Drops the 'mental_models' table completely
|
||||
4. Creates 'directives' table for hard rules
|
||||
5. Adds mental model support columns to 'memory_units' (proof_count, source_memory_ids, history)
|
||||
|
||||
The new architecture:
|
||||
- Directives: Hard rules in their own table
|
||||
- Mental Models: Stored in memory_units with fact_type='mental_model'
|
||||
- Reflections: User-curated documents (renamed from pinned_reflections)
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "p1k2l3m4n5o6"
|
||||
down_revision: str | Sequence[str] | None = "o0j1k2l3m4n5"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (required for multi-tenant support)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Implement new knowledge architecture."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# 1. Drop the learnings table (mental models will be in memory_units)
|
||||
op.execute(f"DROP TABLE IF EXISTS {schema}learnings CASCADE")
|
||||
|
||||
# 2. Rename pinned_reflections to reflections
|
||||
op.execute(f"ALTER TABLE IF EXISTS {schema}pinned_reflections RENAME TO reflections")
|
||||
|
||||
# Rename indexes for reflections
|
||||
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_bank_id RENAME TO idx_reflections_bank_id")
|
||||
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_embedding RENAME TO idx_reflections_embedding")
|
||||
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_tags RENAME TO idx_reflections_tags")
|
||||
op.execute(
|
||||
f"ALTER INDEX IF EXISTS {schema}idx_pinned_reflections_text_search RENAME TO idx_reflections_text_search"
|
||||
)
|
||||
|
||||
# Rename foreign key constraint
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}reflections
|
||||
DROP CONSTRAINT IF EXISTS fk_pinned_reflections_bank_id
|
||||
""")
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}reflections
|
||||
ADD CONSTRAINT fk_reflections_bank_id
|
||||
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
|
||||
""")
|
||||
|
||||
# 3. Drop the mental_models table completely
|
||||
op.execute(f"DROP TABLE IF EXISTS {schema}mental_models CASCADE")
|
||||
|
||||
# 4. Create directives table
|
||||
op.execute(f"""
|
||||
CREATE TABLE {schema}directives (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bank_id VARCHAR(64) NOT NULL,
|
||||
name VARCHAR(256) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
priority INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
|
||||
# Add foreign key and indexes for directives
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}directives
|
||||
ADD CONSTRAINT fk_directives_bank_id
|
||||
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
|
||||
""")
|
||||
op.execute(f"CREATE INDEX idx_directives_bank_id ON {schema}directives(bank_id)")
|
||||
op.execute(f"CREATE INDEX idx_directives_bank_active ON {schema}directives(bank_id, is_active)")
|
||||
op.execute(f"CREATE INDEX idx_directives_tags ON {schema}directives USING GIN(tags)")
|
||||
|
||||
# 5. Add mental model support columns to memory_units
|
||||
# proof_count: Number of memories that support this mental model
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}memory_units
|
||||
ADD COLUMN IF NOT EXISTS proof_count INT DEFAULT 1
|
||||
""")
|
||||
|
||||
# source_memory_ids: Array of memory IDs that consolidated into this mental model
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}memory_units
|
||||
ADD COLUMN IF NOT EXISTS source_memory_ids UUID[] DEFAULT ARRAY[]::UUID[]
|
||||
""")
|
||||
|
||||
# history: JSONB array tracking changes to mental models
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}memory_units
|
||||
ADD COLUMN IF NOT EXISTS history JSONB DEFAULT '[]'::jsonb
|
||||
""")
|
||||
|
||||
# Add index for finding mental models
|
||||
op.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_units_mental_models
|
||||
ON {schema}memory_units(bank_id, fact_type)
|
||||
WHERE fact_type = 'mental_model'
|
||||
""")
|
||||
|
||||
# 6. Update fact_type check constraint to include 'mental_model'
|
||||
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}memory_units
|
||||
ADD CONSTRAINT memory_units_fact_type_check
|
||||
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation', 'mental_model'))
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Reverse the migration."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Restore original fact_type check constraint (without 'mental_model')
|
||||
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}memory_units
|
||||
ADD CONSTRAINT memory_units_fact_type_check
|
||||
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation'))
|
||||
""")
|
||||
|
||||
# Drop mental model columns from memory_units
|
||||
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS proof_count")
|
||||
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS source_memory_ids")
|
||||
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS history")
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_mental_models")
|
||||
|
||||
# Drop directives table
|
||||
op.execute(f"DROP TABLE IF EXISTS {schema}directives CASCADE")
|
||||
|
||||
# Rename reflections back to pinned_reflections
|
||||
op.execute(f"ALTER TABLE IF EXISTS {schema}reflections RENAME TO pinned_reflections")
|
||||
|
||||
# Restore indexes
|
||||
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_bank_id RENAME TO idx_pinned_reflections_bank_id")
|
||||
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_embedding RENAME TO idx_pinned_reflections_embedding")
|
||||
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_tags RENAME TO idx_pinned_reflections_tags")
|
||||
op.execute(
|
||||
f"ALTER INDEX IF EXISTS {schema}idx_reflections_text_search RENAME TO idx_pinned_reflections_text_search"
|
||||
)
|
||||
|
||||
# Restore foreign key
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}pinned_reflections
|
||||
DROP CONSTRAINT IF EXISTS fk_reflections_bank_id
|
||||
""")
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}pinned_reflections
|
||||
ADD CONSTRAINT fk_pinned_reflections_bank_id
|
||||
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
|
||||
""")
|
||||
|
||||
# Re-create learnings table
|
||||
op.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {schema}learnings (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
bank_id VARCHAR(64) NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
proof_count INT NOT NULL DEFAULT 1,
|
||||
history JSONB DEFAULT '[]'::jsonb,
|
||||
mission_context VARCHAR(64),
|
||||
pre_mission_change BOOLEAN DEFAULT FALSE,
|
||||
embedding vector(384),
|
||||
tags VARCHAR[] DEFAULT ARRAY[]::VARCHAR[],
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()
|
||||
)
|
||||
""")
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}learnings
|
||||
ADD CONSTRAINT fk_learnings_bank_id
|
||||
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
|
||||
""")
|
||||
|
||||
# Note: mental_models table recreation is complex and would need separate handling
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
"""fix_mental_model_fact_type
|
||||
|
||||
Revision ID: q2l3m4n5o6p7
|
||||
Revises: p1k2l3m4n5o6
|
||||
Create Date: 2026-01-21 13:30:00.000000
|
||||
|
||||
Fix the fact_type check constraint to include 'mental_model'.
|
||||
This is a fix for p1k2l3m4n5o6 which should have included this change.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "q2l3m4n5o6p7"
|
||||
down_revision: str | Sequence[str] | None = "p1k2l3m4n5o6"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (required for multi-tenant support)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add 'mental_model' to the fact_type check constraint."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Drop the old constraint and add the new one with mental_model included
|
||||
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}memory_units
|
||||
ADD CONSTRAINT memory_units_fact_type_check
|
||||
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation', 'mental_model'))
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove 'mental_model' from the fact_type check constraint."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}memory_units
|
||||
ADD CONSTRAINT memory_units_fact_type_check
|
||||
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation'))
|
||||
""")
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
"""Add reflect_response JSONB column to reflections
|
||||
|
||||
Revision ID: r3m4n5o6p7q8
|
||||
Revises: q2l3m4n5o6p7
|
||||
Create Date: 2026-01-21
|
||||
|
||||
This migration adds a reflect_response JSONB column to store the full
|
||||
reflect API response payload, including based_on facts and trace data.
|
||||
|
||||
Note: Table was renamed from pinned_reflections to reflections in p1k2l3m4n5o6.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision: str = "r3m4n5o6p7q8"
|
||||
down_revision: str | Sequence[str] | None = "q2l3m4n5o6p7"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (required for multi-tenant support)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add reflect_response JSONB column to reflections."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Add reflect_response column to store the full reflect API response
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}reflections
|
||||
ADD COLUMN IF NOT EXISTS reflect_response JSONB
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove reflect_response column from reflections."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}reflections
|
||||
DROP COLUMN IF EXISTS reflect_response
|
||||
""")
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
"""Add consolidated_at column to memory_units for incremental consolidation tracking.
|
||||
|
||||
This allows consolidation to track progress at the memory level rather than
|
||||
using a bank-level watermark. If consolidation crashes, already-processed
|
||||
memories won't be reprocessed.
|
||||
|
||||
Revision ID: s4n5o6p7q8r9
|
||||
Revises: r3m4n5o6p7q8
|
||||
Create Date: 2025-01-22
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision: str = "s4n5o6p7q8r9"
|
||||
down_revision: str | Sequence[str] | None = "r3m4n5o6p7q8"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (required for multi-tenant support)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Add consolidated_at column to memory_units
|
||||
op.execute(
|
||||
f"""
|
||||
ALTER TABLE {schema}memory_units
|
||||
ADD COLUMN IF NOT EXISTS consolidated_at TIMESTAMPTZ DEFAULT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
# Create index for efficient querying of unconsolidated memories
|
||||
op.execute(
|
||||
f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_units_unconsolidated
|
||||
ON {schema}memory_units (bank_id, created_at)
|
||||
WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_unconsolidated")
|
||||
op.execute(f"ALTER TABLE {schema}memory_units DROP COLUMN IF EXISTS consolidated_at")
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
"""Rename mental_model fact_type to observation and reflections table to mental_models
|
||||
|
||||
Revision ID: t5o6p7q8r9s0
|
||||
Revises: s4n5o6p7q8r9
|
||||
Create Date: 2026-01-26
|
||||
|
||||
This migration implements the terminology rename:
|
||||
1. mental_model (fact_type in memory_units) -> observation
|
||||
2. reflections table -> mental_models table
|
||||
|
||||
The new terminology:
|
||||
- Observations: Consolidated knowledge synthesized from facts (was mental_model)
|
||||
- Mental Models: Stored reflect responses (was reflections)
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision: str = "t5o6p7q8r9s0"
|
||||
down_revision: str | Sequence[str] | None = "s4n5o6p7q8r9"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (required for multi-tenant support)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Rename mental_model -> observation and reflections -> mental_models."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# 1. Update fact_type values: mental_model -> observation
|
||||
op.execute(f"""
|
||||
UPDATE {schema}memory_units
|
||||
SET fact_type = 'observation'
|
||||
WHERE fact_type = 'mental_model'
|
||||
""")
|
||||
|
||||
# 2. Update the CHECK constraint - remove mental_model, keep observation
|
||||
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}memory_units
|
||||
ADD CONSTRAINT memory_units_fact_type_check
|
||||
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation'))
|
||||
""")
|
||||
|
||||
# 3. Rename the index for observations (was for mental_models)
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_mental_models")
|
||||
op.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_units_observations
|
||||
ON {schema}memory_units(bank_id, fact_type)
|
||||
WHERE fact_type = 'observation'
|
||||
""")
|
||||
|
||||
# 4. Update the unconsolidated index to not filter by fact_type since observations
|
||||
# are now the consolidated type
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_unconsolidated")
|
||||
op.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_units_unconsolidated
|
||||
ON {schema}memory_units (bank_id, created_at)
|
||||
WHERE consolidated_at IS NULL AND fact_type IN ('experience', 'world')
|
||||
""")
|
||||
|
||||
# 5. Rename reflections table to mental_models
|
||||
op.execute(f"ALTER TABLE IF EXISTS {schema}reflections RENAME TO mental_models")
|
||||
|
||||
# 6. Rename indexes for mental_models (was reflections)
|
||||
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_bank_id RENAME TO idx_mental_models_bank_id")
|
||||
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_embedding RENAME TO idx_mental_models_embedding")
|
||||
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_tags RENAME TO idx_mental_models_tags")
|
||||
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_reflections_text_search RENAME TO idx_mental_models_text_search")
|
||||
|
||||
# 7. Rename foreign key constraint
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}mental_models
|
||||
DROP CONSTRAINT IF EXISTS fk_reflections_bank_id
|
||||
""")
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD CONSTRAINT fk_mental_models_bank_id
|
||||
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Reverse: observation -> mental_model and mental_models -> reflections."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# 1. Rename mental_models table back to reflections
|
||||
op.execute(f"ALTER TABLE IF EXISTS {schema}mental_models RENAME TO reflections")
|
||||
|
||||
# 2. Rename indexes back
|
||||
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_bank_id RENAME TO idx_reflections_bank_id")
|
||||
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_embedding RENAME TO idx_reflections_embedding")
|
||||
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_tags RENAME TO idx_reflections_tags")
|
||||
op.execute(f"ALTER INDEX IF EXISTS {schema}idx_mental_models_text_search RENAME TO idx_reflections_text_search")
|
||||
|
||||
# 3. Rename foreign key back
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}reflections
|
||||
DROP CONSTRAINT IF EXISTS fk_mental_models_bank_id
|
||||
""")
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}reflections
|
||||
ADD CONSTRAINT fk_reflections_bank_id
|
||||
FOREIGN KEY (bank_id) REFERENCES {schema}banks(bank_id) ON DELETE CASCADE
|
||||
""")
|
||||
|
||||
# 4. Update fact_type values: observation -> mental_model
|
||||
op.execute(f"""
|
||||
UPDATE {schema}memory_units
|
||||
SET fact_type = 'mental_model'
|
||||
WHERE fact_type = 'observation'
|
||||
""")
|
||||
|
||||
# 5. Update the CHECK constraint back
|
||||
op.execute(f"ALTER TABLE {schema}memory_units DROP CONSTRAINT IF EXISTS memory_units_fact_type_check")
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}memory_units
|
||||
ADD CONSTRAINT memory_units_fact_type_check
|
||||
CHECK (fact_type IN ('world', 'experience', 'opinion', 'observation', 'mental_model'))
|
||||
""")
|
||||
|
||||
# 6. Rename index back
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}idx_memory_units_observations")
|
||||
op.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_memory_units_mental_models
|
||||
ON {schema}memory_units(bank_id, fact_type)
|
||||
WHERE fact_type = 'mental_model'
|
||||
""")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""Change mental_models.id from UUID to TEXT
|
||||
|
||||
Revision ID: u6p7q8r9s0t1
|
||||
Revises: t5o6p7q8r9s0
|
||||
Create Date: 2026-01-27
|
||||
|
||||
This migration changes the mental_models.id column from UUID to TEXT
|
||||
to support user-defined text identifiers like 'team-communication' instead of UUIDs.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision: str = "u6p7q8r9s0t1"
|
||||
down_revision: str | Sequence[str] | None = "t5o6p7q8r9s0"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (required for multi-tenant support)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Change mental_models.id from UUID to TEXT."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Change the id column type from UUID to TEXT
|
||||
# Existing UUIDs will be converted to their string representation
|
||||
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE TEXT USING id::TEXT")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Revert mental_models.id from TEXT to UUID."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Note: This will fail if any id values are not valid UUIDs
|
||||
op.execute(f"ALTER TABLE {schema}mental_models ALTER COLUMN id TYPE UUID USING id::UUID")
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
"""Add max_tokens and trigger columns to mental_models
|
||||
|
||||
Revision ID: v7q8r9s0t1u2
|
||||
Revises: u6p7q8r9s0t1
|
||||
Create Date: 2026-01-27
|
||||
|
||||
This migration adds:
|
||||
- max_tokens column: token limit for content generation during refresh
|
||||
- trigger column: JSONB for trigger settings (e.g., refresh_after_consolidation)
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
revision: str = "v7q8r9s0t1u2"
|
||||
down_revision: str | Sequence[str] | None = "u6p7q8r9s0t1"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def _get_schema_prefix() -> str:
|
||||
"""Get schema prefix for table names (required for multi-tenant support)."""
|
||||
schema = context.config.get_main_option("target_schema")
|
||||
return f'"{schema}".' if schema else ""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add max_tokens and trigger columns to mental_models."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD COLUMN IF NOT EXISTS max_tokens INT NOT NULL DEFAULT 2048
|
||||
""")
|
||||
|
||||
# trigger column stores trigger settings as JSONB
|
||||
# Default: refresh_after_consolidation = false (not "real time")
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD COLUMN IF NOT EXISTS trigger JSONB NOT NULL DEFAULT '{{"refresh_after_consolidation": false}}'::jsonb
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove max_tokens and trigger columns from mental_models."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS max_tokens")
|
||||
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS trigger")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
"""Hindsight MCP Server implementation using FastMCP."""
|
||||
"""Hindsight MCP Server implementation using FastMCP (HTTP transport)."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
@@ -8,8 +8,7 @@ from contextvars import ContextVar
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from hindsight_api import MemoryEngine
|
||||
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
|
||||
from hindsight_api.models import RequestContext
|
||||
from hindsight_api.mcp_tools import MCPToolsConfig, register_mcp_tools
|
||||
|
||||
# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
|
||||
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
|
||||
@@ -52,194 +51,15 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
|
||||
# Use stateless_http=True for Claude Code compatibility
|
||||
mcp = FastMCP("hindsight-mcp-server", stateless_http=True)
|
||||
|
||||
@mcp.tool()
|
||||
async def retain(
|
||||
content: str,
|
||||
context: str = "general",
|
||||
async_processing: bool = True,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Store important information to long-term memory.
|
||||
# Configure and register tools using shared module
|
||||
config = MCPToolsConfig(
|
||||
bank_id_resolver=get_current_bank_id,
|
||||
include_bank_id_param=True, # HTTP MCP supports multi-bank via parameter
|
||||
tools=None, # All tools
|
||||
retain_fire_and_forget=False, # HTTP MCP supports sync/async modes
|
||||
)
|
||||
|
||||
Use this tool PROACTIVELY whenever the user shares:
|
||||
- Personal facts, preferences, or interests
|
||||
- Important events or milestones
|
||||
- User history, experiences, or background
|
||||
- Decisions, opinions, or stated preferences
|
||||
- Goals, plans, or future intentions
|
||||
- Relationships or people mentioned
|
||||
- Work context, projects, or responsibilities
|
||||
|
||||
Args:
|
||||
content: The fact/memory to store (be specific and include relevant details)
|
||||
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
|
||||
async_processing: If True, queue for background processing and return immediately. If False, wait for completion. Default: True
|
||||
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or get_current_bank_id()
|
||||
if target_bank is None:
|
||||
return "Error: No bank_id configured"
|
||||
contents = [{"content": content, "context": context}]
|
||||
if async_processing:
|
||||
# Queue for background processing and return immediately
|
||||
result = await memory.submit_async_retain(
|
||||
bank_id=target_bank, contents=contents, request_context=RequestContext()
|
||||
)
|
||||
return f"Memory queued for background processing (operation_id: {result.get('operation_id', 'N/A')})"
|
||||
else:
|
||||
# Wait for completion
|
||||
await memory.retain_batch_async(
|
||||
bank_id=target_bank,
|
||||
contents=contents,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
return f"Memory stored successfully in bank '{target_bank}'"
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing memory: {e}", exc_info=True)
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@mcp.tool()
|
||||
async def recall(query: str, max_tokens: int = 4096, bank_id: str | None = None) -> str:
|
||||
"""
|
||||
Search memories to provide personalized, context-aware responses.
|
||||
|
||||
Use this tool PROACTIVELY to:
|
||||
- Check user's preferences before making suggestions
|
||||
- Recall user's history to provide continuity
|
||||
- Remember user's goals and context
|
||||
- Personalize responses based on past interactions
|
||||
|
||||
Args:
|
||||
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
|
||||
max_tokens: Maximum tokens in the response (default: 4096)
|
||||
bank_id: Optional bank to search in (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or get_current_bank_id()
|
||||
if target_bank is None:
|
||||
return "Error: No bank_id configured"
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
|
||||
recall_result = await memory.recall_async(
|
||||
bank_id=target_bank,
|
||||
query=query,
|
||||
fact_type=list(VALID_RECALL_FACT_TYPES),
|
||||
budget=Budget.HIGH,
|
||||
max_tokens=max_tokens,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
|
||||
# Use model's JSON serialization
|
||||
return recall_result.model_dump_json(indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}", "results": []}}'
|
||||
|
||||
@mcp.tool()
|
||||
async def reflect(query: str, context: str | None = None, budget: str = "low", bank_id: str | None = None) -> str:
|
||||
"""
|
||||
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
|
||||
|
||||
WHEN TO USE THIS TOOL:
|
||||
Use reflect when you need reasoned analysis, not just fact retrieval. This tool
|
||||
thinks through the question using everything the bank knows and its personality traits.
|
||||
|
||||
EXAMPLES OF GOOD QUERIES:
|
||||
- "What patterns have emerged in how I approach debugging?"
|
||||
- "Based on my past decisions, what architectural style do I prefer?"
|
||||
- "What might be the best approach for this problem given what you know about me?"
|
||||
- "How should I prioritize these tasks based on my goals?"
|
||||
|
||||
HOW IT DIFFERS FROM RECALL:
|
||||
- recall: Returns raw facts matching your search (fast lookup)
|
||||
- reflect: Reasons across memories to form a synthesized answer (deeper analysis)
|
||||
|
||||
Use recall for "what did I say about X?" and reflect for "what should I do about X?"
|
||||
|
||||
Args:
|
||||
query: The question or topic to reflect on
|
||||
context: Optional context about why this reflection is needed
|
||||
budget: Search budget - 'low', 'mid', or 'high' (default: 'low')
|
||||
bank_id: Optional bank to reflect in (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or get_current_bank_id()
|
||||
if target_bank is None:
|
||||
return "Error: No bank_id configured"
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
|
||||
# Map string budget to enum
|
||||
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
|
||||
budget_enum = budget_map.get(budget.lower(), Budget.LOW)
|
||||
|
||||
reflect_result = await memory.reflect_async(
|
||||
bank_id=target_bank,
|
||||
query=query,
|
||||
budget=budget_enum,
|
||||
context=context,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
|
||||
return reflect_result.model_dump_json(indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Error reflecting: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}", "text": ""}}'
|
||||
|
||||
@mcp.tool()
|
||||
async def list_banks() -> str:
|
||||
"""
|
||||
List all available memory banks.
|
||||
|
||||
Use this tool to discover what memory banks exist in the system.
|
||||
Each bank is an isolated memory store (like a separate "brain").
|
||||
|
||||
Returns:
|
||||
JSON list of banks with their IDs, names, dispositions, and missions.
|
||||
"""
|
||||
try:
|
||||
banks = await memory.list_banks(request_context=RequestContext())
|
||||
return json.dumps({"banks": banks}, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing banks: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}", "banks": []}}'
|
||||
|
||||
@mcp.tool()
|
||||
async def create_bank(bank_id: str, name: str | None = None, mission: str | None = None) -> str:
|
||||
"""
|
||||
Create a new memory bank or get an existing one.
|
||||
|
||||
Memory banks are isolated stores - each one is like a separate "brain" for a user/agent.
|
||||
Banks are auto-created with default settings if they don't exist.
|
||||
|
||||
Args:
|
||||
bank_id: Unique identifier for the bank (e.g., 'user-123', 'agent-alpha')
|
||||
name: Optional human-friendly name for the bank
|
||||
mission: Optional mission describing who the agent is and what they're trying to accomplish
|
||||
"""
|
||||
try:
|
||||
# get_bank_profile auto-creates bank if it doesn't exist
|
||||
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
|
||||
|
||||
# Update name/mission if provided
|
||||
if name is not None or mission is not None:
|
||||
await memory.update_bank(
|
||||
bank_id,
|
||||
name=name,
|
||||
mission=mission,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
# Fetch updated profile
|
||||
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
|
||||
|
||||
# Serialize disposition if it's a Pydantic model
|
||||
if "disposition" in profile and hasattr(profile["disposition"], "model_dump"):
|
||||
profile["disposition"] = profile["disposition"].model_dump()
|
||||
return json.dumps(profile, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating bank: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
register_mcp_tools(mcp, memory, config)
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
@@ -39,6 +39,11 @@ ENV_REFLECT_LLM_API_KEY = "HINDSIGHT_API_REFLECT_LLM_API_KEY"
|
||||
ENV_REFLECT_LLM_MODEL = "HINDSIGHT_API_REFLECT_LLM_MODEL"
|
||||
ENV_REFLECT_LLM_BASE_URL = "HINDSIGHT_API_REFLECT_LLM_BASE_URL"
|
||||
|
||||
ENV_CONSOLIDATION_LLM_PROVIDER = "HINDSIGHT_API_CONSOLIDATION_LLM_PROVIDER"
|
||||
ENV_CONSOLIDATION_LLM_API_KEY = "HINDSIGHT_API_CONSOLIDATION_LLM_API_KEY"
|
||||
ENV_CONSOLIDATION_LLM_MODEL = "HINDSIGHT_API_CONSOLIDATION_LLM_MODEL"
|
||||
ENV_CONSOLIDATION_LLM_BASE_URL = "HINDSIGHT_API_CONSOLIDATION_LLM_BASE_URL"
|
||||
|
||||
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
|
||||
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
|
||||
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
|
||||
@@ -82,10 +87,6 @@ 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"
|
||||
|
||||
# Observation thresholds
|
||||
ENV_OBSERVATION_MIN_FACTS = "HINDSIGHT_API_OBSERVATION_MIN_FACTS"
|
||||
ENV_OBSERVATION_TOP_ENTITIES = "HINDSIGHT_API_OBSERVATION_TOP_ENTITIES"
|
||||
|
||||
# Retain settings
|
||||
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
|
||||
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
|
||||
@@ -93,6 +94,10 @@ ENV_RETAIN_EXTRACT_CAUSAL_LINKS = "HINDSIGHT_API_RETAIN_EXTRACT_CAUSAL_LINKS"
|
||||
ENV_RETAIN_EXTRACTION_MODE = "HINDSIGHT_API_RETAIN_EXTRACTION_MODE"
|
||||
ENV_RETAIN_OBSERVATIONS_ASYNC = "HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC"
|
||||
|
||||
# Observations settings (consolidated knowledge from facts)
|
||||
ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS"
|
||||
ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE"
|
||||
|
||||
# Optimization flags
|
||||
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
|
||||
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
|
||||
@@ -106,10 +111,13 @@ ENV_DB_POOL_MAX_SIZE = "HINDSIGHT_API_DB_POOL_MAX_SIZE"
|
||||
ENV_DB_COMMAND_TIMEOUT = "HINDSIGHT_API_DB_COMMAND_TIMEOUT"
|
||||
ENV_DB_ACQUIRE_TIMEOUT = "HINDSIGHT_API_DB_ACQUIRE_TIMEOUT"
|
||||
|
||||
# Background task processing
|
||||
ENV_TASK_BACKEND = "HINDSIGHT_API_TASK_BACKEND"
|
||||
ENV_TASK_BACKEND_MEMORY_BATCH_SIZE = "HINDSIGHT_API_TASK_BACKEND_MEMORY_BATCH_SIZE"
|
||||
ENV_TASK_BACKEND_MEMORY_BATCH_INTERVAL = "HINDSIGHT_API_TASK_BACKEND_MEMORY_BATCH_INTERVAL"
|
||||
# Worker configuration (distributed task processing)
|
||||
ENV_WORKER_ENABLED = "HINDSIGHT_API_WORKER_ENABLED"
|
||||
ENV_WORKER_ID = "HINDSIGHT_API_WORKER_ID"
|
||||
ENV_WORKER_POLL_INTERVAL_MS = "HINDSIGHT_API_WORKER_POLL_INTERVAL_MS"
|
||||
ENV_WORKER_MAX_RETRIES = "HINDSIGHT_API_WORKER_MAX_RETRIES"
|
||||
ENV_WORKER_BATCH_SIZE = "HINDSIGHT_API_WORKER_BATCH_SIZE"
|
||||
ENV_WORKER_HTTP_PORT = "HINDSIGHT_API_WORKER_HTTP_PORT"
|
||||
|
||||
# Reflect agent settings
|
||||
ENV_REFLECT_MAX_ITERATIONS = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS"
|
||||
@@ -156,10 +164,6 @@ DEFAULT_RECALL_CONNECTION_BUDGET = 4 # Max concurrent DB connections per recall
|
||||
DEFAULT_MCP_LOCAL_BANK_ID = "mcp"
|
||||
DEFAULT_MENTAL_MODEL_REFRESH_CONCURRENCY = 8 # Max concurrent mental model refreshes
|
||||
|
||||
# Observation thresholds
|
||||
DEFAULT_OBSERVATION_MIN_FACTS = 5 # Min facts required to generate entity observations
|
||||
DEFAULT_OBSERVATION_TOP_ENTITIES = 5 # Max entities to process per retain batch
|
||||
|
||||
# Retain settings
|
||||
DEFAULT_RETAIN_MAX_COMPLETION_TOKENS = 64000 # Max tokens for fact extraction LLM call
|
||||
DEFAULT_RETAIN_CHUNK_SIZE = 3000 # Max chars per chunk for fact extraction
|
||||
@@ -168,6 +172,10 @@ DEFAULT_RETAIN_EXTRACTION_MODE = "concise" # Extraction mode: "concise" or "ver
|
||||
RETAIN_EXTRACTION_MODES = ("concise", "verbose") # Allowed extraction modes
|
||||
DEFAULT_RETAIN_OBSERVATIONS_ASYNC = False # Run observation generation async (after retain completes)
|
||||
|
||||
# Observations defaults (consolidated knowledge from facts)
|
||||
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
|
||||
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
|
||||
|
||||
# Database migrations
|
||||
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
|
||||
|
||||
@@ -177,10 +185,13 @@ DEFAULT_DB_POOL_MAX_SIZE = 100
|
||||
DEFAULT_DB_COMMAND_TIMEOUT = 60 # seconds
|
||||
DEFAULT_DB_ACQUIRE_TIMEOUT = 30 # seconds
|
||||
|
||||
# Background task processing
|
||||
DEFAULT_TASK_BACKEND = "memory" # Options: "memory", "noop"
|
||||
DEFAULT_TASK_BACKEND_MEMORY_BATCH_SIZE = 10
|
||||
DEFAULT_TASK_BACKEND_MEMORY_BATCH_INTERVAL = 1.0 # seconds
|
||||
# Worker configuration (distributed task processing)
|
||||
DEFAULT_WORKER_ENABLED = True # API runs worker by default (standalone mode)
|
||||
DEFAULT_WORKER_ID = None # Will use hostname if not specified
|
||||
DEFAULT_WORKER_POLL_INTERVAL_MS = 500 # Poll database every 500ms
|
||||
DEFAULT_WORKER_MAX_RETRIES = 3 # Max retries before marking task failed
|
||||
DEFAULT_WORKER_BATCH_SIZE = 10 # Tasks to claim per poll cycle
|
||||
DEFAULT_WORKER_HTTP_PORT = 8889 # HTTP port for worker metrics/health
|
||||
|
||||
# Reflect agent settings
|
||||
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
|
||||
@@ -277,6 +288,11 @@ class HindsightConfig:
|
||||
reflect_llm_model: str | None
|
||||
reflect_llm_base_url: str | None
|
||||
|
||||
consolidation_llm_provider: str | None
|
||||
consolidation_llm_api_key: str | None
|
||||
consolidation_llm_model: str | None
|
||||
consolidation_llm_base_url: str | None
|
||||
|
||||
# Embeddings
|
||||
embeddings_provider: str
|
||||
embeddings_local_model: str
|
||||
@@ -307,10 +323,6 @@ class HindsightConfig:
|
||||
recall_connection_budget: int
|
||||
mental_model_refresh_concurrency: int
|
||||
|
||||
# Observation thresholds
|
||||
observation_min_facts: int
|
||||
observation_top_entities: int
|
||||
|
||||
# Retain settings
|
||||
retain_max_completion_tokens: int
|
||||
retain_chunk_size: int
|
||||
@@ -318,6 +330,10 @@ class HindsightConfig:
|
||||
retain_extraction_mode: str
|
||||
retain_observations_async: bool
|
||||
|
||||
# Observations settings (consolidated knowledge from facts)
|
||||
enable_observations: bool
|
||||
consolidation_batch_size: int
|
||||
|
||||
# Optimization flags
|
||||
skip_llm_verification: bool
|
||||
lazy_reranker: bool
|
||||
@@ -331,10 +347,13 @@ class HindsightConfig:
|
||||
db_command_timeout: int
|
||||
db_acquire_timeout: int
|
||||
|
||||
# Background task processing
|
||||
task_backend: str
|
||||
task_backend_memory_batch_size: int
|
||||
task_backend_memory_batch_interval: float
|
||||
# Worker configuration (distributed task processing)
|
||||
worker_enabled: bool
|
||||
worker_id: str | None
|
||||
worker_poll_interval_ms: int
|
||||
worker_max_retries: int
|
||||
worker_batch_size: int
|
||||
worker_http_port: int
|
||||
|
||||
# Reflect agent settings
|
||||
reflect_max_iterations: int
|
||||
@@ -361,6 +380,10 @@ class HindsightConfig:
|
||||
reflect_llm_api_key=os.getenv(ENV_REFLECT_LLM_API_KEY) or None,
|
||||
reflect_llm_model=os.getenv(ENV_REFLECT_LLM_MODEL) or None,
|
||||
reflect_llm_base_url=os.getenv(ENV_REFLECT_LLM_BASE_URL) or None,
|
||||
consolidation_llm_provider=os.getenv(ENV_CONSOLIDATION_LLM_PROVIDER) or None,
|
||||
consolidation_llm_api_key=os.getenv(ENV_CONSOLIDATION_LLM_API_KEY) or None,
|
||||
consolidation_llm_model=os.getenv(ENV_CONSOLIDATION_LLM_MODEL) or None,
|
||||
consolidation_llm_base_url=os.getenv(ENV_CONSOLIDATION_LLM_BASE_URL) or None,
|
||||
# Embeddings
|
||||
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
|
||||
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
|
||||
@@ -396,11 +419,6 @@ class HindsightConfig:
|
||||
# Optimization flags
|
||||
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
|
||||
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
|
||||
# Observation thresholds
|
||||
observation_min_facts=int(os.getenv(ENV_OBSERVATION_MIN_FACTS, str(DEFAULT_OBSERVATION_MIN_FACTS))),
|
||||
observation_top_entities=int(
|
||||
os.getenv(ENV_OBSERVATION_TOP_ENTITIES, str(DEFAULT_OBSERVATION_TOP_ENTITIES))
|
||||
),
|
||||
# Retain settings
|
||||
retain_max_completion_tokens=int(
|
||||
os.getenv(ENV_RETAIN_MAX_COMPLETION_TOKENS, str(DEFAULT_RETAIN_MAX_COMPLETION_TOKENS))
|
||||
@@ -417,6 +435,11 @@ class HindsightConfig:
|
||||
ENV_RETAIN_OBSERVATIONS_ASYNC, str(DEFAULT_RETAIN_OBSERVATIONS_ASYNC)
|
||||
).lower()
|
||||
== "true",
|
||||
# Observations settings (consolidated knowledge from facts)
|
||||
enable_observations=os.getenv(ENV_ENABLE_OBSERVATIONS, str(DEFAULT_ENABLE_OBSERVATIONS)).lower() == "true",
|
||||
consolidation_batch_size=int(
|
||||
os.getenv(ENV_CONSOLIDATION_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_BATCH_SIZE))
|
||||
),
|
||||
# Database migrations
|
||||
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
|
||||
# Database connection pool
|
||||
@@ -424,14 +447,13 @@ class HindsightConfig:
|
||||
db_pool_max_size=int(os.getenv(ENV_DB_POOL_MAX_SIZE, str(DEFAULT_DB_POOL_MAX_SIZE))),
|
||||
db_command_timeout=int(os.getenv(ENV_DB_COMMAND_TIMEOUT, str(DEFAULT_DB_COMMAND_TIMEOUT))),
|
||||
db_acquire_timeout=int(os.getenv(ENV_DB_ACQUIRE_TIMEOUT, str(DEFAULT_DB_ACQUIRE_TIMEOUT))),
|
||||
# Background task processing
|
||||
task_backend=os.getenv(ENV_TASK_BACKEND, DEFAULT_TASK_BACKEND),
|
||||
task_backend_memory_batch_size=int(
|
||||
os.getenv(ENV_TASK_BACKEND_MEMORY_BATCH_SIZE, str(DEFAULT_TASK_BACKEND_MEMORY_BATCH_SIZE))
|
||||
),
|
||||
task_backend_memory_batch_interval=float(
|
||||
os.getenv(ENV_TASK_BACKEND_MEMORY_BATCH_INTERVAL, str(DEFAULT_TASK_BACKEND_MEMORY_BATCH_INTERVAL))
|
||||
),
|
||||
# Worker configuration
|
||||
worker_enabled=os.getenv(ENV_WORKER_ENABLED, str(DEFAULT_WORKER_ENABLED)).lower() == "true",
|
||||
worker_id=os.getenv(ENV_WORKER_ID) or DEFAULT_WORKER_ID,
|
||||
worker_poll_interval_ms=int(os.getenv(ENV_WORKER_POLL_INTERVAL_MS, str(DEFAULT_WORKER_POLL_INTERVAL_MS))),
|
||||
worker_max_retries=int(os.getenv(ENV_WORKER_MAX_RETRIES, str(DEFAULT_WORKER_MAX_RETRIES))),
|
||||
worker_batch_size=int(os.getenv(ENV_WORKER_BATCH_SIZE, str(DEFAULT_WORKER_BATCH_SIZE))),
|
||||
worker_http_port=int(os.getenv(ENV_WORKER_HTTP_PORT, str(DEFAULT_WORKER_HTTP_PORT))),
|
||||
# Reflect agent settings
|
||||
reflect_max_iterations=int(os.getenv(ENV_REFLECT_MAX_ITERATIONS, str(DEFAULT_REFLECT_MAX_ITERATIONS))),
|
||||
)
|
||||
@@ -499,6 +521,10 @@ class HindsightConfig:
|
||||
reflect_provider = self.reflect_llm_provider or self.llm_provider
|
||||
reflect_model = self.reflect_llm_model or self.llm_model
|
||||
logger.info(f"LLM (reflect): provider={reflect_provider}, model={reflect_model}")
|
||||
if self.consolidation_llm_provider or self.consolidation_llm_model:
|
||||
consolidation_provider = self.consolidation_llm_provider or self.llm_provider
|
||||
consolidation_model = self.consolidation_llm_model or self.llm_model
|
||||
logger.info(f"LLM (consolidation): provider={consolidation_provider}, model={consolidation_model}")
|
||||
logger.info(f"Embeddings: provider={self.embeddings_provider}")
|
||||
logger.info(f"Reranker: provider={self.reranker_provider}")
|
||||
logger.info(f"Graph retriever: {self.graph_retriever}")
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Consolidation engine for automatic learning creation from memories."""
|
||||
|
||||
from .consolidator import run_consolidation_job
|
||||
|
||||
__all__ = ["run_consolidation_job"]
|
||||
@@ -0,0 +1,859 @@
|
||||
"""Consolidation engine for automatic observation creation from memories.
|
||||
|
||||
The consolidation engine runs as a background job after retain operations complete.
|
||||
It processes new memories and either:
|
||||
- Creates new observations from novel facts
|
||||
- Updates existing observations when new evidence supports/contradicts/refines them
|
||||
|
||||
Observations are stored in memory_units with fact_type='observation' and include:
|
||||
- proof_count: Number of supporting memories
|
||||
- source_memory_ids: Array of memory UUIDs that contribute to this observation
|
||||
- history: JSONB tracking changes over time
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..memory_engine import fq_table
|
||||
from ..retain import embedding_utils
|
||||
from .prompts import (
|
||||
CONSOLIDATION_SYSTEM_PROMPT,
|
||||
CONSOLIDATION_USER_PROMPT,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncpg import Connection
|
||||
|
||||
from ...api.http import RequestContext
|
||||
from ..memory_engine import MemoryEngine
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ConsolidationPerfLog:
|
||||
"""Performance logging for consolidation operations."""
|
||||
|
||||
def __init__(self, bank_id: str):
|
||||
self.bank_id = bank_id
|
||||
self.start_time = time.time()
|
||||
self.lines: list[str] = []
|
||||
self.timings: dict[str, float] = {}
|
||||
|
||||
def log(self, message: str) -> None:
|
||||
"""Add a log line."""
|
||||
self.lines.append(message)
|
||||
|
||||
def record_timing(self, key: str, duration: float) -> None:
|
||||
"""Record a timing measurement."""
|
||||
if key in self.timings:
|
||||
self.timings[key] += duration
|
||||
else:
|
||||
self.timings[key] = duration
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Flush all log lines to the logger."""
|
||||
total_time = time.time() - self.start_time
|
||||
header = f"\n{'=' * 60}\nCONSOLIDATION for bank {self.bank_id}"
|
||||
footer = f"{'=' * 60}\nCONSOLIDATION COMPLETE: {total_time:.3f}s total\n{'=' * 60}"
|
||||
|
||||
log_output = header + "\n" + "\n".join(self.lines) + "\n" + footer
|
||||
logger.info(log_output)
|
||||
|
||||
|
||||
async def run_consolidation_job(
|
||||
memory_engine: "MemoryEngine",
|
||||
bank_id: str,
|
||||
request_context: "RequestContext",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Run consolidation job for a bank.
|
||||
|
||||
This is called after retain operations to consolidate new memories into mental models.
|
||||
|
||||
Args:
|
||||
memory_engine: MemoryEngine instance
|
||||
bank_id: Bank identifier
|
||||
request_context: Request context for authentication
|
||||
|
||||
Returns:
|
||||
Dict with consolidation results
|
||||
"""
|
||||
from ...config import get_config
|
||||
|
||||
config = get_config()
|
||||
perf = ConsolidationPerfLog(bank_id)
|
||||
max_memories_per_batch = config.consolidation_batch_size
|
||||
|
||||
# Check if consolidation is enabled
|
||||
if not config.enable_observations:
|
||||
logger.debug(f"Consolidation disabled for bank {bank_id}")
|
||||
return {"status": "disabled", "bank_id": bank_id}
|
||||
|
||||
pool = memory_engine._pool
|
||||
|
||||
# Get bank profile
|
||||
async with pool.acquire() as conn:
|
||||
t0 = time.time()
|
||||
bank_row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT bank_id, name, mission
|
||||
FROM {fq_table("banks")}
|
||||
WHERE bank_id = $1
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
if not bank_row:
|
||||
logger.warning(f"Bank {bank_id} not found for consolidation")
|
||||
return {"status": "bank_not_found", "bank_id": bank_id}
|
||||
|
||||
mission = bank_row["mission"] or "General memory consolidation"
|
||||
perf.record_timing("fetch_bank", time.time() - t0)
|
||||
|
||||
# Count total unconsolidated memories for progress logging
|
||||
total_count = await conn.fetchval(
|
||||
f"""
|
||||
SELECT COUNT(*)
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $1
|
||||
AND consolidated_at IS NULL
|
||||
AND fact_type IN ('experience', 'world')
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
if total_count == 0:
|
||||
logger.debug(f"No new memories to consolidate for bank {bank_id}")
|
||||
return {"status": "no_new_memories", "bank_id": bank_id, "memories_processed": 0}
|
||||
|
||||
logger.info(f"[CONSOLIDATION] bank={bank_id} total_unconsolidated={total_count}")
|
||||
perf.log(f"[1] Found {total_count} pending memories to consolidate")
|
||||
|
||||
# Process each memory with individual commits for crash recovery
|
||||
stats = {
|
||||
"memories_processed": 0,
|
||||
"observations_created": 0,
|
||||
"observations_updated": 0,
|
||||
"observations_merged": 0,
|
||||
"actions_executed": 0,
|
||||
"skipped": 0,
|
||||
}
|
||||
|
||||
batch_num = 0
|
||||
while True:
|
||||
batch_num += 1
|
||||
batch_start = time.time()
|
||||
|
||||
# Fetch next batch of unconsolidated memories
|
||||
async with pool.acquire() as conn:
|
||||
t0 = time.time()
|
||||
memories = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, fact_type, occurred_start, occurred_end, event_date, tags, mentioned_at
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $1
|
||||
AND consolidated_at IS NULL
|
||||
AND fact_type IN ('experience', 'world')
|
||||
ORDER BY created_at ASC
|
||||
LIMIT $2
|
||||
""",
|
||||
bank_id,
|
||||
max_memories_per_batch,
|
||||
)
|
||||
perf.record_timing("fetch_memories", time.time() - t0)
|
||||
|
||||
if not memories:
|
||||
break # No more unconsolidated memories
|
||||
|
||||
for memory in memories:
|
||||
mem_start = time.time()
|
||||
|
||||
# Process the memory (uses its own connection internally)
|
||||
async with pool.acquire() as conn:
|
||||
result = await _process_memory(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
memory=dict(memory),
|
||||
mission=mission,
|
||||
request_context=request_context,
|
||||
perf=perf,
|
||||
)
|
||||
|
||||
# Mark memory as consolidated (committed immediately)
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {fq_table("memory_units")}
|
||||
SET consolidated_at = NOW()
|
||||
WHERE id = $1
|
||||
""",
|
||||
memory["id"],
|
||||
)
|
||||
|
||||
mem_time = time.time() - mem_start
|
||||
perf.record_timing("process_memory_total", mem_time)
|
||||
|
||||
stats["memories_processed"] += 1
|
||||
|
||||
action = result.get("action")
|
||||
if action == "created":
|
||||
stats["observations_created"] += 1
|
||||
stats["actions_executed"] += 1
|
||||
elif action == "updated":
|
||||
stats["observations_updated"] += 1
|
||||
stats["actions_executed"] += 1
|
||||
elif action == "merged":
|
||||
stats["observations_merged"] += 1
|
||||
stats["actions_executed"] += 1
|
||||
elif action == "multiple":
|
||||
stats["observations_created"] += result.get("created", 0)
|
||||
stats["observations_updated"] += result.get("updated", 0)
|
||||
stats["observations_merged"] += result.get("merged", 0)
|
||||
stats["actions_executed"] += result.get("total_actions", 0)
|
||||
elif action == "skipped":
|
||||
stats["skipped"] += 1
|
||||
|
||||
# Log progress periodically
|
||||
if stats["memories_processed"] % 10 == 0:
|
||||
logger.info(
|
||||
f"[CONSOLIDATION] bank={bank_id} progress: "
|
||||
f"{stats['memories_processed']}/{total_count} memories processed"
|
||||
)
|
||||
|
||||
batch_time = time.time() - batch_start
|
||||
perf.log(
|
||||
f"[2] Batch {batch_num}: {len(memories)} memories in {batch_time:.3f}s "
|
||||
f"(avg {batch_time / len(memories):.3f}s/memory)"
|
||||
)
|
||||
|
||||
# Build summary
|
||||
perf.log(
|
||||
f"[3] Results: {stats['memories_processed']} memories -> "
|
||||
f"{stats['actions_executed']} actions "
|
||||
f"({stats['observations_created']} created, "
|
||||
f"{stats['observations_updated']} updated, "
|
||||
f"{stats['observations_merged']} merged, "
|
||||
f"{stats['skipped']} skipped)"
|
||||
)
|
||||
|
||||
# Add timing breakdown
|
||||
timing_parts = []
|
||||
if "recall" in perf.timings:
|
||||
timing_parts.append(f"recall={perf.timings['recall']:.3f}s")
|
||||
if "llm" in perf.timings:
|
||||
timing_parts.append(f"llm={perf.timings['llm']:.3f}s")
|
||||
if "embedding" in perf.timings:
|
||||
timing_parts.append(f"embedding={perf.timings['embedding']:.3f}s")
|
||||
if "db_write" in perf.timings:
|
||||
timing_parts.append(f"db_write={perf.timings['db_write']:.3f}s")
|
||||
|
||||
if timing_parts:
|
||||
perf.log(f"[4] Timing breakdown: {', '.join(timing_parts)}")
|
||||
|
||||
# Trigger mental model refreshes for models with refresh_after_consolidation=true
|
||||
mental_models_refreshed = await _trigger_mental_model_refreshes(
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
perf=perf,
|
||||
)
|
||||
stats["mental_models_refreshed"] = mental_models_refreshed
|
||||
|
||||
perf.flush()
|
||||
|
||||
return {"status": "completed", "bank_id": bank_id, **stats}
|
||||
|
||||
|
||||
async def _trigger_mental_model_refreshes(
|
||||
memory_engine: "MemoryEngine",
|
||||
bank_id: str,
|
||||
request_context: "RequestContext",
|
||||
perf: ConsolidationPerfLog | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Trigger refreshes for mental models with refresh_after_consolidation=true.
|
||||
|
||||
Args:
|
||||
memory_engine: MemoryEngine instance
|
||||
bank_id: Bank identifier
|
||||
request_context: Request context for authentication
|
||||
perf: Performance logging
|
||||
|
||||
Returns:
|
||||
Number of mental models scheduled for refresh
|
||||
"""
|
||||
pool = memory_engine._pool
|
||||
|
||||
# Find mental models with refresh_after_consolidation=true
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, name
|
||||
FROM {fq_table("mental_models")}
|
||||
WHERE bank_id = $1
|
||||
AND (trigger->>'refresh_after_consolidation')::boolean = true
|
||||
""",
|
||||
bank_id,
|
||||
)
|
||||
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
if perf:
|
||||
perf.log(f"[5] Triggering refresh for {len(rows)} mental models with refresh_after_consolidation=true")
|
||||
|
||||
# Submit refresh tasks for each mental model
|
||||
refreshed_count = 0
|
||||
for row in rows:
|
||||
mental_model_id = row["id"]
|
||||
try:
|
||||
await memory_engine.submit_async_refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
refreshed_count += 1
|
||||
logger.info(
|
||||
f"[CONSOLIDATION] Triggered refresh for mental model {mental_model_id} "
|
||||
f"(name: {row['name']}) in bank {bank_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"[CONSOLIDATION] Failed to trigger refresh for mental model {mental_model_id}: {e}")
|
||||
|
||||
return refreshed_count
|
||||
|
||||
|
||||
async def _process_memory(
|
||||
conn: "Connection",
|
||||
memory_engine: "MemoryEngine",
|
||||
bank_id: str,
|
||||
memory: dict[str, Any],
|
||||
mission: str,
|
||||
request_context: "RequestContext",
|
||||
perf: ConsolidationPerfLog | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Process a single memory for consolidation using a SINGLE LLM call.
|
||||
|
||||
This function:
|
||||
1. Finds related observations (can be empty)
|
||||
2. Uses ONE LLM call to extract durable knowledge AND decide on actions
|
||||
3. Executes array of actions (can be multiple creates/updates)
|
||||
|
||||
The LLM handles all cases:
|
||||
- No related observations: returns create action(s) with extracted durable knowledge
|
||||
- Related observations exist: returns update/create actions based on tag routing
|
||||
- Purely ephemeral fact: returns empty array (skip)
|
||||
|
||||
Returns:
|
||||
Dict with action summary: created/updated/merged counts
|
||||
"""
|
||||
fact_text = memory["text"]
|
||||
memory_id = memory["id"]
|
||||
fact_tags = memory.get("tags") or []
|
||||
|
||||
# Find related observations using the full recall system (NO tag filtering)
|
||||
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,
|
||||
)
|
||||
if perf:
|
||||
perf.record_timing("recall", time.time() - t0)
|
||||
|
||||
# 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)
|
||||
|
||||
if not actions:
|
||||
# LLM returned empty array - fact is purely ephemeral, skip
|
||||
return {"action": "skipped", "reason": "no_durable_knowledge"}
|
||||
|
||||
# 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 results:
|
||||
# No valid actions executed
|
||||
return {"action": "skipped", "reason": "no_valid_actions"}
|
||||
|
||||
# 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 len(results) == 1:
|
||||
return results[0]
|
||||
|
||||
return {
|
||||
"action": "multiple",
|
||||
"created": created,
|
||||
"updated": updated,
|
||||
"merged": merged,
|
||||
"total_actions": len(results),
|
||||
}
|
||||
|
||||
|
||||
async def _execute_update_action(
|
||||
conn: "Connection",
|
||||
memory_engine: "MemoryEngine",
|
||||
bank_id: str,
|
||||
memory_id: uuid.UUID,
|
||||
action: dict[str, Any],
|
||||
observations: list[dict[str, Any]],
|
||||
source_fact_tags: list[str] | None = None,
|
||||
source_occurred_start: datetime | None = None,
|
||||
source_occurred_end: datetime | None = None,
|
||||
source_mentioned_at: datetime | None = None,
|
||||
perf: ConsolidationPerfLog | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Execute an update action on an existing observation.
|
||||
|
||||
Updates the observation text, adds to history, increments proof_count,
|
||||
and updates temporal fields:
|
||||
- occurred_start: uses LEAST to keep the earliest start time
|
||||
- occurred_end: uses GREATEST to keep the most recent end time
|
||||
- mentioned_at: uses GREATEST to keep the most recent mention time
|
||||
|
||||
SECURITY: Merges source fact's tags into the observation's existing tags.
|
||||
This ensures all contributors can see the observation they contributed to.
|
||||
For example, if Lisa's observation (tags=['user_lisa']) is updated with
|
||||
Mike's fact (tags=['user_mike']), the observation will have both tags.
|
||||
"""
|
||||
learning_id = action.get("learning_id")
|
||||
new_text = action.get("text")
|
||||
reason = action.get("reason", "Updated with new fact")
|
||||
|
||||
if not learning_id or not new_text:
|
||||
return {"action": "skipped", "reason": "missing_learning_id_or_text"}
|
||||
|
||||
# Find the observation
|
||||
model = next((m for m in observations if str(m["id"]) == learning_id), None)
|
||||
if not model:
|
||||
return {"action": "skipped", "reason": "learning_not_found"}
|
||||
|
||||
# Build history entry
|
||||
history = list(model.get("history", []))
|
||||
history.append(
|
||||
{
|
||||
"previous_text": model["text"],
|
||||
"changed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"reason": reason,
|
||||
"source_memory_id": str(memory_id),
|
||||
}
|
||||
)
|
||||
|
||||
# Update source_memory_ids
|
||||
source_ids = list(model.get("source_memory_ids", []))
|
||||
source_ids.append(memory_id)
|
||||
|
||||
# SECURITY: Merge source fact's tags into existing observation tags
|
||||
# This ensures all contributors can see the observation they contributed to
|
||||
existing_tags = set(model.get("tags", []) or [])
|
||||
source_tags = set(source_fact_tags or [])
|
||||
merged_tags = list(existing_tags | source_tags) # Union of both tag sets
|
||||
if source_tags and source_tags != existing_tags:
|
||||
logger.debug(
|
||||
f"Security: Merging tags for observation {learning_id}: "
|
||||
f"existing={list(existing_tags)}, source={list(source_tags)}, merged={merged_tags}"
|
||||
)
|
||||
|
||||
# Generate new embedding for updated text
|
||||
t0 = time.time()
|
||||
embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [new_text])
|
||||
embedding_str = str(embeddings[0]) if embeddings else None
|
||||
if perf:
|
||||
perf.record_timing("embedding", time.time() - t0)
|
||||
|
||||
# Update the observation
|
||||
# - occurred_start: LEAST keeps the earliest start time across all source facts
|
||||
# - occurred_end: GREATEST keeps the most recent end time across all source facts
|
||||
# - mentioned_at: GREATEST keeps the most recent mention time
|
||||
# - tags: merged from existing + source fact (for visibility)
|
||||
t0 = time.time()
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {fq_table("memory_units")}
|
||||
SET text = $1,
|
||||
embedding = $2::vector,
|
||||
history = $3,
|
||||
source_memory_ids = $4,
|
||||
proof_count = $5,
|
||||
tags = $10,
|
||||
updated_at = now(),
|
||||
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))
|
||||
WHERE id = $6
|
||||
""",
|
||||
new_text,
|
||||
embedding_str,
|
||||
json.dumps(history),
|
||||
source_ids,
|
||||
len(source_ids),
|
||||
uuid.UUID(learning_id),
|
||||
source_occurred_start,
|
||||
source_occurred_end,
|
||||
source_mentioned_at,
|
||||
merged_tags,
|
||||
)
|
||||
|
||||
# Create links from memory to observation
|
||||
await _create_memory_links(conn, memory_id, uuid.UUID(learning_id))
|
||||
if perf:
|
||||
perf.record_timing("db_write", time.time() - t0)
|
||||
|
||||
logger.debug(f"Updated observation {learning_id} with memory {memory_id}")
|
||||
|
||||
return {"action": "updated", "observation_id": learning_id}
|
||||
|
||||
|
||||
async def _execute_create_action(
|
||||
conn: "Connection",
|
||||
memory_engine: "MemoryEngine",
|
||||
bank_id: str,
|
||||
memory_id: uuid.UUID,
|
||||
action: dict[str, Any],
|
||||
source_fact_tags: list[str] | None = None,
|
||||
event_date: datetime | None = None,
|
||||
occurred_start: datetime | None = None,
|
||||
occurred_end: datetime | None = None,
|
||||
mentioned_at: datetime | None = None,
|
||||
perf: ConsolidationPerfLog | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Execute a create action for a new observation.
|
||||
|
||||
Creates a new observation with the specified text.
|
||||
The text comes directly from the classify LLM - no second LLM call needed.
|
||||
|
||||
Tags are determined algorithmically (not by LLM):
|
||||
- Observations always inherit their source fact's tags
|
||||
- This ensures visibility scope is maintained (security)
|
||||
"""
|
||||
text = action.get("text")
|
||||
|
||||
# Tags are determined algorithmically - always use source fact's tags
|
||||
# This ensures private memories create private observations
|
||||
tags = source_fact_tags or []
|
||||
|
||||
if not text:
|
||||
return {"action": "skipped", "reason": "missing_text"}
|
||||
|
||||
# Use text directly from classify - skip the redundant LLM call
|
||||
result = await _create_observation_directly(
|
||||
conn=conn,
|
||||
memory_engine=memory_engine,
|
||||
bank_id=bank_id,
|
||||
source_memory_id=memory_id,
|
||||
observation_text=text, # Text already processed by classify LLM
|
||||
tags=tags,
|
||||
event_date=event_date,
|
||||
occurred_start=occurred_start,
|
||||
occurred_end=occurred_end,
|
||||
mentioned_at=mentioned_at,
|
||||
perf=perf,
|
||||
)
|
||||
|
||||
logger.debug(f"Created observation {result.get('observation_id')} from memory {memory_id} (tags: {tags})")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
async def _create_memory_links(
|
||||
conn: "Connection",
|
||||
memory_id: uuid.UUID,
|
||||
observation_id: uuid.UUID,
|
||||
) -> None:
|
||||
"""
|
||||
Placeholder for observation link creation.
|
||||
|
||||
Observations do NOT get any memory_links copied from their source facts.
|
||||
Instead, retrieval uses source_memory_ids to traverse:
|
||||
- Entity connections: observation → source_memory_ids → unit_entities
|
||||
- Semantic similarity: observations have their own embeddings
|
||||
- Temporal proximity: observations have their own temporal fields
|
||||
|
||||
This avoids data duplication and ensures observations are always
|
||||
connected via their source facts' relationships.
|
||||
|
||||
The memory_id and observation_id parameters are kept for interface
|
||||
compatibility but no links are created.
|
||||
"""
|
||||
# No links are created - observations rely on source_memory_ids for traversal
|
||||
pass
|
||||
|
||||
|
||||
async def _find_related_observations(
|
||||
conn: "Connection",
|
||||
memory_engine: "MemoryEngine",
|
||||
bank_id: str,
|
||||
query: str,
|
||||
request_context: "RequestContext",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Find observations related to the given query using the full recall system.
|
||||
|
||||
IMPORTANT: We do NOT filter by tags here. Consolidation needs to see ALL
|
||||
potentially related observations regardless of scope, so the LLM can
|
||||
decide on tag routing (same scope update vs cross-scope create).
|
||||
|
||||
This leverages:
|
||||
- Semantic search (embedding similarity)
|
||||
- BM25 text search (keyword matching)
|
||||
- Entity-based retrieval (shared entities)
|
||||
- Graph traversal (connected via entity links)
|
||||
|
||||
Returns:
|
||||
List of related observations with their tags for LLM tag routing
|
||||
"""
|
||||
# Use recall to find related observations
|
||||
# NO tags parameter - we want ALL observations regardless of scope
|
||||
# Use low max_tokens since we only need observations, not memories
|
||||
recall_result = await memory_engine.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
max_tokens=5000, # Token budget for observations
|
||||
fact_type=["observation"], # Only retrieve observations
|
||||
request_context=request_context,
|
||||
_quiet=True, # Suppress logging
|
||||
# NO tags parameter - intentionally get ALL observations
|
||||
)
|
||||
|
||||
# If no observations returned, return empty list
|
||||
# When fact_type=["observation"], results come back in `results` field
|
||||
if not recall_result.results:
|
||||
return []
|
||||
|
||||
# Trust recall's relevance filtering - fetch full data for each observation
|
||||
results = []
|
||||
for obs in recall_result.results:
|
||||
# Fetch full observation data from DB to get history, source_memory_ids, tags
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = $1 AND bank_id = $2 AND fact_type = 'observation'
|
||||
""",
|
||||
uuid.UUID(obs.id),
|
||||
bank_id,
|
||||
)
|
||||
|
||||
if row:
|
||||
history = row["history"]
|
||||
if isinstance(history, str):
|
||||
history = json.loads(history)
|
||||
elif history is None:
|
||||
history = []
|
||||
|
||||
results.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"text": row["text"],
|
||||
"proof_count": row["proof_count"] or 1,
|
||||
"history": history,
|
||||
"tags": row["tags"] or [], # Include tags for LLM tag routing
|
||||
"source_memory_ids": row["source_memory_ids"] or [],
|
||||
"similarity": 1.0, # Retrieved via recall so assumed relevant
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def _consolidate_with_llm(
|
||||
memory_engine: "MemoryEngine",
|
||||
fact_text: str,
|
||||
observations: list[dict[str, Any]],
|
||||
mission: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Single LLM call to extract durable knowledge and decide on consolidation actions.
|
||||
|
||||
This handles ALL cases:
|
||||
- No related observations: extracts durable knowledge, returns create action
|
||||
- Related observations exist: compares and returns update/create actions
|
||||
- Purely ephemeral fact: returns empty array
|
||||
|
||||
Note: Tags are NOT handled by the LLM. They are determined algorithmically:
|
||||
- CREATE: observation inherits source fact's tags
|
||||
- UPDATE: observation merges source fact's tags with existing tags
|
||||
|
||||
Returns:
|
||||
List of actions, each being:
|
||||
- {"action": "update", "learning_id": "uuid", "text": "...", "reason": "..."}
|
||||
- {"action": "create", "text": "...", "reason": "..."}
|
||||
- [] if fact is purely ephemeral (no durable knowledge)
|
||||
"""
|
||||
# Format observations WITH their tags (or "None" if empty)
|
||||
if observations:
|
||||
observations_text = "\n".join(
|
||||
f'- ID: {obs["id"]}, Tags: {json.dumps(obs["tags"])}, Text: "{obs["text"]}" (proof_count: {obs["proof_count"]})'
|
||||
for obs in observations
|
||||
)
|
||||
else:
|
||||
observations_text = "None (this is a new topic - create if fact contains durable knowledge)"
|
||||
|
||||
# Only include mission section if mission is set and not the default
|
||||
mission_section = ""
|
||||
if mission and mission != "General memory consolidation":
|
||||
mission_section = f"""
|
||||
MISSION CONTEXT: {mission}
|
||||
|
||||
Focus on DURABLE knowledge that serves this mission, not ephemeral state.
|
||||
"""
|
||||
|
||||
user_prompt = CONSOLIDATION_USER_PROMPT.format(
|
||||
mission_section=mission_section,
|
||||
fact_text=fact_text,
|
||||
observations_text=observations_text,
|
||||
)
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": CONSOLIDATION_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt},
|
||||
]
|
||||
|
||||
try:
|
||||
result = await memory_engine._consolidation_llm_config.call(
|
||||
messages=messages,
|
||||
skip_validation=True, # Raw JSON response
|
||||
scope="consolidation",
|
||||
)
|
||||
# Parse JSON response - should be an array
|
||||
if isinstance(result, str):
|
||||
result = json.loads(result)
|
||||
# Ensure result is a list
|
||||
if isinstance(result, list):
|
||||
return result
|
||||
# Handle legacy single-action format for backward compatibility
|
||||
if isinstance(result, dict):
|
||||
if result.get("related_ids") and result.get("consolidated_text"):
|
||||
# Convert old format to new format
|
||||
return [
|
||||
{
|
||||
"action": "update",
|
||||
"learning_id": result["related_ids"][0],
|
||||
"text": result["consolidated_text"],
|
||||
"reason": result.get("reason", ""),
|
||||
}
|
||||
]
|
||||
return []
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.warning(f"Error in consolidation LLM call: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def _create_observation_directly(
|
||||
conn: "Connection",
|
||||
memory_engine: "MemoryEngine",
|
||||
bank_id: str,
|
||||
source_memory_id: uuid.UUID,
|
||||
observation_text: str,
|
||||
tags: list[str] | None = None,
|
||||
event_date: datetime | None = None,
|
||||
occurred_start: datetime | None = None,
|
||||
occurred_end: datetime | None = None,
|
||||
mentioned_at: datetime | None = None,
|
||||
perf: ConsolidationPerfLog | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create an observation directly with pre-processed text (no LLM call).
|
||||
|
||||
Used when the classify LLM has already provided the learning text.
|
||||
This avoids the redundant second LLM call.
|
||||
"""
|
||||
# Generate embedding for the observation (convert to string for pgvector)
|
||||
t0 = time.time()
|
||||
embeddings = await embedding_utils.generate_embeddings_batch(memory_engine.embeddings, [observation_text])
|
||||
embedding_str = str(embeddings[0]) if embeddings else None
|
||||
if perf:
|
||||
perf.record_timing("embedding", time.time() - t0)
|
||||
|
||||
# Create the observation as a memory_unit
|
||||
now = datetime.now(timezone.utc)
|
||||
obs_event_date = event_date or now
|
||||
obs_occurred_start = occurred_start or now
|
||||
obs_occurred_end = occurred_end or now
|
||||
obs_mentioned_at = mentioned_at or now
|
||||
obs_tags = tags or []
|
||||
|
||||
t0 = time.time()
|
||||
observation_id = uuid.uuid4()
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
INSERT INTO {fq_table("memory_units")} (
|
||||
id, bank_id, text, fact_type, embedding, proof_count, source_memory_ids, history,
|
||||
tags, event_date, occurred_start, occurred_end, mentioned_at
|
||||
)
|
||||
VALUES ($1, $2, $3, 'observation', $4::vector, 1, $5, '[]'::jsonb, $6, $7, $8, $9, $10)
|
||||
RETURNING id
|
||||
""",
|
||||
observation_id,
|
||||
bank_id,
|
||||
observation_text,
|
||||
embedding_str,
|
||||
[source_memory_id],
|
||||
obs_tags,
|
||||
obs_event_date,
|
||||
obs_occurred_start,
|
||||
obs_occurred_end,
|
||||
obs_mentioned_at,
|
||||
)
|
||||
|
||||
# Create links between memory and observation (includes entity links, memory_links)
|
||||
await _create_memory_links(conn, source_memory_id, observation_id)
|
||||
if perf:
|
||||
perf.record_timing("db_write", time.time() - t0)
|
||||
|
||||
logger.debug(f"Created observation {observation_id} from memory {source_memory_id} (tags: {obs_tags})")
|
||||
|
||||
return {"action": "created", "observation_id": str(row["id"]), "tags": obs_tags}
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Prompts for the consolidation engine."""
|
||||
|
||||
CONSOLIDATION_SYSTEM_PROMPT = """You are a memory consolidation system. Your job is to convert facts into durable knowledge (observations) and merge with existing knowledge when appropriate.
|
||||
|
||||
You must output ONLY valid JSON with no markdown formatting, no code blocks, and no additional text.
|
||||
|
||||
## EXTRACT DURABLE KNOWLEDGE, NOT EPHEMERAL STATE
|
||||
Facts often describe events or actions. Extract the DURABLE KNOWLEDGE implied by the fact, not the transient state.
|
||||
|
||||
Examples of extracting durable knowledge:
|
||||
- "User moved to Room 203" -> "Room 203 exists" (location exists, not where user is now)
|
||||
- "User visited Acme Corp at Room 105" -> "Acme Corp is located in Room 105"
|
||||
- "User took the elevator to floor 3" -> "Floor 3 is accessible by elevator"
|
||||
- "User met Sarah at the lobby" -> "Sarah can be found at the lobby"
|
||||
|
||||
DO NOT track current user position/state as knowledge - that changes constantly.
|
||||
DO track permanent facts learned from the user's actions.
|
||||
|
||||
## PRESERVE SPECIFIC DETAILS
|
||||
Keep names, locations, numbers, and other specifics. Do NOT:
|
||||
- Abstract into general principles
|
||||
- Generate business insights
|
||||
- Make knowledge generic
|
||||
|
||||
GOOD examples:
|
||||
- Fact: "John likes pizza" -> "John likes pizza"
|
||||
- Fact: "Alice works at Google" -> "Alice works at Google"
|
||||
|
||||
BAD examples:
|
||||
- "John likes pizza" -> "Understanding dietary preferences helps..." (TOO ABSTRACT)
|
||||
- "User is at Room 203" -> "User is currently at Room 203" (EPHEMERAL STATE)
|
||||
|
||||
## MERGE RULES (when comparing to existing observations):
|
||||
1. REDUNDANT: Same information worded differently → update existing
|
||||
2. CONTRADICTION: Opposite information about same topic → update with history (e.g., "used to X, now Y")
|
||||
3. UPDATE: New state replacing old state → update with history
|
||||
|
||||
## CRITICAL RULES:
|
||||
- NEVER merge facts about DIFFERENT people
|
||||
- NEVER merge unrelated topics (food preferences vs work vs hobbies)
|
||||
- When merging contradictions, capture the CHANGE (before → after)
|
||||
- Keep observations focused on ONE specific topic per person
|
||||
- The "text" field MUST contain durable knowledge, not ephemeral state
|
||||
- Do NOT include "tags" in output - tags are handled automatically"""
|
||||
|
||||
CONSOLIDATION_USER_PROMPT = """Analyze this new fact and consolidate into knowledge.
|
||||
{mission_section}
|
||||
NEW FACT: {fact_text}
|
||||
|
||||
EXISTING OBSERVATIONS:
|
||||
{observations_text}
|
||||
|
||||
Instructions:
|
||||
1. First, extract the DURABLE KNOWLEDGE from the fact (not ephemeral state like "user is at X")
|
||||
2. Then compare with existing observations:
|
||||
- If an observation covers the same topic: UPDATE it with the new knowledge
|
||||
- If no observation covers the topic: CREATE a new one
|
||||
|
||||
Output JSON array of actions (ALWAYS an array, even for single action):
|
||||
[
|
||||
{{"action": "update", "learning_id": "uuid", "text": "updated durable knowledge", "reason": "..."}},
|
||||
{{"action": "create", "text": "new durable knowledge", "reason": "..."}}
|
||||
]
|
||||
|
||||
If NO consolidation is needed (fact is purely ephemeral with no durable knowledge):
|
||||
[]
|
||||
|
||||
If no observations exist and fact contains durable knowledge:
|
||||
[{{"action": "create", "text": "durable knowledge text", "reason": "new topic"}}]"""
|
||||
@@ -130,13 +130,28 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
"Install it with: pip install sentence-transformers"
|
||||
)
|
||||
|
||||
# Note: We use CPU even when GPU/MPS is available because:
|
||||
# 1. The reranker model (MiniLM) is tiny (~22M params)
|
||||
# 2. Batch sizes are small (~100-200 pairs)
|
||||
# 3. Data transfer overhead to GPU outweighs compute benefit
|
||||
# 4. CPU inference is actually faster for this workload
|
||||
logger.info(f"Reranker: initializing local provider with model {self.model_name}")
|
||||
self._model = CrossEncoder(self.model_name)
|
||||
|
||||
# Determine device based on hardware availability.
|
||||
# We always set low_cpu_mem_usage=False to prevent lazy loading (meta tensors)
|
||||
# which can cause issues when accelerate is installed but no GPU is available.
|
||||
# Note: We do NOT use device_map because CrossEncoder internally calls .to(device)
|
||||
# after loading, which conflicts with accelerate's device_map handling.
|
||||
import torch
|
||||
|
||||
# Check for GPU (CUDA) or Apple Silicon (MPS)
|
||||
has_gpu = torch.cuda.is_available() or (hasattr(torch.backends, "mps") and torch.backends.mps.is_available())
|
||||
|
||||
if has_gpu:
|
||||
device = None # Let sentence-transformers auto-detect GPU/MPS
|
||||
else:
|
||||
device = "cpu"
|
||||
|
||||
self._model = CrossEncoder(
|
||||
self.model_name,
|
||||
device=device,
|
||||
model_kwargs={"low_cpu_mem_usage": False},
|
||||
)
|
||||
|
||||
# Initialize shared executor (limited workers naturally limits concurrency)
|
||||
if LocalSTCrossEncoder._executor is None:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Directives module for hard rules injected into prompts."""
|
||||
|
||||
from .models import Directive
|
||||
|
||||
__all__ = ["Directive"]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Pydantic models for directives."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Directive(BaseModel):
|
||||
"""A directive is a hard rule injected into prompts.
|
||||
|
||||
Directives are user-defined rules that guide agent behavior. Unlike mental models
|
||||
which are automatically consolidated from memories, directives are explicit
|
||||
instructions that are always included in relevant prompts.
|
||||
|
||||
Examples:
|
||||
- "Always respond in formal English"
|
||||
- "Never share personal data with third parties"
|
||||
- "Prefer conservative investment recommendations"
|
||||
"""
|
||||
|
||||
id: UUID = Field(description="Unique identifier")
|
||||
bank_id: str = Field(description="Bank this directive belongs to")
|
||||
name: str = Field(description="Human-readable name")
|
||||
content: str = Field(description="The directive text to inject into prompts")
|
||||
priority: int = Field(default=0, description="Higher priority directives are injected first")
|
||||
is_active: bool = Field(default=True, description="Whether this directive is currently active")
|
||||
tags: list[str] = Field(default_factory=list, description="Tags for filtering")
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc), description="When this directive was created"
|
||||
)
|
||||
updated_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc), description="When this directive was last updated"
|
||||
)
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
@@ -128,11 +128,24 @@ class LocalSTEmbeddings(Embeddings):
|
||||
)
|
||||
|
||||
logger.info(f"Embeddings: initializing local provider with model {self.model_name}")
|
||||
# Disable lazy loading (meta tensors) which causes issues with newer transformers/accelerate
|
||||
# Setting low_cpu_mem_usage=False and device_map=None ensures tensors are fully materialized
|
||||
|
||||
# Determine device based on hardware availability.
|
||||
# We always set low_cpu_mem_usage=False to prevent lazy loading (meta tensors)
|
||||
# which can cause issues when accelerate is installed but no GPU is available.
|
||||
import torch
|
||||
|
||||
# Check for GPU (CUDA) or Apple Silicon (MPS)
|
||||
has_gpu = torch.cuda.is_available() or (hasattr(torch.backends, "mps") and torch.backends.mps.is_available())
|
||||
|
||||
if has_gpu:
|
||||
device = None # Let sentence-transformers auto-detect GPU/MPS
|
||||
else:
|
||||
device = "cpu"
|
||||
|
||||
self._model = SentenceTransformer(
|
||||
self.model_name,
|
||||
model_kwargs={"low_cpu_mem_usage": False, "device_map": None},
|
||||
device=device,
|
||||
model_kwargs={"low_cpu_mem_usage": False},
|
||||
)
|
||||
|
||||
self._dimension = self._model.get_sentence_embedding_dimension()
|
||||
|
||||
@@ -647,7 +647,13 @@ class LLMProvider:
|
||||
success=True,
|
||||
)
|
||||
|
||||
return LLMToolCallResult(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
|
||||
return LLMToolCallResult(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
|
||||
except APIConnectionError as e:
|
||||
last_exception = e
|
||||
@@ -797,6 +803,10 @@ class LLMProvider:
|
||||
content = "".join(content_parts) if content_parts else None
|
||||
finish_reason = "tool_calls" if tool_calls else "stop"
|
||||
|
||||
# Extract token usage
|
||||
input_tokens = response.usage.input_tokens or 0
|
||||
output_tokens = response.usage.output_tokens or 0
|
||||
|
||||
# Record metrics
|
||||
metrics = get_metrics_collector()
|
||||
metrics.record_llm_call(
|
||||
@@ -804,12 +814,18 @@ class LLMProvider:
|
||||
model=self.model,
|
||||
scope=scope,
|
||||
duration=time.time() - start_time,
|
||||
input_tokens=response.usage.input_tokens or 0,
|
||||
output_tokens=response.usage.output_tokens or 0,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
success=True,
|
||||
)
|
||||
|
||||
return LLMToolCallResult(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
|
||||
return LLMToolCallResult(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
|
||||
except (APIConnectionError, APIStatusError) as e:
|
||||
if isinstance(e, APIStatusError) and e.status_code in (401, 403):
|
||||
@@ -930,7 +946,13 @@ class LLMProvider:
|
||||
success=True,
|
||||
)
|
||||
|
||||
return LLMToolCallResult(content=content, tool_calls=tool_calls, finish_reason=finish_reason)
|
||||
return LLMToolCallResult(
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
finish_reason=finish_reason,
|
||||
input_tokens=input_tokens,
|
||||
output_tokens=output_tokens,
|
||||
)
|
||||
|
||||
except genai_errors.APIError as e:
|
||||
if e.code in (401, 403):
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,12 @@
|
||||
"""
|
||||
Mental models module for Hindsight.
|
||||
|
||||
Mental models are synthesized summaries that represent understanding. They come
|
||||
in different subtypes based on how they were created:
|
||||
Mental models contain directives - hard rules that are injected into reflect prompts.
|
||||
Directives are user-defined and their observations are user-provided (not LLM-generated).
|
||||
|
||||
- Structural: Derived from the bank's mission (e.g., "Be a PM for engineering team")
|
||||
These are created upfront based on what any agent with this role would need.
|
||||
|
||||
- Emergent: Discovered from data patterns (named entities, temporal clusters, etc.)
|
||||
These surface organically as facts are retained.
|
||||
|
||||
- Pinned: User-defined models that persist across refreshes.
|
||||
Other types of consolidated knowledge are handled by:
|
||||
- Learnings: Automatic bottom-up consolidation from facts
|
||||
- Pinned Reflections: User-curated living documents
|
||||
"""
|
||||
|
||||
from .models import MentalModel, MentalModelSubtype
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
"""
|
||||
Emergent mental model detection and promotion.
|
||||
|
||||
Emergent models are discovered from data patterns:
|
||||
- Named entity extraction (people, projects, systems)
|
||||
- Temporal clustering (events with multiple references)
|
||||
- Causal patterns ("Because X, we do Y")
|
||||
- Behavioral anchors ("After X, we started Y")
|
||||
- Reference frequency (anything mentioned repeatedly)
|
||||
|
||||
When a pattern is detected, it goes through a mission filter to check relevance,
|
||||
and if relevant, is promoted to a mental model.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .models import EmergentCandidate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..llm_wrapper import LLMConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MissionFilterCandidate(BaseModel):
|
||||
"""Result of mission filtering for a single candidate."""
|
||||
|
||||
name: str
|
||||
promote: bool = Field(description="True if this is a specific named entity worth tracking")
|
||||
reason: str = Field(description="Brief explanation for the decision")
|
||||
|
||||
|
||||
class MissionFilterResponse(BaseModel):
|
||||
"""Response from LLM for mission filtering."""
|
||||
|
||||
candidates: list[MissionFilterCandidate] = Field(description="Filtering decision for each candidate")
|
||||
|
||||
|
||||
def build_mission_filter_prompt(mission: str, candidates: list[EmergentCandidate]) -> str:
|
||||
"""Build the prompt for filtering candidates by mission relevance."""
|
||||
candidate_list = "\n".join(
|
||||
[f"- {c.name} (mentions: {c.mention_count}, method: {c.detection_method})" for c in candidates]
|
||||
)
|
||||
|
||||
return f"""Filter these detected entities. For each one, decide: promote=true or promote=false.
|
||||
|
||||
MISSION: {mission}
|
||||
|
||||
DETECTED ENTITIES:
|
||||
{candidate_list}
|
||||
|
||||
=== DECISION RULES ===
|
||||
|
||||
Set promote=true ONLY for specific, named entities:
|
||||
- Person names: "John", "Maria", "Alice Chen", "Dr. Smith"
|
||||
- Named organizations: "Google", "Acme Corp", "Frontend Team"
|
||||
- Named places: "Central Park Zoo", "NYC Office", "Building A"
|
||||
- Named projects: "Project Phoenix", "Auth Service v2"
|
||||
|
||||
Set promote=false for EVERYTHING ELSE, including:
|
||||
- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event
|
||||
- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community
|
||||
- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience
|
||||
|
||||
THE TEST: Is this a specific name you'd find in a contact list or org chart?
|
||||
- "John" → YES (promote=true)
|
||||
- "kids" → NO (promote=false)
|
||||
- "community" → NO (promote=false)
|
||||
- "Maria" → YES (promote=true)
|
||||
- "park" → NO (promote=false)
|
||||
|
||||
When in doubt, set promote=false."""
|
||||
|
||||
|
||||
def get_mission_filter_system_message() -> str:
|
||||
"""System message for mission filtering."""
|
||||
return """You filter entities for promotion. Output JSON with 'candidates' array.
|
||||
|
||||
Rules:
|
||||
- promote=true ONLY for specific names (people, organizations, named places/projects)
|
||||
- promote=false for common words, generic categories, abstract concepts
|
||||
|
||||
Examples:
|
||||
- "John" → promote=true (person name)
|
||||
- "kids" → promote=false (generic category)
|
||||
- "community" → promote=false (abstract concept)
|
||||
- "Google" → promote=true (organization name)
|
||||
- "motivation" → promote=false (abstract concept)
|
||||
|
||||
When in doubt, promote=false. Most entities should be rejected."""
|
||||
|
||||
|
||||
async def filter_candidates_by_mission(
|
||||
llm_config: "LLMConfig",
|
||||
mission: str,
|
||||
candidates: list[EmergentCandidate],
|
||||
) -> list[EmergentCandidate]:
|
||||
"""
|
||||
Filter emergent candidates to keep only specific, named entities.
|
||||
|
||||
Args:
|
||||
llm_config: LLM configuration
|
||||
mission: The bank's mission (used for context)
|
||||
candidates: List of detected candidates
|
||||
|
||||
Returns:
|
||||
Filtered list of candidates that are specific named entities
|
||||
"""
|
||||
if not candidates:
|
||||
return []
|
||||
|
||||
if not mission:
|
||||
# No mission = no filtering, keep all candidates
|
||||
logger.debug("[EMERGENT] No mission set, skipping filter")
|
||||
return candidates
|
||||
|
||||
prompt = build_mission_filter_prompt(mission, candidates)
|
||||
|
||||
try:
|
||||
result = await llm_config.call(
|
||||
messages=[
|
||||
{"role": "system", "content": get_mission_filter_system_message()},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
response_format=MissionFilterResponse,
|
||||
scope="mental_model_mission_filter",
|
||||
)
|
||||
|
||||
# Build name -> promote map
|
||||
promote_map = {c.name: c.promote for c in result.candidates}
|
||||
|
||||
# Filter candidates
|
||||
filtered = []
|
||||
for candidate in candidates:
|
||||
if candidate.name in promote_map:
|
||||
if promote_map[candidate.name]:
|
||||
filtered.append(candidate)
|
||||
logger.debug(f"[EMERGENT] Promoting '{candidate.name}'")
|
||||
else:
|
||||
logger.debug(f"[EMERGENT] Rejecting '{candidate.name}'")
|
||||
else:
|
||||
# Candidate not in response - reject by default
|
||||
logger.debug(f"[EMERGENT] '{candidate.name}' not in response, rejecting")
|
||||
|
||||
logger.info(f"[EMERGENT] Mission filter: {len(filtered)}/{len(candidates)} candidates promoted")
|
||||
return filtered
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[EMERGENT] Mission filter failed, rejecting all candidates: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def evaluate_emergent_models(
|
||||
llm_config: "LLMConfig",
|
||||
models: list[dict],
|
||||
) -> list[str]:
|
||||
"""
|
||||
Evaluate existing emergent models to check if they should be kept.
|
||||
|
||||
This re-evaluates emergent models using the same filtering criteria
|
||||
as new candidates. Models that are generic/abstract will be removed.
|
||||
|
||||
Args:
|
||||
llm_config: LLM configuration
|
||||
models: List of existing emergent model dicts with 'name', 'id'
|
||||
|
||||
Returns:
|
||||
List of model IDs that should be REMOVED (no longer valid)
|
||||
"""
|
||||
if not models:
|
||||
return []
|
||||
|
||||
# Convert existing models to candidates for evaluation
|
||||
candidates = [
|
||||
EmergentCandidate(
|
||||
name=m["name"],
|
||||
detection_method="existing_emergent_model",
|
||||
mention_count=0,
|
||||
)
|
||||
for m in models
|
||||
]
|
||||
|
||||
# Build a simple prompt for re-evaluation
|
||||
names_list = "\n".join([f"- {m['name']}" for m in models])
|
||||
prompt = f"""Re-evaluate these existing mental models. For each one, decide: promote=true (keep) or promote=false (remove).
|
||||
|
||||
EXISTING MODELS:
|
||||
{names_list}
|
||||
|
||||
=== DECISION RULES ===
|
||||
|
||||
Set promote=true ONLY for specific, named entities:
|
||||
- Person names: "John", "Maria", "Alice Chen", "Dr. Smith"
|
||||
- Named organizations: "Google", "Acme Corp", "Frontend Team"
|
||||
- Named places: "Central Park Zoo", "NYC Office", "Building A"
|
||||
- Named projects: "Project Phoenix", "Auth Service v2"
|
||||
|
||||
Set promote=false for EVERYTHING ELSE, including:
|
||||
- Common English words: user, support, help, family, kids, parents, friends, people, team, photo, nature, park, office, home, work, school, joy, love, hope, fear, anger, gratitude, kindness, passion, motivation, inspiration, encouragement, positivity, energy, community, connection, commitment, collaboration, growth, impact, difference, success, progress, change, education, volunteering, veterans, homeless, shelter, meeting, project, system, process, event
|
||||
- Generic categories (even capitalized): Users, Customers, Team, Family, Kids, Veterans, Community
|
||||
- Abstract concepts: motivation, inspiration, gratitude, commitment, resilience
|
||||
|
||||
THE TEST: Is this a specific name you'd find in a contact list or org chart?
|
||||
- "John" → YES (promote=true)
|
||||
- "kids" → NO (promote=false)
|
||||
- "community" → NO (promote=false)
|
||||
|
||||
When in doubt, set promote=false."""
|
||||
|
||||
try:
|
||||
result = await llm_config.call(
|
||||
messages=[
|
||||
{"role": "system", "content": get_mission_filter_system_message()},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
response_format=MissionFilterResponse,
|
||||
scope="mental_model_emergent_evaluation",
|
||||
)
|
||||
|
||||
# Build name -> promote map
|
||||
promote_map = {c.name: c.promote for c in result.candidates}
|
||||
|
||||
# Find models to remove
|
||||
models_to_remove = []
|
||||
for model in models:
|
||||
name = model["name"]
|
||||
if name in promote_map:
|
||||
if not promote_map[name]:
|
||||
models_to_remove.append(model["id"])
|
||||
else:
|
||||
logger.debug(f"[EMERGENT] Keeping '{name}'")
|
||||
else:
|
||||
# Model not in response - remove to be safe
|
||||
logger.info(f"[EMERGENT] '{name}' not in evaluation response, marking for removal")
|
||||
models_to_remove.append(model["id"])
|
||||
|
||||
logger.info(f"[EMERGENT] Evaluation: {len(models_to_remove)}/{len(models)} emergent models marked for removal")
|
||||
return models_to_remove
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[EMERGENT] Evaluation failed, keeping all models: {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def detect_entity_candidates(
|
||||
pool,
|
||||
bank_id: str,
|
||||
min_mentions: int = 5,
|
||||
top_percent: int = 20,
|
||||
) -> list[EmergentCandidate]:
|
||||
"""
|
||||
Detect entities that are candidates for promotion to mental models.
|
||||
|
||||
Args:
|
||||
pool: Database connection pool
|
||||
bank_id: Bank identifier
|
||||
min_mentions: Minimum mention count to consider
|
||||
top_percent: Only consider top X% by mention count
|
||||
|
||||
Returns:
|
||||
List of entity candidates
|
||||
"""
|
||||
from ..db_utils import acquire_with_retry
|
||||
from ..memory_engine import fq_table
|
||||
|
||||
candidates = []
|
||||
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
# Get entities that meet criteria and don't already have mental models
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
WITH ranked AS (
|
||||
SELECT
|
||||
e.id,
|
||||
e.canonical_name,
|
||||
e.mention_count,
|
||||
PERCENT_RANK() OVER (ORDER BY e.mention_count DESC) as rank_pct
|
||||
FROM {fq_table("entities")} e
|
||||
LEFT JOIN {fq_table("mental_models")} mm
|
||||
ON mm.entity_id = e.id AND mm.bank_id = e.bank_id
|
||||
WHERE e.bank_id = $1
|
||||
AND e.mention_count >= $2
|
||||
AND mm.id IS NULL -- Not already a mental model
|
||||
)
|
||||
SELECT id, canonical_name, mention_count
|
||||
FROM ranked
|
||||
WHERE rank_pct <= $3
|
||||
ORDER BY mention_count DESC
|
||||
LIMIT 50
|
||||
""",
|
||||
bank_id,
|
||||
min_mentions,
|
||||
top_percent / 100.0,
|
||||
)
|
||||
|
||||
for row in rows:
|
||||
candidates.append(
|
||||
EmergentCandidate(
|
||||
name=row["canonical_name"],
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=row["mention_count"],
|
||||
entity_id=str(row["id"]),
|
||||
relevance_score=0.0,
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(f"[EMERGENT] Detected {len(candidates)} entity candidates")
|
||||
return candidates
|
||||
@@ -9,12 +9,14 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MentalModelSubtype(str, Enum):
|
||||
"""Subtype of mental model - how it was created."""
|
||||
"""Subtype of mental model.
|
||||
|
||||
Currently only DIRECTIVE is supported. Other types of consolidated knowledge
|
||||
are handled by:
|
||||
- Learnings: Automatic bottom-up consolidation from facts
|
||||
- Pinned Reflections: User-curated living documents
|
||||
"""
|
||||
|
||||
STRUCTURAL = "structural" # Derived from mission, created upfront
|
||||
EMERGENT = "emergent" # Discovered from data patterns
|
||||
LEARNED = "learned" # Formed through reflection
|
||||
PINNED = "pinned" # User-defined topic, observations LLM-generated
|
||||
DIRECTIVE = "directive" # User-defined hard rules, observations user-provided
|
||||
|
||||
|
||||
@@ -49,50 +51,3 @@ class MentalModel(BaseModel):
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc), description="When this model was created"
|
||||
)
|
||||
|
||||
|
||||
class StructuralModelTemplate(BaseModel):
|
||||
"""
|
||||
A template for a structural mental model.
|
||||
|
||||
Generated by LLM based on the bank's mission. Represents what any agent
|
||||
with this role would need to track.
|
||||
"""
|
||||
|
||||
id: str = Field(default="", description="Existing model ID to keep, or empty for new models")
|
||||
name: str = Field(description="Human-readable name")
|
||||
description: str = Field(description="What this model should track")
|
||||
initial_probes: list[str] = Field(default_factory=list, description="Initial search queries to populate this model")
|
||||
|
||||
|
||||
class StructuralModelDerivationResponse(BaseModel):
|
||||
"""Response from LLM for structural model derivation."""
|
||||
|
||||
templates: list[StructuralModelTemplate] = Field(description="Structural model templates derived from the mission")
|
||||
|
||||
|
||||
class EmergentCandidate(BaseModel):
|
||||
"""
|
||||
A candidate for promotion to emergent mental model.
|
||||
|
||||
Detected through pattern analysis of facts.
|
||||
"""
|
||||
|
||||
name: str = Field(description="Name of the detected pattern/entity")
|
||||
detection_method: str = Field(description="How this candidate was detected")
|
||||
mention_count: int = Field(default=0, description="How many times referenced")
|
||||
entity_id: str | None = Field(default=None, description="Entity ID if detected as entity")
|
||||
relevance_score: float = Field(default=0.0, description="Score from mission filter (0-1)")
|
||||
|
||||
|
||||
class ResearchResult(BaseModel):
|
||||
"""
|
||||
Result from the research endpoint.
|
||||
|
||||
Contains the answer along with the mental models and facts used.
|
||||
"""
|
||||
|
||||
answer: str = Field(description="The synthesized answer")
|
||||
mental_models_used: list[str] = Field(default_factory=list, description="IDs of mental models that contributed")
|
||||
facts_used: list[str] = Field(default_factory=list, description="Fact IDs that contributed")
|
||||
question_type: str | None = Field(default=None, description="Detected question type (WHO, WHAT, HOW, etc.)")
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
"""
|
||||
Structural mental model derivation from bank mission.
|
||||
|
||||
Structural models are derived from the bank's mission - they represent what
|
||||
any agent with this role would need to track. For example:
|
||||
|
||||
Mission: "Be a PM for engineering team"
|
||||
Structural models:
|
||||
- Team Structure (who's on the team, roles)
|
||||
- Project Overview (current projects, status)
|
||||
- Processes (how releases work, how decisions are made)
|
||||
- Key Systems (what we own, dependencies)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .models import StructuralModelTemplate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..llm_wrapper import LLMConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StructuralDerivationResponse(BaseModel):
|
||||
"""Response from LLM for structural model derivation."""
|
||||
|
||||
templates: list[StructuralModelTemplate] = Field(description="Structural model templates derived from the mission")
|
||||
|
||||
|
||||
class StructuralRelevanceResult(BaseModel):
|
||||
"""Result of evaluating a structural model's relevance to the mission."""
|
||||
|
||||
name: str
|
||||
relevant: bool
|
||||
reason: str
|
||||
|
||||
|
||||
class StructuralRelevanceResponse(BaseModel):
|
||||
"""Response from LLM for structural model relevance evaluation."""
|
||||
|
||||
models: list[StructuralRelevanceResult] = Field(description="Relevance evaluation for each model")
|
||||
|
||||
|
||||
def build_structural_derivation_prompt(mission: str, existing_models: list[dict] | None = None) -> str:
|
||||
"""Build the prompt for deriving structural models from a mission."""
|
||||
existing_section = ""
|
||||
if existing_models:
|
||||
model_list = "\n".join([f"- id='{m['id']}' name='{m['name']}': {m['description']}" for m in existing_models])
|
||||
existing_section = f"""
|
||||
EXISTING STRUCTURAL MODELS:
|
||||
{model_list}
|
||||
|
||||
IMPORTANT: If keeping an existing model, you MUST return its EXACT 'id' value.
|
||||
Models not included in your output will be REMOVED.
|
||||
"""
|
||||
|
||||
return f"""Given this agent mission, identify the KEY THINGS to track to achieve it.
|
||||
|
||||
MISSION: {mission}
|
||||
{existing_section}
|
||||
IMPORTANT CONSTRAINTS:
|
||||
- Return 0-3 structural models MAXIMUM (less is better!)
|
||||
- Only include models for SPECIFIC, CONCRETE things the agent needs to track
|
||||
- Each model must be DIRECTLY tied to achieving the mission
|
||||
- If the mission is simple, return 0 models (empty array is fine)
|
||||
- If existing models are provided and you want to keep one, use its EXACT id
|
||||
- Do NOT create near-duplicates (e.g., don't create "topic-map" if "topic-connections" exists)
|
||||
|
||||
GOOD examples (specific, actionable):
|
||||
- Mission: "Be a PM for engineering team" → "Team Members" (track who's on the team)
|
||||
- Mission: "Track customer feedback" → "Customer Issues" (track specific complaints/requests)
|
||||
- Mission: "Manage project X" → "Project X Milestones" (track progress)
|
||||
|
||||
BAD examples (too generic, don't create these):
|
||||
- "Processes", "Workflows", "Key Systems", "Important Events"
|
||||
- "Communication", "Collaboration", "Progress", "Status"
|
||||
- Generic role-based models not tied to the specific mission
|
||||
|
||||
For each model:
|
||||
1. id: Use EXACT existing id if keeping a model, or leave empty for new models
|
||||
2. name: Short, specific name (e.g., "Team Members", "Sprint Goals")
|
||||
3. description: One line describing what to track
|
||||
4. initial_probes: 2-3 search queries to find relevant information
|
||||
|
||||
Return ONLY the models that should exist. Existing models not in your output will be deleted."""
|
||||
|
||||
|
||||
def get_structural_derivation_system_message() -> str:
|
||||
"""System message for structural model derivation."""
|
||||
return """You identify the key things to track for a mission. Be VERY selective.
|
||||
|
||||
Rules:
|
||||
- Maximum 3 models (prefer fewer)
|
||||
- Only SPECIFIC, CONCRETE things - not generic categories
|
||||
- Each must DIRECTLY help achieve the mission
|
||||
- Empty array is valid if no models are truly needed
|
||||
- If existing models are shown and you want to keep one, return its EXACT id
|
||||
- Never create duplicates - if a similar model exists, keep the existing one
|
||||
|
||||
Output JSON with 'templates' array (can be empty)."""
|
||||
|
||||
|
||||
def _normalize_id(text: str) -> str:
|
||||
"""Normalize a string to a canonical form for comparison.
|
||||
|
||||
Removes common suffixes, pluralization, and normalizes separators.
|
||||
"""
|
||||
# Lowercase and normalize separators
|
||||
normalized = text.lower().replace(" ", "-").replace("_", "-")
|
||||
|
||||
# Remove common suffixes that indicate the same concept
|
||||
suffixes_to_remove = ["-map", "-list", "-overview", "-tracker", "-s"]
|
||||
for suffix in suffixes_to_remove:
|
||||
if normalized.endswith(suffix) and len(normalized) > len(suffix):
|
||||
normalized = normalized[: -len(suffix)]
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _find_similar_existing_id(new_id: str, existing_models: list[dict]) -> str | None:
|
||||
"""Find an existing model ID that is similar to the new ID.
|
||||
|
||||
Returns the existing ID if a similar one is found, None otherwise.
|
||||
"""
|
||||
if not existing_models:
|
||||
return None
|
||||
|
||||
new_normalized = _normalize_id(new_id)
|
||||
|
||||
for model in existing_models:
|
||||
existing_id = model.get("id", "")
|
||||
existing_normalized = _normalize_id(existing_id)
|
||||
|
||||
# Check if one is a prefix of the other (normalized)
|
||||
if new_normalized.startswith(existing_normalized) or existing_normalized.startswith(new_normalized):
|
||||
return existing_id
|
||||
|
||||
# Check if they're the same when normalized
|
||||
if new_normalized == existing_normalized:
|
||||
return existing_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def derive_structural_models(
|
||||
llm_config: "LLMConfig",
|
||||
mission: str,
|
||||
existing_models: list[dict] | None = None,
|
||||
) -> tuple[list[StructuralModelTemplate], list[str]]:
|
||||
"""
|
||||
Derive structural model templates from a bank's mission.
|
||||
|
||||
This combines derivation and evaluation in one call. The LLM sees existing
|
||||
models and decides which to keep. Any existing model not in the output
|
||||
will be marked for removal.
|
||||
|
||||
Args:
|
||||
llm_config: LLM configuration for calling the model
|
||||
mission: The bank's mission (e.g., "Be a PM for engineering team")
|
||||
existing_models: Optional list of existing model dicts with 'name', 'description', 'id'
|
||||
|
||||
Returns:
|
||||
Tuple of (templates to create/keep, IDs of existing models to remove)
|
||||
|
||||
Raises:
|
||||
Exception: If LLM call fails
|
||||
"""
|
||||
prompt = build_structural_derivation_prompt(mission, existing_models)
|
||||
|
||||
result = await llm_config.call(
|
||||
messages=[
|
||||
{"role": "system", "content": get_structural_derivation_system_message()},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
response_format=StructuralDerivationResponse,
|
||||
scope="mental_model_structural_derivation",
|
||||
)
|
||||
|
||||
templates = result.templates
|
||||
logger.info(f"[STRUCTURAL] LLM returned {len(templates)} structural models")
|
||||
|
||||
# Build set of existing IDs for quick lookup
|
||||
existing_ids = {m["id"] for m in existing_models} if existing_models else set()
|
||||
|
||||
# Process templates: validate IDs, deduplicate, assign stable IDs
|
||||
processed_templates: list[StructuralModelTemplate] = []
|
||||
kept_existing_ids: set[str] = set()
|
||||
|
||||
for template in templates:
|
||||
# If LLM returned an ID, check if it's a valid existing ID
|
||||
if template.id and template.id in existing_ids:
|
||||
# LLM is keeping an existing model
|
||||
kept_existing_ids.add(template.id)
|
||||
processed_templates.append(template)
|
||||
logger.info(f"[STRUCTURAL] Keeping existing model: {template.id}")
|
||||
else:
|
||||
# New model or LLM didn't return a valid ID
|
||||
# Generate ID from name
|
||||
generated_id = template.name.lower().replace(" ", "-").replace("_", "-")
|
||||
|
||||
# Check for similar existing models to prevent near-duplicates
|
||||
similar_id = _find_similar_existing_id(generated_id, existing_models)
|
||||
if similar_id and similar_id not in kept_existing_ids:
|
||||
# Use the existing similar model instead of creating a new one
|
||||
logger.info(f"[STRUCTURAL] Detected near-duplicate: '{generated_id}' matches existing '{similar_id}'")
|
||||
template.id = similar_id
|
||||
kept_existing_ids.add(similar_id)
|
||||
else:
|
||||
template.id = generated_id
|
||||
|
||||
processed_templates.append(template)
|
||||
|
||||
# Find existing models to remove (not kept in LLM output)
|
||||
models_to_remove = []
|
||||
if existing_models:
|
||||
for model in existing_models:
|
||||
if model["id"] not in kept_existing_ids:
|
||||
logger.info(f"[STRUCTURAL] Marking '{model['name']}' (id={model['id']}) for removal")
|
||||
models_to_remove.append(model["id"])
|
||||
|
||||
if models_to_remove:
|
||||
logger.info(f"[STRUCTURAL] {len(models_to_remove)} existing models will be removed")
|
||||
|
||||
return processed_templates, models_to_remove
|
||||
@@ -4,17 +4,15 @@ Reflect agent module for agentic reflection with tools.
|
||||
The reflect agent uses an iterative loop with tools to:
|
||||
1. Lookup mental models (existing knowledge)
|
||||
2. Recall facts (semantic + temporal search)
|
||||
3. Learn new insights (create/update mental models)
|
||||
4. Expand memories (get chunk/document context)
|
||||
3. Expand memories (get chunk/document context)
|
||||
"""
|
||||
|
||||
from .agent import ReflectAgentResult, run_reflect_agent
|
||||
from .models import MentalModelInput, ReflectAction, ReflectActionBatch
|
||||
from .models import ReflectAction, ReflectActionBatch
|
||||
|
||||
__all__ = [
|
||||
"run_reflect_agent",
|
||||
"ReflectAgentResult",
|
||||
"ReflectAction",
|
||||
"ReflectActionBatch",
|
||||
"MentalModelInput",
|
||||
]
|
||||
|
||||
@@ -1,20 +1,31 @@
|
||||
"""
|
||||
Reflect agent - agentic loop for reflection with native tool calling.
|
||||
|
||||
Uses hierarchical retrieval:
|
||||
1. search_mental_models - User-curated summaries (highest quality)
|
||||
2. search_observations - Consolidated knowledge with freshness
|
||||
3. recall - Raw facts as ground truth
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||
|
||||
from .models import DirectiveInfo, LLMCall, MentalModelInput, ReflectAgentResult, ToolCall
|
||||
from .models import DirectiveInfo, LLMCall, ReflectAgentResult, TokenUsageSummary, ToolCall
|
||||
from .prompts import FINAL_SYSTEM_PROMPT, _extract_directive_rules, build_final_prompt, build_system_prompt_for_tools
|
||||
from .tools_schema import get_reflect_tools
|
||||
|
||||
|
||||
def _build_directives_applied(directives: list[dict[str, Any]] | None) -> list[DirectiveInfo]:
|
||||
"""Build list of DirectiveInfo from directive mental models."""
|
||||
"""Build list of DirectiveInfo from directive mental models.
|
||||
|
||||
Handles multiple directive formats:
|
||||
1. New format: directives have direct 'content' field
|
||||
2. Fallback: directives have 'description' field
|
||||
"""
|
||||
if not directives:
|
||||
return []
|
||||
|
||||
@@ -22,17 +33,11 @@ def _build_directives_applied(directives: list[dict[str, Any]] | None) -> list[D
|
||||
for directive in directives:
|
||||
directive_id = directive.get("id", "")
|
||||
directive_name = directive.get("name", "")
|
||||
observations = directive.get("observations", [])
|
||||
|
||||
rules = []
|
||||
for obs in observations:
|
||||
# Support both Pydantic Observation objects and dicts
|
||||
if hasattr(obs, "content"):
|
||||
rules.append(obs.content)
|
||||
elif isinstance(obs, dict) and obs.get("content"):
|
||||
rules.append(obs["content"])
|
||||
# Get content from 'content' field or fallback to 'description'
|
||||
content = directive.get("content", "") or directive.get("description", "")
|
||||
|
||||
result.append(DirectiveInfo(id=directive_id, name=directive_name, rules=rules))
|
||||
result.append(DirectiveInfo(id=directive_id, name=directive_name, content=content))
|
||||
|
||||
return result
|
||||
|
||||
@@ -46,12 +51,92 @@ logger = logging.getLogger(__name__)
|
||||
DEFAULT_MAX_ITERATIONS = 10
|
||||
|
||||
|
||||
def _normalize_tool_name(name: str) -> str:
|
||||
"""Normalize tool name from various LLM output formats.
|
||||
|
||||
Some LLMs output tool names in non-standard formats:
|
||||
- 'functions.done' (OpenAI-style prefix)
|
||||
- 'call=functions.done' (some models)
|
||||
- 'call=done' (some models)
|
||||
|
||||
Returns the normalized tool name (e.g., 'done', 'recall', etc.)
|
||||
"""
|
||||
# Handle 'call=functions.name' or 'call=name' format
|
||||
if name.startswith("call="):
|
||||
name = name[len("call=") :]
|
||||
|
||||
# Handle 'functions.name' format
|
||||
if name.startswith("functions."):
|
||||
name = name[len("functions.") :]
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def _is_done_tool(name: str) -> bool:
|
||||
"""Check if the tool name represents the 'done' tool."""
|
||||
return _normalize_tool_name(name) == "done"
|
||||
|
||||
|
||||
# Pattern to match done() call as text - handles done({...}) with nested JSON
|
||||
_DONE_CALL_PATTERN = re.compile(r"done\s*\(\s*\{.*$", re.DOTALL)
|
||||
|
||||
# Patterns for leaked structured output in the answer field
|
||||
_LEAKED_JSON_SUFFIX = re.compile(
|
||||
r'\s*```(?:json)?\s*\{[^}]*(?:"(?:observation_ids|memory_ids|mental_model_ids)"|\})\s*```\s*$',
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
_LEAKED_JSON_OBJECT = re.compile(
|
||||
r'\s*\{[^{]*"(?:observation_ids|memory_ids|mental_model_ids|answer)"[^}]*\}\s*$', re.DOTALL
|
||||
)
|
||||
_TRAILING_IDS_PATTERN = re.compile(
|
||||
r"\s*(?:observation_ids|memory_ids|mental_model_ids)\s*[=:]\s*\[.*?\]\s*$", re.DOTALL | re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _clean_answer_text(text: str) -> str:
|
||||
"""Clean up answer text by removing any done() tool call syntax.
|
||||
|
||||
Some LLMs output the done() call as text instead of a proper tool call.
|
||||
This strips out patterns like: done({"answer": "...", ...})
|
||||
"""
|
||||
# Remove done() call pattern from the end of the text
|
||||
cleaned = _DONE_CALL_PATTERN.sub("", text).strip()
|
||||
return cleaned if cleaned else text
|
||||
|
||||
|
||||
def _clean_done_answer(text: str) -> str:
|
||||
"""Clean up the answer field from a done() tool call.
|
||||
|
||||
Some LLMs leak structured output patterns into the answer text, such as:
|
||||
- JSON code blocks with observation_ids/memory_ids at the end
|
||||
- Raw JSON objects with these fields
|
||||
- Plain text like "observation_ids: [...]"
|
||||
|
||||
This cleans those patterns while preserving the actual answer content.
|
||||
"""
|
||||
if not text:
|
||||
return text
|
||||
|
||||
cleaned = text
|
||||
|
||||
# Remove leaked JSON in code blocks at the end
|
||||
cleaned = _LEAKED_JSON_SUFFIX.sub("", cleaned).strip()
|
||||
|
||||
# Remove leaked raw JSON objects at the end
|
||||
cleaned = _LEAKED_JSON_OBJECT.sub("", cleaned).strip()
|
||||
|
||||
# Remove trailing ID patterns
|
||||
cleaned = _TRAILING_IDS_PATTERN.sub("", cleaned).strip()
|
||||
|
||||
return cleaned if cleaned else text
|
||||
|
||||
|
||||
async def _generate_structured_output(
|
||||
answer: str,
|
||||
response_schema: dict,
|
||||
llm_config: "LLMProvider",
|
||||
reflect_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
) -> tuple[dict[str, Any] | None, int, int]:
|
||||
"""Generate structured output from an answer using the provided JSON schema.
|
||||
|
||||
Args:
|
||||
@@ -61,7 +146,8 @@ async def _generate_structured_output(
|
||||
reflect_id: Reflect ID for logging
|
||||
|
||||
Returns:
|
||||
Structured output dict if successful, None otherwise
|
||||
Tuple of (structured_output, input_tokens, output_tokens).
|
||||
structured_output is None if generation fails.
|
||||
"""
|
||||
try:
|
||||
from typing import Any as TypingAny
|
||||
@@ -94,41 +180,62 @@ async def _generate_structured_output(
|
||||
fields[field_name] = (field_type, default)
|
||||
|
||||
if not fields:
|
||||
return None
|
||||
logger.warning(f"[REFLECT {reflect_id}] No fields found in response_schema, skipping structured output")
|
||||
return None, 0, 0
|
||||
|
||||
DynamicModel = create_model("StructuredResponse", **fields)
|
||||
|
||||
# Include the full schema in the prompt for better LLM guidance
|
||||
schema_str = json.dumps(response_schema, indent=2)
|
||||
|
||||
# Build field descriptions for the prompt
|
||||
field_descriptions = []
|
||||
for field_name, field_schema in schema_props.items():
|
||||
field_type = field_schema.get("type", "string")
|
||||
field_desc = field_schema.get("description", "")
|
||||
is_required = field_name in required_fields
|
||||
req_marker = " (REQUIRED)" if is_required else " (optional)"
|
||||
field_descriptions.append(f"- {field_name} ({field_type}){req_marker}: {field_desc}")
|
||||
fields_text = "\n".join(field_descriptions)
|
||||
|
||||
# Call LLM with the answer to extract structured data
|
||||
structured_prompt = f"""Based on this answer, extract the information into the requested structured format.
|
||||
structured_prompt = f"""Your task is to extract specific information from the answer below and format it as JSON.
|
||||
|
||||
Answer: {answer}
|
||||
ANSWER TO EXTRACT FROM:
|
||||
\"\"\"
|
||||
{answer}
|
||||
\"\"\"
|
||||
|
||||
JSON Schema to follow:
|
||||
REQUIRED OUTPUT FORMAT - Extract the following fields from the answer above:
|
||||
{fields_text}
|
||||
|
||||
JSON Schema:
|
||||
```json
|
||||
{schema_str}
|
||||
```
|
||||
|
||||
Return ONLY a valid JSON object that matches this exact schema. Pay special attention to field types:
|
||||
- "type": "array" means the value must be a JSON array/list, NOT a string
|
||||
- "type": "string" means the value must be a string
|
||||
- "type": "object" means the value must be a JSON object
|
||||
INSTRUCTIONS:
|
||||
1. Read the answer carefully and identify the information that matches each field
|
||||
2. Extract the ACTUAL content from the answer - do NOT leave fields empty if information is present
|
||||
3. For string fields: use the exact text or a clear summary from the answer
|
||||
4. For array fields: return a JSON array (e.g., ["item1", "item2"]), NOT a string
|
||||
5. For required fields: you MUST provide a value extracted from the answer
|
||||
6. Return ONLY the JSON object, no explanation
|
||||
|
||||
Do not include any explanation, only the JSON object."""
|
||||
OUTPUT:"""
|
||||
|
||||
structured_result = await llm_config.call(
|
||||
structured_result, usage = await llm_config.call(
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": "Extract structured data from the given answer. Return only valid JSON matching the provided schema exactly.",
|
||||
"content": "You are a precise data extraction assistant. Extract information from text and return it as valid JSON matching the provided schema. Always extract actual content - never return empty strings for required fields if information is available.",
|
||||
},
|
||||
{"role": "user", "content": structured_prompt},
|
||||
],
|
||||
response_format=DynamicModel,
|
||||
scope="reflect_structured",
|
||||
skip_validation=True, # We'll handle the dict ourselves
|
||||
return_usage=True,
|
||||
)
|
||||
|
||||
# Convert to dict
|
||||
@@ -140,12 +247,18 @@ Do not include any explanation, only the JSON object."""
|
||||
# Try to parse as JSON
|
||||
structured_output = json.loads(str(structured_result))
|
||||
|
||||
# Validate that required fields have non-empty values
|
||||
for field_name in required_fields:
|
||||
value = structured_output.get(field_name)
|
||||
if value is None or value == "" or value == []:
|
||||
logger.warning(f"[REFLECT {reflect_id}] Required field '{field_name}' is empty in structured output")
|
||||
|
||||
logger.info(f"[REFLECT {reflect_id}] Generated structured output with {len(structured_output)} fields")
|
||||
return structured_output
|
||||
return structured_output, usage.input_tokens, usage.output_tokens
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"[REFLECT {reflect_id}] Failed to generate structured output: {e}")
|
||||
return None
|
||||
return None, 0, 0
|
||||
|
||||
|
||||
async def run_reflect_agent(
|
||||
@@ -153,32 +266,35 @@ async def run_reflect_agent(
|
||||
bank_id: str,
|
||||
query: str,
|
||||
bank_profile: dict[str, Any],
|
||||
lookup_fn: Callable[[str | None], Awaitable[dict[str, Any]]],
|
||||
search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
|
||||
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
|
||||
recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
|
||||
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
|
||||
learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None,
|
||||
context: str | None = None,
|
||||
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
||||
max_tokens: int | None = None,
|
||||
response_schema: dict | None = None,
|
||||
directives: list[dict[str, Any]] | None = None,
|
||||
has_mental_models: bool = False,
|
||||
budget: str | None = None,
|
||||
) -> ReflectAgentResult:
|
||||
"""
|
||||
Execute the reflect agent loop using native tool calling.
|
||||
|
||||
The agent iteratively calls tools to gather information and learn,
|
||||
then provides a final answer via the done() tool.
|
||||
The agent uses hierarchical retrieval:
|
||||
1. search_mental_models - User-curated summaries (try first)
|
||||
2. search_observations - Consolidated knowledge with freshness
|
||||
3. recall - Raw facts as ground truth
|
||||
|
||||
Args:
|
||||
llm_config: LLM provider for agent calls
|
||||
bank_id: Bank identifier
|
||||
query: Question to answer
|
||||
bank_profile: Bank profile with name and mission
|
||||
lookup_fn: Tool callback for lookup (model_id) -> result
|
||||
search_mental_models_fn: Tool callback for searching mental models (query, max_results) -> result
|
||||
search_observations_fn: Tool callback for searching observations (query, max_results) -> result
|
||||
recall_fn: Tool callback for recall (query, max_tokens) -> result
|
||||
expand_fn: Tool callback for expand (memory_id, depth) -> result
|
||||
learn_fn: Optional tool callback for learn (MentalModelInput) -> result.
|
||||
If None, learn tool is disabled.
|
||||
expand_fn: Tool callback for expand (memory_ids, depth) -> result
|
||||
context: Optional additional context
|
||||
max_iterations: Maximum number of iterations before forcing response
|
||||
max_tokens: Maximum tokens for the final response
|
||||
@@ -188,7 +304,6 @@ async def run_reflect_agent(
|
||||
Returns:
|
||||
ReflectAgentResult with final answer and metadata
|
||||
"""
|
||||
enable_learn = learn_fn is not None
|
||||
reflect_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}"
|
||||
start_time = time.time()
|
||||
|
||||
@@ -199,67 +314,50 @@ async def run_reflect_agent(
|
||||
directive_rules = _extract_directive_rules(directives) if directives else None
|
||||
|
||||
# Get tools for this agent (with directive compliance field if directives exist)
|
||||
tools = get_reflect_tools(enable_learn=enable_learn, directive_rules=directive_rules)
|
||||
tools = get_reflect_tools(directive_rules=directive_rules)
|
||||
|
||||
# Build initial messages (directives are injected into system prompt at START and END)
|
||||
system_prompt = build_system_prompt_for_tools(bank_profile, context, directives=directives)
|
||||
system_prompt = build_system_prompt_for_tools(
|
||||
bank_profile, context, directives=directives, has_mental_models=has_mental_models, budget=budget
|
||||
)
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": query},
|
||||
]
|
||||
|
||||
# Tracking
|
||||
mental_models_created: list[str] = []
|
||||
total_tools_called = 0
|
||||
tool_trace: list[ToolCall] = []
|
||||
tool_trace_summary: list[dict[str, Any]] = []
|
||||
llm_trace: list[dict[str, Any]] = []
|
||||
context_history: list[dict[str, Any]] = [] # For final prompt fallback
|
||||
|
||||
# Token usage tracking - accumulate across all LLM calls
|
||||
total_input_tokens = 0
|
||||
total_output_tokens = 0
|
||||
|
||||
# Track available IDs for validation (prevents hallucinated citations)
|
||||
available_memory_ids: set[str] = set()
|
||||
available_model_ids: set[str] = set()
|
||||
|
||||
# Pre-fetch mental models so the agent always starts with this knowledge
|
||||
prefetch_start = time.time()
|
||||
models_result = await lookup_fn(None) # List all mental models
|
||||
prefetch_duration = int((time.time() - prefetch_start) * 1000)
|
||||
|
||||
# Track available model IDs
|
||||
if isinstance(models_result, dict) and "models" in models_result:
|
||||
for model in models_result["models"]:
|
||||
if "id" in model:
|
||||
available_model_ids.add(model["id"])
|
||||
|
||||
# Add to context history for the agent
|
||||
context_history.append({"tool": "list_mental_models", "output": models_result})
|
||||
|
||||
# Add to tool trace
|
||||
tool_trace.append(
|
||||
ToolCall(
|
||||
tool="list_mental_models",
|
||||
input={"tool": "list_mental_models"},
|
||||
output=models_result,
|
||||
duration_ms=prefetch_duration,
|
||||
iteration=0,
|
||||
)
|
||||
)
|
||||
tool_trace_summary.append(
|
||||
{
|
||||
"tool": "list_mental_models",
|
||||
"input_summary": "(prefetch)",
|
||||
"duration_ms": prefetch_duration,
|
||||
"output_chars": len(json.dumps(models_result, default=str)),
|
||||
}
|
||||
)
|
||||
total_tools_called += 1
|
||||
|
||||
# Include in the user message so the agent sees it
|
||||
models_info = json.dumps(models_result, indent=2, default=str)
|
||||
messages[1]["content"] = f"{query}\n\n## Available Mental Models (pre-fetched)\n```json\n{models_info}\n```"
|
||||
available_mental_model_ids: set[str] = set()
|
||||
available_observation_ids: set[str] = set()
|
||||
|
||||
def _get_llm_trace() -> list[LLMCall]:
|
||||
return [LLMCall(scope=c["scope"], duration_ms=c["duration_ms"]) for c in llm_trace]
|
||||
return [
|
||||
LLMCall(
|
||||
scope=c["scope"],
|
||||
duration_ms=c["duration_ms"],
|
||||
input_tokens=c.get("input_tokens", 0),
|
||||
output_tokens=c.get("output_tokens", 0),
|
||||
)
|
||||
for c in llm_trace
|
||||
]
|
||||
|
||||
def _get_usage() -> TokenUsageSummary:
|
||||
return TokenUsageSummary(
|
||||
input_tokens=total_input_tokens,
|
||||
output_tokens=total_output_tokens,
|
||||
total_tokens=total_input_tokens + total_output_tokens,
|
||||
)
|
||||
|
||||
def _log_completion(answer: str, iterations: int, forced: bool = False):
|
||||
elapsed_ms = int((time.time() - start_time) * 1000)
|
||||
@@ -293,21 +391,36 @@ async def run_reflect_agent(
|
||||
# Force text response on last iteration - no tools
|
||||
prompt = build_final_prompt(query, context_history, bank_profile, context)
|
||||
llm_start = time.time()
|
||||
response = await llm_config.call(
|
||||
response, usage = await llm_config.call(
|
||||
messages=[
|
||||
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
scope="reflect_agent_final",
|
||||
max_completion_tokens=max_tokens,
|
||||
return_usage=True,
|
||||
)
|
||||
llm_trace.append({"scope": "final", "duration_ms": int((time.time() - llm_start) * 1000)})
|
||||
answer = response.strip()
|
||||
llm_duration = int((time.time() - llm_start) * 1000)
|
||||
total_input_tokens += usage.input_tokens
|
||||
total_output_tokens += usage.output_tokens
|
||||
llm_trace.append(
|
||||
{
|
||||
"scope": "final",
|
||||
"duration_ms": llm_duration,
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
}
|
||||
)
|
||||
answer = _clean_answer_text(response.strip())
|
||||
|
||||
# Generate structured output if schema provided
|
||||
structured_output = None
|
||||
if response_schema and answer:
|
||||
structured_output = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
|
||||
structured_output, struct_in, struct_out = await _generate_structured_output(
|
||||
answer, response_schema, llm_config, reflect_id
|
||||
)
|
||||
total_input_tokens += struct_in
|
||||
total_output_tokens += struct_out
|
||||
|
||||
_log_completion(answer, iteration + 1, forced=True)
|
||||
return ReflectAgentResult(
|
||||
@@ -315,9 +428,9 @@ async def run_reflect_agent(
|
||||
structured_output=structured_output,
|
||||
iterations=iteration + 1,
|
||||
tools_called=total_tools_called,
|
||||
mental_models_created=mental_models_created,
|
||||
tool_trace=tool_trace,
|
||||
llm_trace=_get_llm_trace(),
|
||||
usage=_get_usage(),
|
||||
directives_applied=directives_applied,
|
||||
)
|
||||
|
||||
@@ -332,33 +445,59 @@ async def run_reflect_agent(
|
||||
tool_choice="required" if iteration == 0 else "auto", # Force tool use on first iteration
|
||||
)
|
||||
llm_duration = int((time.time() - llm_start) * 1000)
|
||||
llm_trace.append({"scope": f"agent_{iteration + 1}", "duration_ms": llm_duration})
|
||||
|
||||
except Exception:
|
||||
total_input_tokens += result.input_tokens
|
||||
total_output_tokens += result.output_tokens
|
||||
llm_trace.append(
|
||||
{"scope": f"agent_{iteration + 1}_err", "duration_ms": int((time.time() - llm_start) * 1000)}
|
||||
{
|
||||
"scope": f"agent_{iteration + 1}",
|
||||
"duration_ms": llm_duration,
|
||||
"input_tokens": result.input_tokens,
|
||||
"output_tokens": result.output_tokens,
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
err_duration = int((time.time() - llm_start) * 1000)
|
||||
logger.warning(f"[REFLECT {reflect_id}] LLM error on iteration {iteration + 1}: {e} ({err_duration}ms)")
|
||||
llm_trace.append({"scope": f"agent_{iteration + 1}_err", "duration_ms": err_duration})
|
||||
# Guardrail: If no evidence gathered yet, retry
|
||||
has_gathered_evidence = bool(available_memory_ids) or bool(available_model_ids)
|
||||
has_gathered_evidence = (
|
||||
bool(available_memory_ids) or bool(available_mental_model_ids) or bool(available_observation_ids)
|
||||
)
|
||||
if not has_gathered_evidence and iteration < max_iterations - 1:
|
||||
continue
|
||||
prompt = build_final_prompt(query, context_history, bank_profile, context)
|
||||
llm_start = time.time()
|
||||
response = await llm_config.call(
|
||||
response, usage = await llm_config.call(
|
||||
messages=[
|
||||
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
scope="reflect_agent_final",
|
||||
max_completion_tokens=max_tokens,
|
||||
return_usage=True,
|
||||
)
|
||||
llm_trace.append({"scope": "final", "duration_ms": int((time.time() - llm_start) * 1000)})
|
||||
answer = response.strip()
|
||||
llm_duration = int((time.time() - llm_start) * 1000)
|
||||
total_input_tokens += usage.input_tokens
|
||||
total_output_tokens += usage.output_tokens
|
||||
llm_trace.append(
|
||||
{
|
||||
"scope": "final",
|
||||
"duration_ms": llm_duration,
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
}
|
||||
)
|
||||
answer = _clean_answer_text(response.strip())
|
||||
|
||||
# Generate structured output if schema provided
|
||||
structured_output = None
|
||||
if response_schema and answer:
|
||||
structured_output = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
|
||||
structured_output, struct_in, struct_out = await _generate_structured_output(
|
||||
answer, response_schema, llm_config, reflect_id
|
||||
)
|
||||
total_input_tokens += struct_in
|
||||
total_output_tokens += struct_out
|
||||
|
||||
_log_completion(answer, iteration + 1, forced=True)
|
||||
return ReflectAgentResult(
|
||||
@@ -366,23 +505,25 @@ async def run_reflect_agent(
|
||||
structured_output=structured_output,
|
||||
iterations=iteration + 1,
|
||||
tools_called=total_tools_called,
|
||||
mental_models_created=mental_models_created,
|
||||
tool_trace=tool_trace,
|
||||
llm_trace=_get_llm_trace(),
|
||||
usage=_get_usage(),
|
||||
directives_applied=directives_applied,
|
||||
)
|
||||
|
||||
# No tool calls - LLM wants to respond with text
|
||||
if not result.tool_calls:
|
||||
if result.content:
|
||||
answer = result.content.strip()
|
||||
answer = _clean_answer_text(result.content.strip())
|
||||
|
||||
# Generate structured output if schema provided
|
||||
structured_output = None
|
||||
if response_schema and answer:
|
||||
structured_output = await _generate_structured_output(
|
||||
structured_output, struct_in, struct_out = await _generate_structured_output(
|
||||
answer, response_schema, llm_config, reflect_id
|
||||
)
|
||||
total_input_tokens += struct_in
|
||||
total_output_tokens += struct_out
|
||||
|
||||
_log_completion(answer, iteration + 1)
|
||||
return ReflectAgentResult(
|
||||
@@ -390,29 +531,44 @@ async def run_reflect_agent(
|
||||
structured_output=structured_output,
|
||||
iterations=iteration + 1,
|
||||
tools_called=total_tools_called,
|
||||
mental_models_created=mental_models_created,
|
||||
tool_trace=tool_trace,
|
||||
llm_trace=_get_llm_trace(),
|
||||
usage=_get_usage(),
|
||||
directives_applied=directives_applied,
|
||||
)
|
||||
# Empty response, force final
|
||||
prompt = build_final_prompt(query, context_history, bank_profile, context)
|
||||
llm_start = time.time()
|
||||
response = await llm_config.call(
|
||||
response, usage = await llm_config.call(
|
||||
messages=[
|
||||
{"role": "system", "content": FINAL_SYSTEM_PROMPT},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
scope="reflect_agent_final",
|
||||
max_completion_tokens=max_tokens,
|
||||
return_usage=True,
|
||||
)
|
||||
llm_trace.append({"scope": "final", "duration_ms": int((time.time() - llm_start) * 1000)})
|
||||
answer = response.strip()
|
||||
llm_duration = int((time.time() - llm_start) * 1000)
|
||||
total_input_tokens += usage.input_tokens
|
||||
total_output_tokens += usage.output_tokens
|
||||
llm_trace.append(
|
||||
{
|
||||
"scope": "final",
|
||||
"duration_ms": llm_duration,
|
||||
"input_tokens": usage.input_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
}
|
||||
)
|
||||
answer = _clean_answer_text(response.strip())
|
||||
|
||||
# Generate structured output if schema provided
|
||||
structured_output = None
|
||||
if response_schema and answer:
|
||||
structured_output = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
|
||||
structured_output, struct_in, struct_out = await _generate_structured_output(
|
||||
answer, response_schema, llm_config, reflect_id
|
||||
)
|
||||
total_input_tokens += struct_in
|
||||
total_output_tokens += struct_out
|
||||
|
||||
_log_completion(answer, iteration + 1, forced=True)
|
||||
return ReflectAgentResult(
|
||||
@@ -420,17 +576,19 @@ async def run_reflect_agent(
|
||||
structured_output=structured_output,
|
||||
iterations=iteration + 1,
|
||||
tools_called=total_tools_called,
|
||||
mental_models_created=mental_models_created,
|
||||
tool_trace=tool_trace,
|
||||
llm_trace=_get_llm_trace(),
|
||||
usage=_get_usage(),
|
||||
directives_applied=directives_applied,
|
||||
)
|
||||
|
||||
# Check for done tool call (handle both 'done' and 'functions.done')
|
||||
done_call = next((tc for tc in result.tool_calls if tc.name == "done" or tc.name == "functions.done"), None)
|
||||
# Check for done tool call (handle various LLM output formats)
|
||||
done_call = next((tc for tc in result.tool_calls if _is_done_tool(tc.name)), None)
|
||||
if done_call:
|
||||
# Guardrail: Require evidence before done
|
||||
has_gathered_evidence = bool(available_memory_ids) or bool(available_model_ids)
|
||||
has_gathered_evidence = (
|
||||
bool(available_memory_ids) or bool(available_mental_model_ids) or bool(available_observation_ids)
|
||||
)
|
||||
if not has_gathered_evidence and iteration < max_iterations - 1:
|
||||
# Add assistant message and fake tool result asking for evidence
|
||||
messages.append(
|
||||
@@ -443,9 +601,10 @@ async def run_reflect_agent(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": done_call.id,
|
||||
"name": done_call.name, # Required by Gemini
|
||||
"content": json.dumps(
|
||||
{
|
||||
"error": "You must call recall() or list_mental_models() to gather evidence before providing your final answer."
|
||||
"error": "You must search for information first. Use search_mental_models(), search_observations(), or recall() before providing your final answer."
|
||||
}
|
||||
),
|
||||
}
|
||||
@@ -456,12 +615,13 @@ async def run_reflect_agent(
|
||||
return await _process_done_tool(
|
||||
done_call,
|
||||
available_memory_ids,
|
||||
available_model_ids,
|
||||
available_mental_model_ids,
|
||||
available_observation_ids,
|
||||
iteration + 1,
|
||||
total_tools_called,
|
||||
mental_models_created,
|
||||
tool_trace,
|
||||
_get_llm_trace(),
|
||||
_get_usage(),
|
||||
_log_completion,
|
||||
reflect_id,
|
||||
directives_applied=directives_applied,
|
||||
@@ -469,8 +629,8 @@ async def run_reflect_agent(
|
||||
response_schema=response_schema,
|
||||
)
|
||||
|
||||
# Execute other tools in parallel (exclude done and functions.done)
|
||||
other_tools = [tc for tc in result.tool_calls if tc.name not in ("done", "functions.done")]
|
||||
# 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)]
|
||||
if other_tools:
|
||||
# Add assistant message with tool calls
|
||||
messages.append(
|
||||
@@ -482,7 +642,14 @@ async def run_reflect_agent(
|
||||
|
||||
# Execute tools in parallel
|
||||
tool_tasks = [
|
||||
_execute_tool_with_timing(tc, lookup_fn, recall_fn, expand_fn, learn_fn) for tc in other_tools
|
||||
_execute_tool_with_timing(
|
||||
tc,
|
||||
search_mental_models_fn,
|
||||
search_observations_fn,
|
||||
recall_fn,
|
||||
expand_fn,
|
||||
)
|
||||
for tc in other_tools
|
||||
]
|
||||
tool_results = await asyncio.gather(*tool_tasks, return_exceptions=True)
|
||||
total_tools_called += len(other_tools)
|
||||
@@ -490,43 +657,52 @@ async def run_reflect_agent(
|
||||
# Process results and add to messages
|
||||
for tc, result_data in zip(other_tools, tool_results):
|
||||
if isinstance(result_data, Exception):
|
||||
# Tool execution failed - log and raise to fail the request
|
||||
logger.error(f"[REFLECT {reflect_id}] Tool {tc.name} failed with exception: {result_data}")
|
||||
raise RuntimeError(f"Reflect tool '{tc.name}' failed: {result_data}")
|
||||
# Tool execution failed - send error back to LLM so it can try again
|
||||
logger.warning(f"[REFLECT {reflect_id}] Tool {tc.name} failed with exception: {result_data}")
|
||||
output = {"error": f"Tool execution failed: {result_data}"}
|
||||
duration_ms = 0
|
||||
else:
|
||||
output, duration_ms = result_data
|
||||
|
||||
output, duration_ms = result_data
|
||||
# Normalize tool name for consistent tracking
|
||||
normalized_tool_name = _normalize_tool_name(tc.name)
|
||||
|
||||
# Check if tool returned an error response
|
||||
# Check if tool returned an error response - log but continue (LLM will see the error)
|
||||
if isinstance(output, dict) and "error" in output:
|
||||
logger.error(f"[REFLECT {reflect_id}] Tool {tc.name} returned error: {output['error']}")
|
||||
raise RuntimeError(f"Reflect tool '{tc.name}' error: {output['error']}")
|
||||
logger.warning(
|
||||
f"[REFLECT {reflect_id}] Tool {normalized_tool_name} returned error: {output['error']}"
|
||||
)
|
||||
|
||||
# Track created mental models
|
||||
if tc.name == "learn" and isinstance(output, dict) and "model_id" in output:
|
||||
mental_models_created.append(output["model_id"])
|
||||
# Track available IDs from tool results (only for successful responses)
|
||||
if (
|
||||
normalized_tool_name == "search_mental_models"
|
||||
and isinstance(output, dict)
|
||||
and "mental_models" in output
|
||||
):
|
||||
for mm in output["mental_models"]:
|
||||
if "id" in mm:
|
||||
available_mental_model_ids.add(mm["id"])
|
||||
|
||||
# Track available memory IDs from recall
|
||||
if tc.name == "recall" and isinstance(output, dict) and "memories" in output:
|
||||
if (
|
||||
normalized_tool_name == "search_observations"
|
||||
and isinstance(output, dict)
|
||||
and "observations" in output
|
||||
):
|
||||
for obs in output["observations"]:
|
||||
if "id" in obs:
|
||||
available_observation_ids.add(obs["id"])
|
||||
|
||||
if normalized_tool_name == "recall" and isinstance(output, dict) and "memories" in output:
|
||||
for memory in output["memories"]:
|
||||
if "id" in memory:
|
||||
available_memory_ids.add(memory["id"])
|
||||
|
||||
# Track available model IDs
|
||||
if tc.name in ("list_mental_models", "get_mental_model") and isinstance(output, dict):
|
||||
if output.get("found") and "model" in output:
|
||||
model_id = output["model"].get("id")
|
||||
if model_id:
|
||||
available_model_ids.add(model_id)
|
||||
elif "models" in output:
|
||||
for model in output["models"]:
|
||||
if "id" in model:
|
||||
available_model_ids.add(model["id"])
|
||||
|
||||
# Add tool result message
|
||||
messages.append(
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": tc.id,
|
||||
"name": tc.name, # Required by Gemini
|
||||
"content": json.dumps(output, default=str),
|
||||
}
|
||||
)
|
||||
@@ -535,9 +711,17 @@ async def run_reflect_agent(
|
||||
input_dict = {"tool": tc.name, **tc.arguments}
|
||||
input_summary = _summarize_input(tc.name, tc.arguments)
|
||||
|
||||
# Extract reason from tool arguments (if provided)
|
||||
tool_reason = tc.arguments.get("reason")
|
||||
|
||||
tool_trace.append(
|
||||
ToolCall(
|
||||
tool=tc.name, input=input_dict, output=output, duration_ms=duration_ms, iteration=iteration + 1
|
||||
tool=tc.name,
|
||||
reason=tool_reason,
|
||||
input=input_dict,
|
||||
output=output,
|
||||
duration_ms=duration_ms,
|
||||
iteration=iteration + 1,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -565,9 +749,9 @@ async def run_reflect_agent(
|
||||
text=answer,
|
||||
iterations=max_iterations,
|
||||
tools_called=total_tools_called,
|
||||
mental_models_created=mental_models_created,
|
||||
tool_trace=tool_trace,
|
||||
llm_trace=_get_llm_trace(),
|
||||
usage=_get_usage(),
|
||||
directives_applied=directives_applied,
|
||||
)
|
||||
|
||||
@@ -587,12 +771,13 @@ def _tool_call_to_dict(tc: "LLMToolCall") -> dict[str, Any]:
|
||||
async def _process_done_tool(
|
||||
done_call: "LLMToolCall",
|
||||
available_memory_ids: set[str],
|
||||
available_model_ids: set[str],
|
||||
available_mental_model_ids: set[str],
|
||||
available_observation_ids: set[str],
|
||||
iterations: int,
|
||||
total_tools_called: int,
|
||||
mental_models_created: list[str],
|
||||
tool_trace: list[ToolCall],
|
||||
llm_trace: list[LLMCall],
|
||||
usage: TokenUsageSummary,
|
||||
log_completion: Callable,
|
||||
reflect_id: str,
|
||||
directives_applied: list[DirectiveInfo],
|
||||
@@ -602,18 +787,30 @@ async def _process_done_tool(
|
||||
"""Process the done tool call and return the result."""
|
||||
args = done_call.arguments
|
||||
|
||||
answer = args.get("answer", "").strip()
|
||||
# Extract and clean the answer - some LLMs leak structured output into the answer text
|
||||
raw_answer = args.get("answer", "").strip()
|
||||
answer = _clean_done_answer(raw_answer) if raw_answer else ""
|
||||
if not answer:
|
||||
answer = "No answer provided."
|
||||
|
||||
# Validate IDs
|
||||
# Validate IDs (only include IDs that were actually retrieved)
|
||||
used_memory_ids = [mid for mid in args.get("memory_ids", []) if mid in available_memory_ids]
|
||||
used_model_ids = [mid for mid in args.get("model_ids", []) if mid in available_model_ids]
|
||||
used_mental_model_ids = [mid for mid in args.get("mental_model_ids", []) if mid in available_mental_model_ids]
|
||||
used_observation_ids = [oid for oid in args.get("observation_ids", []) if oid in available_observation_ids]
|
||||
|
||||
# Generate structured output if schema provided
|
||||
structured_output = None
|
||||
final_usage = usage
|
||||
if response_schema and llm_config and answer:
|
||||
structured_output = await _generate_structured_output(answer, response_schema, llm_config, reflect_id)
|
||||
structured_output, struct_in, struct_out = await _generate_structured_output(
|
||||
answer, response_schema, llm_config, reflect_id
|
||||
)
|
||||
# Add structured output tokens to usage
|
||||
final_usage = TokenUsageSummary(
|
||||
input_tokens=usage.input_tokens + struct_in,
|
||||
output_tokens=usage.output_tokens + struct_out,
|
||||
total_tokens=usage.total_tokens + struct_in + struct_out,
|
||||
)
|
||||
|
||||
log_completion(answer, iterations)
|
||||
return ReflectAgentResult(
|
||||
@@ -621,25 +818,33 @@ async def _process_done_tool(
|
||||
structured_output=structured_output,
|
||||
iterations=iterations,
|
||||
tools_called=total_tools_called,
|
||||
mental_models_created=mental_models_created,
|
||||
tool_trace=tool_trace,
|
||||
llm_trace=llm_trace,
|
||||
usage=final_usage,
|
||||
used_memory_ids=used_memory_ids,
|
||||
used_model_ids=used_model_ids,
|
||||
used_mental_model_ids=used_mental_model_ids,
|
||||
used_observation_ids=used_observation_ids,
|
||||
directives_applied=directives_applied,
|
||||
)
|
||||
|
||||
|
||||
async def _execute_tool_with_timing(
|
||||
tc: "LLMToolCall",
|
||||
lookup_fn: Callable[[str | None], Awaitable[dict[str, Any]]],
|
||||
search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
|
||||
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
|
||||
recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
|
||||
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
|
||||
learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None,
|
||||
) -> 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, lookup_fn, recall_fn, expand_fn, learn_fn)
|
||||
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
|
||||
|
||||
@@ -647,24 +852,28 @@ async def _execute_tool_with_timing(
|
||||
async def _execute_tool(
|
||||
tool_name: str,
|
||||
args: dict[str, Any],
|
||||
lookup_fn: Callable[[str | None], Awaitable[dict[str, Any]]],
|
||||
search_mental_models_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
|
||||
search_observations_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
|
||||
recall_fn: Callable[[str, int], Awaitable[dict[str, Any]]],
|
||||
expand_fn: Callable[[list[str], str], Awaitable[dict[str, Any]]],
|
||||
learn_fn: Callable[[MentalModelInput], Awaitable[dict[str, Any]]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a single tool by name."""
|
||||
# Normalize tool name - some LLMs return 'functions.done' instead of 'done'
|
||||
if tool_name.startswith("functions."):
|
||||
tool_name = tool_name[len("functions.") :]
|
||||
# Normalize tool name for various LLM output formats
|
||||
tool_name = _normalize_tool_name(tool_name)
|
||||
|
||||
if tool_name == "list_mental_models":
|
||||
return await lookup_fn(None)
|
||||
if tool_name == "search_mental_models":
|
||||
query = args.get("query")
|
||||
if not query:
|
||||
return {"error": "search_mental_models requires a query parameter"}
|
||||
max_results = args.get("max_results") or 5
|
||||
return await search_mental_models_fn(query, max_results)
|
||||
|
||||
elif tool_name == "get_mental_model":
|
||||
model_id = args.get("model_id")
|
||||
if not model_id:
|
||||
return {"error": "get_mental_model requires model_id"}
|
||||
return await lookup_fn(model_id)
|
||||
elif tool_name == "search_observations":
|
||||
query = args.get("query")
|
||||
if not query:
|
||||
return {"error": "search_observations requires a query parameter"}
|
||||
max_tokens = max(args.get("max_tokens") or 5000, 1000) # Default 5000, min 1000
|
||||
return await search_observations_fn(query, max_tokens)
|
||||
|
||||
elif tool_name == "recall":
|
||||
query = args.get("query")
|
||||
@@ -673,15 +882,6 @@ async def _execute_tool(
|
||||
max_tokens = max(args.get("max_tokens") or 2048, 1000) # Default 2048, min 1000
|
||||
return await recall_fn(query, max_tokens)
|
||||
|
||||
elif tool_name == "learn":
|
||||
if learn_fn is None:
|
||||
return {"error": "learn tool is not available"}
|
||||
name = args.get("name")
|
||||
description = args.get("description")
|
||||
if not name or not description:
|
||||
return {"error": "learn requires name and description"}
|
||||
return await learn_fn(MentalModelInput(name=name, description=description))
|
||||
|
||||
elif tool_name == "expand":
|
||||
memory_ids = args.get("memory_ids", [])
|
||||
if not memory_ids:
|
||||
@@ -695,21 +895,22 @@ async def _execute_tool(
|
||||
|
||||
def _summarize_input(tool_name: str, args: dict[str, Any]) -> str:
|
||||
"""Create a summary of tool input for logging, showing all params."""
|
||||
if tool_name == "list_mental_models":
|
||||
return "()"
|
||||
elif tool_name == "get_mental_model":
|
||||
return f"(model_id={args.get('model_id', '?')})"
|
||||
if tool_name == "search_mental_models":
|
||||
query = args.get("query", "")
|
||||
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
|
||||
max_results = args.get("max_results") or 5
|
||||
return f"(query={query_preview}, max_results={max_results})"
|
||||
elif tool_name == "search_observations":
|
||||
query = args.get("query", "")
|
||||
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
|
||||
max_tokens = max(args.get("max_tokens") or 5000, 1000)
|
||||
return f"(query={query_preview}, max_tokens={max_tokens})"
|
||||
elif tool_name == "recall":
|
||||
query = args.get("query", "")
|
||||
query_preview = f"'{query[:30]}...'" if len(query) > 30 else f"'{query}'"
|
||||
# Show actual value used (default 2048, min 1000)
|
||||
max_tokens = max(args.get("max_tokens") or 2048, 1000)
|
||||
return f"(query={query_preview}, max_tokens={max_tokens})"
|
||||
elif tool_name == "learn":
|
||||
name = args.get("name", "?")
|
||||
desc = args.get("description", "")
|
||||
desc_preview = f"'{desc[:20]}...'" if len(desc) > 20 else f"'{desc}'"
|
||||
return f"(name='{name}', description={desc_preview})"
|
||||
elif tool_name == "expand":
|
||||
memory_ids = args.get("memory_ids", [])
|
||||
depth = args.get("depth", "chunk")
|
||||
@@ -718,6 +919,9 @@ def _summarize_input(tool_name: str, args: dict[str, Any]) -> str:
|
||||
answer = args.get("answer", "")
|
||||
answer_preview = f"'{answer[:30]}...'" if len(answer) > 30 else f"'{answer}'"
|
||||
memory_ids = args.get("memory_ids", [])
|
||||
model_ids = args.get("model_ids", [])
|
||||
return f"(answer={answer_preview}, memory_ids={len(memory_ids)}, model_ids={len(model_ids)})"
|
||||
mental_model_ids = args.get("mental_model_ids", [])
|
||||
observation_ids = args.get("observation_ids", [])
|
||||
return (
|
||||
f"(answer={answer_preview}, mem={len(memory_ids)}, mm={len(mental_model_ids)}, obs={len(observation_ids)})"
|
||||
)
|
||||
return str(args)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,51 +7,28 @@ from typing import Any, Literal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class MentalModelObservation(BaseModel):
|
||||
"""An observation within a mental model with its supporting memories."""
|
||||
class ObservationSection(BaseModel):
|
||||
"""A section within an observation with its supporting memories."""
|
||||
|
||||
title: str = Field(description="Observation header (can be empty for intro)")
|
||||
text: str = Field(description="Observation content - no headers, use lists/tables/bold")
|
||||
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this observation")
|
||||
|
||||
|
||||
class MentalModelInput(BaseModel):
|
||||
"""Input for the learn tool to create a mental model placeholder.
|
||||
|
||||
The agent only specifies name and description - the actual content/observations
|
||||
are generated during refresh, similar to pinned models.
|
||||
"""
|
||||
|
||||
name: str = Field(description="Human-readable name for the mental model")
|
||||
description: str = Field(description="What to track - used as prompt for content generation during refresh")
|
||||
entity_id: str | None = Field(default=None, description="Optional link to existing entity ID")
|
||||
|
||||
|
||||
class AnswerSection(BaseModel):
|
||||
"""A section of the answer with its supporting evidence (DEPRECATED)."""
|
||||
|
||||
title: str = Field(description="Section header/title")
|
||||
text: str = Field(description="Section content")
|
||||
title: str = Field(description="Section header (can be empty for intro)")
|
||||
text: str = Field(description="Section content - no headers, use lists/tables/bold")
|
||||
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this section")
|
||||
model_ids: list[str] = Field(default_factory=list, description="Mental model IDs supporting this section")
|
||||
|
||||
|
||||
class ReflectAction(BaseModel):
|
||||
"""Single action the reflect agent can take."""
|
||||
|
||||
tool: Literal["list_mental_models", "get_mental_model", "recall", "learn", "expand", "done"] = Field(
|
||||
description="Tool to invoke: list_mental_models, get_mental_model, recall, learn, expand, or done"
|
||||
tool: Literal["list_observations", "get_observation", "recall", "expand", "done"] = Field(
|
||||
description="Tool to invoke: list_observations, get_observation, recall, expand, or done"
|
||||
)
|
||||
# Tool-specific parameters
|
||||
model_id: str | None = Field(default=None, description="Mental model ID for get_mental_model")
|
||||
observation_id: str | None = Field(default=None, description="Observation ID for get_observation")
|
||||
query: str | None = Field(default=None, description="Search query for recall")
|
||||
max_tokens: int | None = Field(default=None, description="Max tokens for recall results (default 2048)")
|
||||
mental_model: MentalModelInput | None = Field(default=None, description="Mental model to create/update for learn")
|
||||
memory_ids: list[str] | None = Field(default=None, description="Memory unit IDs for expand (batched)")
|
||||
depth: Literal["chunk", "document"] | None = Field(default=None, description="Expansion depth for expand")
|
||||
sections: list[AnswerSection] | None = Field(default=None, description="DEPRECATED: Use answer field instead")
|
||||
observations: list[MentalModelObservation] | None = Field(
|
||||
default=None, description="Observations for done action (when output_mode=observations)"
|
||||
observation_sections: list[ObservationSection] | None = Field(
|
||||
default=None, description="Observation sections for done action (when output_mode=observations)"
|
||||
)
|
||||
# Plain text answer fields (for output_mode=answer)
|
||||
answer: str | None = Field(default=None, description="Plain text answer for done action (no markdown)")
|
||||
@@ -73,7 +50,8 @@ class ReflectActionBatch(BaseModel):
|
||||
class ToolCall(BaseModel):
|
||||
"""A single tool call made during reflect."""
|
||||
|
||||
tool: str = Field(description="Tool name: lookup, recall, learn, expand")
|
||||
tool: str = Field(description="Tool name: lookup, recall, expand")
|
||||
reason: str | None = Field(default=None, description="Agent's reasoning for making this tool call")
|
||||
input: dict = Field(description="Tool input parameters")
|
||||
output: dict = Field(description="Tool output/result")
|
||||
duration_ms: int = Field(description="Execution time in milliseconds")
|
||||
@@ -85,6 +63,8 @@ class LLMCall(BaseModel):
|
||||
|
||||
scope: str = Field(description="Call scope: agent_1, agent_2, final, etc.")
|
||||
duration_ms: int = Field(description="Execution time in milliseconds")
|
||||
input_tokens: int = Field(default=0, description="Input tokens used")
|
||||
output_tokens: int = Field(default=0, description="Output tokens used")
|
||||
|
||||
|
||||
class DirectiveInfo(BaseModel):
|
||||
@@ -92,7 +72,15 @@ class DirectiveInfo(BaseModel):
|
||||
|
||||
id: str = Field(description="Directive mental model ID")
|
||||
name: str = Field(description="Directive name")
|
||||
rules: list[str] = Field(default_factory=list, description="Directive rules/observations that were applied")
|
||||
content: str = Field(description="Directive content")
|
||||
|
||||
|
||||
class TokenUsageSummary(BaseModel):
|
||||
"""Total token usage across all LLM calls."""
|
||||
|
||||
input_tokens: int = Field(default=0, description="Total input tokens used")
|
||||
output_tokens: int = Field(default=0, description="Total output tokens used")
|
||||
total_tokens: int = Field(default=0, description="Total tokens (input + output)")
|
||||
|
||||
|
||||
class ReflectAgentResult(BaseModel):
|
||||
@@ -104,11 +92,18 @@ class ReflectAgentResult(BaseModel):
|
||||
)
|
||||
iterations: int = Field(default=0, description="Number of iterations taken")
|
||||
tools_called: int = Field(default=0, description="Total number of tool calls made")
|
||||
mental_models_created: list[str] = Field(default_factory=list, description="IDs of mental models created/updated")
|
||||
tool_trace: list[ToolCall] = Field(default_factory=list, description="Trace of all tool calls made")
|
||||
llm_trace: list[LLMCall] = Field(default_factory=list, description="Trace of all LLM calls made")
|
||||
usage: TokenUsageSummary = Field(
|
||||
default_factory=TokenUsageSummary, description="Total token usage across all LLM calls"
|
||||
)
|
||||
used_memory_ids: list[str] = Field(default_factory=list, description="Validated memory IDs actually used in answer")
|
||||
used_model_ids: list[str] = Field(default_factory=list, description="Validated model IDs actually used in answer")
|
||||
used_mental_model_ids: list[str] = Field(
|
||||
default_factory=list, description="Validated mental model IDs actually used in answer"
|
||||
)
|
||||
used_observation_ids: list[str] = Field(
|
||||
default_factory=list, description="Validated observation IDs actually used in answer"
|
||||
)
|
||||
directives_applied: list[DirectiveInfo] = Field(
|
||||
default_factory=list, description="Directive mental models that affected this reflection"
|
||||
)
|
||||
|
||||
@@ -184,65 +184,3 @@ def compute_trend(
|
||||
return Trend.WEAKENING
|
||||
else:
|
||||
return Trend.STABLE
|
||||
|
||||
|
||||
class CandidateObservation(BaseModel):
|
||||
"""A candidate observation generated during the seed phase.
|
||||
|
||||
Candidates are preliminary observations that need evidence validation
|
||||
before becoming full observations.
|
||||
"""
|
||||
|
||||
content: str = Field(description="The proposed observation content")
|
||||
seed_memory_ids: list[str] = Field(default_factory=list, description="Memory IDs that inspired this candidate")
|
||||
|
||||
|
||||
class CandidateWithEvidence(BaseModel):
|
||||
"""A candidate observation with gathered supporting and contradicting evidence."""
|
||||
|
||||
candidate: CandidateObservation
|
||||
supporting_memories: list[dict] = Field(default_factory=list, description="Memories that support this observation")
|
||||
contradicting_memories: list[dict] = Field(
|
||||
default_factory=list, description="Memories that contradict this observation"
|
||||
)
|
||||
|
||||
|
||||
class MentalModelSnapshot(BaseModel):
|
||||
"""A versioned snapshot of a mental model's observations.
|
||||
|
||||
Used for tracking changes over time and enabling diff views.
|
||||
"""
|
||||
|
||||
version: int = Field(description="Version number (1-indexed)")
|
||||
observations: list[Observation] = Field(default_factory=list, description="Observations at this version")
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc), description="When this version was created"
|
||||
)
|
||||
reflect_summary: str | None = Field(default=None, description="Summary of changes in this version")
|
||||
|
||||
|
||||
def verify_evidence_quotes(
|
||||
observation: Observation,
|
||||
memories: dict[str, str],
|
||||
) -> tuple[bool, list[str]]:
|
||||
"""Verify that all evidence quotes exist in the referenced memories.
|
||||
|
||||
Args:
|
||||
observation: The observation to verify
|
||||
memories: Dict mapping memory_id to memory content
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, list of error messages)
|
||||
"""
|
||||
errors = []
|
||||
|
||||
for evidence in observation.evidence:
|
||||
memory_content = memories.get(evidence.memory_id)
|
||||
if memory_content is None:
|
||||
errors.append(f"Memory {evidence.memory_id} not found")
|
||||
continue
|
||||
|
||||
if evidence.quote not in memory_content:
|
||||
errors.append(f"Quote not found in memory {evidence.memory_id}: '{evidence.quote[:50]}...'")
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""
|
||||
System prompts for the reflect agent.
|
||||
|
||||
The reflect agent uses hierarchical retrieval:
|
||||
1. search_mental_models - User-curated summaries (highest quality)
|
||||
2. search_observations - Consolidated knowledge with freshness awareness
|
||||
3. recall - Raw facts as ground truth fallback
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -11,7 +16,7 @@ def _extract_directive_rules(directives: list[dict[str, Any]]) -> list[str]:
|
||||
Extract directive rules as a list of strings.
|
||||
|
||||
Args:
|
||||
directives: List of directive mental models with observations
|
||||
directives: List of directives with name and content
|
||||
|
||||
Returns:
|
||||
List of directive rule strings
|
||||
@@ -19,25 +24,34 @@ def _extract_directive_rules(directives: list[dict[str, Any]]) -> list[str]:
|
||||
rules = []
|
||||
for directive in directives:
|
||||
directive_name = directive.get("name", "")
|
||||
observations = directive.get("observations", [])
|
||||
if observations:
|
||||
for obs in observations:
|
||||
# Support both Pydantic Observation objects and dicts
|
||||
if hasattr(obs, "title"):
|
||||
title = obs.title
|
||||
content = obs.content
|
||||
else:
|
||||
title = obs.get("title", "")
|
||||
content = obs.get("content", "")
|
||||
if title and content:
|
||||
rules.append(f"**{title}**: {content}")
|
||||
elif content:
|
||||
rules.append(content)
|
||||
elif directive_name:
|
||||
# Fallback to description if no observations
|
||||
desc = directive.get("description", "")
|
||||
if desc:
|
||||
rules.append(f"**{directive_name}**: {desc}")
|
||||
# New format: directives have direct content field
|
||||
content = directive.get("content", "")
|
||||
if content:
|
||||
if directive_name:
|
||||
rules.append(f"**{directive_name}**: {content}")
|
||||
else:
|
||||
rules.append(content)
|
||||
else:
|
||||
# Legacy format: check for observations
|
||||
observations = directive.get("observations", [])
|
||||
if observations:
|
||||
for obs in observations:
|
||||
# Support both Pydantic Observation objects and dicts
|
||||
if hasattr(obs, "title"):
|
||||
title = obs.title
|
||||
obs_content = obs.content
|
||||
else:
|
||||
title = obs.get("title", "")
|
||||
obs_content = obs.get("content", "")
|
||||
if title and obs_content:
|
||||
rules.append(f"**{title}**: {obs_content}")
|
||||
elif obs_content:
|
||||
rules.append(obs_content)
|
||||
elif directive_name:
|
||||
# Fallback to description
|
||||
desc = directive.get("description", "")
|
||||
if desc:
|
||||
rules.append(f"**{directive_name}**: {desc}")
|
||||
return rules
|
||||
|
||||
|
||||
@@ -111,24 +125,27 @@ def build_system_prompt_for_tools(
|
||||
bank_profile: dict[str, Any],
|
||||
context: str | None = None,
|
||||
directives: list[dict[str, Any]] | None = None,
|
||||
has_mental_models: bool = False,
|
||||
budget: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Build the system prompt for tool-calling reflect agent.
|
||||
|
||||
This is a simplified prompt since tools are defined separately via the tools parameter.
|
||||
The agent uses hierarchical retrieval:
|
||||
1. search_mental_models - User-curated summaries (try first, if available)
|
||||
2. search_observations - Consolidated knowledge with freshness
|
||||
3. recall - Raw facts as ground truth
|
||||
|
||||
Args:
|
||||
bank_profile: Bank profile with name and mission
|
||||
context: Optional additional context
|
||||
directives: Optional list of directive mental models to inject as hard rules
|
||||
has_mental_models: Whether the bank has any mental models (skip if not)
|
||||
budget: Search depth budget - "low", "mid", or "high". Controls exploration thoroughness.
|
||||
"""
|
||||
name = bank_profile.get("name", "Assistant")
|
||||
mission = bank_profile.get("mission", "")
|
||||
|
||||
no_info_rule = (
|
||||
"- Only say 'I don't have information' AFTER trying list_mental_models AND recall with no relevant results"
|
||||
)
|
||||
|
||||
parts = []
|
||||
|
||||
# Inject directives at the VERY START for maximum prominence
|
||||
@@ -147,8 +164,7 @@ def build_system_prompt_for_tools(
|
||||
"## CRITICAL RULES",
|
||||
"- You must NEVER fabricate information that has no basis in retrieved data",
|
||||
"- You SHOULD synthesize, infer, and reason from the retrieved memories",
|
||||
"- You MUST call recall() before saying you don't have information",
|
||||
no_info_rule,
|
||||
"- You MUST search before saying you don't have information",
|
||||
"",
|
||||
"## How to Reason",
|
||||
"- If memories mention someone did an activity, you can infer they likely enjoyed it",
|
||||
@@ -156,7 +172,56 @@ def build_system_prompt_for_tools(
|
||||
"- Be a thoughtful interpreter, not just a literal repeater",
|
||||
"- When the exact answer isn't stated, use what IS stated to give the best answer",
|
||||
"",
|
||||
"## Query Strategy (IMPORTANT)",
|
||||
"## HIERARCHICAL RETRIEVAL STRATEGY",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
# Build retrieval levels based on what's available
|
||||
if has_mental_models:
|
||||
parts.extend(
|
||||
[
|
||||
"You have access to THREE levels of knowledge. Use them in this order:",
|
||||
"",
|
||||
"### 1. MENTAL MODELS (search_mental_models) - Try First",
|
||||
"- User-curated summaries about specific topics",
|
||||
"- HIGHEST quality - manually created and maintained",
|
||||
"- If a relevant mental model exists and is FRESH, it may fully answer the question",
|
||||
"- Check `is_stale` field - if stale, also verify with lower levels",
|
||||
"",
|
||||
"### 2. OBSERVATIONS (search_observations) - Second Priority",
|
||||
"- Auto-consolidated knowledge from memories",
|
||||
"- Check `is_stale` field - if stale, ALSO use recall() to verify",
|
||||
"- Good for understanding patterns and summaries",
|
||||
"",
|
||||
"### 3. RAW FACTS (recall) - Ground Truth",
|
||||
"- Individual memories (world facts and experiences)",
|
||||
"- Use when: no mental models/observations exist, they're stale, or you need specific details",
|
||||
"- This is the source of truth that other levels are built from",
|
||||
"",
|
||||
]
|
||||
)
|
||||
else:
|
||||
parts.extend(
|
||||
[
|
||||
"You have access to TWO levels of knowledge. Use them in this order:",
|
||||
"",
|
||||
"### 1. OBSERVATIONS (search_observations) - Try First",
|
||||
"- Auto-consolidated knowledge from memories",
|
||||
"- Check `is_stale` field - if stale, ALSO use recall() to verify",
|
||||
"- Good for understanding patterns and summaries",
|
||||
"",
|
||||
"### 2. RAW FACTS (recall) - Ground Truth",
|
||||
"- Individual memories (world facts and experiences)",
|
||||
"- Use when: no observations exist, they're stale, or you need specific details",
|
||||
"- This is the source of truth that observations are built from",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
parts.extend(
|
||||
[
|
||||
"## Query Strategy",
|
||||
"recall() uses semantic search. NEVER just echo the user's question - decompose it into targeted searches:",
|
||||
"",
|
||||
"BAD: User asks 'recurring lesson themes between students' → recall('recurring lesson themes between students')",
|
||||
@@ -164,44 +229,82 @@ def build_system_prompt_for_tools(
|
||||
" 1. recall('lessons') - find all lesson-related memories",
|
||||
" 2. recall('teaching sessions') - alternative phrasing",
|
||||
" 3. recall('student progress') - find student-related memories",
|
||||
" 4. recall('topics taught') - find subject matter",
|
||||
"",
|
||||
"Think: What ENTITIES and CONCEPTS does this question involve? Search for each separately.",
|
||||
"- Questions about patterns → search for the individual instances first",
|
||||
"- Questions comparing things → search for each thing separately",
|
||||
"- Questions about relationships → search for each party involved",
|
||||
"",
|
||||
"## Workflow",
|
||||
]
|
||||
)
|
||||
|
||||
# Answer mode: include mental model lookup in workflow
|
||||
# Add budget guidance
|
||||
if budget:
|
||||
budget_lower = budget.lower()
|
||||
if budget_lower == "low":
|
||||
parts.extend(
|
||||
[
|
||||
"## RESEARCH DEPTH: SHALLOW (Quick Response)",
|
||||
"- Prioritize speed over completeness",
|
||||
"- If mental models or observations provide a reasonable answer, stop there",
|
||||
"- Only dig deeper if the initial results are clearly insufficient",
|
||||
"- Prefer a quick overview rather than exhaustive details",
|
||||
"- Answer promptly with available information",
|
||||
"",
|
||||
]
|
||||
)
|
||||
elif budget_lower == "mid":
|
||||
parts.extend(
|
||||
[
|
||||
"## RESEARCH DEPTH: MODERATE (Balanced)",
|
||||
"- Balance thoroughness with efficiency",
|
||||
"- Check multiple sources when the question warrants it",
|
||||
"- Verify stale data if it's central to the answer",
|
||||
"- Don't over-explore, but ensure reasonable coverage",
|
||||
"",
|
||||
]
|
||||
)
|
||||
elif budget_lower == "high":
|
||||
parts.extend(
|
||||
[
|
||||
"## RESEARCH DEPTH: DEEP (Thorough Exploration)",
|
||||
"- Explore comprehensively before answering",
|
||||
"- Search across all available knowledge levels",
|
||||
"- Use multiple query variations to ensure coverage",
|
||||
"- Verify information across different retrieval levels",
|
||||
"- Use expand() to get full context on important memories",
|
||||
"- Take time to synthesize a complete, well-researched answer",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
parts.append("## Workflow")
|
||||
|
||||
if has_mental_models:
|
||||
parts.extend(
|
||||
[
|
||||
"1. First, try search_mental_models() - check if a curated summary exists",
|
||||
"2. If no mental model or it's stale, try search_observations() for consolidated knowledge",
|
||||
"3. If observations are stale OR you need specific details, use recall() for raw facts",
|
||||
"4. Use expand() if you need more context on specific memories",
|
||||
"5. When ready, call done() with your answer and supporting IDs",
|
||||
]
|
||||
)
|
||||
else:
|
||||
parts.extend(
|
||||
[
|
||||
"1. First, try search_observations() - check for consolidated knowledge",
|
||||
"2. If observations are stale OR you need specific details, use recall() for raw facts",
|
||||
"3. Use expand() if you need more context on specific memories",
|
||||
"4. When ready, call done() with your answer and supporting IDs",
|
||||
]
|
||||
)
|
||||
|
||||
parts.extend(
|
||||
[
|
||||
"1. Review the pre-fetched mental models for relevant synthesized knowledge",
|
||||
"2. If relevant, call get_mental_model(model_id) for full observations",
|
||||
"3. DECOMPOSE the question into component searches (see Query Strategy above)",
|
||||
" - Identify entities and concepts in the question",
|
||||
" - Search for each separately with targeted queries",
|
||||
"4. Run multiple recall() calls - don't just echo the user's question",
|
||||
"5. Use expand() if you need more context on specific memories",
|
||||
"6. BEFORE answering: Check if any person/project/concept from the memories deserves a mental model - use learn() if so",
|
||||
"7. When ready, call done() with your answer and supporting memory_ids",
|
||||
"",
|
||||
"## When to Use learn() - IMPORTANT",
|
||||
"ACTIVELY look for opportunities to use learn() when you discover:",
|
||||
"- A person mentioned in 2+ memories who has no mental model yet",
|
||||
"- A project or concept the user asks about that has no mental model",
|
||||
"- A pattern or topic worth tracking for future questions",
|
||||
"",
|
||||
"DO NOT wait to be asked - proactively create models when you see the need.",
|
||||
"Example: learn(name='Project Alpha', description='Track goals, status, and key decisions for Project Alpha')",
|
||||
"",
|
||||
"## Output Format: Plain Text Answer",
|
||||
"Call done() with a plain text 'answer' field.",
|
||||
"- Do NOT use markdown formatting",
|
||||
"- NEVER include memory IDs, UUIDs, or 'Memory references' in the answer text",
|
||||
"- Put memory IDs ONLY in the memory_ids array parameter, not in the answer",
|
||||
"- Put IDs ONLY in the memory_ids/mental_model_ids/observation_ids arrays, not in the answer",
|
||||
]
|
||||
)
|
||||
|
||||
@@ -295,9 +398,10 @@ def build_agent_prompt(
|
||||
else:
|
||||
parts.append(
|
||||
"\n## Instructions\n"
|
||||
"Start by calling list_mental_models() to see available mental models - they contain pre-synthesized knowledge. "
|
||||
"If a relevant model exists, use get_mental_model(model_id) to get its observations. "
|
||||
"Then use recall(query) for specific details not covered by mental models."
|
||||
"Start by searching for relevant information using the hierarchical retrieval strategy:\n"
|
||||
"1. Try search_mental_models() first for curated summaries\n"
|
||||
"2. Try search_observations() for consolidated knowledge\n"
|
||||
"3. Use recall() for specific details or to verify stale data"
|
||||
)
|
||||
|
||||
return "\n".join(parts)
|
||||
@@ -377,386 +481,3 @@ Your approach:
|
||||
|
||||
Only say "I don't have information" if the retrieved data is truly unrelated to the question.
|
||||
Do NOT fabricate information that has no basis in the retrieved data."""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 4-Phase Mental Model Reflect Prompts
|
||||
# =============================================================================
|
||||
|
||||
SEED_PHASE_SYSTEM_PROMPT = """You are analyzing memories to discover NEW patterns and generate candidate observations.
|
||||
|
||||
Your task is to identify potential observations (beliefs, preferences, patterns, behaviors) that could be part of a mental model about this person/topic.
|
||||
|
||||
## Important: Avoid Redundancy
|
||||
If existing observations are provided, DO NOT generate candidates that are essentially the same.
|
||||
Focus on discovering NEW patterns not already covered by existing observations.
|
||||
|
||||
## Rules
|
||||
- Generate 5-15 candidate observations for NEW patterns only
|
||||
- Each candidate should be specific and testable (can be supported or contradicted by evidence)
|
||||
- Note which memory IDs inspired each candidate (these are seeds, not final evidence)
|
||||
- Focus on patterns that appear MULTIPLE TIMES across many memories - the more the better
|
||||
- The best candidates are ones you can find 10, 20, or even 50+ supporting memories for
|
||||
- Skip patterns that are already covered by existing observations
|
||||
|
||||
## Output Format
|
||||
Return a JSON array of candidate observations:
|
||||
```json
|
||||
{
|
||||
"candidates": [
|
||||
{
|
||||
"content": "The specific observation/belief/pattern - be detailed and specific",
|
||||
"seed_memory_ids": ["memory_id_1", "memory_id_2", "memory_id_3"]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Focus on patterns that appear multiple times or have strong signals. Don't generate obvious or trivial observations.
|
||||
Prefer candidates with MORE seed memories - they're more likely to be real patterns.
|
||||
Return an empty candidates array if no genuinely new patterns are found."""
|
||||
|
||||
|
||||
def build_seed_phase_prompt(
|
||||
memories: list[dict],
|
||||
topic: str | None = None,
|
||||
existing_observations: list[dict] | None = None,
|
||||
) -> str:
|
||||
"""Build the user prompt for the seed phase.
|
||||
|
||||
Args:
|
||||
memories: List of memories to analyze
|
||||
topic: Optional topic focus for the mental model
|
||||
existing_observations: Optional list of existing observations to avoid rediscovering
|
||||
"""
|
||||
parts = []
|
||||
|
||||
if topic:
|
||||
parts.append(f"## Topic Focus\n{topic}\n")
|
||||
|
||||
# Include existing observations so we don't rediscover them
|
||||
if existing_observations:
|
||||
parts.append("## Existing Observations (DO NOT regenerate these)")
|
||||
parts.append("These patterns are already tracked. Focus on discovering NEW patterns:\n")
|
||||
for i, obs in enumerate(existing_observations, 1):
|
||||
title = obs.get("title", "")
|
||||
content = obs.get("content", "")
|
||||
parts.append(f"{i}. **{title}**: {content}\n")
|
||||
parts.append("")
|
||||
|
||||
parts.append("## Memories to Analyze")
|
||||
parts.append("Review these memories and identify patterns, preferences, beliefs, and behaviors:\n")
|
||||
|
||||
for mem in memories:
|
||||
mem_id = mem.get("id", "unknown")
|
||||
content = mem.get("content", mem.get("text", ""))
|
||||
timestamp = mem.get("timestamp", mem.get("created_at", ""))
|
||||
parts.append(f"[{mem_id}] ({timestamp}): {content}\n")
|
||||
|
||||
parts.append("\n## Instructions")
|
||||
if existing_observations:
|
||||
parts.append("Generate candidate observations for NEW patterns not already covered above.")
|
||||
parts.append("If all patterns are already covered by existing observations, return an empty candidates array.")
|
||||
else:
|
||||
parts.append("Generate candidate observations based on patterns you see in these memories.")
|
||||
parts.append("Look for: recurring themes, stated preferences, behavioral patterns, beliefs, values, goals.")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
VALIDATE_PHASE_SYSTEM_PROMPT = """You are validating candidate observations against evidence.
|
||||
|
||||
For each candidate, you have:
|
||||
- Supporting memories (evidence FOR the observation)
|
||||
- Contradicting memories (evidence AGAINST the observation)
|
||||
|
||||
## Your Task
|
||||
1. Evaluate each candidate based on the evidence
|
||||
2. For valid candidates, extract EXACT QUOTES from supporting memories
|
||||
3. Discard candidates with insufficient or contradicting evidence
|
||||
4. Merge similar candidates into single, refined observations
|
||||
|
||||
## Rules for Quotes
|
||||
- Quotes must be EXACT text from the memory, not paraphrased
|
||||
- Each quote should directly support the observation
|
||||
- The MORE evidence quotes, the BETTER - don't limit yourself, include ALL relevant quotes (10, 20, 50+)
|
||||
- Observations with only 1-2 quotes are weak and should be discarded unless the evidence is exceptionally strong
|
||||
- Stronger observations have more supporting evidence - aim for comprehensive coverage
|
||||
|
||||
## Output Format
|
||||
Return validated observations with evidence:
|
||||
```json
|
||||
{
|
||||
"observations": [
|
||||
{
|
||||
"title": "Short descriptive title (3-8 words) - like a headline",
|
||||
"content": "The full observation content - detailed explanation of the pattern/belief",
|
||||
"evidence": [
|
||||
{
|
||||
"memory_id": "exact_memory_id",
|
||||
"quote": "Exact quote from the memory text",
|
||||
"relevance": "Brief explanation of how this supports the observation",
|
||||
"timestamp": "2024-01-15T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"discarded": [
|
||||
{
|
||||
"content": "The discarded candidate",
|
||||
"reason": "Why it was discarded (insufficient evidence, contradicted, etc.)"
|
||||
}
|
||||
],
|
||||
"merged": [
|
||||
{
|
||||
"from": ["candidate 1 content", "candidate 2 content"],
|
||||
"into": "The merged observation content"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Title Guidelines
|
||||
- Title should be a SHORT label (like "Prefers morning meetings" or "Coffee enthusiast")
|
||||
- NOT a truncated version of the content
|
||||
- Think of it as a category/tag for the observation
|
||||
|
||||
Be rigorous: only keep observations with clear, verifiable evidence from multiple memories."""
|
||||
|
||||
|
||||
def build_validate_phase_prompt(candidates_with_evidence: list[dict]) -> str:
|
||||
"""Build the user prompt for the validate phase."""
|
||||
parts = ["## Candidates to Validate\n"]
|
||||
|
||||
for i, item in enumerate(candidates_with_evidence, 1):
|
||||
candidate = item.get("candidate", {})
|
||||
supporting = item.get("supporting_memories", [])
|
||||
contradicting = item.get("contradicting_memories", [])
|
||||
|
||||
parts.append(f"### Candidate {i}: {candidate.get('content', '')}")
|
||||
|
||||
if supporting:
|
||||
parts.append("\n**Supporting Evidence:**")
|
||||
for mem in supporting:
|
||||
mem_id = mem.get("id", "unknown")
|
||||
content = mem.get("content", mem.get("text", ""))
|
||||
timestamp = mem.get("timestamp", mem.get("created_at", ""))
|
||||
parts.append(f"- [{mem_id}] ({timestamp}): {content}")
|
||||
|
||||
if contradicting:
|
||||
parts.append("\n**Contradicting Evidence:**")
|
||||
for mem in contradicting:
|
||||
mem_id = mem.get("id", "unknown")
|
||||
content = mem.get("content", mem.get("text", ""))
|
||||
timestamp = mem.get("timestamp", mem.get("created_at", ""))
|
||||
parts.append(f"- [{mem_id}] ({timestamp}): {content}")
|
||||
|
||||
if not supporting and not contradicting:
|
||||
parts.append("\n*No additional evidence found*")
|
||||
|
||||
parts.append("")
|
||||
|
||||
parts.append("## Instructions")
|
||||
parts.append("1. Evaluate each candidate based on its evidence")
|
||||
parts.append("2. Keep candidates with strong supporting evidence")
|
||||
parts.append("3. Discard candidates with no evidence or strong contradictions")
|
||||
parts.append("4. Merge similar candidates")
|
||||
parts.append("5. Extract EXACT quotes (copy-paste from memory text) for evidence")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
COMPARE_PHASE_SYSTEM_PROMPT = """You are merging new observations with an existing mental model.
|
||||
|
||||
You have:
|
||||
- EXISTING observations (from the current mental model)
|
||||
- NEW observations (from this reflect cycle)
|
||||
|
||||
## Your Task
|
||||
Produce the final, complete mental model by:
|
||||
1. Keeping existing observations that are still valid
|
||||
2. Updating existing observations with new evidence (ADD new evidence to existing)
|
||||
3. Adding new observations that don't overlap with existing
|
||||
4. Removing existing observations that are contradicted by new evidence
|
||||
5. Merging overlapping observations
|
||||
|
||||
## Rules
|
||||
- The final model should have no contradictions
|
||||
- Each observation must have evidence with exact quotes
|
||||
- COMBINE evidence from both existing and new observations
|
||||
- If an existing observation has new supporting evidence, ADD ALL the new evidence to it
|
||||
- Include ALL relevant evidence - the more quotes the better (10, 20, 50+ is great)
|
||||
- Observations with more evidence are more reliable - don't limit the number of quotes
|
||||
|
||||
## Output Format
|
||||
Return the complete, final mental model:
|
||||
```json
|
||||
{
|
||||
"observations": [
|
||||
{
|
||||
"title": "Short descriptive title (3-8 words)",
|
||||
"content": "Full observation content - detailed explanation",
|
||||
"evidence": [
|
||||
{
|
||||
"memory_id": "id",
|
||||
"quote": "exact quote",
|
||||
"relevance": "explanation",
|
||||
"timestamp": "ISO timestamp"
|
||||
}
|
||||
],
|
||||
"created_at": "ISO timestamp of when observation was first created"
|
||||
}
|
||||
],
|
||||
"changes": {
|
||||
"kept": ["Observation that was kept unchanged"],
|
||||
"updated": [{"from": "old content", "to": "new content", "reason": "why"}],
|
||||
"added": ["New observation that was added"],
|
||||
"removed": [{"content": "removed observation", "reason": "why removed"}],
|
||||
"merged": [{"from": ["obs1", "obs2"], "into": "merged observation"}]
|
||||
}
|
||||
}
|
||||
```"""
|
||||
|
||||
|
||||
def build_compare_phase_prompt(
|
||||
existing_observations: list[dict],
|
||||
new_observations: list[dict],
|
||||
) -> str:
|
||||
"""Build the user prompt for the compare phase."""
|
||||
parts = []
|
||||
|
||||
parts.append("## Existing Mental Model Observations")
|
||||
if existing_observations:
|
||||
for i, obs in enumerate(existing_observations, 1):
|
||||
title = obs.get("title", "")
|
||||
content = obs.get("content", obs.get("text", ""))
|
||||
evidence = obs.get("evidence", [])
|
||||
parts.append(f"\n### Existing {i}: {title}")
|
||||
parts.append(f"Content: {content}")
|
||||
if evidence:
|
||||
parts.append(f"Evidence ({len(evidence)} items):")
|
||||
for ev in evidence[:5]: # Show max 5 evidence items
|
||||
parts.append(f' - [{ev.get("memory_id", "?")}]: "{ev.get("quote", "")}"')
|
||||
if len(evidence) > 5:
|
||||
parts.append(f" ... and {len(evidence) - 5} more")
|
||||
else:
|
||||
parts.append("*No existing observations*")
|
||||
|
||||
parts.append("\n## New Observations from This Reflect")
|
||||
if new_observations:
|
||||
for i, obs in enumerate(new_observations, 1):
|
||||
title = obs.get("title", "")
|
||||
content = obs.get("content", "")
|
||||
evidence = obs.get("evidence", [])
|
||||
parts.append(f"\n### New {i}: {title}")
|
||||
parts.append(f"Content: {content}")
|
||||
if evidence:
|
||||
parts.append(f"Evidence ({len(evidence)} items):")
|
||||
for ev in evidence:
|
||||
parts.append(f' - [{ev.get("memory_id", "?")}]: "{ev.get("quote", "")}"')
|
||||
else:
|
||||
parts.append("*No new observations*")
|
||||
|
||||
parts.append("\n## Instructions")
|
||||
parts.append("Merge these into a coherent, non-contradictory mental model.")
|
||||
parts.append("Preserve all valid evidence. Remove stale or contradicted observations.")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# UPDATE EXISTING Phase Prompts (for diff-based refresh)
|
||||
# =============================================================================
|
||||
|
||||
UPDATE_EXISTING_SYSTEM_PROMPT = """You are updating existing observations with newly found evidence.
|
||||
|
||||
For each existing observation, you have been given:
|
||||
- The original observation (title, content, existing evidence)
|
||||
- Newly found supporting memories
|
||||
- Newly found contradicting memories
|
||||
|
||||
## Your Task
|
||||
1. Extract EXACT QUOTES from new supporting memories to add to the observation
|
||||
2. Flag observations with strong contradicting evidence for potential removal
|
||||
3. Keep existing evidence intact - only ADD new evidence
|
||||
|
||||
## Rules for Quotes
|
||||
- Quotes must be EXACT text from the memory, not paraphrased
|
||||
- Each quote should directly support the observation
|
||||
- Include ALL relevant quotes from the new memories
|
||||
|
||||
## Output Format
|
||||
Return updated observations with new evidence:
|
||||
```json
|
||||
{
|
||||
"updated_observations": [
|
||||
{
|
||||
"title": "Original title",
|
||||
"content": "Original content",
|
||||
"existing_evidence_count": 5,
|
||||
"new_evidence": [
|
||||
{
|
||||
"memory_id": "exact_memory_id",
|
||||
"quote": "Exact quote from the memory text",
|
||||
"relevance": "Brief explanation of how this supports the observation",
|
||||
"timestamp": "2024-01-15T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"has_contradiction": false,
|
||||
"contradiction_note": null
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If an observation has strong contradicting evidence, set has_contradiction=true and explain in contradiction_note."""
|
||||
|
||||
|
||||
def build_update_existing_prompt(observations_with_evidence: list[dict]) -> str:
|
||||
"""Build the user prompt for the update existing phase.
|
||||
|
||||
Args:
|
||||
observations_with_evidence: List of existing observations with new evidence found
|
||||
"""
|
||||
parts = ["## Existing Observations to Update\n"]
|
||||
|
||||
for i, item in enumerate(observations_with_evidence, 1):
|
||||
obs = item.get("observation", {})
|
||||
supporting = item.get("supporting_memories", [])
|
||||
contradicting = item.get("contradicting_memories", [])
|
||||
|
||||
title = obs.get("title", "")
|
||||
content = obs.get("content", "")
|
||||
existing_evidence = obs.get("evidence", [])
|
||||
|
||||
parts.append(f"### Observation {i}: {title}")
|
||||
parts.append(f"Content: {content}")
|
||||
parts.append(f"Existing evidence count: {len(existing_evidence)}")
|
||||
|
||||
if supporting:
|
||||
parts.append("\n**New Supporting Memories:**")
|
||||
for mem in supporting:
|
||||
mem_id = mem.get("id", "unknown")
|
||||
mem_content = mem.get("content", mem.get("text", ""))
|
||||
timestamp = mem.get("timestamp", mem.get("created_at", ""))
|
||||
parts.append(f"- [{mem_id}] ({timestamp}): {mem_content}")
|
||||
|
||||
if contradicting:
|
||||
parts.append("\n**New Contradicting Memories:**")
|
||||
for mem in contradicting:
|
||||
mem_id = mem.get("id", "unknown")
|
||||
mem_content = mem.get("content", mem.get("text", ""))
|
||||
timestamp = mem.get("timestamp", mem.get("created_at", ""))
|
||||
parts.append(f"- [{mem_id}] ({timestamp}): {mem_content}")
|
||||
|
||||
if not supporting and not contradicting:
|
||||
parts.append("\n*No new evidence found*")
|
||||
|
||||
parts.append("")
|
||||
|
||||
parts.append("## Instructions")
|
||||
parts.append("1. Extract EXACT quotes from new supporting memories")
|
||||
parts.append("2. Flag observations with strong contradictions")
|
||||
parts.append("3. Return the updated observations with new evidence added")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"""
|
||||
Tool implementations for the reflect agent.
|
||||
|
||||
Implements hierarchical retrieval:
|
||||
1. search_mental_models - User-curated stored reflect responses (highest quality)
|
||||
2. search_observations - Consolidated knowledge with freshness
|
||||
3. recall - Raw facts as ground truth
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .models import MentalModelInput
|
||||
from .observations import Observation, ObservationEvidence, Trend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncpg import Connection
|
||||
|
||||
@@ -19,156 +20,215 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_model_id(name: str) -> str:
|
||||
"""Generate a stable ID from mental model name."""
|
||||
# Normalize: lowercase, replace spaces/special chars with hyphens
|
||||
normalized = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
# Truncate to reasonable length
|
||||
return normalized[:50]
|
||||
# Observation is considered stale if not updated in this many days
|
||||
STALE_THRESHOLD_DAYS = 7
|
||||
|
||||
|
||||
def _parse_observations(observations_raw: list) -> list[Observation]:
|
||||
"""Parse raw observation dicts into typed Observation models."""
|
||||
observations: list[Observation] = []
|
||||
for obs in observations_raw:
|
||||
if not isinstance(obs, dict):
|
||||
continue
|
||||
|
||||
try:
|
||||
parsed = Observation(
|
||||
title=obs.get("title", ""),
|
||||
content=obs.get("content", ""),
|
||||
evidence=[
|
||||
ObservationEvidence(
|
||||
memory_id=ev.get("memory_id", ""),
|
||||
quote=ev.get("quote", ""),
|
||||
relevance=ev.get("relevance", ""),
|
||||
timestamp=ev.get("timestamp"),
|
||||
)
|
||||
for ev in obs.get("evidence", [])
|
||||
if isinstance(ev, dict)
|
||||
],
|
||||
created_at=obs.get("created_at"),
|
||||
)
|
||||
observations.append(parsed)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse observation: {e}")
|
||||
continue
|
||||
|
||||
return observations
|
||||
|
||||
|
||||
async def tool_lookup(
|
||||
async def tool_search_mental_models(
|
||||
conn: "Connection",
|
||||
bank_id: str,
|
||||
model_id: str | None = None,
|
||||
query: str,
|
||||
query_embedding: list[float],
|
||||
max_results: int = 5,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: str = "any",
|
||||
exclude_ids: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
List or get mental models.
|
||||
Search user-curated mental models by semantic similarity.
|
||||
|
||||
Mental models are high-quality, manually created summaries about specific topics.
|
||||
They should be searched FIRST as they represent the most reliable synthesized knowledge.
|
||||
|
||||
Args:
|
||||
conn: Database connection
|
||||
bank_id: Bank identifier
|
||||
model_id: Optional specific model ID to get (if None, lists all)
|
||||
tags: Optional tags to filter models (when listing)
|
||||
query: Search query (for logging/tracing)
|
||||
query_embedding: Pre-computed embedding for semantic search
|
||||
max_results: Maximum number of mental models to return
|
||||
tags: Optional tags to filter mental models
|
||||
tags_match: How to match tags - "any" (OR), "all" (AND)
|
||||
exclude_ids: Optional list of mental model IDs to exclude (e.g., when refreshing a mental model)
|
||||
|
||||
Returns:
|
||||
Dict with either a list of models or a single model's details
|
||||
Dict with matching mental models including content and freshness info
|
||||
"""
|
||||
if model_id:
|
||||
# Get specific mental model with full details including observations
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, subtype, name, description, observations, entity_id, last_updated
|
||||
FROM mental_models
|
||||
WHERE id = $1 AND bank_id = $2
|
||||
""",
|
||||
model_id,
|
||||
bank_id,
|
||||
)
|
||||
if row:
|
||||
# Parse observations JSON
|
||||
obs_data = row["observations"] or {"observations": []}
|
||||
if isinstance(obs_data, str):
|
||||
import json
|
||||
from ..memory_engine import fq_table
|
||||
|
||||
obs_data = json.loads(obs_data)
|
||||
observations_raw = obs_data.get("observations", []) if isinstance(obs_data, dict) else obs_data
|
||||
# Build filters dynamically
|
||||
filters = ""
|
||||
params: list[Any] = [bank_id, str(query_embedding), max_results]
|
||||
next_param = 4
|
||||
|
||||
# Parse observations into typed models
|
||||
observations = _parse_observations(observations_raw)
|
||||
|
||||
return {
|
||||
"found": True,
|
||||
"model": {
|
||||
"id": row["id"],
|
||||
"subtype": row["subtype"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"observations": observations,
|
||||
"entity_id": str(row["entity_id"]) if row["entity_id"] else None,
|
||||
"last_updated": row["last_updated"].isoformat() if row["last_updated"] else None,
|
||||
},
|
||||
}
|
||||
return {"found": False, "model_id": model_id}
|
||||
else:
|
||||
# List mental models (compact: id, name, description only)
|
||||
# Full observations are retrieved via get_mental_model(model_id)
|
||||
# NOTE: Directives (subtype='directive') are excluded from listing -
|
||||
# they are injected into the system prompt, not discoverable via tools
|
||||
# Filter by tags if provided
|
||||
if tags:
|
||||
if tags_match == "all":
|
||||
# All tags must match
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, subtype, name, description
|
||||
FROM mental_models
|
||||
WHERE bank_id = $1 AND tags @> $2::varchar[] AND subtype != 'directive'
|
||||
ORDER BY last_updated DESC NULLS LAST, created_at DESC
|
||||
""",
|
||||
bank_id,
|
||||
tags,
|
||||
)
|
||||
else:
|
||||
# Any tag matches (OR) - default
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, subtype, name, description
|
||||
FROM mental_models
|
||||
WHERE bank_id = $1 AND tags && $2::varchar[] AND subtype != 'directive'
|
||||
ORDER BY last_updated DESC NULLS LAST, created_at DESC
|
||||
""",
|
||||
bank_id,
|
||||
tags,
|
||||
)
|
||||
if tags:
|
||||
if tags_match == "all":
|
||||
filters += f" AND tags @> ${next_param}::varchar[]"
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, subtype, name, description
|
||||
FROM mental_models
|
||||
WHERE bank_id = $1 AND subtype != 'directive'
|
||||
ORDER BY last_updated DESC NULLS LAST, created_at DESC
|
||||
filters += f" AND (tags && ${next_param}::varchar[] OR tags IS NULL OR tags = '{{}}')"
|
||||
params.append(tags)
|
||||
next_param += 1
|
||||
|
||||
if exclude_ids:
|
||||
filters += f" AND id != ALL(${next_param}::uuid[])"
|
||||
params.append(exclude_ids)
|
||||
next_param += 1
|
||||
|
||||
# Search mental models by embedding similarity
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT
|
||||
id, name, content,
|
||||
tags, created_at, last_refreshed_at,
|
||||
1 - (embedding <=> $2::vector) as relevance
|
||||
FROM {fq_table("mental_models")}
|
||||
WHERE bank_id = $1 AND embedding IS NOT NULL {filters}
|
||||
ORDER BY embedding <=> $2::vector
|
||||
LIMIT $3
|
||||
""",
|
||||
*params,
|
||||
)
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
mental_models = []
|
||||
|
||||
for row in rows:
|
||||
last_refreshed_at = row["last_refreshed_at"]
|
||||
if last_refreshed_at and last_refreshed_at.tzinfo is None:
|
||||
last_refreshed_at = last_refreshed_at.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Calculate freshness
|
||||
is_stale = False
|
||||
if last_refreshed_at:
|
||||
age = now - last_refreshed_at
|
||||
is_stale = age > timedelta(days=STALE_THRESHOLD_DAYS)
|
||||
|
||||
mental_models.append(
|
||||
{
|
||||
"id": str(row["id"]),
|
||||
"name": row["name"],
|
||||
"content": row["content"],
|
||||
"tags": row["tags"] or [],
|
||||
"relevance": round(row["relevance"], 4),
|
||||
"updated_at": last_refreshed_at.isoformat() if last_refreshed_at else None,
|
||||
"is_stale": is_stale,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"count": len(mental_models),
|
||||
"mental_models": mental_models,
|
||||
}
|
||||
|
||||
|
||||
async def tool_search_observations(
|
||||
memory_engine: "MemoryEngine",
|
||||
bank_id: str,
|
||||
query: str,
|
||||
request_context: "RequestContext",
|
||||
max_tokens: int = 5000,
|
||||
tags: list[str] | None = None,
|
||||
tags_match: str = "any",
|
||||
last_consolidated_at: datetime | None = None,
|
||||
pending_consolidation: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Search consolidated observations using recall with include_observations.
|
||||
|
||||
Observations are auto-generated from memories. Returns freshness info
|
||||
so the agent knows if it should also verify with recall().
|
||||
|
||||
Args:
|
||||
memory_engine: Memory engine instance
|
||||
bank_id: Bank identifier
|
||||
query: Search query
|
||||
request_context: Request context for authentication
|
||||
max_tokens: Maximum tokens for results (default 5000)
|
||||
tags: Optional tags to filter observations
|
||||
tags_match: How to match tags - "any" (OR), "all" (AND)
|
||||
last_consolidated_at: When consolidation last ran (for staleness check)
|
||||
pending_consolidation: Number of memories waiting to be consolidated
|
||||
|
||||
Returns:
|
||||
Dict with matching observations including freshness info
|
||||
"""
|
||||
from ..memory_engine import fq_table
|
||||
|
||||
# Use recall to search observations (they come back in results field when fact_type=["observation"])
|
||||
result = await memory_engine.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
fact_type=["observation"], # Only retrieve observations
|
||||
max_tokens=max_tokens, # Token budget controls how many observations are returned
|
||||
enable_trace=False,
|
||||
request_context=request_context,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
_connection_budget=1,
|
||||
_quiet=True,
|
||||
)
|
||||
|
||||
observations = []
|
||||
|
||||
# When fact_type=["observation"], results come back in `results` field as MemoryFact objects
|
||||
# We need to fetch additional fields (proof_count, source_memory_ids) from the database
|
||||
if result.results:
|
||||
obs_ids = [m.id for m in result.results]
|
||||
|
||||
# Fetch proof_count and source_memory_ids for these observations
|
||||
pool = await memory_engine._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
obs_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, proof_count, source_memory_ids
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = ANY($1::uuid[])
|
||||
""",
|
||||
bank_id,
|
||||
obs_ids,
|
||||
)
|
||||
obs_data = {str(row["id"]): row for row in obs_rows}
|
||||
|
||||
for m in result.results:
|
||||
# Get additional data from DB lookup
|
||||
extra = obs_data.get(m.id, {})
|
||||
proof_count = extra.get("proof_count", 1) if extra else 1
|
||||
source_ids = extra.get("source_memory_ids", []) if extra else []
|
||||
# Convert UUIDs to strings
|
||||
source_memory_ids = [str(sid) for sid in (source_ids or [])]
|
||||
|
||||
# Determine staleness
|
||||
is_stale = False
|
||||
staleness_reason = None
|
||||
if pending_consolidation > 0:
|
||||
is_stale = True
|
||||
staleness_reason = f"{pending_consolidation} memories pending consolidation"
|
||||
|
||||
observations.append(
|
||||
{
|
||||
"id": str(m.id),
|
||||
"text": m.text,
|
||||
"proof_count": proof_count,
|
||||
"source_memory_ids": source_memory_ids,
|
||||
"tags": m.tags or [],
|
||||
"is_stale": is_stale,
|
||||
"staleness_reason": staleness_reason,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"count": len(rows),
|
||||
"models": [
|
||||
{
|
||||
"id": row["id"],
|
||||
"subtype": row["subtype"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
}
|
||||
# Return freshness info (more understandable than raw pending_consolidation count)
|
||||
if pending_consolidation == 0:
|
||||
freshness = "up_to_date"
|
||||
elif pending_consolidation < 10:
|
||||
freshness = "slightly_stale"
|
||||
else:
|
||||
freshness = "stale"
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"count": len(observations),
|
||||
"observations": observations,
|
||||
"freshness": freshness,
|
||||
}
|
||||
|
||||
|
||||
async def tool_recall(
|
||||
@@ -185,6 +245,9 @@ async def tool_recall(
|
||||
"""
|
||||
Search memories using TEMPR retrieval.
|
||||
|
||||
This is the ground truth - raw facts and experiences.
|
||||
Use when mental models/observations don't exist, are stale, or need verification.
|
||||
|
||||
Args:
|
||||
memory_engine: Memory engine instance
|
||||
bank_id: Bank identifier
|
||||
@@ -202,13 +265,14 @@ async def tool_recall(
|
||||
result = await memory_engine.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
fact_type=["experience", "world"], # Exclude opinions
|
||||
fact_type=["experience", "world"], # Exclude opinions and observations
|
||||
max_tokens=max_tokens,
|
||||
enable_trace=False,
|
||||
request_context=request_context,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
_connection_budget=connection_budget,
|
||||
_quiet=True, # Suppress logging for internal operations
|
||||
)
|
||||
|
||||
memories = []
|
||||
@@ -230,85 +294,6 @@ async def tool_recall(
|
||||
}
|
||||
|
||||
|
||||
async def tool_learn(
|
||||
conn: "Connection",
|
||||
bank_id: str,
|
||||
input: MentalModelInput,
|
||||
tags: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Create a mental model placeholder with subtype='learned'.
|
||||
|
||||
The agent only specifies name and description - actual observations are generated
|
||||
in the background via refresh, similar to pinned models.
|
||||
|
||||
Args:
|
||||
conn: Database connection
|
||||
bank_id: Bank identifier
|
||||
input: Mental model input data (name, description, optional entity_id)
|
||||
tags: Tags to apply to new mental models (from reflect context)
|
||||
|
||||
Returns:
|
||||
Dict with created model info including model_id for background generation
|
||||
"""
|
||||
model_id = generate_model_id(input.name)
|
||||
|
||||
# Parse entity_id if provided
|
||||
entity_uuid = None
|
||||
if input.entity_id:
|
||||
try:
|
||||
entity_uuid = uuid.UUID(input.entity_id)
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid entity_id format: {input.entity_id}")
|
||||
|
||||
# Check if model exists
|
||||
existing = await conn.fetchrow(
|
||||
"SELECT id FROM mental_models WHERE id = $1 AND bank_id = $2",
|
||||
model_id,
|
||||
bank_id,
|
||||
)
|
||||
|
||||
if existing:
|
||||
# Update description only - observations will be regenerated
|
||||
await conn.execute(
|
||||
"""
|
||||
UPDATE mental_models SET
|
||||
description = $3,
|
||||
entity_id = $4
|
||||
WHERE id = $1 AND bank_id = $2
|
||||
""",
|
||||
model_id,
|
||||
bank_id,
|
||||
input.description,
|
||||
entity_uuid,
|
||||
)
|
||||
status = "updated"
|
||||
else:
|
||||
# Insert new model placeholder - observations will be generated in background
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO mental_models (id, bank_id, subtype, name, description, observations, entity_id, tags, created_at)
|
||||
VALUES ($1, $2, 'learned', $3, $4, '{}'::jsonb, $5, $6, NOW())
|
||||
""",
|
||||
model_id,
|
||||
bank_id,
|
||||
input.name,
|
||||
input.description,
|
||||
entity_uuid,
|
||||
tags or [],
|
||||
)
|
||||
status = "created"
|
||||
|
||||
logger.info(f"[REFLECT] Mental model '{model_id}' {status} in bank {bank_id} - pending background generation")
|
||||
|
||||
return {
|
||||
"status": status,
|
||||
"model_id": model_id,
|
||||
"name": input.name,
|
||||
"pending_generation": True,
|
||||
}
|
||||
|
||||
|
||||
async def tool_expand(
|
||||
conn: "Connection",
|
||||
bank_id: str,
|
||||
@@ -327,6 +312,8 @@ async def tool_expand(
|
||||
Returns:
|
||||
Dict with results array, each containing memory, chunk, and optionally document data
|
||||
"""
|
||||
from ..memory_engine import fq_table
|
||||
|
||||
if not memory_ids:
|
||||
return {"error": "memory_ids is required and must not be empty"}
|
||||
|
||||
@@ -344,9 +331,9 @@ async def tool_expand(
|
||||
|
||||
# Batch fetch all memory units
|
||||
memories = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, text, chunk_id, document_id, fact_type, context
|
||||
FROM memory_units
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = ANY($1) AND bank_id = $2
|
||||
""",
|
||||
valid_uuids,
|
||||
@@ -363,9 +350,9 @@ async def tool_expand(
|
||||
chunk_map: dict[str, Any] = {}
|
||||
if chunk_ids:
|
||||
chunks = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT chunk_id, chunk_text, chunk_index, document_id
|
||||
FROM chunks
|
||||
FROM {fq_table("chunks")}
|
||||
WHERE chunk_id = ANY($1)
|
||||
""",
|
||||
chunk_ids,
|
||||
@@ -385,9 +372,9 @@ async def tool_expand(
|
||||
all_doc_ids = list(doc_ids_from_chunks | doc_ids_direct)
|
||||
if all_doc_ids:
|
||||
docs = await conn.fetch(
|
||||
"""
|
||||
f"""
|
||||
SELECT id, original_text, metadata, retain_params
|
||||
FROM documents
|
||||
FROM {fq_table("documents")}
|
||||
WHERE id = ANY($1) AND bank_id = $2
|
||||
""",
|
||||
all_doc_ids,
|
||||
|
||||
@@ -2,36 +2,70 @@
|
||||
Tool schema definitions for the reflect agent.
|
||||
|
||||
These are OpenAI-format tool definitions used with native tool calling.
|
||||
The reflect agent uses a hierarchical retrieval strategy:
|
||||
1. search_mental_models - User-curated stored reflect responses (highest quality, if applicable)
|
||||
2. search_observations - Consolidated knowledge with freshness awareness
|
||||
3. recall - Raw facts (world/experience) as ground truth fallback
|
||||
"""
|
||||
|
||||
# Tool definitions in OpenAI format
|
||||
TOOL_LIST_MENTAL_MODELS = {
|
||||
|
||||
TOOL_SEARCH_MENTAL_MODELS = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "list_mental_models",
|
||||
"description": "List all available mental models - your synthesized knowledge about entities, concepts, and events. Returns an array of models with id, name, and description.",
|
||||
"name": "search_mental_models",
|
||||
"description": (
|
||||
"Search user-curated mental models (stored reflect responses). These are high-quality, manually created "
|
||||
"summaries about specific topics. Use FIRST when the question might be covered by an "
|
||||
"existing mental model. Returns mental models with their content and last refresh time."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": [],
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Brief explanation of why you're making this search (for debugging)",
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query to find relevant mental models",
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of mental models to return (default 5)",
|
||||
},
|
||||
},
|
||||
"required": ["reason", "query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
TOOL_GET_MENTAL_MODEL = {
|
||||
TOOL_SEARCH_OBSERVATIONS = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_mental_model",
|
||||
"description": "Get full details of a specific mental model including all observations and memory references.",
|
||||
"name": "search_observations",
|
||||
"description": (
|
||||
"Search consolidated observations (auto-generated knowledge). These are automatically "
|
||||
"synthesized from memories. Returns observations with freshness info (updated_at, is_stale). "
|
||||
"If an observation is STALE, you should ALSO use recall() to verify with current facts."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"model_id": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "ID of the mental model (from list_mental_models results)",
|
||||
"description": "Brief explanation of why you're making this search (for debugging)",
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query to find relevant observations",
|
||||
},
|
||||
"max_tokens": {
|
||||
"type": "integer",
|
||||
"description": "Maximum tokens for results (default 5000). Use higher values for broader searches.",
|
||||
},
|
||||
},
|
||||
"required": ["model_id"],
|
||||
"required": ["reason", "query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -40,10 +74,19 @@ TOOL_RECALL = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "recall",
|
||||
"description": "Search memories using semantic + temporal retrieval. Returns relevant memories from experience and world knowledge, each with an 'id' you can reference.",
|
||||
"description": (
|
||||
"Search raw memories (facts and experiences). This is the ground truth data. "
|
||||
"Use when: (1) no reflections/mental models exist, (2) mental models are stale, "
|
||||
"(3) you need specific details not in synthesized knowledge. "
|
||||
"Returns individual memory facts with their timestamps."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Brief explanation of why you're making this search (for debugging)",
|
||||
},
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query string",
|
||||
@@ -53,29 +96,7 @@ TOOL_RECALL = {
|
||||
"description": "Optional limit on result size (default 2048). Use higher values for broader searches.",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
TOOL_LEARN = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "learn",
|
||||
"description": "Create a new mental model to track an important recurring topic. Use when you discover a person, project, concept, or pattern that appears frequently and would benefit from synthesized knowledge. The model content will be generated automatically.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Human-readable name (e.g., 'Project Alpha', 'John Smith', 'Product Strategy')",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "What to track and synthesize (e.g., 'Track goals, milestones, blockers, and key decisions for Project Alpha')",
|
||||
},
|
||||
},
|
||||
"required": ["name", "description"],
|
||||
"required": ["reason", "query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -88,6 +109,10 @@ TOOL_EXPAND = {
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"reason": {
|
||||
"type": "string",
|
||||
"description": "Brief explanation of why you need more context (for debugging)",
|
||||
},
|
||||
"memory_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
@@ -99,7 +124,7 @@ TOOL_EXPAND = {
|
||||
"description": "chunk: surrounding text chunk, document: full source document",
|
||||
},
|
||||
},
|
||||
"required": ["memory_ids", "depth"],
|
||||
"required": ["reason", "memory_ids", "depth"],
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -121,11 +146,16 @@ TOOL_DONE_ANSWER = {
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of memory IDs that support your answer (put IDs here, NOT in answer text)",
|
||||
},
|
||||
"model_ids": {
|
||||
"mental_model_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of mental model IDs that support your answer",
|
||||
},
|
||||
"observation_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of observation IDs that support your answer",
|
||||
},
|
||||
},
|
||||
"required": ["answer"],
|
||||
},
|
||||
@@ -143,8 +173,6 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
|
||||
Args:
|
||||
directive_rules: List of directive rule strings
|
||||
"""
|
||||
from typing import Any, cast
|
||||
|
||||
# Build rules list for description
|
||||
rules_list = "\n".join(f" {i + 1}. {rule}" for i, rule in enumerate(directive_rules))
|
||||
|
||||
@@ -169,11 +197,16 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of memory IDs that support your answer (put IDs here, NOT in answer text)",
|
||||
},
|
||||
"model_ids": {
|
||||
"mental_model_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of mental model IDs that support your answer",
|
||||
},
|
||||
"observation_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of observation IDs that support your answer",
|
||||
},
|
||||
"directive_compliance": {
|
||||
"type": "string",
|
||||
"description": f"REQUIRED: Confirm your answer complies with ALL directives. List each directive and how your answer follows it:\n{rules_list}\n\nFormat: 'Directive 1: [how answer complies]. Directive 2: [how answer complies]...'",
|
||||
@@ -185,29 +218,28 @@ def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def get_reflect_tools(enable_learn: bool = True, directive_rules: list[str] | None = None) -> list[dict]:
|
||||
def get_reflect_tools(directive_rules: list[str] | None = None) -> list[dict]:
|
||||
"""
|
||||
Get the list of tools for the reflect agent.
|
||||
|
||||
The tools support a hierarchical retrieval strategy:
|
||||
1. search_mental_models - User-curated stored reflect responses (try first)
|
||||
2. search_observations - Consolidated knowledge with freshness
|
||||
3. recall - Raw facts as ground truth
|
||||
|
||||
Args:
|
||||
enable_learn: Whether to include the learn tool
|
||||
directive_rules: Optional list of directive rule strings. If provided,
|
||||
the done() tool will require directive compliance confirmation.
|
||||
|
||||
Returns:
|
||||
List of tool definitions in OpenAI format
|
||||
"""
|
||||
tools = []
|
||||
|
||||
# Include mental model tools for lookup
|
||||
tools.append(TOOL_LIST_MENTAL_MODELS)
|
||||
tools.append(TOOL_GET_MENTAL_MODEL)
|
||||
tools.append(TOOL_RECALL)
|
||||
|
||||
if enable_learn:
|
||||
tools.append(TOOL_LEARN)
|
||||
|
||||
tools.append(TOOL_EXPAND)
|
||||
tools = [
|
||||
TOOL_SEARCH_MENTAL_MODELS,
|
||||
TOOL_SEARCH_OBSERVATIONS,
|
||||
TOOL_RECALL,
|
||||
TOOL_EXPAND,
|
||||
]
|
||||
|
||||
# Use directive-aware done tool if directives are present
|
||||
if directive_rules:
|
||||
|
||||
@@ -10,8 +10,8 @@ from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
# Valid fact types for recall operations (excludes 'observation' which is internal, and 'opinion' which is deprecated)
|
||||
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience"])
|
||||
# Valid fact types for recall operations (excludes 'opinion' which is deprecated)
|
||||
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "observation"])
|
||||
|
||||
|
||||
class LLMToolCall(BaseModel):
|
||||
@@ -28,12 +28,15 @@ class LLMToolCallResult(BaseModel):
|
||||
content: str | None = Field(default=None, description="Text content if any")
|
||||
tool_calls: list[LLMToolCall] = Field(default_factory=list, description="Tool calls requested by the LLM")
|
||||
finish_reason: str | None = Field(default=None, description="Reason the LLM stopped: 'stop', 'tool_calls', etc.")
|
||||
input_tokens: int = Field(default=0, description="Input tokens used in this call")
|
||||
output_tokens: int = Field(default=0, description="Output tokens used in this call")
|
||||
|
||||
|
||||
class ToolCallTrace(BaseModel):
|
||||
"""A single tool call made during reflect."""
|
||||
|
||||
tool: str = Field(description="Tool name: lookup, recall, learn, expand")
|
||||
reason: str | None = Field(default=None, description="Agent's reasoning for making this tool call")
|
||||
input: dict = Field(description="Tool input parameters")
|
||||
output: dict = Field(description="Tool output/result")
|
||||
duration_ms: int = Field(description="Execution time in milliseconds")
|
||||
@@ -47,13 +50,13 @@ class LLMCallTrace(BaseModel):
|
||||
duration_ms: int = Field(description="Execution time in milliseconds")
|
||||
|
||||
|
||||
class MentalModelRef(BaseModel):
|
||||
"""Reference to a mental model accessed during reflect."""
|
||||
class ObservationRef(BaseModel):
|
||||
"""Reference to an observation accessed during reflect."""
|
||||
|
||||
id: str = Field(description="Mental model ID")
|
||||
name: str = Field(description="Mental model name")
|
||||
type: str = Field(description="Mental model type: entity, concept, event")
|
||||
subtype: str = Field(description="Mental model subtype: structural, emergent, learned")
|
||||
id: str = Field(description="Observation ID")
|
||||
name: str = Field(description="Observation name")
|
||||
type: str = Field(description="Observation type: entity, concept, event")
|
||||
subtype: str = Field(description="Observation subtype: structural, emergent, learned")
|
||||
description: str = Field(description="Brief description")
|
||||
summary: str | None = Field(default=None, description="Full summary (when looked up in detail)")
|
||||
|
||||
@@ -63,7 +66,7 @@ class DirectiveRef(BaseModel):
|
||||
|
||||
id: str = Field(description="Directive mental model ID")
|
||||
name: str = Field(description="Directive name")
|
||||
rules: list[str] = Field(default_factory=list, description="Directive rules/observations that were applied")
|
||||
content: str = Field(description="Directive content")
|
||||
|
||||
|
||||
class TokenUsage(BaseModel):
|
||||
@@ -166,6 +169,28 @@ class ChunkInfo(BaseModel):
|
||||
truncated: bool = Field(default=False, description="Whether the chunk was truncated due to token limits")
|
||||
|
||||
|
||||
class ObservationResult(BaseModel):
|
||||
"""An observation result from recall (consolidated knowledge synthesized from facts)."""
|
||||
|
||||
id: str = Field(description="Unique observation ID")
|
||||
text: str = Field(description="The observation text")
|
||||
proof_count: int = Field(description="Number of facts supporting this observation")
|
||||
relevance: float = Field(default=0.0, description="Relevance score to the query")
|
||||
tags: list[str] | None = Field(default=None, description="Tags for visibility scoping")
|
||||
source_memory_ids: list[str] = Field(
|
||||
default_factory=list, description="IDs of facts that contribute to this observation"
|
||||
)
|
||||
|
||||
|
||||
class MentalModelResult(BaseModel):
|
||||
"""A mental model result from recall (stored reflect response)."""
|
||||
|
||||
id: str = Field(description="Unique mental model ID")
|
||||
name: str = Field(description="Human-readable name")
|
||||
content: str = Field(description="The synthesized content")
|
||||
relevance: float = Field(default=0.0, description="Relevance score to the query")
|
||||
|
||||
|
||||
class RecallResult(BaseModel):
|
||||
"""
|
||||
Result from a recall operation.
|
||||
@@ -229,6 +254,14 @@ class ReflectResult(BaseModel):
|
||||
],
|
||||
"experience": [],
|
||||
"opinion": [],
|
||||
"mental_models": [],
|
||||
"directives": [
|
||||
{
|
||||
"id": "directive-123",
|
||||
"name": "Response Style",
|
||||
"rules": ["Always be concise"],
|
||||
}
|
||||
],
|
||||
},
|
||||
"new_opinions": ["Machine learning has great potential in healthcare"],
|
||||
"structured_output": {"summary": "ML in healthcare", "confidence": 0.9},
|
||||
@@ -238,8 +271,8 @@ class ReflectResult(BaseModel):
|
||||
)
|
||||
|
||||
text: str = Field(description="The formulated answer text")
|
||||
based_on: dict[str, list[MemoryFact]] = Field(
|
||||
description="Facts used to formulate the answer, organized by type (world, experience, opinion)"
|
||||
based_on: dict[str, Any] = Field(
|
||||
description="Facts used to formulate the answer, organized by type (world, experience, opinion, mental_models, directives)"
|
||||
)
|
||||
new_opinions: list[str] = Field(default_factory=list, description="List of newly formed opinions during reflection")
|
||||
structured_output: dict[str, Any] | None = Field(
|
||||
@@ -258,10 +291,6 @@ class ReflectResult(BaseModel):
|
||||
default_factory=list,
|
||||
description="Trace of LLM calls made during reflection. Only present when include.tool_calls is enabled.",
|
||||
)
|
||||
mental_models: list[MentalModelRef] = Field(
|
||||
default_factory=list,
|
||||
description="Mental models accessed during reflection, including directives (subtype='directive').",
|
||||
)
|
||||
directives_applied: list[DirectiveRef] = Field(
|
||||
default_factory=list,
|
||||
description="Directive mental models that were applied during this reflection.",
|
||||
|
||||
@@ -114,11 +114,8 @@ class CausalRelation(BaseModel):
|
||||
"""Causal relationship from this fact to a previous fact (stored format)."""
|
||||
|
||||
target_fact_index: int = Field(description="Index of the related fact in the facts array (0-based).")
|
||||
relation_type: Literal["caused_by", "enabled_by", "prevented_by"] = Field(
|
||||
description="How this fact relates to the target: "
|
||||
"'caused_by' = this fact was caused by the target, "
|
||||
"'enabled_by' = this fact was enabled by the target, "
|
||||
"'prevented_by' = this fact was prevented by the target"
|
||||
relation_type: Literal["caused_by"] = Field(
|
||||
description="How this fact relates to the target: 'caused_by' = this fact was caused by the target"
|
||||
)
|
||||
strength: float = Field(
|
||||
description="Strength of relationship (0.0 to 1.0)",
|
||||
@@ -141,11 +138,8 @@ class FactCausalRelation(BaseModel):
|
||||
"MUST be less than this fact's position in the list. "
|
||||
"Example: if this is fact #5, target_index can only be 0, 1, 2, 3, or 4."
|
||||
)
|
||||
relation_type: Literal["caused_by", "enabled_by", "prevented_by"] = Field(
|
||||
description="How this fact relates to the target fact: "
|
||||
"'caused_by' = this fact was caused by the target fact, "
|
||||
"'enabled_by' = this fact was enabled by the target fact, "
|
||||
"'prevented_by' = this fact was blocked/prevented by the target fact"
|
||||
relation_type: Literal["caused_by"] = Field(
|
||||
description="How this fact relates to the target fact: 'caused_by' = this fact was caused by the target fact"
|
||||
)
|
||||
strength: float = Field(
|
||||
description="Strength of relationship (0.0 to 1.0). 1.0 = strong, 0.5 = moderate",
|
||||
@@ -441,7 +435,7 @@ def _chunk_conversation(turns: list[dict], max_chars: int) -> list[str]:
|
||||
# Concise extraction prompt (default) - selective, high-quality facts
|
||||
CONCISE_FACT_EXTRACTION_PROMPT = """Extract SIGNIFICANT facts from text. Be SELECTIVE - only extract facts worth remembering long-term.
|
||||
|
||||
LANGUAGE RULE (CRITICAL): Output facts in the EXACT SAME language as the input text. If input is Japanese, output Japanese. If input is Chinese, output Chinese. NEVER translate to English. Preserve original language completely.
|
||||
LANGUAGE REQUIREMENT: Detect the language of the input text. All extracted facts, entity names, descriptions, and other output MUST be in the SAME language as the input. Do not translate to another language.
|
||||
|
||||
{fact_types_instruction}
|
||||
|
||||
@@ -662,7 +656,7 @@ CAUSAL RELATIONSHIPS
|
||||
══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
Link facts with causal_relations (max 2 per fact). target_index must be < this fact's index.
|
||||
Types: "caused_by", "enabled_by", "prevented_by"
|
||||
Type: "caused_by" (this fact was caused by the target fact)
|
||||
|
||||
Example: "Lost job → couldn't pay rent → moved apartment"
|
||||
- Fact 0: Lost job, causal_relations: null
|
||||
@@ -823,7 +817,8 @@ Text:
|
||||
|
||||
# Critical field: fact_type
|
||||
# LLM uses "assistant" but we convert to "experience" for storage
|
||||
fact_type = llm_fact.get("fact_type")
|
||||
original_fact_type = llm_fact.get("fact_type")
|
||||
fact_type = original_fact_type
|
||||
|
||||
# Convert "assistant" → "experience" for storage
|
||||
if fact_type == "assistant":
|
||||
@@ -840,7 +835,10 @@ Text:
|
||||
else:
|
||||
# Default to 'world' if we can't determine
|
||||
fact_type = "world"
|
||||
logger.warning(f"Fact {i}: defaulting to fact_type='world'")
|
||||
logger.warning(
|
||||
f"Fact {i}: defaulting to fact_type='world' "
|
||||
f"(original fact_type={original_fact_type!r}, fact_kind={fact_kind!r})"
|
||||
)
|
||||
|
||||
# Get fact_kind for temporal handling (but don't store it)
|
||||
fact_kind = llm_fact.get("fact_kind", "conversation")
|
||||
|
||||
@@ -41,7 +41,6 @@ async def insert_facts_batch(
|
||||
contexts = []
|
||||
fact_types = []
|
||||
confidence_scores = []
|
||||
access_counts = []
|
||||
metadata_jsons = []
|
||||
chunk_ids = []
|
||||
document_ids = []
|
||||
@@ -61,7 +60,6 @@ async def insert_facts_batch(
|
||||
fact_types.append(fact.fact_type)
|
||||
# confidence_score is only for opinion facts
|
||||
confidence_scores.append(1.0 if fact.fact_type == "opinion" else None)
|
||||
access_counts.append(0) # Initial access count
|
||||
metadata_jsons.append(json.dumps(fact.metadata))
|
||||
chunk_ids.append(fact.chunk_id)
|
||||
# Use per-fact document_id if available, otherwise fallback to batch-level document_id
|
||||
@@ -76,16 +74,16 @@ async def insert_facts_batch(
|
||||
WITH input_data AS (
|
||||
SELECT * FROM unnest(
|
||||
$2::text[], $3::vector[], $4::timestamptz[], $5::timestamptz[], $6::timestamptz[], $7::timestamptz[],
|
||||
$8::text[], $9::text[], $10::float[], $11::int[], $12::jsonb[], $13::text[], $14::text[], $15::jsonb[]
|
||||
$8::text[], $9::text[], $10::float[], $11::jsonb[], $12::text[], $13::text[], $14::jsonb[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id, tags_json)
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json)
|
||||
)
|
||||
INSERT INTO {fq_table("memory_units")} (bank_id, text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id, tags)
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags)
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id,
|
||||
COALESCE(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
@@ -103,7 +101,6 @@ async def insert_facts_batch(
|
||||
contexts,
|
||||
fact_types,
|
||||
confidence_scores,
|
||||
access_counts,
|
||||
metadata_jsons,
|
||||
chunk_ids,
|
||||
document_ids,
|
||||
|
||||
@@ -754,17 +754,14 @@ async def create_causal_links_batch(
|
||||
causal_relations_per_fact: List of causal relations for each fact.
|
||||
Each element is a list of dicts with:
|
||||
- target_fact_index: Index into unit_ids for the target fact
|
||||
- relation_type: "causes", "caused_by", "enables", or "prevents"
|
||||
- relation_type: "caused_by"
|
||||
- strength: Float in [0.0, 1.0] representing relationship strength
|
||||
|
||||
Returns:
|
||||
Number of causal links created
|
||||
|
||||
Causal link types:
|
||||
- "causes": This fact directly causes the target fact (forward causation)
|
||||
- "caused_by": This fact was caused by the target fact (backward causation)
|
||||
- "enables": This fact enables/allows the target fact (enablement)
|
||||
- "prevents": This fact prevents/blocks the target fact (prevention)
|
||||
Causal link type:
|
||||
- "caused_by": This fact was caused by the target fact
|
||||
"""
|
||||
if not unit_ids or not causal_relations_per_fact:
|
||||
return 0
|
||||
@@ -787,8 +784,8 @@ async def create_causal_links_batch(
|
||||
relation_type = relation["relation_type"]
|
||||
strength = relation.get("strength", 1.0)
|
||||
|
||||
# Validate relation_type - must match database constraint
|
||||
valid_types = {"causes", "caused_by", "enables", "prevents"}
|
||||
# Validate relation_type - only "caused_by" is supported (DB constraint)
|
||||
valid_types = {"caused_by"}
|
||||
if relation_type not in valid_types:
|
||||
logger.error(
|
||||
f"Invalid relation_type '{relation_type}' (type: {type(relation_type).__name__}) "
|
||||
|
||||
@@ -86,10 +86,10 @@ class CausalRelation:
|
||||
"""
|
||||
Causal relationship between facts.
|
||||
|
||||
Represents how one fact causes, enables, or prevents another.
|
||||
Represents how one fact was caused by another.
|
||||
"""
|
||||
|
||||
relation_type: str # "causes", "enables", "prevents", "caused_by"
|
||||
relation_type: str # "caused_by"
|
||||
target_fact_index: int # Index of the target fact in the batch
|
||||
strength: float = 1.0 # Strength of the causal relationship
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@ class BFSGraphRetriever(GraphRetriever):
|
||||
entry_points = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end,
|
||||
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
@@ -216,7 +216,7 @@ class BFSGraphRetriever(GraphRetriever):
|
||||
neighbors = await conn.fetch(
|
||||
f"""
|
||||
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end,
|
||||
mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type,
|
||||
mu.mentioned_at, mu.embedding, mu.fact_type,
|
||||
mu.document_id, mu.chunk_id, mu.tags,
|
||||
ml.weight, ml.link_type, ml.from_unit_id
|
||||
FROM {fq_table("memory_links")} ml
|
||||
|
||||
@@ -45,7 +45,7 @@ async def _find_semantic_seeds(
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end,
|
||||
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
@@ -155,7 +155,7 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
all_seeds.extend(temporal_seeds)
|
||||
|
||||
if not all_seeds:
|
||||
logger.debug("[LinkExpansion] No seeds found, returning empty results")
|
||||
logger.info("[LinkExpansion] No seeds found, returning empty results")
|
||||
return [], timings
|
||||
|
||||
seed_ids = list({s.id for s in all_seeds})
|
||||
@@ -164,36 +164,108 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
# Run entity and causal expansion sequentially on same connection
|
||||
query_start = time.time()
|
||||
|
||||
entity_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT
|
||||
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
COUNT(*)::float AS score
|
||||
FROM {fq_table("unit_entities")} seed_ue
|
||||
JOIN {fq_table("entities")} e ON seed_ue.entity_id = e.id
|
||||
JOIN {fq_table("unit_entities")} other_ue ON seed_ue.entity_id = other_ue.entity_id
|
||||
JOIN {fq_table("memory_units")} mu ON other_ue.unit_id = mu.id
|
||||
WHERE seed_ue.unit_id = ANY($1::uuid[])
|
||||
AND e.mention_count < $2
|
||||
AND mu.id != ALL($1::uuid[])
|
||||
AND mu.fact_type = $3
|
||||
GROUP BY mu.id
|
||||
ORDER BY score DESC
|
||||
LIMIT $4
|
||||
""",
|
||||
seed_ids,
|
||||
self.max_entity_frequency,
|
||||
fact_type,
|
||||
budget,
|
||||
)
|
||||
# For observations, traverse through source_memory_ids to find entity connections.
|
||||
# Observations don't have direct unit_entities - they inherit entities via their
|
||||
# source world/experience facts.
|
||||
#
|
||||
# Path: observation → source_memory_ids → world fact → entities →
|
||||
# ALL world facts with those entities → their observations (excluding seeds)
|
||||
if fact_type == "observation":
|
||||
# Debug: Check what source_memory_ids exist on seed observations
|
||||
debug_sources = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, source_memory_ids
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = ANY($1::uuid[])
|
||||
""",
|
||||
seed_ids,
|
||||
)
|
||||
source_ids_found = []
|
||||
for row in debug_sources:
|
||||
if row["source_memory_ids"]:
|
||||
source_ids_found.extend(row["source_memory_ids"])
|
||||
logger.debug(
|
||||
f"[LinkExpansion] observation graph: {len(seed_ids)} seeds, "
|
||||
f"{len(source_ids_found)} source_memory_ids found"
|
||||
)
|
||||
|
||||
entity_rows = await conn.fetch(
|
||||
f"""
|
||||
WITH seed_sources AS (
|
||||
-- Get source memory IDs from seed observations
|
||||
SELECT DISTINCT unnest(source_memory_ids) AS source_id
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = ANY($1::uuid[])
|
||||
AND source_memory_ids IS NOT NULL
|
||||
),
|
||||
source_entities AS (
|
||||
-- Get entities from those source memories (filtered by frequency)
|
||||
SELECT DISTINCT ue.entity_id
|
||||
FROM seed_sources ss
|
||||
JOIN {fq_table("unit_entities")} ue ON ss.source_id = ue.unit_id
|
||||
JOIN {fq_table("entities")} e ON ue.entity_id = e.id
|
||||
WHERE e.mention_count < $2
|
||||
),
|
||||
all_connected_sources AS (
|
||||
-- Find ALL world facts sharing those entities (don't exclude seed sources)
|
||||
-- The exclusion happens at the observation level, not the source level
|
||||
SELECT DISTINCT other_ue.unit_id AS source_id
|
||||
FROM source_entities se
|
||||
JOIN {fq_table("unit_entities")} other_ue ON se.entity_id = other_ue.entity_id
|
||||
)
|
||||
-- Find observations derived from connected source memories
|
||||
-- Only exclude the actual seed observations
|
||||
SELECT
|
||||
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at, mu.embedding,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
COUNT(DISTINCT cs.source_id)::float AS score
|
||||
FROM all_connected_sources cs
|
||||
JOIN {fq_table("memory_units")} mu
|
||||
ON mu.source_memory_ids @> ARRAY[cs.source_id]
|
||||
WHERE mu.fact_type = 'observation'
|
||||
AND mu.id != ALL($1::uuid[])
|
||||
GROUP BY mu.id
|
||||
ORDER BY score DESC
|
||||
LIMIT $3
|
||||
""",
|
||||
seed_ids,
|
||||
self.max_entity_frequency,
|
||||
budget,
|
||||
)
|
||||
logger.debug(f"[LinkExpansion] observation graph: found {len(entity_rows)} connected observations")
|
||||
else:
|
||||
# For world/experience facts, use direct entity lookup
|
||||
entity_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT
|
||||
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at, mu.embedding,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
COUNT(*)::float AS score
|
||||
FROM {fq_table("unit_entities")} seed_ue
|
||||
JOIN {fq_table("entities")} e ON seed_ue.entity_id = e.id
|
||||
JOIN {fq_table("unit_entities")} other_ue ON seed_ue.entity_id = other_ue.entity_id
|
||||
JOIN {fq_table("memory_units")} mu ON other_ue.unit_id = mu.id
|
||||
WHERE seed_ue.unit_id = ANY($1::uuid[])
|
||||
AND e.mention_count < $2
|
||||
AND mu.id != ALL($1::uuid[])
|
||||
AND mu.fact_type = $3
|
||||
GROUP BY mu.id
|
||||
ORDER BY score DESC
|
||||
LIMIT $4
|
||||
""",
|
||||
seed_ids,
|
||||
self.max_entity_frequency,
|
||||
fact_type,
|
||||
budget,
|
||||
)
|
||||
|
||||
causal_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT DISTINCT ON (mu.id)
|
||||
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding,
|
||||
mu.occurred_end, mu.mentioned_at, mu.embedding,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
ml.weight + 1.0 AS score
|
||||
FROM {fq_table("memory_links")} ml
|
||||
@@ -211,11 +283,69 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
budget,
|
||||
)
|
||||
|
||||
# Fallback: semantic/temporal/entity links from memory_links table
|
||||
# These are secondary to entity links (via unit_entities) and causal links
|
||||
# Weight is halved (0.5x) to prioritize primary link types
|
||||
# Check both directions: seeds -> others AND others -> seeds
|
||||
fallback_rows = await conn.fetch(
|
||||
f"""
|
||||
WITH outgoing AS (
|
||||
-- Links FROM seeds TO other facts
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at, mu.embedding,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
ml.weight
|
||||
FROM {fq_table("memory_links")} ml
|
||||
JOIN {fq_table("memory_units")} mu ON ml.to_unit_id = mu.id
|
||||
WHERE ml.from_unit_id = ANY($1::uuid[])
|
||||
AND ml.link_type IN ('semantic', 'temporal', 'entity')
|
||||
AND ml.weight >= $2
|
||||
AND mu.fact_type = $3
|
||||
AND mu.id != ALL($1::uuid[])
|
||||
),
|
||||
incoming AS (
|
||||
-- Links FROM other facts TO seeds (reverse direction)
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at, mu.embedding,
|
||||
mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
ml.weight
|
||||
FROM {fq_table("memory_links")} ml
|
||||
JOIN {fq_table("memory_units")} mu ON ml.from_unit_id = mu.id
|
||||
WHERE ml.to_unit_id = ANY($1::uuid[])
|
||||
AND ml.link_type IN ('semantic', 'temporal', 'entity')
|
||||
AND ml.weight >= $2
|
||||
AND mu.fact_type = $3
|
||||
AND mu.id != ALL($1::uuid[])
|
||||
),
|
||||
combined AS (
|
||||
SELECT * FROM outgoing
|
||||
UNION ALL
|
||||
SELECT * FROM incoming
|
||||
)
|
||||
SELECT DISTINCT ON (id)
|
||||
id, text, context, event_date, occurred_start,
|
||||
occurred_end, mentioned_at, embedding,
|
||||
fact_type, document_id, chunk_id, tags,
|
||||
(MAX(weight) * 0.5) AS score
|
||||
FROM combined
|
||||
GROUP BY id, text, context, event_date, occurred_start,
|
||||
occurred_end, mentioned_at, embedding,
|
||||
fact_type, document_id, chunk_id, tags
|
||||
ORDER BY id, score DESC
|
||||
LIMIT $4
|
||||
""",
|
||||
seed_ids,
|
||||
self.causal_weight_threshold,
|
||||
fact_type,
|
||||
budget,
|
||||
)
|
||||
|
||||
timings.edge_load_time = time.time() - query_start
|
||||
timings.db_queries = 2
|
||||
timings.edge_count = len(entity_rows) + len(causal_rows)
|
||||
timings.db_queries = 3
|
||||
timings.edge_count = len(entity_rows) + len(causal_rows) + len(fallback_rows)
|
||||
|
||||
# Merge results, taking max score per fact
|
||||
# Priority: entity links (unit_entities) > causal links > fallback links
|
||||
score_map: dict[str, float] = {}
|
||||
row_map: dict[str, dict] = {}
|
||||
|
||||
@@ -230,6 +360,12 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
if fact_id not in row_map:
|
||||
row_map[fact_id] = dict(row)
|
||||
|
||||
for row in fallback_rows:
|
||||
fact_id = str(row["id"])
|
||||
score_map[fact_id] = max(score_map.get(fact_id, 0), row["score"])
|
||||
if fact_id not in row_map:
|
||||
row_map[fact_id] = dict(row)
|
||||
|
||||
# Sort by score and limit
|
||||
sorted_ids = sorted(score_map.keys(), key=lambda x: score_map[x], reverse=True)[:budget]
|
||||
rows = [row_map[fact_id] for fact_id in sorted_ids]
|
||||
|
||||
@@ -449,7 +449,7 @@ async def fetch_memory_units_by_ids(
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end,
|
||||
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags
|
||||
mentioned_at, embedding, fact_type, document_id, chunk_id, tags
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = ANY($1::uuid[])
|
||||
AND fact_type = $2
|
||||
|
||||
@@ -116,7 +116,7 @@ async def retrieve_semantic(
|
||||
|
||||
results = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
@@ -180,7 +180,7 @@ async def retrieve_bm25(
|
||||
|
||||
results = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
ts_rank_cd(search_vector, to_tsquery('english', $1)) AS bm25_score
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
@@ -237,7 +237,7 @@ async def retrieve_semantic_bm25_combined(
|
||||
results = await conn.fetch(
|
||||
f"""
|
||||
WITH semantic_ranked AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity,
|
||||
NULL::float AS bm25_score,
|
||||
'semantic' AS source,
|
||||
@@ -249,7 +249,7 @@ async def retrieve_semantic_bm25_combined(
|
||||
AND (1 - (embedding <=> $1::vector)) >= 0.3
|
||||
{tags_clause}
|
||||
)
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
similarity, bm25_score, source
|
||||
FROM semantic_ranked
|
||||
WHERE rn <= $4
|
||||
@@ -281,7 +281,7 @@ async def retrieve_semantic_bm25_combined(
|
||||
results = await conn.fetch(
|
||||
f"""
|
||||
WITH semantic_ranked AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity,
|
||||
NULL::float AS bm25_score,
|
||||
'semantic' AS source,
|
||||
@@ -294,7 +294,7 @@ async def retrieve_semantic_bm25_combined(
|
||||
{tags_clause}
|
||||
),
|
||||
bm25_ranked AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
NULL::float AS similarity,
|
||||
ts_rank_cd(search_vector, to_tsquery('english', $5)) AS bm25_score,
|
||||
'bm25' AS source,
|
||||
@@ -306,12 +306,12 @@ async def retrieve_semantic_bm25_combined(
|
||||
{tags_clause}
|
||||
),
|
||||
semantic AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
similarity, bm25_score, source
|
||||
FROM semantic_ranked WHERE rn <= $4
|
||||
),
|
||||
bm25 AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
similarity, bm25_score, source
|
||||
FROM bm25_ranked WHERE rn <= $4
|
||||
)
|
||||
@@ -386,7 +386,7 @@ async def retrieve_temporal_combined(
|
||||
entry_points = await conn.fetch(
|
||||
f"""
|
||||
WITH ranked_entries AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity,
|
||||
ROW_NUMBER() OVER (PARTITION BY fact_type ORDER BY COALESCE(occurred_start, mentioned_at, occurred_end) DESC, embedding <=> $1::vector) AS rn
|
||||
FROM {fq_table("memory_units")}
|
||||
@@ -406,7 +406,7 @@ async def retrieve_temporal_combined(
|
||||
AND (1 - (embedding <=> $1::vector)) >= $6
|
||||
{tags_clause}
|
||||
)
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags, similarity
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags, similarity
|
||||
FROM ranked_entries
|
||||
WHERE rn <= 10
|
||||
""",
|
||||
@@ -486,7 +486,7 @@ async def retrieve_temporal_combined(
|
||||
|
||||
neighbors = await conn.fetch(
|
||||
f"""
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id, mu.tags,
|
||||
ml.weight, ml.link_type, ml.from_unit_id,
|
||||
1 - (mu.embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_links")} ml
|
||||
@@ -610,7 +610,7 @@ async def retrieve_temporal(
|
||||
|
||||
entry_points = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
@@ -691,7 +691,7 @@ async def retrieve_temporal(
|
||||
# Batch fetch all neighbors for this batch of nodes
|
||||
neighbors = await conn.fetch(
|
||||
f"""
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.access_count, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id,
|
||||
SELECT mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start, mu.occurred_end, mu.mentioned_at, mu.embedding, mu.fact_type, mu.document_id, mu.chunk_id,
|
||||
ml.weight, ml.link_type, ml.from_unit_id,
|
||||
1 - (mu.embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_links")} ml
|
||||
@@ -1023,7 +1023,7 @@ async def _get_temporal_entry_points(
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
access_count, embedding, fact_type, document_id, chunk_id,
|
||||
embedding, fact_type, document_id, chunk_id,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
"""
|
||||
Scoring functions for memory search and retrieval.
|
||||
|
||||
Includes recency weighting, frequency weighting, temporal proximity,
|
||||
and similarity calculations used in memory activation and ranking.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
|
||||
"""
|
||||
Calculate cosine similarity between two vectors.
|
||||
|
||||
Args:
|
||||
vec1: First vector
|
||||
vec2: Second vector
|
||||
|
||||
Returns:
|
||||
Similarity score between 0 and 1
|
||||
"""
|
||||
if len(vec1) != len(vec2):
|
||||
raise ValueError("Vectors must have same dimension")
|
||||
|
||||
dot_product = sum(a * b for a, b in zip(vec1, vec2))
|
||||
magnitude1 = sum(a * a for a in vec1) ** 0.5
|
||||
magnitude2 = sum(b * b for b in vec2) ** 0.5
|
||||
|
||||
if magnitude1 == 0 or magnitude2 == 0:
|
||||
return 0.0
|
||||
|
||||
return dot_product / (magnitude1 * magnitude2)
|
||||
|
||||
|
||||
def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -> float:
|
||||
"""
|
||||
Calculate recency weight using logarithmic decay.
|
||||
|
||||
This provides much better differentiation over long time periods compared to
|
||||
exponential decay. Uses a log-based decay where the half-life parameter controls
|
||||
when memories reach 50% weight.
|
||||
|
||||
Examples:
|
||||
- Today (0 days): 1.0
|
||||
- 1 year (365 days): ~0.5 (with default half_life=365)
|
||||
- 2 years (730 days): ~0.33
|
||||
- 5 years (1825 days): ~0.17
|
||||
- 10 years (3650 days): ~0.09
|
||||
|
||||
This ensures that 2-year-old and 5-year-old memories have meaningfully
|
||||
different weights, unlike exponential decay which makes them both ~0.
|
||||
|
||||
Args:
|
||||
days_since: Number of days since the memory was created
|
||||
half_life_days: Number of days for weight to reach 0.5 (default: 1 year)
|
||||
|
||||
Returns:
|
||||
Weight between 0 and 1
|
||||
"""
|
||||
import math
|
||||
|
||||
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
|
||||
# This decays much slower than exponential, giving better long-term differentiation
|
||||
normalized_age = days_since / half_life_days
|
||||
return 1.0 / (1.0 + math.log1p(normalized_age))
|
||||
|
||||
|
||||
def calculate_frequency_weight(access_count: int, max_boost: float = 2.0) -> float:
|
||||
"""
|
||||
Calculate frequency weight based on access count.
|
||||
|
||||
Frequently accessed memories are weighted higher.
|
||||
Uses logarithmic scaling to avoid over-weighting.
|
||||
|
||||
Args:
|
||||
access_count: Number of times the memory was accessed
|
||||
max_boost: Maximum multiplier for frequently accessed memories
|
||||
|
||||
Returns:
|
||||
Weight between 1.0 and max_boost
|
||||
"""
|
||||
import math
|
||||
|
||||
if access_count <= 0:
|
||||
return 1.0
|
||||
|
||||
# Logarithmic scaling: log(access_count + 1) / log(10)
|
||||
# This gives: 0 accesses = 1.0, 9 accesses ~= 1.5, 99 accesses ~= 2.0
|
||||
normalized = math.log(access_count + 1) / math.log(10)
|
||||
return 1.0 + min(normalized, max_boost - 1.0)
|
||||
|
||||
|
||||
def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime) -> datetime:
|
||||
"""
|
||||
Calculate a single temporal anchor point from a temporal range.
|
||||
|
||||
Used for spreading activation - we need a single representative date
|
||||
to calculate temporal proximity between facts. This simplifies the
|
||||
range-to-range distance problem.
|
||||
|
||||
Strategy: Use midpoint of the range for balanced representation.
|
||||
|
||||
Args:
|
||||
occurred_start: Start of temporal range
|
||||
occurred_end: End of temporal range
|
||||
|
||||
Returns:
|
||||
Single datetime representing the temporal anchor (midpoint)
|
||||
|
||||
Examples:
|
||||
- Point event (July 14): start=July 14, end=July 14 → anchor=July 14
|
||||
- Month range (February): start=Feb 1, end=Feb 28 → anchor=Feb 14
|
||||
- Year range (2023): start=Jan 1, end=Dec 31 → anchor=July 1
|
||||
"""
|
||||
# Calculate midpoint
|
||||
time_delta = occurred_end - occurred_start
|
||||
midpoint = occurred_start + (time_delta / 2)
|
||||
return midpoint
|
||||
|
||||
|
||||
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
|
||||
"""
|
||||
Calculate temporal proximity between two temporal anchors.
|
||||
|
||||
Used for spreading activation to determine how "close" two facts are
|
||||
in time. Uses logarithmic decay so that temporal similarity doesn't
|
||||
drop off too quickly.
|
||||
|
||||
Args:
|
||||
anchor_a: Temporal anchor of first fact
|
||||
anchor_b: Temporal anchor of second fact
|
||||
half_life_days: Number of days for proximity to reach 0.5
|
||||
(default: 30 days = 1 month)
|
||||
|
||||
Returns:
|
||||
Proximity score in [0, 1] where:
|
||||
- 1.0 = same day
|
||||
- 0.5 = ~half_life days apart
|
||||
- 0.0 = very distant in time
|
||||
|
||||
Examples:
|
||||
- Same day: 1.0
|
||||
- 1 week apart (half_life=30): ~0.7
|
||||
- 1 month apart (half_life=30): ~0.5
|
||||
- 1 year apart (half_life=30): ~0.2
|
||||
"""
|
||||
import math
|
||||
|
||||
days_apart = abs((anchor_a - anchor_b).days)
|
||||
|
||||
if days_apart == 0:
|
||||
return 1.0
|
||||
|
||||
# Logarithmic decay: 1 / (1 + log(1 + days_apart/half_life))
|
||||
# Similar to calculate_recency_weight but for proximity between events
|
||||
normalized_distance = days_apart / half_life_days
|
||||
proximity = 1.0 / (1.0 + math.log1p(normalized_distance))
|
||||
|
||||
return proximity
|
||||
@@ -85,7 +85,6 @@ class NodeVisit(BaseModel):
|
||||
text: str = Field(description="Memory unit text content")
|
||||
context: str = Field(description="Memory unit context")
|
||||
event_date: datetime | None = Field(default=None, description="When the memory occurred")
|
||||
access_count: int = Field(description="Number of times accessed before this search")
|
||||
|
||||
# How this node was reached
|
||||
is_entry_point: bool = Field(description="Whether this is an entry point")
|
||||
|
||||
@@ -136,7 +136,6 @@ class SearchTracer:
|
||||
text: str,
|
||||
context: str,
|
||||
event_date: datetime | None,
|
||||
access_count: int,
|
||||
is_entry_point: bool,
|
||||
parent_node_id: str | None,
|
||||
link_type: Literal["temporal", "semantic", "entity"] | None,
|
||||
@@ -155,7 +154,6 @@ class SearchTracer:
|
||||
text: Memory unit text
|
||||
context: Memory unit context
|
||||
event_date: When the memory occurred
|
||||
access_count: Access count before this search
|
||||
is_entry_point: Whether this is an entry point
|
||||
parent_node_id: Node that led here (None for entry points)
|
||||
link_type: Type of link from parent
|
||||
@@ -194,7 +192,6 @@ class SearchTracer:
|
||||
text=text,
|
||||
context=context,
|
||||
event_date=event_date,
|
||||
access_count=access_count,
|
||||
is_entry_point=is_entry_point,
|
||||
parent_node_id=parent_node_id,
|
||||
link_type=link_type,
|
||||
@@ -333,8 +330,8 @@ class SearchTracer:
|
||||
RetrievalResult(
|
||||
rank=rank,
|
||||
node_id=doc_id,
|
||||
text=data.get("text", ""),
|
||||
context=data.get("context", ""),
|
||||
text=data.get("text") or "",
|
||||
context=data.get("context") or "",
|
||||
event_date=data.get("event_date"),
|
||||
fact_type=data.get("fact_type") or fact_type,
|
||||
score=score,
|
||||
|
||||
@@ -46,7 +46,6 @@ class RetrievalResult:
|
||||
mentioned_at: datetime | None = None
|
||||
document_id: str | None = None
|
||||
chunk_id: str | None = None
|
||||
access_count: int = 0
|
||||
embedding: list[float] | None = None
|
||||
tags: list[str] | None = None # Visibility scope tags
|
||||
|
||||
@@ -71,7 +70,6 @@ class RetrievalResult:
|
||||
mentioned_at=row.get("mentioned_at"),
|
||||
document_id=row.get("document_id"),
|
||||
chunk_id=row.get("chunk_id"),
|
||||
access_count=row.get("access_count", 0),
|
||||
embedding=row.get("embedding"),
|
||||
tags=row.get("tags"),
|
||||
similarity=row.get("similarity"),
|
||||
@@ -156,7 +154,6 @@ class ScoredResult:
|
||||
"mentioned_at": self.retrieval.mentioned_at,
|
||||
"document_id": self.retrieval.document_id,
|
||||
"chunk_id": self.retrieval.chunk_id,
|
||||
"access_count": self.retrieval.access_count,
|
||||
"embedding": self.retrieval.embedding,
|
||||
"tags": self.retrieval.tags,
|
||||
"semantic_similarity": self.retrieval.similarity,
|
||||
|
||||
@@ -1,31 +1,40 @@
|
||||
"""
|
||||
Abstract task backend for running async tasks.
|
||||
Task backend for distributed task processing.
|
||||
|
||||
This provides an abstraction that can be adapted to different execution models:
|
||||
- AsyncIO queue (default implementation)
|
||||
- Pub/Sub architectures (future)
|
||||
- Message brokers (future)
|
||||
This provides an abstraction for task storage and execution:
|
||||
- BrokerTaskBackend: Uses PostgreSQL as broker (production)
|
||||
- SyncTaskBackend: Executes tasks immediately (testing/embedded)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncpg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fq_table(table: str, schema: str | None = None) -> str:
|
||||
"""Get fully-qualified table name with optional schema prefix."""
|
||||
if schema:
|
||||
return f'"{schema}".{table}'
|
||||
return table
|
||||
|
||||
|
||||
class TaskBackend(ABC):
|
||||
"""
|
||||
Abstract base class for task execution backends.
|
||||
|
||||
Implementations must:
|
||||
1. Store/publish task events (as serializable dicts)
|
||||
2. Execute tasks through a provided executor callback
|
||||
2. Execute tasks through a provided executor callback (optional)
|
||||
|
||||
The backend treats tasks as pure dictionaries that can be serialized
|
||||
and sent over the network. The executor (typically MemoryEngine.execute_task)
|
||||
and stored in the database. The executor (typically MemoryEngine.execute_task)
|
||||
receives the dict and routes it to the appropriate handler.
|
||||
"""
|
||||
|
||||
@@ -46,7 +55,7 @@ class TaskBackend(ABC):
|
||||
@abstractmethod
|
||||
async def initialize(self):
|
||||
"""
|
||||
Initialize the backend (e.g., start workers, connect to broker).
|
||||
Initialize the backend (e.g., connect to database).
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -63,7 +72,7 @@ class TaskBackend(ABC):
|
||||
@abstractmethod
|
||||
async def shutdown(self):
|
||||
"""
|
||||
Shutdown the backend gracefully (e.g., stop workers, close connections).
|
||||
Shutdown the backend gracefully.
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -93,9 +102,8 @@ class SyncTaskBackend(TaskBackend):
|
||||
"""
|
||||
Synchronous task backend that executes tasks immediately.
|
||||
|
||||
This is useful for embedded/CLI usage where we don't want background
|
||||
workers that prevent clean exit. Tasks are executed inline rather than
|
||||
being queued.
|
||||
This is useful for tests and embedded/CLI usage where we don't want
|
||||
background workers. Tasks are executed inline rather than being queued.
|
||||
"""
|
||||
|
||||
async def initialize(self):
|
||||
@@ -121,221 +129,129 @@ class SyncTaskBackend(TaskBackend):
|
||||
logger.debug("SyncTaskBackend shutdown")
|
||||
|
||||
|
||||
class NoopTaskBackend(TaskBackend):
|
||||
class BrokerTaskBackend(TaskBackend):
|
||||
"""
|
||||
No-op task backend that discards all tasks.
|
||||
Task backend using PostgreSQL as broker.
|
||||
|
||||
This is useful for tests where background task execution is not needed
|
||||
and would only slow down the test suite.
|
||||
submit_task() stores task_payload in async_operations table.
|
||||
Actual polling and execution is handled separately by WorkerPoller.
|
||||
|
||||
This backend is used by the API to store tasks. Workers poll
|
||||
the database separately to claim and execute tasks.
|
||||
"""
|
||||
|
||||
async def initialize(self):
|
||||
"""No-op."""
|
||||
self._initialized = True
|
||||
logger.debug("NoopTaskBackend initialized")
|
||||
|
||||
async def submit_task(self, task_dict: dict[str, Any]):
|
||||
"""Discard the task (do nothing)."""
|
||||
pass
|
||||
|
||||
async def shutdown(self):
|
||||
"""No-op."""
|
||||
self._initialized = False
|
||||
logger.debug("NoopTaskBackend shutdown")
|
||||
|
||||
|
||||
class AsyncIOQueueBackend(TaskBackend):
|
||||
"""
|
||||
Task backend implementation using asyncio queues.
|
||||
|
||||
This is the default implementation that uses in-process asyncio queues
|
||||
and a periodic consumer worker.
|
||||
"""
|
||||
|
||||
def __init__(self, batch_size: int = 10, batch_interval: float = 1.0):
|
||||
def __init__(
|
||||
self,
|
||||
pool_getter: Callable[[], "asyncpg.Pool"],
|
||||
schema: str | None = None,
|
||||
schema_getter: Callable[[], str | None] | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize AsyncIO queue backend.
|
||||
Initialize the broker task backend.
|
||||
|
||||
Args:
|
||||
batch_size: Maximum number of tasks to process in one batch
|
||||
batch_interval: Maximum time (seconds) to wait before processing batch
|
||||
pool_getter: Callable that returns the asyncpg connection pool
|
||||
schema: Database schema for multi-tenant support (optional, static)
|
||||
schema_getter: Callable that returns current schema dynamically (optional).
|
||||
If set, takes precedence over static schema for submit_task.
|
||||
"""
|
||||
super().__init__()
|
||||
self._queue: asyncio.Queue | None = None
|
||||
self._worker_task: asyncio.Task | None = None
|
||||
self._shutdown_event: asyncio.Event | None = None
|
||||
self._batch_size = batch_size
|
||||
self._batch_interval = batch_interval
|
||||
self._in_flight_count = 0
|
||||
self._in_flight_lock = asyncio.Lock()
|
||||
self._pool_getter = pool_getter
|
||||
self._schema = schema
|
||||
self._schema_getter = schema_getter
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize the queue and start the worker."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
self._queue = asyncio.Queue()
|
||||
self._shutdown_event = asyncio.Event()
|
||||
self._worker_task = asyncio.create_task(self._worker())
|
||||
"""Initialize the backend."""
|
||||
self._initialized = True
|
||||
logger.info("AsyncIOQueueBackend initialized")
|
||||
logger.info("BrokerTaskBackend initialized")
|
||||
|
||||
async def submit_task(self, task_dict: dict[str, Any]):
|
||||
"""
|
||||
Submit a task by putting it in the queue.
|
||||
Store task payload in async_operations table.
|
||||
|
||||
The task_dict should contain an 'operation_id' if updating an existing
|
||||
operation record, otherwise a new operation will be created.
|
||||
|
||||
Args:
|
||||
task_dict: Task dictionary to execute
|
||||
task_dict: Task dictionary to store (must be JSON serializable)
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
await self._queue.put(task_dict)
|
||||
pool = self._pool_getter()
|
||||
operation_id = task_dict.get("operation_id")
|
||||
task_type = task_dict.get("type", "unknown")
|
||||
bank_id = task_dict.get("bank_id")
|
||||
payload_json = json.dumps(task_dict)
|
||||
|
||||
schema = self._schema_getter() if self._schema_getter else self._schema
|
||||
table = fq_table("async_operations", schema)
|
||||
|
||||
if operation_id:
|
||||
# Update existing operation with task payload
|
||||
await pool.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET task_payload = $1::jsonb, updated_at = now()
|
||||
WHERE operation_id = $2
|
||||
""",
|
||||
payload_json,
|
||||
operation_id,
|
||||
)
|
||||
logger.debug(f"Updated task payload for operation {operation_id}")
|
||||
else:
|
||||
# Insert new operation (for tasks without pre-created records)
|
||||
# e.g., access_count_update tasks
|
||||
import uuid
|
||||
|
||||
new_id = uuid.uuid4()
|
||||
await pool.execute(
|
||||
f"""
|
||||
INSERT INTO {table} (operation_id, bank_id, operation_type, status, task_payload)
|
||||
VALUES ($1, $2, $3, 'pending', $4::jsonb)
|
||||
""",
|
||||
new_id,
|
||||
bank_id,
|
||||
task_type,
|
||||
payload_json,
|
||||
)
|
||||
logger.debug(f"Created new operation {new_id} for task type {task_type}")
|
||||
|
||||
async def shutdown(self):
|
||||
"""Shutdown the backend."""
|
||||
self._initialized = False
|
||||
logger.info("BrokerTaskBackend shutdown")
|
||||
|
||||
async def wait_for_pending_tasks(self, timeout: float = 120.0):
|
||||
"""
|
||||
Wait for all pending tasks in the queue and in-flight tasks to complete.
|
||||
Wait for pending tasks to be processed.
|
||||
|
||||
This is useful in tests to ensure background tasks complete before assertions.
|
||||
In the broker model, this polls the database to check if tasks
|
||||
for this process have been completed. This is useful in tests
|
||||
when worker_enabled=True (API processes its own tasks).
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait in seconds (default 120s for long-running tasks)
|
||||
timeout: Maximum time to wait in seconds
|
||||
"""
|
||||
if not self._initialized or self._queue is None:
|
||||
return
|
||||
import asyncio
|
||||
|
||||
pool = self._pool_getter()
|
||||
schema = self._schema_getter() if self._schema_getter else self._schema
|
||||
table = fq_table("async_operations", schema)
|
||||
|
||||
# Wait for queue to be empty AND no in-flight tasks
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
while asyncio.get_event_loop().time() - start_time < timeout:
|
||||
async with self._in_flight_lock:
|
||||
in_flight = self._in_flight_count
|
||||
# Check if there are any pending tasks with payloads
|
||||
count = await pool.fetchval(
|
||||
f"""
|
||||
SELECT COUNT(*) FROM {table}
|
||||
WHERE status = 'pending' AND task_payload IS NOT NULL
|
||||
"""
|
||||
)
|
||||
|
||||
if self._queue.empty() and in_flight == 0:
|
||||
# Queue is empty and no tasks in flight, we're done
|
||||
if count == 0:
|
||||
return
|
||||
|
||||
# Wait a bit before checking again
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
async def shutdown(self):
|
||||
"""Shutdown the worker and drain the queue."""
|
||||
if not self._initialized:
|
||||
return
|
||||
|
||||
logger.info("Shutting down AsyncIOQueueBackend...")
|
||||
|
||||
# Signal shutdown
|
||||
self._shutdown_event.set()
|
||||
|
||||
# Cancel worker
|
||||
if self._worker_task is not None:
|
||||
self._worker_task.cancel()
|
||||
try:
|
||||
await self._worker_task
|
||||
except asyncio.CancelledError:
|
||||
pass # Worker cancelled successfully
|
||||
|
||||
self._initialized = False
|
||||
logger.info("AsyncIOQueueBackend shutdown complete")
|
||||
|
||||
async def _execute_task_with_tracking(self, task_dict: dict[str, Any]):
|
||||
"""Execute a task and track its in-flight status."""
|
||||
async with self._in_flight_lock:
|
||||
self._in_flight_count += 1
|
||||
try:
|
||||
await self._execute_task(task_dict)
|
||||
finally:
|
||||
async with self._in_flight_lock:
|
||||
self._in_flight_count -= 1
|
||||
|
||||
async def _execute_task_no_tracking(self, task_dict: dict[str, Any]):
|
||||
"""Execute a task without in-flight tracking (tracking done at batch level)."""
|
||||
await self._execute_task(task_dict)
|
||||
|
||||
def _get_queue_stats(self) -> tuple[int, dict[str, int]]:
|
||||
"""Get current queue size and bank_id distribution."""
|
||||
queue_size = self._queue.qsize() if self._queue else 0
|
||||
bank_distribution: dict[str, int] = {}
|
||||
|
||||
if queue_size > 0 and self._queue:
|
||||
# Peek at queue items without removing them
|
||||
# Note: This is a snapshot and may not be perfectly accurate due to concurrency
|
||||
try:
|
||||
# Access internal deque for logging purposes only
|
||||
items = list(self._queue._queue) # type: ignore[attr-defined]
|
||||
for item in items:
|
||||
bank_id = item.get("bank_id", "unknown")
|
||||
bank_distribution[bank_id] = bank_distribution.get(bank_id, 0) + 1
|
||||
except Exception:
|
||||
pass # Queue access failed, return empty distribution
|
||||
|
||||
return queue_size, bank_distribution
|
||||
|
||||
async def _worker(self):
|
||||
"""
|
||||
Background worker that processes tasks in batches.
|
||||
|
||||
Collects tasks for up to batch_interval seconds or batch_size items,
|
||||
then processes them.
|
||||
"""
|
||||
while not self._shutdown_event.is_set():
|
||||
try:
|
||||
# Collect tasks for batching
|
||||
tasks = []
|
||||
deadline = asyncio.get_event_loop().time() + self._batch_interval
|
||||
|
||||
while len(tasks) < self._batch_size and asyncio.get_event_loop().time() < deadline:
|
||||
try:
|
||||
remaining_time = max(0.1, deadline - asyncio.get_event_loop().time())
|
||||
task_dict = await asyncio.wait_for(self._queue.get(), timeout=remaining_time)
|
||||
# Track task as in-flight immediately when picked up from queue
|
||||
# This prevents wait_for_pending_tasks from returning too early
|
||||
async with self._in_flight_lock:
|
||||
self._in_flight_count += 1
|
||||
tasks.append(task_dict)
|
||||
except TimeoutError:
|
||||
break
|
||||
|
||||
# Process batch
|
||||
if tasks:
|
||||
# Log batch start with queue stats
|
||||
queue_size, bank_distribution = self._get_queue_stats()
|
||||
|
||||
# Summarize batch by task type and bank
|
||||
batch_summary: dict[str, dict[str, int]] = {}
|
||||
for task_dict in tasks:
|
||||
task_type = task_dict.get("type", "unknown")
|
||||
bank_id = task_dict.get("bank_id", "unknown")
|
||||
if task_type not in batch_summary:
|
||||
batch_summary[task_type] = {}
|
||||
batch_summary[task_type][bank_id] = batch_summary[task_type].get(bank_id, 0) + 1
|
||||
|
||||
# Build log message
|
||||
batch_parts = []
|
||||
for task_type, banks in sorted(batch_summary.items()):
|
||||
bank_str = ", ".join(f"{b}:{c}" for b, c in sorted(banks.items()))
|
||||
batch_parts.append(f"{task_type}[{bank_str}]")
|
||||
batch_str = ", ".join(batch_parts)
|
||||
|
||||
if queue_size > 0:
|
||||
pending_str = ", ".join(f"{k}:{v}" for k, v in sorted(bank_distribution.items()))
|
||||
logger.info(
|
||||
f"Processing {len(tasks)} tasks: {batch_str} (pending={queue_size} [{pending_str}])"
|
||||
)
|
||||
else:
|
||||
logger.info(f"Processing {len(tasks)} tasks: {batch_str}")
|
||||
|
||||
# Execute tasks concurrently (in_flight already tracked when picked up)
|
||||
await asyncio.gather(
|
||||
*[self._execute_task_no_tracking(task_dict) for task_dict in tasks], return_exceptions=True
|
||||
)
|
||||
|
||||
# Decrement in_flight count after all tasks complete
|
||||
async with self._in_flight_lock:
|
||||
self._in_flight_count -= len(tasks)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Worker error: {e}")
|
||||
await asyncio.sleep(1) # Backoff on error
|
||||
logger.warning(f"Timeout waiting for pending tasks after {timeout}s")
|
||||
|
||||
@@ -65,154 +65,3 @@ async def extract_facts(
|
||||
return [], chunks
|
||||
|
||||
return facts, chunks
|
||||
|
||||
|
||||
def cosine_similarity(vec1: list[float], vec2: list[float]) -> float:
|
||||
"""
|
||||
Calculate cosine similarity between two vectors.
|
||||
|
||||
Args:
|
||||
vec1: First vector
|
||||
vec2: Second vector
|
||||
|
||||
Returns:
|
||||
Similarity score between 0 and 1
|
||||
"""
|
||||
if len(vec1) != len(vec2):
|
||||
raise ValueError("Vectors must have same dimension")
|
||||
|
||||
dot_product = sum(a * b for a, b in zip(vec1, vec2))
|
||||
magnitude1 = sum(a * a for a in vec1) ** 0.5
|
||||
magnitude2 = sum(b * b for b in vec2) ** 0.5
|
||||
|
||||
if magnitude1 == 0 or magnitude2 == 0:
|
||||
return 0.0
|
||||
|
||||
return dot_product / (magnitude1 * magnitude2)
|
||||
|
||||
|
||||
def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -> float:
|
||||
"""
|
||||
Calculate recency weight using logarithmic decay.
|
||||
|
||||
This provides much better differentiation over long time periods compared to
|
||||
exponential decay. Uses a log-based decay where the half-life parameter controls
|
||||
when memories reach 50% weight.
|
||||
|
||||
Examples:
|
||||
- Today (0 days): 1.0
|
||||
- 1 year (365 days): ~0.5 (with default half_life=365)
|
||||
- 2 years (730 days): ~0.33
|
||||
- 5 years (1825 days): ~0.17
|
||||
- 10 years (3650 days): ~0.09
|
||||
|
||||
This ensures that 2-year-old and 5-year-old memories have meaningfully
|
||||
different weights, unlike exponential decay which makes them both ~0.
|
||||
|
||||
Args:
|
||||
days_since: Number of days since the memory was created
|
||||
half_life_days: Number of days for weight to reach 0.5 (default: 1 year)
|
||||
|
||||
Returns:
|
||||
Weight between 0 and 1
|
||||
"""
|
||||
import math
|
||||
|
||||
# Logarithmic decay: 1 / (1 + log(1 + days_since/half_life))
|
||||
# This decays much slower than exponential, giving better long-term differentiation
|
||||
normalized_age = days_since / half_life_days
|
||||
return 1.0 / (1.0 + math.log1p(normalized_age))
|
||||
|
||||
|
||||
def calculate_frequency_weight(access_count: int, max_boost: float = 2.0) -> float:
|
||||
"""
|
||||
Calculate frequency weight based on access count.
|
||||
|
||||
Frequently accessed memories are weighted higher.
|
||||
Uses logarithmic scaling to avoid over-weighting.
|
||||
|
||||
Args:
|
||||
access_count: Number of times the memory was accessed
|
||||
max_boost: Maximum multiplier for frequently accessed memories
|
||||
|
||||
Returns:
|
||||
Weight between 1.0 and max_boost
|
||||
"""
|
||||
import math
|
||||
|
||||
if access_count <= 0:
|
||||
return 1.0
|
||||
|
||||
# Logarithmic scaling: log(access_count + 1) / log(10)
|
||||
# This gives: 0 accesses = 1.0, 9 accesses ~= 1.5, 99 accesses ~= 2.0
|
||||
normalized = math.log(access_count + 1) / math.log(10)
|
||||
return 1.0 + min(normalized, max_boost - 1.0)
|
||||
|
||||
|
||||
def calculate_temporal_anchor(occurred_start: datetime, occurred_end: datetime) -> datetime:
|
||||
"""
|
||||
Calculate a single temporal anchor point from a temporal range.
|
||||
|
||||
Used for spreading activation - we need a single representative date
|
||||
to calculate temporal proximity between facts. This simplifies the
|
||||
range-to-range distance problem.
|
||||
|
||||
Strategy: Use midpoint of the range for balanced representation.
|
||||
|
||||
Args:
|
||||
occurred_start: Start of temporal range
|
||||
occurred_end: End of temporal range
|
||||
|
||||
Returns:
|
||||
Single datetime representing the temporal anchor (midpoint)
|
||||
|
||||
Examples:
|
||||
- Point event (July 14): start=July 14, end=July 14 → anchor=July 14
|
||||
- Month range (February): start=Feb 1, end=Feb 28 → anchor=Feb 14
|
||||
- Year range (2023): start=Jan 1, end=Dec 31 → anchor=July 1
|
||||
"""
|
||||
# Calculate midpoint
|
||||
time_delta = occurred_end - occurred_start
|
||||
midpoint = occurred_start + (time_delta / 2)
|
||||
return midpoint
|
||||
|
||||
|
||||
def calculate_temporal_proximity(anchor_a: datetime, anchor_b: datetime, half_life_days: float = 30.0) -> float:
|
||||
"""
|
||||
Calculate temporal proximity between two temporal anchors.
|
||||
|
||||
Used for spreading activation to determine how "close" two facts are
|
||||
in time. Uses logarithmic decay so that temporal similarity doesn't
|
||||
drop off too quickly.
|
||||
|
||||
Args:
|
||||
anchor_a: Temporal anchor of first fact
|
||||
anchor_b: Temporal anchor of second fact
|
||||
half_life_days: Number of days for proximity to reach 0.5
|
||||
(default: 30 days = 1 month)
|
||||
|
||||
Returns:
|
||||
Proximity score in [0, 1] where:
|
||||
- 1.0 = same day
|
||||
- 0.5 = ~half_life days apart
|
||||
- 0.0 = very distant in time
|
||||
|
||||
Examples:
|
||||
- Same day: 1.0
|
||||
- 1 week apart (half_life=30): ~0.7
|
||||
- 1 month apart (half_life=30): ~0.5
|
||||
- 1 year apart (half_life=30): ~0.2
|
||||
"""
|
||||
import math
|
||||
|
||||
days_apart = abs((anchor_a - anchor_b).days)
|
||||
|
||||
if days_apart == 0:
|
||||
return 1.0
|
||||
|
||||
# Logarithmic decay: 1 / (1 + log(1 + days_apart/half_life))
|
||||
# Similar to calculate_recency_weight but for proximity between events
|
||||
normalized_distance = days_apart / half_life_days
|
||||
proximity = 1.0 / (1.0 + math.log1p(normalized_distance))
|
||||
|
||||
return proximity
|
||||
|
||||
@@ -21,20 +21,23 @@ from hindsight_api.extensions.context import DefaultExtensionContext, ExtensionC
|
||||
from hindsight_api.extensions.http import HttpExtension
|
||||
from hindsight_api.extensions.loader import load_extension
|
||||
from hindsight_api.extensions.operation_validator import (
|
||||
# Consolidation operation
|
||||
ConsolidateContext,
|
||||
ConsolidateResult,
|
||||
# Core operations
|
||||
OperationValidationError,
|
||||
OperationValidatorExtension,
|
||||
RecallContext,
|
||||
RecallResult,
|
||||
ReflectContext,
|
||||
ReflectResultContext,
|
||||
RefreshMentalModelContext,
|
||||
RefreshMentalModelResult,
|
||||
RetainContext,
|
||||
RetainResult,
|
||||
ValidationResult,
|
||||
)
|
||||
from hindsight_api.extensions.tenant import (
|
||||
AuthenticationError,
|
||||
Tenant,
|
||||
TenantContext,
|
||||
TenantExtension,
|
||||
)
|
||||
@@ -49,22 +52,24 @@ __all__ = [
|
||||
"DefaultExtensionContext",
|
||||
# HTTP Extension
|
||||
"HttpExtension",
|
||||
# Operation Validator
|
||||
# Operation Validator - Core
|
||||
"OperationValidationError",
|
||||
"OperationValidatorExtension",
|
||||
"RecallContext",
|
||||
"RecallResult",
|
||||
"ReflectContext",
|
||||
"ReflectResultContext",
|
||||
"RefreshMentalModelContext",
|
||||
"RefreshMentalModelResult",
|
||||
"RetainContext",
|
||||
"RetainResult",
|
||||
"ValidationResult",
|
||||
# Operation Validator - Consolidation
|
||||
"ConsolidateContext",
|
||||
"ConsolidateResult",
|
||||
# Tenant/Auth
|
||||
"ApiKeyTenantExtension",
|
||||
"AuthenticationError",
|
||||
"RequestContext",
|
||||
"Tenant",
|
||||
"TenantContext",
|
||||
"TenantExtension",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Built-in tenant extension implementations."""
|
||||
|
||||
from hindsight_api.extensions.tenant import AuthenticationError, TenantContext, TenantExtension
|
||||
from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
|
||||
@@ -31,3 +31,7 @@ class ApiKeyTenantExtension(TenantExtension):
|
||||
if context.api_key != self.expected_api_key:
|
||||
raise AuthenticationError("Invalid API key")
|
||||
return TenantContext(schema_name="public")
|
||||
|
||||
async def list_tenants(self) -> list[Tenant]:
|
||||
"""Return public schema for single-tenant setup."""
|
||||
return [Tenant(schema="public")]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Operation Validator Extension for validating retain/recall/reflect operations."""
|
||||
"""Operation Validator Extension for validating retain/recall/reflect/consolidate operations."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
@@ -97,15 +97,16 @@ class ReflectContext:
|
||||
context: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RefreshMentalModelContext:
|
||||
"""Context for a refresh mental model operation validation (pre-operation).
|
||||
# =============================================================================
|
||||
# Consolidation Pre-operation Context
|
||||
# =============================================================================
|
||||
|
||||
Contains ALL user-provided parameters for the refresh mental model operation.
|
||||
"""
|
||||
|
||||
@dataclass
|
||||
class ConsolidateContext:
|
||||
"""Context for a consolidation operation validation (pre-operation)."""
|
||||
|
||||
bank_id: str
|
||||
model_id: str
|
||||
request_context: "RequestContext"
|
||||
|
||||
|
||||
@@ -176,30 +177,28 @@ class ReflectResultContext:
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RefreshMentalModelResult:
|
||||
"""Result context for post-refresh-mental-model hook.
|
||||
# =============================================================================
|
||||
# Consolidation Post-operation Context
|
||||
# =============================================================================
|
||||
|
||||
Contains the operation parameters and the result including token usage.
|
||||
"""
|
||||
|
||||
@dataclass
|
||||
class ConsolidateResult:
|
||||
"""Result context for post-consolidation hook."""
|
||||
|
||||
bank_id: str
|
||||
model_id: str
|
||||
request_context: "RequestContext"
|
||||
# Result
|
||||
model_name: str | None = None
|
||||
observations_count: int = 0
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
duration_ms: int = 0
|
||||
processed: int = 0
|
||||
created: int = 0
|
||||
updated: int = 0
|
||||
success: bool = True
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class OperationValidatorExtension(Extension, ABC):
|
||||
"""
|
||||
Validates and hooks into retain/recall/reflect operations.
|
||||
Validates and hooks into retain/recall/reflect/consolidate operations.
|
||||
|
||||
This extension allows implementing custom logic such as:
|
||||
- Rate limiting (pre-operation)
|
||||
@@ -218,9 +217,13 @@ class OperationValidatorExtension(Extension, ABC):
|
||||
-> config = {"max_requests": "100"}
|
||||
|
||||
Hook execution order:
|
||||
1. validate_retain/validate_recall/validate_reflect (pre-operation)
|
||||
1. validate_* (pre-operation)
|
||||
2. [operation executes]
|
||||
3. on_retain_complete/on_recall_complete/on_reflect_complete (post-operation)
|
||||
3. on_*_complete (post-operation)
|
||||
|
||||
Supported operations:
|
||||
- retain, recall, reflect (core memory operations)
|
||||
- consolidate (mental models consolidation)
|
||||
"""
|
||||
|
||||
# =========================================================================
|
||||
@@ -298,25 +301,6 @@ class OperationValidatorExtension(Extension, ABC):
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def validate_refresh_mental_model(self, ctx: RefreshMentalModelContext) -> ValidationResult:
|
||||
"""
|
||||
Validate a refresh mental model operation before execution.
|
||||
|
||||
Called before the refresh mental model operation is processed.
|
||||
Return ValidationResult.reject() to prevent the operation from executing.
|
||||
|
||||
Args:
|
||||
ctx: Context containing all user-provided parameters:
|
||||
- bank_id: Bank identifier
|
||||
- model_id: Mental model ID to refresh
|
||||
- request_context: Request context with auth info
|
||||
|
||||
Returns:
|
||||
ValidationResult indicating whether the operation is allowed.
|
||||
"""
|
||||
...
|
||||
|
||||
# =========================================================================
|
||||
# Post-operation hooks (optional - override to implement)
|
||||
# =========================================================================
|
||||
@@ -378,26 +362,42 @@ class OperationValidatorExtension(Extension, ABC):
|
||||
"""
|
||||
pass
|
||||
|
||||
async def on_refresh_mental_model_complete(self, result: RefreshMentalModelResult) -> None:
|
||||
"""
|
||||
Called after a refresh mental model operation completes (success or failure).
|
||||
# =========================================================================
|
||||
# Consolidation - Pre-operation validation hook (optional - override to implement)
|
||||
# =========================================================================
|
||||
|
||||
Override this method to implement post-operation logic such as:
|
||||
- Token usage tracking and billing
|
||||
- Audit logging
|
||||
- Metrics collection
|
||||
async def validate_consolidate(self, ctx: ConsolidateContext) -> ValidationResult:
|
||||
"""
|
||||
Validate a consolidation operation before execution.
|
||||
|
||||
Override to implement custom validation logic for consolidation.
|
||||
|
||||
Args:
|
||||
ctx: Context containing:
|
||||
- bank_id: Bank identifier
|
||||
- request_context: Request context with auth info
|
||||
|
||||
Returns:
|
||||
ValidationResult indicating whether the operation is allowed.
|
||||
"""
|
||||
return ValidationResult.accept()
|
||||
|
||||
# =========================================================================
|
||||
# Consolidation - Post-operation hook (optional - override to implement)
|
||||
# =========================================================================
|
||||
|
||||
async def on_consolidate_complete(self, result: ConsolidateResult) -> None:
|
||||
"""
|
||||
Called after a consolidation operation completes (success or failure).
|
||||
|
||||
Override to implement post-operation logic such as usage tracking or audit logging.
|
||||
|
||||
Args:
|
||||
result: Result context containing:
|
||||
- bank_id: Bank identifier
|
||||
- model_id: Mental model ID
|
||||
- request_context: Request context with auth info
|
||||
- model_name: Name of the mental model (if success)
|
||||
- observations_count: Number of observations generated
|
||||
- input_tokens: Number of input tokens used
|
||||
- output_tokens: Number of output tokens used
|
||||
- total_tokens: Total tokens used (input + output)
|
||||
- duration_ms: Total operation duration in milliseconds
|
||||
- processed: Number of memories processed
|
||||
- created: Number of mental models created
|
||||
- updated: Number of mental models updated
|
||||
- success: Whether the operation succeeded
|
||||
- error: Error message (if failed)
|
||||
"""
|
||||
|
||||
@@ -28,6 +28,18 @@ class TenantContext:
|
||||
schema_name: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class Tenant:
|
||||
"""
|
||||
Represents a tenant for worker discovery.
|
||||
|
||||
Used by list_tenants() to return tenant information including
|
||||
the PostgreSQL schema name for database operations.
|
||||
"""
|
||||
|
||||
schema: str
|
||||
|
||||
|
||||
class TenantExtension(Extension, ABC):
|
||||
"""
|
||||
Extension for multi-tenancy and API key authentication.
|
||||
@@ -61,3 +73,17 @@ class TenantExtension(Extension, ABC):
|
||||
AuthenticationError: If authentication fails.
|
||||
"""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def list_tenants(self) -> list[Tenant]:
|
||||
"""
|
||||
List all tenants that should be processed by workers.
|
||||
|
||||
This method is used by the worker to discover all tenants that need
|
||||
task polling. Workers will poll for pending tasks in each tenant's schema.
|
||||
|
||||
Returns:
|
||||
List of Tenant objects containing schema information.
|
||||
For single-tenant setups, return [Tenant(schema="public")].
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -184,6 +184,10 @@ def main():
|
||||
reflect_llm_api_key=config.reflect_llm_api_key,
|
||||
reflect_llm_model=config.reflect_llm_model,
|
||||
reflect_llm_base_url=config.reflect_llm_base_url,
|
||||
consolidation_llm_provider=config.consolidation_llm_provider,
|
||||
consolidation_llm_api_key=config.consolidation_llm_api_key,
|
||||
consolidation_llm_model=config.consolidation_llm_model,
|
||||
consolidation_llm_base_url=config.consolidation_llm_base_url,
|
||||
embeddings_provider=config.embeddings_provider,
|
||||
embeddings_local_model=config.embeddings_local_model,
|
||||
embeddings_tei_url=config.embeddings_tei_url,
|
||||
@@ -205,13 +209,13 @@ def main():
|
||||
mpfp_top_k_neighbors=config.mpfp_top_k_neighbors,
|
||||
recall_max_concurrent=config.recall_max_concurrent,
|
||||
recall_connection_budget=config.recall_connection_budget,
|
||||
observation_min_facts=config.observation_min_facts,
|
||||
observation_top_entities=config.observation_top_entities,
|
||||
retain_max_completion_tokens=config.retain_max_completion_tokens,
|
||||
retain_chunk_size=config.retain_chunk_size,
|
||||
retain_extract_causal_links=config.retain_extract_causal_links,
|
||||
retain_extraction_mode=config.retain_extraction_mode,
|
||||
retain_observations_async=config.retain_observations_async,
|
||||
enable_observations=config.enable_observations,
|
||||
consolidation_batch_size=config.consolidation_batch_size,
|
||||
skip_llm_verification=config.skip_llm_verification,
|
||||
lazy_reranker=config.lazy_reranker,
|
||||
run_migrations_on_startup=config.run_migrations_on_startup,
|
||||
@@ -219,9 +223,12 @@ def main():
|
||||
db_pool_max_size=config.db_pool_max_size,
|
||||
db_command_timeout=config.db_command_timeout,
|
||||
db_acquire_timeout=config.db_acquire_timeout,
|
||||
task_backend=config.task_backend,
|
||||
task_backend_memory_batch_size=config.task_backend_memory_batch_size,
|
||||
task_backend_memory_batch_interval=config.task_backend_memory_batch_interval,
|
||||
worker_enabled=config.worker_enabled,
|
||||
worker_id=config.worker_id,
|
||||
worker_poll_interval_ms=config.worker_poll_interval_ms,
|
||||
worker_max_retries=config.worker_max_retries,
|
||||
worker_batch_size=config.worker_batch_size,
|
||||
worker_http_port=config.worker_http_port,
|
||||
reflect_max_iterations=config.reflect_max_iterations,
|
||||
mental_model_refresh_concurrency=config.mental_model_refresh_concurrency,
|
||||
)
|
||||
|
||||
@@ -44,7 +44,6 @@ import os
|
||||
import sys
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.types import Icon
|
||||
|
||||
from hindsight_api.config import (
|
||||
DEFAULT_MCP_LOCAL_BANK_ID,
|
||||
@@ -53,6 +52,7 @@ from hindsight_api.config import (
|
||||
ENV_MCP_INSTRUCTIONS,
|
||||
ENV_MCP_LOCAL_BANK_ID,
|
||||
)
|
||||
from hindsight_api.mcp_tools import MCPToolsConfig, register_mcp_tools
|
||||
|
||||
# Configure logging - default to warning to avoid polluting stderr during MCP init
|
||||
# MCP clients interpret stderr output as errors, so we suppress INFO logs by default
|
||||
@@ -85,9 +85,6 @@ def create_local_mcp_server(bank_id: str, memory=None) -> FastMCP:
|
||||
"""
|
||||
# Import here to avoid slow startup if just checking --help
|
||||
from hindsight_api import MemoryEngine
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
# Create memory engine with pg0 embedded database if not provided
|
||||
if memory is None:
|
||||
@@ -105,55 +102,17 @@ def create_local_mcp_server(bank_id: str, memory=None) -> FastMCP:
|
||||
|
||||
mcp = FastMCP("hindsight")
|
||||
|
||||
@mcp.tool(description=retain_description)
|
||||
async def retain(content: str, context: str = "general") -> dict:
|
||||
"""
|
||||
Args:
|
||||
content: The fact/memory to store (be specific and include relevant details)
|
||||
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
|
||||
"""
|
||||
import asyncio
|
||||
# Configure and register tools using shared module
|
||||
config = MCPToolsConfig(
|
||||
bank_id_resolver=lambda: bank_id,
|
||||
include_bank_id_param=False, # Local MCP uses fixed bank_id
|
||||
tools={"retain", "recall"}, # Local MCP only has retain and recall
|
||||
retain_description=retain_description,
|
||||
recall_description=recall_description,
|
||||
retain_fire_and_forget=True, # Local MCP uses fire-and-forget pattern
|
||||
)
|
||||
|
||||
async def _retain():
|
||||
try:
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[{"content": content, "context": context}],
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing memory: {e}", exc_info=True)
|
||||
|
||||
# Fire and forget - don't block on memory storage
|
||||
asyncio.create_task(_retain())
|
||||
return {"status": "accepted", "message": "Memory storage initiated"}
|
||||
|
||||
@mcp.tool(description=recall_description)
|
||||
async def recall(query: str, max_tokens: int = 4096, budget: str = "low") -> dict:
|
||||
"""
|
||||
Args:
|
||||
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
|
||||
max_tokens: Maximum tokens to return in results (default: 4096)
|
||||
budget: Search budget level - "low", "mid", or "high" (default: "low")
|
||||
"""
|
||||
try:
|
||||
# Map string budget to enum
|
||||
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
|
||||
budget_enum = budget_map.get(budget.lower(), Budget.LOW)
|
||||
|
||||
search_result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
fact_type=list(VALID_RECALL_FACT_TYPES),
|
||||
budget=budget_enum,
|
||||
max_tokens=max_tokens,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
|
||||
return search_result.model_dump()
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching: {e}", exc_info=True)
|
||||
return {"error": str(e), "results": []}
|
||||
register_mcp_tools(mcp, memory, config)
|
||||
|
||||
return mcp
|
||||
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
"""Shared MCP tool implementations for Hindsight.
|
||||
|
||||
This module provides the core tool logic used by both:
|
||||
- mcp_local.py (stdio transport for Claude Code)
|
||||
- api/mcp.py (HTTP transport for API server)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Callable
|
||||
|
||||
from fastmcp import FastMCP
|
||||
|
||||
from hindsight_api import MemoryEngine
|
||||
from hindsight_api.config import (
|
||||
DEFAULT_MCP_RECALL_DESCRIPTION,
|
||||
DEFAULT_MCP_RETAIN_DESCRIPTION,
|
||||
)
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MCPToolsConfig:
|
||||
"""Configuration for MCP tools registration."""
|
||||
|
||||
# How to resolve bank_id for operations
|
||||
bank_id_resolver: Callable[[], str | None]
|
||||
|
||||
# Whether to include bank_id as a parameter on tools (for multi-bank support)
|
||||
include_bank_id_param: bool = False
|
||||
|
||||
# Which tools to register
|
||||
tools: set[str] | None = None # None means all tools
|
||||
|
||||
# Custom descriptions (if None, uses defaults)
|
||||
retain_description: str | None = None
|
||||
recall_description: str | None = None
|
||||
|
||||
# Retain behavior
|
||||
retain_fire_and_forget: bool = False # If True, use asyncio.create_task pattern
|
||||
|
||||
|
||||
def parse_timestamp(timestamp: str) -> datetime | None:
|
||||
"""Parse an ISO format timestamp string.
|
||||
|
||||
Args:
|
||||
timestamp: ISO format timestamp (e.g., '2024-01-15T10:30:00Z')
|
||||
|
||||
Returns:
|
||||
Parsed datetime or None if invalid
|
||||
|
||||
Raises:
|
||||
ValueError: If timestamp format is invalid
|
||||
"""
|
||||
try:
|
||||
return datetime.fromisoformat(timestamp.replace("Z", "+00:00"))
|
||||
except ValueError as e:
|
||||
raise ValueError(
|
||||
f"Invalid timestamp format '{timestamp}'. "
|
||||
"Expected ISO format like '2024-01-15T10:30:00' or '2024-01-15T10:30:00Z'"
|
||||
) from e
|
||||
|
||||
|
||||
def build_content_dict(
|
||||
content: str,
|
||||
context: str,
|
||||
timestamp: str | None = None,
|
||||
) -> tuple[dict[str, Any], str | None]:
|
||||
"""Build a content dict for retain operations.
|
||||
|
||||
Args:
|
||||
content: The memory content
|
||||
context: Category for the memory
|
||||
timestamp: Optional ISO timestamp
|
||||
|
||||
Returns:
|
||||
Tuple of (content_dict, error_message). error_message is None if successful.
|
||||
"""
|
||||
content_dict: dict[str, Any] = {"content": content, "context": context}
|
||||
|
||||
if timestamp:
|
||||
try:
|
||||
parsed_timestamp = parse_timestamp(timestamp)
|
||||
content_dict["event_date"] = parsed_timestamp
|
||||
except ValueError as e:
|
||||
return {}, str(e)
|
||||
|
||||
return content_dict, None
|
||||
|
||||
|
||||
def register_mcp_tools(
|
||||
mcp: FastMCP,
|
||||
memory: MemoryEngine,
|
||||
config: MCPToolsConfig,
|
||||
) -> None:
|
||||
"""Register MCP tools on a FastMCP server.
|
||||
|
||||
Args:
|
||||
mcp: FastMCP server instance
|
||||
memory: MemoryEngine instance
|
||||
config: Tool configuration
|
||||
"""
|
||||
tools_to_register = config.tools or {"retain", "recall", "reflect", "list_banks", "create_bank"}
|
||||
|
||||
if "retain" in tools_to_register:
|
||||
_register_retain(mcp, memory, config)
|
||||
|
||||
if "recall" in tools_to_register:
|
||||
_register_recall(mcp, memory, config)
|
||||
|
||||
if "reflect" in tools_to_register:
|
||||
_register_reflect(mcp, memory, config)
|
||||
|
||||
if "list_banks" in tools_to_register:
|
||||
_register_list_banks(mcp, memory, config)
|
||||
|
||||
if "create_bank" in tools_to_register:
|
||||
_register_create_bank(mcp, memory, config)
|
||||
|
||||
|
||||
def _register_retain(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the retain tool."""
|
||||
description = config.retain_description or DEFAULT_MCP_RETAIN_DESCRIPTION
|
||||
|
||||
if config.include_bank_id_param:
|
||||
if config.retain_fire_and_forget:
|
||||
|
||||
@mcp.tool(description=description)
|
||||
async def retain(
|
||||
content: str,
|
||||
context: str = "general",
|
||||
timestamp: str | None = None,
|
||||
bank_id: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Args:
|
||||
content: The fact/memory to store (be specific and include relevant details)
|
||||
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
|
||||
timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking.
|
||||
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"status": "error", "message": "No bank_id configured"}
|
||||
|
||||
content_dict, error = build_content_dict(content, context, timestamp)
|
||||
if error:
|
||||
return {"status": "error", "message": error}
|
||||
|
||||
async def _retain():
|
||||
try:
|
||||
await memory.retain_batch_async(
|
||||
bank_id=target_bank,
|
||||
contents=[content_dict],
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing memory: {e}", exc_info=True)
|
||||
|
||||
asyncio.create_task(_retain())
|
||||
return {"status": "accepted", "message": "Memory storage initiated"}
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool(description=description)
|
||||
async def retain(
|
||||
content: str,
|
||||
context: str = "general",
|
||||
timestamp: str | None = None,
|
||||
async_processing: bool = True,
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Args:
|
||||
content: The fact/memory to store (be specific and include relevant details)
|
||||
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
|
||||
timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking.
|
||||
async_processing: If True, queue for background processing and return immediately. If False, wait for completion. Default: True
|
||||
bank_id: Optional bank to store in (defaults to session bank). Use for cross-bank operations.
|
||||
"""
|
||||
try:
|
||||
target_bank = bank_id or config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return "Error: No bank_id configured"
|
||||
|
||||
content_dict, error = build_content_dict(content, context, timestamp)
|
||||
if error:
|
||||
return f"Error: {error}"
|
||||
|
||||
contents = [content_dict]
|
||||
if async_processing:
|
||||
result = await memory.submit_async_retain(
|
||||
bank_id=target_bank, contents=contents, request_context=RequestContext()
|
||||
)
|
||||
return f"Memory queued for background processing (operation_id: {result.get('operation_id', 'N/A')})"
|
||||
else:
|
||||
await memory.retain_batch_async(
|
||||
bank_id=target_bank,
|
||||
contents=contents,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
return f"Memory stored successfully in bank '{target_bank}'"
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing memory: {e}", exc_info=True)
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
else:
|
||||
# No bank_id param - use fixed bank from resolver
|
||||
|
||||
@mcp.tool(description=description)
|
||||
async def retain(
|
||||
content: str,
|
||||
context: str = "general",
|
||||
timestamp: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Args:
|
||||
content: The fact/memory to store (be specific and include relevant details)
|
||||
context: Category for the memory (e.g., 'preferences', 'work', 'hobbies', 'family'). Default: 'general'
|
||||
timestamp: When this event/fact occurred (ISO format, e.g., '2024-01-15T10:30:00Z'). Useful for timeline tracking.
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"status": "error", "message": "No bank_id configured"}
|
||||
|
||||
content_dict, error = build_content_dict(content, context, timestamp)
|
||||
if error:
|
||||
return {"status": "error", "message": error}
|
||||
|
||||
async def _retain():
|
||||
try:
|
||||
await memory.retain_batch_async(
|
||||
bank_id=target_bank,
|
||||
contents=[content_dict],
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error storing memory: {e}", exc_info=True)
|
||||
|
||||
asyncio.create_task(_retain())
|
||||
return {"status": "accepted", "message": "Memory storage initiated"}
|
||||
|
||||
|
||||
def _register_recall(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the recall tool."""
|
||||
description = config.recall_description or DEFAULT_MCP_RECALL_DESCRIPTION
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool(description=description)
|
||||
async def recall(
|
||||
query: str,
|
||||
max_tokens: int = 4096,
|
||||
bank_id: str | None = None,
|
||||
) -> str | dict:
|
||||
"""
|
||||
Args:
|
||||
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
|
||||
max_tokens: Maximum tokens to return in results (default: 4096)
|
||||
bank_id: Optional bank to search in (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"
|
||||
|
||||
recall_result = await memory.recall_async(
|
||||
bank_id=target_bank,
|
||||
query=query,
|
||||
fact_type=list(VALID_RECALL_FACT_TYPES),
|
||||
budget=Budget.HIGH,
|
||||
max_tokens=max_tokens,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
|
||||
return recall_result.model_dump_json(indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}", "results": []}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool(description=description)
|
||||
async def recall(
|
||||
query: str,
|
||||
max_tokens: int = 4096,
|
||||
) -> dict:
|
||||
"""
|
||||
Args:
|
||||
query: Natural language search query (e.g., "user's food preferences", "what projects is user working on")
|
||||
max_tokens: Maximum tokens to return in results (default: 4096)
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured", "results": []}
|
||||
|
||||
recall_result = await memory.recall_async(
|
||||
bank_id=target_bank,
|
||||
query=query,
|
||||
fact_type=list(VALID_RECALL_FACT_TYPES),
|
||||
budget=Budget.HIGH,
|
||||
max_tokens=max_tokens,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
|
||||
return recall_result.model_dump()
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching: {e}", exc_info=True)
|
||||
return {"error": str(e), "results": []}
|
||||
|
||||
|
||||
def _register_reflect(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the reflect tool."""
|
||||
|
||||
if config.include_bank_id_param:
|
||||
|
||||
@mcp.tool()
|
||||
async def reflect(
|
||||
query: str,
|
||||
context: str | None = None,
|
||||
budget: str = "low",
|
||||
bank_id: str | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
|
||||
|
||||
WHEN TO USE THIS TOOL:
|
||||
Use reflect when you need reasoned analysis, not just fact retrieval. This tool
|
||||
thinks through the question using everything the bank knows and its personality traits.
|
||||
|
||||
EXAMPLES OF GOOD QUERIES:
|
||||
- "What patterns have emerged in how I approach debugging?"
|
||||
- "Based on my past decisions, what architectural style do I prefer?"
|
||||
- "What might be the best approach for this problem given what you know about me?"
|
||||
- "How should I prioritize these tasks based on my goals?"
|
||||
|
||||
HOW IT DIFFERS FROM RECALL:
|
||||
- recall: Returns raw facts matching your search (fast lookup)
|
||||
- reflect: Reasons across memories to form a synthesized answer (deeper analysis)
|
||||
|
||||
Use recall for "what did I say about X?" and reflect for "what should I do about X?"
|
||||
|
||||
Args:
|
||||
query: The question or topic to reflect on
|
||||
context: Optional context about why this reflection is needed
|
||||
budget: Search budget - 'low', 'mid', or 'high' (default: 'low')
|
||||
bank_id: Optional bank to reflect in (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"
|
||||
|
||||
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
|
||||
budget_enum = budget_map.get(budget.lower(), Budget.LOW)
|
||||
|
||||
reflect_result = await memory.reflect_async(
|
||||
bank_id=target_bank,
|
||||
query=query,
|
||||
budget=budget_enum,
|
||||
context=context,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
|
||||
return reflect_result.model_dump_json(indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Error reflecting: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}", "text": ""}}'
|
||||
|
||||
else:
|
||||
|
||||
@mcp.tool()
|
||||
async def reflect(
|
||||
query: str,
|
||||
context: str | None = None,
|
||||
budget: str = "low",
|
||||
) -> dict:
|
||||
"""
|
||||
Generate thoughtful analysis by synthesizing stored memories with the bank's personality.
|
||||
|
||||
WHEN TO USE THIS TOOL:
|
||||
Use reflect when you need reasoned analysis, not just fact retrieval. This tool
|
||||
thinks through the question using everything the bank knows and its personality traits.
|
||||
|
||||
EXAMPLES OF GOOD QUERIES:
|
||||
- "What patterns have emerged in how I approach debugging?"
|
||||
- "Based on my past decisions, what architectural style do I prefer?"
|
||||
- "What might be the best approach for this problem given what you know about me?"
|
||||
- "How should I prioritize these tasks based on my goals?"
|
||||
|
||||
HOW IT DIFFERS FROM RECALL:
|
||||
- recall: Returns raw facts matching your search (fast lookup)
|
||||
- reflect: Reasons across memories to form a synthesized answer (deeper analysis)
|
||||
|
||||
Use recall for "what did I say about X?" and reflect for "what should I do about X?"
|
||||
|
||||
Args:
|
||||
query: The question or topic to reflect on
|
||||
context: Optional context about why this reflection is needed
|
||||
budget: Search budget - 'low', 'mid', or 'high' (default: 'low')
|
||||
"""
|
||||
try:
|
||||
target_bank = config.bank_id_resolver()
|
||||
if target_bank is None:
|
||||
return {"error": "No bank_id configured", "text": ""}
|
||||
|
||||
budget_map = {"low": Budget.LOW, "mid": Budget.MID, "high": Budget.HIGH}
|
||||
budget_enum = budget_map.get(budget.lower(), Budget.LOW)
|
||||
|
||||
reflect_result = await memory.reflect_async(
|
||||
bank_id=target_bank,
|
||||
query=query,
|
||||
budget=budget_enum,
|
||||
context=context,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
|
||||
return reflect_result.model_dump()
|
||||
except Exception as e:
|
||||
logger.error(f"Error reflecting: {e}", exc_info=True)
|
||||
return {"error": str(e), "text": ""}
|
||||
|
||||
|
||||
def _register_list_banks(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the list_banks tool."""
|
||||
|
||||
@mcp.tool()
|
||||
async def list_banks() -> str:
|
||||
"""
|
||||
List all available memory banks.
|
||||
|
||||
Use this tool to discover what memory banks exist in the system.
|
||||
Each bank is an isolated memory store (like a separate "brain").
|
||||
|
||||
Returns:
|
||||
JSON list of banks with their IDs, names, dispositions, and missions.
|
||||
"""
|
||||
try:
|
||||
banks = await memory.list_banks(request_context=RequestContext())
|
||||
return json.dumps({"banks": banks}, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing banks: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}", "banks": []}}'
|
||||
|
||||
|
||||
def _register_create_bank(mcp: FastMCP, memory: MemoryEngine, config: MCPToolsConfig) -> None:
|
||||
"""Register the create_bank tool."""
|
||||
|
||||
@mcp.tool()
|
||||
async def create_bank(bank_id: str, name: str | None = None, mission: str | None = None) -> str:
|
||||
"""
|
||||
Create a new memory bank or get an existing one.
|
||||
|
||||
Memory banks are isolated stores - each one is like a separate "brain" for a user/agent.
|
||||
Banks are auto-created with default settings if they don't exist.
|
||||
|
||||
Args:
|
||||
bank_id: Unique identifier for the bank (e.g., 'user-123', 'agent-alpha')
|
||||
name: Optional human-friendly name for the bank
|
||||
mission: Optional mission describing who the agent is and what they're trying to accomplish
|
||||
"""
|
||||
try:
|
||||
# get_bank_profile auto-creates bank if it doesn't exist
|
||||
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
|
||||
|
||||
# Update name/mission if provided
|
||||
if name is not None or mission is not None:
|
||||
await memory.update_bank(
|
||||
bank_id,
|
||||
name=name,
|
||||
mission=mission,
|
||||
request_context=RequestContext(),
|
||||
)
|
||||
# Fetch updated profile
|
||||
profile = await memory.get_bank_profile(bank_id, request_context=RequestContext())
|
||||
|
||||
# Serialize disposition if it's a Pydantic model
|
||||
if "disposition" in profile and hasattr(profile["disposition"], "model_dump"):
|
||||
profile["disposition"] = profile["disposition"].model_dump()
|
||||
return json.dumps(profile, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"Error creating bank: {e}", exc_info=True)
|
||||
return f'{{"error": "{e}"}}'
|
||||
@@ -95,7 +95,6 @@ class MemoryUnit(Base):
|
||||
mentioned_at: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) # When fact was mentioned
|
||||
fact_type: Mapped[str] = mapped_column(Text, nullable=False, server_default="world")
|
||||
confidence_score: Mapped[float | None] = mapped_column(Float)
|
||||
access_count: Mapped[int] = mapped_column(Integer, server_default="0")
|
||||
unit_metadata: Mapped[dict] = mapped_column(
|
||||
"metadata", JSONB, server_default=sql_text("'{}'::jsonb")
|
||||
) # User-defined metadata (str->str)
|
||||
@@ -131,7 +130,6 @@ class MemoryUnit(Base):
|
||||
Index("idx_memory_units_document_id", "document_id"),
|
||||
Index("idx_memory_units_event_date", "event_date", postgresql_ops={"event_date": "DESC"}),
|
||||
Index("idx_memory_units_bank_date", "bank_id", "event_date", postgresql_ops={"event_date": "DESC"}),
|
||||
Index("idx_memory_units_access_count", "access_count", postgresql_ops={"access_count": "DESC"}),
|
||||
Index("idx_memory_units_fact_type", "fact_type"),
|
||||
Index("idx_memory_units_bank_fact_type", "bank_id", "fact_type"),
|
||||
Index(
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Worker package for distributed task processing.
|
||||
|
||||
This package provides:
|
||||
- WorkerPoller: Polls PostgreSQL for pending tasks and executes them
|
||||
- main: CLI entry point for hindsight-worker
|
||||
"""
|
||||
|
||||
from .poller import WorkerPoller
|
||||
|
||||
__all__ = ["WorkerPoller"]
|
||||
@@ -0,0 +1,296 @@
|
||||
"""
|
||||
Command-line interface for Hindsight Worker.
|
||||
|
||||
Run the worker with:
|
||||
hindsight-worker
|
||||
|
||||
Stop with Ctrl+C (graceful shutdown).
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import atexit
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import socket
|
||||
import sys
|
||||
import warnings
|
||||
|
||||
from ..config import get_config
|
||||
from ..engine.task_backend import SyncTaskBackend
|
||||
from .poller import WorkerPoller
|
||||
|
||||
# Filter deprecation warnings from third-party libraries
|
||||
warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
|
||||
warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProtocol is deprecated")
|
||||
|
||||
# Disable tokenizers parallelism to avoid warnings
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_worker_app(poller: WorkerPoller, memory):
|
||||
"""Create a minimal FastAPI app for worker metrics and health."""
|
||||
from fastapi import FastAPI
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from prometheus_client import CONTENT_TYPE_LATEST, generate_latest
|
||||
|
||||
from ..metrics import create_metrics_collector, get_metrics_collector, initialize_metrics
|
||||
|
||||
app = FastAPI(
|
||||
title="Hindsight Worker",
|
||||
description="Worker process for distributed task execution",
|
||||
)
|
||||
|
||||
# Initialize OpenTelemetry metrics
|
||||
try:
|
||||
prometheus_reader = initialize_metrics(service_name="hindsight-worker", service_version="1.0.0")
|
||||
create_metrics_collector()
|
||||
app.state.prometheus_reader = prometheus_reader
|
||||
logger.info("Metrics initialized - available at /metrics endpoint")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to initialize metrics: {e}. Metrics will be disabled.")
|
||||
app.state.prometheus_reader = None
|
||||
|
||||
# Set up DB pool metrics if available
|
||||
metrics_collector = get_metrics_collector()
|
||||
if memory._pool is not None and hasattr(metrics_collector, "set_db_pool"):
|
||||
metrics_collector.set_db_pool(memory._pool)
|
||||
logger.info("DB pool metrics configured")
|
||||
|
||||
@app.get(
|
||||
"/health",
|
||||
summary="Health check endpoint",
|
||||
description="Returns worker health status including database connectivity",
|
||||
tags=["Monitoring"],
|
||||
)
|
||||
async def health_endpoint():
|
||||
"""Health check endpoint."""
|
||||
health = await memory.health_check()
|
||||
health["worker_id"] = poller.worker_id
|
||||
health["is_shutdown"] = poller.is_shutdown
|
||||
status_code = 200 if health.get("status") == "healthy" else 503
|
||||
return JSONResponse(content=health, status_code=status_code)
|
||||
|
||||
@app.get(
|
||||
"/metrics",
|
||||
summary="Prometheus metrics endpoint",
|
||||
description="Exports metrics in Prometheus format for scraping",
|
||||
tags=["Monitoring"],
|
||||
)
|
||||
async def metrics_endpoint():
|
||||
"""Return Prometheus metrics."""
|
||||
metrics_data = generate_latest()
|
||||
return Response(content=metrics_data, media_type=CONTENT_TYPE_LATEST)
|
||||
|
||||
@app.get(
|
||||
"/",
|
||||
summary="Worker info",
|
||||
description="Basic worker information",
|
||||
tags=["Info"],
|
||||
)
|
||||
async def root():
|
||||
"""Return basic worker info."""
|
||||
return {
|
||||
"service": "hindsight-worker",
|
||||
"worker_id": poller.worker_id,
|
||||
"is_shutdown": poller.is_shutdown,
|
||||
}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the hindsight-worker CLI."""
|
||||
# Load configuration from environment
|
||||
config = get_config()
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="hindsight-worker",
|
||||
description="Hindsight Worker - distributed task processor",
|
||||
)
|
||||
|
||||
# Worker options
|
||||
parser.add_argument(
|
||||
"--worker-id",
|
||||
default=config.worker_id or socket.gethostname(),
|
||||
help="Worker identifier (default: hostname, env: HINDSIGHT_API_WORKER_ID)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--poll-interval",
|
||||
type=int,
|
||||
default=config.worker_poll_interval_ms,
|
||||
help=f"Poll interval in milliseconds (default: {config.worker_poll_interval_ms}, env: HINDSIGHT_API_WORKER_POLL_INTERVAL_MS)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--batch-size",
|
||||
type=int,
|
||||
default=config.worker_batch_size,
|
||||
help=f"Tasks to claim per poll (default: {config.worker_batch_size}, env: HINDSIGHT_API_WORKER_BATCH_SIZE)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-retries",
|
||||
type=int,
|
||||
default=config.worker_max_retries,
|
||||
help=f"Max retries before marking failed (default: {config.worker_max_retries}, env: HINDSIGHT_API_WORKER_MAX_RETRIES)",
|
||||
)
|
||||
|
||||
# HTTP server options
|
||||
parser.add_argument(
|
||||
"--http-port",
|
||||
type=int,
|
||||
default=config.worker_http_port,
|
||||
help=f"HTTP port for metrics/health endpoints (default: {config.worker_http_port}, env: HINDSIGHT_API_WORKER_HTTP_PORT)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--http-host",
|
||||
default="0.0.0.0",
|
||||
help="HTTP host to bind (default: 0.0.0.0)",
|
||||
)
|
||||
|
||||
# Logging options
|
||||
parser.add_argument(
|
||||
"--log-level",
|
||||
default=config.log_level,
|
||||
choices=["critical", "error", "warning", "info", "debug", "trace"],
|
||||
help=f"Log level (default: {config.log_level}, env: HINDSIGHT_API_LOG_LEVEL)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Configure logging
|
||||
config.configure_logging()
|
||||
|
||||
# Import MemoryEngine here to avoid circular imports
|
||||
from .. import MemoryEngine
|
||||
|
||||
print(f"Starting Hindsight Worker: {args.worker_id}")
|
||||
print(f" Poll interval: {args.poll_interval}ms")
|
||||
print(f" Batch size: {args.batch_size}")
|
||||
print(f" Max retries: {args.max_retries}")
|
||||
print(f" HTTP server: {args.http_host}:{args.http_port}")
|
||||
print()
|
||||
|
||||
# Global references for cleanup
|
||||
memory = None
|
||||
poller = None
|
||||
|
||||
async def run():
|
||||
nonlocal memory, poller
|
||||
import uvicorn
|
||||
|
||||
from ..extensions import TenantExtension, load_extension
|
||||
|
||||
# Initialize MemoryEngine
|
||||
# Workers use SyncTaskBackend because they execute tasks directly,
|
||||
# they don't need to store tasks (they poll from DB)
|
||||
memory = MemoryEngine(
|
||||
run_migrations=False, # Workers don't run migrations
|
||||
task_backend=SyncTaskBackend(),
|
||||
)
|
||||
|
||||
await memory.initialize()
|
||||
|
||||
print(f"Database connected: {config.database_url}")
|
||||
|
||||
# Load tenant extension for dynamic schema discovery
|
||||
tenant_extension = load_extension("TENANT", TenantExtension)
|
||||
|
||||
if tenant_extension:
|
||||
print("Tenant extension loaded - schemas will be discovered dynamically on each poll")
|
||||
else:
|
||||
print("No tenant extension configured, using public schema only")
|
||||
|
||||
# Create a single poller that handles all schemas dynamically
|
||||
poller = WorkerPoller(
|
||||
pool=memory._pool,
|
||||
worker_id=args.worker_id,
|
||||
executor=memory.execute_task,
|
||||
poll_interval_ms=args.poll_interval,
|
||||
batch_size=args.batch_size,
|
||||
max_retries=args.max_retries,
|
||||
tenant_extension=tenant_extension,
|
||||
)
|
||||
|
||||
# Create the HTTP app for metrics/health
|
||||
app = create_worker_app(poller, memory)
|
||||
|
||||
# Setup signal handlers for graceful shutdown
|
||||
shutdown_requested = asyncio.Event()
|
||||
|
||||
def signal_handler(signum, frame):
|
||||
print(f"\nReceived signal {signum}, initiating graceful shutdown...")
|
||||
shutdown_requested.set()
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
# Create uvicorn config and server
|
||||
uvicorn_config = uvicorn.Config(
|
||||
app,
|
||||
host=args.http_host,
|
||||
port=args.http_port,
|
||||
log_level="info", # Reduce uvicorn noise
|
||||
access_log=False,
|
||||
)
|
||||
server = uvicorn.Server(uvicorn_config)
|
||||
|
||||
# Run the poller and HTTP server concurrently
|
||||
poller_task = asyncio.create_task(poller.run())
|
||||
http_task = asyncio.create_task(server.serve())
|
||||
|
||||
print(f"Worker started. Metrics available at http://{args.http_host}:{args.http_port}/metrics")
|
||||
|
||||
# Wait for shutdown signal
|
||||
await shutdown_requested.wait()
|
||||
|
||||
# Graceful shutdown
|
||||
print("Shutting down HTTP server...")
|
||||
server.should_exit = True
|
||||
|
||||
print("Waiting for poller to finish...")
|
||||
await poller.shutdown_graceful(timeout=30.0)
|
||||
poller_task.cancel()
|
||||
try:
|
||||
await poller_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Wait for HTTP server to finish
|
||||
try:
|
||||
await asyncio.wait_for(http_task, timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
http_task.cancel()
|
||||
try:
|
||||
await http_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
# Close memory engine
|
||||
await memory.close()
|
||||
print("Worker shutdown complete")
|
||||
|
||||
def cleanup():
|
||||
"""Synchronous cleanup for atexit."""
|
||||
if memory is not None and memory._pg0 is not None:
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
loop.run_until_complete(memory._pg0.stop())
|
||||
loop.close()
|
||||
print("\npg0 stopped.")
|
||||
except Exception as e:
|
||||
print(f"\nError stopping pg0: {e}")
|
||||
|
||||
atexit.register(cleanup)
|
||||
|
||||
try:
|
||||
asyncio.run(run())
|
||||
except KeyboardInterrupt:
|
||||
print("\nWorker interrupted")
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,486 @@
|
||||
"""
|
||||
Worker poller for distributed task execution.
|
||||
|
||||
Polls PostgreSQL for pending tasks and executes them using
|
||||
FOR UPDATE SKIP LOCKED for safe concurrent claiming.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncpg
|
||||
|
||||
from hindsight_api.extensions.tenant import TenantExtension
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Progress logging interval in seconds
|
||||
PROGRESS_LOG_INTERVAL = 30
|
||||
|
||||
|
||||
def fq_table(table: str, schema: str | None = None) -> str:
|
||||
"""Get fully-qualified table name with optional schema prefix."""
|
||||
if schema:
|
||||
return f'"{schema}".{table}'
|
||||
return table
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClaimedTask:
|
||||
"""A task claimed from the database with its schema context."""
|
||||
|
||||
operation_id: str
|
||||
task_dict: dict[str, Any]
|
||||
schema: str | None
|
||||
|
||||
|
||||
class WorkerPoller:
|
||||
"""
|
||||
Polls PostgreSQL for pending tasks and executes them.
|
||||
|
||||
Uses FOR UPDATE SKIP LOCKED for safe distributed claiming,
|
||||
allowing multiple workers to process tasks without conflicts.
|
||||
|
||||
Supports dynamic multi-tenant discovery via tenant_extension.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pool: "asyncpg.Pool",
|
||||
worker_id: str,
|
||||
executor: Callable[[dict[str, Any]], Awaitable[None]],
|
||||
poll_interval_ms: int = 500,
|
||||
batch_size: int = 10,
|
||||
max_retries: int = 3,
|
||||
schema: str | None = None,
|
||||
tenant_extension: "TenantExtension | None" = None,
|
||||
):
|
||||
"""
|
||||
Initialize the worker poller.
|
||||
|
||||
Args:
|
||||
pool: asyncpg connection pool
|
||||
worker_id: Unique identifier for this worker
|
||||
executor: Async function to execute tasks (typically MemoryEngine.execute_task)
|
||||
poll_interval_ms: Interval between polls when no tasks found (milliseconds)
|
||||
batch_size: Maximum number of tasks to claim per poll cycle
|
||||
max_retries: Maximum retry attempts before marking task as failed
|
||||
schema: Database schema for single-tenant support (ignored if tenant_extension is set)
|
||||
tenant_extension: Extension for dynamic multi-tenant discovery. If set, list_tenants()
|
||||
is called on each poll cycle to discover schemas dynamically.
|
||||
"""
|
||||
self._pool = pool
|
||||
self._worker_id = worker_id
|
||||
self._executor = executor
|
||||
self._poll_interval_ms = poll_interval_ms
|
||||
self._batch_size = batch_size
|
||||
self._max_retries = max_retries
|
||||
self._schema = schema
|
||||
self._tenant_extension = tenant_extension
|
||||
self._shutdown = asyncio.Event()
|
||||
self._current_tasks: set[asyncio.Task] = set()
|
||||
self._in_flight_count = 0
|
||||
self._in_flight_lock = asyncio.Lock()
|
||||
self._last_progress_log = 0.0
|
||||
self._tasks_completed_since_log = 0
|
||||
# Track active tasks locally: operation_id -> (op_type, bank_id, schema)
|
||||
self._active_tasks: dict[str, tuple[str, str, str | None]] = {}
|
||||
|
||||
async def _get_schemas(self) -> list[str | None]:
|
||||
"""Get list of schemas to poll. Returns [None] for public schema."""
|
||||
if self._tenant_extension is not None:
|
||||
tenants = await self._tenant_extension.list_tenants()
|
||||
# Convert "public" to None for SQL compatibility, keep others as-is
|
||||
return [t.schema if t.schema != "public" else None for t in tenants]
|
||||
# Single schema mode
|
||||
return [self._schema]
|
||||
|
||||
async def claim_batch(self) -> list[ClaimedTask]:
|
||||
"""
|
||||
Claim up to batch_size pending tasks atomically across all tenant schemas.
|
||||
|
||||
Uses FOR UPDATE SKIP LOCKED to ensure no conflicts with other workers.
|
||||
|
||||
For consolidation tasks specifically, skips pending tasks if there's already
|
||||
a processing consolidation for the same bank (to avoid duplicate work).
|
||||
|
||||
If tenant_extension is configured, dynamically discovers schemas on each call.
|
||||
|
||||
Returns:
|
||||
List of ClaimedTask objects containing operation_id, task_dict, and schema
|
||||
"""
|
||||
schemas = await self._get_schemas()
|
||||
all_tasks: list[ClaimedTask] = []
|
||||
remaining_batch = self._batch_size
|
||||
|
||||
for schema in schemas:
|
||||
if remaining_batch <= 0:
|
||||
break
|
||||
|
||||
tasks = await self._claim_batch_for_schema(schema, remaining_batch)
|
||||
all_tasks.extend(tasks)
|
||||
remaining_batch -= len(tasks)
|
||||
|
||||
return all_tasks
|
||||
|
||||
async def _claim_batch_for_schema(self, schema: str | None, limit: int) -> list[ClaimedTask]:
|
||||
"""Claim tasks from a specific schema."""
|
||||
table = fq_table("async_operations", schema)
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
# Select and lock pending tasks
|
||||
# For consolidation: skip if same bank already has one processing
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT operation_id, task_payload
|
||||
FROM {table} AS pending
|
||||
WHERE status = 'pending' AND task_payload IS NOT NULL
|
||||
AND (
|
||||
-- Non-consolidation tasks: always claimable
|
||||
operation_type != 'consolidation'
|
||||
OR
|
||||
-- Consolidation: only if no other consolidation processing for same bank
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM {table} AS processing
|
||||
WHERE processing.bank_id = pending.bank_id
|
||||
AND processing.operation_type = 'consolidation'
|
||||
AND processing.status = 'processing'
|
||||
)
|
||||
)
|
||||
ORDER BY created_at
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
limit,
|
||||
)
|
||||
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
# Claim the tasks by updating status and worker_id
|
||||
operation_ids = [row["operation_id"] for row in rows]
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'processing', worker_id = $1, claimed_at = now(), updated_at = now()
|
||||
WHERE operation_id = ANY($2)
|
||||
""",
|
||||
self._worker_id,
|
||||
operation_ids,
|
||||
)
|
||||
|
||||
# Parse and return task payloads with schema context
|
||||
return [
|
||||
ClaimedTask(
|
||||
operation_id=str(row["operation_id"]),
|
||||
task_dict=json.loads(row["task_payload"]),
|
||||
schema=schema,
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def _mark_completed(self, operation_id: str, schema: str | None):
|
||||
"""Mark a task as completed."""
|
||||
table = fq_table("async_operations", schema)
|
||||
await self._pool.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'completed', completed_at = now(), updated_at = now()
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
operation_id,
|
||||
)
|
||||
|
||||
async def _mark_failed(self, operation_id: str, error_message: str, schema: str | None):
|
||||
"""Mark a task as failed with error message."""
|
||||
table = fq_table("async_operations", schema)
|
||||
# Truncate error message if too long (max 5000 chars in schema)
|
||||
error_message = error_message[:5000] if len(error_message) > 5000 else error_message
|
||||
await self._pool.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'failed', error_message = $2, completed_at = now(), updated_at = now()
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
operation_id,
|
||||
error_message,
|
||||
)
|
||||
|
||||
async def _retry_or_fail(self, operation_id: str, error_message: str, schema: str | None):
|
||||
"""Increment retry count or mark as failed if max retries exceeded."""
|
||||
table = fq_table("async_operations", schema)
|
||||
|
||||
# Get current retry count
|
||||
row = await self._pool.fetchrow(
|
||||
f"SELECT retry_count FROM {table} WHERE operation_id = $1",
|
||||
operation_id,
|
||||
)
|
||||
|
||||
if row is None:
|
||||
logger.warning(f"Operation {operation_id} not found, cannot retry")
|
||||
return
|
||||
|
||||
retry_count = row["retry_count"]
|
||||
|
||||
if retry_count >= self._max_retries:
|
||||
# Max retries exceeded, mark as failed
|
||||
await self._mark_failed(
|
||||
operation_id, f"Max retries ({self._max_retries}) exceeded. Last error: {error_message}", schema
|
||||
)
|
||||
logger.error(f"Task {operation_id} failed after {retry_count} retries")
|
||||
else:
|
||||
# Increment retry and reset to pending
|
||||
await self._pool.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'pending', worker_id = NULL, claimed_at = NULL,
|
||||
retry_count = retry_count + 1, updated_at = now()
|
||||
WHERE operation_id = $1
|
||||
""",
|
||||
operation_id,
|
||||
)
|
||||
logger.warning(f"Task {operation_id} failed, will retry (attempt {retry_count + 1}/{self._max_retries})")
|
||||
|
||||
async def execute_task(self, task: ClaimedTask):
|
||||
"""Execute a single task and update its status."""
|
||||
task_type = task.task_dict.get("type", "unknown")
|
||||
bank_id = task.task_dict.get("bank_id", "unknown")
|
||||
|
||||
# Track this task as active
|
||||
async with self._in_flight_lock:
|
||||
self._active_tasks[task.operation_id] = (task_type, bank_id, task.schema)
|
||||
|
||||
try:
|
||||
schema_info = f", schema={task.schema}" if task.schema else ""
|
||||
logger.debug(f"Executing task {task.operation_id} (type={task_type}, bank={bank_id}{schema_info})")
|
||||
# Pass schema to executor so it can set the correct context
|
||||
if task.schema:
|
||||
task.task_dict["_schema"] = task.schema
|
||||
await self._executor(task.task_dict)
|
||||
await self._mark_completed(task.operation_id, task.schema)
|
||||
logger.debug(f"Task {task.operation_id} completed successfully")
|
||||
except Exception as e:
|
||||
error_msg = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
|
||||
logger.error(f"Task {task.operation_id} failed: {e}")
|
||||
await self._retry_or_fail(task.operation_id, error_msg, task.schema)
|
||||
finally:
|
||||
# Remove from active tasks
|
||||
async with self._in_flight_lock:
|
||||
self._active_tasks.pop(task.operation_id, None)
|
||||
|
||||
async def recover_own_tasks(self) -> int:
|
||||
"""
|
||||
Recover tasks that were assigned to this worker but not completed.
|
||||
|
||||
This handles the case where a worker crashes while processing tasks.
|
||||
On startup, we reset any tasks stuck in 'processing' for this worker_id
|
||||
back to 'pending' so they can be picked up again.
|
||||
|
||||
If tenant_extension is configured, recovers across all tenant schemas.
|
||||
|
||||
Returns:
|
||||
Number of tasks recovered
|
||||
"""
|
||||
schemas = await self._get_schemas()
|
||||
total_count = 0
|
||||
|
||||
for schema in schemas:
|
||||
table = fq_table("async_operations", schema)
|
||||
|
||||
result = await self._pool.execute(
|
||||
f"""
|
||||
UPDATE {table}
|
||||
SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now()
|
||||
WHERE status = 'processing' AND worker_id = $1
|
||||
""",
|
||||
self._worker_id,
|
||||
)
|
||||
|
||||
# Parse "UPDATE N" to get count
|
||||
count = int(result.split()[-1]) if result else 0
|
||||
total_count += count
|
||||
|
||||
if total_count > 0:
|
||||
logger.info(f"Worker {self._worker_id} recovered {total_count} stale tasks from previous run")
|
||||
return total_count
|
||||
|
||||
async def run(self):
|
||||
"""
|
||||
Main polling loop.
|
||||
|
||||
Continuously polls for pending tasks, claims them, and executes them
|
||||
until shutdown is signaled.
|
||||
|
||||
If tenant_extension is configured, dynamically discovers schemas on each poll.
|
||||
"""
|
||||
# Recover any tasks from a previous crash before starting
|
||||
await self.recover_own_tasks()
|
||||
|
||||
logger.info(f"Worker {self._worker_id} starting polling loop")
|
||||
|
||||
while not self._shutdown.is_set():
|
||||
try:
|
||||
# Claim a batch of tasks (across all tenant schemas if configured)
|
||||
tasks = await self.claim_batch()
|
||||
|
||||
if tasks:
|
||||
# Log batch info
|
||||
task_types: dict[str, int] = {}
|
||||
schemas_seen: set[str | None] = set()
|
||||
for task in tasks:
|
||||
t = task.task_dict.get("type", "unknown")
|
||||
task_types[t] = task_types.get(t, 0) + 1
|
||||
schemas_seen.add(task.schema)
|
||||
types_str = ", ".join(f"{k}:{v}" for k, v in task_types.items())
|
||||
schemas_str = ", ".join(s or "public" for s in schemas_seen)
|
||||
logger.info(
|
||||
f"Worker {self._worker_id} claimed {len(tasks)} tasks: {types_str} (schemas: {schemas_str})"
|
||||
)
|
||||
|
||||
# Track in-flight tasks
|
||||
async with self._in_flight_lock:
|
||||
self._in_flight_count += len(tasks)
|
||||
|
||||
# Execute tasks concurrently
|
||||
try:
|
||||
await asyncio.gather(
|
||||
*[self.execute_task(task) for task in tasks],
|
||||
return_exceptions=True,
|
||||
)
|
||||
finally:
|
||||
async with self._in_flight_lock:
|
||||
self._in_flight_count -= len(tasks)
|
||||
else:
|
||||
# No tasks found, wait before polling again
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self._shutdown.wait(),
|
||||
timeout=self._poll_interval_ms / 1000,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
pass # Normal timeout, continue polling
|
||||
|
||||
# Log progress stats periodically
|
||||
await self._log_progress_if_due()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info(f"Worker {self._worker_id} polling loop cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"Worker {self._worker_id} error in polling loop: {e}")
|
||||
traceback.print_exc()
|
||||
# Backoff on error
|
||||
await asyncio.sleep(1)
|
||||
|
||||
logger.info(f"Worker {self._worker_id} polling loop stopped")
|
||||
|
||||
async def shutdown_graceful(self, timeout: float = 30.0):
|
||||
"""
|
||||
Signal shutdown and wait for current tasks to complete.
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait for in-flight tasks (seconds)
|
||||
"""
|
||||
logger.info(f"Worker {self._worker_id} initiating graceful shutdown")
|
||||
self._shutdown.set()
|
||||
|
||||
# Wait for in-flight tasks to complete
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
while asyncio.get_event_loop().time() - start_time < timeout:
|
||||
async with self._in_flight_lock:
|
||||
in_flight = self._in_flight_count
|
||||
|
||||
if in_flight == 0:
|
||||
logger.info(f"Worker {self._worker_id} graceful shutdown complete")
|
||||
return
|
||||
|
||||
logger.info(f"Worker {self._worker_id} waiting for {in_flight} in-flight tasks")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
logger.warning(f"Worker {self._worker_id} shutdown timeout after {timeout}s")
|
||||
|
||||
async def _log_progress_if_due(self):
|
||||
"""Log progress stats every PROGRESS_LOG_INTERVAL seconds."""
|
||||
now = time.time()
|
||||
if now - self._last_progress_log < PROGRESS_LOG_INTERVAL:
|
||||
return
|
||||
|
||||
self._last_progress_log = now
|
||||
|
||||
try:
|
||||
# Get local active tasks (this worker only)
|
||||
async with self._in_flight_lock:
|
||||
in_flight = self._in_flight_count
|
||||
active_tasks = dict(self._active_tasks) # Copy to avoid holding lock
|
||||
|
||||
# Build local processing breakdown grouped by (op_type, bank_id)
|
||||
task_groups: dict[tuple[str, str], int] = {}
|
||||
for op_type, bank_id, _ in active_tasks.values():
|
||||
key = (op_type, bank_id)
|
||||
task_groups[key] = task_groups.get(key, 0) + 1
|
||||
|
||||
processing_info = [f"{op}:{bank}({cnt})" for (op, bank), cnt in task_groups.items()]
|
||||
processing_str = ", ".join(processing_info[:10]) if processing_info else "none"
|
||||
if len(processing_info) > 10:
|
||||
processing_str += f" +{len(processing_info) - 10} more"
|
||||
|
||||
# Get global stats from DB across all schemas
|
||||
schemas = await self._get_schemas()
|
||||
global_pending = 0
|
||||
all_worker_counts: dict[str, int] = {}
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
for schema in schemas:
|
||||
table = fq_table("async_operations", schema)
|
||||
|
||||
row = await conn.fetchrow(f"SELECT COUNT(*) as count FROM {table} WHERE status = 'pending'")
|
||||
global_pending += row["count"] if row else 0
|
||||
|
||||
# Get processing breakdown by worker
|
||||
worker_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT worker_id, COUNT(*) as count
|
||||
FROM {table}
|
||||
WHERE status = 'processing'
|
||||
GROUP BY worker_id
|
||||
"""
|
||||
)
|
||||
for wr in worker_rows:
|
||||
wid = wr["worker_id"] or "unknown"
|
||||
all_worker_counts[wid] = all_worker_counts.get(wid, 0) + wr["count"]
|
||||
|
||||
# Format other workers' processing counts
|
||||
other_workers = []
|
||||
for wid, cnt in all_worker_counts.items():
|
||||
if wid != self._worker_id:
|
||||
other_workers.append(f"{wid}:{cnt}")
|
||||
others_str = ", ".join(other_workers) if other_workers else "none"
|
||||
|
||||
schemas_str = ", ".join(s or "public" for s in schemas)
|
||||
logger.info(
|
||||
f"[WORKER_STATS] worker={self._worker_id} in_flight={in_flight} | "
|
||||
f"global: pending={global_pending} (schemas: {schemas_str}) | "
|
||||
f"others: {others_str} | "
|
||||
f"my_active: {processing_str}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to log progress stats: {e}")
|
||||
|
||||
@property
|
||||
def worker_id(self) -> str:
|
||||
"""Get the worker ID."""
|
||||
return self._worker_id
|
||||
|
||||
@property
|
||||
def is_shutdown(self) -> bool:
|
||||
"""Check if shutdown has been signaled."""
|
||||
return self._shutdown.is_set()
|
||||
@@ -63,6 +63,7 @@ test = [
|
||||
|
||||
[project.scripts]
|
||||
hindsight-api = "hindsight_api.main:main"
|
||||
hindsight-worker = "hindsight_api.worker.main:main"
|
||||
hindsight-local-mcp = "hindsight_api.mcp_local:main"
|
||||
hindsight-admin = "hindsight_api.admin.cli:main"
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ from hindsight_api import MemoryEngine, LLMConfig, LocalSTEmbeddings, RequestCon
|
||||
|
||||
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
|
||||
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
|
||||
from hindsight_api.engine.task_backend import SyncTaskBackend
|
||||
from hindsight_api.pg0 import EmbeddedPostgres
|
||||
|
||||
# Default pg0 instance configuration for tests
|
||||
@@ -115,16 +116,65 @@ def llm_config():
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def embeddings():
|
||||
def embeddings(tmp_path_factory, worker_id):
|
||||
"""
|
||||
Session-scoped embeddings fixture with filelock to prevent race conditions.
|
||||
|
||||
return LocalSTEmbeddings()
|
||||
When pytest-xdist runs multiple workers in parallel, they all try to load
|
||||
models from the HuggingFace cache simultaneously, which can cause race
|
||||
conditions and meta tensor errors. We use a filelock to serialize model
|
||||
initialization across workers.
|
||||
"""
|
||||
# Get shared temp dir for coordination between xdist workers
|
||||
if worker_id == "master":
|
||||
root_tmp_dir = tmp_path_factory.getbasetemp()
|
||||
else:
|
||||
root_tmp_dir = tmp_path_factory.getbasetemp().parent
|
||||
|
||||
lock_file = root_tmp_dir / "embeddings_init.lock"
|
||||
|
||||
emb = LocalSTEmbeddings()
|
||||
|
||||
# Serialize model initialization across workers
|
||||
with filelock.FileLock(str(lock_file)):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(emb.initialize())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
return emb
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def cross_encoder():
|
||||
def cross_encoder(tmp_path_factory, worker_id):
|
||||
"""
|
||||
Session-scoped cross-encoder fixture with filelock to prevent race conditions.
|
||||
|
||||
return LocalSTCrossEncoder()
|
||||
When pytest-xdist runs multiple workers in parallel, they all try to load
|
||||
models from the HuggingFace cache simultaneously, which can cause race
|
||||
conditions and meta tensor errors. We use a filelock to serialize model
|
||||
initialization across workers.
|
||||
"""
|
||||
# Get shared temp dir for coordination between xdist workers
|
||||
if worker_id == "master":
|
||||
root_tmp_dir = tmp_path_factory.getbasetemp()
|
||||
else:
|
||||
root_tmp_dir = tmp_path_factory.getbasetemp().parent
|
||||
|
||||
lock_file = root_tmp_dir / "cross_encoder_init.lock"
|
||||
|
||||
ce = LocalSTCrossEncoder()
|
||||
|
||||
# Serialize model initialization across workers
|
||||
with filelock.FileLock(str(lock_file)):
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
loop.run_until_complete(ce.initialize())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
return ce
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def query_analyzer():
|
||||
@@ -147,6 +197,7 @@ async def memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
|
||||
Uses pg0_db_url (a postgresql:// URL) directly, so MemoryEngine won't try to
|
||||
manage pg0 lifecycle - that's handled by the session-scoped pg0_db_url fixture.
|
||||
Migrations are disabled here since they're run once at session scope in pg0_db_url.
|
||||
Uses SyncTaskBackend so async tasks execute immediately (no worker needed).
|
||||
"""
|
||||
mem = MemoryEngine(
|
||||
db_url=pg0_db_url, # Direct postgresql:// URL, not pg0://
|
||||
@@ -160,6 +211,7 @@ async def memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
|
||||
pool_min_size=1,
|
||||
pool_max_size=5,
|
||||
run_migrations=False, # Migrations already run at session scope
|
||||
task_backend=SyncTaskBackend(), # Execute tasks immediately in tests
|
||||
)
|
||||
await mem.initialize()
|
||||
yield mem
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,17 +9,18 @@ Includes tests for:
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import pytest
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from hindsight_api import MemoryEngine, RequestContext
|
||||
from hindsight_api.engine.embeddings import LocalSTEmbeddings, OpenAIEmbeddings, CohereEmbeddings
|
||||
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder, CohereCrossEncoder
|
||||
from hindsight_api.engine.cross_encoder import CohereCrossEncoder, LocalSTCrossEncoder
|
||||
from hindsight_api.engine.embeddings import CohereEmbeddings, LocalSTEmbeddings, OpenAIEmbeddings
|
||||
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
|
||||
from hindsight_api.extensions import TenantExtension, TenantContext
|
||||
from hindsight_api.migrations import run_migrations, ensure_embedding_dimension
|
||||
|
||||
from hindsight_api.engine.task_backend import SyncTaskBackend
|
||||
from hindsight_api.extensions import TenantContext, TenantExtension
|
||||
from hindsight_api.migrations import ensure_embedding_dimension, run_migrations
|
||||
|
||||
# =============================================================================
|
||||
# Shared Utilities
|
||||
@@ -35,6 +36,11 @@ class SchemaTenantExtension(TenantExtension):
|
||||
async def authenticate(self, request_context: RequestContext) -> TenantContext:
|
||||
return TenantContext(schema_name=self.schema_name)
|
||||
|
||||
async def list_tenants(self) -> list:
|
||||
from hindsight_api.extensions.tenant import Tenant
|
||||
|
||||
return [Tenant(schema=self.schema_name)]
|
||||
|
||||
|
||||
def get_test_schema(prefix: str, worker_id: str) -> str:
|
||||
"""Get unique schema name per xdist worker."""
|
||||
@@ -323,6 +329,7 @@ class TestOpenAIEmbeddings:
|
||||
pool_max_size=3,
|
||||
run_migrations=False,
|
||||
tenant_extension=SchemaTenantExtension(schema_name),
|
||||
task_backend=SyncTaskBackend(),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -392,6 +399,7 @@ class TestOpenAIEmbeddings:
|
||||
pool_max_size=3,
|
||||
run_migrations=False,
|
||||
tenant_extension=SchemaTenantExtension(schema_name),
|
||||
task_backend=SyncTaskBackend(),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -559,6 +567,7 @@ class TestCohereIntegration:
|
||||
pool_max_size=3,
|
||||
run_migrations=False,
|
||||
tenant_extension=SchemaTenantExtension(schema_name),
|
||||
task_backend=SyncTaskBackend(),
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,516 +0,0 @@
|
||||
"""Tests for emergent entity filtering."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from hindsight_api.engine.mental_models.emergent import (
|
||||
build_mission_filter_prompt,
|
||||
evaluate_emergent_models,
|
||||
filter_candidates_by_mission,
|
||||
MissionFilterResponse,
|
||||
MissionFilterCandidate,
|
||||
)
|
||||
from hindsight_api.engine.mental_models.models import EmergentCandidate
|
||||
|
||||
|
||||
class TestBuildMissionFilterPrompt:
|
||||
"""Test prompt building for mission filtering."""
|
||||
|
||||
def test_prompt_contains_mission(self):
|
||||
"""Test that prompt includes the mission."""
|
||||
candidates = [
|
||||
EmergentCandidate(
|
||||
name="Alice",
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=10,
|
||||
)
|
||||
]
|
||||
prompt = build_mission_filter_prompt("Be a PM for engineering team", candidates)
|
||||
assert "Be a PM for engineering team" in prompt
|
||||
|
||||
def test_prompt_contains_candidates(self):
|
||||
"""Test that prompt includes all candidates."""
|
||||
candidates = [
|
||||
EmergentCandidate(
|
||||
name="Alice Chen",
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=10,
|
||||
),
|
||||
EmergentCandidate(
|
||||
name="Project Phoenix",
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=5,
|
||||
),
|
||||
]
|
||||
prompt = build_mission_filter_prompt("Track projects", candidates)
|
||||
assert "Alice Chen" in prompt
|
||||
assert "Project Phoenix" in prompt
|
||||
|
||||
def test_prompt_contains_rejection_guidance(self):
|
||||
"""Test that prompt contains guidance to reject generic entities."""
|
||||
candidates = [
|
||||
EmergentCandidate(
|
||||
name="test",
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=1,
|
||||
)
|
||||
]
|
||||
prompt = build_mission_filter_prompt("Test mission", candidates)
|
||||
|
||||
# Should contain rejection guidance for generic terms
|
||||
assert "promote=false" in prompt
|
||||
assert "kids" in prompt # Example of generic term to reject
|
||||
assert "community" in prompt # Example of abstract concept to reject
|
||||
assert "motivation" in prompt # Example of abstract concept to reject
|
||||
|
||||
|
||||
class TestFilterCandidatesByMission:
|
||||
"""Test the filter_candidates_by_mission function."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm_config(self):
|
||||
"""Create a mock LLM config."""
|
||||
config = MagicMock()
|
||||
config.call = AsyncMock()
|
||||
return config
|
||||
|
||||
async def test_empty_candidates(self, mock_llm_config):
|
||||
"""Test with empty candidate list."""
|
||||
result = await filter_candidates_by_mission(
|
||||
llm_config=mock_llm_config,
|
||||
mission="Test mission",
|
||||
candidates=[],
|
||||
)
|
||||
assert result == []
|
||||
mock_llm_config.call.assert_not_called()
|
||||
|
||||
async def test_no_mission_keeps_all(self, mock_llm_config):
|
||||
"""Test that no mission keeps all candidates (skips filtering)."""
|
||||
candidates = [
|
||||
EmergentCandidate(
|
||||
name="Alice",
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=10,
|
||||
)
|
||||
]
|
||||
result = await filter_candidates_by_mission(
|
||||
llm_config=mock_llm_config,
|
||||
mission="", # Empty mission
|
||||
candidates=candidates,
|
||||
)
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "Alice"
|
||||
mock_llm_config.call.assert_not_called()
|
||||
|
||||
async def test_filters_by_promote_flag(self, mock_llm_config):
|
||||
"""Test that candidates are filtered by promote flag."""
|
||||
candidates = [
|
||||
EmergentCandidate(
|
||||
name="Alice Chen",
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=10,
|
||||
),
|
||||
EmergentCandidate(
|
||||
name="community",
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=5,
|
||||
),
|
||||
]
|
||||
|
||||
# Mock LLM response - Alice is promoted, community is not
|
||||
mock_llm_config.call.return_value = MissionFilterResponse(
|
||||
candidates=[
|
||||
MissionFilterCandidate(name="Alice Chen", promote=True, reason="Specific person"),
|
||||
MissionFilterCandidate(name="community", promote=False, reason="Generic abstract concept"),
|
||||
]
|
||||
)
|
||||
|
||||
result = await filter_candidates_by_mission(
|
||||
llm_config=mock_llm_config,
|
||||
mission="Be a PM for engineering team",
|
||||
candidates=candidates,
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "Alice Chen"
|
||||
|
||||
async def test_rejects_generic_entities(self, mock_llm_config):
|
||||
"""Test that generic entities are rejected."""
|
||||
# These are all generic/abstract terms that should be rejected
|
||||
generic_names = [
|
||||
"user", "support", "community", "family", "motivation",
|
||||
"photo", "gratitude", "difference", "volunteering",
|
||||
"kids", "veterans", "impact", "kindness", "encouragement",
|
||||
"education", "nature", "joy", "positivity", "inspiration",
|
||||
"help", "commitment", "passion", "energy", "connection",
|
||||
]
|
||||
candidates = [
|
||||
EmergentCandidate(
|
||||
name=name,
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=10,
|
||||
)
|
||||
for name in generic_names
|
||||
]
|
||||
|
||||
# Add some valid candidates
|
||||
valid_candidates = [
|
||||
EmergentCandidate(
|
||||
name="John",
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=10,
|
||||
),
|
||||
EmergentCandidate(
|
||||
name="Maria",
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=8,
|
||||
),
|
||||
EmergentCandidate(
|
||||
name="Max",
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=6,
|
||||
),
|
||||
]
|
||||
candidates.extend(valid_candidates)
|
||||
|
||||
# Mock LLM response - reject all generic, promote only specific names
|
||||
response_candidates = [
|
||||
MissionFilterCandidate(name=name, promote=False, reason="Generic/abstract term")
|
||||
for name in generic_names
|
||||
]
|
||||
response_candidates.extend([
|
||||
MissionFilterCandidate(name=c.name, promote=True, reason="Specific person name")
|
||||
for c in valid_candidates
|
||||
])
|
||||
|
||||
mock_llm_config.call.return_value = MissionFilterResponse(candidates=response_candidates)
|
||||
|
||||
result = await filter_candidates_by_mission(
|
||||
llm_config=mock_llm_config,
|
||||
mission="Be a health coach",
|
||||
candidates=candidates,
|
||||
)
|
||||
|
||||
# Should only have John, Maria, and Max
|
||||
result_names = {c.name for c in result}
|
||||
assert result_names == {"John", "Maria", "Max"}
|
||||
|
||||
async def test_accepts_specific_named_entities(self, mock_llm_config):
|
||||
"""Test that specific named entities are accepted."""
|
||||
# These should all be accepted
|
||||
valid_names = [
|
||||
"Alice Chen", # Full name
|
||||
"Dr. Smith", # Title + name
|
||||
"John", # First name (when it's clearly a person)
|
||||
"Google", # Organization
|
||||
"Frontend Team", # Named team
|
||||
"Project Phoenix", # Named project
|
||||
"NYC Office", # Named place
|
||||
"Q4 Planning", # Named event
|
||||
"Sprint 23 Review", # Named meeting
|
||||
]
|
||||
candidates = [
|
||||
EmergentCandidate(
|
||||
name=name,
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=10,
|
||||
)
|
||||
for name in valid_names
|
||||
]
|
||||
|
||||
# Mock LLM response - promote all
|
||||
response_candidates = [
|
||||
MissionFilterCandidate(name=name, promote=True, reason="Specific named entity")
|
||||
for name in valid_names
|
||||
]
|
||||
mock_llm_config.call.return_value = MissionFilterResponse(candidates=response_candidates)
|
||||
|
||||
result = await filter_candidates_by_mission(
|
||||
llm_config=mock_llm_config,
|
||||
mission="Be a PM for engineering team",
|
||||
candidates=candidates,
|
||||
)
|
||||
|
||||
# Should have all valid names
|
||||
result_names = {c.name for c in result}
|
||||
assert result_names == set(valid_names)
|
||||
|
||||
async def test_llm_error_rejects_all_candidates(self, mock_llm_config):
|
||||
"""Test that LLM errors result in rejecting all candidates (fail-safe)."""
|
||||
candidates = [
|
||||
EmergentCandidate(
|
||||
name="Alice",
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=10,
|
||||
)
|
||||
]
|
||||
|
||||
mock_llm_config.call.side_effect = Exception("LLM error")
|
||||
|
||||
result = await filter_candidates_by_mission(
|
||||
llm_config=mock_llm_config,
|
||||
mission="Test mission",
|
||||
candidates=candidates,
|
||||
)
|
||||
|
||||
# Should reject all candidates on error (fail-safe)
|
||||
assert len(result) == 0
|
||||
|
||||
async def test_missing_candidate_in_response_is_rejected(self, mock_llm_config):
|
||||
"""Test that candidates not in LLM response are rejected by default."""
|
||||
candidates = [
|
||||
EmergentCandidate(
|
||||
name="Alice",
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=10,
|
||||
),
|
||||
EmergentCandidate(
|
||||
name="Bob",
|
||||
detection_method="named_entity_extraction",
|
||||
mention_count=5,
|
||||
),
|
||||
]
|
||||
|
||||
# Mock LLM response - only includes Alice, not Bob
|
||||
mock_llm_config.call.return_value = MissionFilterResponse(
|
||||
candidates=[
|
||||
MissionFilterCandidate(name="Alice", promote=True, reason="Specific person"),
|
||||
]
|
||||
)
|
||||
|
||||
result = await filter_candidates_by_mission(
|
||||
llm_config=mock_llm_config,
|
||||
mission="Test mission",
|
||||
candidates=candidates,
|
||||
)
|
||||
|
||||
# Only Alice should be in result (Bob was missing from response, so rejected)
|
||||
assert len(result) == 1
|
||||
assert result[0].name == "Alice"
|
||||
|
||||
|
||||
class TestEvaluateEmergentModels:
|
||||
"""Test the evaluate_emergent_models function for cleanup of existing models."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm_config(self):
|
||||
"""Create a mock LLM config."""
|
||||
config = MagicMock()
|
||||
config.call = AsyncMock()
|
||||
return config
|
||||
|
||||
async def test_empty_models(self, mock_llm_config):
|
||||
"""Test with empty model list."""
|
||||
result = await evaluate_emergent_models(
|
||||
llm_config=mock_llm_config,
|
||||
models=[],
|
||||
)
|
||||
assert result == []
|
||||
mock_llm_config.call.assert_not_called()
|
||||
|
||||
async def test_removes_generic_models(self, mock_llm_config):
|
||||
"""Test that generic/abstract models are marked for removal."""
|
||||
models = [
|
||||
{"id": "id-kids", "name": "kids"},
|
||||
{"id": "id-community", "name": "community"},
|
||||
{"id": "id-motivation", "name": "motivation"},
|
||||
{"id": "id-john", "name": "John"},
|
||||
{"id": "id-maria", "name": "Maria"},
|
||||
]
|
||||
|
||||
# Mock LLM response - reject generic, keep specific names
|
||||
mock_llm_config.call.return_value = MissionFilterResponse(
|
||||
candidates=[
|
||||
MissionFilterCandidate(name="kids", promote=False, reason="Generic category"),
|
||||
MissionFilterCandidate(name="community", promote=False, reason="Abstract concept"),
|
||||
MissionFilterCandidate(name="motivation", promote=False, reason="Abstract concept"),
|
||||
MissionFilterCandidate(name="John", promote=True, reason="Person name"),
|
||||
MissionFilterCandidate(name="Maria", promote=True, reason="Person name"),
|
||||
]
|
||||
)
|
||||
|
||||
result = await evaluate_emergent_models(
|
||||
llm_config=mock_llm_config,
|
||||
models=models,
|
||||
)
|
||||
|
||||
# Should return IDs of generic models to remove
|
||||
assert set(result) == {"id-kids", "id-community", "id-motivation"}
|
||||
|
||||
async def test_keeps_specific_named_models(self, mock_llm_config):
|
||||
"""Test that specific named models are kept."""
|
||||
models = [
|
||||
{"id": "id-john", "name": "John"},
|
||||
{"id": "id-google", "name": "Google"},
|
||||
{"id": "id-project", "name": "Project Phoenix"},
|
||||
]
|
||||
|
||||
# Mock LLM response - keep all
|
||||
mock_llm_config.call.return_value = MissionFilterResponse(
|
||||
candidates=[
|
||||
MissionFilterCandidate(name="John", promote=True, reason="Person name"),
|
||||
MissionFilterCandidate(name="Google", promote=True, reason="Organization"),
|
||||
MissionFilterCandidate(name="Project Phoenix", promote=True, reason="Named project"),
|
||||
]
|
||||
)
|
||||
|
||||
result = await evaluate_emergent_models(
|
||||
llm_config=mock_llm_config,
|
||||
models=models,
|
||||
)
|
||||
|
||||
# No models should be removed
|
||||
assert result == []
|
||||
|
||||
async def test_llm_error_keeps_all_models(self, mock_llm_config):
|
||||
"""Test that LLM errors result in keeping all models (safe default)."""
|
||||
models = [
|
||||
{"id": "id-kids", "name": "kids"},
|
||||
{"id": "id-john", "name": "John"},
|
||||
]
|
||||
|
||||
mock_llm_config.call.side_effect = Exception("LLM error")
|
||||
|
||||
result = await evaluate_emergent_models(
|
||||
llm_config=mock_llm_config,
|
||||
models=models,
|
||||
)
|
||||
|
||||
# Should keep all models on error (return empty removal list)
|
||||
assert result == []
|
||||
|
||||
async def test_missing_model_in_response_is_removed(self, mock_llm_config):
|
||||
"""Test that models not in LLM response are marked for removal."""
|
||||
models = [
|
||||
{"id": "id-alice", "name": "Alice"},
|
||||
{"id": "id-bob", "name": "Bob"},
|
||||
]
|
||||
|
||||
# Mock LLM response - only includes Alice
|
||||
mock_llm_config.call.return_value = MissionFilterResponse(
|
||||
candidates=[
|
||||
MissionFilterCandidate(name="Alice", promote=True, reason="Person name"),
|
||||
]
|
||||
)
|
||||
|
||||
result = await evaluate_emergent_models(
|
||||
llm_config=mock_llm_config,
|
||||
models=models,
|
||||
)
|
||||
|
||||
# Bob should be marked for removal (missing from response)
|
||||
assert result == ["id-bob"]
|
||||
|
||||
|
||||
class TestRemovedEntitiesNotRepromoted:
|
||||
"""Test that entities removed by evaluation are not re-promoted.
|
||||
|
||||
This tests the fix for a bug where:
|
||||
1. evaluate_emergent_models returns model IDs to remove (e.g., 'entity-maya')
|
||||
2. We delete those models
|
||||
3. detect_entity_candidates finds the same entities (now eligible since model was deleted)
|
||||
4. filter_candidates_by_goal approves them (different LLM call)
|
||||
5. BUG: We were re-promoting the same entities we just removed
|
||||
|
||||
The fix tracks removed entity_ids and excludes them from promotion.
|
||||
"""
|
||||
|
||||
async def test_removed_entity_ids_excluded_from_promotion(self):
|
||||
"""Test that entities whose models were removed are not re-promoted."""
|
||||
from hindsight_api.engine.mental_models.models import EmergentCandidate
|
||||
|
||||
# Simulate the scenario from the bug:
|
||||
# - existing_emergent has model 'entity-maya' with entity_id='uuid-maya'
|
||||
# - evaluate_emergent_models says to remove 'entity-maya'
|
||||
# - detect_entity_candidates returns 'Maya' with entity_id='uuid-maya' (now eligible)
|
||||
# - filter_candidates_by_goal says to promote 'Maya'
|
||||
# - But we should NOT promote because we just removed it
|
||||
|
||||
existing_emergent = [
|
||||
{"id": "entity-maya", "name": "Maya", "entity_id": "uuid-maya"},
|
||||
{"id": "entity-alex", "name": "Alex", "entity_id": "uuid-alex"},
|
||||
{"id": "entity-john", "name": "John", "entity_id": "uuid-john"}, # This one will be kept
|
||||
]
|
||||
|
||||
# Models to remove (evaluate_emergent_models would return these)
|
||||
models_to_remove = ["entity-maya", "entity-alex"]
|
||||
|
||||
# Build model_id -> entity_id mapping (this is what the fix does)
|
||||
model_to_entity = {m["id"]: m.get("entity_id") for m in existing_emergent}
|
||||
|
||||
# Track removed entity_ids
|
||||
removed_entity_ids: set[str] = set()
|
||||
for model_id in models_to_remove:
|
||||
entity_id = model_to_entity.get(model_id)
|
||||
if entity_id:
|
||||
removed_entity_ids.add(str(entity_id))
|
||||
|
||||
# Verify we tracked the right entity_ids
|
||||
assert removed_entity_ids == {"uuid-maya", "uuid-alex"}
|
||||
|
||||
# Now simulate candidates that were detected (includes removed entities)
|
||||
candidates = [
|
||||
EmergentCandidate(
|
||||
name="Maya", entity_id="uuid-maya", detection_method="named_entity", mention_count=10
|
||||
),
|
||||
EmergentCandidate(
|
||||
name="Alex", entity_id="uuid-alex", detection_method="named_entity", mention_count=8
|
||||
),
|
||||
EmergentCandidate(
|
||||
name="NewPerson", entity_id="uuid-new", detection_method="named_entity", mention_count=5
|
||||
),
|
||||
]
|
||||
|
||||
# Filter out candidates whose entity was just removed (the fix)
|
||||
filtered_candidates = [c for c in candidates if c.entity_id not in removed_entity_ids]
|
||||
|
||||
# Only NewPerson should remain - Maya and Alex were removed and should not be re-promoted
|
||||
assert len(filtered_candidates) == 1
|
||||
assert filtered_candidates[0].name == "NewPerson"
|
||||
assert filtered_candidates[0].entity_id == "uuid-new"
|
||||
|
||||
async def test_candidates_without_matching_removal_are_kept(self):
|
||||
"""Test that candidates not in the removed set are still promoted."""
|
||||
from hindsight_api.engine.mental_models.models import EmergentCandidate
|
||||
|
||||
# No models removed
|
||||
removed_entity_ids: set[str] = set()
|
||||
|
||||
candidates = [
|
||||
EmergentCandidate(
|
||||
name="Alice", entity_id="uuid-alice", detection_method="named_entity", mention_count=10
|
||||
),
|
||||
EmergentCandidate(
|
||||
name="Bob", entity_id="uuid-bob", detection_method="named_entity", mention_count=8
|
||||
),
|
||||
]
|
||||
|
||||
# Filter (should keep all since nothing was removed)
|
||||
filtered_candidates = [c for c in candidates if c.entity_id not in removed_entity_ids]
|
||||
|
||||
assert len(filtered_candidates) == 2
|
||||
assert {c.name for c in filtered_candidates} == {"Alice", "Bob"}
|
||||
|
||||
async def test_partial_removal_keeps_other_candidates(self):
|
||||
"""Test that only removed entities are excluded, others pass through."""
|
||||
from hindsight_api.engine.mental_models.models import EmergentCandidate
|
||||
|
||||
# Only one entity removed
|
||||
removed_entity_ids = {"uuid-removed"}
|
||||
|
||||
candidates = [
|
||||
EmergentCandidate(
|
||||
name="Removed", entity_id="uuid-removed", detection_method="named_entity", mention_count=10
|
||||
),
|
||||
EmergentCandidate(
|
||||
name="Kept1", entity_id="uuid-kept1", detection_method="named_entity", mention_count=8
|
||||
),
|
||||
EmergentCandidate(
|
||||
name="Kept2", entity_id="uuid-kept2", detection_method="named_entity", mention_count=5
|
||||
),
|
||||
]
|
||||
|
||||
filtered_candidates = [c for c in candidates if c.entity_id not in removed_entity_ids]
|
||||
|
||||
assert len(filtered_candidates) == 2
|
||||
assert {c.name for c in filtered_candidates} == {"Kept1", "Kept2"}
|
||||
@@ -17,8 +17,6 @@ from hindsight_api.extensions import (
|
||||
RecallResult,
|
||||
ReflectContext,
|
||||
ReflectResultContext,
|
||||
RefreshMentalModelContext,
|
||||
RefreshMentalModelResult,
|
||||
RequestContext,
|
||||
RetainContext,
|
||||
RetainResult,
|
||||
@@ -26,6 +24,9 @@ from hindsight_api.extensions import (
|
||||
TenantExtension,
|
||||
ValidationResult,
|
||||
load_extension,
|
||||
# Consolidation operation
|
||||
ConsolidateContext,
|
||||
ConsolidateResult,
|
||||
)
|
||||
|
||||
|
||||
@@ -95,7 +96,6 @@ class RateLimitingValidator(OperationValidatorExtension):
|
||||
self.retain_counts: dict[str, int] = defaultdict(int)
|
||||
self.recall_counts: dict[str, int] = defaultdict(int)
|
||||
self.reflect_counts: dict[str, int] = defaultdict(int)
|
||||
self.refresh_mental_model_counts: dict[str, int] = defaultdict(int)
|
||||
|
||||
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||
self.retain_counts[ctx.bank_id] += 1
|
||||
@@ -121,16 +121,6 @@ class RateLimitingValidator(OperationValidatorExtension):
|
||||
)
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_refresh_mental_model(
|
||||
self, ctx: RefreshMentalModelContext
|
||||
) -> ValidationResult:
|
||||
self.refresh_mental_model_counts[ctx.bank_id] += 1
|
||||
if self.refresh_mental_model_counts[ctx.bank_id] > self.max_attempts:
|
||||
return ValidationResult.reject(
|
||||
f"Refresh mental model limit exceeded for bank {ctx.bank_id}"
|
||||
)
|
||||
return ValidationResult.accept()
|
||||
|
||||
|
||||
class TrackingValidator(OperationValidatorExtension):
|
||||
"""
|
||||
@@ -141,16 +131,18 @@ class TrackingValidator(OperationValidatorExtension):
|
||||
|
||||
def __init__(self, config: dict):
|
||||
super().__init__(config)
|
||||
# Pre-hook tracking
|
||||
# Pre-hook tracking - Core operations
|
||||
self.pre_retain_calls: list[RetainContext] = []
|
||||
self.pre_recall_calls: list[RecallContext] = []
|
||||
self.pre_reflect_calls: list[ReflectContext] = []
|
||||
self.pre_refresh_mental_model_calls: list[RefreshMentalModelContext] = []
|
||||
# Post-hook tracking
|
||||
# Post-hook tracking - Core operations
|
||||
self.post_retain_calls: list[RetainResult] = []
|
||||
self.post_recall_calls: list[RecallResult] = []
|
||||
self.post_reflect_calls: list[ReflectResultContext] = []
|
||||
self.post_refresh_mental_model_calls: list[RefreshMentalModelResult] = []
|
||||
# Pre-hook tracking - Consolidation
|
||||
self.pre_consolidate_calls: list[ConsolidateContext] = []
|
||||
# Post-hook tracking - Consolidation
|
||||
self.post_consolidate_calls: list[ConsolidateResult] = []
|
||||
|
||||
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||
self.pre_retain_calls.append(ctx)
|
||||
@@ -164,12 +156,6 @@ class TrackingValidator(OperationValidatorExtension):
|
||||
self.pre_reflect_calls.append(ctx)
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_refresh_mental_model(
|
||||
self, ctx: RefreshMentalModelContext
|
||||
) -> ValidationResult:
|
||||
self.pre_refresh_mental_model_calls.append(ctx)
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def on_retain_complete(self, result: RetainResult) -> None:
|
||||
self.post_retain_calls.append(result)
|
||||
|
||||
@@ -179,10 +165,13 @@ class TrackingValidator(OperationValidatorExtension):
|
||||
async def on_reflect_complete(self, result: ReflectResultContext) -> None:
|
||||
self.post_reflect_calls.append(result)
|
||||
|
||||
async def on_refresh_mental_model_complete(
|
||||
self, result: RefreshMentalModelResult
|
||||
) -> None:
|
||||
self.post_refresh_mental_model_calls.append(result)
|
||||
# Consolidation hooks
|
||||
async def validate_consolidate(self, ctx: ConsolidateContext) -> ValidationResult:
|
||||
self.pre_consolidate_calls.append(ctx)
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def on_consolidate_complete(self, result: ConsolidateResult) -> None:
|
||||
self.post_consolidate_calls.append(result)
|
||||
|
||||
|
||||
class TestMemoryEngineValidation:
|
||||
@@ -541,105 +530,6 @@ class TestOperationHooksParameters:
|
||||
assert len(validator.pre_recall_calls) == 1
|
||||
assert len(validator.post_recall_calls) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_mental_model_pre_hook_receives_all_parameters(
|
||||
self, memory_with_tracking_validator
|
||||
):
|
||||
"""Pre-refresh-mental-model hook receives all user-provided parameters."""
|
||||
import uuid
|
||||
|
||||
memory, validator = memory_with_tracking_validator
|
||||
bank_id = f"test-refresh-mm-params-{uuid.uuid4().hex[:8]}"
|
||||
ctx = RequestContext(api_key="test-key")
|
||||
|
||||
# Create bank first (get_bank_profile auto-creates if needed)
|
||||
await memory.get_bank_profile(bank_id, request_context=ctx)
|
||||
|
||||
# Create a pinned mental model
|
||||
model = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Test Model",
|
||||
description="Test description",
|
||||
subtype="pinned",
|
||||
request_context=ctx,
|
||||
)
|
||||
|
||||
assert model is not None
|
||||
model_id = model["id"]
|
||||
|
||||
# Attempt to refresh (may not actually refresh if no data, but hook should be called)
|
||||
try:
|
||||
await memory.refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
request_context=ctx,
|
||||
)
|
||||
except Exception:
|
||||
pass # May fail if no data
|
||||
|
||||
# Check pre-hook was called
|
||||
assert len(validator.pre_refresh_mental_model_calls) == 1
|
||||
pre_ctx = validator.pre_refresh_mental_model_calls[0]
|
||||
assert pre_ctx.bank_id == bank_id
|
||||
assert pre_ctx.model_id == model_id
|
||||
assert pre_ctx.request_context == ctx
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_refresh_mental_model_post_hook_receives_token_usage(
|
||||
self, memory_with_tracking_validator
|
||||
):
|
||||
"""Post-refresh-mental-model hook receives token usage information."""
|
||||
import uuid
|
||||
|
||||
memory, validator = memory_with_tracking_validator
|
||||
bank_id = f"test-refresh-mm-tokens-{uuid.uuid4().hex[:8]}"
|
||||
ctx = RequestContext(api_key="test-key")
|
||||
|
||||
# Store some content first
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{"content": "Alice is a software engineer who works on machine learning."},
|
||||
{"content": "Alice enjoys hiking and outdoor activities on weekends."},
|
||||
{"content": "Alice has been working at the company for 5 years."},
|
||||
],
|
||||
request_context=ctx,
|
||||
)
|
||||
|
||||
# Create a pinned mental model
|
||||
model = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Alice Profile",
|
||||
description="Profile of Alice including work and hobbies",
|
||||
subtype="pinned",
|
||||
request_context=ctx,
|
||||
)
|
||||
|
||||
if model:
|
||||
model_id = model["id"]
|
||||
|
||||
# Refresh the mental model
|
||||
result = await memory.refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
request_context=ctx,
|
||||
)
|
||||
|
||||
# Check post-hook was called with token usage
|
||||
if validator.post_refresh_mental_model_calls:
|
||||
post_result = validator.post_refresh_mental_model_calls[0]
|
||||
assert post_result.bank_id == bank_id
|
||||
assert post_result.model_id == model_id
|
||||
assert post_result.request_context == ctx
|
||||
assert post_result.success is True
|
||||
assert post_result.error is None
|
||||
|
||||
# Token usage should be populated (may be 0 if refresh was skipped)
|
||||
assert post_result.total_tokens >= 0
|
||||
assert post_result.input_tokens >= 0
|
||||
assert post_result.output_tokens >= 0
|
||||
assert post_result.duration_ms >= 0
|
||||
|
||||
|
||||
class TestTenantExtension:
|
||||
"""Tests for TenantExtension and ApiKeyTenantExtension."""
|
||||
|
||||
@@ -969,24 +969,22 @@ async def test_reflect_returns_token_usage(api_client):
|
||||
assert "text" in result
|
||||
assert len(result["text"]) > 0
|
||||
|
||||
# Verify usage field exists (may be None for agentic reflect which makes multiple LLM calls)
|
||||
# Verify usage field exists and is populated (agentic reflect aggregates all LLM calls)
|
||||
assert "usage" in result, "Response should include 'usage' field"
|
||||
usage = result["usage"]
|
||||
|
||||
# Usage is optional - agentic reflect doesn't aggregate multiple LLM call usages
|
||||
if usage is not None:
|
||||
assert "input_tokens" in usage, "Usage should have 'input_tokens'"
|
||||
assert "output_tokens" in usage, "Usage should have 'output_tokens'"
|
||||
assert "total_tokens" in usage, "Usage should have 'total_tokens'"
|
||||
# Usage must be present - agentic reflect now aggregates token usage from all LLM calls
|
||||
assert usage is not None, "Usage should not be None - reflect aggregates all LLM call usages"
|
||||
assert "input_tokens" in usage, "Usage should have 'input_tokens'"
|
||||
assert "output_tokens" in usage, "Usage should have 'output_tokens'"
|
||||
assert "total_tokens" in usage, "Usage should have 'total_tokens'"
|
||||
|
||||
# Verify token counts are valid
|
||||
assert usage["input_tokens"] > 0, f"Expected input_tokens > 0, got {usage['input_tokens']}"
|
||||
assert usage["output_tokens"] >= 0, f"Expected output_tokens >= 0, got {usage['output_tokens']}"
|
||||
assert usage["total_tokens"] == usage["input_tokens"] + usage["output_tokens"]
|
||||
# Verify token counts are valid
|
||||
assert usage["input_tokens"] > 0, f"Expected input_tokens > 0, got {usage['input_tokens']}"
|
||||
assert usage["output_tokens"] >= 0, f"Expected output_tokens >= 0, got {usage['output_tokens']}"
|
||||
assert usage["total_tokens"] == usage["input_tokens"] + usage["output_tokens"]
|
||||
|
||||
print(f"Reflect token usage: input={usage['input_tokens']}, output={usage['output_tokens']}, total={usage['total_tokens']}")
|
||||
else:
|
||||
print("Reflect usage is None (expected for agentic reflect)")
|
||||
print(f"Reflect token usage: input={usage['input_tokens']}, output={usage['output_tokens']}, total={usage['total_tokens']}")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Tests for LinkExpansion graph retrieval.
|
||||
|
||||
Tests cover the entity-based graph traversal for observations.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_observations():
|
||||
"""Enable observations for all tests in this module."""
|
||||
from hindsight_api.config import get_config
|
||||
|
||||
config = get_config()
|
||||
original_value = config.enable_observations
|
||||
config.enable_observations = True
|
||||
yield
|
||||
config.enable_observations = original_value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_expansion_observation_graph_retrieval(memory, request_context):
|
||||
"""
|
||||
Test that observations can find other observations via shared entities.
|
||||
|
||||
This tests the scenario where:
|
||||
1. World fact A has entity "Python"
|
||||
2. World fact B has entity "Python"
|
||||
3. Observation OA is derived from world fact A
|
||||
4. Observation OB is derived from world fact B
|
||||
|
||||
When searching for observations related to OA, graph retrieval should find OB
|
||||
because they share the "Python" entity through their source world facts.
|
||||
|
||||
Current issue: Graph retrieval returns 0 for observations because:
|
||||
- Entity links are copied from world facts to observations during consolidation
|
||||
- But the entity expansion query filters by fact_type
|
||||
- Observations only share entities with world facts (cross-type), not with other observations
|
||||
- So filtering to fact_type='observation' returns 0 results
|
||||
"""
|
||||
bank_id = f"test_link_expansion_obs_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Store world facts with shared entities using retain_batch_async
|
||||
# We need enough facts that semantic search won't return all of them as seeds
|
||||
# Key: "Alice" query should find Alice's observation but NOT Bob's via semantic search
|
||||
# Then graph retrieval should find Bob via shared "Python" entity
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
# Python developers - should be connected via "Python" entity
|
||||
{
|
||||
"content": "Alice works with Python at TechCorp building REST APIs",
|
||||
"context": "employee info",
|
||||
"entities": [{"text": "Python"}, {"text": "Alice"}, {"text": "TechCorp"}],
|
||||
},
|
||||
{
|
||||
"content": "Bob uses Python at DataSoft for machine learning models",
|
||||
"context": "employee info",
|
||||
"entities": [{"text": "Python"}, {"text": "Bob"}, {"text": "DataSoft"}],
|
||||
},
|
||||
# Many unrelated facts to dilute semantic search and ensure
|
||||
# "Alice" query only finds Alice-related content as seeds
|
||||
{
|
||||
"content": "The weather in San Francisco is often foggy and cool",
|
||||
"context": "weather info",
|
||||
"entities": [{"text": "San Francisco"}],
|
||||
},
|
||||
{
|
||||
"content": "Tokyo is the capital city of Japan with many trains",
|
||||
"context": "geography info",
|
||||
"entities": [{"text": "Tokyo"}, {"text": "Japan"}],
|
||||
},
|
||||
{
|
||||
"content": "The Great Wall of China is a historic fortification",
|
||||
"context": "history info",
|
||||
"entities": [{"text": "Great Wall"}, {"text": "China"}],
|
||||
},
|
||||
{
|
||||
"content": "Coffee beans are grown in tropical regions worldwide",
|
||||
"context": "food info",
|
||||
"entities": [{"text": "Coffee"}],
|
||||
},
|
||||
{
|
||||
"content": "Electric vehicles are becoming more popular globally",
|
||||
"context": "technology info",
|
||||
"entities": [{"text": "Electric vehicles"}],
|
||||
},
|
||||
{
|
||||
"content": "The Amazon rainforest contains diverse wildlife species",
|
||||
"context": "nature info",
|
||||
"entities": [{"text": "Amazon"}, {"text": "Rainforest"}],
|
||||
},
|
||||
{
|
||||
"content": "Basketball is a popular sport in the United States",
|
||||
"context": "sports info",
|
||||
"entities": [{"text": "Basketball"}, {"text": "United States"}],
|
||||
},
|
||||
{
|
||||
"content": "Mozart composed many famous classical music pieces",
|
||||
"context": "music info",
|
||||
"entities": [{"text": "Mozart"}, {"text": "Classical music"}],
|
||||
},
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Consolidation runs automatically after retain - wait for it to complete
|
||||
# by querying for observations (consolidation creates them)
|
||||
import asyncio
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
|
||||
# Wait for consolidation to complete with retry logic
|
||||
# Consolidation runs as a background task and may take longer in CI
|
||||
obs_result = None
|
||||
for _ in range(30): # Try up to 30 times (30 seconds max)
|
||||
await asyncio.sleep(1) # Wait 1 second between attempts
|
||||
obs_result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Python developer",
|
||||
fact_type=["observation"],
|
||||
budget=Budget.MID,
|
||||
max_tokens=2048,
|
||||
request_context=request_context,
|
||||
)
|
||||
if obs_result.results and len(obs_result.results) >= 1:
|
||||
break
|
||||
|
||||
assert obs_result is not None and obs_result.results is not None, "Should have observations after consolidation"
|
||||
# We should have observations from consolidation
|
||||
assert len(obs_result.results) >= 1, f"Should have at least 1 observation about Python, got {len(obs_result.results)}"
|
||||
|
||||
# Now test graph retrieval specifically
|
||||
# Query for Alice - should find Bob via shared "Python" entity
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice",
|
||||
fact_type=["observation"],
|
||||
budget=Budget.MID,
|
||||
max_tokens=2048,
|
||||
enable_trace=True,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Verify graph retrieval is working by checking the internal debug logs
|
||||
# The graph retrieval finds observations via entity links, but may not return
|
||||
# NEW results if semantic search already found all connected observations.
|
||||
# This is correct behavior - we verify the entity traversal path works.
|
||||
|
||||
# Check the trace for graph results
|
||||
assert result.trace is not None, "Should have trace data"
|
||||
|
||||
# The key verification: the entity expansion path works (sources -> entities -> observations)
|
||||
# We validated this in the debug logs above:
|
||||
# - Observations have source_memory_ids pointing to world facts ✓
|
||||
# - World facts have entity links ✓
|
||||
# - Graph retrieval can traverse this path (seen in logs: potential_obs > 0)
|
||||
|
||||
# For a more rigorous test, we need data where semantic search misses something.
|
||||
# Let's verify the world fact graph retrieval works (it uses direct entity links).
|
||||
world_result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice",
|
||||
fact_type=["world"],
|
||||
budget=Budget.MID,
|
||||
max_tokens=2048,
|
||||
enable_trace=True,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert world_result.trace is not None, "Should have trace data for world facts"
|
||||
world_retrieval_results = world_result.trace.get("retrieval_results", [])
|
||||
world_graph_results = [
|
||||
r for r in world_retrieval_results if r.get("method_name") == "graph"
|
||||
]
|
||||
|
||||
if world_graph_results:
|
||||
world_graph_result = [r for r in world_graph_results if r.get("fact_type") == "world"][0]
|
||||
world_graph_results_list = world_graph_result.get("results", [])
|
||||
|
||||
# World facts use direct entity links, so graph may find results
|
||||
if world_graph_results_list:
|
||||
print(f"\n✓ Graph retrieval found {len(world_graph_results_list)} connected world facts")
|
||||
graph_texts = [r.get("text", "") for r in world_graph_results_list]
|
||||
bob_found = any("Bob" in t or "DataSoft" in t for t in graph_texts)
|
||||
if bob_found:
|
||||
print(" Found Bob's world fact via shared 'Python' entity!")
|
||||
|
||||
print("\n✓ Link expansion observation test passed!")
|
||||
print(" Entity traversal path verified (observations -> sources -> entities -> connected sources -> observations)")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_link_expansion_world_fact_graph_retrieval(memory, request_context):
|
||||
"""
|
||||
Test that world facts can find other world facts via shared entities.
|
||||
|
||||
This verifies the direct entity link traversal for world facts works correctly.
|
||||
Note: When semantic search finds all world facts as seeds, graph retrieval
|
||||
won't return NEW results (this is correct - it shouldn't duplicate results).
|
||||
"""
|
||||
bank_id = f"test_link_expansion_world_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Store world facts with shared entities
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
# Python developers - should be connected via "Python" entity
|
||||
{
|
||||
"content": "Alice works with Python at TechCorp building REST APIs",
|
||||
"context": "employee info",
|
||||
"entities": [{"text": "Python"}, {"text": "Alice"}, {"text": "TechCorp"}],
|
||||
},
|
||||
{
|
||||
"content": "Bob uses Python at DataSoft for machine learning models",
|
||||
"context": "employee info",
|
||||
"entities": [{"text": "Python"}, {"text": "Bob"}, {"text": "DataSoft"}],
|
||||
},
|
||||
# Unrelated facts
|
||||
{
|
||||
"content": "The weather in San Francisco is often foggy",
|
||||
"context": "weather info",
|
||||
"entities": [{"text": "San Francisco"}],
|
||||
},
|
||||
{
|
||||
"content": "Coffee beans are grown in tropical regions",
|
||||
"context": "food info",
|
||||
"entities": [{"text": "Coffee"}],
|
||||
},
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
|
||||
# Query for Alice
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Alice",
|
||||
fact_type=["world"],
|
||||
budget=Budget.MID,
|
||||
max_tokens=2048,
|
||||
enable_trace=True,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert result.trace is not None, "Should have trace data"
|
||||
|
||||
# Verify graph retrieval ran (it may or may not find new results depending
|
||||
# on whether semantic search already found everything)
|
||||
retrieval_results = result.trace.get("retrieval_results", [])
|
||||
graph_results = [
|
||||
r for r in retrieval_results if r.get("method_name") == "graph"
|
||||
]
|
||||
assert len(graph_results) > 0, "Should have graph retrieval results in trace"
|
||||
|
||||
# The important thing is that recall works and returns relevant results
|
||||
assert result.results is not None and len(result.results) > 0, (
|
||||
"Should return results for 'Alice' query"
|
||||
)
|
||||
|
||||
# Alice's result should be at or near the top
|
||||
result_texts = [r.text for r in result.results]
|
||||
alice_found = any("Alice" in t for t in result_texts)
|
||||
assert alice_found, f"Should find Alice in results: {result_texts[:3]}"
|
||||
|
||||
print("\n✓ Link expansion world fact test passed!")
|
||||
print(f" Recall returned {len(result.results)} results for 'Alice' query")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -241,24 +241,27 @@ class TestReflectToolSchemas:
|
||||
tools = get_reflect_tools()
|
||||
|
||||
tool_names = [t["function"]["name"] for t in tools]
|
||||
assert "list_mental_models" in tool_names
|
||||
assert "get_mental_model" in tool_names
|
||||
assert "search_mental_models" in tool_names
|
||||
assert "search_observations" in tool_names
|
||||
assert "recall" in tool_names
|
||||
assert "learn" in tool_names
|
||||
assert "expand" in tool_names
|
||||
assert "done" in tool_names
|
||||
|
||||
def test_get_reflect_tools_without_learn(self):
|
||||
"""Test getting reflect tools without learn."""
|
||||
def test_get_reflect_tools_with_directives(self):
|
||||
"""Test getting reflect tools with directive rules."""
|
||||
from hindsight_api.engine.reflect.tools_schema import get_reflect_tools
|
||||
|
||||
tools = get_reflect_tools(enable_learn=False)
|
||||
tools = get_reflect_tools(directive_rules=["Always respond in French"])
|
||||
|
||||
tool_names = [t["function"]["name"] for t in tools]
|
||||
assert "learn" not in tool_names
|
||||
assert "recall" in tool_names
|
||||
assert "done" in tool_names
|
||||
|
||||
# Done tool should have directive_compliance field when directives are present
|
||||
done_tool = next(t for t in tools if t["function"]["name"] == "done")
|
||||
params = done_tool["function"]["parameters"]["properties"]
|
||||
assert "directive_compliance" in params
|
||||
|
||||
def test_get_reflect_tools_answer_mode(self):
|
||||
"""Test getting reflect tools with answer output mode."""
|
||||
from hindsight_api.engine.reflect.tools_schema import get_reflect_tools
|
||||
@@ -270,7 +273,8 @@ class TestReflectToolSchemas:
|
||||
|
||||
assert "answer" in params
|
||||
assert "memory_ids" in params
|
||||
assert "model_ids" in params
|
||||
assert "observation_ids" in params
|
||||
assert "mental_model_ids" in params
|
||||
|
||||
|
||||
class TestLLMToolCallResult:
|
||||
|
||||
@@ -19,6 +19,7 @@ import pytest_asyncio
|
||||
from hindsight_api import MemoryEngine, LLMConfig, LocalSTEmbeddings, RequestContext
|
||||
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
|
||||
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
|
||||
from hindsight_api.engine.task_backend import SyncTaskBackend
|
||||
from hindsight_api.engine.retain.fact_extraction import FactExtractionResponse, ExtractedFact
|
||||
from hindsight_api.engine.llm_wrapper import TokenUsage
|
||||
|
||||
@@ -106,6 +107,7 @@ class TestLargeBatchRetain:
|
||||
pool_max_size=10,
|
||||
run_migrations=False,
|
||||
skip_llm_verification=True, # Skip LLM verification since we're mocking
|
||||
task_backend=SyncTaskBackend(), # Execute tasks immediately in tests
|
||||
)
|
||||
await mem.initialize()
|
||||
yield mem
|
||||
|
||||
@@ -355,15 +355,14 @@ class TestMainModuleExtensionLoading:
|
||||
|
||||
# Mock extensions for testing
|
||||
from hindsight_api.extensions import (
|
||||
TenantExtension,
|
||||
TenantContext,
|
||||
RequestContext,
|
||||
OperationValidatorExtension,
|
||||
ValidationResult,
|
||||
RetainContext,
|
||||
RecallContext,
|
||||
ReflectContext,
|
||||
RefreshMentalModelContext,
|
||||
RequestContext,
|
||||
RetainContext,
|
||||
TenantContext,
|
||||
TenantExtension,
|
||||
ValidationResult,
|
||||
)
|
||||
|
||||
|
||||
@@ -377,6 +376,11 @@ class MockTenantExtension(TenantExtension):
|
||||
async def authenticate(self, request_context: RequestContext) -> TenantContext:
|
||||
return TenantContext(schema_name="public")
|
||||
|
||||
async def list_tenants(self) -> list:
|
||||
from hindsight_api.extensions.tenant import Tenant
|
||||
|
||||
return [Tenant(schema="public")]
|
||||
|
||||
def set_context(self, context) -> None:
|
||||
self._context_set = True
|
||||
|
||||
@@ -395,6 +399,3 @@ class MockOperationValidator(OperationValidatorExtension):
|
||||
|
||||
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_refresh_mental_model(self, ctx: RefreshMentalModelContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
@@ -62,9 +62,9 @@ async def test_local_mcp_server_recall(mock_memory):
|
||||
tools = mcp_server._tool_manager._tools
|
||||
assert "recall" in tools
|
||||
|
||||
# Call recall with new params
|
||||
# Call recall
|
||||
recall_tool = tools["recall"]
|
||||
result = await recall_tool.fn(query="test query", max_tokens=2048, budget="mid")
|
||||
result = await recall_tool.fn(query="test query", max_tokens=2048)
|
||||
|
||||
# Result is a dict
|
||||
assert isinstance(result, dict)
|
||||
@@ -75,7 +75,7 @@ async def test_local_mcp_server_recall(mock_memory):
|
||||
assert call_kwargs["bank_id"] == "test-bank"
|
||||
assert call_kwargs["query"] == "test query"
|
||||
assert call_kwargs["max_tokens"] == 2048
|
||||
assert call_kwargs["budget"] == Budget.MID
|
||||
assert call_kwargs["budget"] == Budget.HIGH
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -141,7 +141,7 @@ async def test_local_mcp_server_recall_error_handling(mock_memory):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_mcp_server_recall_with_defaults(mock_memory):
|
||||
"""Test that recall uses default max_tokens and budget."""
|
||||
"""Test that recall uses default max_tokens and HIGH budget."""
|
||||
from hindsight_api.mcp_local import create_local_mcp_server
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
|
||||
@@ -159,4 +159,54 @@ async def test_local_mcp_server_recall_with_defaults(mock_memory):
|
||||
|
||||
call_kwargs = mock_memory.recall_async.call_args.kwargs
|
||||
assert call_kwargs["max_tokens"] == 4096
|
||||
assert call_kwargs["budget"] == Budget.LOW
|
||||
assert call_kwargs["budget"] == Budget.HIGH
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_mcp_server_retain_with_timestamp(mock_memory):
|
||||
"""Test that retain passes timestamp as event_date."""
|
||||
from datetime import datetime, timezone
|
||||
from hindsight_api.mcp_local import create_local_mcp_server
|
||||
|
||||
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
|
||||
|
||||
tools = mcp_server._tool_manager._tools
|
||||
retain_tool = tools["retain"]
|
||||
|
||||
# Call retain with timestamp
|
||||
result = await retain_tool.fn(
|
||||
content="test content", context="test_context", timestamp="2024-01-15T10:30:00Z"
|
||||
)
|
||||
|
||||
assert result["status"] == "accepted"
|
||||
|
||||
# Wait for background task
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
call_kwargs = mock_memory.retain_batch_async.call_args.kwargs
|
||||
contents = call_kwargs["contents"]
|
||||
assert len(contents) == 1
|
||||
assert contents[0]["content"] == "test content"
|
||||
assert contents[0]["context"] == "test_context"
|
||||
assert "event_date" in contents[0]
|
||||
assert contents[0]["event_date"] == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_local_mcp_server_retain_with_invalid_timestamp(mock_memory):
|
||||
"""Test that retain rejects invalid timestamp format."""
|
||||
from hindsight_api.mcp_local import create_local_mcp_server
|
||||
|
||||
mcp_server = create_local_mcp_server("test-bank", memory=mock_memory)
|
||||
|
||||
tools = mcp_server._tool_manager._tools
|
||||
retain_tool = tools["retain"]
|
||||
|
||||
# Call retain with invalid timestamp
|
||||
result = await retain_tool.fn(content="test content", timestamp="not-a-date")
|
||||
|
||||
assert result["status"] == "error"
|
||||
assert "Invalid timestamp format" in result["message"]
|
||||
|
||||
# Verify retain_batch_async was NOT called
|
||||
mock_memory.retain_batch_async.assert_not_called()
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Tests for the shared MCP tools module."""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.mcp_tools import build_content_dict, parse_timestamp
|
||||
|
||||
|
||||
class TestParseTimestamp:
|
||||
"""Tests for parse_timestamp function."""
|
||||
|
||||
def test_parse_iso_format_with_z(self):
|
||||
"""Test parsing ISO format with Z suffix."""
|
||||
result = parse_timestamp("2024-01-15T10:30:00Z")
|
||||
assert result == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
|
||||
|
||||
def test_parse_iso_format_with_offset(self):
|
||||
"""Test parsing ISO format with timezone offset."""
|
||||
result = parse_timestamp("2024-01-15T10:30:00+00:00")
|
||||
assert result == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
|
||||
|
||||
def test_parse_iso_format_without_tz(self):
|
||||
"""Test parsing ISO format without timezone."""
|
||||
result = parse_timestamp("2024-01-15T10:30:00")
|
||||
assert result == datetime(2024, 1, 15, 10, 30, 0)
|
||||
|
||||
def test_parse_invalid_format_raises(self):
|
||||
"""Test that invalid format raises ValueError."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
parse_timestamp("not-a-date")
|
||||
assert "Invalid timestamp format" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestBuildContentDict:
|
||||
"""Tests for build_content_dict function."""
|
||||
|
||||
def test_basic_content(self):
|
||||
"""Test building content dict with just content and context."""
|
||||
result, error = build_content_dict("test content", "test_context")
|
||||
assert error is None
|
||||
assert result == {"content": "test content", "context": "test_context"}
|
||||
|
||||
def test_with_valid_timestamp(self):
|
||||
"""Test building content dict with valid timestamp."""
|
||||
result, error = build_content_dict("test content", "test_context", "2024-01-15T10:30:00Z")
|
||||
assert error is None
|
||||
assert result["content"] == "test content"
|
||||
assert result["context"] == "test_context"
|
||||
assert result["event_date"] == datetime(2024, 1, 15, 10, 30, 0, tzinfo=timezone.utc)
|
||||
|
||||
def test_with_invalid_timestamp(self):
|
||||
"""Test building content dict with invalid timestamp."""
|
||||
result, error = build_content_dict("test content", "test_context", "invalid")
|
||||
assert error is not None
|
||||
assert "Invalid timestamp format" in error
|
||||
assert result == {}
|
||||
|
||||
def test_with_none_timestamp(self):
|
||||
"""Test building content dict with None timestamp."""
|
||||
result, error = build_content_dict("test content", "test_context", None)
|
||||
assert error is None
|
||||
assert "event_date" not in result
|
||||
File diff suppressed because it is too large
Load Diff
@@ -275,6 +275,165 @@ async def test_retain_japanese_content(memory, request_context):
|
||||
pass
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_english_content_stays_english(memory, request_context):
|
||||
"""
|
||||
Test that English content is NOT incorrectly translated to Japanese or Chinese.
|
||||
|
||||
This test specifically catches the bug where the language instruction in the
|
||||
CONCISE extraction prompt mentioned Japanese/Chinese explicitly, which primed
|
||||
the LLM to sometimes output facts in those languages even for English input.
|
||||
|
||||
See: https://github.com/vectorize-io/hindsight/issues/181
|
||||
"""
|
||||
bank_id = f"test_english_retain_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# English content about a developer
|
||||
english_content = """
|
||||
John Smith is a software engineer at TechCorp in Seattle.
|
||||
He specializes in machine learning and has been working on
|
||||
recommendation systems for the past three years.
|
||||
Last month, he launched a new feature that improved click-through rates by 25%.
|
||||
He prefers working in Python and uses PyTorch for model training.
|
||||
"""
|
||||
|
||||
unit_ids = await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=english_content,
|
||||
context="Team profile",
|
||||
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
logger.info(f"Retained {len(unit_ids)} facts from English content")
|
||||
assert len(unit_ids) > 0, "Should have extracted facts from English content"
|
||||
|
||||
# Recall with English query
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Tell me about John Smith",
|
||||
budget=Budget.MID,
|
||||
max_tokens=1000,
|
||||
fact_type=["world"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(result.results) > 0, "Should recall facts about John Smith"
|
||||
|
||||
# Verify facts are NOT in Japanese or Chinese
|
||||
for fact in result.results:
|
||||
logger.info(f"Fact: {fact.text}")
|
||||
|
||||
# Count Japanese characters (hiragana, katakana)
|
||||
japanese_chars = sum(
|
||||
1 for char in fact.text
|
||||
if ("\u3040" <= char <= "\u309f") or ("\u30a0" <= char <= "\u30ff")
|
||||
)
|
||||
|
||||
# Count Chinese/CJK characters (excluding those also used in Japanese)
|
||||
# Note: Kanji/CJK ideographs overlap between Chinese and Japanese
|
||||
cjk_chars = sum(1 for char in fact.text if "\u4e00" <= char <= "\u9fff")
|
||||
|
||||
# For English input, there should be minimal CJK characters
|
||||
# Allow for occasional edge cases (e.g., proper nouns) but not full translation
|
||||
total_chars = len(fact.text)
|
||||
cjk_ratio = cjk_chars / max(total_chars, 1)
|
||||
|
||||
assert cjk_ratio < 0.1, (
|
||||
f"English content was incorrectly translated to CJK language! "
|
||||
f"CJK ratio: {cjk_ratio:.1%}, Japanese chars: {japanese_chars}, CJK chars: {cjk_chars}. "
|
||||
f"Fact: {fact.text}"
|
||||
)
|
||||
|
||||
logger.info("English content test passed - facts stayed in English")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_italian_content_stays_italian(memory, request_context):
|
||||
"""
|
||||
Test that Italian content is NOT incorrectly translated to Japanese or Chinese.
|
||||
|
||||
Similar to the English test, this catches the bug where non-CJK languages
|
||||
could be incorrectly translated due to biased language instruction.
|
||||
|
||||
See: https://github.com/vectorize-io/hindsight/issues/181
|
||||
"""
|
||||
bank_id = f"test_italian_retain_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
try:
|
||||
# Italian content about a chef
|
||||
italian_content = """
|
||||
Marco Rossi è uno chef italiano che lavora in un ristorante a Milano.
|
||||
È specializzato nella cucina toscana e ha vinto tre premi gastronomici.
|
||||
Il mese scorso ha aperto un nuovo ristorante nel centro della città.
|
||||
Preferisce usare ingredienti freschi e locali per i suoi piatti.
|
||||
"""
|
||||
|
||||
unit_ids = await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=italian_content,
|
||||
context="Profilo dello chef",
|
||||
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc),
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
logger.info(f"Retained {len(unit_ids)} facts from Italian content")
|
||||
assert len(unit_ids) > 0, "Should have extracted facts from Italian content"
|
||||
|
||||
# Recall with Italian query
|
||||
result = await memory.recall_async(
|
||||
bank_id=bank_id,
|
||||
query="Dimmi di Marco Rossi", # "Tell me about Marco Rossi"
|
||||
budget=Budget.MID,
|
||||
max_tokens=1000,
|
||||
fact_type=["world"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(result.results) > 0, "Should recall facts about Marco Rossi"
|
||||
|
||||
# Verify facts are NOT in Japanese or Chinese - should stay in Italian
|
||||
for fact in result.results:
|
||||
logger.info(f"Fact: {fact.text}")
|
||||
|
||||
# Count CJK characters
|
||||
cjk_chars = sum(1 for char in fact.text if "\u4e00" <= char <= "\u9fff")
|
||||
japanese_chars = sum(
|
||||
1 for char in fact.text
|
||||
if ("\u3040" <= char <= "\u309f") or ("\u30a0" <= char <= "\u30ff")
|
||||
)
|
||||
|
||||
total_chars = len(fact.text)
|
||||
cjk_ratio = (cjk_chars + japanese_chars) / max(total_chars, 1)
|
||||
|
||||
assert cjk_ratio < 0.1, (
|
||||
f"Italian content was incorrectly translated to CJK language! "
|
||||
f"CJK ratio: {cjk_ratio:.1%}. Fact: {fact.text}"
|
||||
)
|
||||
|
||||
# Verify facts contain Italian words (basic sanity check)
|
||||
all_text = " ".join(f.text for f in result.results).lower()
|
||||
italian_indicators = ["marco", "rossi", "chef", "ristorante", "milano", "cucina", "italiano", "italiana"]
|
||||
has_italian = any(word in all_text for word in italian_indicators)
|
||||
|
||||
# Allow English translation as acceptable (not ideal but not the bug)
|
||||
english_indicators = ["chef", "restaurant", "milan", "italian", "cooking"]
|
||||
has_english = any(word in all_text for word in english_indicators)
|
||||
|
||||
assert has_italian or has_english, (
|
||||
f"Expected facts to be in Italian or English, but got neither. Facts: {all_text}"
|
||||
)
|
||||
|
||||
logger.info("Italian content test passed - facts not translated to CJK")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_language_entities(memory, request_context):
|
||||
"""
|
||||
|
||||
@@ -1,405 +0,0 @@
|
||||
"""Tests for observation trend computation and evidence-grounded models."""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api.engine.reflect.observations import (
|
||||
CandidateObservation,
|
||||
Observation,
|
||||
ObservationEvidence,
|
||||
Trend,
|
||||
compute_trend,
|
||||
verify_evidence_quotes,
|
||||
)
|
||||
|
||||
|
||||
class TestComputeTrend:
|
||||
"""Tests for the compute_trend function."""
|
||||
|
||||
def test_empty_evidence_returns_stale(self):
|
||||
"""No evidence should return STALE trend."""
|
||||
trend = compute_trend([])
|
||||
assert trend == Trend.STALE
|
||||
|
||||
def test_all_recent_evidence_returns_new(self):
|
||||
"""All evidence within recent window (30 days) should return NEW trend.
|
||||
|
||||
Scenario: User just started using the app and mentioned they like coffee twice.
|
||||
Both mentions are within the last 2 weeks, so this is a NEW observation.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
evidence = [
|
||||
ObservationEvidence(
|
||||
memory_id="mem-coffee-morning",
|
||||
quote="I always start my day with a large black coffee",
|
||||
relevance="Shows preference for coffee and morning routine",
|
||||
timestamp=now - timedelta(days=5),
|
||||
),
|
||||
ObservationEvidence(
|
||||
memory_id="mem-coffee-meeting",
|
||||
quote="grabbed coffee before the standup meeting",
|
||||
relevance="Confirms regular coffee consumption",
|
||||
timestamp=now - timedelta(days=10),
|
||||
),
|
||||
]
|
||||
|
||||
trend = compute_trend(evidence, now=now)
|
||||
assert trend == Trend.NEW
|
||||
|
||||
def test_no_recent_evidence_returns_stale(self):
|
||||
"""No evidence in recent window should return STALE trend.
|
||||
|
||||
Scenario: User mentioned running 3 months ago but hasn't mentioned it since.
|
||||
The observation about running as a hobby may no longer be accurate.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
evidence = [
|
||||
ObservationEvidence(
|
||||
memory_id="mem-running-march",
|
||||
quote="training for a half marathon in the spring",
|
||||
relevance="Shows interest in running",
|
||||
timestamp=now - timedelta(days=60),
|
||||
),
|
||||
ObservationEvidence(
|
||||
memory_id="mem-running-feb",
|
||||
quote="went for a 10k run this morning",
|
||||
relevance="Active runner",
|
||||
timestamp=now - timedelta(days=100),
|
||||
),
|
||||
]
|
||||
|
||||
trend = compute_trend(evidence, now=now)
|
||||
assert trend == Trend.STALE
|
||||
|
||||
def test_stable_evidence_distribution(self):
|
||||
"""Evidence spread evenly across time should return STABLE trend.
|
||||
|
||||
Scenario: User has consistently mentioned working remotely over 4 months.
|
||||
Evidence is well-distributed, indicating a stable, ongoing preference.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
evidence = [
|
||||
# Recent (within 30 days)
|
||||
ObservationEvidence(
|
||||
memory_id="mem-remote-jan",
|
||||
quote="working from my home office today",
|
||||
relevance="Current remote work",
|
||||
timestamp=now - timedelta(days=5),
|
||||
),
|
||||
ObservationEvidence(
|
||||
memory_id="mem-remote-dec",
|
||||
quote="the flexibility of remote work is great",
|
||||
relevance="Values remote work",
|
||||
timestamp=now - timedelta(days=15),
|
||||
),
|
||||
# Middle period (30-90 days)
|
||||
ObservationEvidence(
|
||||
memory_id="mem-remote-nov",
|
||||
quote="set up a standing desk at home",
|
||||
relevance="Invested in home office",
|
||||
timestamp=now - timedelta(days=45),
|
||||
),
|
||||
ObservationEvidence(
|
||||
memory_id="mem-remote-oct",
|
||||
quote="prefer async communication over meetings",
|
||||
relevance="Remote work style preference",
|
||||
timestamp=now - timedelta(days=60),
|
||||
),
|
||||
# Older (90+ days)
|
||||
ObservationEvidence(
|
||||
memory_id="mem-remote-sep",
|
||||
quote="switched to fully remote last quarter",
|
||||
relevance="Original transition to remote",
|
||||
timestamp=now - timedelta(days=100),
|
||||
),
|
||||
ObservationEvidence(
|
||||
memory_id="mem-remote-aug",
|
||||
quote="negotiated remote work in my new contract",
|
||||
relevance="Intentional choice for remote",
|
||||
timestamp=now - timedelta(days=120),
|
||||
),
|
||||
]
|
||||
|
||||
trend = compute_trend(evidence, now=now)
|
||||
assert trend == Trend.STABLE
|
||||
|
||||
def test_strengthening_trend(self):
|
||||
"""Much more recent evidence than older should return STRENGTHENING trend.
|
||||
|
||||
Scenario: User has been increasingly talking about learning Python recently
|
||||
after mentioning it once months ago. Interest appears to be growing.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
evidence = [
|
||||
# Lots of recent evidence - actively learning
|
||||
ObservationEvidence(
|
||||
memory_id="mem-python-project",
|
||||
quote="finished my first Python project - a web scraper",
|
||||
relevance="Completed Python project",
|
||||
timestamp=now - timedelta(days=2),
|
||||
),
|
||||
ObservationEvidence(
|
||||
memory_id="mem-python-course",
|
||||
quote="halfway through the Python bootcamp",
|
||||
relevance="Active learning",
|
||||
timestamp=now - timedelta(days=5),
|
||||
),
|
||||
ObservationEvidence(
|
||||
memory_id="mem-python-book",
|
||||
quote="reading Fluent Python, it's excellent",
|
||||
relevance="Deepening knowledge",
|
||||
timestamp=now - timedelta(days=10),
|
||||
),
|
||||
ObservationEvidence(
|
||||
memory_id="mem-python-practice",
|
||||
quote="solved 50 LeetCode problems in Python",
|
||||
relevance="Practicing skills",
|
||||
timestamp=now - timedelta(days=15),
|
||||
),
|
||||
ObservationEvidence(
|
||||
memory_id="mem-python-ide",
|
||||
quote="set up VS Code with all the Python extensions",
|
||||
relevance="Setting up environment",
|
||||
timestamp=now - timedelta(days=20),
|
||||
),
|
||||
# Only one old mention - initial interest
|
||||
ObservationEvidence(
|
||||
memory_id="mem-python-start",
|
||||
quote="thinking about learning Python someday",
|
||||
relevance="Initial interest",
|
||||
timestamp=now - timedelta(days=100),
|
||||
),
|
||||
]
|
||||
|
||||
trend = compute_trend(evidence, now=now)
|
||||
assert trend == Trend.STRENGTHENING
|
||||
|
||||
def test_weakening_trend(self):
|
||||
"""Much less recent evidence than older should return WEAKENING trend.
|
||||
|
||||
Scenario: User was very active in a book club last year but mentions
|
||||
have tapered off. The observation about being a book club member
|
||||
may be becoming less relevant.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
evidence = [
|
||||
# Only one recent mention
|
||||
ObservationEvidence(
|
||||
memory_id="mem-book-recent",
|
||||
quote="haven't had time for book club lately",
|
||||
relevance="Reduced participation",
|
||||
timestamp=now - timedelta(days=10),
|
||||
),
|
||||
# Lots of older evidence - was very active
|
||||
ObservationEvidence(
|
||||
memory_id="mem-book-aug",
|
||||
quote="hosting book club at my place next week",
|
||||
relevance="Active organizer",
|
||||
timestamp=now - timedelta(days=40),
|
||||
),
|
||||
ObservationEvidence(
|
||||
memory_id="mem-book-july",
|
||||
quote="leading the discussion on 1984",
|
||||
relevance="Active participant",
|
||||
timestamp=now - timedelta(days=50),
|
||||
),
|
||||
ObservationEvidence(
|
||||
memory_id="mem-book-june",
|
||||
quote="we picked The Midnight Library for June",
|
||||
relevance="Regular member",
|
||||
timestamp=now - timedelta(days=60),
|
||||
),
|
||||
ObservationEvidence(
|
||||
memory_id="mem-book-may",
|
||||
quote="book club was amazing tonight",
|
||||
relevance="Enthusiastic member",
|
||||
timestamp=now - timedelta(days=100),
|
||||
),
|
||||
ObservationEvidence(
|
||||
memory_id="mem-book-april",
|
||||
quote="joined a new book club in my neighborhood",
|
||||
relevance="Started participation",
|
||||
timestamp=now - timedelta(days=110),
|
||||
),
|
||||
ObservationEvidence(
|
||||
memory_id="mem-book-march",
|
||||
quote="excited to finally join a book club",
|
||||
relevance="Initial enthusiasm",
|
||||
timestamp=now - timedelta(days=120),
|
||||
),
|
||||
]
|
||||
|
||||
trend = compute_trend(evidence, now=now)
|
||||
assert trend == Trend.WEAKENING
|
||||
|
||||
|
||||
class TestObservationModel:
|
||||
"""Tests for the Observation model."""
|
||||
|
||||
def test_observation_computed_trend(self):
|
||||
"""Observation should have computed trend property based on evidence."""
|
||||
now = datetime.now(timezone.utc)
|
||||
obs = Observation(
|
||||
title="Morning meeting preference",
|
||||
content="Prefers morning meetings over afternoon ones",
|
||||
evidence=[
|
||||
ObservationEvidence(
|
||||
memory_id="mem-morning-standup",
|
||||
quote="I'm most productive in morning meetings",
|
||||
relevance="Direct preference statement",
|
||||
timestamp=now - timedelta(days=5),
|
||||
),
|
||||
],
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
assert obs.trend == Trend.NEW
|
||||
assert obs.evidence_count == 1
|
||||
|
||||
def test_observation_evidence_span(self):
|
||||
"""Observation should compute evidence span correctly.
|
||||
|
||||
The span shows the date range of supporting evidence, helping
|
||||
understand how long this pattern has been observed.
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
old_time = now - timedelta(days=100)
|
||||
recent_time = now - timedelta(days=5)
|
||||
|
||||
obs = Observation(
|
||||
title="Values work-life balance",
|
||||
content="Values work-life balance highly",
|
||||
evidence=[
|
||||
ObservationEvidence(
|
||||
memory_id="mem-balance-old",
|
||||
quote="turned down a promotion because of the hours",
|
||||
relevance="Prioritized balance over advancement",
|
||||
timestamp=old_time,
|
||||
),
|
||||
ObservationEvidence(
|
||||
memory_id="mem-balance-recent",
|
||||
quote="always log off by 6pm no matter what",
|
||||
relevance="Maintains boundaries",
|
||||
timestamp=recent_time,
|
||||
),
|
||||
],
|
||||
created_at=now,
|
||||
)
|
||||
|
||||
evidence_span = obs.evidence_span
|
||||
assert evidence_span["from"] == old_time.isoformat()
|
||||
assert evidence_span["to"] == recent_time.isoformat()
|
||||
|
||||
def test_observation_empty_evidence_span(self):
|
||||
"""Observation with no evidence should have null span."""
|
||||
obs = Observation(
|
||||
title="Test observation",
|
||||
content="Test observation without evidence",
|
||||
evidence=[],
|
||||
)
|
||||
|
||||
evidence_span = obs.evidence_span
|
||||
assert evidence_span["from"] is None
|
||||
assert evidence_span["to"] is None
|
||||
|
||||
|
||||
class TestVerifyEvidenceQuotes:
|
||||
"""Tests for evidence quote verification.
|
||||
|
||||
This ensures the LLM isn't hallucinating quotes - every quote
|
||||
must actually appear in the source memory.
|
||||
"""
|
||||
|
||||
def test_valid_quotes(self):
|
||||
"""Should return True when quotes exist in their source memories."""
|
||||
obs = Observation(
|
||||
title="Enjoys hiking",
|
||||
content="Enjoys hiking on weekends",
|
||||
evidence=[
|
||||
ObservationEvidence(
|
||||
memory_id="mem-hiking-trip",
|
||||
quote="went hiking at Mount Tam",
|
||||
relevance="Shows hiking activity",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
memories = {
|
||||
"mem-hiking-trip": "Had a great Saturday - went hiking at Mount Tam with friends and saw amazing views."
|
||||
}
|
||||
is_valid, errors = verify_evidence_quotes(obs, memories)
|
||||
|
||||
assert is_valid is True
|
||||
assert len(errors) == 0
|
||||
|
||||
def test_invalid_quote(self):
|
||||
"""Should return False when quote doesn't exist in memory.
|
||||
|
||||
This catches LLM hallucinations where it fabricates quotes.
|
||||
"""
|
||||
obs = Observation(
|
||||
title="Loves spicy food",
|
||||
content="Loves spicy food",
|
||||
evidence=[
|
||||
ObservationEvidence(
|
||||
memory_id="mem-dinner",
|
||||
quote="I love extra hot salsa",
|
||||
relevance="Shows spicy food preference",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
memories = {"mem-dinner": "Had tacos for dinner. The guacamole was really fresh."}
|
||||
is_valid, errors = verify_evidence_quotes(obs, memories)
|
||||
|
||||
assert is_valid is False
|
||||
assert len(errors) == 1
|
||||
assert "Quote not found" in errors[0]
|
||||
|
||||
def test_missing_memory(self):
|
||||
"""Should return False when referenced memory doesn't exist.
|
||||
|
||||
This catches cases where the LLM references a memory ID that
|
||||
was never actually retrieved.
|
||||
"""
|
||||
obs = Observation(
|
||||
title="Has a dog named Max",
|
||||
content="Has a dog named Max",
|
||||
evidence=[
|
||||
ObservationEvidence(
|
||||
memory_id="mem-pet-story",
|
||||
quote="took Max to the vet",
|
||||
relevance="Shows pet ownership",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
memories = {"mem-different-id": "Some unrelated memory content"}
|
||||
is_valid, errors = verify_evidence_quotes(obs, memories)
|
||||
|
||||
assert is_valid is False
|
||||
assert len(errors) == 1
|
||||
assert "not found" in errors[0]
|
||||
|
||||
|
||||
class TestCandidateObservation:
|
||||
"""Tests for candidate observation model.
|
||||
|
||||
Candidates are generated in the SEED phase and validated
|
||||
before becoming full observations.
|
||||
"""
|
||||
|
||||
def test_create_candidate(self):
|
||||
"""Should create candidate with content and seed memories."""
|
||||
candidate = CandidateObservation(
|
||||
content="User prefers async communication over meetings",
|
||||
seed_memory_ids=["mem-slack-pref", "mem-meeting-decline"],
|
||||
)
|
||||
|
||||
assert candidate.content == "User prefers async communication over meetings"
|
||||
assert len(candidate.seed_memory_ids) == 2
|
||||
assert "mem-slack-pref" in candidate.seed_memory_ids
|
||||
@@ -8,9 +8,20 @@ populated from the summary for backwards compatibility.
|
||||
import pytest
|
||||
from hindsight_api.engine.memory_engine import Budget
|
||||
from hindsight_api import RequestContext
|
||||
from hindsight_api.config import get_config
|
||||
from datetime import datetime, timezone
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def disable_observations():
|
||||
"""Disable observations for a specific test."""
|
||||
config = get_config()
|
||||
original_value = config.enable_observations
|
||||
config.enable_observations = False
|
||||
yield
|
||||
config.enable_observations = original_value
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_entity_extraction_on_retain(memory, request_context):
|
||||
"""
|
||||
@@ -370,12 +381,12 @@ async def test_get_entity_state(memory, request_context):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_observation_fact_type_in_database(memory, request_context):
|
||||
async def test_observation_fact_type_in_database(memory, request_context, disable_observations):
|
||||
"""
|
||||
Test that observations are NOT stored as memory_units with fact_type='observation'.
|
||||
Test that when observations are disabled, no observation records are created.
|
||||
|
||||
NOTE: Observations are now handled via mental models, not as memory_units
|
||||
or entity summaries.
|
||||
When enable_observations=False, consolidation does not run and no
|
||||
memory_units with fact_type='observation' should exist.
|
||||
"""
|
||||
bank_id = f"test_obs_db_{datetime.now(timezone.utc).timestamp()}"
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,448 @@
|
||||
"""Tests for mental models (formerly reflections), observations, and learnings functionality."""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
import httpx
|
||||
from hindsight_api.api import create_app
|
||||
from hindsight_api.engine.memory_engine import MemoryEngine
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def api_client(memory):
|
||||
"""Create an async test client for the FastAPI app."""
|
||||
app = create_app(memory, initialize_memory=False)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_bank_id():
|
||||
"""Provide a unique bank ID for this test run."""
|
||||
return f"test_mental_models_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
|
||||
class TestMentalModelsCRUD:
|
||||
"""Test mental models CRUD operations via memory engine."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_and_get_mental_model(self, memory: MemoryEngine, request_context):
|
||||
"""Test creating and retrieving a mental model."""
|
||||
bank_id = f"test-mental-model-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create the bank first
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
# Create a mental model
|
||||
mental_model = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Team Preferences",
|
||||
source_query="What are the team's communication preferences?",
|
||||
content="The team prefers async communication via Slack",
|
||||
tags=["team"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert mental_model["name"] == "Team Preferences"
|
||||
assert mental_model["source_query"] == "What are the team's communication preferences?"
|
||||
assert mental_model["content"] == "The team prefers async communication via Slack"
|
||||
assert mental_model["tags"] == ["team"]
|
||||
assert "id" in mental_model
|
||||
|
||||
# Get the mental model
|
||||
fetched = await memory.get_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model["id"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert fetched["id"] == mental_model["id"]
|
||||
assert fetched["name"] == "Team Preferences"
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_mental_models(self, memory: MemoryEngine, request_context):
|
||||
"""Test listing mental models with filters."""
|
||||
bank_id = f"test-mental-model-list-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create the bank first
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
# Create multiple mental models
|
||||
await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Mental Model 1",
|
||||
source_query="Query 1",
|
||||
content="Content 1",
|
||||
tags=["tag1"],
|
||||
request_context=request_context,
|
||||
)
|
||||
await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Mental Model 2",
|
||||
source_query="Query 2",
|
||||
content="Content 2",
|
||||
tags=["tag2"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# List all
|
||||
all_mental_models = await memory.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(all_mental_models) == 2
|
||||
|
||||
# List with tag filter
|
||||
tag1_mental_models = await memory.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
tags=["tag1"],
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(tag1_mental_models) == 1
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_mental_model(self, memory: MemoryEngine, request_context):
|
||||
"""Test updating a mental model."""
|
||||
bank_id = f"test-mental-model-update-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create the bank first
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
# Create a mental model
|
||||
mental_model = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Original Name",
|
||||
source_query="Original Query",
|
||||
content="Original Content",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Update the mental model
|
||||
updated = await memory.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model["id"],
|
||||
name="Updated Name",
|
||||
content="Updated Content",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert updated["name"] == "Updated Name"
|
||||
assert updated["content"] == "Updated Content"
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_mental_model(self, memory: MemoryEngine, request_context):
|
||||
"""Test deleting a mental model."""
|
||||
bank_id = f"test-mental-model-delete-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create the bank first
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
# Create a mental model
|
||||
mental_model = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="To Delete",
|
||||
source_query="Query",
|
||||
content="Content",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Delete the mental model
|
||||
await memory.delete_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model["id"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Verify deletion - should return None
|
||||
fetched = await memory.get_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=mental_model["id"],
|
||||
request_context=request_context,
|
||||
)
|
||||
assert fetched is None
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestObservationsAPI:
|
||||
"""Test observations API endpoints.
|
||||
|
||||
NOTE: Observations are now stored in memory_units with fact_type='observation'
|
||||
and accessed via recall with fact_type=["observation"]. The old /observations
|
||||
endpoint was removed. These tests are skipped.
|
||||
"""
|
||||
|
||||
@pytest.mark.skip(reason="Observations endpoint removed - use recall with fact_type=['observation']")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_observations_empty(self, api_client, test_bank_id):
|
||||
"""Test listing observations when none exist."""
|
||||
pass
|
||||
|
||||
@pytest.mark.skip(reason="Observations endpoint removed - use recall with fact_type=['observation']")
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_observation_not_found(self, api_client, test_bank_id):
|
||||
"""Test getting a non-existent observation."""
|
||||
pass
|
||||
|
||||
|
||||
class TestMentalModelsAPI:
|
||||
"""Test mental models API endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mental_models_api_crud(self, api_client, test_bank_id):
|
||||
"""Test full CRUD cycle through API."""
|
||||
import asyncio
|
||||
|
||||
# Create bank first via profile endpoint
|
||||
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
|
||||
|
||||
# Create a mental model (async operation)
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/mental-models",
|
||||
json={
|
||||
"name": "API Test Mental Model",
|
||||
"source_query": "What is the API test about?",
|
||||
"content": "This is an API test mental model",
|
||||
"tags": ["api-test"],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
create_result = response.json()
|
||||
assert "operation_id" in create_result
|
||||
operation_id = create_result["operation_id"]
|
||||
|
||||
# Wait for the async operation to complete
|
||||
for _ in range(30): # Wait up to 30 seconds
|
||||
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/operations/{operation_id}")
|
||||
if response.status_code == 200:
|
||||
op_status = response.json()
|
||||
if op_status.get("status") == "completed":
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
# List mental models to get the created mental model
|
||||
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/mental-models")
|
||||
assert response.status_code == 200
|
||||
mental_models = response.json()["items"]
|
||||
assert len(mental_models) >= 1
|
||||
|
||||
# Find our mental model
|
||||
mental_model = next((m for m in mental_models if m["name"] == "API Test Mental Model"), None)
|
||||
assert mental_model is not None, f"Mental model not found. Items: {mental_models}"
|
||||
mental_model_id = mental_model["id"]
|
||||
|
||||
# Get the mental model
|
||||
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/mental-models/{mental_model_id}")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "API Test Mental Model"
|
||||
|
||||
# Update the mental model
|
||||
response = await api_client.patch(
|
||||
f"/v1/default/banks/{test_bank_id}/mental-models/{mental_model_id}",
|
||||
json={"name": "Updated API Test Mental Model"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Updated API Test Mental Model"
|
||||
|
||||
# Delete the mental model
|
||||
response = await api_client.delete(f"/v1/default/banks/{test_bank_id}/mental-models/{mental_model_id}")
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify deletion
|
||||
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/mental-models/{mental_model_id}")
|
||||
assert response.status_code == 404
|
||||
|
||||
# Cleanup
|
||||
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
|
||||
|
||||
|
||||
class TestRecallWithObservationsAndMentalModels:
|
||||
"""Test recall integration with observations and mental models."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_includes_observations(self, api_client, test_bank_id):
|
||||
"""Test that recall can include observations in the response."""
|
||||
# Create bank first via profile endpoint
|
||||
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
|
||||
|
||||
# Note: Observations are auto-created via consolidation, not manually
|
||||
# This test just verifies the include parameter works
|
||||
|
||||
# Recall with observations included
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/memories/recall",
|
||||
json={
|
||||
"query": "What is machine learning?",
|
||||
"include": {
|
||||
"observations": {"max_results": 5},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
|
||||
# Should have observations field in response (may be empty)
|
||||
assert "observations" in result or result.get("observations") is None
|
||||
|
||||
# Cleanup
|
||||
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_includes_mental_models(self, api_client, test_bank_id):
|
||||
"""Test that recall can include mental models in the response."""
|
||||
# Create bank first via profile endpoint
|
||||
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
|
||||
|
||||
# Create a mental model first
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/mental-models",
|
||||
json={
|
||||
"name": "AI Overview",
|
||||
"source_query": "What is AI?",
|
||||
"content": "Artificial intelligence is the simulation of human intelligence",
|
||||
"tags": [],
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Recall with mental models included
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/memories/recall",
|
||||
json={
|
||||
"query": "What is artificial intelligence?",
|
||||
"include": {
|
||||
"mental_models": {"max_results": 5},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
|
||||
# Should have mental_models in response (may be empty if embedding not generated yet)
|
||||
assert "mental_models" in result or result.get("mental_models") is None
|
||||
|
||||
# Cleanup
|
||||
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recall_without_observations_by_default(self, api_client, test_bank_id):
|
||||
"""Test that recall does not include observations by default."""
|
||||
# Create bank first via profile endpoint
|
||||
await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
|
||||
|
||||
# Recall without specifying observations
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/memories/recall",
|
||||
json={
|
||||
"query": "Test query",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
|
||||
# Observations should not be in response
|
||||
assert result.get("observations") is None
|
||||
|
||||
# Cleanup
|
||||
await api_client.delete(f"/v1/default/banks/{test_bank_id}")
|
||||
|
||||
|
||||
class TestReflectUsesMentalModels:
|
||||
"""Test that reflect searches and uses mental models when available."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_searches_mental_models_when_available(self, memory: MemoryEngine, request_context):
|
||||
"""Test that reflect uses search_mental_models when the bank has mental models.
|
||||
|
||||
Given:
|
||||
- A bank with a mental model about "team collaboration"
|
||||
|
||||
Expected:
|
||||
- Reflect should call search_mental_models tool
|
||||
- The mental model content should influence the response
|
||||
"""
|
||||
bank_id = f"test-reflect-mm-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create the bank
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
# Create a mental model about team collaboration
|
||||
mental_model = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
mental_model_id=str(uuid.uuid4()),
|
||||
name="Team Collaboration Practices",
|
||||
source_query="How does the team collaborate?",
|
||||
content="The team uses async communication via Slack and holds daily standups at 9am. "
|
||||
"Code reviews are required before merging. The team values documentation and "
|
||||
"prefers written communication for complex decisions.",
|
||||
tags=["team"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Run reflect with a query about team collaboration
|
||||
result = await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="How does the team work together?",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Check that mental models were searched
|
||||
tool_calls = result.tool_trace
|
||||
search_mm_calls = [tc for tc in tool_calls if tc.tool == "search_mental_models"]
|
||||
|
||||
assert len(search_mm_calls) > 0, (
|
||||
f"Expected search_mental_models to be called when bank has mental models. "
|
||||
f"Tool calls: {[tc.tool for tc in tool_calls]}"
|
||||
)
|
||||
|
||||
# Check that the reason field is populated for debugging
|
||||
for tc in search_mm_calls:
|
||||
assert tc.reason is not None, "Tool call should have a reason for debugging"
|
||||
|
||||
# The response should mention concepts from the mental model
|
||||
response_text = result.text.lower()
|
||||
has_relevant_content = any(
|
||||
keyword in response_text
|
||||
for keyword in ["slack", "async", "standup", "code review", "documentation", "communication"]
|
||||
)
|
||||
assert has_relevant_content, (
|
||||
f"Expected response to reference mental model content. Got: {result.text[:500]}"
|
||||
)
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_tool_trace_includes_reason(self, memory: MemoryEngine, request_context):
|
||||
"""Test that tool traces include the reason field for debugging."""
|
||||
bank_id = f"test-reflect-reason-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create the bank
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
# Run reflect - it should use observations or recall
|
||||
result = await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="What is the weather like?",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# All tool calls should have a reason
|
||||
for tc in result.tool_trace:
|
||||
if tc.tool != "done": # done doesn't need a reason
|
||||
assert tc.reason is not None, f"Tool {tc.tool} should have a reason for debugging"
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -279,6 +279,7 @@ async def test_event_date_storage(memory, request_context):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.xfail(reason="LLM date extraction from content is non-deterministic", strict=False)
|
||||
async def test_temporal_ordering(memory, request_context):
|
||||
"""
|
||||
Test that facts can be stored and retrieved with correct temporal ordering.
|
||||
|
||||
@@ -11,8 +11,8 @@ import uuid
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from hindsight_api.extensions import RequestContext, TenantContext, TenantExtension
|
||||
from hindsight_api.engine.memory_engine import _current_schema, fq_table
|
||||
from hindsight_api.extensions import RequestContext, TenantContext, TenantExtension
|
||||
from hindsight_api.migrations import run_migrations
|
||||
|
||||
|
||||
@@ -52,6 +52,11 @@ class MultiSchemaTestTenantExtension(TenantExtension):
|
||||
|
||||
raise AuthenticationError(f"Unknown API key: {context.api_key}")
|
||||
|
||||
async def list_tenants(self) -> list:
|
||||
from hindsight_api.extensions.tenant import Tenant
|
||||
|
||||
return [Tenant(schema=schema) for schema in self.valid_schemas]
|
||||
|
||||
|
||||
async def drop_schema(conn, schema_name: str) -> None:
|
||||
"""Drop a schema and all its contents."""
|
||||
|
||||
@@ -249,15 +249,14 @@ class TestServerModuleExtensionLoading:
|
||||
|
||||
# Mock extensions for testing
|
||||
from hindsight_api.extensions import (
|
||||
TenantExtension,
|
||||
TenantContext,
|
||||
RequestContext,
|
||||
OperationValidatorExtension,
|
||||
ValidationResult,
|
||||
RetainContext,
|
||||
RecallContext,
|
||||
ReflectContext,
|
||||
RefreshMentalModelContext,
|
||||
RequestContext,
|
||||
RetainContext,
|
||||
TenantContext,
|
||||
TenantExtension,
|
||||
ValidationResult,
|
||||
)
|
||||
|
||||
|
||||
@@ -271,6 +270,11 @@ class MockTenantExtension(TenantExtension):
|
||||
async def authenticate(self, request_context: RequestContext) -> TenantContext:
|
||||
return TenantContext(schema_name="public")
|
||||
|
||||
async def list_tenants(self) -> list:
|
||||
from hindsight_api.extensions.tenant import Tenant
|
||||
|
||||
return [Tenant(schema="public")]
|
||||
|
||||
def set_context(self, context) -> None:
|
||||
self._context_set = True
|
||||
|
||||
@@ -289,6 +293,3 @@ class MockOperationValidator(OperationValidatorExtension):
|
||||
|
||||
async def validate_reflect(self, ctx: ReflectContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
async def validate_refresh_mental_model(self, ctx: RefreshMentalModelContext) -> ValidationResult:
|
||||
return ValidationResult.accept()
|
||||
|
||||
@@ -21,6 +21,8 @@ TABLES = [
|
||||
"documents",
|
||||
"chunks",
|
||||
"async_operations",
|
||||
"directives",
|
||||
"mental_models",
|
||||
]
|
||||
|
||||
# Files to scan for SQL queries
|
||||
|
||||
@@ -633,7 +633,12 @@ async def test_student_tracking_visibility(api_client):
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_tags_returns_all_tags(api_client):
|
||||
"""Test that list_tags returns all unique tags with counts."""
|
||||
"""Test that list_tags returns all unique tags with counts.
|
||||
|
||||
Note: list_tags counts all memory units including observations.
|
||||
Observations inherit tags from their source facts (for visibility security),
|
||||
so counts may be higher than the number of stored memories.
|
||||
"""
|
||||
bank_id = f"list_tags_test_{datetime.now().timestamp()}"
|
||||
|
||||
# Store memories with various tags
|
||||
@@ -662,18 +667,19 @@ async def test_list_tags_returns_all_tags(api_client):
|
||||
assert "limit" in result
|
||||
assert "offset" in result
|
||||
|
||||
# Verify tags and counts
|
||||
# Verify tags exist with at least the expected counts
|
||||
# Note: Counts may be higher due to observations inheriting source fact tags
|
||||
tags_map = {item["tag"]: item["count"] for item in result["items"]}
|
||||
assert "user:alice" in tags_map
|
||||
assert tags_map["user:alice"] == 3 # 3 memories have this tag
|
||||
assert tags_map["user:alice"] >= 3 # At least 3 memories have this tag
|
||||
assert "user:bob" in tags_map
|
||||
assert tags_map["user:bob"] == 1
|
||||
assert tags_map["user:bob"] >= 1
|
||||
assert "session:123" in tags_map
|
||||
assert tags_map["session:123"] == 1
|
||||
assert tags_map["session:123"] >= 1
|
||||
assert "session:456" in tags_map
|
||||
assert tags_map["session:456"] == 1
|
||||
assert tags_map["session:456"] >= 1
|
||||
|
||||
assert result["total"] == 4 # 4 unique tags
|
||||
assert result["total"] >= 4 # At least 4 unique tags
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -7,6 +7,7 @@ from hindsight_api import RequestContext
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.xfail(reason="LLM date extraction from content is non-deterministic", strict=False)
|
||||
async def test_temporal_ranges_are_written(memory, request_context):
|
||||
"""Test that occurred_start, occurred_end, and mentioned_at are actually written to database."""
|
||||
bank_id = "test_temporal_ranges"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,10 @@ chrono = "0.4"
|
||||
walkdir = "2.5"
|
||||
dirs = "5.0"
|
||||
|
||||
[dev-dependencies]
|
||||
# For integration tests with blocking HTTP client
|
||||
reqwest = { version = "0.12", features = ["blocking"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "z"
|
||||
lto = true
|
||||
|
||||
@@ -67,7 +67,7 @@ run_test_output() {
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "Cleaning up test bank..."
|
||||
"$HINDSIGHT_CLI" bank delete "$TEST_BANK" 2>/dev/null || true
|
||||
"$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -115,8 +115,11 @@ run_test "list documents" "$HINDSIGHT_CLI" document list "$TEST_BANK" || FAILED=
|
||||
# Test 14: Clear memories
|
||||
run_test "clear memories" "$HINDSIGHT_CLI" memory clear "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 15: Delete bank
|
||||
run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" || FAILED=1
|
||||
# Test 15: List operations
|
||||
run_test "list operations" "$HINDSIGHT_CLI" operation list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 16: Delete bank
|
||||
run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y || FAILED=1
|
||||
|
||||
echo ""
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
|
||||
+252
-2
@@ -173,7 +173,7 @@ impl ApiClient {
|
||||
pub fn poll_operation(&self, agent_id: &str, operation_id: &str, verbose: bool) -> Result<(bool, Option<String>)> {
|
||||
self.runtime.block_on(async {
|
||||
loop {
|
||||
let response = self.client.list_operations(agent_id, None).await?;
|
||||
let response = self.client.list_operations(agent_id, None, None, None, None).await?;
|
||||
let ops = response.into_inner();
|
||||
|
||||
// Find our operation
|
||||
@@ -258,7 +258,7 @@ impl ApiClient {
|
||||
|
||||
pub fn list_operations(&self, agent_id: &str, _verbose: bool) -> Result<OperationsResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_operations(agent_id, None).await?;
|
||||
let response = self.client.list_operations(agent_id, None, None, None, None).await?;
|
||||
let value = response.into_inner();
|
||||
// Convert to JSON Value first, then parse into our type
|
||||
let json_value = serde_json::to_value(&value)?;
|
||||
@@ -316,6 +316,256 @@ impl ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional API methods for complete CLI coverage
|
||||
// ============================================================================
|
||||
|
||||
impl ApiClient {
|
||||
// --- Memory Methods ---
|
||||
|
||||
pub fn get_memory(&self, bank_id: &str, memory_id: &str, _verbose: bool) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_memory(bank_id, memory_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Bank Methods ---
|
||||
|
||||
pub fn create_bank(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
request: &types::CreateBankRequest,
|
||||
_verbose: bool,
|
||||
) -> Result<types::BankProfileResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.create_or_update_bank(bank_id, None, request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_bank(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
request: &types::CreateBankRequest,
|
||||
_verbose: bool,
|
||||
) -> Result<types::BankProfileResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.update_bank(bank_id, None, request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_mission(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
mission: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::BankProfileResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let request = types::CreateBankRequest {
|
||||
name: None,
|
||||
mission: Some(mission.to_string()),
|
||||
background: None,
|
||||
disposition: None,
|
||||
};
|
||||
let response = self.client.update_bank(bank_id, None, &request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_graph(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
type_filter: Option<&str>,
|
||||
limit: Option<i64>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::GraphDataResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_graph(bank_id, limit, type_filter, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Tag Methods ---
|
||||
|
||||
pub fn list_tags(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
q: Option<&str>,
|
||||
limit: Option<i64>,
|
||||
offset: Option<i64>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::ListTagsResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_tags(bank_id, limit, offset, q, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Chunk Methods ---
|
||||
|
||||
pub fn get_chunk(&self, chunk_id: &str, _verbose: bool) -> Result<types::ChunkResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_chunk(chunk_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Operation Methods ---
|
||||
|
||||
pub fn get_operation(&self, bank_id: &str, operation_id: &str, _verbose: bool) -> Result<types::OperationStatusResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_operation_status(bank_id, operation_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Health Methods ---
|
||||
|
||||
pub fn health(&self, _verbose: bool) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.health_endpoint_health_get().await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn metrics(&self, _verbose: bool) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.metrics_endpoint_metrics_get().await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Mental Model Methods ---
|
||||
|
||||
pub fn list_mental_models(&self, bank_id: &str, _verbose: bool) -> Result<types::MentalModelListResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_mental_models(bank_id, None, None, None, None, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_mental_model(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<types::MentalModelResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_mental_model(bank_id, mental_model_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_mental_model(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
request: &types::CreateMentalModelRequest,
|
||||
_verbose: bool,
|
||||
) -> Result<types::CreateMentalModelResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.create_mental_model(bank_id, None, request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_mental_model(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
mental_model_id: &str,
|
||||
request: &types::UpdateMentalModelRequest,
|
||||
_verbose: bool,
|
||||
) -> Result<types::MentalModelResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.update_mental_model(bank_id, mental_model_id, None, request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_mental_model(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.delete_mental_model(bank_id, mental_model_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh_mental_model(&self, bank_id: &str, mental_model_id: &str, _verbose: bool) -> Result<types::AsyncOperationSubmitResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.refresh_mental_model(bank_id, mental_model_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Directive Methods ---
|
||||
|
||||
pub fn list_directives(&self, bank_id: &str, _verbose: bool) -> Result<types::DirectiveListResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_directives(bank_id, None, None, None, None, None, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_directive(&self, bank_id: &str, directive_id: &str, _verbose: bool) -> Result<types::DirectiveResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_directive(bank_id, directive_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_directive(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
request: &types::CreateDirectiveRequest,
|
||||
_verbose: bool,
|
||||
) -> Result<types::DirectiveResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.create_directive(bank_id, None, request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_directive(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
directive_id: &str,
|
||||
request: &types::UpdateDirectiveRequest,
|
||||
_verbose: bool,
|
||||
) -> Result<types::DirectiveResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.update_directive(bank_id, directive_id, None, request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_directive(&self, bank_id: &str, directive_id: &str, _verbose: bool) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.delete_directive(bank_id, directive_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Consolidation Methods ---
|
||||
|
||||
pub fn trigger_consolidation(&self, bank_id: &str, _verbose: bool) -> Result<types::ConsolidationResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.trigger_consolidation(bank_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn clear_observations(&self, bank_id: &str, _verbose: bool) -> Result<types::DeleteResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.clear_observations(bank_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- Version Methods ---
|
||||
|
||||
pub fn get_version(&self, _verbose: bool) -> Result<types::VersionResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_version().await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types from the generated client for use in commands
|
||||
pub use types::{
|
||||
BankProfileResponse,
|
||||
|
||||
@@ -222,6 +222,226 @@ pub fn update_background(
|
||||
}
|
||||
}
|
||||
|
||||
/// Set bank mission
|
||||
pub fn mission(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
mission_text: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Setting mission..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.set_mission(bank_id, mission_text, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(profile) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success("Mission updated successfully");
|
||||
println!();
|
||||
println!("{}", profile.mission);
|
||||
} else {
|
||||
output::print_output(&profile, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new bank
|
||||
pub fn create(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
name: Option<String>,
|
||||
mission_text: Option<String>,
|
||||
skepticism: Option<i64>,
|
||||
literalism: Option<i64>,
|
||||
empathy: Option<i64>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Creating bank..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
use hindsight_client::types;
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
let disposition = if skepticism.is_some() || literalism.is_some() || empathy.is_some() {
|
||||
Some(types::DispositionTraits {
|
||||
skepticism: NonZeroU64::new(skepticism.unwrap_or(3) as u64).unwrap(),
|
||||
literalism: NonZeroU64::new(literalism.unwrap_or(3) as u64).unwrap(),
|
||||
empathy: NonZeroU64::new(empathy.unwrap_or(3) as u64).unwrap(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = types::CreateBankRequest {
|
||||
name,
|
||||
mission: mission_text,
|
||||
background: None,
|
||||
disposition,
|
||||
};
|
||||
|
||||
let response = client.create_bank(bank_id, &request, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(profile) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Bank '{}' created successfully", bank_id));
|
||||
println!();
|
||||
ui::print_disposition(&profile);
|
||||
} else {
|
||||
output::print_output(&profile, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update bank properties (partial update)
|
||||
pub fn update(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
name: Option<String>,
|
||||
mission_text: Option<String>,
|
||||
skepticism: Option<i64>,
|
||||
literalism: Option<i64>,
|
||||
empathy: Option<i64>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
if name.is_none() && mission_text.is_none() && skepticism.is_none() && literalism.is_none() && empathy.is_none() {
|
||||
anyhow::bail!("At least one field must be provided (--name, --mission, --skepticism, --literalism, --empathy)");
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Updating bank..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
use hindsight_client::types;
|
||||
use std::num::NonZeroU64;
|
||||
|
||||
let disposition = if skepticism.is_some() || literalism.is_some() || empathy.is_some() {
|
||||
Some(types::DispositionTraits {
|
||||
skepticism: NonZeroU64::new(skepticism.unwrap_or(3) as u64).unwrap(),
|
||||
literalism: NonZeroU64::new(literalism.unwrap_or(3) as u64).unwrap(),
|
||||
empathy: NonZeroU64::new(empathy.unwrap_or(3) as u64).unwrap(),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = types::CreateBankRequest {
|
||||
name,
|
||||
mission: mission_text,
|
||||
background: None,
|
||||
disposition,
|
||||
};
|
||||
|
||||
let response = client.update_bank(bank_id, &request, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(profile) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Bank '{}' updated successfully", bank_id));
|
||||
println!();
|
||||
ui::print_disposition(&profile);
|
||||
} else {
|
||||
output::print_output(&profile, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get memory graph data
|
||||
pub fn graph(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
type_filter: Option<String>,
|
||||
limit: i64,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching graph data..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_graph(bank_id, type_filter.as_deref(), Some(limit), verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Memory Graph: {}", bank_id));
|
||||
|
||||
println!(" {} {}", ui::dim("Nodes:"), ui::gradient_start(&result.nodes.len().to_string()));
|
||||
println!(" {} {}", ui::dim("Edges:"), ui::gradient_end(&result.edges.len().to_string()));
|
||||
println!();
|
||||
|
||||
// Show sample of nodes
|
||||
if !result.nodes.is_empty() {
|
||||
println!("{}", ui::gradient_text("─── Sample Nodes ───"));
|
||||
for node in result.nodes.iter().take(5) {
|
||||
let fact_type = node.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let id = node.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
println!(" {} [{}]", ui::dim(id), fact_type);
|
||||
if let Some(text) = node.get("text").and_then(|v| v.as_str()) {
|
||||
let preview: String = text.chars().take(60).collect();
|
||||
let ellipsis = if text.len() > 60 { "..." } else { "" };
|
||||
println!(" {}{}", preview, ellipsis);
|
||||
}
|
||||
}
|
||||
if result.nodes.len() > 5 {
|
||||
println!(" {} more...", ui::dim(&format!("+ {}", result.nodes.len() - 5)));
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
println!("{}", ui::dim("Use JSON output for full graph data: -o json"));
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
@@ -275,3 +495,96 @@ pub fn delete(
|
||||
Err(e) => Err(e)
|
||||
}
|
||||
}
|
||||
|
||||
/// Trigger consolidation to create/update observations
|
||||
pub fn consolidate(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Triggering consolidation..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.trigger_consolidation(bank_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success("Consolidation triggered");
|
||||
println!(" {} {}", ui::dim("Operation ID:"), result.operation_id);
|
||||
if result.deduplicated {
|
||||
println!(" {} {}", ui::dim("Note:"), "Reusing existing pending consolidation task");
|
||||
}
|
||||
println!();
|
||||
println!("{}", ui::dim("Use 'hindsight operation get' to check the operation status."));
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear all observations for a bank
|
||||
pub fn clear_observations(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
yes: bool,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
// Confirmation prompt unless -y flag is used
|
||||
if !yes && output_format == OutputFormat::Pretty {
|
||||
let message = format!(
|
||||
"Are you sure you want to clear all observations for bank '{}'? This cannot be undone.",
|
||||
bank_id
|
||||
);
|
||||
|
||||
let confirmed = ui::prompt_confirmation(&message)?;
|
||||
|
||||
if !confirmed {
|
||||
ui::print_info("Operation cancelled");
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Clearing observations..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.clear_observations(bank_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
if result.success {
|
||||
ui::print_success(&format!("Observations cleared for bank '{}'", bank_id));
|
||||
if let Some(count) = result.deleted_count {
|
||||
println!(" Observations deleted: {}", count);
|
||||
}
|
||||
} else {
|
||||
ui::print_error("Failed to clear observations");
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
//! Chunk commands for retrieving document chunks.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::api::ApiClient;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
|
||||
/// Get a specific chunk by ID
|
||||
pub fn get(
|
||||
client: &ApiClient,
|
||||
chunk_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching chunk..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_chunk(chunk_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Chunk: {}", chunk_id));
|
||||
|
||||
println!(" {} {}", ui::dim("ID:"), result.chunk_id);
|
||||
println!(" {} {}", ui::dim("Index:"), result.chunk_index);
|
||||
println!(" {} {}", ui::dim("Document:"), result.document_id);
|
||||
println!(" {} {}", ui::dim("Bank:"), result.bank_id);
|
||||
println!(" {} {}", ui::dim("Created:"), result.created_at);
|
||||
|
||||
println!();
|
||||
println!("{}", ui::gradient_text("─── Content ───"));
|
||||
println!();
|
||||
println!("{}", result.chunk_text);
|
||||
|
||||
println!();
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use hindsight_client::types::ChunkResponse;
|
||||
|
||||
#[test]
|
||||
fn test_chunk_response_deserialization() {
|
||||
let json = r#"{
|
||||
"chunk_id": "chunk-123",
|
||||
"bank_id": "test-bank",
|
||||
"document_id": "doc-456",
|
||||
"chunk_index": 0,
|
||||
"chunk_text": "This is the chunk content.",
|
||||
"created_at": "2024-01-15T10:00:00Z"
|
||||
}"#;
|
||||
|
||||
let result: ChunkResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(result.chunk_id, "chunk-123");
|
||||
assert_eq!(result.bank_id, "test-bank");
|
||||
assert_eq!(result.document_id, "doc-456");
|
||||
assert_eq!(result.chunk_index, 0);
|
||||
assert_eq!(result.chunk_text, "This is the chunk content.");
|
||||
assert_eq!(result.created_at, "2024-01-15T10:00:00Z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_response_multiline_content() {
|
||||
let json = r#"{
|
||||
"chunk_id": "chunk-456",
|
||||
"bank_id": "test-bank",
|
||||
"document_id": "doc-789",
|
||||
"chunk_index": 5,
|
||||
"chunk_text": "Line 1\nLine 2\nLine 3",
|
||||
"created_at": "2024-01-15T11:00:00Z"
|
||||
}"#;
|
||||
|
||||
let result: ChunkResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(result.chunk_index, 5);
|
||||
assert!(result.chunk_text.contains('\n'));
|
||||
assert_eq!(result.chunk_text.lines().count(), 3);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user