Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45ed02cd6a | ||
|
|
2921fa091b | ||
|
|
aafa7ae17c | ||
|
|
caeab8ac44 | ||
|
|
42cc098a7c | ||
|
|
2871ddc34c | ||
|
|
987b47e6a1 | ||
|
|
25fb4b9273 | ||
|
|
f72c9f03fb | ||
|
|
517eee4eda | ||
|
|
e19c6b9252 |
+26
-14
@@ -153,15 +153,8 @@ jobs:
|
||||
- name: Build docs
|
||||
run: npm run build --workspace=hindsight-docs
|
||||
|
||||
test-rust-cli:
|
||||
build-rust-cli:
|
||||
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
|
||||
HINDSIGHT_API_URL: http://localhost:8888
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -178,10 +171,6 @@ jobs:
|
||||
hindsight-cli/target
|
||||
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: hindsight-cli
|
||||
run: cargo test
|
||||
|
||||
- name: Build CLI
|
||||
working-directory: hindsight-cli
|
||||
run: cargo build --release
|
||||
@@ -193,6 +182,29 @@ jobs:
|
||||
path: hindsight-cli/target/release/hindsight
|
||||
retention-days: 1
|
||||
|
||||
test-rust-cli:
|
||||
runs-on: ubuntu-latest
|
||||
needs: build-rust-cli
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: groq
|
||||
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
|
||||
HINDSIGHT_API_URL: http://localhost:8888
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
UV_INDEX: pytorch=https://download.pytorch.org/whl/cpu
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Download CLI artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: hindsight-cli
|
||||
path: /tmp/cli
|
||||
|
||||
- name: Make CLI executable
|
||||
run: chmod +x /tmp/cli/hindsight
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v5
|
||||
with:
|
||||
@@ -239,7 +251,7 @@ jobs:
|
||||
|
||||
- name: Run CLI smoke test
|
||||
run: |
|
||||
HINDSIGHT_CLI=hindsight-cli/target/release/hindsight ./hindsight-cli/smoke-test.sh
|
||||
HINDSIGHT_CLI=/tmp/cli/hindsight ./hindsight-cli/smoke-test.sh
|
||||
|
||||
- name: Show API server logs
|
||||
if: always()
|
||||
@@ -765,7 +777,7 @@ jobs:
|
||||
|
||||
test-doc-examples:
|
||||
runs-on: ubuntu-latest
|
||||
needs: test-rust-cli
|
||||
needs: build-rust-cli
|
||||
env:
|
||||
HINDSIGHT_API_LLM_PROVIDER: groq
|
||||
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
|
||||
+1
-1
@@ -29,7 +29,7 @@ nltk_data/
|
||||
|
||||
# Monitoring stack (Prometheus/Grafana binaries and data)
|
||||
.monitoring/
|
||||
.pgbouncer/
|
||||
.pgbouncer
|
||||
|
||||
# Large benchmark datasets (will be downloaded automatically)
|
||||
**/longmemeval_s_cleaned.json
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
[databases]
|
||||
; Connect to pg0 on port 5433
|
||||
; The actual pg0 database is called "hindsight"
|
||||
hindsight = host=127.0.0.1 port=5433 dbname=hindsight user=hindsight password=hindsight
|
||||
|
||||
[pgbouncer]
|
||||
listen_addr = 127.0.0.1
|
||||
listen_port = 6432
|
||||
|
||||
; Use md5 authentication (matches pg0's auth)
|
||||
auth_type = md5
|
||||
auth_file = /Users/nicoloboschi/dev/memory-poc/.pgbouncer/userlist.txt
|
||||
|
||||
; Transaction pooling mode (recommended for hindsight)
|
||||
pool_mode = transaction
|
||||
|
||||
; Reset connection state after each transaction
|
||||
server_reset_query = DISCARD ALL
|
||||
|
||||
; Pool sizing
|
||||
default_pool_size = 20
|
||||
max_client_conn = 200
|
||||
min_pool_size = 5
|
||||
|
||||
; Timeouts
|
||||
server_idle_timeout = 600
|
||||
server_lifetime = 3600
|
||||
query_timeout = 120
|
||||
|
||||
; Logging
|
||||
log_connections = 1
|
||||
log_disconnections = 1
|
||||
log_pooler_errors = 1
|
||||
|
||||
; Stats
|
||||
stats_period = 60
|
||||
|
||||
; Admin console
|
||||
admin_users = admin
|
||||
@@ -0,0 +1,2 @@
|
||||
"hindsight" "md5d842ccb6249bcd3c53b2f648378092a6"
|
||||
"admin" ""
|
||||
@@ -1,3 +1,153 @@
|
||||
# AGENTS.md
|
||||
|
||||
See [CLAUDE.md](./CLAUDE.md) for project documentation and coding conventions.
|
||||
This document captures architectural decisions and coding conventions for the Hindsight project.
|
||||
|
||||
## Documentation
|
||||
|
||||
- **Main documentation**: [hindsight-docs/docs/developer/](./hindsight-docs/docs/developer/)
|
||||
- **Use case patterns**: [hindsight-docs/docs/cookbook/](./hindsight-docs/docs/cookbook/)
|
||||
- **API reference**: Auto-generated from OpenAPI spec
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
hindsight/ # Python package for embedded usage
|
||||
hindsight-api/ # FastAPI server (core memory engine)
|
||||
hindsight-cli/ # Rust CLI client
|
||||
hindsight-embed/ # Embedded CLI (no server needed)
|
||||
hindsight-control-plane/ # Next.js admin UI
|
||||
hindsight-docs/ # Docusaurus documentation site
|
||||
hindsight-dev/ # Development tools and benchmarks
|
||||
hindsight-integrations/ # Framework integrations (LangChain, etc.)
|
||||
hindsight-clients/ # Generated API clients (Python, TypeScript, Rust)
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Memory Banks
|
||||
- Each bank is an isolated memory store (like a "brain" for one user/agent)
|
||||
- Banks contain: memory units (facts), entities, documents, entity links
|
||||
- Banks have a **disposition** (personality traits) and **background** (context)
|
||||
- Bank isolation is strict - no cross-bank data leakage
|
||||
|
||||
### Memory Types
|
||||
- **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)
|
||||
|
||||
### Operations
|
||||
- **Retain**: Store new memories (extracts facts, entities, relationships)
|
||||
- **Recall**: Retrieve memories (semantic, BM25, graph, temporal search)
|
||||
- **Reflect**: Deep analysis to form new insights/opinions
|
||||
|
||||
## API Design Decisions
|
||||
|
||||
### Single Bank Per Request
|
||||
- All API endpoints (`recall`, `reflect`, `retain`) operate on a single bank
|
||||
- Multi-bank queries are the **client/agent's responsibility** to orchestrate
|
||||
- This keeps the API simple and the isolation model clear
|
||||
|
||||
### Disposition Traits (3-trait system)
|
||||
- **Skepticism** (1-5): How skeptical vs trusting when forming opinions
|
||||
- **Literalism** (1-5): How literally to interpret information
|
||||
- **Empathy** (1-5): How much to consider emotional context
|
||||
- These influence the `reflect` operation, not `recall`
|
||||
- Background info also only affects `reflect` (opinion formation)
|
||||
|
||||
## Multi-Bank Architecture Patterns
|
||||
|
||||
See [hindsight-docs/docs/cookbook/](./hindsight-docs/docs/cookbook/) for detailed guides:
|
||||
|
||||
- **Per-User Memory**: One bank per user, simplest pattern
|
||||
- **Support Agent + Shared Knowledge**: User bank + shared docs bank, client orchestrates
|
||||
|
||||
## Developer Guide
|
||||
|
||||
### Running the API Server
|
||||
|
||||
```bash
|
||||
# From project root
|
||||
./scripts/dev/start-api.sh
|
||||
|
||||
# With options
|
||||
./scripts/dev/start-api.sh --reload --port 8888 --log-level debug
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# API tests
|
||||
cd hindsight-api
|
||||
uv run pytest tests/
|
||||
|
||||
# Specific test
|
||||
uv run pytest tests/test_http_api_integration.py -v
|
||||
```
|
||||
|
||||
### Generating OpenAPI Spec
|
||||
|
||||
After changing API endpoints, regenerate the OpenAPI spec and docs:
|
||||
|
||||
```bash
|
||||
./scripts/generate-openapi.sh
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Generate `openapi.json` at project root
|
||||
2. Copy to `hindsight-docs/openapi.json`
|
||||
3. Regenerate API reference documentation
|
||||
|
||||
### Generating API Clients
|
||||
|
||||
After updating the OpenAPI spec, regenerate all clients:
|
||||
|
||||
```bash
|
||||
./scripts/generate-clients.sh
|
||||
```
|
||||
|
||||
This generates:
|
||||
- **Rust client**: `hindsight-clients/rust/` (via progenitor in build.rs)
|
||||
- **Python client**: `hindsight-clients/python/` (via openapi-generator Docker)
|
||||
- **TypeScript client**: `hindsight-clients/typescript/` (via @hey-api/openapi-ts)
|
||||
|
||||
Note: The maintained wrapper `hindsight_client.py` and `README.md` are preserved during regeneration.
|
||||
|
||||
### Running the Documentation Site
|
||||
|
||||
```bash
|
||||
./scripts/dev/start-docs.sh
|
||||
```
|
||||
|
||||
### Running the Control Plane
|
||||
|
||||
```bash
|
||||
./scripts/dev/start-control-plane.sh
|
||||
```
|
||||
|
||||
## Code Style
|
||||
|
||||
### Python (hindsight-api)
|
||||
- Use `uv` for package management
|
||||
- Async throughout (asyncpg, async FastAPI endpoints)
|
||||
- Pydantic models for request/response validation
|
||||
- No py files at project root - maintain clean directory structure
|
||||
|
||||
### TypeScript (control-plane, clients)
|
||||
- Next.js with App Router for control plane
|
||||
- Tailwind CSS with shadcn/ui components
|
||||
|
||||
### Rust (CLI)
|
||||
- Async with tokio
|
||||
- reqwest for HTTP client
|
||||
- progenitor for API client generation
|
||||
|
||||
## Database
|
||||
|
||||
- PostgreSQL with pgvector extension
|
||||
- Schema managed via Alembic migrations in `hindsight-api/alembic/`, db migrations happen during api startup, no manual commands
|
||||
- Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links`
|
||||
|
||||
# Branding
|
||||
## Colors
|
||||
- Primary: gradient from #0074d9 to #009296
|
||||
|
||||
|
||||
@@ -199,38 +199,6 @@ When adding or modifying parameters in the dataplane API (hindsight-api), you mu
|
||||
- Pydantic models for request/response
|
||||
- Ruff for linting (line-length 120)
|
||||
- No Python files at project root - maintain clean directory structure
|
||||
- **Never use multi-item tuple return values** - prefer dataclass or Pydantic model for structured returns
|
||||
|
||||
### Type Safety with Pydantic Models
|
||||
**NEVER use raw `dict` types for structured data.** Always use Pydantic models:
|
||||
- Use Pydantic `BaseModel` for all data structures passed between functions
|
||||
- Add `@field_validator` for type coercion (e.g., ensuring datetimes are timezone-aware)
|
||||
- Avoid `dict.get()` patterns - use typed model attributes instead
|
||||
- Parse external data (JSON, API responses) into Pydantic models at the boundary
|
||||
- This catches type errors at parse time, not deep in business logic
|
||||
|
||||
```python
|
||||
# BAD - error-prone dict access
|
||||
def process(data: dict) -> str:
|
||||
return data.get("name", "") # No validation, silent failures
|
||||
|
||||
# GOOD - typed and validated
|
||||
class UserData(BaseModel):
|
||||
name: str
|
||||
created_at: datetime
|
||||
|
||||
@field_validator("created_at", mode="before")
|
||||
@classmethod
|
||||
def ensure_tz_aware(cls, v):
|
||||
if isinstance(v, str):
|
||||
v = datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||||
if v.tzinfo is None:
|
||||
return v.replace(tzinfo=timezone.utc)
|
||||
return v
|
||||
|
||||
def process(data: UserData) -> str:
|
||||
return data.name # Type-safe, validated at construction
|
||||
```
|
||||
|
||||
### TypeScript Style
|
||||
- Next.js App Router for control plane
|
||||
|
||||
@@ -80,22 +80,6 @@ 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,11 +55,6 @@ 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 }}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{{- 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 }}
|
||||
@@ -1,110 +0,0 @@
|
||||
{{- 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,63 +67,6 @@ 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,65 +244,6 @@ 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()
|
||||
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
"""mental_model_versions
|
||||
|
||||
Revision ID: j5e6f7g8h9i0
|
||||
Revises: i4d5e6f7g8h9
|
||||
Create Date: 2026-01-16 00:00:00.000000
|
||||
|
||||
This migration adds versioning support for mental models:
|
||||
1. Creates mental_model_versions table to store observation snapshots
|
||||
2. Adds version column to mental_models for tracking current version
|
||||
|
||||
This enables changelog/diff functionality for mental model observations.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "j5e6f7g8h9i0"
|
||||
down_revision: str | Sequence[str] | None = "i4d5e6f7g8h9"
|
||||
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 mental_model_versions table and add version tracking."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Create mental_model_versions table for storing observation snapshots
|
||||
op.execute(f"""
|
||||
CREATE TABLE {schema}mental_model_versions (
|
||||
id SERIAL PRIMARY KEY,
|
||||
mental_model_id VARCHAR(64) NOT NULL,
|
||||
bank_id VARCHAR(64) NOT NULL,
|
||||
version INT NOT NULL,
|
||||
observations JSONB NOT NULL DEFAULT '{{"observations": []}}'::jsonb,
|
||||
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
|
||||
FOREIGN KEY (mental_model_id, bank_id)
|
||||
REFERENCES {schema}mental_models(id, bank_id) ON DELETE CASCADE,
|
||||
UNIQUE (mental_model_id, bank_id, version)
|
||||
)
|
||||
""")
|
||||
|
||||
# Index for efficient version queries (get latest, list versions)
|
||||
op.execute(f"""
|
||||
CREATE INDEX idx_mental_model_versions_lookup
|
||||
ON {schema}mental_model_versions(mental_model_id, bank_id, version DESC)
|
||||
""")
|
||||
|
||||
# Add version column to mental_models to track current version
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD COLUMN IF NOT EXISTS version INT NOT NULL DEFAULT 0
|
||||
""")
|
||||
|
||||
# Migrate existing mental models: create version 1 for any that have observations
|
||||
op.execute(f"""
|
||||
INSERT INTO {schema}mental_model_versions (mental_model_id, bank_id, version, observations, created_at)
|
||||
SELECT id, bank_id, 1, observations, COALESCE(last_updated, created_at)
|
||||
FROM {schema}mental_models
|
||||
WHERE observations IS NOT NULL
|
||||
AND observations != '{{"observations": []}}'::jsonb
|
||||
AND (observations->'observations') IS NOT NULL
|
||||
AND jsonb_array_length(observations->'observations') > 0
|
||||
""")
|
||||
|
||||
# Update version to 1 for migrated mental models
|
||||
op.execute(f"""
|
||||
UPDATE {schema}mental_models
|
||||
SET version = 1
|
||||
WHERE observations IS NOT NULL
|
||||
AND observations != '{{"observations": []}}'::jsonb
|
||||
AND (observations->'observations') IS NOT NULL
|
||||
AND jsonb_array_length(observations->'observations') > 0
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove mental_model_versions table and version column."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Drop index
|
||||
op.execute(f"DROP INDEX IF EXISTS {schema}idx_mental_model_versions_lookup")
|
||||
|
||||
# Drop versions table
|
||||
op.execute(f"DROP TABLE IF EXISTS {schema}mental_model_versions")
|
||||
|
||||
# Remove version column from mental_models
|
||||
op.execute(f"ALTER TABLE {schema}mental_models DROP COLUMN IF EXISTS version")
|
||||
@@ -1,58 +0,0 @@
|
||||
"""add_directive_subtype
|
||||
|
||||
Revision ID: k6f7g8h9i0j1
|
||||
Revises: j5e6f7g8h9i0
|
||||
Create Date: 2026-01-16 00:00:00.000000
|
||||
|
||||
This migration adds 'directive' to the mental_models subtype constraint.
|
||||
Directives are hard rules with user-provided observations that the reflect agent must follow.
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import context, op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "k6f7g8h9i0j1"
|
||||
down_revision: str | Sequence[str] | None = "j5e6f7g8h9i0"
|
||||
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 'directive' to mental_models subtype constraint."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# Drop existing constraint
|
||||
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype")
|
||||
|
||||
# Create new constraint with 'directive' added
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD CONSTRAINT ck_mental_models_subtype
|
||||
CHECK (subtype IN ('structural', 'emergent', 'pinned', 'learned', 'directive'))
|
||||
""")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove 'directive' from mental_models subtype constraint."""
|
||||
schema = _get_schema_prefix()
|
||||
|
||||
# First delete any directives (cannot downgrade if they exist)
|
||||
op.execute(f"DELETE FROM {schema}mental_models WHERE subtype = 'directive'")
|
||||
|
||||
# Drop constraint with directive
|
||||
op.execute(f"ALTER TABLE {schema}mental_models DROP CONSTRAINT IF EXISTS ck_mental_models_subtype")
|
||||
|
||||
# Recreate original constraint without directive
|
||||
op.execute(f"""
|
||||
ALTER TABLE {schema}mental_models
|
||||
ADD CONSTRAINT ck_mental_models_subtype
|
||||
CHECK (subtype IN ('structural', 'emergent', 'pinned', 'learned'))
|
||||
""")
|
||||
@@ -1,109 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -36,7 +36,6 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
from hindsight_api import MemoryEngine
|
||||
from hindsight_api.engine.db_utils import acquire_with_retry
|
||||
from hindsight_api.engine.memory_engine import Budget, fq_table
|
||||
from hindsight_api.engine.reflect.observations import Observation
|
||||
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES, TokenUsage
|
||||
from hindsight_api.engine.search.tags import TagsMatch
|
||||
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
|
||||
@@ -560,10 +559,9 @@ class ReflectMentalModel(BaseModel):
|
||||
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, directive")
|
||||
observations: list[str] | None = Field(
|
||||
default=None, description="Observations for directive mental models (subtype='directive')"
|
||||
)
|
||||
subtype: str = Field(description="Mental model subtype: structural, emergent, learned")
|
||||
description: str = Field(description="Brief description")
|
||||
summary: str | None = Field(default=None, description="Full summary (when looked up in detail)")
|
||||
|
||||
|
||||
class ReflectBasedOn(BaseModel):
|
||||
@@ -580,10 +578,6 @@ class ReflectTrace(BaseModel):
|
||||
|
||||
tool_calls: list[ReflectToolCall] = Field(default_factory=list, description="Tool calls made during reflection")
|
||||
llm_calls: list[ReflectLLMCall] = Field(default_factory=list, description="LLM calls made during reflection")
|
||||
mental_models: list[ReflectMentalModel] = Field(
|
||||
default_factory=list,
|
||||
description="Mental models used during reflection (includes directives with subtype='directive')",
|
||||
)
|
||||
|
||||
|
||||
class CreatedMentalModel(BaseModel):
|
||||
@@ -1051,40 +1045,12 @@ class BankStatsResponse(BaseModel):
|
||||
# Mental Model models
|
||||
|
||||
|
||||
class ObservationEvidenceResponse(BaseModel):
|
||||
"""A single piece of evidence supporting an observation."""
|
||||
|
||||
memory_id: str = Field(description="ID of the memory unit this evidence comes from")
|
||||
quote: str = Field(description="Exact quote from the memory supporting the observation")
|
||||
relevance: str = Field(description="Brief explanation of how this quote supports the observation")
|
||||
timestamp: str = Field(description="When the source memory was created (ISO format)")
|
||||
|
||||
|
||||
class MentalModelObservationResponse(BaseModel):
|
||||
"""An observation within a mental model with its supporting evidence."""
|
||||
"""An observation within a mental model with its supporting memories."""
|
||||
|
||||
title: str = Field(description="Short summary title for the observation")
|
||||
content: str = Field(description="The observation content - detailed explanation")
|
||||
evidence: list[ObservationEvidenceResponse] = Field(
|
||||
default_factory=list, description="Supporting evidence with quotes"
|
||||
)
|
||||
created_at: str = Field(description="When this observation was first created (ISO format)")
|
||||
trend: str = Field(description="Computed trend: stable, strengthening, weakening, new, stale")
|
||||
evidence_count: int = Field(description="Number of evidence items supporting this observation")
|
||||
evidence_span: dict = Field(description="Time span of evidence: {from: iso_date, to: iso_date}")
|
||||
|
||||
|
||||
class MentalModelFreshnessResponse(BaseModel):
|
||||
"""Freshness information for a mental model."""
|
||||
|
||||
is_up_to_date: bool = Field(description="Whether the model has been refreshed since the last memory was added")
|
||||
last_refresh_at: str | None = Field(description="When the model was last refreshed (ISO format)")
|
||||
memories_since_refresh: int = Field(description="Number of memories added since last refresh")
|
||||
reasons: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Reasons why the model needs refresh (empty if up to date). "
|
||||
"Possible values: never_refreshed, new_memories, mission_changed, disposition_changed, directives_changed",
|
||||
)
|
||||
title: str = Field(description="Observation header (empty for intro)")
|
||||
text: str = Field(description="Observation content")
|
||||
based_on: list[str] = Field(default_factory=list, description="Memory IDs supporting this observation")
|
||||
|
||||
|
||||
class MentalModelResponse(BaseModel):
|
||||
@@ -1098,36 +1064,11 @@ class MentalModelResponse(BaseModel):
|
||||
"subtype": "structural",
|
||||
"name": "Team Structure",
|
||||
"description": "Who's on the team and their roles",
|
||||
"observations": [
|
||||
{
|
||||
"title": "Prefers async communication",
|
||||
"content": "The team prefers async communication over synchronous meetings",
|
||||
"evidence": [
|
||||
{
|
||||
"memory_id": "uuid1",
|
||||
"quote": "I prefer Slack over meetings",
|
||||
"relevance": "Shows async preference",
|
||||
"timestamp": "2024-01-10T08:00:00Z",
|
||||
}
|
||||
],
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"trend": "stable",
|
||||
"evidence_count": 1,
|
||||
"evidence_span": {"from": "2024-01-10T08:00:00Z", "to": "2024-01-10T08:00:00Z"},
|
||||
}
|
||||
],
|
||||
"version": 1,
|
||||
"observations": [{"title": "Overview", "text": "The team consists of...", "based_on": ["uuid1"]}],
|
||||
"entity_id": None,
|
||||
"links": [],
|
||||
"tags": ["project-x"],
|
||||
"last_updated": "2024-01-15T10:30:00Z",
|
||||
"last_refresh_at": "2024-01-15T10:30:00Z",
|
||||
"freshness": {
|
||||
"is_up_to_date": True,
|
||||
"last_refresh_at": "2024-01-15T10:30:00Z",
|
||||
"memories_since_refresh": 0,
|
||||
"reasons": [],
|
||||
},
|
||||
"created_at": "2024-01-10T08:00:00Z",
|
||||
}
|
||||
}
|
||||
@@ -1141,15 +1082,10 @@ class MentalModelResponse(BaseModel):
|
||||
observations: list[MentalModelObservationResponse] = Field(
|
||||
default_factory=list, description="Structured observations with per-observation fact attribution"
|
||||
)
|
||||
version: int = Field(default=0, description="Version number of the mental model observations")
|
||||
entity_id: str | None = None
|
||||
links: list[str] = []
|
||||
tags: list[str] = []
|
||||
last_updated: str | None = None
|
||||
last_refresh_at: str | None = Field(default=None, description="When observations were last refreshed (ISO format)")
|
||||
freshness: MentalModelFreshnessResponse | None = Field(
|
||||
default=None, description="Freshness info (null for directive subtypes which don't need refresh)"
|
||||
)
|
||||
created_at: str
|
||||
|
||||
|
||||
@@ -1159,39 +1095,6 @@ class MentalModelListResponse(BaseModel):
|
||||
items: list[MentalModelResponse]
|
||||
|
||||
|
||||
def _observation_to_response(obs: Observation) -> MentalModelObservationResponse:
|
||||
"""Convert internal Observation model to API response model."""
|
||||
return MentalModelObservationResponse(
|
||||
title=obs.title,
|
||||
content=obs.content,
|
||||
evidence=[
|
||||
ObservationEvidenceResponse(
|
||||
memory_id=ev.memory_id,
|
||||
quote=ev.quote,
|
||||
relevance=ev.relevance,
|
||||
timestamp=ev.timestamp.isoformat(),
|
||||
)
|
||||
for ev in obs.evidence
|
||||
],
|
||||
created_at=obs.created_at.isoformat(),
|
||||
trend=obs.trend.value,
|
||||
evidence_count=obs.evidence_count,
|
||||
evidence_span=obs.evidence_span,
|
||||
)
|
||||
|
||||
|
||||
def _prepare_mental_model_response(model: dict[str, Any]) -> MentalModelResponse:
|
||||
"""Convert internal mental model dict to API response model.
|
||||
|
||||
Handles conversion of Observation models to MentalModelObservationResponse.
|
||||
"""
|
||||
observations = model.get("observations", [])
|
||||
converted_observations = [
|
||||
_observation_to_response(obs) if isinstance(obs, Observation) else obs for obs in observations
|
||||
]
|
||||
return MentalModelResponse(**{**model, "observations": converted_observations})
|
||||
|
||||
|
||||
class RefreshMentalModelsRequest(BaseModel):
|
||||
"""Request model for refresh mental models endpoint."""
|
||||
|
||||
@@ -1204,63 +1107,24 @@ class RefreshMentalModelsRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ObservationInput(BaseModel):
|
||||
"""Input model for a single observation."""
|
||||
|
||||
title: str = Field(description="Short title/header for the observation")
|
||||
content: str = Field(description="Content of the observation")
|
||||
|
||||
|
||||
class CreateMentalModelRequest(BaseModel):
|
||||
"""Request model for creating a mental model."""
|
||||
"""Request model for creating a pinned mental model."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"examples": [
|
||||
{
|
||||
"name": "Product Roadmap",
|
||||
"description": "Key product priorities and upcoming features",
|
||||
"tags": ["project-x"],
|
||||
},
|
||||
{
|
||||
"name": "Meeting Rules",
|
||||
"description": "Rules about scheduling meetings",
|
||||
"subtype": "directive",
|
||||
"observations": [{"title": "Morning meetings", "content": "Never schedule meetings before 10am"}],
|
||||
},
|
||||
]
|
||||
"example": {
|
||||
"name": "Product Roadmap",
|
||||
"description": "Key product priorities and upcoming features",
|
||||
"tags": ["project-x"],
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
name: str = Field(description="Human-readable name for the mental model")
|
||||
description: str = Field(description="One-liner description for quick scanning")
|
||||
subtype: str = Field(
|
||||
default="pinned",
|
||||
description="Type of mental model: 'pinned' (observations LLM-generated) or 'directive' (observations user-provided)",
|
||||
)
|
||||
observations: list[ObservationInput] | None = Field(
|
||||
default=None,
|
||||
description="For directives only: list of user-provided observations. Required when subtype='directive'.",
|
||||
)
|
||||
tags: list[str] = Field(default_factory=list, description="Tags for scoped visibility")
|
||||
|
||||
|
||||
class UpdateMentalModelRequest(BaseModel):
|
||||
"""Request model for updating a mental model."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"name": "Updated Name",
|
||||
"description": "Updated description with new rules",
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
name: str | None = Field(default=None, description="New name for the mental model")
|
||||
description: str | None = Field(default=None, description="New description/rule text")
|
||||
|
||||
|
||||
class OperationResponse(BaseModel):
|
||||
"""Response model for a single async operation."""
|
||||
|
||||
@@ -1408,16 +1272,6 @@ def create_app(
|
||||
Lifespan context manager for startup and shutdown events.
|
||||
Note: This only fires when running the app standalone, not when mounted.
|
||||
"""
|
||||
import asyncio
|
||||
import socket
|
||||
|
||||
from hindsight_api.config import get_config
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
config = get_config()
|
||||
poller = None
|
||||
poller_task = None
|
||||
|
||||
# Initialize OpenTelemetry metrics
|
||||
try:
|
||||
prometheus_reader = initialize_metrics(service_name="hindsight-api", service_version="1.0.0")
|
||||
@@ -1440,20 +1294,6 @@ def create_app(
|
||||
metrics_collector.set_db_pool(memory._pool)
|
||||
logging.info("DB pool metrics configured")
|
||||
|
||||
# Start worker poller if enabled (standalone mode)
|
||||
if config.worker_enabled and memory._pool is not None:
|
||||
worker_id = config.worker_id or socket.gethostname()
|
||||
poller = WorkerPoller(
|
||||
pool=memory._pool,
|
||||
worker_id=worker_id,
|
||||
executor=memory.execute_task,
|
||||
poll_interval_ms=config.worker_poll_interval_ms,
|
||||
batch_size=config.worker_batch_size,
|
||||
max_retries=config.worker_max_retries,
|
||||
)
|
||||
poller_task = asyncio.create_task(poller.run())
|
||||
logging.info(f"Worker poller started (worker_id={worker_id})")
|
||||
|
||||
# Call HTTP extension startup hook
|
||||
if http_extension:
|
||||
await http_extension.on_startup()
|
||||
@@ -1461,17 +1301,6 @@ def create_app(
|
||||
|
||||
yield
|
||||
|
||||
# Shutdown worker poller if running
|
||||
if poller is not None:
|
||||
await poller.shutdown_graceful(timeout=30.0)
|
||||
if poller_task is not None:
|
||||
poller_task.cancel()
|
||||
try:
|
||||
await poller_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logging.info("Worker poller stopped")
|
||||
|
||||
# Call HTTP extension shutdown hook
|
||||
if http_extension:
|
||||
await http_extension.on_shutdown()
|
||||
@@ -1913,12 +1742,14 @@ def _register_routes(app: FastAPI):
|
||||
name=mm.name,
|
||||
type=mm.type,
|
||||
subtype=mm.subtype,
|
||||
description=mm.description,
|
||||
summary=mm.summary,
|
||||
)
|
||||
for mm in core_result.mental_models
|
||||
]
|
||||
based_on_result = ReflectBasedOn(memories=memories, mental_models=mental_models)
|
||||
|
||||
# Build trace (tool_calls + llm_calls + mental_models) if tool_calls is requested
|
||||
# Build trace (tool_calls + llm_calls) if tool_calls is requested
|
||||
trace_result: ReflectTrace | None = None
|
||||
if request.include.tool_calls is not None:
|
||||
include_output = request.include.tool_calls.output
|
||||
@@ -1933,24 +1764,7 @@ def _register_routes(app: FastAPI):
|
||||
for tc in core_result.tool_trace
|
||||
]
|
||||
llm_calls = [ReflectLLMCall(scope=lc.scope, duration_ms=lc.duration_ms) for lc in core_result.llm_trace]
|
||||
# Build map of directive observations by id
|
||||
directive_observations = {d.id: d.rules for d in core_result.directives_applied}
|
||||
# Include all mental models (including directives with subtype='directive')
|
||||
trace_mental_models = [
|
||||
ReflectMentalModel(
|
||||
id=mm.id,
|
||||
name=mm.name,
|
||||
type=mm.type,
|
||||
subtype=mm.subtype,
|
||||
observations=directive_observations.get(mm.id) if mm.subtype == "directive" else None,
|
||||
)
|
||||
for mm in core_result.mental_models
|
||||
]
|
||||
trace_result = ReflectTrace(
|
||||
tool_calls=tool_calls,
|
||||
llm_calls=llm_calls,
|
||||
mental_models=trace_mental_models,
|
||||
)
|
||||
trace_result = ReflectTrace(tool_calls=tool_calls, llm_calls=llm_calls)
|
||||
|
||||
# Build mental_models_created from tool trace (learn tool outputs)
|
||||
created_models: list[CreatedMentalModel] = []
|
||||
@@ -2262,46 +2076,7 @@ def _register_routes(app: FastAPI):
|
||||
tags_match=tags_match,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Add freshness to each model (skip for directives)
|
||||
# Get data needed for freshness computation (once for all models)
|
||||
from hindsight_api.engine.reflect.mental_model_reflect import (
|
||||
BankProfile,
|
||||
DirectiveMentalModel,
|
||||
check_needs_refresh,
|
||||
)
|
||||
|
||||
total_memories = await app.state.memory._count_memories_since(bank_id, None)
|
||||
bank_profile_dict = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
# Convert to typed models at the boundary
|
||||
bank_profile = BankProfile.model_validate(bank_profile_dict)
|
||||
directives = [DirectiveMentalModel.model_validate(m) for m in models if m.get("subtype") == "directive"]
|
||||
|
||||
for model in models:
|
||||
if model.get("subtype") != "directive":
|
||||
last_refresh_at = model.get("last_refresh_at")
|
||||
memories_since = await app.state.memory._count_memories_since(bank_id, last_refresh_at)
|
||||
|
||||
# Use check_needs_refresh to get reasons
|
||||
stored_refresh_state = model.get("refresh_state")
|
||||
refresh_check = check_needs_refresh(
|
||||
stored_state=stored_refresh_state,
|
||||
current_memories_count=total_memories,
|
||||
bank_profile=bank_profile,
|
||||
directives=directives,
|
||||
)
|
||||
|
||||
model["freshness"] = {
|
||||
"is_up_to_date": not refresh_check.needs_refresh,
|
||||
"last_refresh_at": last_refresh_at,
|
||||
"memories_since_refresh": memories_since,
|
||||
"reasons": refresh_check.reasons,
|
||||
}
|
||||
else:
|
||||
model["freshness"] = None
|
||||
|
||||
return MentalModelListResponse(items=[_prepare_mental_model_response(m) for m in models])
|
||||
return MentalModelListResponse(items=[MentalModelResponse(**m) for m in models])
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -2315,11 +2090,7 @@ def _register_routes(app: FastAPI):
|
||||
"/v1/default/banks/{bank_id}/mental-models",
|
||||
response_model=MentalModelResponse,
|
||||
summary="Create mental model",
|
||||
description=(
|
||||
"Create a mental model. Supports two subtypes:\n"
|
||||
"- 'pinned' (default): User-defined topic, observations are LLM-generated on refresh\n"
|
||||
"- 'directive': User-defined hard rules, observations are provided at creation and never regenerated"
|
||||
),
|
||||
description="Create a pinned mental model. Pinned models are user-defined and persist across refreshes.",
|
||||
operation_id="create_mental_model",
|
||||
tags=["Mental Models"],
|
||||
)
|
||||
@@ -2328,23 +2099,16 @@ def _register_routes(app: FastAPI):
|
||||
body: CreateMentalModelRequest,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Create a mental model (pinned or directive)."""
|
||||
"""Create a pinned mental model."""
|
||||
try:
|
||||
# Convert observations to list of dicts if provided
|
||||
observations_list = None
|
||||
if body.observations:
|
||||
observations_list = [{"title": obs.title, "content": obs.content} for obs in body.observations]
|
||||
|
||||
model = await app.state.memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
subtype=body.subtype,
|
||||
observations=observations_list,
|
||||
tags=body.tags,
|
||||
request_context=request_context,
|
||||
)
|
||||
return _prepare_mental_model_response(model)
|
||||
return MentalModelResponse(**model)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except (AuthenticationError, HTTPException):
|
||||
@@ -2378,47 +2142,7 @@ def _register_routes(app: FastAPI):
|
||||
)
|
||||
if model is None:
|
||||
raise HTTPException(status_code=404, detail=f"Mental model '{model_id}' not found")
|
||||
|
||||
# Compute freshness for non-directive models
|
||||
if model.get("subtype") != "directive":
|
||||
from hindsight_api.engine.reflect.mental_model_reflect import (
|
||||
BankProfile,
|
||||
DirectiveMentalModel,
|
||||
check_needs_refresh,
|
||||
)
|
||||
|
||||
last_refresh_at = model.get("last_refresh_at")
|
||||
total_memories = await app.state.memory._count_memories_since(bank_id, None)
|
||||
memories_since = await app.state.memory._count_memories_since(bank_id, last_refresh_at)
|
||||
bank_profile_dict = await app.state.memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
directives_dicts = await app.state.memory.list_mental_models(
|
||||
bank_id, subtype="directive", request_context=request_context
|
||||
)
|
||||
|
||||
# Convert to typed models at the boundary
|
||||
bank_profile = BankProfile.model_validate(bank_profile_dict)
|
||||
directives = [DirectiveMentalModel.model_validate(d) for d in directives_dicts]
|
||||
|
||||
# Use check_needs_refresh to get reasons
|
||||
stored_refresh_state = model.get("refresh_state")
|
||||
refresh_check = check_needs_refresh(
|
||||
stored_state=stored_refresh_state,
|
||||
current_memories_count=total_memories,
|
||||
bank_profile=bank_profile,
|
||||
directives=directives,
|
||||
)
|
||||
|
||||
model["freshness"] = {
|
||||
"is_up_to_date": not refresh_check.needs_refresh,
|
||||
"last_refresh_at": last_refresh_at,
|
||||
"memories_since_refresh": memories_since,
|
||||
"reasons": refresh_check.reasons,
|
||||
}
|
||||
else:
|
||||
# Directives don't need freshness - they're static
|
||||
model["freshness"] = None
|
||||
|
||||
return _prepare_mental_model_response(model)
|
||||
return MentalModelResponse(**model)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
@@ -2503,61 +2227,23 @@ def _register_routes(app: FastAPI):
|
||||
logger.error(f"Error in DELETE /v1/default/banks/{bank_id}/mental-models/{model_id}: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.patch(
|
||||
"/v1/default/banks/{bank_id}/mental-models/{model_id}",
|
||||
response_model=MentalModelResponse,
|
||||
summary="Update mental model",
|
||||
description="Update a mental model's name and/or description. Useful for editing directives.",
|
||||
operation_id="update_mental_model",
|
||||
tags=["Mental Models"],
|
||||
)
|
||||
async def api_update_mental_model(
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
body: UpdateMentalModelRequest,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Update a mental model's name and/or description."""
|
||||
try:
|
||||
if body.name is None and body.description is None:
|
||||
raise HTTPException(status_code=400, detail="At least one of 'name' or 'description' must be provided")
|
||||
|
||||
updated = await app.state.memory.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
name=body.name,
|
||||
description=body.description,
|
||||
request_context=request_context,
|
||||
)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=404, detail=f"Mental model '{model_id}' not found")
|
||||
return _prepare_mental_model_response(updated)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in PATCH /v1/default/banks/{bank_id}/mental-models/{model_id}: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.post(
|
||||
"/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh",
|
||||
"/v1/default/banks/{bank_id}/mental-models/{model_id}/generate",
|
||||
response_model=AsyncOperationSubmitResponse,
|
||||
summary="Refresh mental model content (async)",
|
||||
description="Submit a background job to refresh content for a specific mental model. "
|
||||
"This is useful for newly created learned models or to refresh content for any model.",
|
||||
operation_id="refresh_mental_model",
|
||||
summary="Generate mental model content (async)",
|
||||
description="Submit a background job to generate/refresh content for a specific mental model. "
|
||||
"This is useful for newly created learned models or to regenerate content for any model.",
|
||||
operation_id="generate_mental_model",
|
||||
tags=["Mental Models"],
|
||||
)
|
||||
async def api_refresh_mental_model(
|
||||
async def api_generate_mental_model(
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Refresh content for a specific mental model."""
|
||||
"""Generate content for a specific mental model."""
|
||||
try:
|
||||
result = await app.state.memory.refresh_mental_model_async(
|
||||
result = await app.state.memory.generate_mental_model_async(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
request_context=request_context,
|
||||
@@ -2574,74 +2260,7 @@ def _register_routes(app: FastAPI):
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models/{model_id}/refresh: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get(
|
||||
"/v1/default/banks/{bank_id}/mental-models/{model_id}/versions",
|
||||
summary="List mental model version history",
|
||||
description="List all saved versions of a mental model's observations, ordered by version descending.",
|
||||
operation_id="list_mental_model_versions",
|
||||
tags=["Mental Models"],
|
||||
)
|
||||
async def api_list_mental_model_versions(
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""List version history for a mental model."""
|
||||
try:
|
||||
versions = await app.state.memory.get_mental_model_versions(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
return {"versions": versions}
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(f"Error in GET /v1/default/banks/{bank_id}/mental-models/{model_id}/versions: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get(
|
||||
"/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}",
|
||||
summary="Get specific mental model version",
|
||||
description="Get observations from a specific version of a mental model.",
|
||||
operation_id="get_mental_model_version",
|
||||
tags=["Mental Models"],
|
||||
)
|
||||
async def api_get_mental_model_version(
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
version: int,
|
||||
request_context: RequestContext = Depends(get_request_context),
|
||||
):
|
||||
"""Get a specific version of a mental model."""
|
||||
try:
|
||||
version_data = await app.state.memory.get_mental_model_version(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
version=version,
|
||||
request_context=request_context,
|
||||
)
|
||||
if not version_data:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Version {version} not found for mental model '{model_id}'",
|
||||
)
|
||||
return version_data
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
error_detail = f"{str(e)}\n\nTraceback:\n{traceback.format_exc()}"
|
||||
logger.error(
|
||||
f"Error in GET /v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}: {error_detail}"
|
||||
)
|
||||
logger.error(f"Error in POST /v1/default/banks/{bank_id}/mental-models/{model_id}/generate: {error_detail}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@app.get(
|
||||
|
||||
@@ -4,12 +4,9 @@ Centralized configuration for Hindsight API.
|
||||
All environment variables and their defaults are defined here.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from dotenv import find_dotenv, load_dotenv
|
||||
|
||||
@@ -71,7 +68,6 @@ ENV_RERANKER_FLASHRANK_CACHE_DIR = "HINDSIGHT_API_RERANKER_FLASHRANK_CACHE_DIR"
|
||||
ENV_HOST = "HINDSIGHT_API_HOST"
|
||||
ENV_PORT = "HINDSIGHT_API_PORT"
|
||||
ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
|
||||
ENV_LOG_FORMAT = "HINDSIGHT_API_LOG_FORMAT"
|
||||
ENV_WORKERS = "HINDSIGHT_API_WORKERS"
|
||||
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
|
||||
ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
|
||||
@@ -106,13 +102,10 @@ 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"
|
||||
|
||||
# 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"
|
||||
# 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"
|
||||
|
||||
# Reflect agent settings
|
||||
ENV_REFLECT_MAX_ITERATIONS = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS"
|
||||
@@ -149,7 +142,6 @@ DEFAULT_RERANKER_LITELLM_MODEL = "cohere/rerank-english-v3.0"
|
||||
DEFAULT_HOST = "0.0.0.0"
|
||||
DEFAULT_PORT = 8888
|
||||
DEFAULT_LOG_LEVEL = "info"
|
||||
DEFAULT_LOG_FORMAT = "text" # Options: "text", "json"
|
||||
DEFAULT_WORKERS = 1
|
||||
DEFAULT_MCP_ENABLED = True
|
||||
DEFAULT_GRAPH_RETRIEVER = "link_expansion" # Options: "link_expansion", "mpfp", "bfs"
|
||||
@@ -180,13 +172,10 @@ DEFAULT_DB_POOL_MAX_SIZE = 100
|
||||
DEFAULT_DB_COMMAND_TIMEOUT = 60 # seconds
|
||||
DEFAULT_DB_ACQUIRE_TIMEOUT = 30 # 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
|
||||
# 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
|
||||
|
||||
# Reflect agent settings
|
||||
DEFAULT_REFLECT_MAX_ITERATIONS = 10 # Max tool call iterations before forcing response
|
||||
@@ -215,36 +204,6 @@ Use this tool PROACTIVELY to:
|
||||
EMBEDDING_DIMENSION = DEFAULT_EMBEDDING_DIMENSION
|
||||
|
||||
|
||||
class JsonFormatter(logging.Formatter):
|
||||
"""JSON formatter for structured logging.
|
||||
|
||||
Outputs logs in JSON format with a 'severity' field that cloud logging
|
||||
systems (GCP, AWS CloudWatch, etc.) can parse to correctly categorize log levels.
|
||||
"""
|
||||
|
||||
SEVERITY_MAP = {
|
||||
logging.DEBUG: "DEBUG",
|
||||
logging.INFO: "INFO",
|
||||
logging.WARNING: "WARNING",
|
||||
logging.ERROR: "ERROR",
|
||||
logging.CRITICAL: "CRITICAL",
|
||||
}
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
log_entry = {
|
||||
"severity": self.SEVERITY_MAP.get(record.levelno, "DEFAULT"),
|
||||
"message": record.getMessage(),
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"logger": record.name,
|
||||
}
|
||||
|
||||
# Add exception info if present
|
||||
if record.exc_info:
|
||||
log_entry["exception"] = self.formatException(record.exc_info)
|
||||
|
||||
return json.dumps(log_entry)
|
||||
|
||||
|
||||
def _validate_extraction_mode(mode: str) -> str:
|
||||
"""Validate and normalize extraction mode."""
|
||||
mode_lower = mode.lower()
|
||||
@@ -303,7 +262,6 @@ class HindsightConfig:
|
||||
host: str
|
||||
port: int
|
||||
log_level: str
|
||||
log_format: str
|
||||
mcp_enabled: bool
|
||||
|
||||
# Recall
|
||||
@@ -337,13 +295,10 @@ class HindsightConfig:
|
||||
db_command_timeout: int
|
||||
db_acquire_timeout: int
|
||||
|
||||
# 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
|
||||
# Background task processing
|
||||
task_backend: str
|
||||
task_backend_memory_batch_size: int
|
||||
task_backend_memory_batch_interval: float
|
||||
|
||||
# Reflect agent settings
|
||||
reflect_max_iterations: int
|
||||
@@ -390,7 +345,6 @@ class HindsightConfig:
|
||||
host=os.getenv(ENV_HOST, DEFAULT_HOST),
|
||||
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
|
||||
log_level=os.getenv(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL),
|
||||
log_format=os.getenv(ENV_LOG_FORMAT, DEFAULT_LOG_FORMAT).lower(),
|
||||
mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true",
|
||||
# Recall
|
||||
graph_retriever=os.getenv(ENV_GRAPH_RETRIEVER, DEFAULT_GRAPH_RETRIEVER),
|
||||
@@ -433,13 +387,14 @@ 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))),
|
||||
# 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))),
|
||||
# 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))
|
||||
),
|
||||
# Reflect agent settings
|
||||
reflect_max_iterations=int(os.getenv(ENV_REFLECT_MAX_ITERATIONS, str(DEFAULT_REFLECT_MAX_ITERATIONS))),
|
||||
)
|
||||
@@ -472,28 +427,12 @@ class HindsightConfig:
|
||||
return log_level_map.get(self.log_level.lower(), logging.INFO)
|
||||
|
||||
def configure_logging(self) -> None:
|
||||
"""Configure Python logging based on the log level and format.
|
||||
|
||||
When log_format is "json", outputs structured JSON logs with a severity
|
||||
field that GCP Cloud Logging can parse for proper log level categorization.
|
||||
"""
|
||||
root_logger = logging.getLogger()
|
||||
root_logger.setLevel(self.get_python_log_level())
|
||||
|
||||
# Remove existing handlers
|
||||
for handler in root_logger.handlers[:]:
|
||||
root_logger.removeHandler(handler)
|
||||
|
||||
# Create handler writing to stdout (GCP treats stderr as ERROR)
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.setLevel(self.get_python_log_level())
|
||||
|
||||
if self.log_format == "json":
|
||||
handler.setFormatter(JsonFormatter())
|
||||
else:
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(name)s - %(message)s"))
|
||||
|
||||
root_logger.addHandler(handler)
|
||||
"""Configure Python logging based on the log level."""
|
||||
logging.basicConfig(
|
||||
level=self.get_python_log_level(),
|
||||
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s",
|
||||
force=True, # Override any existing configuration
|
||||
)
|
||||
|
||||
def log_config(self) -> None:
|
||||
"""Log the current configuration (without sensitive values)."""
|
||||
|
||||
@@ -136,12 +136,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
# 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}")
|
||||
# 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.
|
||||
self._model = CrossEncoder(
|
||||
self.model_name,
|
||||
model_kwargs={"low_cpu_mem_usage": False, "device_map": None},
|
||||
)
|
||||
self._model = CrossEncoder(self.model_name)
|
||||
|
||||
# Initialize shared executor (limited workers naturally limits concurrency)
|
||||
if LocalSTCrossEncoder._executor is None:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,8 +14,7 @@ class MentalModelSubtype(str, Enum):
|
||||
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
|
||||
PINNED = "pinned" # User-defined, persists across refreshes
|
||||
|
||||
|
||||
class MentalModel(BaseModel):
|
||||
|
||||
@@ -6,37 +6,12 @@ import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, Any, Awaitable, Callable, Literal
|
||||
|
||||
from .models import DirectiveInfo, LLMCall, MentalModelInput, ReflectAgentResult, ToolCall
|
||||
from .prompts import FINAL_SYSTEM_PROMPT, _extract_directive_rules, build_final_prompt, build_system_prompt_for_tools
|
||||
from .models import LLMCall, MentalModelInput, Observation, ReflectAgentResult, ToolCall
|
||||
from .prompts import FINAL_SYSTEM_PROMPT, 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."""
|
||||
if not directives:
|
||||
return []
|
||||
|
||||
result = []
|
||||
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"])
|
||||
|
||||
result.append(DirectiveInfo(id=directive_id, name=directive_name, rules=rules))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..llm_wrapper import LLMProvider
|
||||
from ..response_models import LLMToolCall
|
||||
@@ -161,7 +136,7 @@ async def run_reflect_agent(
|
||||
max_iterations: int = DEFAULT_MAX_ITERATIONS,
|
||||
max_tokens: int | None = None,
|
||||
response_schema: dict | None = None,
|
||||
directives: list[dict[str, Any]] | None = None,
|
||||
output_mode: Literal["answer", "observations"] = "answer",
|
||||
) -> ReflectAgentResult:
|
||||
"""
|
||||
Execute the reflect agent loop using native tool calling.
|
||||
@@ -183,7 +158,7 @@ async def run_reflect_agent(
|
||||
max_iterations: Maximum number of iterations before forcing response
|
||||
max_tokens: Maximum tokens for the final response
|
||||
response_schema: Optional JSON Schema for structured output in final response
|
||||
directives: Optional list of directive mental models to inject as hard rules
|
||||
output_mode: "answer" returns final text, "observations" returns structured observations
|
||||
|
||||
Returns:
|
||||
ReflectAgentResult with final answer and metadata
|
||||
@@ -192,17 +167,11 @@ async def run_reflect_agent(
|
||||
reflect_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}"
|
||||
start_time = time.time()
|
||||
|
||||
# Build directives_applied for the trace
|
||||
directives_applied = _build_directives_applied(directives)
|
||||
# Get tools for this agent
|
||||
tools = get_reflect_tools(enable_learn=enable_learn, output_mode=output_mode)
|
||||
|
||||
# Extract directive rules for tool schema (if any)
|
||||
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)
|
||||
|
||||
# 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)
|
||||
# Build initial messages
|
||||
system_prompt = build_system_prompt_for_tools(bank_profile, context, output_mode=output_mode)
|
||||
messages: list[dict[str, Any]] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": query},
|
||||
@@ -220,43 +189,44 @@ async def run_reflect_agent(
|
||||
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)
|
||||
# In answer mode, pre-fetch mental models so the agent always starts with this knowledge
|
||||
if output_mode == "answer":
|
||||
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"])
|
||||
# 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 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,
|
||||
# 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
|
||||
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```"
|
||||
# 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```"
|
||||
|
||||
def _get_llm_trace() -> list[LLMCall]:
|
||||
return [LLMCall(scope=c["scope"], duration_ms=c["duration_ms"]) for c in llm_trace]
|
||||
@@ -318,7 +288,6 @@ async def run_reflect_agent(
|
||||
mental_models_created=mental_models_created,
|
||||
tool_trace=tool_trace,
|
||||
llm_trace=_get_llm_trace(),
|
||||
directives_applied=directives_applied,
|
||||
)
|
||||
|
||||
# Call LLM with tools
|
||||
@@ -369,7 +338,6 @@ async def run_reflect_agent(
|
||||
mental_models_created=mental_models_created,
|
||||
tool_trace=tool_trace,
|
||||
llm_trace=_get_llm_trace(),
|
||||
directives_applied=directives_applied,
|
||||
)
|
||||
|
||||
# No tool calls - LLM wants to respond with text
|
||||
@@ -393,7 +361,6 @@ async def run_reflect_agent(
|
||||
mental_models_created=mental_models_created,
|
||||
tool_trace=tool_trace,
|
||||
llm_trace=_get_llm_trace(),
|
||||
directives_applied=directives_applied,
|
||||
)
|
||||
# Empty response, force final
|
||||
prompt = build_final_prompt(query, context_history, bank_profile, context)
|
||||
@@ -423,11 +390,10 @@ async def run_reflect_agent(
|
||||
mental_models_created=mental_models_created,
|
||||
tool_trace=tool_trace,
|
||||
llm_trace=_get_llm_trace(),
|
||||
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
|
||||
done_call = next((tc for tc in result.tool_calls if tc.name == "done"), None)
|
||||
if done_call:
|
||||
# Guardrail: Require evidence before done
|
||||
has_gathered_evidence = bool(available_memory_ids) or bool(available_model_ids)
|
||||
@@ -455,6 +421,7 @@ async def run_reflect_agent(
|
||||
# Process done tool
|
||||
return await _process_done_tool(
|
||||
done_call,
|
||||
output_mode,
|
||||
available_memory_ids,
|
||||
available_model_ids,
|
||||
iteration + 1,
|
||||
@@ -464,13 +431,12 @@ async def run_reflect_agent(
|
||||
_get_llm_trace(),
|
||||
_log_completion,
|
||||
reflect_id,
|
||||
directives_applied=directives_applied,
|
||||
llm_config=llm_config,
|
||||
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
|
||||
other_tools = [tc for tc in result.tool_calls if tc.name != "done"]
|
||||
if other_tools:
|
||||
# Add assistant message with tool calls
|
||||
messages.append(
|
||||
@@ -568,7 +534,6 @@ async def run_reflect_agent(
|
||||
mental_models_created=mental_models_created,
|
||||
tool_trace=tool_trace,
|
||||
llm_trace=_get_llm_trace(),
|
||||
directives_applied=directives_applied,
|
||||
)
|
||||
|
||||
|
||||
@@ -586,6 +551,7 @@ def _tool_call_to_dict(tc: "LLMToolCall") -> dict[str, Any]:
|
||||
|
||||
async def _process_done_tool(
|
||||
done_call: "LLMToolCall",
|
||||
output_mode: str,
|
||||
available_memory_ids: set[str],
|
||||
available_model_ids: set[str],
|
||||
iterations: int,
|
||||
@@ -595,13 +561,60 @@ async def _process_done_tool(
|
||||
llm_trace: list[LLMCall],
|
||||
log_completion: Callable,
|
||||
reflect_id: str,
|
||||
directives_applied: list[DirectiveInfo],
|
||||
llm_config: "LLMProvider | None" = None,
|
||||
response_schema: dict | None = None,
|
||||
) -> ReflectAgentResult:
|
||||
"""Process the done tool call and return the result."""
|
||||
args = done_call.arguments
|
||||
|
||||
if output_mode == "observations" and "observations" in args:
|
||||
# Process observations - handle both list and nested {"observations": [...]} format
|
||||
observations: list[Observation] = []
|
||||
used_memory_ids: list[str] = []
|
||||
|
||||
obs_list = args["observations"]
|
||||
# Handle nested format where LLM outputs {"observations": [...]} instead of just [...]
|
||||
if isinstance(obs_list, dict) and "observations" in obs_list:
|
||||
obs_list = obs_list["observations"]
|
||||
|
||||
for obs_data in obs_list:
|
||||
validated_mids = []
|
||||
for mid in obs_data.get("memory_ids", []):
|
||||
if mid in available_memory_ids:
|
||||
validated_mids.append(mid)
|
||||
if mid not in used_memory_ids:
|
||||
used_memory_ids.append(mid)
|
||||
|
||||
observations.append(
|
||||
Observation(
|
||||
title=obs_data.get("title", ""),
|
||||
text=obs_data.get("text", ""),
|
||||
memory_ids=validated_mids,
|
||||
)
|
||||
)
|
||||
|
||||
# Build text from observations
|
||||
text_parts = []
|
||||
for obs in observations:
|
||||
if obs.title:
|
||||
text_parts.append(f"## {obs.title}\n{obs.text}")
|
||||
else:
|
||||
text_parts.append(obs.text)
|
||||
answer = "\n\n".join(text_parts)
|
||||
|
||||
log_completion(answer, iterations)
|
||||
return ReflectAgentResult(
|
||||
text=answer,
|
||||
observations=observations,
|
||||
iterations=iterations,
|
||||
tools_called=total_tools_called,
|
||||
mental_models_created=mental_models_created,
|
||||
tool_trace=tool_trace,
|
||||
llm_trace=llm_trace,
|
||||
used_memory_ids=used_memory_ids,
|
||||
)
|
||||
|
||||
# Default: answer mode
|
||||
answer = args.get("answer", "").strip()
|
||||
if not answer:
|
||||
answer = "No answer provided."
|
||||
@@ -626,7 +639,6 @@ async def _process_done_tool(
|
||||
llm_trace=llm_trace,
|
||||
used_memory_ids=used_memory_ids,
|
||||
used_model_ids=used_model_ids,
|
||||
directives_applied=directives_applied,
|
||||
)
|
||||
|
||||
|
||||
@@ -653,10 +665,6 @@ async def _execute_tool(
|
||||
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.") :]
|
||||
|
||||
if tool_name == "list_mental_models":
|
||||
return await lookup_fn(None)
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -87,18 +87,21 @@ class LLMCall(BaseModel):
|
||||
duration_ms: int = Field(description="Execution time in milliseconds")
|
||||
|
||||
|
||||
class DirectiveInfo(BaseModel):
|
||||
"""Information about a directive that was applied during reflect."""
|
||||
class Observation(BaseModel):
|
||||
"""A single observation with supporting memories."""
|
||||
|
||||
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")
|
||||
title: str = Field(description="Observation title/header")
|
||||
text: str = Field(description="Observation content")
|
||||
memory_ids: list[str] = Field(default_factory=list, description="Memory IDs supporting this observation")
|
||||
|
||||
|
||||
class ReflectAgentResult(BaseModel):
|
||||
"""Result from the reflect agent."""
|
||||
|
||||
text: str = Field(description="Final answer text")
|
||||
observations: list[Observation] = Field(
|
||||
default_factory=list, description="Structured observations (when output_mode=observations)"
|
||||
)
|
||||
structured_output: dict[str, Any] | None = Field(
|
||||
default=None, description="Structured output parsed according to provided response_schema"
|
||||
)
|
||||
@@ -109,6 +112,3 @@ class ReflectAgentResult(BaseModel):
|
||||
llm_trace: list[LLMCall] = Field(default_factory=list, description="Trace of all LLM calls made")
|
||||
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")
|
||||
directives_applied: list[DirectiveInfo] = Field(
|
||||
default_factory=list, description="Directive mental models that affected this reflection"
|
||||
)
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
"""
|
||||
Models and utilities for evidence-grounded observations with computed trends.
|
||||
|
||||
Observations are part of mental models and represent patterns/beliefs derived
|
||||
from memories. Each observation must be grounded in specific evidence (quotes)
|
||||
from memories, and trends are computed algorithmically from evidence timestamps.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, Field, computed_field, field_validator
|
||||
|
||||
|
||||
class Trend(str, Enum):
|
||||
"""Computed trend for an observation based on evidence timestamps.
|
||||
|
||||
Trends indicate how an observation's evidence is distributed over time:
|
||||
- STABLE: Evidence spread across time, continues to present
|
||||
- STRENGTHENING: More/denser evidence recently than before
|
||||
- WEAKENING: Evidence mostly old, sparse recently
|
||||
- NEW: All evidence within recent window
|
||||
- STALE: No evidence in recent window (may no longer apply)
|
||||
"""
|
||||
|
||||
STABLE = "stable"
|
||||
STRENGTHENING = "strengthening"
|
||||
WEAKENING = "weakening"
|
||||
NEW = "new"
|
||||
STALE = "stale"
|
||||
|
||||
|
||||
class ObservationEvidence(BaseModel):
|
||||
"""A single piece of evidence supporting an observation.
|
||||
|
||||
Each evidence item must include an exact quote from the source memory
|
||||
to ensure observations are grounded and verifiable.
|
||||
"""
|
||||
|
||||
memory_id: str = Field(description="ID of the memory unit this evidence comes from")
|
||||
quote: str = Field(description="Exact quote from the memory supporting the observation")
|
||||
relevance: str = Field(default="", description="Brief explanation of how this quote supports the observation")
|
||||
timestamp: datetime = Field(description="When the source memory was created")
|
||||
|
||||
@field_validator("timestamp", mode="before")
|
||||
@classmethod
|
||||
def ensure_timezone_aware(cls, v: datetime | str | None) -> datetime:
|
||||
"""Ensure timestamp is always timezone-aware UTC."""
|
||||
if v is None:
|
||||
return datetime.now(timezone.utc)
|
||||
if isinstance(v, str):
|
||||
# Parse ISO format string, handling 'Z' suffix
|
||||
v = datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||||
if isinstance(v, datetime):
|
||||
if v.tzinfo is None:
|
||||
return v.replace(tzinfo=timezone.utc)
|
||||
return v
|
||||
raise ValueError(f"Invalid timestamp type: {type(v)}")
|
||||
|
||||
|
||||
class Observation(BaseModel):
|
||||
"""A single observation within a mental model.
|
||||
|
||||
Observations represent patterns, preferences, beliefs, or other insights
|
||||
derived from memories. Each observation must be grounded in evidence
|
||||
with exact quotes from source memories.
|
||||
"""
|
||||
|
||||
title: str = Field(description="Short summary title for the observation (5-10 words)")
|
||||
content: str = Field(description="The observation content - detailed explanation of what we believe to be true")
|
||||
evidence: list[ObservationEvidence] = Field(default_factory=list, description="Supporting evidence with quotes")
|
||||
created_at: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc), description="When this observation was first created"
|
||||
)
|
||||
|
||||
@field_validator("created_at", mode="before")
|
||||
@classmethod
|
||||
def ensure_created_at_timezone_aware(cls, v: datetime | str | None) -> datetime:
|
||||
"""Ensure created_at is always timezone-aware UTC."""
|
||||
if v is None:
|
||||
return datetime.now(timezone.utc)
|
||||
if isinstance(v, str):
|
||||
v = datetime.fromisoformat(v.replace("Z", "+00:00"))
|
||||
if isinstance(v, datetime):
|
||||
if v.tzinfo is None:
|
||||
return v.replace(tzinfo=timezone.utc)
|
||||
return v
|
||||
raise ValueError(f"Invalid created_at type: {type(v)}")
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def trend(self) -> Trend:
|
||||
"""Compute trend from evidence timestamps."""
|
||||
return compute_trend(self.evidence)
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def evidence_span(self) -> dict[str, str | None]:
|
||||
"""Get the time span covered by evidence."""
|
||||
if not self.evidence:
|
||||
return {"from": None, "to": None}
|
||||
timestamps = [e.timestamp for e in self.evidence]
|
||||
return {
|
||||
"from": min(timestamps).isoformat(),
|
||||
"to": max(timestamps).isoformat(),
|
||||
}
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def evidence_count(self) -> int:
|
||||
"""Number of evidence items supporting this observation."""
|
||||
return len(self.evidence)
|
||||
|
||||
|
||||
def compute_trend(
|
||||
evidence: list[ObservationEvidence],
|
||||
now: datetime | None = None,
|
||||
recent_days: int = 30,
|
||||
old_days: int = 90,
|
||||
) -> Trend:
|
||||
"""Compute the trend for an observation based on evidence timestamps.
|
||||
|
||||
The trend indicates how the evidence is distributed over time:
|
||||
- STABLE: Evidence spread across time, continues to present
|
||||
- STRENGTHENING: More evidence recently than historically
|
||||
- WEAKENING: Evidence mostly old, sparse recently
|
||||
- NEW: All evidence is recent (within recent_days)
|
||||
- STALE: No evidence in recent window
|
||||
|
||||
Args:
|
||||
evidence: List of evidence items with timestamps
|
||||
now: Reference time for calculations (defaults to current UTC time)
|
||||
recent_days: Number of days to consider "recent" (default 30)
|
||||
old_days: Number of days to consider "old" (default 90)
|
||||
|
||||
Returns:
|
||||
Computed Trend enum value
|
||||
"""
|
||||
if now is None:
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Ensure now is timezone-aware
|
||||
if now.tzinfo is None:
|
||||
now = now.replace(tzinfo=timezone.utc)
|
||||
|
||||
if not evidence:
|
||||
return Trend.STALE
|
||||
|
||||
recent_cutoff = now - timedelta(days=recent_days)
|
||||
old_cutoff = now - timedelta(days=old_days)
|
||||
|
||||
# Normalize timestamps to UTC for comparison
|
||||
def normalize_ts(ts: datetime) -> datetime:
|
||||
if ts.tzinfo is None:
|
||||
return ts.replace(tzinfo=timezone.utc)
|
||||
return ts
|
||||
|
||||
recent = [e for e in evidence if normalize_ts(e.timestamp) > recent_cutoff]
|
||||
old = [e for e in evidence if normalize_ts(e.timestamp) < old_cutoff]
|
||||
middle = [e for e in evidence if old_cutoff <= normalize_ts(e.timestamp) <= recent_cutoff]
|
||||
|
||||
# No recent evidence = stale
|
||||
if not recent:
|
||||
return Trend.STALE
|
||||
|
||||
# All evidence is recent = new
|
||||
if not old and not middle:
|
||||
return Trend.NEW
|
||||
|
||||
# Compare density (evidence per day)
|
||||
recent_density = len(recent) / recent_days if recent_days > 0 else 0
|
||||
older_period = old_days - recent_days
|
||||
older_density = (len(old) + len(middle)) / older_period if older_period > 0 else 0
|
||||
|
||||
# Avoid division by zero
|
||||
if older_density == 0:
|
||||
return Trend.NEW
|
||||
|
||||
ratio = recent_density / older_density
|
||||
|
||||
if ratio > 1.5:
|
||||
return Trend.STRENGTHENING
|
||||
elif ratio < 0.5:
|
||||
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
|
||||
@@ -6,111 +6,10 @@ import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
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
|
||||
|
||||
Returns:
|
||||
List of directive rule strings
|
||||
"""
|
||||
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}")
|
||||
return rules
|
||||
|
||||
|
||||
def build_directives_section(directives: list[dict[str, Any]]) -> str:
|
||||
"""
|
||||
Build the directives section for the system prompt.
|
||||
|
||||
Directives are hard rules that MUST be followed in all responses.
|
||||
|
||||
Args:
|
||||
directives: List of directive mental models with observations
|
||||
"""
|
||||
if not directives:
|
||||
return ""
|
||||
|
||||
rules = _extract_directive_rules(directives)
|
||||
if not rules:
|
||||
return ""
|
||||
|
||||
parts = [
|
||||
"## DIRECTIVES (MANDATORY)",
|
||||
"These are hard rules you MUST follow in ALL responses:",
|
||||
"",
|
||||
]
|
||||
|
||||
for rule in rules:
|
||||
parts.append(f"- {rule}")
|
||||
|
||||
parts.extend(
|
||||
[
|
||||
"",
|
||||
"NEVER violate these directives, even if other context suggests otherwise.",
|
||||
"IMPORTANT: Do NOT explain or justify how you handled directives in your answer. Just follow them silently.",
|
||||
"",
|
||||
]
|
||||
)
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def build_directives_reminder(directives: list[dict[str, Any]]) -> str:
|
||||
"""
|
||||
Build a reminder section for directives to place at the end of the prompt.
|
||||
|
||||
Args:
|
||||
directives: List of directive mental models with observations
|
||||
"""
|
||||
if not directives:
|
||||
return ""
|
||||
|
||||
rules = _extract_directive_rules(directives)
|
||||
if not rules:
|
||||
return ""
|
||||
|
||||
parts = [
|
||||
"",
|
||||
"## REMINDER: MANDATORY DIRECTIVES",
|
||||
"Before responding, ensure your answer complies with ALL of these directives:",
|
||||
"",
|
||||
]
|
||||
|
||||
for i, rule in enumerate(rules, 1):
|
||||
parts.append(f"{i}. {rule}")
|
||||
|
||||
parts.append("")
|
||||
parts.append("Your response will be REJECTED if it violates any directive above.")
|
||||
parts.append("Do NOT include any commentary about how you handled directives - just follow them.")
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
def build_system_prompt_for_tools(
|
||||
bank_profile: dict[str, Any],
|
||||
context: str | None = None,
|
||||
directives: list[dict[str, Any]] | None = None,
|
||||
output_mode: str = "answer",
|
||||
) -> str:
|
||||
"""
|
||||
Build the system prompt for tool-calling reflect agent.
|
||||
@@ -120,90 +19,128 @@ def build_system_prompt_for_tools(
|
||||
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
|
||||
output_mode: "answer" for plain text response, "observations" for structured observations
|
||||
"""
|
||||
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"
|
||||
)
|
||||
# Build critical rules based on mode
|
||||
if output_mode == "observations":
|
||||
no_info_rule = "- Only say 'I don't have information' AFTER trying recall with no relevant results"
|
||||
else:
|
||||
no_info_rule = (
|
||||
"- Only say 'I don't have information' AFTER trying list_mental_models AND recall with no relevant results"
|
||||
)
|
||||
|
||||
parts = []
|
||||
parts = [
|
||||
"You are a reflection agent that answers questions by reasoning over retrieved memories.",
|
||||
"",
|
||||
"## 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,
|
||||
"",
|
||||
"## How to Reason",
|
||||
"- If memories mention someone did an activity, you can infer they likely enjoyed it",
|
||||
"- Synthesize a coherent narrative from related memories",
|
||||
"- 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)",
|
||||
"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')",
|
||||
"GOOD: Break it down into component searches:",
|
||||
" 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",
|
||||
]
|
||||
|
||||
# Inject directives at the VERY START for maximum prominence
|
||||
if directives:
|
||||
parts.append(build_directives_section(directives))
|
||||
|
||||
parts.extend(
|
||||
[
|
||||
"You are a reflection agent that answers questions by reasoning over retrieved memories.",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
parts.extend(
|
||||
[
|
||||
"## 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,
|
||||
"",
|
||||
"## How to Reason",
|
||||
"- If memories mention someone did an activity, you can infer they likely enjoyed it",
|
||||
"- Synthesize a coherent narrative from related memories",
|
||||
"- 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)",
|
||||
"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')",
|
||||
"GOOD: Break it down into component searches:",
|
||||
" 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
|
||||
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",
|
||||
]
|
||||
)
|
||||
# Mode-specific workflow and output format
|
||||
if output_mode == "observations":
|
||||
# Observations mode: for mental model generation - no mental model lookup tools
|
||||
parts.extend(
|
||||
[
|
||||
"1. DECOMPOSE the topic into component searches (see Query Strategy above)",
|
||||
" - Don't search for the topic name itself - search for related concepts",
|
||||
" - Example for 'Coffee preferences': search 'coffee', 'drinks', 'morning routine', 'caffeine'",
|
||||
"2. Run multiple recall() calls with varied, targeted queries",
|
||||
"3. IMPORTANT: Use expand(memory_ids, 'chunk') to verify memories before using them",
|
||||
" - Always verify the source chunk to confirm the memory is actually relevant",
|
||||
" - Don't assume a memory is relevant based on the summary alone",
|
||||
" - Only include memories you've verified via expand()",
|
||||
"4. When ready, call done() with MULTIPLE structured observations",
|
||||
"",
|
||||
"## Output Format: MULTIPLE Structured Observations",
|
||||
"",
|
||||
"CRITICAL: You MUST create MULTIPLE separate observations in the array - one for each theme.",
|
||||
"Do NOT put all content in a single observation!",
|
||||
"",
|
||||
"- Create 3-8 separate observations, each as its OWN item in the observations array",
|
||||
"- Each observation covers ONE specific theme (preferences, history, relationships, etc.)",
|
||||
"- Each observation has: title (short header), text (content), memory_ids (full UUIDs)",
|
||||
"",
|
||||
"Text format for each observation:",
|
||||
"- Main insight or finding (no markdown headers)",
|
||||
"- End with 'Key evidence:' section containing DIRECT QUOTES from memories in *italics*",
|
||||
"- Quote the actual memory text, don't summarize - use *italics* for citations",
|
||||
"",
|
||||
"Example done() call with MULTIPLE observations:",
|
||||
"```json",
|
||||
"{",
|
||||
' "observations": [',
|
||||
" {",
|
||||
' "title": "Work Preferences",',
|
||||
' "text": "Prefers async communication and flexible schedules.\\n\\nKey evidence:\\n- *I prefer Slack over calls for most communication*\\n- *Flexible hours help me do my best work*",',
|
||||
' "memory_ids": ["abc123-full-uuid", "def456-full-uuid"]',
|
||||
" },",
|
||||
" {",
|
||||
' "title": "Technical Background",',
|
||||
' "text": "Has extensive ML experience spanning a decade.\\n\\nKey evidence:\\n- *I have 10 years of experience in machine learning*\\n- *Led the ML team at my previous company*",',
|
||||
' "memory_ids": ["ghi789-full-uuid"]',
|
||||
" }",
|
||||
" ]",
|
||||
"}",
|
||||
"```",
|
||||
]
|
||||
)
|
||||
else:
|
||||
# Answer mode: include mental model lookup in workflow
|
||||
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. If you discover an important recurring topic worth tracking, use learn() to create a mental model",
|
||||
"7. When ready, call done() with your answer and supporting memory_ids",
|
||||
"",
|
||||
"## When to Use learn()",
|
||||
"Use learn() to create a new mental model when you discover:",
|
||||
"- A person, project, or concept that appears frequently in memories",
|
||||
"- An important topic the user seems to care about but has no mental model for",
|
||||
"- A pattern or relationship worth synthesizing for future reference",
|
||||
"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",
|
||||
]
|
||||
)
|
||||
|
||||
parts.append("")
|
||||
parts.append(f"## Memory Bank: {name}")
|
||||
@@ -227,10 +164,6 @@ def build_system_prompt_for_tools(
|
||||
if context:
|
||||
parts.append(f"\n## Additional Context\n{context}")
|
||||
|
||||
# Add directive reminder at the END for recency effect
|
||||
if directives:
|
||||
parts.append(build_directives_reminder(directives))
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
|
||||
@@ -377,386 +310,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)
|
||||
|
||||
@@ -5,11 +5,9 @@ Tool implementations for the reflect agent.
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .models import MentalModelInput
|
||||
from .observations import Observation, ObservationEvidence, Trend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from asyncpg import Connection
|
||||
@@ -28,37 +26,6 @@ def generate_model_id(name: str) -> str:
|
||||
return normalized[:50]
|
||||
|
||||
|
||||
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(
|
||||
conn: "Connection",
|
||||
bank_id: str,
|
||||
@@ -99,8 +66,18 @@ async def tool_lookup(
|
||||
obs_data = json.loads(obs_data)
|
||||
observations_raw = obs_data.get("observations", []) if isinstance(obs_data, dict) else obs_data
|
||||
|
||||
# Parse observations into typed models
|
||||
observations = _parse_observations(observations_raw)
|
||||
# Normalize observation format: map memory_ids/fact_ids to based_on
|
||||
observations = []
|
||||
for obs in observations_raw:
|
||||
if isinstance(obs, dict):
|
||||
based_on = obs.get("memory_ids") or obs.get("fact_ids") or []
|
||||
observations.append(
|
||||
{
|
||||
"title": obs.get("title", ""),
|
||||
"text": obs.get("text", ""),
|
||||
"based_on": based_on,
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"found": True,
|
||||
@@ -109,7 +86,7 @@ async def tool_lookup(
|
||||
"subtype": row["subtype"],
|
||||
"name": row["name"],
|
||||
"description": row["description"],
|
||||
"observations": observations,
|
||||
"observations": observations, # [{title, text, based_on}, ...]
|
||||
"entity_id": str(row["entity_id"]) if row["entity_id"] else None,
|
||||
"last_updated": row["last_updated"].isoformat() if row["last_updated"] else None,
|
||||
},
|
||||
@@ -118,8 +95,6 @@ async def tool_lookup(
|
||||
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":
|
||||
@@ -128,7 +103,7 @@ async def tool_lookup(
|
||||
"""
|
||||
SELECT id, subtype, name, description
|
||||
FROM mental_models
|
||||
WHERE bank_id = $1 AND tags @> $2::varchar[] AND subtype != 'directive'
|
||||
WHERE bank_id = $1 AND tags @> $2::varchar[]
|
||||
ORDER BY last_updated DESC NULLS LAST, created_at DESC
|
||||
""",
|
||||
bank_id,
|
||||
@@ -140,7 +115,7 @@ async def tool_lookup(
|
||||
"""
|
||||
SELECT id, subtype, name, description
|
||||
FROM mental_models
|
||||
WHERE bank_id = $1 AND tags && $2::varchar[] AND subtype != 'directive'
|
||||
WHERE bank_id = $1 AND tags && $2::varchar[]
|
||||
ORDER BY last_updated DESC NULLS LAST, created_at DESC
|
||||
""",
|
||||
bank_id,
|
||||
@@ -151,7 +126,7 @@ async def tool_lookup(
|
||||
"""
|
||||
SELECT id, subtype, name, description
|
||||
FROM mental_models
|
||||
WHERE bank_id = $1 AND subtype != 'directive'
|
||||
WHERE bank_id = $1
|
||||
ORDER BY last_updated DESC NULLS LAST, created_at DESC
|
||||
""",
|
||||
bank_id,
|
||||
|
||||
@@ -4,6 +4,8 @@ Tool schema definitions for the reflect agent.
|
||||
These are OpenAI-format tool definitions used with native tool calling.
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
# Tool definitions in OpenAI format
|
||||
TOOL_LIST_MENTAL_MODELS = {
|
||||
"type": "function",
|
||||
@@ -132,76 +134,68 @@ TOOL_DONE_ANSWER = {
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_done_tool_with_directives(directive_rules: list[str]) -> dict:
|
||||
"""
|
||||
Build the done tool schema with directive compliance field.
|
||||
|
||||
When directives are present, adds a required field that forces the agent
|
||||
to confirm compliance with each directive before submitting.
|
||||
|
||||
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))
|
||||
|
||||
# Build the tool with directive compliance field
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "done",
|
||||
"description": (
|
||||
"Signal completion with your final answer. IMPORTANT: You must confirm directive compliance before submitting. "
|
||||
"Your answer will be REJECTED if it violates any directive."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"answer": {
|
||||
"type": "string",
|
||||
"description": "Your response as plain text. Do NOT use markdown formatting. NEVER include memory IDs, UUIDs, or 'Memory references' in this text - put IDs only in memory_ids array.",
|
||||
},
|
||||
"memory_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of memory IDs that support your answer (put IDs here, NOT in answer text)",
|
||||
},
|
||||
"model_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Array of mental model 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]...'",
|
||||
TOOL_DONE_OBSERVATIONS = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "done",
|
||||
"description": "Signal completion with MULTIPLE structured observations. Each observation must be a SEPARATE item in the array covering ONE theme. Do NOT combine all content into a single observation.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"observations": {
|
||||
"type": "array",
|
||||
"minItems": 3,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "Short header for this observation's theme (e.g., 'Work Style', 'Technical Skills')",
|
||||
},
|
||||
"text": {
|
||||
"type": "string",
|
||||
"description": "Observation content about ONE theme. End with 'Key evidence:' containing text citations (summaries of what memories say), NOT memory IDs.",
|
||||
},
|
||||
"memory_ids": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Full UUIDs of memories supporting this observation (put IDs here, not in text)",
|
||||
},
|
||||
},
|
||||
"required": ["title", "text", "memory_ids"],
|
||||
},
|
||||
"description": "Array of 3-8 observations, each covering a DIFFERENT aspect/theme. Do NOT put everything in one observation.",
|
||||
},
|
||||
"required": ["answer", "directive_compliance"],
|
||||
},
|
||||
"required": ["observations"],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_reflect_tools(enable_learn: bool = True, directive_rules: list[str] | None = None) -> list[dict]:
|
||||
def get_reflect_tools(
|
||||
enable_learn: bool = True, output_mode: Literal["answer", "observations"] = "answer"
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Get the list of tools for the reflect agent.
|
||||
|
||||
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.
|
||||
output_mode: "answer" or "observations" - determines done tool format
|
||||
In observations mode, mental model tools are excluded to avoid
|
||||
using potentially outdated models during regeneration.
|
||||
|
||||
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)
|
||||
# In answer mode, include mental model tools for lookup
|
||||
# In observations mode (mental model generation), exclude them to avoid circular references
|
||||
if output_mode == "answer":
|
||||
tools.append(TOOL_LIST_MENTAL_MODELS)
|
||||
tools.append(TOOL_GET_MENTAL_MODEL)
|
||||
|
||||
tools.append(TOOL_RECALL)
|
||||
|
||||
if enable_learn:
|
||||
@@ -209,9 +203,9 @@ def get_reflect_tools(enable_learn: bool = True, directive_rules: list[str] | No
|
||||
|
||||
tools.append(TOOL_EXPAND)
|
||||
|
||||
# Use directive-aware done tool if directives are present
|
||||
if directive_rules:
|
||||
tools.append(_build_done_tool_with_directives(directive_rules))
|
||||
# Add appropriate done tool based on output mode
|
||||
if output_mode == "observations":
|
||||
tools.append(TOOL_DONE_OBSERVATIONS)
|
||||
else:
|
||||
tools.append(TOOL_DONE_ANSWER)
|
||||
|
||||
|
||||
@@ -58,14 +58,6 @@ class MentalModelRef(BaseModel):
|
||||
summary: str | None = Field(default=None, description="Full summary (when looked up in detail)")
|
||||
|
||||
|
||||
class DirectiveRef(BaseModel):
|
||||
"""Reference to a directive that was applied during reflect."""
|
||||
|
||||
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")
|
||||
|
||||
|
||||
class TokenUsage(BaseModel):
|
||||
"""
|
||||
Token usage metrics for LLM calls.
|
||||
@@ -260,11 +252,7 @@ class ReflectResult(BaseModel):
|
||||
)
|
||||
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.",
|
||||
description="Mental models accessed during reflection. Only present when include.facts is enabled.",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ async def insert_facts_batch(
|
||||
contexts = []
|
||||
fact_types = []
|
||||
confidence_scores = []
|
||||
access_counts = []
|
||||
metadata_jsons = []
|
||||
chunk_ids = []
|
||||
document_ids = []
|
||||
@@ -60,6 +61,7 @@ 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
|
||||
@@ -74,16 +76,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::jsonb[], $12::text[], $13::text[], $14::jsonb[]
|
||||
$8::text[], $9::text[], $10::float[], $11::int[], $12::jsonb[], $13::text[], $14::text[], $15::jsonb[]
|
||||
) AS t(text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id, tags_json)
|
||||
context, fact_type, confidence_score, access_count, 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, metadata, chunk_id, document_id, tags)
|
||||
context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id, tags)
|
||||
SELECT
|
||||
$1,
|
||||
text, embedding, event_date, occurred_start, occurred_end, mentioned_at,
|
||||
context, fact_type, confidence_score, metadata, chunk_id, document_id,
|
||||
context, fact_type, confidence_score, access_count, metadata, chunk_id, document_id,
|
||||
COALESCE(
|
||||
(SELECT array_agg(elem) FROM jsonb_array_elements_text(tags_json) AS elem),
|
||||
'{{}}'::varchar[]
|
||||
@@ -101,6 +103,7 @@ async def insert_facts_batch(
|
||||
contexts,
|
||||
fact_types,
|
||||
confidence_scores,
|
||||
access_counts,
|
||||
metadata_jsons,
|
||||
chunk_ids,
|
||||
document_ids,
|
||||
|
||||
@@ -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, embedding, fact_type, document_id, chunk_id, tags,
|
||||
mentioned_at, access_count, 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.embedding, mu.fact_type,
|
||||
mu.mentioned_at, mu.access_count, 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, embedding, fact_type, document_id, chunk_id, tags,
|
||||
mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
@@ -168,7 +168,7 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
f"""
|
||||
SELECT
|
||||
mu.id, mu.text, mu.context, mu.event_date, mu.occurred_start,
|
||||
mu.occurred_end, mu.mentioned_at, mu.embedding,
|
||||
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
|
||||
@@ -193,7 +193,7 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
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.embedding,
|
||||
mu.occurred_end, mu.mentioned_at, mu.access_count, 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
|
||||
|
||||
@@ -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, embedding, fact_type, document_id, chunk_id, tags
|
||||
mentioned_at, access_count, 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, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, 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, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, 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, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, 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, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, 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, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, 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, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, 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, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, embedding, fact_type, document_id, chunk_id, tags,
|
||||
similarity, bm25_score, source
|
||||
FROM semantic_ranked WHERE rn <= $4
|
||||
),
|
||||
bm25 AS (
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, 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, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, 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, embedding, fact_type, document_id, chunk_id, tags, similarity
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, 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.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.access_count, 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, embedding, fact_type, document_id, chunk_id, tags,
|
||||
SELECT id, text, context, event_date, occurred_start, occurred_end, mentioned_at, access_count, 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.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.access_count, 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,
|
||||
embedding, fact_type, document_id, chunk_id,
|
||||
access_count, embedding, fact_type, document_id, chunk_id,
|
||||
1 - (embedding <=> $1::vector) AS similarity
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $2
|
||||
|
||||
@@ -65,6 +65,31 @@ def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -
|
||||
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.
|
||||
|
||||
@@ -85,6 +85,7 @@ 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,6 +136,7 @@ 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,
|
||||
@@ -154,6 +155,7 @@ 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
|
||||
@@ -192,6 +194,7 @@ 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,
|
||||
|
||||
@@ -46,6 +46,7 @@ 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
|
||||
|
||||
@@ -70,6 +71,7 @@ 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"),
|
||||
@@ -154,6 +156,7 @@ 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,40 +1,31 @@
|
||||
"""
|
||||
Task backend for distributed task processing.
|
||||
Abstract task backend for running async tasks.
|
||||
|
||||
This provides an abstraction for task storage and execution:
|
||||
- BrokerTaskBackend: Uses PostgreSQL as broker (production)
|
||||
- SyncTaskBackend: Executes tasks immediately (testing/embedded)
|
||||
This provides an abstraction that can be adapted to different execution models:
|
||||
- AsyncIO queue (default implementation)
|
||||
- Pub/Sub architectures (future)
|
||||
- Message brokers (future)
|
||||
"""
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncpg
|
||||
from typing import Any
|
||||
|
||||
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 (optional)
|
||||
2. Execute tasks through a provided executor callback
|
||||
|
||||
The backend treats tasks as pure dictionaries that can be serialized
|
||||
and stored in the database. The executor (typically MemoryEngine.execute_task)
|
||||
and sent over the network. The executor (typically MemoryEngine.execute_task)
|
||||
receives the dict and routes it to the appropriate handler.
|
||||
"""
|
||||
|
||||
@@ -55,7 +46,7 @@ class TaskBackend(ABC):
|
||||
@abstractmethod
|
||||
async def initialize(self):
|
||||
"""
|
||||
Initialize the backend (e.g., connect to database).
|
||||
Initialize the backend (e.g., start workers, connect to broker).
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -72,7 +63,7 @@ class TaskBackend(ABC):
|
||||
@abstractmethod
|
||||
async def shutdown(self):
|
||||
"""
|
||||
Shutdown the backend gracefully.
|
||||
Shutdown the backend gracefully (e.g., stop workers, close connections).
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -102,8 +93,9 @@ class SyncTaskBackend(TaskBackend):
|
||||
"""
|
||||
Synchronous task backend that executes tasks immediately.
|
||||
|
||||
This is useful for tests and embedded/CLI usage where we don't want
|
||||
background workers. Tasks are executed inline rather than being queued.
|
||||
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.
|
||||
"""
|
||||
|
||||
async def initialize(self):
|
||||
@@ -129,123 +121,221 @@ class SyncTaskBackend(TaskBackend):
|
||||
logger.debug("SyncTaskBackend shutdown")
|
||||
|
||||
|
||||
class BrokerTaskBackend(TaskBackend):
|
||||
class NoopTaskBackend(TaskBackend):
|
||||
"""
|
||||
Task backend using PostgreSQL as broker.
|
||||
No-op task backend that discards all tasks.
|
||||
|
||||
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.
|
||||
This is useful for tests where background task execution is not needed
|
||||
and would only slow down the test suite.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
pool_getter: Callable[[], "asyncpg.Pool"],
|
||||
schema: str | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the broker task backend.
|
||||
|
||||
Args:
|
||||
pool_getter: Callable that returns the asyncpg connection pool
|
||||
schema: Database schema for multi-tenant support (optional)
|
||||
"""
|
||||
super().__init__()
|
||||
self._pool_getter = pool_getter
|
||||
self._schema = schema
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize the backend."""
|
||||
"""No-op."""
|
||||
self._initialized = True
|
||||
logger.info("BrokerTaskBackend initialized")
|
||||
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):
|
||||
"""
|
||||
Initialize AsyncIO queue backend.
|
||||
|
||||
Args:
|
||||
batch_size: Maximum number of tasks to process in one batch
|
||||
batch_interval: Maximum time (seconds) to wait before processing batch
|
||||
"""
|
||||
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()
|
||||
|
||||
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())
|
||||
self._initialized = True
|
||||
logger.info("AsyncIOQueueBackend initialized")
|
||||
|
||||
async def submit_task(self, task_dict: dict[str, Any]):
|
||||
"""
|
||||
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.
|
||||
Submit a task by putting it in the queue.
|
||||
|
||||
Args:
|
||||
task_dict: Task dictionary to store (must be JSON serializable)
|
||||
task_dict: Task dictionary to execute
|
||||
"""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
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)
|
||||
|
||||
table = fq_table("async_operations", self._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")
|
||||
await self._queue.put(task_dict)
|
||||
|
||||
async def wait_for_pending_tasks(self, timeout: float = 120.0):
|
||||
"""
|
||||
Wait for pending tasks to be processed.
|
||||
Wait for all pending tasks in the queue and in-flight tasks to complete.
|
||||
|
||||
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).
|
||||
This is useful in tests to ensure background tasks complete before assertions.
|
||||
|
||||
Args:
|
||||
timeout: Maximum time to wait in seconds
|
||||
timeout: Maximum time to wait in seconds (default 120s for long-running tasks)
|
||||
"""
|
||||
import asyncio
|
||||
|
||||
pool = self._pool_getter()
|
||||
table = fq_table("async_operations", self._schema)
|
||||
if not self._initialized or self._queue is None:
|
||||
return
|
||||
|
||||
# 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:
|
||||
# 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
|
||||
"""
|
||||
)
|
||||
async with self._in_flight_lock:
|
||||
in_flight = self._in_flight_count
|
||||
|
||||
if count == 0:
|
||||
if self._queue.empty() and in_flight == 0:
|
||||
# Queue is empty and no tasks in flight, we're done
|
||||
return
|
||||
|
||||
# Wait a bit before checking again
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
logger.warning(f"Timeout waiting for pending tasks after {timeout}s")
|
||||
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
|
||||
|
||||
@@ -124,6 +124,31 @@ def calculate_recency_weight(days_since: float, half_life_days: float = 365.0) -
|
||||
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.
|
||||
|
||||
@@ -27,8 +27,6 @@ from hindsight_api.extensions.operation_validator import (
|
||||
RecallResult,
|
||||
ReflectContext,
|
||||
ReflectResultContext,
|
||||
RefreshMentalModelContext,
|
||||
RefreshMentalModelResult,
|
||||
RetainContext,
|
||||
RetainResult,
|
||||
ValidationResult,
|
||||
@@ -56,8 +54,6 @@ __all__ = [
|
||||
"RecallResult",
|
||||
"ReflectContext",
|
||||
"ReflectResultContext",
|
||||
"RefreshMentalModelContext",
|
||||
"RefreshMentalModelResult",
|
||||
"RetainContext",
|
||||
"RetainResult",
|
||||
"ValidationResult",
|
||||
|
||||
@@ -97,18 +97,6 @@ class ReflectContext:
|
||||
context: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RefreshMentalModelContext:
|
||||
"""Context for a refresh mental model operation validation (pre-operation).
|
||||
|
||||
Contains ALL user-provided parameters for the refresh mental model operation.
|
||||
"""
|
||||
|
||||
bank_id: str
|
||||
model_id: str
|
||||
request_context: "RequestContext"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Post-operation Contexts (includes results)
|
||||
# =============================================================================
|
||||
@@ -176,27 +164,6 @@ class ReflectResultContext:
|
||||
error: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class RefreshMentalModelResult:
|
||||
"""Result context for post-refresh-mental-model hook.
|
||||
|
||||
Contains the operation parameters and the result including token usage.
|
||||
"""
|
||||
|
||||
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
|
||||
success: bool = True
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class OperationValidatorExtension(Extension, ABC):
|
||||
"""
|
||||
Validates and hooks into retain/recall/reflect operations.
|
||||
@@ -298,25 +265,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)
|
||||
# =========================================================================
|
||||
@@ -377,28 +325,3 @@ class OperationValidatorExtension(Extension, ABC):
|
||||
- error: Error message (if failed)
|
||||
"""
|
||||
pass
|
||||
|
||||
async def on_refresh_mental_model_complete(self, result: RefreshMentalModelResult) -> None:
|
||||
"""
|
||||
Called after a refresh mental model operation completes (success or failure).
|
||||
|
||||
Override this method to implement post-operation logic such as:
|
||||
- Token usage tracking and billing
|
||||
- Audit logging
|
||||
- Metrics collection
|
||||
|
||||
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
|
||||
- success: Whether the operation succeeded
|
||||
- error: Error message (if failed)
|
||||
"""
|
||||
pass
|
||||
|
||||
@@ -199,7 +199,6 @@ def main():
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
log_level=args.log_level,
|
||||
log_format=config.log_format,
|
||||
mcp_enabled=config.mcp_enabled,
|
||||
graph_retriever=config.graph_retriever,
|
||||
mpfp_top_k_neighbors=config.mpfp_top_k_neighbors,
|
||||
@@ -219,12 +218,9 @@ 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,
|
||||
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,
|
||||
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,
|
||||
reflect_max_iterations=config.reflect_max_iterations,
|
||||
mental_model_refresh_concurrency=config.mental_model_refresh_concurrency,
|
||||
)
|
||||
|
||||
@@ -95,6 +95,7 @@ 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)
|
||||
@@ -130,6 +131,7 @@ 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(
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
"""
|
||||
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"]
|
||||
@@ -1,285 +0,0 @@
|
||||
"""
|
||||
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
|
||||
|
||||
# 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}")
|
||||
|
||||
# Create and start the poller
|
||||
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,
|
||||
)
|
||||
|
||||
# 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()
|
||||
@@ -1,281 +0,0 @@
|
||||
"""
|
||||
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 traceback
|
||||
from collections.abc import Awaitable, Callable
|
||||
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 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.
|
||||
"""
|
||||
|
||||
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,
|
||||
):
|
||||
"""
|
||||
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 multi-tenant support (optional)
|
||||
"""
|
||||
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._shutdown = asyncio.Event()
|
||||
self._current_tasks: set[asyncio.Task] = set()
|
||||
self._in_flight_count = 0
|
||||
self._in_flight_lock = asyncio.Lock()
|
||||
|
||||
async def claim_batch(self) -> list[tuple[str, dict[str, Any]]]:
|
||||
"""
|
||||
Claim up to batch_size pending tasks atomically.
|
||||
|
||||
Uses FOR UPDATE SKIP LOCKED to ensure no conflicts with other workers.
|
||||
|
||||
Returns:
|
||||
List of tuples (operation_id, task_dict)
|
||||
"""
|
||||
table = fq_table("async_operations", self._schema)
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
# Select and lock pending tasks
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT operation_id, task_payload
|
||||
FROM {table}
|
||||
WHERE status = 'pending' AND task_payload IS NOT NULL
|
||||
ORDER BY created_at
|
||||
LIMIT $1
|
||||
FOR UPDATE SKIP LOCKED
|
||||
""",
|
||||
self._batch_size,
|
||||
)
|
||||
|
||||
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
|
||||
return [(str(row["operation_id"]), json.loads(row["task_payload"])) for row in rows]
|
||||
|
||||
async def _mark_completed(self, operation_id: str):
|
||||
"""Mark a task as completed."""
|
||||
table = fq_table("async_operations", self._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):
|
||||
"""Mark a task as failed with error message."""
|
||||
table = fq_table("async_operations", self._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):
|
||||
"""Increment retry count or mark as failed if max retries exceeded."""
|
||||
table = fq_table("async_operations", self._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}"
|
||||
)
|
||||
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, operation_id: str, task_dict: dict[str, Any]):
|
||||
"""Execute a single task and update its status."""
|
||||
task_type = task_dict.get("type", "unknown")
|
||||
bank_id = task_dict.get("bank_id", "unknown")
|
||||
|
||||
try:
|
||||
logger.debug(f"Executing task {operation_id} (type={task_type}, bank={bank_id})")
|
||||
await self._executor(task_dict)
|
||||
await self._mark_completed(operation_id)
|
||||
logger.debug(f"Task {operation_id} completed successfully")
|
||||
except Exception as e:
|
||||
error_msg = f"{type(e).__name__}: {e}\n{traceback.format_exc()}"
|
||||
logger.error(f"Task {operation_id} failed: {e}")
|
||||
await self._retry_or_fail(operation_id, error_msg)
|
||||
|
||||
async def run(self):
|
||||
"""
|
||||
Main polling loop.
|
||||
|
||||
Continuously polls for pending tasks, claims them, and executes them
|
||||
until shutdown is signaled.
|
||||
"""
|
||||
logger.info(f"Worker {self._worker_id} starting polling loop")
|
||||
|
||||
while not self._shutdown.is_set():
|
||||
try:
|
||||
# Claim a batch of tasks
|
||||
tasks = await self.claim_batch()
|
||||
|
||||
if tasks:
|
||||
# Log batch info
|
||||
task_types = {}
|
||||
for _, task_dict in tasks:
|
||||
t = task_dict.get("type", "unknown")
|
||||
task_types[t] = task_types.get(t, 0) + 1
|
||||
types_str = ", ".join(f"{k}:{v}" for k, v in task_types.items())
|
||||
logger.info(f"Worker {self._worker_id} claimed {len(tasks)} tasks: {types_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(op_id, task_dict) for op_id, task_dict 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
|
||||
|
||||
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")
|
||||
|
||||
@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()
|
||||
@@ -25,7 +25,7 @@ dependencies = [
|
||||
"psycopg2-binary>=2.9.11",
|
||||
"tiktoken>=0.12.0",
|
||||
"httpx>=0.27.0",
|
||||
"fastmcp>=2.14.0", # CVE-2025-66416
|
||||
"fastmcp>=2.3.0",
|
||||
"pg0-embedded>=0.11.0",
|
||||
"python-dateutil>=2.8.0",
|
||||
"opentelemetry-api>=1.20.0",
|
||||
@@ -39,17 +39,10 @@ dependencies = [
|
||||
"cohere>=5.0.0",
|
||||
"flashrank>=0.2.0",
|
||||
# Local ML models for embeddings/reranking - can be excluded in Docker with INCLUDE_LOCAL_MODELS=false
|
||||
"sentence-transformers>=3.3.0",
|
||||
"transformers>=4.53.0", # Security fixes for ReDoS vulnerabilities
|
||||
"torch>=2.6.0", # CVE fix for remote code execution
|
||||
"sentence-transformers>=3.0.0,<3.3.0",
|
||||
"transformers>=4.30.0,<4.46.0",
|
||||
"torch>=2.0.0",
|
||||
"uvloop>=0.22.1",
|
||||
# Transitive dependency security fixes
|
||||
"pyasn1>=0.6.2", # DoS vulnerability fix
|
||||
"urllib3>=2.6.3", # Decompression-bomb safeguards bypass fix
|
||||
"langchain-core>=1.2.5", # Serialization injection vulnerability fix
|
||||
"filelock>=3.20.1", # TOCTOU race condition fix
|
||||
"authlib>=1.6.6", # Account takeover vulnerability fix
|
||||
"aiohttp>=3.13.3", # Multiple DoS vulnerabilities
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -58,12 +51,11 @@ test = [
|
||||
"pytest-asyncio>=0.21.0",
|
||||
"pytest-timeout>=2.4.0",
|
||||
"pytest-xdist>=3.0.0",
|
||||
"filelock>=3.20.1", # TOCTOU race condition fix
|
||||
"filelock>=3.0.0",
|
||||
]
|
||||
|
||||
[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"
|
||||
|
||||
@@ -105,7 +97,7 @@ dev = [
|
||||
"pytest-timeout>=2.4.0",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
"filelock>=3.20.1", # TOCTOU race condition fix
|
||||
"filelock>=3.0.0",
|
||||
"ruff>=0.8.0",
|
||||
"ty>=0.0.1",
|
||||
]
|
||||
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
@@ -148,7 +147,6 @@ 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://
|
||||
@@ -162,7 +160,6 @@ 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
|
||||
|
||||
@@ -17,7 +17,6 @@ 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.query_analyzer import DateparserQueryAnalyzer
|
||||
from hindsight_api.engine.task_backend import SyncTaskBackend
|
||||
from hindsight_api.extensions import TenantExtension, TenantContext
|
||||
from hindsight_api.migrations import run_migrations, ensure_embedding_dimension
|
||||
|
||||
@@ -324,7 +323,6 @@ class TestOpenAIEmbeddings:
|
||||
pool_max_size=3,
|
||||
run_migrations=False,
|
||||
tenant_extension=SchemaTenantExtension(schema_name),
|
||||
task_backend=SyncTaskBackend(),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -394,7 +392,6 @@ class TestOpenAIEmbeddings:
|
||||
pool_max_size=3,
|
||||
run_migrations=False,
|
||||
tenant_extension=SchemaTenantExtension(schema_name),
|
||||
task_backend=SyncTaskBackend(),
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -562,7 +559,6 @@ class TestCohereIntegration:
|
||||
pool_max_size=3,
|
||||
run_migrations=False,
|
||||
tenant_extension=SchemaTenantExtension(schema_name),
|
||||
task_backend=SyncTaskBackend(),
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -17,8 +17,6 @@ from hindsight_api.extensions import (
|
||||
RecallResult,
|
||||
ReflectContext,
|
||||
ReflectResultContext,
|
||||
RefreshMentalModelContext,
|
||||
RefreshMentalModelResult,
|
||||
RequestContext,
|
||||
RetainContext,
|
||||
RetainResult,
|
||||
@@ -95,7 +93,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 +118,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):
|
||||
"""
|
||||
@@ -145,12 +132,10 @@ class TrackingValidator(OperationValidatorExtension):
|
||||
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
|
||||
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] = []
|
||||
|
||||
async def validate_retain(self, ctx: RetainContext) -> ValidationResult:
|
||||
self.pre_retain_calls.append(ctx)
|
||||
@@ -164,12 +149,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,11 +158,6 @@ 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)
|
||||
|
||||
|
||||
class TestMemoryEngineValidation:
|
||||
"""Tests for validation integration with MemoryEngine.
|
||||
@@ -541,105 +515,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."""
|
||||
|
||||
@@ -259,11 +259,23 @@ class TestReflectToolSchemas:
|
||||
assert "recall" in tool_names
|
||||
assert "done" in tool_names
|
||||
|
||||
def test_get_reflect_tools_observations_mode(self):
|
||||
"""Test getting reflect tools with observations output mode."""
|
||||
from hindsight_api.engine.reflect.tools_schema import get_reflect_tools
|
||||
|
||||
tools = get_reflect_tools(output_mode="observations")
|
||||
|
||||
done_tool = next(t for t in tools if t["function"]["name"] == "done")
|
||||
params = done_tool["function"]["parameters"]["properties"]
|
||||
|
||||
assert "observations" in params
|
||||
assert "answer" not 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
|
||||
|
||||
tools = get_reflect_tools()
|
||||
tools = get_reflect_tools(output_mode="answer")
|
||||
|
||||
done_tool = next(t for t in tools if t["function"]["name"] == "done")
|
||||
params = done_tool["function"]["parameters"]["properties"]
|
||||
|
||||
@@ -19,7 +19,6 @@ 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
|
||||
|
||||
@@ -107,7 +106,6 @@ 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
|
||||
|
||||
@@ -363,7 +363,6 @@ from hindsight_api.extensions import (
|
||||
RetainContext,
|
||||
RecallContext,
|
||||
ReflectContext,
|
||||
RefreshMentalModelContext,
|
||||
)
|
||||
|
||||
|
||||
@@ -395,6 +394,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()
|
||||
|
||||
@@ -793,642 +793,3 @@ class TestMentalModelTags:
|
||||
)
|
||||
assert "tags" in model
|
||||
assert isinstance(model["tags"], list)
|
||||
|
||||
|
||||
class TestDirectives:
|
||||
"""Test directive mental model functionality."""
|
||||
|
||||
async def test_create_directive(self, memory: MemoryEngine, request_context):
|
||||
"""Test creating a directive mental model with user-provided observations."""
|
||||
bank_id = f"test-directive-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Ensure bank exists
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
# Create a directive with observations
|
||||
model = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Competitor Policy",
|
||||
description="Rules about mentioning competitors",
|
||||
subtype="directive",
|
||||
observations=[
|
||||
{"title": "Never mention", "content": "Never mention competitor product names directly"},
|
||||
{"title": "Redirect", "content": "If asked about competitors, redirect to our features"},
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert model["name"] == "Competitor Policy"
|
||||
assert model["description"] == "Rules about mentioning competitors"
|
||||
assert model["subtype"] == "directive"
|
||||
assert len(model["observations"]) == 2
|
||||
assert model["observations"][0].title == "Never mention"
|
||||
assert model["observations"][0].content == "Never mention competitor product names directly"
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
async def test_directive_included_in_list(self, memory: MemoryEngine, request_context):
|
||||
"""Test that directives are included in list_mental_models for admin visibility."""
|
||||
bank_id = f"test-directive-list-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Set up bank with mission
|
||||
await memory.set_bank_mission(
|
||||
bank_id=bank_id,
|
||||
mission="Test mission",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Create a directive
|
||||
directive = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Test Directive",
|
||||
description="A test directive",
|
||||
subtype="directive",
|
||||
observations=[{"title": "Rule", "content": "Follow this rule"}],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Create a pinned model
|
||||
pinned = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Test Pinned",
|
||||
description="A test pinned model",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# List without subtype filter - both should appear
|
||||
models = await memory.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Both should appear (directives included in API listing for admin visibility)
|
||||
model_ids = [m["id"] for m in models]
|
||||
assert pinned["id"] in model_ids
|
||||
assert directive["id"] in model_ids
|
||||
|
||||
# List with directive subtype filter - should find only directive
|
||||
directives = await memory.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
subtype="directive",
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(directives) == 1
|
||||
assert directives[0]["id"] == directive["id"]
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
async def test_directive_get_includes_observations(self, memory: MemoryEngine, request_context):
|
||||
"""Test that getting a directive returns its user-provided observations."""
|
||||
bank_id = f"test-directive-get-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Ensure bank exists
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
# Create a directive with observations
|
||||
created = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Meeting Rules",
|
||||
description="Rules for scheduling meetings",
|
||||
subtype="directive",
|
||||
observations=[
|
||||
{"title": "No mornings", "content": "Never schedule meetings before noon"},
|
||||
{"title": "Max duration", "content": "Meetings should be 30 minutes max"},
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Get the directive
|
||||
retrieved = await memory.get_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=created["id"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved["subtype"] == "directive"
|
||||
assert len(retrieved["observations"]) == 2
|
||||
assert retrieved["observations"][0].title == "No mornings"
|
||||
assert retrieved["observations"][1].title == "Max duration"
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
async def test_directive_survives_refresh(self, memory: MemoryEngine, request_context):
|
||||
"""Test that directives are not modified during refresh_mental_models."""
|
||||
bank_id = f"test-directive-refresh-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Set up bank with mission
|
||||
await memory.set_bank_mission(
|
||||
bank_id=bank_id,
|
||||
mission="Test mission",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Add some test data
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[{"content": "Alice is the engineer."}],
|
||||
request_context=request_context,
|
||||
)
|
||||
await memory.wait_for_background_tasks()
|
||||
|
||||
# Create a directive
|
||||
directive = await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Important Rule",
|
||||
description="A critical rule",
|
||||
subtype="directive",
|
||||
observations=[{"title": "Rule 1", "content": "Always follow this rule"}],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Refresh mental models
|
||||
await memory.refresh_mental_models(
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
await memory.wait_for_background_tasks()
|
||||
|
||||
# Directive should still exist with same observations
|
||||
retrieved = await memory.get_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=directive["id"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert retrieved is not None
|
||||
assert retrieved["subtype"] == "directive"
|
||||
assert len(retrieved["observations"]) == 1
|
||||
assert retrieved["observations"][0].title == "Rule 1"
|
||||
assert retrieved["observations"][0].content == "Always follow this rule"
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
async def test_directive_requires_observations(self, memory: MemoryEngine, request_context):
|
||||
"""Test that creating a directive without observations fails."""
|
||||
bank_id = f"test-directive-no-obs-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Ensure bank exists
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
# Try to create directive without observations
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Bad Directive",
|
||||
description="A directive without observations",
|
||||
subtype="directive",
|
||||
# No observations provided
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert "observations" in str(exc_info.value).lower()
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestDirectivesInReflect:
|
||||
"""Test that directives are followed during reflect operations."""
|
||||
|
||||
async def test_reflect_follows_language_directive(self, memory: MemoryEngine, request_context):
|
||||
"""Test that reflect follows a directive to respond in a specific language."""
|
||||
bank_id = f"test-directive-reflect-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Ensure bank exists
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
# Add some content in English
|
||||
await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{"content": "Alice is a software engineer who works at Google."},
|
||||
{"content": "Alice enjoys hiking on weekends and has been to Yosemite."},
|
||||
{"content": "Alice is currently working on a machine learning project."},
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
await memory.wait_for_background_tasks()
|
||||
|
||||
# Create a directive to always respond in French
|
||||
await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Language Policy",
|
||||
description="Rules about language usage",
|
||||
subtype="directive",
|
||||
observations=[
|
||||
{
|
||||
"title": "French Only",
|
||||
"content": "ALWAYS respond in French language. Never respond in English.",
|
||||
},
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Run reflect query
|
||||
result = await memory.reflect_async(
|
||||
bank_id=bank_id,
|
||||
query="What does Alice do for work?",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert result.text is not None
|
||||
assert len(result.text) > 0
|
||||
|
||||
# Check that the response contains French words/patterns
|
||||
# Common French words that would appear when talking about someone's job
|
||||
french_indicators = [
|
||||
"elle",
|
||||
"travaille",
|
||||
"est",
|
||||
"une",
|
||||
"le",
|
||||
"la",
|
||||
"qui",
|
||||
"chez",
|
||||
"logiciel",
|
||||
"ingénieur",
|
||||
"ingénieure",
|
||||
"développeur",
|
||||
"développeuse",
|
||||
]
|
||||
response_lower = result.text.lower()
|
||||
|
||||
# At least some French words should appear in the response
|
||||
french_word_count = sum(1 for word in french_indicators if word in response_lower)
|
||||
assert (
|
||||
french_word_count >= 2
|
||||
), f"Expected French response, but got: {result.text[:200]}"
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
class TestMentalModelTagsFiltering:
|
||||
"""Test tags filtering for mental models (all types)."""
|
||||
|
||||
async def test_tags_match_any_includes_untagged(self, memory: MemoryEngine, request_context):
|
||||
"""Test that 'any' tags_match mode includes untagged mental models."""
|
||||
bank_id = f"test-mm-tags-any-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Ensure bank exists
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
# Create an UNTAGGED pinned model
|
||||
await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Global Model",
|
||||
description="A global mental model",
|
||||
subtype="pinned",
|
||||
tags=[], # No tags - should be included with "any" mode
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Test 1: list_mental_models with tags and tags_match="any" should include untagged
|
||||
models_any = await memory.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
tags=["some-tag"],
|
||||
tags_match="any", # Should include untagged
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(models_any) == 1, f"Expected untagged model with 'any' mode, got {len(models_any)}"
|
||||
|
||||
# Test 2: list_mental_models with tags and tags_match="any_strict" should exclude untagged
|
||||
models_strict = await memory.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
tags=["some-tag"],
|
||||
tags_match="any_strict", # Should exclude untagged
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(models_strict) == 0, f"Expected no models with 'any_strict' mode, got {len(models_strict)}"
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
async def test_tags_match_strict_modes(self, memory: MemoryEngine, request_context):
|
||||
"""Test that strict modes only include mental models with matching tags."""
|
||||
bank_id = f"test-mm-tags-strict-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Ensure bank exists
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
# Create a TAGGED pinned model
|
||||
await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Tagged Model",
|
||||
description="A tagged mental model",
|
||||
subtype="pinned",
|
||||
tags=["project-a"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Create an UNTAGGED pinned model
|
||||
await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Untagged Model",
|
||||
description="An untagged mental model",
|
||||
subtype="pinned",
|
||||
tags=[], # No tags
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Test 1: any_strict with matching tag - should get ONLY the tagged model
|
||||
models_match = await memory.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
tags=["project-a"],
|
||||
tags_match="any_strict",
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(models_match) == 1, f"Expected 1 model with matching tag, got {len(models_match)}"
|
||||
assert models_match[0]["name"] == "Tagged Model"
|
||||
|
||||
# Test 2: any_strict with different tag - should get NO models
|
||||
models_no_match = await memory.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
tags=["project-b"],
|
||||
tags_match="any_strict",
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(models_no_match) == 0, f"Expected no models with non-matching tag, got {len(models_no_match)}"
|
||||
|
||||
# Test 3: any (non-strict) with any tag - should get BOTH models
|
||||
models_any = await memory.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
tags=["project-a"],
|
||||
tags_match="any",
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(models_any) == 2, f"Expected 2 models with 'any' mode, got {len(models_any)}"
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
async def test_tags_match_all_strict(self, memory: MemoryEngine, request_context):
|
||||
"""Test that 'all_strict' requires ALL tags to be present."""
|
||||
bank_id = f"test-mm-tags-all-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Ensure bank exists
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
# Create a model with multiple tags
|
||||
await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Multi-Tag Model",
|
||||
description="Has project-a and project-b tags",
|
||||
subtype="pinned",
|
||||
tags=["project-a", "project-b"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Create a model with only one tag
|
||||
await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Single-Tag Model",
|
||||
description="Has only project-a tag",
|
||||
subtype="pinned",
|
||||
tags=["project-a"],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Test 1: all_strict with both tags - should get ONLY the multi-tag model
|
||||
models_all = await memory.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
tags=["project-a", "project-b"],
|
||||
tags_match="all_strict",
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(models_all) == 1, f"Expected 1 model with all tags, got {len(models_all)}"
|
||||
assert models_all[0]["name"] == "Multi-Tag Model"
|
||||
|
||||
# Test 2: all (non-strict) with both tags - should include untagged too
|
||||
# Add an untagged model
|
||||
await memory.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Untagged Model",
|
||||
description="No tags",
|
||||
subtype="pinned",
|
||||
tags=[],
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
models_all_non_strict = await memory.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
tags=["project-a", "project-b"],
|
||||
tags_match="all",
|
||||
request_context=request_context,
|
||||
)
|
||||
# Should get Multi-Tag Model + Untagged Model
|
||||
assert len(models_all_non_strict) == 2, f"Expected 2 models with 'all' mode, got {len(models_all_non_strict)}"
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
class TestDirectivesPromptInjection:
|
||||
"""Test that directives are properly injected into the system prompt."""
|
||||
|
||||
def test_build_directives_section_empty(self):
|
||||
"""Test that empty directives returns empty string."""
|
||||
from hindsight_api.engine.reflect.prompts import build_directives_section
|
||||
|
||||
result = build_directives_section([])
|
||||
assert result == ""
|
||||
|
||||
def test_build_directives_section_with_observations(self):
|
||||
"""Test that directives with observations are formatted correctly."""
|
||||
from hindsight_api.engine.reflect.prompts import build_directives_section
|
||||
|
||||
directives = [
|
||||
{
|
||||
"name": "Competitor Policy",
|
||||
"observations": [
|
||||
{"title": "Never mention", "content": "Never mention competitor names"},
|
||||
{"title": "Redirect", "content": "Redirect to our features"},
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
result = build_directives_section(directives)
|
||||
|
||||
assert "## DIRECTIVES (MANDATORY)" in result
|
||||
assert "**Never mention**: Never mention competitor names" in result
|
||||
assert "**Redirect**: Redirect to our features" in result
|
||||
assert "NEVER violate these directives" in result
|
||||
|
||||
def test_build_directives_section_fallback_to_description(self):
|
||||
"""Test that directives without observations fall back to description."""
|
||||
from hindsight_api.engine.reflect.prompts import build_directives_section
|
||||
|
||||
directives = [
|
||||
{
|
||||
"name": "Simple Rule",
|
||||
"description": "Just a simple rule description",
|
||||
"observations": [],
|
||||
}
|
||||
]
|
||||
|
||||
result = build_directives_section(directives)
|
||||
|
||||
assert "**Simple Rule**: Just a simple rule description" in result
|
||||
|
||||
def test_system_prompt_includes_directives(self):
|
||||
"""Test that build_system_prompt_for_tools includes directives."""
|
||||
from hindsight_api.engine.reflect.prompts import build_system_prompt_for_tools
|
||||
|
||||
bank_profile = {"name": "Test Bank", "mission": "Test mission"}
|
||||
directives = [
|
||||
{
|
||||
"name": "Test Directive",
|
||||
"observations": [{"title": "Rule", "content": "Follow this rule"}],
|
||||
}
|
||||
]
|
||||
|
||||
prompt = build_system_prompt_for_tools(
|
||||
bank_profile=bank_profile,
|
||||
directives=directives,
|
||||
)
|
||||
|
||||
assert "## DIRECTIVES (MANDATORY)" in prompt
|
||||
assert "**Rule**: Follow this rule" in prompt
|
||||
# Directives should appear before CRITICAL RULES
|
||||
directives_pos = prompt.find("## DIRECTIVES")
|
||||
critical_rules_pos = prompt.find("## CRITICAL RULES")
|
||||
assert directives_pos < critical_rules_pos
|
||||
|
||||
|
||||
class TestMentalModelVersioning:
|
||||
"""Test mental model versioning functionality."""
|
||||
|
||||
async def test_refresh_creates_version(self, memory_with_mission, request_context):
|
||||
"""Test that refreshing a mental model creates a version entry."""
|
||||
memory, bank_id = memory_with_mission
|
||||
|
||||
# First create a mental model via refresh_mental_models
|
||||
await memory.refresh_mental_models(
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
await memory.wait_for_background_tasks()
|
||||
|
||||
# Get the created models
|
||||
models = await memory.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(models) > 0
|
||||
|
||||
model_id = models[0]["id"]
|
||||
|
||||
# Refresh the specific model to trigger versioning
|
||||
result = await memory.refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
# Version should be incremented
|
||||
assert result.get("version", 0) >= 1
|
||||
|
||||
# Check version history
|
||||
versions = await memory.get_mental_model_versions(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert len(versions) >= 1
|
||||
assert versions[0]["version"] >= 1
|
||||
assert "created_at" in versions[0]
|
||||
assert "observation_count" in versions[0]
|
||||
|
||||
async def test_get_specific_version(self, memory_with_mission, request_context):
|
||||
"""Test retrieving a specific version of a mental model."""
|
||||
memory, bank_id = memory_with_mission
|
||||
|
||||
# Create and refresh a mental model
|
||||
await memory.refresh_mental_models(
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
await memory.wait_for_background_tasks()
|
||||
|
||||
models = await memory.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(models) > 0
|
||||
|
||||
model_id = models[0]["id"]
|
||||
|
||||
# Refresh to create version
|
||||
await memory.refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Get versions
|
||||
versions = await memory.get_mental_model_versions(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(versions) >= 1
|
||||
|
||||
# Get specific version
|
||||
version_num = versions[0]["version"]
|
||||
version_data = await memory.get_mental_model_version(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
version=version_num,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
assert version_data is not None
|
||||
assert version_data["version"] == version_num
|
||||
assert "observations" in version_data
|
||||
|
||||
async def test_version_cleanup_keeps_max_versions(self, memory_with_mission, request_context):
|
||||
"""Test that old versions are cleaned up when max is exceeded."""
|
||||
memory, bank_id = memory_with_mission
|
||||
|
||||
# Create a mental model
|
||||
await memory.refresh_mental_models(
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
await memory.wait_for_background_tasks()
|
||||
|
||||
models = await memory.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(models) > 0
|
||||
|
||||
model_id = models[0]["id"]
|
||||
|
||||
# Refresh multiple times to create versions
|
||||
for _ in range(3):
|
||||
await memory.refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Get versions - should have multiple but within max limit
|
||||
versions = await memory.get_mental_model_versions(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Should have versions (exact count depends on config, but at least some)
|
||||
assert len(versions) >= 1
|
||||
# Versions should be in descending order
|
||||
if len(versions) > 1:
|
||||
assert versions[0]["version"] > versions[1]["version"]
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -88,19 +88,7 @@ class TestToolLookup:
|
||||
"subtype": "learned",
|
||||
"name": "Model 1",
|
||||
"description": "First model",
|
||||
"observations": {
|
||||
"observations": [
|
||||
{
|
||||
"title": "Overview",
|
||||
"content": "Full summary of model 1",
|
||||
"evidence": [
|
||||
{"memory_id": "mem-1", "quote": "quote 1", "relevance": "relevant", "timestamp": "2024-01-01T00:00:00Z"},
|
||||
{"memory_id": "mem-2", "quote": "quote 2", "relevance": "relevant", "timestamp": "2024-01-01T00:00:00Z"},
|
||||
],
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
}
|
||||
]
|
||||
},
|
||||
"observations": {"observations": [{"title": "Overview", "text": "Full summary of model 1", "memory_ids": ["mem-1", "mem-2"]}]},
|
||||
"entity_id": None,
|
||||
"last_updated": MagicMock(isoformat=lambda: "2024-01-01T00:00:00"),
|
||||
}
|
||||
@@ -110,12 +98,9 @@ class TestToolLookup:
|
||||
assert result["found"] is True
|
||||
assert result["model"]["id"] == "model-1"
|
||||
assert len(result["model"]["observations"]) == 1
|
||||
# Observations are now Observation objects
|
||||
obs = result["model"]["observations"][0]
|
||||
assert obs.content == "Full summary of model 1"
|
||||
assert obs.title == "Overview"
|
||||
assert len(obs.evidence) == 2
|
||||
assert obs.evidence[0].memory_id == "mem-1"
|
||||
assert result["model"]["observations"][0]["text"] == "Full summary of model 1"
|
||||
# Verify memory_ids are mapped to based_on
|
||||
assert result["model"]["observations"][0]["based_on"] == ["mem-1", "mem-2"]
|
||||
|
||||
async def test_model_not_found(self, mock_conn):
|
||||
"""Test looking up non-existent model."""
|
||||
@@ -855,158 +840,6 @@ class TestReflectAgent:
|
||||
|
||||
assert result.text == "The answer is simple and direct."
|
||||
|
||||
async def test_agent_includes_directives_in_system_prompt(self, mock_llm, bank_profile, mock_tools):
|
||||
"""Test that directives are included in the system prompt."""
|
||||
from hindsight_api.engine.reflect.observations import Observation
|
||||
|
||||
# Create directive with Observation objects (new format)
|
||||
directives = [
|
||||
{
|
||||
"id": "response-rules",
|
||||
"name": "Response Rules",
|
||||
"description": "Rules for responses",
|
||||
"subtype": "directive",
|
||||
"observations": [
|
||||
Observation(
|
||||
title="No Speculation",
|
||||
content="Never speculate about information not in the memories.",
|
||||
evidence=[],
|
||||
),
|
||||
Observation(
|
||||
title="Be Concise",
|
||||
content="Always keep responses under 100 words.",
|
||||
evidence=[],
|
||||
),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
# Capture the system prompt
|
||||
captured_messages = []
|
||||
|
||||
async def capture_call(*args, **kwargs):
|
||||
if "messages" in kwargs:
|
||||
captured_messages.extend(kwargs["messages"])
|
||||
return self._make_tool_result([{"name": "done", "arguments": {"answer": "Done."}}])
|
||||
|
||||
mock_llm.call_with_tools.side_effect = [
|
||||
# First: gather evidence (guardrail requirement)
|
||||
self._make_tool_result([{"name": "recall", "arguments": {"query": "test"}}]),
|
||||
# Then: done
|
||||
self._make_tool_result([{"name": "done", "arguments": {"answer": "Done."}}]),
|
||||
]
|
||||
|
||||
# Store original to check messages
|
||||
original_call = mock_llm.call_with_tools
|
||||
|
||||
async def wrapped_call(*args, **kwargs):
|
||||
if "messages" in kwargs:
|
||||
captured_messages.extend(kwargs["messages"])
|
||||
return await original_call(*args, **kwargs)
|
||||
|
||||
mock_llm.call_with_tools = wrapped_call
|
||||
|
||||
result = await run_reflect_agent(
|
||||
llm_config=mock_llm,
|
||||
bank_id="test-bank",
|
||||
query="What do we know?",
|
||||
bank_profile=bank_profile,
|
||||
directives=directives,
|
||||
**mock_tools,
|
||||
)
|
||||
|
||||
# Find the system message
|
||||
system_messages = [m for m in captured_messages if m.get("role") == "system"]
|
||||
assert len(system_messages) > 0, "No system message found"
|
||||
|
||||
system_content = system_messages[0]["content"]
|
||||
|
||||
# Verify directives are in the system prompt
|
||||
assert "DIRECTIVES" in system_content, "Directives section not found in system prompt"
|
||||
assert "No Speculation" in system_content, "Directive title not found"
|
||||
assert "Never speculate" in system_content, "Directive content not found"
|
||||
assert "Be Concise" in system_content, "Second directive title not found"
|
||||
assert "100 words" in system_content, "Second directive content not found"
|
||||
assert "NEVER violate these directives" in system_content, "Directive warning not found"
|
||||
|
||||
|
||||
class TestDirectivesSection:
|
||||
"""Test the directives section builder."""
|
||||
|
||||
def test_build_directives_section_with_observation_objects(self):
|
||||
"""Test building directives section with Observation objects."""
|
||||
from hindsight_api.engine.reflect.observations import Observation
|
||||
from hindsight_api.engine.reflect.prompts import build_directives_section
|
||||
|
||||
directives = [
|
||||
{
|
||||
"name": "Safety Rules",
|
||||
"observations": [
|
||||
Observation(
|
||||
title="No Harmful Content",
|
||||
content="Never generate harmful or dangerous content.",
|
||||
evidence=[],
|
||||
),
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
result = build_directives_section(directives)
|
||||
|
||||
assert "DIRECTIVES" in result
|
||||
assert "No Harmful Content" in result
|
||||
assert "Never generate harmful" in result
|
||||
assert "NEVER violate" in result
|
||||
|
||||
def test_build_directives_section_with_dicts(self):
|
||||
"""Test building directives section with dict observations."""
|
||||
from hindsight_api.engine.reflect.prompts import build_directives_section
|
||||
|
||||
directives = [
|
||||
{
|
||||
"name": "Safety Rules",
|
||||
"observations": [
|
||||
{
|
||||
"title": "No Harmful Content",
|
||||
"content": "Never generate harmful or dangerous content.",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
result = build_directives_section(directives)
|
||||
|
||||
assert "DIRECTIVES" in result
|
||||
assert "No Harmful Content" in result
|
||||
assert "Never generate harmful" in result
|
||||
|
||||
def test_build_directives_section_fallback_to_description(self):
|
||||
"""Test that directives without observations use description."""
|
||||
from hindsight_api.engine.reflect.prompts import build_directives_section
|
||||
|
||||
directives = [
|
||||
{
|
||||
"name": "Simple Rule",
|
||||
"description": "This is a simple rule to follow.",
|
||||
"observations": [],
|
||||
},
|
||||
]
|
||||
|
||||
result = build_directives_section(directives)
|
||||
|
||||
assert "Simple Rule" in result
|
||||
assert "simple rule to follow" in result
|
||||
|
||||
def test_build_directives_section_empty(self):
|
||||
"""Test that empty directives returns empty string."""
|
||||
from hindsight_api.engine.reflect.prompts import build_directives_section
|
||||
|
||||
result = build_directives_section([])
|
||||
assert result == ""
|
||||
|
||||
result = build_directives_section(None)
|
||||
assert result == ""
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestReflectIntegration:
|
||||
|
||||
@@ -2058,26 +2058,3 @@ async def test_user_provided_entities(memory, request_context):
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
def test_recall_result_model_empty_construction():
|
||||
"""
|
||||
Test that RecallResultModel can be constructed with empty results.
|
||||
|
||||
This is a regression test for the bug where constructing an empty RecallResultModel
|
||||
would cause an UnboundLocalError because RecallResult was imported as RecallResultModel
|
||||
but the code mistakenly used the wrong name.
|
||||
|
||||
The fix ensures RecallResultModel is used consistently throughout memory_engine.py.
|
||||
"""
|
||||
from hindsight_api.engine.response_models import RecallResult
|
||||
|
||||
# This should not raise any errors
|
||||
result = RecallResult(results=[], entities={}, chunks={})
|
||||
|
||||
assert result is not None, "Should create a result object"
|
||||
assert result.results == [], "Should have empty results"
|
||||
assert result.entities == {}, "Should have empty entities"
|
||||
assert result.chunks == {}, "Should have empty chunks"
|
||||
|
||||
logger.info("✓ RecallResult empty construction works correctly")
|
||||
|
||||
@@ -257,7 +257,6 @@ from hindsight_api.extensions import (
|
||||
RetainContext,
|
||||
RecallContext,
|
||||
ReflectContext,
|
||||
RefreshMentalModelContext,
|
||||
)
|
||||
|
||||
|
||||
@@ -289,6 +288,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()
|
||||
|
||||
@@ -1,592 +0,0 @@
|
||||
"""
|
||||
Tests for the distributed worker system.
|
||||
|
||||
Tests cover:
|
||||
- BrokerTaskBackend task submission and storage
|
||||
- WorkerPoller task claiming with FOR UPDATE SKIP LOCKED
|
||||
- Concurrent workers claiming different tasks (no duplicates)
|
||||
- Task completion and failure handling
|
||||
- Retry mechanism
|
||||
- Worker decommissioning
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from hindsight_api.engine.task_backend import BrokerTaskBackend, SyncTaskBackend
|
||||
|
||||
|
||||
# Use loadgroup to ensure these tests run in the same worker
|
||||
# since they share database state
|
||||
pytestmark = pytest.mark.xdist_group("worker_tests")
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def pool(pg0_db_url):
|
||||
"""Create a dedicated connection pool for worker tests."""
|
||||
import asyncpg
|
||||
|
||||
from hindsight_api.pg0 import resolve_database_url
|
||||
|
||||
# Resolve pg0:// URL to postgresql:// URL if needed
|
||||
resolved_url = await resolve_database_url(pg0_db_url)
|
||||
|
||||
pool = await asyncpg.create_pool(
|
||||
resolved_url,
|
||||
min_size=2,
|
||||
max_size=10,
|
||||
command_timeout=30,
|
||||
)
|
||||
yield pool
|
||||
await pool.close()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def clean_operations(pool):
|
||||
"""Clean up async_operations table before and after tests."""
|
||||
# Clean before test
|
||||
await pool.execute("DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%'")
|
||||
yield
|
||||
# Clean after test
|
||||
await pool.execute("DELETE FROM async_operations WHERE bank_id LIKE 'test-worker-%'")
|
||||
|
||||
|
||||
class TestBrokerTaskBackend:
|
||||
"""Tests for BrokerTaskBackend task storage."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_task_updates_existing_operation(self, pool, clean_operations):
|
||||
"""Test that submit_task updates task_payload for existing operations."""
|
||||
# Create an operation record first
|
||||
operation_id = uuid.uuid4()
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status)
|
||||
VALUES ($1, $2, 'test_operation', 'pending')
|
||||
""",
|
||||
operation_id,
|
||||
bank_id,
|
||||
)
|
||||
|
||||
# Submit task with same operation_id
|
||||
backend = BrokerTaskBackend(pool_getter=lambda: pool)
|
||||
await backend.initialize()
|
||||
|
||||
task_dict = {
|
||||
"operation_id": str(operation_id),
|
||||
"type": "test_task",
|
||||
"bank_id": bank_id,
|
||||
"data": {"key": "value"},
|
||||
}
|
||||
await backend.submit_task(task_dict)
|
||||
|
||||
# Verify task_payload was stored
|
||||
row = await pool.fetchrow(
|
||||
"SELECT task_payload, status FROM async_operations WHERE operation_id = $1",
|
||||
operation_id,
|
||||
)
|
||||
assert row is not None
|
||||
assert row["status"] == "pending"
|
||||
payload = json.loads(row["task_payload"])
|
||||
assert payload["type"] == "test_task"
|
||||
assert payload["data"] == {"key": "value"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_submit_task_creates_new_operation(self, pool, clean_operations):
|
||||
"""Test that submit_task creates new operation when no operation_id provided."""
|
||||
backend = BrokerTaskBackend(pool_getter=lambda: pool)
|
||||
await backend.initialize()
|
||||
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
task_dict = {
|
||||
"type": "access_count_update",
|
||||
"bank_id": bank_id,
|
||||
"node_ids": ["node1", "node2"],
|
||||
}
|
||||
await backend.submit_task(task_dict)
|
||||
|
||||
# Verify new operation was created
|
||||
row = await pool.fetchrow(
|
||||
"SELECT operation_type, status, task_payload FROM async_operations WHERE bank_id = $1",
|
||||
bank_id,
|
||||
)
|
||||
assert row is not None
|
||||
assert row["operation_type"] == "access_count_update"
|
||||
assert row["status"] == "pending"
|
||||
payload = json.loads(row["task_payload"])
|
||||
assert payload["node_ids"] == ["node1", "node2"]
|
||||
|
||||
|
||||
class TestWorkerPoller:
|
||||
"""Tests for WorkerPoller task claiming and execution."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_batch_claims_pending_tasks(self, pool, clean_operations):
|
||||
"""Test that claim_batch claims pending tasks with task_payload."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
# Create some pending tasks
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
for i in range(3):
|
||||
op_id = uuid.uuid4()
|
||||
payload = json.dumps({"type": "test_task", "index": i, "bank_id": bank_id})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
VALUES ($1, $2, 'test', 'pending', $3::jsonb)
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
payload,
|
||||
)
|
||||
|
||||
# Create poller and claim tasks
|
||||
executed_tasks = []
|
||||
|
||||
async def mock_executor(task_dict):
|
||||
executed_tasks.append(task_dict)
|
||||
|
||||
poller = WorkerPoller(
|
||||
pool=pool,
|
||||
worker_id="test-worker-1",
|
||||
executor=mock_executor,
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
claimed = await poller.claim_batch()
|
||||
assert len(claimed) == 3
|
||||
|
||||
# Verify tasks are marked as processing with worker_id
|
||||
rows = await pool.fetch(
|
||||
"SELECT status, worker_id FROM async_operations WHERE bank_id = $1",
|
||||
bank_id,
|
||||
)
|
||||
for row in rows:
|
||||
assert row["status"] == "processing"
|
||||
assert row["worker_id"] == "test-worker-1"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claim_batch_respects_batch_size(self, pool, clean_operations):
|
||||
"""Test that claim_batch respects the batch_size limit."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
# Create 10 pending tasks
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
for i in range(10):
|
||||
op_id = uuid.uuid4()
|
||||
payload = json.dumps({"type": "test_task", "index": i, "bank_id": bank_id})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
VALUES ($1, $2, 'test', 'pending', $3::jsonb)
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
payload,
|
||||
)
|
||||
|
||||
# Claim with batch_size=3
|
||||
poller = WorkerPoller(
|
||||
pool=pool,
|
||||
worker_id="test-worker-1",
|
||||
executor=lambda x: None,
|
||||
batch_size=3,
|
||||
)
|
||||
|
||||
claimed = await poller.claim_batch()
|
||||
assert len(claimed) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_task_marks_completed(self, pool, clean_operations):
|
||||
"""Test that successful task execution marks task as completed."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
# Create a pending task
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
op_id = uuid.uuid4()
|
||||
payload = json.dumps({"type": "test_task", "operation_id": str(op_id), "bank_id": bank_id})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id)
|
||||
VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'test-worker-1')
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
payload,
|
||||
)
|
||||
|
||||
executed = []
|
||||
|
||||
async def mock_executor(task_dict):
|
||||
executed.append(task_dict)
|
||||
|
||||
poller = WorkerPoller(
|
||||
pool=pool,
|
||||
worker_id="test-worker-1",
|
||||
executor=mock_executor,
|
||||
)
|
||||
|
||||
# Execute the task
|
||||
task_dict = json.loads(payload)
|
||||
await poller.execute_task(str(op_id), task_dict)
|
||||
|
||||
assert len(executed) == 1
|
||||
|
||||
# Verify task is marked as completed
|
||||
row = await pool.fetchrow(
|
||||
"SELECT status, completed_at FROM async_operations WHERE operation_id = $1",
|
||||
op_id,
|
||||
)
|
||||
assert row["status"] == "completed"
|
||||
assert row["completed_at"] is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_task_retries_on_failure(self, pool, clean_operations):
|
||||
"""Test that failed task execution triggers retry mechanism."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
# Create a pending task with retry_count=0
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
op_id = uuid.uuid4()
|
||||
payload = json.dumps({"type": "test_task", "operation_id": str(op_id), "bank_id": bank_id})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id, retry_count)
|
||||
VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'test-worker-1', 0)
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
payload,
|
||||
)
|
||||
|
||||
async def failing_executor(task_dict):
|
||||
raise ValueError("Simulated failure")
|
||||
|
||||
poller = WorkerPoller(
|
||||
pool=pool,
|
||||
worker_id="test-worker-1",
|
||||
executor=failing_executor,
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
# Execute (should fail and retry)
|
||||
task_dict = json.loads(payload)
|
||||
await poller.execute_task(str(op_id), task_dict)
|
||||
|
||||
# Verify task is back to pending with incremented retry_count
|
||||
row = await pool.fetchrow(
|
||||
"SELECT status, retry_count, worker_id FROM async_operations WHERE operation_id = $1",
|
||||
op_id,
|
||||
)
|
||||
assert row["status"] == "pending"
|
||||
assert row["retry_count"] == 1
|
||||
assert row["worker_id"] is None # Worker ID cleared for retry
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_task_fails_after_max_retries(self, pool, clean_operations):
|
||||
"""Test that task is marked failed after exceeding max retries."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
# Create a task that has already used all retries
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
op_id = uuid.uuid4()
|
||||
payload = json.dumps({"type": "test_task", "operation_id": str(op_id), "bank_id": bank_id})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id, retry_count)
|
||||
VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'test-worker-1', 3)
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
payload,
|
||||
)
|
||||
|
||||
async def failing_executor(task_dict):
|
||||
raise ValueError("Simulated failure")
|
||||
|
||||
poller = WorkerPoller(
|
||||
pool=pool,
|
||||
worker_id="test-worker-1",
|
||||
executor=failing_executor,
|
||||
max_retries=3,
|
||||
)
|
||||
|
||||
# Execute (should fail permanently)
|
||||
task_dict = json.loads(payload)
|
||||
await poller.execute_task(str(op_id), task_dict)
|
||||
|
||||
# Verify task is marked as failed
|
||||
row = await pool.fetchrow(
|
||||
"SELECT status, error_message FROM async_operations WHERE operation_id = $1",
|
||||
op_id,
|
||||
)
|
||||
assert row["status"] == "failed"
|
||||
assert "Max retries" in row["error_message"]
|
||||
|
||||
|
||||
class TestConcurrentWorkers:
|
||||
"""Tests for concurrent worker task claiming (FOR UPDATE SKIP LOCKED)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_workers_claim_different_tasks(self, pool, clean_operations):
|
||||
"""Test that multiple workers claim different tasks (no duplicates)."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
# Create 10 pending tasks
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
task_ids = []
|
||||
for i in range(10):
|
||||
op_id = uuid.uuid4()
|
||||
task_ids.append(op_id)
|
||||
payload = json.dumps({"type": "test_task", "index": i, "bank_id": bank_id, "operation_id": str(op_id)})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
VALUES ($1, $2, 'test', 'pending', $3::jsonb)
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
payload,
|
||||
)
|
||||
|
||||
# Create 3 workers that will claim tasks concurrently
|
||||
workers_claimed: dict[str, list[str]] = {"worker-1": [], "worker-2": [], "worker-3": []}
|
||||
|
||||
async def claim_for_worker(worker_id: str):
|
||||
poller = WorkerPoller(
|
||||
pool=pool,
|
||||
worker_id=worker_id,
|
||||
executor=lambda x: None,
|
||||
batch_size=5, # Each worker tries to claim 5
|
||||
)
|
||||
claimed = await poller.claim_batch()
|
||||
workers_claimed[worker_id] = [op_id for op_id, _ in claimed]
|
||||
|
||||
# Run all workers concurrently
|
||||
await asyncio.gather(
|
||||
claim_for_worker("worker-1"),
|
||||
claim_for_worker("worker-2"),
|
||||
claim_for_worker("worker-3"),
|
||||
)
|
||||
|
||||
# Verify no duplicates - each task claimed by exactly one worker
|
||||
all_claimed = workers_claimed["worker-1"] + workers_claimed["worker-2"] + workers_claimed["worker-3"]
|
||||
assert len(all_claimed) == len(set(all_claimed)), "Duplicate task claimed by multiple workers!"
|
||||
|
||||
# Verify total claimed equals available tasks (10)
|
||||
assert len(all_claimed) == 10, f"Expected 10 tasks claimed, got {len(all_claimed)}"
|
||||
|
||||
# Verify each task is assigned to exactly one worker in DB
|
||||
rows = await pool.fetch(
|
||||
"SELECT operation_id, worker_id FROM async_operations WHERE bank_id = $1",
|
||||
bank_id,
|
||||
)
|
||||
worker_assignments = {str(row["operation_id"]): row["worker_id"] for row in rows}
|
||||
|
||||
# With FOR UPDATE SKIP LOCKED, it's a race condition which workers get tasks.
|
||||
# The important invariant is no duplicates and all tasks claimed, which we verified above.
|
||||
# Just verify that at least 1 worker got tasks and all tasks have a worker assigned.
|
||||
assert len(set(worker_assignments.values())) >= 1, "At least one worker should have claimed tasks"
|
||||
assert all(w is not None for w in worker_assignments.values()), "All tasks should have a worker assigned"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workers_do_not_claim_already_processing_tasks(self, pool, clean_operations):
|
||||
"""Test that workers skip tasks already being processed by another worker."""
|
||||
from hindsight_api.worker import WorkerPoller
|
||||
|
||||
# Create tasks - some pending, some already processing
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create 3 pending tasks
|
||||
for i in range(3):
|
||||
op_id = uuid.uuid4()
|
||||
payload = json.dumps({"type": "test_task", "index": i, "bank_id": bank_id})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload)
|
||||
VALUES ($1, $2, 'test', 'pending', $3::jsonb)
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
payload,
|
||||
)
|
||||
|
||||
# Create 2 already-processing tasks owned by another worker
|
||||
for i in range(2):
|
||||
op_id = uuid.uuid4()
|
||||
payload = json.dumps({"type": "test_task", "index": i + 10, "bank_id": bank_id})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id)
|
||||
VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'other-worker')
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
payload,
|
||||
)
|
||||
|
||||
# New worker should only claim the 3 pending tasks
|
||||
poller = WorkerPoller(
|
||||
pool=pool,
|
||||
worker_id="new-worker",
|
||||
executor=lambda x: None,
|
||||
batch_size=10,
|
||||
)
|
||||
|
||||
claimed = await poller.claim_batch()
|
||||
assert len(claimed) == 3, "Worker should only claim pending tasks"
|
||||
|
||||
# Verify other worker's tasks are still owned by them
|
||||
row = await pool.fetchrow(
|
||||
"SELECT COUNT(*) as count FROM async_operations WHERE bank_id = $1 AND worker_id = 'other-worker'",
|
||||
bank_id,
|
||||
)
|
||||
assert row["count"] == 2
|
||||
|
||||
|
||||
class TestWorkerDecommission:
|
||||
"""Tests for worker decommissioning functionality."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decommission_releases_worker_tasks(self, pool, clean_operations):
|
||||
"""Test that decommissioning a worker releases all its processing tasks."""
|
||||
# Create tasks being processed by a worker
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
worker_id = "worker-to-decommission"
|
||||
|
||||
for i in range(5):
|
||||
op_id = uuid.uuid4()
|
||||
payload = json.dumps({"type": "test_task", "index": i, "bank_id": bank_id})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id, claimed_at)
|
||||
VALUES ($1, $2, 'test', 'processing', $3::jsonb, $4, now())
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
payload,
|
||||
worker_id,
|
||||
)
|
||||
|
||||
# Run decommission
|
||||
result = await pool.fetch(
|
||||
"""
|
||||
UPDATE async_operations
|
||||
SET status = 'pending', worker_id = NULL, claimed_at = NULL, updated_at = now()
|
||||
WHERE worker_id = $1 AND status = 'processing'
|
||||
RETURNING operation_id
|
||||
""",
|
||||
worker_id,
|
||||
)
|
||||
|
||||
assert len(result) == 5
|
||||
|
||||
# Verify all tasks are back to pending
|
||||
rows = await pool.fetch(
|
||||
"SELECT status, worker_id, claimed_at FROM async_operations WHERE bank_id = $1",
|
||||
bank_id,
|
||||
)
|
||||
for row in rows:
|
||||
assert row["status"] == "pending"
|
||||
assert row["worker_id"] is None
|
||||
assert row["claimed_at"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decommission_does_not_affect_other_workers(self, pool, clean_operations):
|
||||
"""Test that decommissioning one worker doesn't affect another worker's tasks."""
|
||||
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create tasks for worker-1
|
||||
for i in range(3):
|
||||
op_id = uuid.uuid4()
|
||||
payload = json.dumps({"type": "test_task", "index": i, "bank_id": bank_id})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id)
|
||||
VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'worker-1')
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
payload,
|
||||
)
|
||||
|
||||
# Create tasks for worker-2
|
||||
for i in range(3):
|
||||
op_id = uuid.uuid4()
|
||||
payload = json.dumps({"type": "test_task", "index": i + 10, "bank_id": bank_id})
|
||||
await pool.execute(
|
||||
"""
|
||||
INSERT INTO async_operations (operation_id, bank_id, operation_type, status, task_payload, worker_id)
|
||||
VALUES ($1, $2, 'test', 'processing', $3::jsonb, 'worker-2')
|
||||
""",
|
||||
op_id,
|
||||
bank_id,
|
||||
payload,
|
||||
)
|
||||
|
||||
# Decommission worker-1 only
|
||||
await pool.execute(
|
||||
"""
|
||||
UPDATE async_operations
|
||||
SET status = 'pending', worker_id = NULL, claimed_at = NULL
|
||||
WHERE worker_id = 'worker-1' AND status = 'processing'
|
||||
"""
|
||||
)
|
||||
|
||||
# Verify worker-1 tasks are released
|
||||
worker1_rows = await pool.fetch(
|
||||
"SELECT status, worker_id FROM async_operations WHERE bank_id = $1 AND worker_id IS NULL",
|
||||
bank_id,
|
||||
)
|
||||
assert len(worker1_rows) == 3
|
||||
|
||||
# Verify worker-2 tasks are unaffected
|
||||
worker2_rows = await pool.fetch(
|
||||
"SELECT status, worker_id FROM async_operations WHERE bank_id = $1 AND worker_id = 'worker-2'",
|
||||
bank_id,
|
||||
)
|
||||
assert len(worker2_rows) == 3
|
||||
for row in worker2_rows:
|
||||
assert row["status"] == "processing"
|
||||
|
||||
|
||||
class TestSyncTaskBackend:
|
||||
"""Tests for SyncTaskBackend (used in tests and embedded mode)."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_backend_executes_immediately(self):
|
||||
"""Test that SyncTaskBackend executes tasks immediately."""
|
||||
executed = []
|
||||
|
||||
async def mock_executor(task_dict):
|
||||
executed.append(task_dict)
|
||||
|
||||
backend = SyncTaskBackend()
|
||||
backend.set_executor(mock_executor)
|
||||
await backend.initialize()
|
||||
|
||||
task_dict = {"type": "test", "data": "value"}
|
||||
await backend.submit_task(task_dict)
|
||||
|
||||
assert len(executed) == 1
|
||||
assert executed[0] == task_dict
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_backend_handles_errors(self):
|
||||
"""Test that SyncTaskBackend handles executor errors gracefully."""
|
||||
|
||||
async def failing_executor(task_dict):
|
||||
raise ValueError("Test error")
|
||||
|
||||
backend = SyncTaskBackend()
|
||||
backend.set_executor(failing_executor)
|
||||
await backend.initialize()
|
||||
|
||||
# Should not raise, error is logged
|
||||
await backend.submit_task({"type": "test"})
|
||||
@@ -45,10 +45,6 @@ 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" -y 2>/dev/null || true
|
||||
"$HINDSIGHT_CLI" bank delete "$TEST_BANK" 2>/dev/null || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -115,32 +115,8 @@ 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: Health check
|
||||
run_test_output "health check" "healthy" "$HINDSIGHT_CLI" health || FAILED=1
|
||||
|
||||
# Test 16: List memories (new command)
|
||||
run_test "list memories" "$HINDSIGHT_CLI" memory list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 17: List tags
|
||||
run_test "list tags" "$HINDSIGHT_CLI" tag list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 18: List mental models
|
||||
run_test "list mental models" "$HINDSIGHT_CLI" mental-model list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 19: Create mental model
|
||||
run_test "create mental model" "$HINDSIGHT_CLI" mental-model create "$TEST_BANK" "Test Model" "A test mental model" || FAILED=1
|
||||
|
||||
# Test 20: List mental models (should have one now)
|
||||
run_test_output "list mental models with model" "Test Model" "$HINDSIGHT_CLI" mental-model list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 21: Bank graph
|
||||
run_test "bank graph" "$HINDSIGHT_CLI" bank graph "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 22: List operations
|
||||
run_test "list operations" "$HINDSIGHT_CLI" operation list "$TEST_BANK" || FAILED=1
|
||||
|
||||
# Test 23: Delete bank
|
||||
run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" -y || FAILED=1
|
||||
# Test 15: Delete bank
|
||||
run_test "delete bank" "$HINDSIGHT_CLI" bank delete "$TEST_BANK" || FAILED=1
|
||||
|
||||
echo ""
|
||||
if [ $FAILED -eq 0 ]; then
|
||||
|
||||
@@ -55,7 +55,6 @@ pub struct MemoryPutResult {
|
||||
pub items_count: i64,
|
||||
pub message: String,
|
||||
pub is_async: bool,
|
||||
pub operation_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -163,54 +162,10 @@ impl ApiClient {
|
||||
items_count: result.items_count,
|
||||
message: format!("Stored {} memory units", result.items_count),
|
||||
is_async: result.async_,
|
||||
operation_id: result.operation_id,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Poll an operation until it completes or fails.
|
||||
/// Returns Ok(true) if completed successfully, Ok(false) if failed, Err if polling error.
|
||||
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 ops = response.into_inner();
|
||||
|
||||
// Find our operation
|
||||
let op = ops.operations.iter().find(|o| o.id == operation_id);
|
||||
|
||||
match op {
|
||||
Some(operation) => {
|
||||
if verbose {
|
||||
eprintln!("Operation {} status: {}", operation_id, operation.status);
|
||||
}
|
||||
match operation.status.as_str() {
|
||||
"pending" => {
|
||||
// Still running, wait and poll again
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
"completed" => {
|
||||
// Operation completed successfully
|
||||
return Ok((true, None));
|
||||
}
|
||||
"failed" => {
|
||||
return Ok((false, operation.error_message.clone()));
|
||||
}
|
||||
_ => {
|
||||
// Unknown status, treat as failed
|
||||
return Ok((false, Some(format!("Unknown status: {}", operation.status))));
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Operation not in list means it completed successfully (removed from pending/failed)
|
||||
return Ok((true, None));
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_memory(&self, _agent_id: &str, _unit_id: &str, _verbose: bool) -> Result<types::DeleteResponse> {
|
||||
// Note: Individual memory deletion is no longer supported in the API
|
||||
anyhow::bail!("Individual memory deletion is no longer supported. Use 'memory clear' to clear all memories.")
|
||||
@@ -316,266 +271,6 @@ impl ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Additional API methods for complete CLI coverage
|
||||
// ============================================================================
|
||||
|
||||
impl ApiClient {
|
||||
// --- Mental Model Methods ---
|
||||
|
||||
pub fn list_mental_models(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
subtype: Option<&str>,
|
||||
tags: Option<Vec<String>>,
|
||||
tags_match: Option<&str>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::MentalModelListResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let tags_match_enum = match tags_match {
|
||||
Some("all") => Some(types::TagsMatch::All),
|
||||
Some("any_strict") => Some(types::TagsMatch::AnyStrict),
|
||||
Some("all_strict") => Some(types::TagsMatch::AllStrict),
|
||||
_ => Some(types::TagsMatch::Any),
|
||||
};
|
||||
let response = self.client.list_mental_models(
|
||||
bank_id,
|
||||
subtype,
|
||||
tags.as_ref(),
|
||||
tags_match_enum,
|
||||
None,
|
||||
).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_mental_model(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::MentalModelResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_mental_model(bank_id, model_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_mental_model(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
request: &types::CreateMentalModelRequest,
|
||||
_verbose: bool,
|
||||
) -> Result<types::MentalModelResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.create_mental_model(bank_id, None, request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn delete_mental_model(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::DeleteResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.delete_mental_model(bank_id, model_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn update_mental_model(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
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, model_id, None, request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh_mental_models(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
subtype: Option<&str>,
|
||||
tags: Option<Vec<String>>,
|
||||
_verbose: bool,
|
||||
) -> Result<types::AsyncOperationSubmitResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let subtype_enum = match subtype {
|
||||
Some("structural") => Some(types::Subtype::Structural),
|
||||
Some("emergent") => Some(types::Subtype::Emergent),
|
||||
Some("pinned") => Some(types::Subtype::Pinned),
|
||||
Some("learned") => Some(types::Subtype::Learned),
|
||||
_ => None,
|
||||
};
|
||||
let request = types::RefreshMentalModelsRequest {
|
||||
subtype: subtype_enum,
|
||||
tags,
|
||||
};
|
||||
let response = self.client.refresh_mental_models(bank_id, None, &request).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn refresh_mental_model(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<types::AsyncOperationSubmitResponse> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.refresh_mental_model(bank_id, model_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_mental_model_versions(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
_verbose: bool,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.list_mental_model_versions(bank_id, model_id, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_mental_model_version(
|
||||
&self,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
version: i64,
|
||||
_verbose: bool,
|
||||
) -> Result<serde_json::Value> {
|
||||
self.runtime.block_on(async {
|
||||
let response = self.client.get_mental_model_version(bank_id, model_id, version, None).await?;
|
||||
Ok(response.into_inner())
|
||||
})
|
||||
}
|
||||
|
||||
// --- 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types from the generated client for use in commands
|
||||
pub use types::{
|
||||
BankProfileResponse,
|
||||
@@ -587,105 +282,3 @@ pub use types::{
|
||||
ReflectResponse,
|
||||
RetainRequest,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_operation_deserialize() {
|
||||
let json = r#"{
|
||||
"id": "test-op-123",
|
||||
"task_type": "retain",
|
||||
"items_count": 5,
|
||||
"document_id": "doc-456",
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"status": "pending",
|
||||
"error_message": null
|
||||
}"#;
|
||||
let op: Operation = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(op.id, "test-op-123");
|
||||
assert_eq!(op.task_type, "retain");
|
||||
assert_eq!(op.items_count, 5);
|
||||
assert_eq!(op.document_id, Some("doc-456".to_string()));
|
||||
assert_eq!(op.status, "pending");
|
||||
assert!(op.error_message.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_operation_deserialize_with_error() {
|
||||
let json = r#"{
|
||||
"id": "test-op-456",
|
||||
"task_type": "retain",
|
||||
"items_count": 3,
|
||||
"document_id": null,
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"status": "failed",
|
||||
"error_message": "Something went wrong"
|
||||
}"#;
|
||||
let op: Operation = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(op.status, "failed");
|
||||
assert_eq!(op.error_message, Some("Something went wrong".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_put_result_serialize() {
|
||||
let result = MemoryPutResult {
|
||||
success: true,
|
||||
items_count: 10,
|
||||
message: "Stored 10 memory units".to_string(),
|
||||
is_async: true,
|
||||
operation_id: Some("op-789".to_string()),
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"success\":true"));
|
||||
assert!(json.contains("\"items_count\":10"));
|
||||
assert!(json.contains("\"is_async\":true"));
|
||||
assert!(json.contains("\"operation_id\":\"op-789\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_put_result_without_operation_id() {
|
||||
let result = MemoryPutResult {
|
||||
success: true,
|
||||
items_count: 5,
|
||||
message: "Stored 5 memory units".to_string(),
|
||||
is_async: false,
|
||||
operation_id: None,
|
||||
};
|
||||
let json = serde_json::to_string(&result).unwrap();
|
||||
assert!(json.contains("\"operation_id\":null"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_operations_response_deserialize() {
|
||||
let json = r#"{
|
||||
"bank_id": "test-bank",
|
||||
"operations": [
|
||||
{
|
||||
"id": "op-1",
|
||||
"task_type": "retain",
|
||||
"items_count": 2,
|
||||
"document_id": null,
|
||||
"created_at": "2024-01-15T10:00:00Z",
|
||||
"status": "pending",
|
||||
"error_message": null
|
||||
},
|
||||
{
|
||||
"id": "op-2",
|
||||
"task_type": "retain",
|
||||
"items_count": 3,
|
||||
"document_id": "doc-123",
|
||||
"created_at": "2024-01-15T11:00:00Z",
|
||||
"status": "completed",
|
||||
"error_message": null
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
let ops: OperationsResponse = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(ops.bank_id, "test-bank");
|
||||
assert_eq!(ops.operations.len(), 2);
|
||||
assert_eq!(ops.operations[0].status, "pending");
|
||||
assert_eq!(ops.operations[1].status, "completed");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,226 +222,6 @@ 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,
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
//! 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);
|
||||
}
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
//! Health and metrics commands.
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::api::ApiClient;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
|
||||
// Local type for health response
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct HealthResponse {
|
||||
status: String,
|
||||
database: Option<String>,
|
||||
version: Option<String>,
|
||||
}
|
||||
|
||||
/// Check API health
|
||||
pub fn health(
|
||||
client: &ApiClient,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Checking health..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.health(verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(value) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
let result: HealthResponse = serde_json::from_value(value.clone())
|
||||
.unwrap_or(HealthResponse {
|
||||
status: "unknown".to_string(),
|
||||
database: None,
|
||||
version: None,
|
||||
});
|
||||
|
||||
let status_str = if result.status == "healthy" {
|
||||
ui::gradient_start(&result.status)
|
||||
} else {
|
||||
ui::gradient_end(&result.status)
|
||||
};
|
||||
|
||||
ui::print_section_header("Health Check");
|
||||
println!(" {} {}", ui::dim("Status:"), status_str);
|
||||
|
||||
if let Some(db_status) = &result.database {
|
||||
let db_str = if db_status == "connected" {
|
||||
ui::gradient_start(db_status)
|
||||
} else {
|
||||
ui::gradient_end(db_status)
|
||||
};
|
||||
println!(" {} {}", ui::dim("Database:"), db_str);
|
||||
}
|
||||
|
||||
if let Some(version) = &result.version {
|
||||
println!(" {} {}", ui::dim("Version:"), version);
|
||||
}
|
||||
|
||||
println!();
|
||||
} else {
|
||||
output::print_output(&value, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get Prometheus metrics
|
||||
pub fn metrics(
|
||||
client: &ApiClient,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching metrics..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.metrics(verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header("Prometheus Metrics");
|
||||
println!("{}", result);
|
||||
} else {
|
||||
// For JSON/YAML, wrap in an object
|
||||
let wrapped = serde_json::json!({ "metrics": result });
|
||||
output::print_output(&wrapped, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_health_response_deserialization() {
|
||||
let json = r#"{
|
||||
"status": "healthy",
|
||||
"database": "connected",
|
||||
"version": "0.3.0"
|
||||
}"#;
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(json).unwrap();
|
||||
let result: HealthResponse = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(result.status, "healthy");
|
||||
assert_eq!(result.database, Some("connected".to_string()));
|
||||
assert_eq!(result.version, Some("0.3.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_response_minimal() {
|
||||
let json = r#"{"status": "healthy"}"#;
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(json).unwrap();
|
||||
let result: HealthResponse = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(result.status, "healthy");
|
||||
assert_eq!(result.database, None);
|
||||
assert_eq!(result.version, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_response_unhealthy() {
|
||||
let json = r#"{
|
||||
"status": "unhealthy",
|
||||
"database": "disconnected"
|
||||
}"#;
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(json).unwrap();
|
||||
let result: HealthResponse = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(result.status, "unhealthy");
|
||||
assert_eq!(result.database, Some("disconnected".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -10,30 +10,8 @@ use crate::ui;
|
||||
|
||||
// Import types from generated client
|
||||
use hindsight_client::types::{Budget, ChunkIncludeOptions, IncludeOptions, TagsMatch};
|
||||
use serde::Deserialize;
|
||||
use serde_json;
|
||||
|
||||
// Local types for serde_json::Value deserialization
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MemoryUnitDetail {
|
||||
id: String,
|
||||
text: String,
|
||||
#[serde(rename = "type")]
|
||||
type_: Option<String>,
|
||||
document_id: Option<String>,
|
||||
context: Option<String>,
|
||||
occurred_start: Option<String>,
|
||||
occurred_end: Option<String>,
|
||||
entities: Option<Vec<EntityRef>>,
|
||||
tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EntityRef {
|
||||
id: String,
|
||||
name: String,
|
||||
}
|
||||
|
||||
// Helper function to parse budget string to Budget enum
|
||||
fn parse_budget(budget: &str) -> Budget {
|
||||
match budget.to_lowercase().as_str() {
|
||||
@@ -43,194 +21,6 @@ fn parse_budget(budget: &str) -> Budget {
|
||||
}
|
||||
}
|
||||
|
||||
/// List memory units with pagination and optional filters
|
||||
pub fn list(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
type_filter: Option<String>,
|
||||
query: Option<String>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching memories..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.list_memories(
|
||||
bank_id,
|
||||
type_filter.as_deref(),
|
||||
query.as_deref(),
|
||||
Some(limit),
|
||||
Some(offset),
|
||||
verbose,
|
||||
);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Memories: {} (showing {}-{})", bank_id, offset + 1, offset + result.items.len() as i64));
|
||||
|
||||
if result.items.is_empty() {
|
||||
println!(" {}", ui::dim("No memories found."));
|
||||
} else {
|
||||
for item in &result.items {
|
||||
let fact_type = item.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
let type_t = match fact_type {
|
||||
"world" => 0.0,
|
||||
"experience" => 0.5,
|
||||
"opinion" => 1.0,
|
||||
_ => 0.5,
|
||||
};
|
||||
|
||||
let id = item.get("id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("unknown");
|
||||
|
||||
println!(
|
||||
" {} {}",
|
||||
ui::gradient(&format!("[{}]", fact_type.to_uppercase()), type_t),
|
||||
ui::dim(id)
|
||||
);
|
||||
|
||||
// Truncate text if too long
|
||||
if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
|
||||
let text_preview: String = text.chars().take(100).collect();
|
||||
let ellipsis = if text.len() > 100 { "..." } else { "" };
|
||||
println!(" {}{}", text_preview, ellipsis);
|
||||
}
|
||||
|
||||
if let Some(doc_id) = item.get("document_id").and_then(|v| v.as_str()) {
|
||||
println!(" {} {}", ui::dim("doc:"), ui::dim(doc_id));
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
println!(" {} {} total", ui::dim("Total:"), result.total);
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a specific memory unit by ID
|
||||
pub fn get(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
memory_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching memory..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_memory(bank_id, memory_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(value) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
let result: MemoryUnitDetail = serde_json::from_value(value)
|
||||
.with_context(|| "Failed to parse memory response")?;
|
||||
|
||||
let fact_type = result.type_.as_deref().unwrap_or("unknown");
|
||||
let type_t = match fact_type {
|
||||
"world" => 0.0,
|
||||
"experience" => 0.5,
|
||||
"opinion" => 1.0,
|
||||
_ => 0.5,
|
||||
};
|
||||
|
||||
ui::print_section_header(&format!("Memory: {}", memory_id));
|
||||
|
||||
println!(" {} {}", ui::dim("Type:"), ui::gradient(&fact_type.to_uppercase(), type_t));
|
||||
println!(" {} {}", ui::dim("ID:"), result.id);
|
||||
|
||||
if let Some(doc_id) = &result.document_id {
|
||||
println!(" {} {}", ui::dim("Document:"), doc_id);
|
||||
}
|
||||
|
||||
if let Some(context) = &result.context {
|
||||
println!(" {} {}", ui::dim("Context:"), context);
|
||||
}
|
||||
|
||||
println!();
|
||||
println!("{}", ui::gradient_text("─── Content ───"));
|
||||
println!();
|
||||
println!("{}", result.text);
|
||||
|
||||
// Show temporal info if available
|
||||
if result.occurred_start.is_some() || result.occurred_end.is_some() {
|
||||
println!();
|
||||
println!("{}", ui::gradient_text("─── Temporal ───"));
|
||||
if let Some(start) = &result.occurred_start {
|
||||
println!(" {} {}", ui::dim("Start:"), start);
|
||||
}
|
||||
if let Some(end) = &result.occurred_end {
|
||||
println!(" {} {}", ui::dim("End:"), end);
|
||||
}
|
||||
}
|
||||
|
||||
// Show entities if available
|
||||
if let Some(entities) = &result.entities {
|
||||
if !entities.is_empty() {
|
||||
println!();
|
||||
println!("{}", ui::gradient_text("─── Entities ───"));
|
||||
for entity in entities {
|
||||
println!(" • {} ({})", entity.name, entity.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show tags if available
|
||||
if let Some(tags) = &result.tags {
|
||||
if !tags.is_empty() {
|
||||
println!();
|
||||
println!("{}", ui::gradient_text("─── Tags ───"));
|
||||
println!(" {}", tags.join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
} else {
|
||||
output::print_output(&value, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a file has a text-based extension
|
||||
fn is_text_file(path: &std::path::Path) -> bool {
|
||||
const TEXT_EXTENSIONS: &[&str] = &[
|
||||
"txt", "md", "json", "yaml", "yml", "toml", "xml", "csv", "log", "rst", "adoc",
|
||||
];
|
||||
path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| TEXT_EXTENSIONS.contains(&ext.to_lowercase().as_str()))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn recall(
|
||||
client: &ApiClient,
|
||||
agent_id: &str,
|
||||
@@ -439,23 +229,29 @@ pub fn retain_files(
|
||||
.filter(|e| e.file_type().is_file())
|
||||
{
|
||||
let path = entry.path();
|
||||
if is_text_file(&path) {
|
||||
files.push(path.to_path_buf());
|
||||
if let Some(ext) = path.extension() {
|
||||
if ext == "txt" || ext == "md" {
|
||||
files.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for entry in fs::read_dir(&path)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if path.is_file() && is_text_file(&path) {
|
||||
files.push(path);
|
||||
if path.is_file() {
|
||||
if let Some(ext) = path.extension() {
|
||||
if ext == "txt" || ext == "md" {
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if files.is_empty() {
|
||||
ui::print_warning("No text files found (supported: txt, md, json, yaml, yml, toml, xml, csv, log, rst, adoc)");
|
||||
ui::print_warning("No .txt or .md files found");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -490,20 +286,19 @@ pub fn retain_files(
|
||||
|
||||
pb.finish_with_message("Files processed");
|
||||
|
||||
// Always use async mode for the API call
|
||||
let request = RetainRequest {
|
||||
items,
|
||||
async_: true,
|
||||
document_tags: None,
|
||||
};
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Submitting retain request..."))
|
||||
Some(ui::create_spinner("Retaining memories..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.retain(agent_id, &request, true, verbose);
|
||||
let request = RetainRequest {
|
||||
items,
|
||||
async_: r#async,
|
||||
document_tags: None,
|
||||
};
|
||||
|
||||
let response = client.retain(agent_id, &request, r#async, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
@@ -511,55 +306,16 @@ pub fn retain_files(
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if r#async {
|
||||
// User requested async mode - return immediately
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success("Files queued for processing");
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success("Files retained successfully");
|
||||
if result.is_async {
|
||||
println!(" Status: queued for background processing");
|
||||
println!(" Items: {}", result.items_count);
|
||||
if let Some(op_id) = &result.operation_id {
|
||||
println!(" Operation ID: {}", op_id);
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
println!(" Total units created: {}", result.items_count);
|
||||
}
|
||||
} else {
|
||||
// Poll until completion
|
||||
if let Some(operation_id) = &result.operation_id {
|
||||
let poll_spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Processing memories..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let (success, error_msg) = client.poll_operation(agent_id, operation_id, verbose)?;
|
||||
|
||||
if let Some(mut sp) = poll_spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
if success {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success("Files retained successfully");
|
||||
println!(" Items processed: {}", result.items_count);
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
} else {
|
||||
let msg = error_msg.unwrap_or_else(|| "Unknown error".to_string());
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_error(&format!("Retain operation failed: {}", msg));
|
||||
}
|
||||
anyhow::bail!("Retain operation failed: {}", msg);
|
||||
}
|
||||
} else {
|
||||
// No operation ID returned, shouldn't happen with async=true
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success("Files retained successfully");
|
||||
println!(" Items processed: {}", result.items_count);
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
}
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -672,84 +428,3 @@ pub fn clear(
|
||||
Err(e) => Err(e)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_is_text_file_supported_extensions() {
|
||||
let supported = [
|
||||
"file.txt", "file.md", "file.json", "file.yaml", "file.yml",
|
||||
"file.toml", "file.xml", "file.csv", "file.log", "file.rst", "file.adoc",
|
||||
];
|
||||
for filename in supported {
|
||||
assert!(
|
||||
is_text_file(Path::new(filename)),
|
||||
"{} should be recognized as a text file",
|
||||
filename
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_text_file_case_insensitive() {
|
||||
assert!(is_text_file(Path::new("file.JSON")));
|
||||
assert!(is_text_file(Path::new("file.TXT")));
|
||||
assert!(is_text_file(Path::new("file.Md")));
|
||||
assert!(is_text_file(Path::new("file.YAML")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_text_file_unsupported_extensions() {
|
||||
let unsupported = [
|
||||
"file.pdf", "file.doc", "file.docx", "file.png", "file.jpg",
|
||||
"file.exe", "file.bin", "file.zip", "file.tar", "file.gz",
|
||||
];
|
||||
for filename in unsupported {
|
||||
assert!(
|
||||
!is_text_file(Path::new(filename)),
|
||||
"{} should NOT be recognized as a text file",
|
||||
filename
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_text_file_no_extension() {
|
||||
assert!(!is_text_file(Path::new("README")));
|
||||
assert!(!is_text_file(Path::new("Makefile")));
|
||||
assert!(!is_text_file(Path::new(".gitignore")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_text_file_with_path() {
|
||||
assert!(is_text_file(Path::new("/some/path/to/file.json")));
|
||||
assert!(is_text_file(Path::new("../relative/path/file.md")));
|
||||
assert!(!is_text_file(Path::new("/path/to/image.png")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_budget_valid_values() {
|
||||
assert!(matches!(parse_budget("low"), Budget::Low));
|
||||
assert!(matches!(parse_budget("mid"), Budget::Mid));
|
||||
assert!(matches!(parse_budget("high"), Budget::High));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_budget_case_insensitive() {
|
||||
assert!(matches!(parse_budget("LOW"), Budget::Low));
|
||||
assert!(matches!(parse_budget("MID"), Budget::Mid));
|
||||
assert!(matches!(parse_budget("HIGH"), Budget::High));
|
||||
assert!(matches!(parse_budget("Low"), Budget::Low));
|
||||
assert!(matches!(parse_budget("High"), Budget::High));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_budget_defaults_to_mid() {
|
||||
assert!(matches!(parse_budget("invalid"), Budget::Mid));
|
||||
assert!(matches!(parse_budget(""), Budget::Mid));
|
||||
assert!(matches!(parse_budget("unknown"), Budget::Mid));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,721 +0,0 @@
|
||||
//! Mental model commands for managing structured knowledge containers.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::api::ApiClient;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
|
||||
use hindsight_client::types;
|
||||
use serde::Deserialize;
|
||||
|
||||
// Local types for serde_json::Value deserialization
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VersionListResponse {
|
||||
versions: Vec<VersionItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VersionItem {
|
||||
version: i64,
|
||||
created_at: String,
|
||||
observations_count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct VersionDetailResponse {
|
||||
version: i64,
|
||||
created_at: String,
|
||||
observations: Option<Vec<ObservationData>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ObservationData {
|
||||
title: String,
|
||||
content: String,
|
||||
trend: Option<String>,
|
||||
evidence: Option<Vec<EvidenceData>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct EvidenceData {
|
||||
quote: String,
|
||||
}
|
||||
|
||||
/// List mental models for a bank
|
||||
pub fn list(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
subtype: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
tags_match: Option<String>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching mental models..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.list_mental_models(
|
||||
bank_id,
|
||||
subtype.as_deref(),
|
||||
tags,
|
||||
tags_match.as_deref(),
|
||||
verbose,
|
||||
);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Mental Models: {}", bank_id));
|
||||
|
||||
if result.items.is_empty() {
|
||||
println!(" {}", ui::dim("No mental models found."));
|
||||
} else {
|
||||
for model in &result.items {
|
||||
let subtype_str = &model.subtype;
|
||||
let obs_count = model.observations.len();
|
||||
|
||||
println!(
|
||||
" {} {} {}",
|
||||
ui::gradient_start(&model.id),
|
||||
ui::dim(&format!("[{}]", subtype_str)),
|
||||
model.name
|
||||
);
|
||||
|
||||
if !model.description.is_empty() {
|
||||
println!(" {}", ui::dim(&model.description));
|
||||
}
|
||||
|
||||
println!(
|
||||
" {} observations, v{}",
|
||||
obs_count,
|
||||
model.version
|
||||
);
|
||||
|
||||
// Show freshness status
|
||||
if let Some(freshness) = &model.freshness {
|
||||
let status = if freshness.is_up_to_date {
|
||||
ui::gradient_start("up to date")
|
||||
} else {
|
||||
ui::gradient_end("needs refresh")
|
||||
};
|
||||
println!(" {}", status);
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a specific mental model
|
||||
pub fn get(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching mental model..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_mental_model(bank_id, model_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(model) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
print_mental_model_detail(&model);
|
||||
} else {
|
||||
output::print_output(&model, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new mental model
|
||||
pub fn create(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
subtype: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
observations_file: Option<PathBuf>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Creating mental model..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Parse observations from file if provided
|
||||
let observations = if let Some(path) = observations_file {
|
||||
let content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read observations file: {}", path.display()))?;
|
||||
let obs: Vec<types::ObservationInput> = serde_json::from_str(&content)
|
||||
.with_context(|| format!("Failed to parse observations JSON from: {}", path.display()))?;
|
||||
Some(obs)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = types::CreateMentalModelRequest {
|
||||
name: name.to_string(),
|
||||
description: description.to_string(),
|
||||
subtype: subtype.unwrap_or_else(|| "pinned".to_string()),
|
||||
tags: tags.unwrap_or_default(),
|
||||
observations,
|
||||
};
|
||||
|
||||
let response = client.create_mental_model(bank_id, &request, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(model) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Mental model '{}' created successfully", model.id));
|
||||
println!();
|
||||
print_mental_model_detail(&model);
|
||||
} else {
|
||||
output::print_output(&model, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a mental model
|
||||
pub fn delete(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
model_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 delete mental model '{}'? This cannot be undone.",
|
||||
model_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("Deleting mental model..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.delete_mental_model(bank_id, model_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!("Mental model '{}' deleted successfully", model_id));
|
||||
} else {
|
||||
ui::print_error("Failed to delete mental model");
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Update a mental model's name or description
|
||||
pub fn update(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
name: Option<String>,
|
||||
description: Option<String>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
if name.is_none() && description.is_none() {
|
||||
anyhow::bail!("At least one of --name or --description must be provided");
|
||||
}
|
||||
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Updating mental model..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = types::UpdateMentalModelRequest { name, description };
|
||||
|
||||
let response = client.update_mental_model(bank_id, model_id, &request, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(model) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Mental model '{}' updated successfully", model_id));
|
||||
println!();
|
||||
print_mental_model_detail(&model);
|
||||
} else {
|
||||
output::print_output(&model, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh all mental models (or filtered by subtype)
|
||||
pub fn refresh_all(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
subtype: Option<String>,
|
||||
tags: Option<Vec<String>>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Submitting refresh request..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.refresh_mental_models(bank_id, subtype.as_deref(), tags, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success("Refresh operation submitted");
|
||||
println!(" Operation ID: {}", result.operation_id);
|
||||
println!(" Status: {}", result.status);
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Refresh a specific mental model
|
||||
pub fn refresh(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Submitting refresh request..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.refresh_mental_model(bank_id, model_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_success(&format!("Refresh submitted for model '{}'", model_id));
|
||||
println!(" Operation ID: {}", result.operation_id);
|
||||
println!(" Status: {}", result.status);
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// List version history for a mental model
|
||||
pub fn versions(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching versions..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.list_mental_model_versions(bank_id, model_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(value) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
let result: VersionListResponse = serde_json::from_value(value)
|
||||
.with_context(|| "Failed to parse version list response")?;
|
||||
|
||||
ui::print_section_header(&format!("Version History: {}", model_id));
|
||||
|
||||
if result.versions.is_empty() {
|
||||
println!(" {}", ui::dim("No versions found."));
|
||||
} else {
|
||||
for version in &result.versions {
|
||||
let obs_count = version.observations_count.unwrap_or(0);
|
||||
println!(
|
||||
" {} v{} - {} observations",
|
||||
ui::gradient_start(&format!("v{}", version.version)),
|
||||
version.version,
|
||||
obs_count
|
||||
);
|
||||
println!(" {}", ui::dim(&version.created_at));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output::print_output(&value, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a specific version of a mental model
|
||||
pub fn version(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
model_id: &str,
|
||||
version_num: i64,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching version..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_mental_model_version(bank_id, model_id, version_num, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(value) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
let result: VersionDetailResponse = serde_json::from_value(value)
|
||||
.with_context(|| "Failed to parse version response")?;
|
||||
|
||||
ui::print_section_header(&format!("{} v{}", model_id, version_num));
|
||||
|
||||
println!(" {} {}", ui::dim("Created:"), result.created_at);
|
||||
println!();
|
||||
|
||||
if let Some(observations) = &result.observations {
|
||||
if observations.is_empty() {
|
||||
println!(" {}", ui::dim("No observations in this version."));
|
||||
} else {
|
||||
for (i, obs) in observations.iter().enumerate() {
|
||||
print_observation_data(i + 1, obs);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
output::print_output(&value, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to print mental model details
|
||||
fn print_mental_model_detail(model: &types::MentalModelResponse) {
|
||||
ui::print_section_header(&model.name);
|
||||
|
||||
let subtype_str = &model.subtype;
|
||||
println!(" {} {}", ui::dim("ID:"), ui::gradient_start(&model.id));
|
||||
println!(" {} {}", ui::dim("Subtype:"), subtype_str);
|
||||
println!(" {} v{}", ui::dim("Version:"), model.version);
|
||||
|
||||
if !model.description.is_empty() {
|
||||
println!(" {} {}", ui::dim("Description:"), &model.description);
|
||||
}
|
||||
|
||||
if !model.tags.is_empty() {
|
||||
println!(" {} {}", ui::dim("Tags:"), model.tags.join(", "));
|
||||
}
|
||||
|
||||
// Freshness status
|
||||
if let Some(freshness) = &model.freshness {
|
||||
println!();
|
||||
println!("{}", ui::gradient_text("─── Freshness ───"));
|
||||
let status = if freshness.is_up_to_date {
|
||||
ui::gradient_start("Up to date")
|
||||
} else {
|
||||
ui::gradient_end("Needs refresh")
|
||||
};
|
||||
println!(" {} {}", ui::dim("Status:"), status);
|
||||
|
||||
if let Some(last_refresh) = &freshness.last_refresh_at {
|
||||
println!(" {} {}", ui::dim("Last refresh:"), last_refresh);
|
||||
}
|
||||
|
||||
if freshness.memories_since_refresh > 0 {
|
||||
println!(" {} {}", ui::dim("New memories:"), freshness.memories_since_refresh);
|
||||
}
|
||||
|
||||
if !freshness.reasons.is_empty() {
|
||||
println!(" {} {}", ui::dim("Reasons:"), freshness.reasons.join(", "));
|
||||
}
|
||||
}
|
||||
|
||||
// Observations
|
||||
println!();
|
||||
println!("{}", ui::gradient_text("─── Observations ───"));
|
||||
println!();
|
||||
|
||||
if model.observations.is_empty() {
|
||||
println!(" {}", ui::dim("No observations yet."));
|
||||
} else {
|
||||
for (i, obs) in model.observations.iter().enumerate() {
|
||||
print_observation(i + 1, obs);
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
fn print_observation(index: usize, obs: &types::MentalModelObservationResponse) {
|
||||
let trend_str = &obs.trend;
|
||||
let trend_colored = match trend_str.as_str() {
|
||||
"strengthening" => ui::gradient_start(trend_str),
|
||||
"stable" => ui::gradient_mid(trend_str),
|
||||
"weakening" | "stale" => ui::gradient_end(trend_str),
|
||||
_ => trend_str.to_string(),
|
||||
};
|
||||
|
||||
println!(" {}. {} {}", index, ui::gradient_mid(&obs.title), ui::dim(&format!("[{}]", trend_colored)));
|
||||
println!(" {}", obs.content);
|
||||
|
||||
// Show evidence if available
|
||||
if !obs.evidence.is_empty() {
|
||||
println!(" {} evidence items:", ui::dim(&obs.evidence.len().to_string()));
|
||||
for ev in obs.evidence.iter().take(2) {
|
||||
// Show first 2 evidence items
|
||||
let quote_preview: String = ev.quote.chars().take(60).collect();
|
||||
let ellipsis = if ev.quote.len() > 60 { "..." } else { "" };
|
||||
println!(" • \"{}{}\"", quote_preview, ellipsis);
|
||||
}
|
||||
if obs.evidence.len() > 2 {
|
||||
println!(" {} more...", ui::dim(&format!("+ {}", obs.evidence.len() - 2)));
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
fn print_observation_data(index: usize, obs: &ObservationData) {
|
||||
let trend_str = obs.trend.as_deref().unwrap_or("unknown");
|
||||
let trend_colored = match trend_str {
|
||||
"strengthening" => ui::gradient_start(trend_str),
|
||||
"stable" => ui::gradient_mid(trend_str),
|
||||
"weakening" | "stale" => ui::gradient_end(trend_str),
|
||||
_ => trend_str.to_string(),
|
||||
};
|
||||
|
||||
println!(" {}. {} {}", index, ui::gradient_mid(&obs.title), ui::dim(&format!("[{}]", trend_colored)));
|
||||
println!(" {}", obs.content);
|
||||
|
||||
// Show evidence if available
|
||||
if let Some(evidence) = &obs.evidence {
|
||||
if !evidence.is_empty() {
|
||||
println!(" {} evidence items:", ui::dim(&evidence.len().to_string()));
|
||||
for ev in evidence.iter().take(2) {
|
||||
// Show first 2 evidence items
|
||||
let quote_preview: String = ev.quote.chars().take(60).collect();
|
||||
let ellipsis = if ev.quote.len() > 60 { "..." } else { "" };
|
||||
println!(" • \"{}{}\"", quote_preview, ellipsis);
|
||||
}
|
||||
if evidence.len() > 2 {
|
||||
println!(" {} more...", ui::dim(&format!("+ {}", evidence.len() - 2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_observation_input_serialization() {
|
||||
let obs = types::ObservationInput {
|
||||
title: "Test observation".to_string(),
|
||||
content: "Test content".to_string(),
|
||||
};
|
||||
let json = serde_json::to_string(&obs).unwrap();
|
||||
assert!(json.contains("Test observation"));
|
||||
assert!(json.contains("Test content"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_list_response_deserialization() {
|
||||
let json = r#"{
|
||||
"versions": [
|
||||
{"version": 1, "created_at": "2024-01-10T10:00:00Z", "observations_count": 5},
|
||||
{"version": 2, "created_at": "2024-01-15T10:00:00Z", "observations_count": 8}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(json).unwrap();
|
||||
let result: VersionListResponse = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(result.versions.len(), 2);
|
||||
assert_eq!(result.versions[0].version, 1);
|
||||
assert_eq!(result.versions[1].version, 2);
|
||||
assert_eq!(result.versions[1].observations_count, Some(8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_detail_response_deserialization() {
|
||||
let json = r#"{
|
||||
"version": 1,
|
||||
"created_at": "2024-01-10T10:00:00Z",
|
||||
"observations": [
|
||||
{
|
||||
"title": "Test observation",
|
||||
"content": "Test content",
|
||||
"trend": "stable",
|
||||
"evidence": [{"quote": "test evidence"}]
|
||||
}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let value: serde_json::Value = serde_json::from_str(json).unwrap();
|
||||
let result: VersionDetailResponse = serde_json::from_value(value).unwrap();
|
||||
|
||||
assert_eq!(result.created_at, "2024-01-10T10:00:00Z");
|
||||
let observations = result.observations.unwrap();
|
||||
assert_eq!(observations.len(), 1);
|
||||
assert_eq!(observations[0].title, "Test observation");
|
||||
assert_eq!(observations[0].trend, Some("stable".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_observation_data_deserialization() {
|
||||
let json = r#"{
|
||||
"title": "Test Title",
|
||||
"content": "Test Content",
|
||||
"trend": "strengthening",
|
||||
"evidence": [
|
||||
{"quote": "Evidence 1"},
|
||||
{"quote": "Evidence 2"}
|
||||
]
|
||||
}"#;
|
||||
|
||||
let result: ObservationData = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(result.title, "Test Title");
|
||||
assert_eq!(result.content, "Test Content");
|
||||
assert_eq!(result.trend, Some("strengthening".to_string()));
|
||||
let evidence = result.evidence.unwrap();
|
||||
assert_eq!(evidence.len(), 2);
|
||||
assert_eq!(evidence[0].quote, "Evidence 1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_mental_model_request() {
|
||||
let request = types::CreateMentalModelRequest {
|
||||
name: "Test Model".to_string(),
|
||||
description: "A test model".to_string(),
|
||||
subtype: "pinned".to_string(),
|
||||
tags: vec!["test".to_string()],
|
||||
observations: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
assert!(json.contains("Test Model"));
|
||||
assert!(json.contains("pinned"));
|
||||
assert!(json.contains("test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_mental_model_request() {
|
||||
let request = types::UpdateMentalModelRequest {
|
||||
name: Some("Updated Name".to_string()),
|
||||
description: None,
|
||||
};
|
||||
|
||||
let json = serde_json::to_string(&request).unwrap();
|
||||
assert!(json.contains("Updated Name"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_async_operation_submit_response_deserialization() {
|
||||
let json = r#"{
|
||||
"operation_id": "op-123",
|
||||
"status": "pending"
|
||||
}"#;
|
||||
|
||||
let result: types::AsyncOperationSubmitResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(result.operation_id, "op-123");
|
||||
assert_eq!(result.status, "pending");
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,6 @@
|
||||
pub mod bank;
|
||||
pub mod chunk;
|
||||
pub mod memory;
|
||||
pub mod document;
|
||||
pub mod entity;
|
||||
pub mod explore;
|
||||
pub mod health;
|
||||
pub mod memory;
|
||||
pub mod mental_model;
|
||||
pub mod operation;
|
||||
pub mod tag;
|
||||
pub mod explore;
|
||||
|
||||
@@ -47,55 +47,6 @@ pub fn list(
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the status of a specific operation
|
||||
pub fn get(
|
||||
client: &ApiClient,
|
||||
agent_id: &str,
|
||||
operation_id: &str,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching operation status..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.get_operation(agent_id, operation_id, verbose);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Operation: {}", operation_id));
|
||||
|
||||
use hindsight_client::types::Status;
|
||||
let status_str = match &result.status {
|
||||
Status::Completed => ui::gradient_start("completed"),
|
||||
Status::Pending => ui::gradient_mid("pending"),
|
||||
Status::Failed => ui::gradient_end("failed"),
|
||||
Status::NotFound => ui::gradient_end("not_found"),
|
||||
};
|
||||
|
||||
println!(" {} {}", ui::dim("Status:"), status_str);
|
||||
|
||||
if let Some(error) = &result.error_message {
|
||||
println!(" {} {}", ui::dim("Error:"), ui::gradient_end(error));
|
||||
}
|
||||
|
||||
println!();
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cancel(
|
||||
client: &ApiClient,
|
||||
agent_id: &str,
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
//! Tag commands for listing tags in a memory bank.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::api::ApiClient;
|
||||
use crate::output::{self, OutputFormat};
|
||||
use crate::ui;
|
||||
|
||||
/// List tags in a bank
|
||||
pub fn list(
|
||||
client: &ApiClient,
|
||||
bank_id: &str,
|
||||
query: Option<String>,
|
||||
limit: i64,
|
||||
offset: i64,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
let spinner = if output_format == OutputFormat::Pretty {
|
||||
Some(ui::create_spinner("Fetching tags..."))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let response = client.list_tags(
|
||||
bank_id,
|
||||
query.as_deref(),
|
||||
Some(limit),
|
||||
Some(offset),
|
||||
verbose,
|
||||
);
|
||||
|
||||
if let Some(mut sp) = spinner {
|
||||
sp.finish();
|
||||
}
|
||||
|
||||
match response {
|
||||
Ok(result) => {
|
||||
if output_format == OutputFormat::Pretty {
|
||||
ui::print_section_header(&format!("Tags: {}", bank_id));
|
||||
|
||||
if result.items.is_empty() {
|
||||
println!(" {}", ui::dim("No tags found."));
|
||||
} else {
|
||||
for (i, tag) in result.items.iter().enumerate() {
|
||||
let t = i as f32 / result.items.len().max(1) as f32;
|
||||
println!(
|
||||
" {} {}",
|
||||
ui::gradient(&tag.tag, t),
|
||||
ui::dim(&format!("({})", tag.count))
|
||||
);
|
||||
}
|
||||
println!();
|
||||
println!(" {} {} total", ui::dim("Total:"), result.total);
|
||||
}
|
||||
} else {
|
||||
output::print_output(&result, output_format)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use hindsight_client::types::{ListTagsResponse, TagItem};
|
||||
|
||||
#[test]
|
||||
fn test_tag_item_fields() {
|
||||
// Verify TagItem has the expected fields
|
||||
let tag = TagItem {
|
||||
tag: "test-tag".to_string(),
|
||||
count: 5,
|
||||
};
|
||||
|
||||
assert_eq!(tag.tag, "test-tag");
|
||||
assert_eq!(tag.count, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_tags_response_deserialization() {
|
||||
let json = r#"{
|
||||
"items": [
|
||||
{"tag": "user", "count": 10},
|
||||
{"tag": "system", "count": 5}
|
||||
],
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"total": 2
|
||||
}"#;
|
||||
|
||||
let result: ListTagsResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert_eq!(result.items.len(), 2);
|
||||
assert_eq!(result.items[0].tag, "user");
|
||||
assert_eq!(result.items[0].count, 10);
|
||||
assert_eq!(result.items[1].tag, "system");
|
||||
assert_eq!(result.items[1].count, 5);
|
||||
assert_eq!(result.total, 2);
|
||||
assert_eq!(result.limit, 100);
|
||||
assert_eq!(result.offset, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tags_response() {
|
||||
let json = r#"{
|
||||
"items": [],
|
||||
"limit": 100,
|
||||
"offset": 0,
|
||||
"total": 0
|
||||
}"#;
|
||||
|
||||
let result: ListTagsResponse = serde_json::from_str(json).unwrap();
|
||||
|
||||
assert!(result.items.is_empty());
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ const DEFAULT_API_URL: &str = "http://localhost:8888";
|
||||
const CONFIG_FILE_NAME: &str = "config";
|
||||
const CONFIG_DIR_NAME: &str = ".hindsight";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct Config {
|
||||
pub api_url: String,
|
||||
pub api_key: Option<String>,
|
||||
@@ -175,156 +174,3 @@ pub fn generate_doc_id() -> String {
|
||||
let now = chrono::Local::now();
|
||||
format!("cli_put_{}", now.format("%Y%m%d_%H%M%S"))
|
||||
}
|
||||
|
||||
/// Parse a simple TOML-like config line and extract value.
|
||||
/// Handles both quoted and unquoted values.
|
||||
pub fn parse_config_value(line: &str, key: &str) -> Option<String> {
|
||||
let line = line.trim();
|
||||
if !line.starts_with(key) {
|
||||
return None;
|
||||
}
|
||||
line.split('=').nth(1).map(|value| {
|
||||
value.trim().trim_matches('"').trim_matches('\'').to_string()
|
||||
}).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_source_display() {
|
||||
assert_eq!(format!("{}", ConfigSource::LocalFile), "config file");
|
||||
assert_eq!(format!("{}", ConfigSource::Environment), "environment variable");
|
||||
assert_eq!(format!("{}", ConfigSource::Default), "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_and_create_valid_http() {
|
||||
let config = Config::validate_and_create(
|
||||
"http://localhost:8888".to_string(),
|
||||
None,
|
||||
ConfigSource::Default,
|
||||
);
|
||||
assert!(config.is_ok());
|
||||
let config = config.unwrap();
|
||||
assert_eq!(config.api_url, "http://localhost:8888");
|
||||
assert_eq!(config.source, ConfigSource::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_and_create_valid_https() {
|
||||
let config = Config::validate_and_create(
|
||||
"https://api.example.com".to_string(),
|
||||
Some("secret-key".to_string()),
|
||||
ConfigSource::Environment,
|
||||
);
|
||||
assert!(config.is_ok());
|
||||
let config = config.unwrap();
|
||||
assert_eq!(config.api_url, "https://api.example.com");
|
||||
assert_eq!(config.api_key, Some("secret-key".to_string()));
|
||||
assert_eq!(config.source, ConfigSource::Environment);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_and_create_invalid_url() {
|
||||
let config = Config::validate_and_create(
|
||||
"localhost:8888".to_string(),
|
||||
None,
|
||||
ConfigSource::Default,
|
||||
);
|
||||
assert!(config.is_err());
|
||||
let err = config.unwrap_err().to_string();
|
||||
assert!(err.contains("Invalid API URL"));
|
||||
assert!(err.contains("Must start with http:// or https://"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_and_create_ftp_url() {
|
||||
let config = Config::validate_and_create(
|
||||
"ftp://example.com".to_string(),
|
||||
None,
|
||||
ConfigSource::Default,
|
||||
);
|
||||
assert!(config.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_doc_id_format() {
|
||||
let doc_id = generate_doc_id();
|
||||
assert!(doc_id.starts_with("cli_put_"));
|
||||
// Should be cli_put_YYYYMMDD_HHMMSS format
|
||||
assert!(doc_id.len() > 20); // cli_put_ (8) + date (8) + _ (1) + time (6) = 23
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_doc_id_uniqueness() {
|
||||
let id1 = generate_doc_id();
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
let id2 = generate_doc_id();
|
||||
// IDs generated at different times should be different
|
||||
assert_ne!(id1, id2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_value_quoted() {
|
||||
assert_eq!(
|
||||
parse_config_value(r#"api_url = "http://localhost:8888""#, "api_url"),
|
||||
Some("http://localhost:8888".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_value_single_quoted() {
|
||||
assert_eq!(
|
||||
parse_config_value("api_url = 'http://localhost:8888'", "api_url"),
|
||||
Some("http://localhost:8888".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_value_unquoted() {
|
||||
assert_eq!(
|
||||
parse_config_value("api_url = http://localhost:8888", "api_url"),
|
||||
Some("http://localhost:8888".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_value_with_spaces() {
|
||||
assert_eq!(
|
||||
parse_config_value(" api_url = \"http://localhost:8888\" ", "api_url"),
|
||||
Some("http://localhost:8888".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_value_wrong_key() {
|
||||
assert_eq!(
|
||||
parse_config_value("api_key = secret", "api_url"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_config_value_empty() {
|
||||
assert_eq!(
|
||||
parse_config_value("api_url = ", "api_url"),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
parse_config_value("api_url = \"\"", "api_url"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_api_url_accessor() {
|
||||
let config = Config {
|
||||
api_url: "http://test:8080".to_string(),
|
||||
api_key: None,
|
||||
source: ConfigSource::Default,
|
||||
};
|
||||
assert_eq!(config.api_url(), "http://test:8080");
|
||||
}
|
||||
}
|
||||
|
||||
+5
-373
@@ -67,18 +67,14 @@ fn get_before_help() -> &'static str {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Commands {
|
||||
/// Manage banks (list, create, update, profile, stats, mission, graph, delete)
|
||||
/// Manage banks (list, profile, stats)
|
||||
#[command(subcommand)]
|
||||
Bank(BankCommands),
|
||||
|
||||
/// Manage memories (list, get, recall, reflect, retain, clear)
|
||||
/// Manage memories (recall, reflect, retain, delete)
|
||||
#[command(subcommand)]
|
||||
Memory(MemoryCommands),
|
||||
|
||||
/// Manage mental models (list, get, create, update, delete, refresh, versions)
|
||||
#[command(subcommand)]
|
||||
MentalModel(MentalModelCommands),
|
||||
|
||||
/// Manage documents (list, get, delete)
|
||||
#[command(subcommand)]
|
||||
Document(DocumentCommands),
|
||||
@@ -87,24 +83,10 @@ enum Commands {
|
||||
#[command(subcommand)]
|
||||
Entity(EntityCommands),
|
||||
|
||||
/// Manage tags (list)
|
||||
#[command(subcommand)]
|
||||
Tag(TagCommands),
|
||||
|
||||
/// Manage chunks (get)
|
||||
#[command(subcommand)]
|
||||
Chunk(ChunkCommands),
|
||||
|
||||
/// Manage async operations (list, get, cancel)
|
||||
/// Manage async operations (list, cancel)
|
||||
#[command(subcommand)]
|
||||
Operation(OperationCommands),
|
||||
|
||||
/// Check API health status
|
||||
Health,
|
||||
|
||||
/// Get Prometheus metrics
|
||||
Metrics,
|
||||
|
||||
/// Interactive TUI explorer (k9s-style) for navigating banks, memories, entities, and performing recall/reflect
|
||||
#[command(alias = "tui")]
|
||||
Explore,
|
||||
@@ -129,59 +111,7 @@ enum BankCommands {
|
||||
/// List all banks
|
||||
List,
|
||||
|
||||
/// Create a new bank
|
||||
Create {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Bank name
|
||||
#[arg(short = 'n', long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// Mission statement
|
||||
#[arg(short = 'm', long)]
|
||||
mission: Option<String>,
|
||||
|
||||
/// Skepticism trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
skepticism: Option<i64>,
|
||||
|
||||
/// Literalism trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
literalism: Option<i64>,
|
||||
|
||||
/// Empathy trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
empathy: Option<i64>,
|
||||
},
|
||||
|
||||
/// Update bank properties (partial update)
|
||||
Update {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Bank name
|
||||
#[arg(short = 'n', long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// Mission statement
|
||||
#[arg(short = 'm', long)]
|
||||
mission: Option<String>,
|
||||
|
||||
/// Skepticism trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
skepticism: Option<i64>,
|
||||
|
||||
/// Literalism trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
literalism: Option<i64>,
|
||||
|
||||
/// Empathy trait (1-5)
|
||||
#[arg(long, value_parser = clap::value_parser!(i64).range(1..=5))]
|
||||
empathy: Option<i64>,
|
||||
},
|
||||
|
||||
/// Get bank disposition and profile
|
||||
/// Get bank disposition and background
|
||||
Disposition {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
@@ -202,17 +132,7 @@ enum BankCommands {
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Set bank mission
|
||||
Mission {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mission statement
|
||||
mission: String,
|
||||
},
|
||||
|
||||
/// Set or merge bank background (deprecated: use mission instead)
|
||||
#[command(hide = true)]
|
||||
/// Set or merge bank background
|
||||
Background {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
@@ -225,20 +145,6 @@ enum BankCommands {
|
||||
no_update_disposition: bool,
|
||||
},
|
||||
|
||||
/// Get memory graph data
|
||||
Graph {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Filter by fact type (world, experience, opinion)
|
||||
#[arg(short = 't', long)]
|
||||
fact_type: Option<String>,
|
||||
|
||||
/// Maximum nodes to return
|
||||
#[arg(short = 'l', long, default_value = "1000")]
|
||||
limit: i64,
|
||||
},
|
||||
|
||||
/// Delete a bank and all its data
|
||||
Delete {
|
||||
/// Bank ID
|
||||
@@ -252,37 +158,6 @@ enum BankCommands {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum MemoryCommands {
|
||||
/// List memory units with pagination
|
||||
List {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Filter by fact type (world, experience, opinion)
|
||||
#[arg(short = 't', long)]
|
||||
fact_type: Option<String>,
|
||||
|
||||
/// Full-text search query
|
||||
#[arg(short = 'q', long)]
|
||||
query: Option<String>,
|
||||
|
||||
/// Maximum number of results
|
||||
#[arg(short = 'l', long, default_value = "100")]
|
||||
limit: i64,
|
||||
|
||||
/// Offset for pagination
|
||||
#[arg(short = 's', long, default_value = "0")]
|
||||
offset: i64,
|
||||
},
|
||||
|
||||
/// Get a specific memory unit by ID
|
||||
Get {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Memory unit ID
|
||||
memory_id: String,
|
||||
},
|
||||
|
||||
/// Recall memories using semantic search
|
||||
Recall {
|
||||
/// Bank ID
|
||||
@@ -485,15 +360,6 @@ enum OperationCommands {
|
||||
bank_id: String,
|
||||
},
|
||||
|
||||
/// Get the status of a specific operation
|
||||
Get {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Operation ID
|
||||
operation_id: String,
|
||||
},
|
||||
|
||||
/// Cancel a pending async operation
|
||||
Cancel {
|
||||
/// Bank ID
|
||||
@@ -504,164 +370,6 @@ enum OperationCommands {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum MentalModelCommands {
|
||||
/// List mental models for a bank
|
||||
List {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Filter by subtype (structural, emergent, pinned, learned, directive)
|
||||
#[arg(long)]
|
||||
subtype: Option<String>,
|
||||
|
||||
/// Filter by tags
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Option<Vec<String>>,
|
||||
|
||||
/// Tag matching mode (any, all, any_strict, all_strict)
|
||||
#[arg(long, default_value = "any")]
|
||||
tags_match: Option<String>,
|
||||
},
|
||||
|
||||
/// Get a specific mental model
|
||||
Get {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mental model ID
|
||||
model_id: String,
|
||||
},
|
||||
|
||||
/// Create a new mental model (pinned or directive subtype)
|
||||
Create {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Model name
|
||||
name: String,
|
||||
|
||||
/// Model description
|
||||
description: String,
|
||||
|
||||
/// Subtype (pinned or directive)
|
||||
#[arg(long, default_value = "pinned")]
|
||||
subtype: Option<String>,
|
||||
|
||||
/// Tags for the model
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Option<Vec<String>>,
|
||||
|
||||
/// Path to JSON file containing initial observations
|
||||
#[arg(long)]
|
||||
observations: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// Update a mental model's name or description
|
||||
Update {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mental model ID
|
||||
model_id: String,
|
||||
|
||||
/// New name
|
||||
#[arg(long)]
|
||||
name: Option<String>,
|
||||
|
||||
/// New description
|
||||
#[arg(long)]
|
||||
description: Option<String>,
|
||||
},
|
||||
|
||||
/// Delete a mental model
|
||||
Delete {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mental model ID
|
||||
model_id: String,
|
||||
|
||||
/// Skip confirmation prompt
|
||||
#[arg(short = 'y', long)]
|
||||
yes: bool,
|
||||
},
|
||||
|
||||
/// Refresh all mental models (async operation)
|
||||
RefreshAll {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Filter by subtype
|
||||
#[arg(long)]
|
||||
subtype: Option<String>,
|
||||
|
||||
/// Filter by tags
|
||||
#[arg(long, value_delimiter = ',')]
|
||||
tags: Option<Vec<String>>,
|
||||
},
|
||||
|
||||
/// Refresh a specific mental model (async operation)
|
||||
Refresh {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mental model ID
|
||||
model_id: String,
|
||||
},
|
||||
|
||||
/// List version history for a mental model
|
||||
Versions {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mental model ID
|
||||
model_id: String,
|
||||
},
|
||||
|
||||
/// Get a specific version of a mental model
|
||||
Version {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Mental model ID
|
||||
model_id: String,
|
||||
|
||||
/// Version number
|
||||
version: i64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum TagCommands {
|
||||
/// List tags in a bank
|
||||
List {
|
||||
/// Bank ID
|
||||
bank_id: String,
|
||||
|
||||
/// Wildcard search query (e.g., 'user:*')
|
||||
#[arg(short = 'q', long)]
|
||||
query: Option<String>,
|
||||
|
||||
/// Maximum number of results
|
||||
#[arg(short = 'l', long, default_value = "100")]
|
||||
limit: i64,
|
||||
|
||||
/// Offset for pagination
|
||||
#[arg(short = 's', long, default_value = "0")]
|
||||
offset: i64,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum ChunkCommands {
|
||||
/// Get a specific chunk by ID
|
||||
Get {
|
||||
/// Chunk ID
|
||||
chunk_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(_) = run() {
|
||||
std::process::exit(1);
|
||||
@@ -704,45 +412,20 @@ fn run() -> Result<()> {
|
||||
Commands::Configure { .. } => unreachable!(), // Handled above
|
||||
Commands::Ui => unreachable!(), // Handled above
|
||||
Commands::Explore => commands::explore::run(&client),
|
||||
|
||||
// Health and Metrics
|
||||
Commands::Health => commands::health::health(&client, verbose, output_format),
|
||||
Commands::Metrics => commands::health::metrics(&client, verbose, output_format),
|
||||
|
||||
// Bank commands
|
||||
Commands::Bank(bank_cmd) => match bank_cmd {
|
||||
BankCommands::List => commands::bank::list(&client, verbose, output_format),
|
||||
BankCommands::Create { bank_id, name, mission, skepticism, literalism, empathy } => {
|
||||
commands::bank::create(&client, &bank_id, name, mission, skepticism, literalism, empathy, verbose, output_format)
|
||||
}
|
||||
BankCommands::Update { bank_id, name, mission, skepticism, literalism, empathy } => {
|
||||
commands::bank::update(&client, &bank_id, name, mission, skepticism, literalism, empathy, verbose, output_format)
|
||||
}
|
||||
BankCommands::Disposition { bank_id } => commands::bank::disposition(&client, &bank_id, verbose, output_format),
|
||||
BankCommands::Stats { bank_id } => commands::bank::stats(&client, &bank_id, verbose, output_format),
|
||||
BankCommands::Name { bank_id, name } => commands::bank::update_name(&client, &bank_id, &name, verbose, output_format),
|
||||
BankCommands::Mission { bank_id, mission } => {
|
||||
commands::bank::mission(&client, &bank_id, &mission, verbose, output_format)
|
||||
}
|
||||
BankCommands::Background { bank_id, content, no_update_disposition } => {
|
||||
commands::bank::update_background(&client, &bank_id, &content, no_update_disposition, verbose, output_format)
|
||||
}
|
||||
BankCommands::Graph { bank_id, fact_type, limit } => {
|
||||
commands::bank::graph(&client, &bank_id, fact_type, limit, verbose, output_format)
|
||||
}
|
||||
BankCommands::Delete { bank_id, yes } => {
|
||||
commands::bank::delete(&client, &bank_id, yes, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Memory commands
|
||||
Commands::Memory(memory_cmd) => match memory_cmd {
|
||||
MemoryCommands::List { bank_id, fact_type, query, limit, offset } => {
|
||||
commands::memory::list(&client, &bank_id, fact_type, query, limit, offset, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Get { bank_id, memory_id } => {
|
||||
commands::memory::get(&client, &bank_id, &memory_id, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Recall { bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens } => {
|
||||
commands::memory::recall(&client, &bank_id, query, fact_type, budget, max_tokens, trace, include_chunks, chunk_max_tokens, verbose, output_format)
|
||||
}
|
||||
@@ -763,38 +446,6 @@ fn run() -> Result<()> {
|
||||
}
|
||||
},
|
||||
|
||||
// Mental Model commands
|
||||
Commands::MentalModel(mm_cmd) => match mm_cmd {
|
||||
MentalModelCommands::List { bank_id, subtype, tags, tags_match } => {
|
||||
commands::mental_model::list(&client, &bank_id, subtype, tags, tags_match, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Get { bank_id, model_id } => {
|
||||
commands::mental_model::get(&client, &bank_id, &model_id, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Create { bank_id, name, description, subtype, tags, observations } => {
|
||||
commands::mental_model::create(&client, &bank_id, &name, &description, subtype, tags, observations, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Update { bank_id, model_id, name, description } => {
|
||||
commands::mental_model::update(&client, &bank_id, &model_id, name, description, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Delete { bank_id, model_id, yes } => {
|
||||
commands::mental_model::delete(&client, &bank_id, &model_id, yes, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::RefreshAll { bank_id, subtype, tags } => {
|
||||
commands::mental_model::refresh_all(&client, &bank_id, subtype, tags, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Refresh { bank_id, model_id } => {
|
||||
commands::mental_model::refresh(&client, &bank_id, &model_id, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Versions { bank_id, model_id } => {
|
||||
commands::mental_model::versions(&client, &bank_id, &model_id, verbose, output_format)
|
||||
}
|
||||
MentalModelCommands::Version { bank_id, model_id, version } => {
|
||||
commands::mental_model::version(&client, &bank_id, &model_id, version, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Document commands
|
||||
Commands::Document(doc_cmd) => match doc_cmd {
|
||||
DocumentCommands::List { bank_id, query, limit, offset } => {
|
||||
commands::document::list(&client, &bank_id, query, limit, offset, verbose, output_format)
|
||||
@@ -807,7 +458,6 @@ fn run() -> Result<()> {
|
||||
}
|
||||
},
|
||||
|
||||
// Entity commands
|
||||
Commands::Entity(entity_cmd) => match entity_cmd {
|
||||
EntityCommands::List { bank_id, limit } => {
|
||||
commands::entity::list(&client, &bank_id, limit, verbose, output_format)
|
||||
@@ -820,28 +470,10 @@ fn run() -> Result<()> {
|
||||
}
|
||||
},
|
||||
|
||||
// Tag commands
|
||||
Commands::Tag(tag_cmd) => match tag_cmd {
|
||||
TagCommands::List { bank_id, query, limit, offset } => {
|
||||
commands::tag::list(&client, &bank_id, query, limit, offset, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Chunk commands
|
||||
Commands::Chunk(chunk_cmd) => match chunk_cmd {
|
||||
ChunkCommands::Get { chunk_id } => {
|
||||
commands::chunk::get(&client, &chunk_id, verbose, output_format)
|
||||
}
|
||||
},
|
||||
|
||||
// Operation commands
|
||||
Commands::Operation(op_cmd) => match op_cmd {
|
||||
OperationCommands::List { bank_id } => {
|
||||
commands::operation::list(&client, &bank_id, verbose, output_format)
|
||||
}
|
||||
OperationCommands::Get { bank_id, operation_id } => {
|
||||
commands::operation::get(&client, &bank_id, &operation_id, verbose, output_format)
|
||||
}
|
||||
OperationCommands::Cancel { bank_id, operation_id } => {
|
||||
commands::operation::cancel(&client, &bank_id, &operation_id, verbose, output_format)
|
||||
}
|
||||
|
||||
+2
-142
@@ -8,35 +8,13 @@ pub enum OutputFormat {
|
||||
Yaml,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
/// Parse output format from string
|
||||
pub fn from_str(s: &str) -> Option<Self> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"json" => Some(OutputFormat::Json),
|
||||
"yaml" | "yml" => Some(OutputFormat::Yaml),
|
||||
"pretty" | "text" => Some(OutputFormat::Pretty),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format data as JSON string
|
||||
pub fn to_json<T: Serialize>(data: &T) -> Result<String> {
|
||||
Ok(serde_json::to_string_pretty(data)?)
|
||||
}
|
||||
|
||||
/// Format data as YAML string
|
||||
pub fn to_yaml<T: Serialize>(data: &T) -> Result<String> {
|
||||
Ok(serde_yaml::to_string(data)?)
|
||||
}
|
||||
|
||||
pub fn print_output<T: Serialize>(data: &T, format: OutputFormat) -> Result<()> {
|
||||
match format {
|
||||
OutputFormat::Json => {
|
||||
println!("{}", to_json(data)?);
|
||||
println!("{}", serde_json::to_string_pretty(data)?);
|
||||
}
|
||||
OutputFormat::Yaml => {
|
||||
println!("{}", to_yaml(data)?);
|
||||
println!("{}", serde_yaml::to_string(data)?);
|
||||
}
|
||||
OutputFormat::Pretty => {
|
||||
// This should not be called - pretty printing is handled in ui.rs
|
||||
@@ -45,121 +23,3 @@ pub fn print_output<T: Serialize>(data: &T, format: OutputFormat) -> Result<()>
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||
struct TestData {
|
||||
name: String,
|
||||
count: i32,
|
||||
active: bool,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_format_from_str_json() {
|
||||
assert_eq!(OutputFormat::from_str("json"), Some(OutputFormat::Json));
|
||||
assert_eq!(OutputFormat::from_str("JSON"), Some(OutputFormat::Json));
|
||||
assert_eq!(OutputFormat::from_str("Json"), Some(OutputFormat::Json));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_format_from_str_yaml() {
|
||||
assert_eq!(OutputFormat::from_str("yaml"), Some(OutputFormat::Yaml));
|
||||
assert_eq!(OutputFormat::from_str("YAML"), Some(OutputFormat::Yaml));
|
||||
assert_eq!(OutputFormat::from_str("yml"), Some(OutputFormat::Yaml));
|
||||
assert_eq!(OutputFormat::from_str("YML"), Some(OutputFormat::Yaml));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_format_from_str_pretty() {
|
||||
assert_eq!(OutputFormat::from_str("pretty"), Some(OutputFormat::Pretty));
|
||||
assert_eq!(OutputFormat::from_str("PRETTY"), Some(OutputFormat::Pretty));
|
||||
assert_eq!(OutputFormat::from_str("text"), Some(OutputFormat::Pretty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_format_from_str_invalid() {
|
||||
assert_eq!(OutputFormat::from_str("xml"), None);
|
||||
assert_eq!(OutputFormat::from_str("csv"), None);
|
||||
assert_eq!(OutputFormat::from_str(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_json() {
|
||||
let data = TestData {
|
||||
name: "test".to_string(),
|
||||
count: 42,
|
||||
active: true,
|
||||
};
|
||||
let json = to_json(&data).unwrap();
|
||||
assert!(json.contains("\"name\": \"test\""));
|
||||
assert!(json.contains("\"count\": 42"));
|
||||
assert!(json.contains("\"active\": true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml() {
|
||||
let data = TestData {
|
||||
name: "test".to_string(),
|
||||
count: 42,
|
||||
active: true,
|
||||
};
|
||||
let yaml = to_yaml(&data).unwrap();
|
||||
assert!(yaml.contains("name: test"));
|
||||
assert!(yaml.contains("count: 42"));
|
||||
assert!(yaml.contains("active: true"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_json_array() {
|
||||
let data = vec![
|
||||
TestData { name: "a".to_string(), count: 1, active: true },
|
||||
TestData { name: "b".to_string(), count: 2, active: false },
|
||||
];
|
||||
let json = to_json(&data).unwrap();
|
||||
assert!(json.contains("\"name\": \"a\""));
|
||||
assert!(json.contains("\"name\": \"b\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_yaml_array() {
|
||||
let data = vec![
|
||||
TestData { name: "a".to_string(), count: 1, active: true },
|
||||
TestData { name: "b".to_string(), count: 2, active: false },
|
||||
];
|
||||
let yaml = to_yaml(&data).unwrap();
|
||||
assert!(yaml.contains("name: a"));
|
||||
assert!(yaml.contains("name: b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_format_equality() {
|
||||
assert_eq!(OutputFormat::Json, OutputFormat::Json);
|
||||
assert_ne!(OutputFormat::Json, OutputFormat::Yaml);
|
||||
assert_ne!(OutputFormat::Yaml, OutputFormat::Pretty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_output_format_clone() {
|
||||
let format = OutputFormat::Json;
|
||||
let cloned = format.clone();
|
||||
assert_eq!(format, cloned);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_json_special_chars() {
|
||||
let data = TestData {
|
||||
name: "test\"with\\special\nchars".to_string(),
|
||||
count: 0,
|
||||
active: false,
|
||||
};
|
||||
let json = to_json(&data).unwrap();
|
||||
// JSON should properly escape special characters
|
||||
assert!(json.contains("\\\""));
|
||||
assert!(json.contains("\\\\"));
|
||||
assert!(json.contains("\\n"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,483 +0,0 @@
|
||||
//! Integration tests for the hindsight CLI commands.
|
||||
//!
|
||||
//! These tests require a running hindsight API server.
|
||||
//! Set HINDSIGHT_API_URL environment variable to point to the server.
|
||||
//! Tests will be skipped if the server is not available.
|
||||
|
||||
use std::env;
|
||||
use std::process::Command;
|
||||
|
||||
/// Check if the API server is available
|
||||
fn server_available() -> bool {
|
||||
let api_url = env::var("HINDSIGHT_API_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
let health_url = format!("{}/health", api_url);
|
||||
|
||||
match reqwest::blocking::get(&health_url) {
|
||||
Ok(resp) => resp.status().is_success(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper macro to skip tests when server is not available
|
||||
macro_rules! skip_if_no_server {
|
||||
() => {
|
||||
if !server_available() {
|
||||
eprintln!("Skipping test: API server not available");
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Get the path to the hindsight binary
|
||||
fn hindsight_binary() -> String {
|
||||
env::var("CARGO_BIN_EXE_hindsight")
|
||||
.unwrap_or_else(|_| {
|
||||
// Try common locations
|
||||
let target_debug = "./target/debug/hindsight";
|
||||
let target_release = "./target/release/hindsight";
|
||||
if std::path::Path::new(target_debug).exists() {
|
||||
target_debug.to_string()
|
||||
} else if std::path::Path::new(target_release).exists() {
|
||||
target_release.to_string()
|
||||
} else {
|
||||
"hindsight".to_string()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Test bank ID for integration tests - each test needs a unique bank ID
|
||||
/// to avoid parallel test interference
|
||||
fn test_bank_id(test_name: &str) -> String {
|
||||
format!("cli-test-{}-{}", test_name, std::process::id())
|
||||
}
|
||||
|
||||
/// Run a hindsight CLI command
|
||||
fn run_hindsight(args: &[&str]) -> std::process::Output {
|
||||
let api_url = env::var("HINDSIGHT_API_URL").unwrap_or_else(|_| "http://localhost:8080".to_string());
|
||||
|
||||
Command::new(hindsight_binary())
|
||||
.env("HINDSIGHT_API_URL", &api_url)
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("Failed to execute hindsight command")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_check() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let output = run_hindsight(&["health"]);
|
||||
|
||||
// Should succeed or fail gracefully
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Either succeeded with "healthy" output or has a reasonable error
|
||||
if output.status.success() {
|
||||
// Note: output may contain ANSI color codes, so check for key text
|
||||
assert!(
|
||||
stdout.contains("healthy") || stdout.contains("Health") || stdout.contains("status"),
|
||||
"Expected health check output, got: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_health_check_json_output() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let output = run_hindsight(&["health", "-o", "json"]);
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
// Should be valid JSON
|
||||
let result: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.expect(&format!("Expected valid JSON output, got: {}", stdout));
|
||||
|
||||
// Should have status field
|
||||
assert!(result.get("status").is_some(), "Expected status field in health response");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bank_list() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let output = run_hindsight(&["bank", "list"]);
|
||||
|
||||
// Should succeed (even if no banks exist)
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Bank list command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bank_list_json_output() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let output = run_hindsight(&["bank", "list", "-o", "json"]);
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
// Should be valid JSON array
|
||||
let _result: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.expect(&format!("Expected valid JSON output, got: {}", stdout));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bank_create_and_delete() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("create-delete");
|
||||
|
||||
// Create a bank
|
||||
let output = run_hindsight(&[
|
||||
"bank", "create",
|
||||
&bank_id,
|
||||
"--name", "Test Bank",
|
||||
"--mission", "A test bank for CLI integration tests",
|
||||
]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Bank might already exist, which is OK
|
||||
let created = output.status.success();
|
||||
|
||||
// Get bank disposition
|
||||
let output = run_hindsight(&["bank", "disposition", &bank_id]);
|
||||
if created {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Bank disposition command failed: {} / {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up: delete the bank
|
||||
let output = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
// Deletion should succeed
|
||||
if created {
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Bank delete command failed: {} / {}",
|
||||
String::from_utf8_lossy(&output.stdout),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_memory_list() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("memory-list");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// List memories (should be empty for new bank)
|
||||
let output = run_hindsight(&["memory", "list", &bank_id]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should succeed (even if empty)
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Memory list command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mental_model_list() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("mm-list");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// List mental models
|
||||
let output = run_hindsight(&["mental-model", "list", &bank_id]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should succeed
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Mental model list command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mental_model_create_and_delete() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("mm-create");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// Create a mental model
|
||||
let output = run_hindsight(&[
|
||||
"mental-model", "create",
|
||||
&bank_id,
|
||||
"Test Model",
|
||||
"A test mental model",
|
||||
]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// The create command should succeed
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Mental model create failed: stdout={}, stderr={}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Verify it's in the list
|
||||
let output = run_hindsight(&["mental-model", "list", &bank_id, "-o", "json"]);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Mental model list failed: {}",
|
||||
stdout
|
||||
);
|
||||
|
||||
// Parse JSON and verify model exists
|
||||
if let Ok(result) = serde_json::from_str::<serde_json::Value>(&stdout) {
|
||||
if let Some(items) = result.get("items").and_then(|v| v.as_array()) {
|
||||
// Check if any model has the name "Test Model"
|
||||
let found = items.iter().any(|item| {
|
||||
item.get("name").and_then(|v| v.as_str()) == Some("Test Model")
|
||||
});
|
||||
assert!(found, "Expected to find 'Test Model' in mental models list: {}", stdout);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tag_list() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("tag-list");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// List tags
|
||||
let output = run_hindsight(&["tag", "list", &bank_id]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should succeed (even if no tags)
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Tag list command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_entity_list() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("entity-list");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// List entities
|
||||
let output = run_hindsight(&["entity", "list", &bank_id]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should succeed (even if no entities)
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Entity list command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_operation_list() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("op-list");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// List operations
|
||||
let output = run_hindsight(&["operation", "list", &bank_id]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should succeed (even if no operations)
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Operation list command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bank_stats() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("bank-stats");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// Get stats
|
||||
let output = run_hindsight(&["bank", "stats", &bank_id]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should succeed
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Bank stats command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bank_graph() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("bank-graph");
|
||||
|
||||
// Create the bank first
|
||||
let _ = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
// Get graph
|
||||
let output = run_hindsight(&["bank", "graph", &bank_id]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Should succeed (even if empty graph)
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Bank graph command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bank_update() {
|
||||
skip_if_no_server!();
|
||||
|
||||
let bank_id = test_bank_id("bank-update");
|
||||
|
||||
// Create the bank first
|
||||
let output = run_hindsight(&["bank", "create", &bank_id, "--name", "Test Bank"]);
|
||||
|
||||
if output.status.success() {
|
||||
// Update the bank
|
||||
let output = run_hindsight(&[
|
||||
"bank", "update", &bank_id,
|
||||
"--name", "Updated Test Bank",
|
||||
]);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Bank update command failed: {} / {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Verify the update
|
||||
let output = run_hindsight(&["bank", "disposition", &bank_id, "-o", "json"]);
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: serde_json::Value = serde_json::from_str(&stdout).unwrap();
|
||||
assert_eq!(
|
||||
result.get("name").and_then(|v| v.as_str()),
|
||||
Some("Updated Test Bank")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
let _ = run_hindsight(&["bank", "delete", &bank_id, "-y"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_yaml_output_formats() {
|
||||
skip_if_no_server!();
|
||||
|
||||
// Test JSON output for bank list
|
||||
let output = run_hindsight(&["bank", "list", "-o", "json"]);
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.expect("Expected valid JSON for bank list");
|
||||
}
|
||||
|
||||
// Test YAML output for bank list
|
||||
let output = run_hindsight(&["bank", "list", "-o", "yaml"]);
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let _: serde_yaml::Value = serde_yaml::from_str(&stdout)
|
||||
.expect("Expected valid YAML for bank list");
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,9 @@ hindsight_client_api/models/list_documents_response.py
|
||||
hindsight_client_api/models/list_memory_units_response.py
|
||||
hindsight_client_api/models/list_tags_response.py
|
||||
hindsight_client_api/models/memory_item.py
|
||||
hindsight_client_api/models/mental_model_freshness_response.py
|
||||
hindsight_client_api/models/mental_model_list_response.py
|
||||
hindsight_client_api/models/mental_model_observation_response.py
|
||||
hindsight_client_api/models/mental_model_response.py
|
||||
hindsight_client_api/models/observation_evidence_response.py
|
||||
hindsight_client_api/models/observation_input.py
|
||||
hindsight_client_api/models/operation_response.py
|
||||
hindsight_client_api/models/operation_status_response.py
|
||||
hindsight_client_api/models/operations_list_response.py
|
||||
@@ -73,7 +70,6 @@ hindsight_client_api/models/tag_item.py
|
||||
hindsight_client_api/models/token_usage.py
|
||||
hindsight_client_api/models/tool_calls_include_options.py
|
||||
hindsight_client_api/models/update_disposition_request.py
|
||||
hindsight_client_api/models/update_mental_model_request.py
|
||||
hindsight_client_api/models/validation_error.py
|
||||
hindsight_client_api/models/validation_error_loc_inner.py
|
||||
hindsight_client_api/rest.py
|
||||
|
||||
@@ -6,11 +6,11 @@ easy-to-use interface on top of the auto-generated OpenAPI client.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from typing import Optional, List, Dict, Any, Literal
|
||||
from typing import Optional, List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
import hindsight_client_api
|
||||
from hindsight_client_api.api import memory_api, banks_api, mental_models_api
|
||||
from hindsight_client_api.api import memory_api, banks_api
|
||||
from hindsight_client_api.models import (
|
||||
recall_request,
|
||||
retain_request,
|
||||
@@ -23,9 +23,6 @@ from hindsight_client_api.models.recall_result import RecallResult
|
||||
from hindsight_client_api.models.reflect_response import ReflectResponse
|
||||
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
|
||||
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
|
||||
from hindsight_client_api.models.mental_model_response import MentalModelResponse
|
||||
from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse
|
||||
from hindsight_client_api.models.async_operation_submit_response import AsyncOperationSubmitResponse
|
||||
|
||||
|
||||
def _run_async(coro):
|
||||
@@ -81,7 +78,6 @@ class Hindsight:
|
||||
self._api_client.set_default_header("Authorization", f"Bearer {api_key}")
|
||||
self._memory_api = memory_api.MemoryApi(self._api_client)
|
||||
self._banks_api = banks_api.BanksApi(self._api_client)
|
||||
self._mental_models_api = mental_models_api.MentalModelsApi(self._api_client)
|
||||
|
||||
def __enter__(self):
|
||||
"""Context manager entry."""
|
||||
@@ -119,7 +115,6 @@ class Hindsight:
|
||||
document_id: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, str]] = None,
|
||||
entities: Optional[List[Dict[str, str]]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> RetainResponse:
|
||||
"""
|
||||
Store a single memory (simplified interface).
|
||||
@@ -132,14 +127,13 @@ class Hindsight:
|
||||
document_id: Optional document ID for grouping
|
||||
metadata: Optional user-defined metadata
|
||||
entities: Optional list of entities [{"text": "...", "type": "..."}]
|
||||
tags: Optional list of tags for this memory
|
||||
|
||||
Returns:
|
||||
RetainResponse with success status
|
||||
"""
|
||||
return self.retain_batch(
|
||||
bank_id=bank_id,
|
||||
items=[{"content": content, "timestamp": timestamp, "context": context, "metadata": metadata, "entities": entities, "tags": tags}],
|
||||
items=[{"content": content, "timestamp": timestamp, "context": context, "metadata": metadata, "entities": entities}],
|
||||
document_id=document_id,
|
||||
)
|
||||
|
||||
@@ -149,17 +143,15 @@ class Hindsight:
|
||||
items: List[Dict[str, Any]],
|
||||
document_id: Optional[str] = None,
|
||||
retain_async: bool = False,
|
||||
document_tags: Optional[List[str]] = None,
|
||||
) -> RetainResponse:
|
||||
"""
|
||||
Store multiple memories in batch.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
items: List of memory items with 'content' and optional 'timestamp', 'context', 'metadata', 'document_id', 'entities', 'tags'
|
||||
items: List of memory items with 'content' and optional 'timestamp', 'context', 'metadata', 'document_id', 'entities'
|
||||
document_id: Optional document ID for grouping memories (applied to items that don't have their own)
|
||||
retain_async: If True, process asynchronously in background (default: False)
|
||||
document_tags: Optional list of tags to apply to all memories in this batch
|
||||
|
||||
Returns:
|
||||
RetainResponse with success status and item count
|
||||
@@ -183,14 +175,12 @@ class Hindsight:
|
||||
# Use item's document_id if provided, otherwise fall back to batch-level document_id
|
||||
document_id=item.get("document_id") or document_id,
|
||||
entities=entities,
|
||||
tags=item.get("tags"),
|
||||
)
|
||||
)
|
||||
|
||||
request_obj = retain_request.RetainRequest(
|
||||
items=memory_items,
|
||||
async_=retain_async,
|
||||
document_tags=document_tags,
|
||||
)
|
||||
|
||||
return _run_async(self._memory_api.retain_memories(bank_id, request_obj))
|
||||
@@ -208,8 +198,6 @@ class Hindsight:
|
||||
max_entity_tokens: int = 500,
|
||||
include_chunks: bool = False,
|
||||
max_chunk_tokens: int = 8192,
|
||||
tags: Optional[List[str]] = None,
|
||||
tags_match: str = "any",
|
||||
) -> RecallResponse:
|
||||
"""
|
||||
Recall memories using semantic similarity.
|
||||
@@ -226,9 +214,6 @@ class Hindsight:
|
||||
max_entity_tokens: Maximum tokens for entity observations (default: 500)
|
||||
include_chunks: Include raw text chunks in results (default: False)
|
||||
max_chunk_tokens: Maximum tokens for chunks (default: 8192)
|
||||
tags: Optional list of tags to filter memories by
|
||||
tags_match: How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged),
|
||||
'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged). Default: 'any'
|
||||
|
||||
Returns:
|
||||
RecallResponse with results, optional entities, optional chunks, and optional trace
|
||||
@@ -248,8 +233,6 @@ class Hindsight:
|
||||
trace=trace,
|
||||
query_timestamp=query_timestamp,
|
||||
include=include_opts,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
)
|
||||
|
||||
return _run_async(self._memory_api.recall_memories(bank_id, request_obj))
|
||||
@@ -262,8 +245,6 @@ class Hindsight:
|
||||
context: Optional[str] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
response_schema: Optional[Dict[str, Any]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
tags_match: str = "any",
|
||||
) -> ReflectResponse:
|
||||
"""
|
||||
Generate a contextual answer based on bank identity and memories.
|
||||
@@ -277,9 +258,6 @@ class Hindsight:
|
||||
response_schema: Optional JSON Schema for structured output. When provided,
|
||||
the response will include a 'structured_output' field with the LLM
|
||||
response parsed according to this schema.
|
||||
tags: Optional list of tags to filter memories by
|
||||
tags_match: How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged),
|
||||
'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged). Default: 'any'
|
||||
|
||||
Returns:
|
||||
ReflectResponse with answer text, optionally facts used, and optionally
|
||||
@@ -291,8 +269,6 @@ class Hindsight:
|
||||
context=context,
|
||||
max_tokens=max_tokens,
|
||||
response_schema=response_schema,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
)
|
||||
|
||||
return _run_async(self._memory_api.reflect(bank_id, request_obj))
|
||||
@@ -336,256 +312,6 @@ class Hindsight:
|
||||
|
||||
return _run_async(self._banks_api.create_or_update_bank(bank_id, request_obj))
|
||||
|
||||
def set_mission(
|
||||
self,
|
||||
bank_id: str,
|
||||
mission: str,
|
||||
) -> BankProfileResponse:
|
||||
"""
|
||||
Set or update the mission for a memory bank.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
mission: The mission text describing the agent's purpose
|
||||
|
||||
Returns:
|
||||
BankProfileResponse with updated bank profile
|
||||
"""
|
||||
from hindsight_client_api.models import create_bank_request
|
||||
|
||||
request_obj = create_bank_request.CreateBankRequest(mission=mission)
|
||||
return _run_async(self._banks_api.create_or_update_bank(bank_id, request_obj))
|
||||
|
||||
def list_mental_models(
|
||||
self,
|
||||
bank_id: str,
|
||||
subtype: Optional[Literal["structural", "emergent", "pinned", "learned", "directive"]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
tags_match: Optional[Literal["any", "all", "exact"]] = None,
|
||||
) -> MentalModelListResponse:
|
||||
"""
|
||||
List mental models for a bank.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
subtype: Optional filter by subtype (structural, emergent, pinned, learned, directive)
|
||||
tags: Optional list of tags to filter by
|
||||
tags_match: How to match tags - 'any' (OR), 'all' (AND), or 'exact'
|
||||
|
||||
Returns:
|
||||
MentalModelListResponse with list of mental models
|
||||
"""
|
||||
return _run_async(self._mental_models_api.list_mental_models(
|
||||
bank_id=bank_id,
|
||||
subtype=subtype,
|
||||
tags=tags,
|
||||
tags_match=tags_match,
|
||||
))
|
||||
|
||||
def get_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
) -> MentalModelResponse:
|
||||
"""
|
||||
Get a specific mental model by ID.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID
|
||||
|
||||
Returns:
|
||||
MentalModelResponse with full mental model details including observations
|
||||
"""
|
||||
return _run_async(self._mental_models_api.get_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
))
|
||||
|
||||
def create_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
name: str,
|
||||
description: str,
|
||||
subtype: Literal["pinned", "directive"] = "pinned",
|
||||
observations: Optional[List[Dict[str, str]]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> MentalModelResponse:
|
||||
"""
|
||||
Create a mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
name: Human-readable name for the mental model
|
||||
description: One-liner description for quick scanning
|
||||
subtype: Type of mental model - 'pinned' (LLM-generated observations) or 'directive' (user-provided observations)
|
||||
observations: For directives only - list of observations with 'title' and 'content' keys
|
||||
tags: Optional list of tags for scoped visibility
|
||||
|
||||
Returns:
|
||||
MentalModelResponse with created mental model
|
||||
"""
|
||||
from hindsight_client_api.models.create_mental_model_request import CreateMentalModelRequest
|
||||
from hindsight_client_api.models.observation_input import ObservationInput
|
||||
|
||||
obs_list = None
|
||||
if observations:
|
||||
obs_list = [ObservationInput(title=o.get("title", ""), content=o.get("content", "")) for o in observations]
|
||||
|
||||
request_obj = CreateMentalModelRequest(
|
||||
name=name,
|
||||
description=description,
|
||||
subtype=subtype,
|
||||
observations=obs_list,
|
||||
tags=tags or [],
|
||||
)
|
||||
return _run_async(self._mental_models_api.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
create_mental_model_request=request_obj,
|
||||
))
|
||||
|
||||
def update_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> MentalModelResponse:
|
||||
"""
|
||||
Update a mental model's name and/or description.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID
|
||||
name: Optional new name
|
||||
description: Optional new description
|
||||
|
||||
Returns:
|
||||
MentalModelResponse with updated mental model
|
||||
"""
|
||||
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
|
||||
|
||||
request_obj = UpdateMentalModelRequest(
|
||||
name=name,
|
||||
description=description,
|
||||
)
|
||||
return _run_async(self._mental_models_api.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
update_mental_model_request=request_obj,
|
||||
))
|
||||
|
||||
def delete_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
):
|
||||
"""
|
||||
Delete a mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID
|
||||
|
||||
Returns:
|
||||
DeleteResponse confirming deletion
|
||||
"""
|
||||
return _run_async(self._mental_models_api.delete_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
))
|
||||
|
||||
def refresh_mental_models(
|
||||
self,
|
||||
bank_id: str,
|
||||
subtype: Optional[Literal["structural", "emergent", "pinned", "learned"]] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
) -> AsyncOperationSubmitResponse:
|
||||
"""
|
||||
Submit a background job to refresh mental models for a bank.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
subtype: Optional - only refresh models of this subtype
|
||||
tags: Optional - tags to apply to newly created mental models
|
||||
|
||||
Returns:
|
||||
AsyncOperationSubmitResponse with operation_id to track progress
|
||||
"""
|
||||
from hindsight_client_api.models.refresh_mental_models_request import RefreshMentalModelsRequest
|
||||
|
||||
request_obj = RefreshMentalModelsRequest(
|
||||
subtype=subtype,
|
||||
tags=tags,
|
||||
)
|
||||
return _run_async(self._mental_models_api.refresh_mental_models(
|
||||
bank_id=bank_id,
|
||||
refresh_mental_models_request=request_obj,
|
||||
))
|
||||
|
||||
def refresh_mental_model(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
) -> AsyncOperationSubmitResponse:
|
||||
"""
|
||||
Submit a background job to refresh content for a specific mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID to refresh
|
||||
|
||||
Returns:
|
||||
AsyncOperationSubmitResponse with operation_id to track progress
|
||||
"""
|
||||
return _run_async(self._mental_models_api.refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
))
|
||||
|
||||
def list_mental_model_versions(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
):
|
||||
"""
|
||||
List all saved versions of a mental model's observations.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID
|
||||
|
||||
Returns:
|
||||
List of version objects ordered by version descending
|
||||
"""
|
||||
return _run_async(self._mental_models_api.list_mental_model_versions(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
))
|
||||
|
||||
def get_mental_model_version(
|
||||
self,
|
||||
bank_id: str,
|
||||
model_id: str,
|
||||
version: int,
|
||||
):
|
||||
"""
|
||||
Get observations from a specific version of a mental model.
|
||||
|
||||
Args:
|
||||
bank_id: The memory bank ID
|
||||
model_id: The mental model ID
|
||||
version: The version number
|
||||
|
||||
Returns:
|
||||
Version object with observations at that version
|
||||
"""
|
||||
return _run_async(self._mental_models_api.get_mental_model_version(
|
||||
bank_id=bank_id,
|
||||
model_id=model_id,
|
||||
version=version,
|
||||
))
|
||||
|
||||
# Async methods (native async, no _run_async wrapper)
|
||||
|
||||
async def aretain_batch(
|
||||
|
||||
@@ -70,12 +70,9 @@ from hindsight_client_api.models.list_documents_response import ListDocumentsRes
|
||||
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
|
||||
from hindsight_client_api.models.list_tags_response import ListTagsResponse
|
||||
from hindsight_client_api.models.memory_item import MemoryItem
|
||||
from hindsight_client_api.models.mental_model_freshness_response import MentalModelFreshnessResponse
|
||||
from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse
|
||||
from hindsight_client_api.models.mental_model_observation_response import MentalModelObservationResponse
|
||||
from hindsight_client_api.models.mental_model_response import MentalModelResponse
|
||||
from hindsight_client_api.models.observation_evidence_response import ObservationEvidenceResponse
|
||||
from hindsight_client_api.models.observation_input import ObservationInput
|
||||
from hindsight_client_api.models.operation_response import OperationResponse
|
||||
from hindsight_client_api.models.operation_status_response import OperationStatusResponse
|
||||
from hindsight_client_api.models.operations_list_response import OperationsListResponse
|
||||
@@ -98,6 +95,5 @@ from hindsight_client_api.models.tag_item import TagItem
|
||||
from hindsight_client_api.models.token_usage import TokenUsage
|
||||
from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions
|
||||
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
|
||||
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
|
||||
from hindsight_client_api.models.validation_error import ValidationError
|
||||
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -47,12 +47,9 @@ from hindsight_client_api.models.list_documents_response import ListDocumentsRes
|
||||
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
|
||||
from hindsight_client_api.models.list_tags_response import ListTagsResponse
|
||||
from hindsight_client_api.models.memory_item import MemoryItem
|
||||
from hindsight_client_api.models.mental_model_freshness_response import MentalModelFreshnessResponse
|
||||
from hindsight_client_api.models.mental_model_list_response import MentalModelListResponse
|
||||
from hindsight_client_api.models.mental_model_observation_response import MentalModelObservationResponse
|
||||
from hindsight_client_api.models.mental_model_response import MentalModelResponse
|
||||
from hindsight_client_api.models.observation_evidence_response import ObservationEvidenceResponse
|
||||
from hindsight_client_api.models.observation_input import ObservationInput
|
||||
from hindsight_client_api.models.operation_response import OperationResponse
|
||||
from hindsight_client_api.models.operation_status_response import OperationStatusResponse
|
||||
from hindsight_client_api.models.operations_list_response import OperationsListResponse
|
||||
@@ -75,6 +72,5 @@ from hindsight_client_api.models.tag_item import TagItem
|
||||
from hindsight_client_api.models.token_usage import TokenUsage
|
||||
from hindsight_client_api.models.tool_calls_include_options import ToolCallsIncludeOptions
|
||||
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
|
||||
from hindsight_client_api.models.update_mental_model_request import UpdateMentalModelRequest
|
||||
from hindsight_client_api.models.validation_error import ValidationError
|
||||
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
|
||||
|
||||
+2
-19
@@ -19,20 +19,17 @@ import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from hindsight_client_api.models.observation_input import ObservationInput
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class CreateMentalModelRequest(BaseModel):
|
||||
"""
|
||||
Request model for creating a mental model.
|
||||
Request model for creating a pinned mental model.
|
||||
""" # noqa: E501
|
||||
name: StrictStr = Field(description="Human-readable name for the mental model")
|
||||
description: StrictStr = Field(description="One-liner description for quick scanning")
|
||||
subtype: Optional[StrictStr] = Field(default='pinned', description="Type of mental model: 'pinned' (observations LLM-generated) or 'directive' (observations user-provided)")
|
||||
observations: Optional[List[ObservationInput]] = None
|
||||
tags: Optional[List[StrictStr]] = Field(default=None, description="Tags for scoped visibility")
|
||||
__properties: ClassVar[List[str]] = ["name", "description", "subtype", "observations", "tags"]
|
||||
__properties: ClassVar[List[str]] = ["name", "description", "tags"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -73,18 +70,6 @@ class CreateMentalModelRequest(BaseModel):
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# override the default output from pydantic by calling `to_dict()` of each item in observations (list)
|
||||
_items = []
|
||||
if self.observations:
|
||||
for _item_observations in self.observations:
|
||||
if _item_observations:
|
||||
_items.append(_item_observations.to_dict())
|
||||
_dict['observations'] = _items
|
||||
# set to None if observations (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.observations is None and "observations" in self.model_fields_set:
|
||||
_dict['observations'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
@@ -99,8 +84,6 @@ class CreateMentalModelRequest(BaseModel):
|
||||
_obj = cls.model_validate({
|
||||
"name": obj.get("name"),
|
||||
"description": obj.get("description"),
|
||||
"subtype": obj.get("subtype") if obj.get("subtype") is not None else 'pinned',
|
||||
"observations": [ObservationInput.from_dict(_item) for _item in obj["observations"]] if obj.get("observations") is not None else None,
|
||||
"tags": obj.get("tags")
|
||||
})
|
||||
return _obj
|
||||
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class MentalModelFreshnessResponse(BaseModel):
|
||||
"""
|
||||
Freshness information for a mental model.
|
||||
""" # noqa: E501
|
||||
is_up_to_date: StrictBool = Field(description="Whether the model has been refreshed since the last memory was added")
|
||||
last_refresh_at: Optional[StrictStr]
|
||||
memories_since_refresh: StrictInt = Field(description="Number of memories added since last refresh")
|
||||
reasons: Optional[List[StrictStr]] = Field(default=None, description="Reasons why the model needs refresh (empty if up to date). Possible values: never_refreshed, new_memories, mission_changed, disposition_changed, directives_changed")
|
||||
__properties: ClassVar[List[str]] = ["is_up_to_date", "last_refresh_at", "memories_since_refresh", "reasons"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of MentalModelFreshnessResponse from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# set to None if last_refresh_at (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.last_refresh_at is None and "last_refresh_at" in self.model_fields_set:
|
||||
_dict['last_refresh_at'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of MentalModelFreshnessResponse from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"is_up_to_date": obj.get("is_up_to_date"),
|
||||
"last_refresh_at": obj.get("last_refresh_at"),
|
||||
"memories_since_refresh": obj.get("memories_since_refresh"),
|
||||
"reasons": obj.get("reasons")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
+8
-24
@@ -17,24 +17,19 @@ import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from hindsight_client_api.models.observation_evidence_response import ObservationEvidenceResponse
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class MentalModelObservationResponse(BaseModel):
|
||||
"""
|
||||
An observation within a mental model with its supporting evidence.
|
||||
An observation within a mental model with its supporting memories.
|
||||
""" # noqa: E501
|
||||
title: StrictStr = Field(description="Short summary title for the observation")
|
||||
content: StrictStr = Field(description="The observation content - detailed explanation")
|
||||
evidence: Optional[List[ObservationEvidenceResponse]] = Field(default=None, description="Supporting evidence with quotes")
|
||||
created_at: StrictStr = Field(description="When this observation was first created (ISO format)")
|
||||
trend: StrictStr = Field(description="Computed trend: stable, strengthening, weakening, new, stale")
|
||||
evidence_count: StrictInt = Field(description="Number of evidence items supporting this observation")
|
||||
evidence_span: Dict[str, Any] = Field(description="Time span of evidence: {from: iso_date, to: iso_date}")
|
||||
__properties: ClassVar[List[str]] = ["title", "content", "evidence", "created_at", "trend", "evidence_count", "evidence_span"]
|
||||
title: StrictStr = Field(description="Observation header (empty for intro)")
|
||||
text: StrictStr = Field(description="Observation content")
|
||||
based_on: Optional[List[StrictStr]] = Field(default=None, description="Memory IDs supporting this observation")
|
||||
__properties: ClassVar[List[str]] = ["title", "text", "based_on"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -75,13 +70,6 @@ class MentalModelObservationResponse(BaseModel):
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# override the default output from pydantic by calling `to_dict()` of each item in evidence (list)
|
||||
_items = []
|
||||
if self.evidence:
|
||||
for _item_evidence in self.evidence:
|
||||
if _item_evidence:
|
||||
_items.append(_item_evidence.to_dict())
|
||||
_dict['evidence'] = _items
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
@@ -95,12 +83,8 @@ class MentalModelObservationResponse(BaseModel):
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"title": obj.get("title"),
|
||||
"content": obj.get("content"),
|
||||
"evidence": [ObservationEvidenceResponse.from_dict(_item) for _item in obj["evidence"]] if obj.get("evidence") is not None else None,
|
||||
"created_at": obj.get("created_at"),
|
||||
"trend": obj.get("trend"),
|
||||
"evidence_count": obj.get("evidence_count"),
|
||||
"evidence_span": obj.get("evidence_span")
|
||||
"text": obj.get("text"),
|
||||
"based_on": obj.get("based_on")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -17,9 +17,8 @@ import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from hindsight_client_api.models.mental_model_freshness_response import MentalModelFreshnessResponse
|
||||
from hindsight_client_api.models.mental_model_observation_response import MentalModelObservationResponse
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
@@ -34,15 +33,12 @@ class MentalModelResponse(BaseModel):
|
||||
name: StrictStr
|
||||
description: StrictStr
|
||||
observations: Optional[List[MentalModelObservationResponse]] = Field(default=None, description="Structured observations with per-observation fact attribution")
|
||||
version: Optional[StrictInt] = Field(default=0, description="Version number of the mental model observations")
|
||||
entity_id: Optional[StrictStr] = None
|
||||
links: Optional[List[StrictStr]] = None
|
||||
tags: Optional[List[StrictStr]] = None
|
||||
last_updated: Optional[StrictStr] = None
|
||||
last_refresh_at: Optional[StrictStr] = None
|
||||
freshness: Optional[MentalModelFreshnessResponse] = None
|
||||
created_at: StrictStr
|
||||
__properties: ClassVar[List[str]] = ["id", "bank_id", "subtype", "name", "description", "observations", "version", "entity_id", "links", "tags", "last_updated", "last_refresh_at", "freshness", "created_at"]
|
||||
__properties: ClassVar[List[str]] = ["id", "bank_id", "subtype", "name", "description", "observations", "entity_id", "links", "tags", "last_updated", "created_at"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -90,9 +86,6 @@ class MentalModelResponse(BaseModel):
|
||||
if _item_observations:
|
||||
_items.append(_item_observations.to_dict())
|
||||
_dict['observations'] = _items
|
||||
# override the default output from pydantic by calling `to_dict()` of freshness
|
||||
if self.freshness:
|
||||
_dict['freshness'] = self.freshness.to_dict()
|
||||
# set to None if entity_id (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.entity_id is None and "entity_id" in self.model_fields_set:
|
||||
@@ -103,16 +96,6 @@ class MentalModelResponse(BaseModel):
|
||||
if self.last_updated is None and "last_updated" in self.model_fields_set:
|
||||
_dict['last_updated'] = None
|
||||
|
||||
# set to None if last_refresh_at (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.last_refresh_at is None and "last_refresh_at" in self.model_fields_set:
|
||||
_dict['last_refresh_at'] = None
|
||||
|
||||
# set to None if freshness (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.freshness is None and "freshness" in self.model_fields_set:
|
||||
_dict['freshness'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
@@ -131,13 +114,10 @@ class MentalModelResponse(BaseModel):
|
||||
"name": obj.get("name"),
|
||||
"description": obj.get("description"),
|
||||
"observations": [MentalModelObservationResponse.from_dict(_item) for _item in obj["observations"]] if obj.get("observations") is not None else None,
|
||||
"version": obj.get("version") if obj.get("version") is not None else 0,
|
||||
"entity_id": obj.get("entity_id"),
|
||||
"links": obj.get("links"),
|
||||
"tags": obj.get("tags"),
|
||||
"last_updated": obj.get("last_updated"),
|
||||
"last_refresh_at": obj.get("last_refresh_at"),
|
||||
"freshness": MentalModelFreshnessResponse.from_dict(obj["freshness"]) if obj.get("freshness") is not None else None,
|
||||
"created_at": obj.get("created_at")
|
||||
})
|
||||
return _obj
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class ObservationEvidenceResponse(BaseModel):
|
||||
"""
|
||||
A single piece of evidence supporting an observation.
|
||||
""" # noqa: E501
|
||||
memory_id: StrictStr = Field(description="ID of the memory unit this evidence comes from")
|
||||
quote: StrictStr = Field(description="Exact quote from the memory supporting the observation")
|
||||
relevance: StrictStr = Field(description="Brief explanation of how this quote supports the observation")
|
||||
timestamp: StrictStr = Field(description="When the source memory was created (ISO format)")
|
||||
__properties: ClassVar[List[str]] = ["memory_id", "quote", "relevance", "timestamp"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of ObservationEvidenceResponse from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of ObservationEvidenceResponse from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"memory_id": obj.get("memory_id"),
|
||||
"quote": obj.get("quote"),
|
||||
"relevance": obj.get("relevance"),
|
||||
"timestamp": obj.get("timestamp")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class ObservationInput(BaseModel):
|
||||
"""
|
||||
Input model for a single observation.
|
||||
""" # noqa: E501
|
||||
title: StrictStr = Field(description="Short title/header for the observation")
|
||||
content: StrictStr = Field(description="Content of the observation")
|
||||
__properties: ClassVar[List[str]] = ["title", "content"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of ObservationInput from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of ObservationInput from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"title": obj.get("title"),
|
||||
"content": obj.get("content")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -29,9 +29,10 @@ class ReflectMentalModel(BaseModel):
|
||||
id: StrictStr = Field(description="Mental model ID")
|
||||
name: StrictStr = Field(description="Mental model name")
|
||||
type: StrictStr = Field(description="Mental model type: entity, concept, event")
|
||||
subtype: StrictStr = Field(description="Mental model subtype: structural, emergent, learned, directive")
|
||||
observations: Optional[List[StrictStr]] = None
|
||||
__properties: ClassVar[List[str]] = ["id", "name", "type", "subtype", "observations"]
|
||||
subtype: StrictStr = Field(description="Mental model subtype: structural, emergent, learned")
|
||||
description: StrictStr = Field(description="Brief description")
|
||||
summary: Optional[StrictStr] = None
|
||||
__properties: ClassVar[List[str]] = ["id", "name", "type", "subtype", "description", "summary"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -72,10 +73,10 @@ class ReflectMentalModel(BaseModel):
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# set to None if observations (nullable) is None
|
||||
# set to None if summary (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.observations is None and "observations" in self.model_fields_set:
|
||||
_dict['observations'] = None
|
||||
if self.summary is None and "summary" in self.model_fields_set:
|
||||
_dict['summary'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@@ -93,7 +94,8 @@ class ReflectMentalModel(BaseModel):
|
||||
"name": obj.get("name"),
|
||||
"type": obj.get("type"),
|
||||
"subtype": obj.get("subtype"),
|
||||
"observations": obj.get("observations")
|
||||
"description": obj.get("description"),
|
||||
"summary": obj.get("summary")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -20,7 +20,6 @@ import json
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from hindsight_client_api.models.reflect_llm_call import ReflectLLMCall
|
||||
from hindsight_client_api.models.reflect_mental_model import ReflectMentalModel
|
||||
from hindsight_client_api.models.reflect_tool_call import ReflectToolCall
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
@@ -31,8 +30,7 @@ class ReflectTrace(BaseModel):
|
||||
""" # noqa: E501
|
||||
tool_calls: Optional[List[ReflectToolCall]] = Field(default=None, description="Tool calls made during reflection")
|
||||
llm_calls: Optional[List[ReflectLLMCall]] = Field(default=None, description="LLM calls made during reflection")
|
||||
mental_models: Optional[List[ReflectMentalModel]] = Field(default=None, description="Mental models used during reflection (includes directives with subtype='directive')")
|
||||
__properties: ClassVar[List[str]] = ["tool_calls", "llm_calls", "mental_models"]
|
||||
__properties: ClassVar[List[str]] = ["tool_calls", "llm_calls"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -87,13 +85,6 @@ class ReflectTrace(BaseModel):
|
||||
if _item_llm_calls:
|
||||
_items.append(_item_llm_calls.to_dict())
|
||||
_dict['llm_calls'] = _items
|
||||
# override the default output from pydantic by calling `to_dict()` of each item in mental_models (list)
|
||||
_items = []
|
||||
if self.mental_models:
|
||||
for _item_mental_models in self.mental_models:
|
||||
if _item_mental_models:
|
||||
_items.append(_item_mental_models.to_dict())
|
||||
_dict['mental_models'] = _items
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
@@ -107,8 +98,7 @@ class ReflectTrace(BaseModel):
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"tool_calls": [ReflectToolCall.from_dict(_item) for _item in obj["tool_calls"]] if obj.get("tool_calls") is not None else None,
|
||||
"llm_calls": [ReflectLLMCall.from_dict(_item) for _item in obj["llm_calls"]] if obj.get("llm_calls") is not None else None,
|
||||
"mental_models": [ReflectMentalModel.from_dict(_item) for _item in obj["mental_models"]] if obj.get("mental_models") is not None else None
|
||||
"llm_calls": [ReflectLLMCall.from_dict(_item) for _item in obj["llm_calls"]] if obj.get("llm_calls") is not None else None
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class UpdateMentalModelRequest(BaseModel):
|
||||
"""
|
||||
Request model for updating a mental model.
|
||||
""" # noqa: E501
|
||||
name: Optional[StrictStr] = None
|
||||
description: Optional[StrictStr] = None
|
||||
__properties: ClassVar[List[str]] = ["name", "description"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of UpdateMentalModelRequest from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# set to None if name (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.name is None and "name" in self.model_fields_set:
|
||||
_dict['name'] = None
|
||||
|
||||
# set to None if description (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.description is None and "description" in self.model_fields_set:
|
||||
_dict['description'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of UpdateMentalModelRequest from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"name": obj.get("name"),
|
||||
"description": obj.get("description")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -544,205 +544,3 @@ class TestDeleteBank:
|
||||
# Verify bank data is deleted - memories should be gone
|
||||
memories = client.list_memories(bank_id=bank_id)
|
||||
assert memories.total == 0
|
||||
|
||||
|
||||
class TestMentalModels:
|
||||
"""Tests for mental model operations."""
|
||||
|
||||
def test_set_mission(self, client, bank_id):
|
||||
"""Test setting a bank's mission."""
|
||||
response = client.set_mission(
|
||||
bank_id=bank_id,
|
||||
mission="Be a helpful PM tracking sprint progress and team capacity",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.bank_id == bank_id
|
||||
assert response.mission == "Be a helpful PM tracking sprint progress and team capacity"
|
||||
|
||||
def test_create_pinned_mental_model(self, client, bank_id):
|
||||
"""Test creating a pinned mental model."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
response = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Product Roadmap",
|
||||
description="Track product priorities and feature decisions",
|
||||
subtype="pinned",
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.name == "Product Roadmap"
|
||||
assert response.description == "Track product priorities and feature decisions"
|
||||
assert response.subtype == "pinned"
|
||||
|
||||
def test_create_directive_mental_model(self, client, bank_id):
|
||||
"""Test creating a directive mental model with observations."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
response = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Response Guidelines",
|
||||
description="Rules for responding to users",
|
||||
subtype="directive",
|
||||
observations=[
|
||||
{"title": "Always be polite", "content": "All responses must be courteous and professional"},
|
||||
{"title": "Never share private info", "content": "Do not reveal internal details or user data"},
|
||||
],
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.name == "Response Guidelines"
|
||||
assert response.subtype == "directive"
|
||||
assert response.observations is not None
|
||||
assert len(response.observations) == 2
|
||||
|
||||
def test_list_mental_models(self, client, bank_id):
|
||||
"""Test listing mental models."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Test Model",
|
||||
description="A test mental model",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.list_mental_models(bank_id=bank_id)
|
||||
|
||||
assert response is not None
|
||||
assert response.items is not None
|
||||
assert len(response.items) >= 1
|
||||
|
||||
def test_get_mental_model(self, client, bank_id):
|
||||
"""Test getting a specific mental model."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
created = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Retrieve Test Model",
|
||||
description="A model to retrieve",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.get_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=created.id,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.id == created.id
|
||||
assert response.name == "Retrieve Test Model"
|
||||
|
||||
def test_update_mental_model(self, client, bank_id):
|
||||
"""Test updating a mental model."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
created = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Update Test Model",
|
||||
description="Original description",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.update_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=created.id,
|
||||
name="Updated Model Name",
|
||||
description="Updated description",
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.name == "Updated Model Name"
|
||||
assert response.description == "Updated description"
|
||||
|
||||
def test_delete_mental_model(self, client, bank_id):
|
||||
"""Test deleting a mental model."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
created = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Delete Test Model",
|
||||
description="A model to delete",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.delete_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=created.id,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.success is True
|
||||
|
||||
def test_refresh_mental_models(self, client, bank_id):
|
||||
"""Test refreshing all mental models (async operation)."""
|
||||
# Set mission first (required for refresh) - this also creates the bank
|
||||
client.set_mission(
|
||||
bank_id=bank_id,
|
||||
mission="Track team progress and decisions",
|
||||
)
|
||||
|
||||
response = client.refresh_mental_models(
|
||||
bank_id=bank_id,
|
||||
tags=["test"],
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.operation_id is not None
|
||||
assert response.status == "queued"
|
||||
|
||||
def test_refresh_mental_model(self, client, bank_id):
|
||||
"""Test refreshing a single mental model (async operation)."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
created = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Refresh Single Test",
|
||||
description="A model to refresh individually",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.refresh_mental_model(
|
||||
bank_id=bank_id,
|
||||
model_id=created.id,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.operation_id is not None
|
||||
assert response.status == "queued"
|
||||
|
||||
def test_list_mental_model_versions(self, client, bank_id):
|
||||
"""Test listing mental model versions."""
|
||||
# Create bank first (required for mental models)
|
||||
client.create_bank(bank_id=bank_id)
|
||||
|
||||
# Create a model first
|
||||
created = client.create_mental_model(
|
||||
bank_id=bank_id,
|
||||
name="Versions Test Model",
|
||||
description="A model to test version history",
|
||||
subtype="pinned",
|
||||
)
|
||||
|
||||
response = client.list_mental_model_versions(
|
||||
bank_id=bank_id,
|
||||
model_id=created.id,
|
||||
)
|
||||
|
||||
# Newly created model should have version history
|
||||
assert response is not None
|
||||
|
||||
@@ -27,6 +27,9 @@ import type {
|
||||
DeleteMentalModelData,
|
||||
DeleteMentalModelErrors,
|
||||
DeleteMentalModelResponses,
|
||||
GenerateMentalModelData,
|
||||
GenerateMentalModelErrors,
|
||||
GenerateMentalModelResponses,
|
||||
GetAgentStatsData,
|
||||
GetAgentStatsErrors,
|
||||
GetAgentStatsResponses,
|
||||
@@ -51,9 +54,6 @@ import type {
|
||||
GetMentalModelData,
|
||||
GetMentalModelErrors,
|
||||
GetMentalModelResponses,
|
||||
GetMentalModelVersionData,
|
||||
GetMentalModelVersionErrors,
|
||||
GetMentalModelVersionResponses,
|
||||
GetOperationStatusData,
|
||||
GetOperationStatusErrors,
|
||||
GetOperationStatusResponses,
|
||||
@@ -74,9 +74,6 @@ import type {
|
||||
ListMentalModelsData,
|
||||
ListMentalModelsErrors,
|
||||
ListMentalModelsResponses,
|
||||
ListMentalModelVersionsData,
|
||||
ListMentalModelVersionsErrors,
|
||||
ListMentalModelVersionsResponses,
|
||||
ListOperationsData,
|
||||
ListOperationsErrors,
|
||||
ListOperationsResponses,
|
||||
@@ -91,9 +88,6 @@ import type {
|
||||
ReflectData,
|
||||
ReflectErrors,
|
||||
ReflectResponses,
|
||||
RefreshMentalModelData,
|
||||
RefreshMentalModelErrors,
|
||||
RefreshMentalModelResponses,
|
||||
RefreshMentalModelsData,
|
||||
RefreshMentalModelsErrors,
|
||||
RefreshMentalModelsResponses,
|
||||
@@ -109,9 +103,6 @@ import type {
|
||||
UpdateBankDispositionResponses,
|
||||
UpdateBankErrors,
|
||||
UpdateBankResponses,
|
||||
UpdateMentalModelData,
|
||||
UpdateMentalModelErrors,
|
||||
UpdateMentalModelResponses,
|
||||
} from "./types.gen";
|
||||
|
||||
export type Options<
|
||||
@@ -352,9 +343,7 @@ export const listMentalModels = <ThrowOnError extends boolean = false>(
|
||||
/**
|
||||
* Create mental model
|
||||
*
|
||||
* Create a mental model. Supports two subtypes:
|
||||
* - 'pinned' (default): User-defined topic, observations are LLM-generated on refresh
|
||||
* - 'directive': User-defined hard rules, observations are provided at creation and never regenerated
|
||||
* Create a pinned mental model. Pinned models are user-defined and persist across refreshes.
|
||||
*/
|
||||
export const createMentalModel = <ThrowOnError extends boolean = false>(
|
||||
options: Options<CreateMentalModelData, ThrowOnError>,
|
||||
@@ -406,27 +395,6 @@ export const getMentalModel = <ThrowOnError extends boolean = false>(
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Update mental model
|
||||
*
|
||||
* Update a mental model's name and/or description. Useful for editing directives.
|
||||
*/
|
||||
export const updateMentalModel = <ThrowOnError extends boolean = false>(
|
||||
options: Options<UpdateMentalModelData, ThrowOnError>,
|
||||
) =>
|
||||
(options.client ?? client).patch<
|
||||
UpdateMentalModelResponses,
|
||||
UpdateMentalModelErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Refresh mental models (async)
|
||||
*
|
||||
@@ -449,53 +417,19 @@ export const refreshMentalModels = <ThrowOnError extends boolean = false>(
|
||||
});
|
||||
|
||||
/**
|
||||
* Refresh mental model content (async)
|
||||
* Generate mental model content (async)
|
||||
*
|
||||
* Submit a background job to refresh content for a specific mental model. This is useful for newly created learned models or to refresh content for any model.
|
||||
* Submit a background job to generate/refresh content for a specific mental model. This is useful for newly created learned models or to regenerate content for any model.
|
||||
*/
|
||||
export const refreshMentalModel = <ThrowOnError extends boolean = false>(
|
||||
options: Options<RefreshMentalModelData, ThrowOnError>,
|
||||
export const generateMentalModel = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GenerateMentalModelData, ThrowOnError>,
|
||||
) =>
|
||||
(options.client ?? client).post<
|
||||
RefreshMentalModelResponses,
|
||||
RefreshMentalModelErrors,
|
||||
GenerateMentalModelResponses,
|
||||
GenerateMentalModelErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* List mental model version history
|
||||
*
|
||||
* List all saved versions of a mental model's observations, ordered by version descending.
|
||||
*/
|
||||
export const listMentalModelVersions = <ThrowOnError extends boolean = false>(
|
||||
options: Options<ListMentalModelVersionsData, ThrowOnError>,
|
||||
) =>
|
||||
(options.client ?? client).get<
|
||||
ListMentalModelVersionsResponses,
|
||||
ListMentalModelVersionsErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions",
|
||||
...options,
|
||||
});
|
||||
|
||||
/**
|
||||
* Get specific mental model version
|
||||
*
|
||||
* Get observations from a specific version of a mental model.
|
||||
*/
|
||||
export const getMentalModelVersion = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetMentalModelVersionData, ThrowOnError>,
|
||||
) =>
|
||||
(options.client ?? client).get<
|
||||
GetMentalModelVersionResponses,
|
||||
GetMentalModelVersionErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}",
|
||||
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/generate",
|
||||
...options,
|
||||
});
|
||||
|
||||
|
||||
@@ -314,7 +314,7 @@ export type CreateBankRequest = {
|
||||
/**
|
||||
* CreateMentalModelRequest
|
||||
*
|
||||
* Request model for creating a mental model.
|
||||
* Request model for creating a pinned mental model.
|
||||
*/
|
||||
export type CreateMentalModelRequest = {
|
||||
/**
|
||||
@@ -329,18 +329,6 @@ export type CreateMentalModelRequest = {
|
||||
* One-liner description for quick scanning
|
||||
*/
|
||||
description: string;
|
||||
/**
|
||||
* Subtype
|
||||
*
|
||||
* Type of mental model: 'pinned' (observations LLM-generated) or 'directive' (observations user-provided)
|
||||
*/
|
||||
subtype?: string;
|
||||
/**
|
||||
* Observations
|
||||
*
|
||||
* For directives only: list of user-provided observations. Required when subtype='directive'.
|
||||
*/
|
||||
observations?: Array<ObservationInput> | null;
|
||||
/**
|
||||
* Tags
|
||||
*
|
||||
@@ -842,38 +830,6 @@ export type MemoryItem = {
|
||||
tags?: Array<string> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* MentalModelFreshnessResponse
|
||||
*
|
||||
* Freshness information for a mental model.
|
||||
*/
|
||||
export type MentalModelFreshnessResponse = {
|
||||
/**
|
||||
* Is Up To Date
|
||||
*
|
||||
* Whether the model has been refreshed since the last memory was added
|
||||
*/
|
||||
is_up_to_date: boolean;
|
||||
/**
|
||||
* Last Refresh At
|
||||
*
|
||||
* When the model was last refreshed (ISO format)
|
||||
*/
|
||||
last_refresh_at: string | null;
|
||||
/**
|
||||
* Memories Since Refresh
|
||||
*
|
||||
* Number of memories added since last refresh
|
||||
*/
|
||||
memories_since_refresh: number;
|
||||
/**
|
||||
* Reasons
|
||||
*
|
||||
* Reasons why the model needs refresh (empty if up to date). Possible values: never_refreshed, new_memories, mission_changed, disposition_changed, directives_changed
|
||||
*/
|
||||
reasons?: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
* MentalModelListResponse
|
||||
*
|
||||
@@ -889,53 +845,27 @@ export type MentalModelListResponse = {
|
||||
/**
|
||||
* MentalModelObservationResponse
|
||||
*
|
||||
* An observation within a mental model with its supporting evidence.
|
||||
* An observation within a mental model with its supporting memories.
|
||||
*/
|
||||
export type MentalModelObservationResponse = {
|
||||
/**
|
||||
* Title
|
||||
*
|
||||
* Short summary title for the observation
|
||||
* Observation header (empty for intro)
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Content
|
||||
* Text
|
||||
*
|
||||
* The observation content - detailed explanation
|
||||
* Observation content
|
||||
*/
|
||||
content: string;
|
||||
text: string;
|
||||
/**
|
||||
* Evidence
|
||||
* Based On
|
||||
*
|
||||
* Supporting evidence with quotes
|
||||
* Memory IDs supporting this observation
|
||||
*/
|
||||
evidence?: Array<ObservationEvidenceResponse>;
|
||||
/**
|
||||
* Created At
|
||||
*
|
||||
* When this observation was first created (ISO format)
|
||||
*/
|
||||
created_at: string;
|
||||
/**
|
||||
* Trend
|
||||
*
|
||||
* Computed trend: stable, strengthening, weakening, new, stale
|
||||
*/
|
||||
trend: string;
|
||||
/**
|
||||
* Evidence Count
|
||||
*
|
||||
* Number of evidence items supporting this observation
|
||||
*/
|
||||
evidence_count: number;
|
||||
/**
|
||||
* Evidence Span
|
||||
*
|
||||
* Time span of evidence: {from: iso_date, to: iso_date}
|
||||
*/
|
||||
evidence_span: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
based_on?: Array<string>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -970,12 +900,6 @@ export type MentalModelResponse = {
|
||||
* Structured observations with per-observation fact attribution
|
||||
*/
|
||||
observations?: Array<MentalModelObservationResponse>;
|
||||
/**
|
||||
* Version
|
||||
*
|
||||
* Version number of the mental model observations
|
||||
*/
|
||||
version?: number;
|
||||
/**
|
||||
* Entity Id
|
||||
*/
|
||||
@@ -992,74 +916,12 @@ export type MentalModelResponse = {
|
||||
* Last Updated
|
||||
*/
|
||||
last_updated?: string | null;
|
||||
/**
|
||||
* Last Refresh At
|
||||
*
|
||||
* When observations were last refreshed (ISO format)
|
||||
*/
|
||||
last_refresh_at?: string | null;
|
||||
/**
|
||||
* Freshness info (null for directive subtypes which don't need refresh)
|
||||
*/
|
||||
freshness?: MentalModelFreshnessResponse | null;
|
||||
/**
|
||||
* Created At
|
||||
*/
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* ObservationEvidenceResponse
|
||||
*
|
||||
* A single piece of evidence supporting an observation.
|
||||
*/
|
||||
export type ObservationEvidenceResponse = {
|
||||
/**
|
||||
* Memory Id
|
||||
*
|
||||
* ID of the memory unit this evidence comes from
|
||||
*/
|
||||
memory_id: string;
|
||||
/**
|
||||
* Quote
|
||||
*
|
||||
* Exact quote from the memory supporting the observation
|
||||
*/
|
||||
quote: string;
|
||||
/**
|
||||
* Relevance
|
||||
*
|
||||
* Brief explanation of how this quote supports the observation
|
||||
*/
|
||||
relevance: string;
|
||||
/**
|
||||
* Timestamp
|
||||
*
|
||||
* When the source memory was created (ISO format)
|
||||
*/
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* ObservationInput
|
||||
*
|
||||
* Input model for a single observation.
|
||||
*/
|
||||
export type ObservationInput = {
|
||||
/**
|
||||
* Title
|
||||
*
|
||||
* Short title/header for the observation
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Content
|
||||
*
|
||||
* Content of the observation
|
||||
*/
|
||||
content: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* OperationResponse
|
||||
*
|
||||
@@ -1408,15 +1270,21 @@ export type ReflectMentalModel = {
|
||||
/**
|
||||
* Subtype
|
||||
*
|
||||
* Mental model subtype: structural, emergent, learned, directive
|
||||
* Mental model subtype: structural, emergent, learned
|
||||
*/
|
||||
subtype: string;
|
||||
/**
|
||||
* Observations
|
||||
* Description
|
||||
*
|
||||
* Observations for directive mental models (subtype='directive')
|
||||
* Brief description
|
||||
*/
|
||||
observations?: Array<string> | null;
|
||||
description: string;
|
||||
/**
|
||||
* Summary
|
||||
*
|
||||
* Full summary (when looked up in detail)
|
||||
*/
|
||||
summary?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1568,12 +1436,6 @@ export type ReflectTrace = {
|
||||
* LLM calls made during reflection
|
||||
*/
|
||||
llm_calls?: Array<ReflectLlmCall>;
|
||||
/**
|
||||
* Mental Models
|
||||
*
|
||||
* Mental models used during reflection (includes directives with subtype='directive')
|
||||
*/
|
||||
mental_models?: Array<ReflectMentalModel>;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1728,26 +1590,6 @@ export type UpdateDispositionRequest = {
|
||||
disposition: DispositionTraits;
|
||||
};
|
||||
|
||||
/**
|
||||
* UpdateMentalModelRequest
|
||||
*
|
||||
* Request model for updating a mental model.
|
||||
*/
|
||||
export type UpdateMentalModelRequest = {
|
||||
/**
|
||||
* Name
|
||||
*
|
||||
* New name for the mental model
|
||||
*/
|
||||
name?: string | null;
|
||||
/**
|
||||
* Description
|
||||
*
|
||||
* New description/rule text
|
||||
*/
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* ValidationError
|
||||
*/
|
||||
@@ -2384,48 +2226,6 @@ export type GetMentalModelResponses = {
|
||||
export type GetMentalModelResponse =
|
||||
GetMentalModelResponses[keyof GetMentalModelResponses];
|
||||
|
||||
export type UpdateMentalModelData = {
|
||||
body: UpdateMentalModelRequest;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
/**
|
||||
* Model Id
|
||||
*/
|
||||
model_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}";
|
||||
};
|
||||
|
||||
export type UpdateMentalModelErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type UpdateMentalModelError =
|
||||
UpdateMentalModelErrors[keyof UpdateMentalModelErrors];
|
||||
|
||||
export type UpdateMentalModelResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: MentalModelResponse;
|
||||
};
|
||||
|
||||
export type UpdateMentalModelResponse =
|
||||
UpdateMentalModelResponses[keyof UpdateMentalModelResponses];
|
||||
|
||||
export type RefreshMentalModelsData = {
|
||||
/**
|
||||
* Body
|
||||
@@ -2467,7 +2267,7 @@ export type RefreshMentalModelsResponses = {
|
||||
export type RefreshMentalModelsResponse =
|
||||
RefreshMentalModelsResponses[keyof RefreshMentalModelsResponses];
|
||||
|
||||
export type RefreshMentalModelData = {
|
||||
export type GenerateMentalModelData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
@@ -2486,110 +2286,28 @@ export type RefreshMentalModelData = {
|
||||
model_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/refresh";
|
||||
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/generate";
|
||||
};
|
||||
|
||||
export type RefreshMentalModelErrors = {
|
||||
export type GenerateMentalModelErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type RefreshMentalModelError =
|
||||
RefreshMentalModelErrors[keyof RefreshMentalModelErrors];
|
||||
export type GenerateMentalModelError =
|
||||
GenerateMentalModelErrors[keyof GenerateMentalModelErrors];
|
||||
|
||||
export type RefreshMentalModelResponses = {
|
||||
export type GenerateMentalModelResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: AsyncOperationSubmitResponse;
|
||||
};
|
||||
|
||||
export type RefreshMentalModelResponse =
|
||||
RefreshMentalModelResponses[keyof RefreshMentalModelResponses];
|
||||
|
||||
export type ListMentalModelVersionsData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
/**
|
||||
* Model Id
|
||||
*/
|
||||
model_id: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions";
|
||||
};
|
||||
|
||||
export type ListMentalModelVersionsErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type ListMentalModelVersionsError =
|
||||
ListMentalModelVersionsErrors[keyof ListMentalModelVersionsErrors];
|
||||
|
||||
export type ListMentalModelVersionsResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
|
||||
export type GetMentalModelVersionData = {
|
||||
body?: never;
|
||||
headers?: {
|
||||
/**
|
||||
* Authorization
|
||||
*/
|
||||
authorization?: string | null;
|
||||
};
|
||||
path: {
|
||||
/**
|
||||
* Bank Id
|
||||
*/
|
||||
bank_id: string;
|
||||
/**
|
||||
* Model Id
|
||||
*/
|
||||
model_id: string;
|
||||
/**
|
||||
* Version
|
||||
*/
|
||||
version: number;
|
||||
};
|
||||
query?: never;
|
||||
url: "/v1/default/banks/{bank_id}/mental-models/{model_id}/versions/{version}";
|
||||
};
|
||||
|
||||
export type GetMentalModelVersionErrors = {
|
||||
/**
|
||||
* Validation Error
|
||||
*/
|
||||
422: HttpValidationError;
|
||||
};
|
||||
|
||||
export type GetMentalModelVersionError =
|
||||
GetMentalModelVersionErrors[keyof GetMentalModelVersionErrors];
|
||||
|
||||
export type GetMentalModelVersionResponses = {
|
||||
/**
|
||||
* Successful Response
|
||||
*/
|
||||
200: unknown;
|
||||
};
|
||||
export type GenerateMentalModelResponse =
|
||||
GenerateMentalModelResponses[keyof GenerateMentalModelResponses];
|
||||
|
||||
export type ListDocumentsData = {
|
||||
body?: never;
|
||||
|
||||
@@ -40,10 +40,6 @@ import type {
|
||||
BankProfileResponse,
|
||||
CreateBankRequest,
|
||||
Budget,
|
||||
MentalModelResponse,
|
||||
MentalModelListResponse,
|
||||
AsyncOperationSubmitResponse,
|
||||
ObservationInput,
|
||||
} from '../generated/types.gen';
|
||||
|
||||
export interface HindsightClientOptions {
|
||||
@@ -106,8 +102,6 @@ export class HindsightClient {
|
||||
documentId?: string;
|
||||
async?: boolean;
|
||||
entities?: EntityInput[];
|
||||
/** Optional list of tags for this memory */
|
||||
tags?: string[];
|
||||
}
|
||||
): Promise<RetainResponse> {
|
||||
const item: {
|
||||
@@ -117,7 +111,6 @@ export class HindsightClient {
|
||||
metadata?: Record<string, string>;
|
||||
document_id?: string;
|
||||
entities?: EntityInput[];
|
||||
tags?: string[];
|
||||
} = { content };
|
||||
if (options?.timestamp) {
|
||||
item.timestamp =
|
||||
@@ -137,9 +130,6 @@ export class HindsightClient {
|
||||
if (options?.entities) {
|
||||
item.entities = options.entities;
|
||||
}
|
||||
if (options?.tags) {
|
||||
item.tags = options.tags;
|
||||
}
|
||||
|
||||
const response = await sdk.retainMemories({
|
||||
client: this.client,
|
||||
@@ -202,10 +192,6 @@ export class HindsightClient {
|
||||
maxEntityTokens?: number;
|
||||
includeChunks?: boolean;
|
||||
maxChunkTokens?: number;
|
||||
/** Optional list of tags to filter memories by */
|
||||
tags?: string[];
|
||||
/** How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged). Default: 'any' */
|
||||
tagsMatch?: 'any' | 'all' | 'any_strict' | 'all_strict';
|
||||
}
|
||||
): Promise<RecallResponse> {
|
||||
const response = await sdk.recallMemories({
|
||||
@@ -222,8 +208,6 @@ export class HindsightClient {
|
||||
entities: options?.includeEntities ? { max_tokens: options?.maxEntityTokens ?? 500 } : undefined,
|
||||
chunks: options?.includeChunks ? { max_tokens: options?.maxChunkTokens ?? 8192 } : undefined,
|
||||
},
|
||||
tags: options?.tags,
|
||||
tags_match: options?.tagsMatch,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -236,14 +220,7 @@ export class HindsightClient {
|
||||
async reflect(
|
||||
bankId: string,
|
||||
query: string,
|
||||
options?: {
|
||||
context?: string;
|
||||
budget?: Budget;
|
||||
/** Optional list of tags to filter memories by */
|
||||
tags?: string[];
|
||||
/** How to match tags: 'any' (OR, includes untagged), 'all' (AND, includes untagged), 'any_strict' (OR, excludes untagged), 'all_strict' (AND, excludes untagged). Default: 'any' */
|
||||
tagsMatch?: 'any' | 'all' | 'any_strict' | 'all_strict';
|
||||
}
|
||||
options?: { context?: string; budget?: Budget }
|
||||
): Promise<ReflectResponse> {
|
||||
const response = await sdk.reflect({
|
||||
client: this.client,
|
||||
@@ -252,8 +229,6 @@ export class HindsightClient {
|
||||
query,
|
||||
context: options?.context,
|
||||
budget: options?.budget || 'low',
|
||||
tags: options?.tags,
|
||||
tags_match: options?.tagsMatch,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -312,176 +287,6 @@ export class HindsightClient {
|
||||
|
||||
return this.validateResponse(response, 'getBankProfile');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set or update the mission for a memory bank.
|
||||
*/
|
||||
async setMission(bankId: string, mission: string): Promise<BankProfileResponse> {
|
||||
const response = await sdk.createOrUpdateBank({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId },
|
||||
body: { mission },
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'setMission');
|
||||
}
|
||||
|
||||
/**
|
||||
* List mental models for a bank.
|
||||
*/
|
||||
async listMentalModels(
|
||||
bankId: string,
|
||||
options?: {
|
||||
subtype?: 'structural' | 'emergent' | 'pinned' | 'learned' | 'directive';
|
||||
tags?: string[];
|
||||
tagsMatch?: 'any' | 'all' | 'exact';
|
||||
}
|
||||
): Promise<MentalModelListResponse> {
|
||||
const response = await sdk.listMentalModels({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId },
|
||||
query: {
|
||||
subtype: options?.subtype,
|
||||
tags: options?.tags,
|
||||
tags_match: options?.tagsMatch,
|
||||
},
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'listMentalModels');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific mental model by ID.
|
||||
*/
|
||||
async getMentalModel(bankId: string, modelId: string): Promise<MentalModelResponse> {
|
||||
const response = await sdk.getMentalModel({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId, model_id: modelId },
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'getMentalModel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mental model.
|
||||
*/
|
||||
async createMentalModel(
|
||||
bankId: string,
|
||||
options: {
|
||||
name: string;
|
||||
description: string;
|
||||
subtype?: 'pinned' | 'directive';
|
||||
observations?: Array<{ title: string; content: string }>;
|
||||
tags?: string[];
|
||||
}
|
||||
): Promise<MentalModelResponse> {
|
||||
const response = await sdk.createMentalModel({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId },
|
||||
body: {
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
subtype: options.subtype,
|
||||
observations: options.observations,
|
||||
tags: options.tags,
|
||||
},
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'createMentalModel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a mental model's name and/or description.
|
||||
*/
|
||||
async updateMentalModel(
|
||||
bankId: string,
|
||||
modelId: string,
|
||||
options: {
|
||||
name?: string;
|
||||
description?: string;
|
||||
}
|
||||
): Promise<MentalModelResponse> {
|
||||
const response = await sdk.updateMentalModel({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId, model_id: modelId },
|
||||
body: {
|
||||
name: options.name,
|
||||
description: options.description,
|
||||
},
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'updateMentalModel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a mental model.
|
||||
*/
|
||||
async deleteMentalModel(bankId: string, modelId: string): Promise<void> {
|
||||
const response = await sdk.deleteMentalModel({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId, model_id: modelId },
|
||||
});
|
||||
|
||||
this.validateResponse(response, 'deleteMentalModel');
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a background job to refresh mental models for a bank.
|
||||
*/
|
||||
async refreshMentalModels(
|
||||
bankId: string,
|
||||
options?: {
|
||||
subtype?: 'structural' | 'emergent' | 'pinned' | 'learned';
|
||||
tags?: string[];
|
||||
}
|
||||
): Promise<AsyncOperationSubmitResponse> {
|
||||
const response = await sdk.refreshMentalModels({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId },
|
||||
body: {
|
||||
subtype: options?.subtype,
|
||||
tags: options?.tags,
|
||||
},
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'refreshMentalModels');
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a background job to refresh content for a specific mental model.
|
||||
*/
|
||||
async refreshMentalModel(bankId: string, modelId: string): Promise<AsyncOperationSubmitResponse> {
|
||||
const response = await sdk.refreshMentalModel({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId, model_id: modelId },
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'refreshMentalModel');
|
||||
}
|
||||
|
||||
/**
|
||||
* List all saved versions of a mental model's observations.
|
||||
*/
|
||||
async listMentalModelVersions(bankId: string, modelId: string): Promise<unknown> {
|
||||
const response = await sdk.listMentalModelVersions({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId, model_id: modelId },
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'listMentalModelVersions');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get observations from a specific version of a mental model.
|
||||
*/
|
||||
async getMentalModelVersion(bankId: string, modelId: string, version: number): Promise<unknown> {
|
||||
const response = await sdk.getMentalModelVersion({
|
||||
client: this.client,
|
||||
path: { bank_id: bankId, model_id: modelId, version },
|
||||
});
|
||||
|
||||
return this.validateResponse(response, 'getMentalModelVersion');
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export types for convenience
|
||||
@@ -497,10 +302,6 @@ export type {
|
||||
BankProfileResponse,
|
||||
CreateBankRequest,
|
||||
Budget,
|
||||
MentalModelResponse,
|
||||
MentalModelListResponse,
|
||||
AsyncOperationSubmitResponse,
|
||||
ObservationInput,
|
||||
};
|
||||
|
||||
// Also export low-level SDK functions for advanced usage
|
||||
|
||||
@@ -412,186 +412,3 @@ describe('TestDeleteBank', () => {
|
||||
expect(memories.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TestMentalModels', () => {
|
||||
test('set mission', async () => {
|
||||
const bankId = randomBankId();
|
||||
const response = await client.setMission(
|
||||
bankId,
|
||||
'Be a helpful PM tracking sprint progress and team capacity'
|
||||
);
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.bank_id).toBe(bankId);
|
||||
expect(response.mission).toBe('Be a helpful PM tracking sprint progress and team capacity');
|
||||
});
|
||||
|
||||
test('create pinned mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
const response = await client.createMentalModel(bankId, {
|
||||
name: 'Product Roadmap',
|
||||
description: 'Track product priorities and feature decisions',
|
||||
subtype: 'pinned',
|
||||
tags: ['test'],
|
||||
});
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.name).toBe('Product Roadmap');
|
||||
expect(response.description).toBe('Track product priorities and feature decisions');
|
||||
expect(response.subtype).toBe('pinned');
|
||||
});
|
||||
|
||||
test('create directive mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
const response = await client.createMentalModel(bankId, {
|
||||
name: 'Response Guidelines',
|
||||
description: 'Rules for responding to users',
|
||||
subtype: 'directive',
|
||||
observations: [
|
||||
{ title: 'Always be polite', content: 'All responses must be courteous and professional' },
|
||||
{ title: 'Never share private info', content: 'Do not reveal internal details or user data' },
|
||||
],
|
||||
tags: ['test'],
|
||||
});
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.name).toBe('Response Guidelines');
|
||||
expect(response.subtype).toBe('directive');
|
||||
expect(response.observations).toBeDefined();
|
||||
expect(response.observations!.length).toBe(2);
|
||||
});
|
||||
|
||||
test('list mental models', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
await client.createMentalModel(bankId, {
|
||||
name: 'Test Model',
|
||||
description: 'A test mental model',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
const response = await client.listMentalModels(bankId);
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.items).toBeDefined();
|
||||
expect(response.items!.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('get mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
const created = await client.createMentalModel(bankId, {
|
||||
name: 'Retrieve Test Model',
|
||||
description: 'A model to retrieve',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
const response = await client.getMentalModel(bankId, created.id);
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.id).toBe(created.id);
|
||||
expect(response.name).toBe('Retrieve Test Model');
|
||||
});
|
||||
|
||||
test('update mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
const created = await client.createMentalModel(bankId, {
|
||||
name: 'Update Test Model',
|
||||
description: 'Original description',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
const response = await client.updateMentalModel(bankId, created.id, {
|
||||
name: 'Updated Model Name',
|
||||
description: 'Updated description',
|
||||
});
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.name).toBe('Updated Model Name');
|
||||
expect(response.description).toBe('Updated description');
|
||||
});
|
||||
|
||||
test('delete mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
const created = await client.createMentalModel(bankId, {
|
||||
name: 'Delete Test Model',
|
||||
description: 'A model to delete',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
// Delete should not throw
|
||||
await expect(client.deleteMentalModel(bankId, created.id)).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
test('refresh mental models', async () => {
|
||||
const bankId = randomBankId();
|
||||
|
||||
// Set mission first (required for refresh) - this also creates the bank
|
||||
await client.setMission(bankId, 'Track team progress and decisions');
|
||||
|
||||
const response = await client.refreshMentalModels(bankId, {
|
||||
tags: ['test'],
|
||||
});
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.operation_id).toBeDefined();
|
||||
expect(response.status).toBe('queued');
|
||||
});
|
||||
|
||||
test('refresh mental model', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
const created = await client.createMentalModel(bankId, {
|
||||
name: 'Refresh Single Test',
|
||||
description: 'A model to refresh individually',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
const response = await client.refreshMentalModel(bankId, created.id);
|
||||
|
||||
expect(response).not.toBeNull();
|
||||
expect(response.operation_id).toBeDefined();
|
||||
expect(response.status).toBe('queued');
|
||||
});
|
||||
|
||||
test('list mental model versions', async () => {
|
||||
const bankId = randomBankId();
|
||||
// Create bank first (required for mental models)
|
||||
await client.createBank(bankId, {});
|
||||
|
||||
// Create a model first
|
||||
const created = await client.createMentalModel(bankId, {
|
||||
name: 'Versions Test Model',
|
||||
description: 'A model to test version history',
|
||||
subtype: 'pinned',
|
||||
});
|
||||
|
||||
const response = await client.listMentalModelVersions(bankId, created.id);
|
||||
|
||||
// Newly created model should have version history
|
||||
expect(response).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -38,8 +38,6 @@
|
||||
"@radix-ui/react-slider": "^1.3.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tailwindcss/postcss": "^4.1.17",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@types/cytoscape": "^3.21.9",
|
||||
|
||||
+5
-5
@@ -16,19 +16,19 @@ export async function POST(
|
||||
return NextResponse.json({ error: "model_id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const response = await sdk.refreshMentalModel({
|
||||
const response = await sdk.generateMentalModel({
|
||||
client: lowLevelClient,
|
||||
path: { bank_id: bankId, model_id: modelId },
|
||||
});
|
||||
|
||||
if (response.error) {
|
||||
console.error("API error refreshing mental model:", response.error);
|
||||
return NextResponse.json({ error: "Failed to refresh mental model" }, { status: 500 });
|
||||
console.error("API error generating mental model:", response.error);
|
||||
return NextResponse.json({ error: "Failed to generate mental model" }, { status: 500 });
|
||||
}
|
||||
|
||||
return NextResponse.json(response.data, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error refreshing mental model:", error);
|
||||
return NextResponse.json({ error: "Failed to refresh mental model" }, { status: 500 });
|
||||
console.error("Error generating mental model:", error);
|
||||
return NextResponse.json({ error: "Failed to generate mental model" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { sdk, lowLevelClient } from "@/lib/hindsight-client";
|
||||
|
||||
const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888";
|
||||
|
||||
export async function PATCH(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ bankId: string; modelId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { bankId, modelId } = await params;
|
||||
|
||||
if (!bankId) {
|
||||
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!modelId) {
|
||||
return NextResponse.json({ error: "model_id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
// Call the dataplane API directly since SDK may not have the update method yet
|
||||
const response = await fetch(
|
||||
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("API error updating mental model:", errorText);
|
||||
return NextResponse.json(
|
||||
{ error: errorText || "Failed to update mental model" },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error updating mental model:", error);
|
||||
return NextResponse.json({ error: "Failed to update mental model" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ bankId: string; modelId: string }> }
|
||||
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ bankId: string; modelId: string; version: string }> }
|
||||
) {
|
||||
try {
|
||||
const { bankId, modelId, version } = await params;
|
||||
|
||||
if (!bankId) {
|
||||
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!modelId) {
|
||||
return NextResponse.json({ error: "model_id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!version) {
|
||||
return NextResponse.json({ error: "version is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}/versions/${version}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("API error getting mental model version:", errorText);
|
||||
return NextResponse.json(
|
||||
{ error: errorText || "Failed to get mental model version" },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error getting mental model version:", error);
|
||||
return NextResponse.json({ error: "Failed to get mental model version" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://localhost:8888";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ bankId: string; modelId: string }> }
|
||||
) {
|
||||
try {
|
||||
const { bankId, modelId } = await params;
|
||||
|
||||
if (!bankId) {
|
||||
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!modelId) {
|
||||
return NextResponse.json({ error: "model_id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models/${modelId}/versions`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("API error listing mental model versions:", errorText);
|
||||
return NextResponse.json(
|
||||
{ error: errorText || "Failed to list mental model versions" },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: 200 });
|
||||
} catch (error) {
|
||||
console.error("Error listing mental model versions:", error);
|
||||
return NextResponse.json({ error: "Failed to list mental model versions" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -6,34 +6,11 @@ const DATAPLANE_URL = process.env.HINDSIGHT_CP_DATAPLANE_API_URL || "http://loca
|
||||
export async function GET(request: Request, { params }: { params: Promise<{ bankId: string }> }) {
|
||||
try {
|
||||
const { bankId } = await params;
|
||||
const { searchParams } = new URL(request.url);
|
||||
const subtype = searchParams.get("subtype");
|
||||
|
||||
if (!bankId) {
|
||||
return NextResponse.json({ error: "bank_id is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
// If subtype is specified, call the dataplane API directly with the query param
|
||||
if (subtype) {
|
||||
const response = await fetch(
|
||||
`${DATAPLANE_URL}/v1/default/banks/${bankId}/mental-models?subtype=${subtype}`,
|
||||
{ method: "GET" }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error("API error listing mental models:", errorText);
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to list mental models" },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: 200 });
|
||||
}
|
||||
|
||||
// Default: use SDK which excludes directives
|
||||
const response = await sdk.listMentalModels({
|
||||
client: lowLevelClient,
|
||||
path: { bank_id: bankId },
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
Settings2,
|
||||
Eye,
|
||||
EyeOff,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
@@ -249,9 +248,11 @@ export function DataView({ factType }: DataViewProps) {
|
||||
return (
|
||||
<div>
|
||||
{loading ? (
|
||||
<div className="text-center py-12">
|
||||
<RefreshCw className="w-8 h-8 mx-auto mb-3 text-muted-foreground animate-spin" />
|
||||
<p className="text-muted-foreground">Loading memories...</p>
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-2">⏳</div>
|
||||
<div className="text-sm text-muted-foreground">Loading memories...</div>
|
||||
</div>
|
||||
</div>
|
||||
) : data ? (
|
||||
<>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user