Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5261e5193a | ||
|
|
ce45d301ce | ||
|
|
d49e8201b4 | ||
|
|
c8c7603580 | ||
|
|
787ed60763 | ||
|
|
6b78f7d949 | ||
|
|
54e2df0baf | ||
|
|
967e586e01 | ||
|
|
dfa7cec05b | ||
|
|
36e48a7166 |
@@ -2,11 +2,23 @@
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# LLM Configuration (Required)
|
||||
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio
|
||||
HINDSIGHT_API_LLM_PROVIDER=openai
|
||||
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
|
||||
HINDSIGHT_API_LLM_MODEL=o3-mini
|
||||
HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
|
||||
|
||||
# Example: Anthropic Claude configuration
|
||||
# HINDSIGHT_API_LLM_PROVIDER=anthropic
|
||||
# HINDSIGHT_API_LLM_API_KEY=your-anthropic-api-key
|
||||
# HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-20250514
|
||||
|
||||
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
|
||||
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
|
||||
# HINDSIGHT_API_LLM_API_KEY=lmstudio
|
||||
# HINDSIGHT_API_LLM_BASE_URL=http://localhost:1234/v1
|
||||
# HINDSIGHT_API_LLM_MODEL=qwen2.5-32b-instruct
|
||||
|
||||
# API Configuration (Optional)
|
||||
HINDSIGHT_API_HOST=0.0.0.0
|
||||
HINDSIGHT_API_PORT=8888
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Hindsight is an agent memory system that provides long-term memory for AI agents using biomimetic data structures. It stores memories as World facts, Experiences, Opinions, and Observations across memory banks.
|
||||
|
||||
## Development Commands
|
||||
|
||||
### API Server (Python/FastAPI)
|
||||
```bash
|
||||
# Start API server (loads .env automatically)
|
||||
./scripts/dev/start-api.sh
|
||||
|
||||
# Run tests
|
||||
cd hindsight-api && uv run pytest tests/
|
||||
|
||||
# Run specific test file
|
||||
cd hindsight-api && uv run pytest tests/test_http_api_integration.py -v
|
||||
|
||||
# Lint
|
||||
cd hindsight-api && uv run ruff check .
|
||||
```
|
||||
|
||||
### Control Plane (Next.js)
|
||||
```bash
|
||||
./scripts/dev/start-control-plane.sh
|
||||
# Or manually:
|
||||
cd hindsight-control-plane && npm run dev
|
||||
```
|
||||
|
||||
### Documentation Site (Docusaurus)
|
||||
```bash
|
||||
./scripts/dev/start-docs.sh
|
||||
```
|
||||
|
||||
### Generating Clients/OpenAPI
|
||||
```bash
|
||||
# Regenerate OpenAPI spec after API changes
|
||||
./scripts/generate-openapi.sh
|
||||
|
||||
# Regenerate all client SDKs (Python, TypeScript, Rust)
|
||||
./scripts/generate-clients.sh
|
||||
```
|
||||
|
||||
### Benchmarks
|
||||
```bash
|
||||
./scripts/benchmarks/run-longmemeval.sh
|
||||
./scripts/benchmarks/run-locomo.sh
|
||||
./scripts/benchmarks/start-visualizer.sh # View results at localhost:8001
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Monorepo Structure
|
||||
- **hindsight-api/**: Core FastAPI server with memory engine (Python, uv)
|
||||
- **hindsight/**: Embedded Python bundle (hindsight-all package)
|
||||
- **hindsight-control-plane/**: Admin UI (Next.js, npm)
|
||||
- **hindsight-cli/**: CLI tool (Rust, cargo)
|
||||
- **hindsight-clients/**: Generated SDK clients (Python, TypeScript, Rust)
|
||||
- **hindsight-docs/**: Docusaurus documentation site
|
||||
- **hindsight-integrations/**: Framework integrations (LiteLLM, OpenAI)
|
||||
- **hindsight-dev/**: Development tools and benchmarks
|
||||
|
||||
### Core Engine (hindsight-api/hindsight_api/engine/)
|
||||
- `memory_engine.py`: Main orchestrator for retain/recall/reflect operations
|
||||
- `llm_wrapper.py`: LLM abstraction supporting OpenAI, Anthropic, Gemini, Groq, Ollama, LM Studio
|
||||
- `embeddings.py`: Embedding generation (local or TEI)
|
||||
- `cross_encoder.py`: Reranking (local or TEI)
|
||||
- `entity_resolver.py`: Entity extraction and normalization
|
||||
- `query_analyzer.py`: Query intent analysis
|
||||
- `retain/`: Memory ingestion pipeline
|
||||
- `search/`: Multi-strategy retrieval (semantic, BM25, graph, temporal)
|
||||
|
||||
### API Layer (hindsight-api/hindsight_api/api/)
|
||||
FastAPI routers for all endpoints. Main operations:
|
||||
- **Retain**: Store memories, extracts facts/entities/relationships
|
||||
- **Recall**: Retrieve memories via parallel search strategies + reranking
|
||||
- **Reflect**: Deep analysis forming new opinions/observations
|
||||
|
||||
### Database
|
||||
PostgreSQL with pgvector. Schema managed via Alembic migrations in `hindsight-api/hindsight_api/alembic/`. Migrations run automatically on API startup.
|
||||
|
||||
Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links`
|
||||
|
||||
## Key Conventions
|
||||
|
||||
### Memory Banks
|
||||
- Each bank is isolated (no cross-bank data access)
|
||||
- Banks have dispositions (skepticism, literalism, empathy traits 1-5) affecting reflect
|
||||
- Banks can have background context
|
||||
|
||||
### API Design
|
||||
- All endpoints operate on a single bank per request
|
||||
- Multi-bank queries are client responsibility
|
||||
- Disposition traits only affect reflect, not recall
|
||||
|
||||
### Python Style
|
||||
- Python 3.11+, type hints required
|
||||
- Async throughout (asyncpg, async FastAPI)
|
||||
- Pydantic models for request/response
|
||||
- Ruff for linting (line-length 120)
|
||||
|
||||
### TypeScript Style
|
||||
- Next.js App Router for control plane
|
||||
- Tailwind CSS with shadcn/ui components
|
||||
|
||||
## Environment Setup
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with LLM API key
|
||||
|
||||
# Python deps
|
||||
uv sync --directory hindsight-api/
|
||||
|
||||
# Node deps (workspace)
|
||||
npm install
|
||||
```
|
||||
|
||||
Required env vars:
|
||||
- `HINDSIGHT_API_LLM_PROVIDER`: openai, anthropic, gemini, groq, ollama, lmstudio
|
||||
- `HINDSIGHT_API_LLM_API_KEY`: Your API key
|
||||
- `HINDSIGHT_API_LLM_MODEL`: Model name (e.g., o3-mini, claude-sonnet-4-20250514)
|
||||
@@ -81,6 +81,8 @@ docker run --rm -it --pull always -p 8888:8888 -p 9999:9999 \
|
||||
ghcr.io/vectorize-io/hindsight:latest
|
||||
```
|
||||
|
||||
You can modify the LLM provider by setting `HINDSIGHT_API_LLM_PROVIDER`. Valid options are `openai`, `anthropic`, `gemini`, `groq`, `ollama`, and `lmstudio`. The documentation provides more details on [supported models](https://hindsight.vectorize.io/developer/models).
|
||||
|
||||
API: http://localhost:8888
|
||||
UI: http://localhost:9999
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ Configure via environment variables:
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_DATABASE_URL` | PostgreSQL connection string | `pg0` (embedded) |
|
||||
| `HINDSIGHT_API_LLM_PROVIDER` | `openai`, `groq`, `gemini`, `ollama` | `openai` |
|
||||
| `HINDSIGHT_API_LLM_PROVIDER` | `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio` | `openai` |
|
||||
| `HINDSIGHT_API_LLM_API_KEY` | API key for LLM provider | - |
|
||||
| `HINDSIGHT_API_LLM_MODEL` | Model name | `gpt-4o-mini` |
|
||||
| `HINDSIGHT_API_HOST` | Server bind address | `0.0.0.0` |
|
||||
|
||||
@@ -5,6 +5,7 @@ Provides both HTTP REST API and MCP (Model Context Protocol) server.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
@@ -45,6 +46,18 @@ def create_app(
|
||||
# Both HTTP and MCP
|
||||
app = create_app(memory, mcp_api_enabled=True)
|
||||
"""
|
||||
mcp_app = None
|
||||
|
||||
# Create MCP app first if enabled (we need its lifespan for chaining)
|
||||
if mcp_api_enabled:
|
||||
try:
|
||||
from .mcp import create_mcp_app
|
||||
|
||||
mcp_app = create_mcp_app(memory=memory)
|
||||
except ImportError as e:
|
||||
logger.error(f"MCP server requested but dependencies not available: {e}")
|
||||
logger.error("Install with: pip install hindsight-api[mcp]")
|
||||
raise
|
||||
|
||||
# Import and create HTTP API if enabled
|
||||
if http_api_enabled:
|
||||
@@ -57,20 +70,31 @@ def create_app(
|
||||
app = FastAPI(title="Hindsight API", version="0.0.7")
|
||||
logger.info("HTTP REST API disabled")
|
||||
|
||||
# Mount MCP server if enabled
|
||||
if mcp_api_enabled:
|
||||
try:
|
||||
from .mcp import create_mcp_app
|
||||
# Mount MCP server and chain its lifespan if enabled
|
||||
if mcp_app is not None:
|
||||
# Get the MCP app's underlying Starlette app for lifespan access
|
||||
mcp_starlette_app = mcp_app.mcp_app
|
||||
|
||||
# Create MCP app with dynamic bank_id support
|
||||
# Supports: /mcp/{bank_id}/sse (bank-specific SSE endpoint)
|
||||
mcp_app = create_mcp_app(memory=memory)
|
||||
app.mount(mcp_mount_path, mcp_app)
|
||||
logger.info(f"MCP server enabled at {mcp_mount_path}/{{bank_id}}/sse")
|
||||
except ImportError as e:
|
||||
logger.error(f"MCP server requested but dependencies not available: {e}")
|
||||
logger.error("Install with: pip install hindsight-api[mcp]")
|
||||
raise
|
||||
# Store the original lifespan
|
||||
original_lifespan = app.router.lifespan_context
|
||||
|
||||
@asynccontextmanager
|
||||
async def chained_lifespan(app_instance: FastAPI):
|
||||
"""Chain the MCP lifespan with the main app lifespan."""
|
||||
# Start MCP lifespan first
|
||||
async with mcp_starlette_app.router.lifespan_context(mcp_starlette_app):
|
||||
logger.info("MCP lifespan started")
|
||||
# Then start the original app lifespan
|
||||
async with original_lifespan(app_instance):
|
||||
yield
|
||||
logger.info("MCP lifespan stopped")
|
||||
|
||||
# Replace the app's lifespan with the chained version
|
||||
app.router.lifespan_context = chained_lifespan
|
||||
|
||||
# Mount the MCP middleware
|
||||
app.mount(mcp_mount_path, mcp_app)
|
||||
logger.info(f"MCP server enabled at {mcp_mount_path}/{{bank_id}}/mcp")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ from typing import Any
|
||||
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query
|
||||
|
||||
from hindsight_api.extensions import AuthenticationError
|
||||
|
||||
|
||||
def _parse_metadata(metadata: Any) -> dict[str, Any]:
|
||||
"""Parse metadata that may be a dict, JSON string, or None."""
|
||||
@@ -35,7 +37,7 @@ from hindsight_api import MemoryEngine
|
||||
from hindsight_api.engine.db_utils import acquire_with_retry
|
||||
from hindsight_api.engine.memory_engine import Budget, fq_table
|
||||
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
|
||||
from hindsight_api.extensions import HttpExtension, load_extension
|
||||
from hindsight_api.extensions import HttpExtension, OperationValidationError, load_extension
|
||||
from hindsight_api.metrics import create_metrics_collector, get_metrics_collector, initialize_metrics
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
@@ -385,7 +387,16 @@ class ReflectRequest(BaseModel):
|
||||
"query": "What do you think about artificial intelligence?",
|
||||
"budget": "low",
|
||||
"context": "This is for a research paper on AI ethics",
|
||||
"max_tokens": 4096,
|
||||
"include": {"facts": {}},
|
||||
"response_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {"type": "string"},
|
||||
"key_points": {"type": "array", "items": {"type": "string"}},
|
||||
},
|
||||
"required": ["summary", "key_points"],
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -393,9 +404,14 @@ class ReflectRequest(BaseModel):
|
||||
query: str
|
||||
budget: Budget = Budget.LOW
|
||||
context: str | None = None
|
||||
max_tokens: int = Field(default=4096, description="Maximum tokens for the response")
|
||||
include: ReflectIncludeOptions = Field(
|
||||
default_factory=ReflectIncludeOptions, description="Options for including additional data (disabled by default)"
|
||||
)
|
||||
response_schema: dict | None = Field(
|
||||
default=None,
|
||||
description="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.",
|
||||
)
|
||||
|
||||
|
||||
class OpinionItem(BaseModel):
|
||||
@@ -440,12 +456,20 @@ class ReflectResponse(BaseModel):
|
||||
{"id": "123", "text": "AI is used in healthcare", "type": "world"},
|
||||
{"id": "456", "text": "I discussed AI applications last week", "type": "experience"},
|
||||
],
|
||||
"structured_output": {
|
||||
"summary": "AI is transformative",
|
||||
"key_points": ["Used in healthcare", "Discussed recently"],
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
text: str
|
||||
based_on: list[ReflectFact] = [] # Facts used to generate the response
|
||||
structured_output: dict | None = Field(
|
||||
default=None,
|
||||
description="Structured output parsed according to the request's response_schema. Only present when response_schema was provided in the request.",
|
||||
)
|
||||
|
||||
|
||||
class BanksResponse(BaseModel):
|
||||
@@ -967,6 +991,16 @@ def _register_routes(app: FastAPI):
|
||||
api_key = authorization.strip()
|
||||
return RequestContext(api_key=api_key)
|
||||
|
||||
# Global exception handler for authentication errors
|
||||
@app.exception_handler(AuthenticationError)
|
||||
async def authentication_error_handler(request, exc: AuthenticationError):
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
return JSONResponse(
|
||||
status_code=401,
|
||||
content={"detail": str(exc)},
|
||||
)
|
||||
|
||||
@app.get(
|
||||
"/health",
|
||||
summary="Health check endpoint",
|
||||
@@ -1014,6 +1048,8 @@ def _register_routes(app: FastAPI):
|
||||
try:
|
||||
data = await app.state.memory.get_graph_data(bank_id, type, request_context=request_context)
|
||||
return data
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1060,6 +1096,8 @@ def _register_routes(app: FastAPI):
|
||||
request_context=request_context,
|
||||
)
|
||||
return data
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1176,6 +1214,10 @@ def _register_routes(app: FastAPI):
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1211,6 +1253,8 @@ def _register_routes(app: FastAPI):
|
||||
query=request.query,
|
||||
budget=request.budget,
|
||||
context=request.context,
|
||||
max_tokens=request.max_tokens,
|
||||
response_schema=request.response_schema,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
@@ -1233,8 +1277,13 @@ def _register_routes(app: FastAPI):
|
||||
return ReflectResponse(
|
||||
text=core_result.text,
|
||||
based_on=based_on_facts,
|
||||
structured_output=core_result.structured_output,
|
||||
)
|
||||
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1255,6 +1304,8 @@ def _register_routes(app: FastAPI):
|
||||
try:
|
||||
banks = await app.state.memory.list_banks(request_context=request_context)
|
||||
return BankListResponse(banks=banks)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1378,6 +1429,8 @@ def _register_routes(app: FastAPI):
|
||||
failed_operations=failed_operations,
|
||||
)
|
||||
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1402,6 +1455,8 @@ def _register_routes(app: FastAPI):
|
||||
try:
|
||||
entities = await app.state.memory.list_entities(bank_id, limit=limit, request_context=request_context)
|
||||
return EntityListResponse(items=[EntityListItem(**e) for e in entities])
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1439,7 +1494,7 @@ def _register_routes(app: FastAPI):
|
||||
for obs in entity["observations"]
|
||||
],
|
||||
)
|
||||
except HTTPException:
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
@@ -1492,7 +1547,7 @@ def _register_routes(app: FastAPI):
|
||||
for obs in entity["observations"]
|
||||
],
|
||||
)
|
||||
except HTTPException:
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
@@ -1530,6 +1585,8 @@ def _register_routes(app: FastAPI):
|
||||
bank_id=bank_id, search_query=q, limit=limit, offset=offset, request_context=request_context
|
||||
)
|
||||
return data
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1560,7 +1617,7 @@ def _register_routes(app: FastAPI):
|
||||
if not document:
|
||||
raise HTTPException(status_code=404, detail="Document not found")
|
||||
return document
|
||||
except HTTPException:
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
@@ -1589,7 +1646,7 @@ def _register_routes(app: FastAPI):
|
||||
if not chunk:
|
||||
raise HTTPException(status_code=404, detail="Chunk not found")
|
||||
return chunk
|
||||
except HTTPException:
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
@@ -1633,7 +1690,7 @@ def _register_routes(app: FastAPI):
|
||||
document_id=document_id,
|
||||
memory_units_deleted=result["memory_units_deleted"],
|
||||
)
|
||||
except HTTPException:
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
@@ -1658,6 +1715,8 @@ def _register_routes(app: FastAPI):
|
||||
bank_id=bank_id,
|
||||
operations=[OperationResponse(**op) for op in operations],
|
||||
)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1688,6 +1747,8 @@ def _register_routes(app: FastAPI):
|
||||
return CancelOperationResponse(**result)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1719,6 +1780,8 @@ def _register_routes(app: FastAPI):
|
||||
disposition=DispositionTraits(**disposition_dict),
|
||||
background=profile["background"],
|
||||
)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1757,6 +1820,8 @@ def _register_routes(app: FastAPI):
|
||||
disposition=DispositionTraits(**disposition_dict),
|
||||
background=profile["background"],
|
||||
)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1786,6 +1851,8 @@ def _register_routes(app: FastAPI):
|
||||
response.disposition = DispositionTraits(**result["disposition"])
|
||||
|
||||
return response
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1837,6 +1904,8 @@ def _register_routes(app: FastAPI):
|
||||
disposition=DispositionTraits(**disposition_dict),
|
||||
background=final_profile["background"],
|
||||
)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1864,6 +1933,8 @@ def _register_routes(app: FastAPI):
|
||||
+ result.get("entities_deleted", 0)
|
||||
+ result.get("documents_deleted", 0),
|
||||
)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1938,6 +2009,10 @@ def _register_routes(app: FastAPI):
|
||||
return RetainResponse.model_validate(
|
||||
{"success": True, "bank_id": bank_id, "items_count": len(contents), "async": False}
|
||||
)
|
||||
except OperationValidationError as e:
|
||||
raise HTTPException(status_code=e.status_code, detail=e.reason)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
@@ -1976,6 +2051,8 @@ def _register_routes(app: FastAPI):
|
||||
await app.state.memory.delete_bank(bank_id, fact_type=type, request_context=request_context)
|
||||
|
||||
return DeleteResponse(success=True)
|
||||
except (AuthenticationError, HTTPException):
|
||||
raise
|
||||
except Exception as e:
|
||||
import traceback
|
||||
|
||||
|
||||
@@ -8,6 +8,11 @@ import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from dotenv import find_dotenv, load_dotenv
|
||||
|
||||
# Load .env file, searching current and parent directories (overrides existing env vars)
|
||||
load_dotenv(find_dotenv(usecwd=True), override=True)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Environment variable names
|
||||
@@ -16,6 +21,8 @@ ENV_LLM_PROVIDER = "HINDSIGHT_API_LLM_PROVIDER"
|
||||
ENV_LLM_API_KEY = "HINDSIGHT_API_LLM_API_KEY"
|
||||
ENV_LLM_MODEL = "HINDSIGHT_API_LLM_MODEL"
|
||||
ENV_LLM_BASE_URL = "HINDSIGHT_API_LLM_BASE_URL"
|
||||
ENV_LLM_MAX_CONCURRENT = "HINDSIGHT_API_LLM_MAX_CONCURRENT"
|
||||
ENV_LLM_TIMEOUT = "HINDSIGHT_API_LLM_TIMEOUT"
|
||||
|
||||
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
|
||||
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
|
||||
@@ -33,6 +40,10 @@ ENV_GRAPH_RETRIEVER = "HINDSIGHT_API_GRAPH_RETRIEVER"
|
||||
ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
|
||||
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
|
||||
|
||||
# Observation thresholds
|
||||
ENV_OBSERVATION_MIN_FACTS = "HINDSIGHT_API_OBSERVATION_MIN_FACTS"
|
||||
ENV_OBSERVATION_TOP_ENTITIES = "HINDSIGHT_API_OBSERVATION_TOP_ENTITIES"
|
||||
|
||||
# Optimization flags
|
||||
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
|
||||
ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
|
||||
@@ -41,6 +52,8 @@ ENV_LAZY_RERANKER = "HINDSIGHT_API_LAZY_RERANKER"
|
||||
DEFAULT_DATABASE_URL = "pg0"
|
||||
DEFAULT_LLM_PROVIDER = "openai"
|
||||
DEFAULT_LLM_MODEL = "gpt-5-mini"
|
||||
DEFAULT_LLM_MAX_CONCURRENT = 32
|
||||
DEFAULT_LLM_TIMEOUT = 120.0 # seconds
|
||||
|
||||
DEFAULT_EMBEDDINGS_PROVIDER = "local"
|
||||
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
|
||||
@@ -55,6 +68,10 @@ DEFAULT_MCP_ENABLED = True
|
||||
DEFAULT_GRAPH_RETRIEVER = "bfs" # Options: "bfs", "mpfp"
|
||||
DEFAULT_MCP_LOCAL_BANK_ID = "mcp"
|
||||
|
||||
# Observation thresholds
|
||||
DEFAULT_OBSERVATION_MIN_FACTS = 5 # Min facts required to generate entity observations
|
||||
DEFAULT_OBSERVATION_TOP_ENTITIES = 5 # Max entities to process per retain batch
|
||||
|
||||
# Default MCP tool descriptions (can be customized via env vars)
|
||||
DEFAULT_MCP_RETAIN_DESCRIPTION = """Store important information to long-term memory.
|
||||
|
||||
@@ -91,6 +108,8 @@ class HindsightConfig:
|
||||
llm_api_key: str | None
|
||||
llm_model: str
|
||||
llm_base_url: str | None
|
||||
llm_max_concurrent: int
|
||||
llm_timeout: float
|
||||
|
||||
# Embeddings
|
||||
embeddings_provider: str
|
||||
@@ -111,6 +130,10 @@ class HindsightConfig:
|
||||
# Recall
|
||||
graph_retriever: str
|
||||
|
||||
# Observation thresholds
|
||||
observation_min_facts: int
|
||||
observation_top_entities: int
|
||||
|
||||
# Optimization flags
|
||||
skip_llm_verification: bool
|
||||
lazy_reranker: bool
|
||||
@@ -126,6 +149,8 @@ class HindsightConfig:
|
||||
llm_api_key=os.getenv(ENV_LLM_API_KEY),
|
||||
llm_model=os.getenv(ENV_LLM_MODEL, DEFAULT_LLM_MODEL),
|
||||
llm_base_url=os.getenv(ENV_LLM_BASE_URL) or None,
|
||||
llm_max_concurrent=int(os.getenv(ENV_LLM_MAX_CONCURRENT, str(DEFAULT_LLM_MAX_CONCURRENT))),
|
||||
llm_timeout=float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))),
|
||||
# Embeddings
|
||||
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
|
||||
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
|
||||
@@ -144,6 +169,11 @@ class HindsightConfig:
|
||||
# Optimization flags
|
||||
skip_llm_verification=os.getenv(ENV_SKIP_LLM_VERIFICATION, "false").lower() == "true",
|
||||
lazy_reranker=os.getenv(ENV_LAZY_RERANKER, "false").lower() == "true",
|
||||
# Observation thresholds
|
||||
observation_min_facts=int(os.getenv(ENV_OBSERVATION_MIN_FACTS, str(DEFAULT_OBSERVATION_MIN_FACTS))),
|
||||
observation_top_entities=int(
|
||||
os.getenv(ENV_OBSERVATION_TOP_ENTITIES, str(DEFAULT_OBSERVATION_TOP_ENTITIES))
|
||||
),
|
||||
)
|
||||
|
||||
def get_llm_base_url(self) -> str:
|
||||
@@ -156,6 +186,8 @@ class HindsightConfig:
|
||||
return "https://api.groq.com/openai/v1"
|
||||
elif provider == "ollama":
|
||||
return "http://localhost:11434/v1"
|
||||
elif provider == "lmstudio":
|
||||
return "http://localhost:1234/v1"
|
||||
else:
|
||||
return ""
|
||||
|
||||
|
||||
@@ -110,6 +110,8 @@ class MemoryEngineInterface(ABC):
|
||||
*,
|
||||
budget: "Budget | None" = None,
|
||||
context: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
response_schema: dict | None = None,
|
||||
request_context: "RequestContext",
|
||||
) -> "ReflectResult":
|
||||
"""
|
||||
@@ -120,6 +122,8 @@ class MemoryEngineInterface(ABC):
|
||||
query: The question to reflect on.
|
||||
budget: Search budget for retrieving context.
|
||||
context: Additional context for the reflection.
|
||||
max_tokens: Maximum tokens for the response.
|
||||
response_schema: Optional JSON Schema for structured output.
|
||||
request_context: Request context for authentication.
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -15,6 +15,13 @@ from google.genai import errors as genai_errors
|
||||
from google.genai import types as genai_types
|
||||
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
|
||||
|
||||
from ..config import (
|
||||
DEFAULT_LLM_MAX_CONCURRENT,
|
||||
DEFAULT_LLM_TIMEOUT,
|
||||
ENV_LLM_MAX_CONCURRENT,
|
||||
ENV_LLM_TIMEOUT,
|
||||
)
|
||||
|
||||
# Seed applied to every Groq request for deterministic behavior.
|
||||
DEFAULT_LLM_SEED = 4242
|
||||
|
||||
@@ -24,7 +31,9 @@ logger = logging.getLogger(__name__)
|
||||
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||
|
||||
# Global semaphore to limit concurrent LLM requests across all instances
|
||||
_global_llm_semaphore = asyncio.Semaphore(32)
|
||||
# Set HINDSIGHT_API_LLM_MAX_CONCURRENT=1 for local LLMs (LM Studio, Ollama)
|
||||
_llm_max_concurrent = int(os.getenv(ENV_LLM_MAX_CONCURRENT, str(DEFAULT_LLM_MAX_CONCURRENT)))
|
||||
_global_llm_semaphore = asyncio.Semaphore(_llm_max_concurrent)
|
||||
|
||||
|
||||
class OutputTooLongError(Exception):
|
||||
@@ -58,7 +67,7 @@ class LLMProvider:
|
||||
Initialize LLM provider.
|
||||
|
||||
Args:
|
||||
provider: Provider name ("openai", "groq", "ollama", "gemini").
|
||||
provider: Provider name ("openai", "groq", "ollama", "gemini", "anthropic", "lmstudio").
|
||||
api_key: API key.
|
||||
base_url: Base URL for the API.
|
||||
model: Model name.
|
||||
@@ -71,7 +80,7 @@ class LLMProvider:
|
||||
self.reasoning_effort = reasoning_effort
|
||||
|
||||
# Validate provider
|
||||
valid_providers = ["openai", "groq", "ollama", "gemini"]
|
||||
valid_providers = ["openai", "groq", "ollama", "gemini", "anthropic", "lmstudio"]
|
||||
if self.provider not in valid_providers:
|
||||
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
|
||||
|
||||
@@ -81,25 +90,48 @@ class LLMProvider:
|
||||
self.base_url = "https://api.groq.com/openai/v1"
|
||||
elif self.provider == "ollama":
|
||||
self.base_url = "http://localhost:11434/v1"
|
||||
elif self.provider == "lmstudio":
|
||||
self.base_url = "http://localhost:1234/v1"
|
||||
|
||||
# Validate API key (not needed for ollama)
|
||||
if self.provider != "ollama" and not self.api_key:
|
||||
# Validate API key (not needed for ollama or lmstudio)
|
||||
if self.provider not in ("ollama", "lmstudio") and not self.api_key:
|
||||
raise ValueError(f"API key not found for {self.provider}")
|
||||
|
||||
# Get timeout config (set HINDSIGHT_API_LLM_TIMEOUT for local LLMs that need longer timeouts)
|
||||
self.timeout = float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT)))
|
||||
|
||||
# Create client based on provider
|
||||
self._client = None
|
||||
self._gemini_client = None
|
||||
self._anthropic_client = None
|
||||
|
||||
if self.provider == "gemini":
|
||||
self._gemini_client = genai.Client(api_key=self.api_key)
|
||||
self._client = None
|
||||
elif self.provider == "ollama":
|
||||
self._client = AsyncOpenAI(api_key="ollama", base_url=self.base_url, max_retries=0)
|
||||
self._gemini_client = None
|
||||
elif self.provider == "anthropic":
|
||||
from anthropic import AsyncAnthropic
|
||||
|
||||
# Only pass base_url if it's set (Anthropic uses default URL otherwise)
|
||||
anthropic_kwargs = {"api_key": self.api_key}
|
||||
if self.base_url:
|
||||
anthropic_kwargs["base_url"] = self.base_url
|
||||
if self.timeout:
|
||||
anthropic_kwargs["timeout"] = self.timeout
|
||||
self._anthropic_client = AsyncAnthropic(**anthropic_kwargs)
|
||||
elif self.provider in ("ollama", "lmstudio"):
|
||||
# Use dummy key if not provided for local
|
||||
api_key = self.api_key or "local"
|
||||
client_kwargs = {"api_key": api_key, "base_url": self.base_url, "max_retries": 0}
|
||||
if self.timeout:
|
||||
client_kwargs["timeout"] = self.timeout
|
||||
self._client = AsyncOpenAI(**client_kwargs)
|
||||
else:
|
||||
# Only pass base_url if it's set (OpenAI uses default URL otherwise)
|
||||
client_kwargs = {"api_key": self.api_key, "max_retries": 0}
|
||||
if self.base_url:
|
||||
client_kwargs["base_url"] = self.base_url
|
||||
self._client = AsyncOpenAI(**client_kwargs) # type: ignore[invalid-argument-type] - dict kwargs
|
||||
self._gemini_client = None
|
||||
if self.timeout:
|
||||
client_kwargs["timeout"] = self.timeout
|
||||
self._client = AsyncOpenAI(**client_kwargs)
|
||||
|
||||
async def verify_connection(self) -> None:
|
||||
"""
|
||||
@@ -135,6 +167,7 @@ class LLMProvider:
|
||||
initial_backoff: float = 1.0,
|
||||
max_backoff: float = 60.0,
|
||||
skip_validation: bool = False,
|
||||
strict_schema: bool = False,
|
||||
) -> Any:
|
||||
"""
|
||||
Make an LLM API call with retry logic.
|
||||
@@ -149,6 +182,7 @@ class LLMProvider:
|
||||
initial_backoff: Initial backoff time in seconds.
|
||||
max_backoff: Maximum backoff time in seconds.
|
||||
skip_validation: Return raw JSON without Pydantic validation.
|
||||
strict_schema: Use strict JSON schema enforcement (OpenAI only). Guarantees all required fields.
|
||||
|
||||
Returns:
|
||||
Parsed response if response_format is provided, otherwise text content.
|
||||
@@ -166,6 +200,19 @@ class LLMProvider:
|
||||
messages, response_format, max_retries, initial_backoff, max_backoff, skip_validation, start_time
|
||||
)
|
||||
|
||||
# Handle Anthropic provider separately
|
||||
if self.provider == "anthropic":
|
||||
return await self._call_anthropic(
|
||||
messages,
|
||||
response_format,
|
||||
max_completion_tokens,
|
||||
max_retries,
|
||||
initial_backoff,
|
||||
max_backoff,
|
||||
skip_validation,
|
||||
start_time,
|
||||
)
|
||||
|
||||
# Handle Ollama with native API for structured output (better schema enforcement)
|
||||
if self.provider == "ollama" and response_format is not None:
|
||||
return await self._call_ollama_native(
|
||||
@@ -226,47 +273,78 @@ class LLMProvider:
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
if response_format is not None:
|
||||
# Add schema to system message for JSON mode
|
||||
schema = None
|
||||
if hasattr(response_format, "model_json_schema"):
|
||||
schema = response_format.model_json_schema()
|
||||
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
|
||||
|
||||
if call_params["messages"] and call_params["messages"][0].get("role") == "system":
|
||||
call_params["messages"][0]["content"] += schema_msg
|
||||
elif call_params["messages"]:
|
||||
call_params["messages"][0]["content"] = (
|
||||
schema_msg + "\n\n" + call_params["messages"][0]["content"]
|
||||
)
|
||||
if strict_schema and schema is not None:
|
||||
# Use OpenAI's strict JSON schema enforcement
|
||||
# This guarantees all required fields are returned
|
||||
call_params["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "response",
|
||||
"strict": True,
|
||||
"schema": schema,
|
||||
},
|
||||
}
|
||||
else:
|
||||
# Soft enforcement: add schema to prompt and use json_object mode
|
||||
if schema is not None:
|
||||
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
|
||||
|
||||
call_params["response_format"] = {"type": "json_object"}
|
||||
if call_params["messages"] and call_params["messages"][0].get("role") == "system":
|
||||
call_params["messages"][0]["content"] += schema_msg
|
||||
elif call_params["messages"]:
|
||||
call_params["messages"][0]["content"] = (
|
||||
schema_msg + "\n\n" + call_params["messages"][0]["content"]
|
||||
)
|
||||
if self.provider not in ("lmstudio", "ollama"):
|
||||
call_params["response_format"] = {"type": "json_object"}
|
||||
|
||||
logger.debug(f"Sending request to {self.provider}/{self.model} (timeout={self.timeout})")
|
||||
response = await self._client.chat.completions.create(**call_params)
|
||||
logger.debug(f"Received response from {self.provider}/{self.model}")
|
||||
|
||||
content = response.choices[0].message.content
|
||||
|
||||
# Log raw LLM response for debugging JSON parse issues
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
except json.JSONDecodeError as json_err:
|
||||
# Truncate content for logging (first 500 and last 200 chars)
|
||||
content_preview = content[:500] if content else "<empty>"
|
||||
if content and len(content) > 700:
|
||||
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
|
||||
logger.warning(
|
||||
f"JSON parse error from LLM response (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
|
||||
f" Model: {self.provider}/{self.model}\n"
|
||||
f" Content length: {len(content) if content else 0} chars\n"
|
||||
f" Content preview: {content_preview!r}\n"
|
||||
f" Finish reason: {response.choices[0].finish_reason if response.choices else 'unknown'}"
|
||||
)
|
||||
# Retry on JSON parse errors - LLM may return valid JSON on next attempt
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
last_exception = json_err
|
||||
continue
|
||||
else:
|
||||
logger.error(f"JSON parse error after {max_retries + 1} attempts, giving up")
|
||||
raise
|
||||
# For local models, they may wrap JSON in markdown code blocks
|
||||
if self.provider in ("lmstudio", "ollama"):
|
||||
clean_content = content
|
||||
if "```json" in content:
|
||||
clean_content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
clean_content = content.split("```")[1].split("```")[0].strip()
|
||||
try:
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to parsing raw content
|
||||
json_data = json.loads(content)
|
||||
else:
|
||||
# Log raw LLM response for debugging JSON parse issues
|
||||
try:
|
||||
json_data = json.loads(content)
|
||||
except json.JSONDecodeError as json_err:
|
||||
# Truncate content for logging (first 500 and last 200 chars)
|
||||
content_preview = content[:500] if content else "<empty>"
|
||||
if content and len(content) > 700:
|
||||
content_preview = f"{content[:500]}...TRUNCATED...{content[-200:]}"
|
||||
logger.warning(
|
||||
f"JSON parse error from LLM response (attempt {attempt + 1}/{max_retries + 1}): {json_err}\n"
|
||||
f" Model: {self.provider}/{self.model}\n"
|
||||
f" Content length: {len(content) if content else 0} chars\n"
|
||||
f" Content preview: {content_preview!r}\n"
|
||||
f" Finish reason: {response.choices[0].finish_reason if response.choices else 'unknown'}"
|
||||
)
|
||||
# Retry on JSON parse errors - LLM may return valid JSON on next attempt
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
last_exception = json_err
|
||||
continue
|
||||
else:
|
||||
logger.error(f"JSON parse error after {max_retries + 1} attempts, giving up")
|
||||
raise
|
||||
|
||||
if skip_validation:
|
||||
result = json_data
|
||||
@@ -339,6 +417,142 @@ class LLMProvider:
|
||||
raise last_exception
|
||||
raise RuntimeError("LLM call failed after all retries with no exception captured")
|
||||
|
||||
async def _call_anthropic(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
response_format: Any | None,
|
||||
max_completion_tokens: int | None,
|
||||
max_retries: int,
|
||||
initial_backoff: float,
|
||||
max_backoff: float,
|
||||
skip_validation: bool,
|
||||
start_time: float,
|
||||
) -> Any:
|
||||
"""Handle Anthropic-specific API calls."""
|
||||
from anthropic import APIConnectionError, APIStatusError, RateLimitError
|
||||
|
||||
# Convert OpenAI-style messages to Anthropic format
|
||||
system_prompt = None
|
||||
anthropic_messages = []
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get("role", "user")
|
||||
content = msg.get("content", "")
|
||||
|
||||
if role == "system":
|
||||
if system_prompt:
|
||||
system_prompt += "\n\n" + content
|
||||
else:
|
||||
system_prompt = content
|
||||
else:
|
||||
anthropic_messages.append({"role": role, "content": content})
|
||||
|
||||
# Add JSON schema instruction if response_format is provided
|
||||
if response_format is not None and hasattr(response_format, "model_json_schema"):
|
||||
schema = response_format.model_json_schema()
|
||||
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
|
||||
if system_prompt:
|
||||
system_prompt += schema_msg
|
||||
else:
|
||||
system_prompt = schema_msg
|
||||
|
||||
# Prepare parameters
|
||||
call_params = {
|
||||
"model": self.model,
|
||||
"messages": anthropic_messages,
|
||||
"max_tokens": max_completion_tokens if max_completion_tokens is not None else 4096,
|
||||
}
|
||||
|
||||
if system_prompt:
|
||||
call_params["system"] = system_prompt
|
||||
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
response = await self._anthropic_client.messages.create(**call_params)
|
||||
|
||||
# Anthropic response content is a list of blocks
|
||||
content = ""
|
||||
for block in response.content:
|
||||
if block.type == "text":
|
||||
content += block.text
|
||||
|
||||
if response_format is not None:
|
||||
# Models may wrap JSON in markdown code blocks
|
||||
clean_content = content
|
||||
if "```json" in content:
|
||||
clean_content = content.split("```json")[1].split("```")[0].strip()
|
||||
elif "```" in content:
|
||||
clean_content = content.split("```")[1].split("```")[0].strip()
|
||||
|
||||
try:
|
||||
json_data = json.loads(clean_content)
|
||||
except json.JSONDecodeError:
|
||||
# Fallback to parsing raw content if markdown stripping failed
|
||||
json_data = json.loads(content)
|
||||
|
||||
if skip_validation:
|
||||
result = json_data
|
||||
else:
|
||||
result = response_format.model_validate(json_data)
|
||||
else:
|
||||
result = content
|
||||
|
||||
# Log slow calls
|
||||
duration = time.time() - start_time
|
||||
if duration > 10.0:
|
||||
input_tokens = response.usage.input_tokens
|
||||
output_tokens = response.usage.output_tokens
|
||||
logger.info(
|
||||
f"slow llm call: model={self.provider}/{self.model}, "
|
||||
f"input_tokens={input_tokens}, output_tokens={output_tokens}, "
|
||||
f"time={duration:.3f}s"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
logger.warning("Anthropic returned invalid JSON, retrying...")
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"Anthropic returned invalid JSON after {max_retries + 1} attempts")
|
||||
raise
|
||||
|
||||
except (APIConnectionError, RateLimitError, APIStatusError) as e:
|
||||
# Fast fail on 401/403
|
||||
if isinstance(e, APIStatusError) and e.status_code in (401, 403):
|
||||
logger.error(f"Anthropic auth error (HTTP {e.status_code}), not retrying: {str(e)}")
|
||||
raise
|
||||
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
# Check if it's a rate limit or server error
|
||||
should_retry = isinstance(e, (APIConnectionError, RateLimitError)) or (
|
||||
isinstance(e, APIStatusError) and e.status_code >= 500
|
||||
)
|
||||
|
||||
if should_retry:
|
||||
backoff = min(initial_backoff * (2**attempt), max_backoff)
|
||||
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
|
||||
await asyncio.sleep(backoff + jitter)
|
||||
continue
|
||||
|
||||
logger.error(f"Anthropic API error after {max_retries + 1} attempts: {str(e)}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during Anthropic call: {type(e).__name__}: {str(e)}")
|
||||
raise
|
||||
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
raise RuntimeError("Anthropic call failed after all retries")
|
||||
|
||||
async def _call_ollama_native(
|
||||
self,
|
||||
messages: list[dict[str, str]],
|
||||
|
||||
@@ -17,6 +17,8 @@ import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ..config import get_config
|
||||
|
||||
# Context variable for current schema (async-safe, per-task isolation)
|
||||
_current_schema: contextvars.ContextVar[str] = contextvars.ContextVar("current_schema", default="public")
|
||||
|
||||
@@ -372,7 +374,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
|
||||
result = await validation_coro
|
||||
if not result.allowed:
|
||||
raise OperationValidationError(result.reason or "Operation not allowed")
|
||||
raise OperationValidationError(result.reason or "Operation not allowed", result.status_code)
|
||||
|
||||
async def _authenticate_tenant(self, request_context: "RequestContext | None") -> str:
|
||||
"""
|
||||
@@ -399,7 +401,9 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if request_context is None:
|
||||
raise AuthenticationError("RequestContext is required when tenant extension is configured")
|
||||
|
||||
# Let AuthenticationError propagate - HTTP layer will convert to 401
|
||||
tenant_context = await self._tenant_extension.authenticate(request_context)
|
||||
|
||||
_current_schema.set(tenant_context.schema_name)
|
||||
return tenant_context.schema_name
|
||||
|
||||
@@ -2825,13 +2829,16 @@ Guidelines:
|
||||
Handler for form opinion tasks.
|
||||
|
||||
Args:
|
||||
task_dict: Dict with keys: 'bank_id', 'answer_text', 'query'
|
||||
task_dict: Dict with keys: 'bank_id', 'answer_text', 'query', 'tenant_id'
|
||||
"""
|
||||
bank_id = task_dict["bank_id"]
|
||||
answer_text = task_dict["answer_text"]
|
||||
query = task_dict["query"]
|
||||
tenant_id = task_dict.get("tenant_id")
|
||||
|
||||
await self._extract_and_store_opinions_async(bank_id=bank_id, answer_text=answer_text, query=query)
|
||||
await self._extract_and_store_opinions_async(
|
||||
bank_id=bank_id, answer_text=answer_text, query=query, tenant_id=tenant_id
|
||||
)
|
||||
|
||||
async def _handle_reinforce_opinion(self, task_dict: dict[str, Any]):
|
||||
"""
|
||||
@@ -3076,6 +3083,8 @@ Guidelines:
|
||||
*,
|
||||
budget: Budget | None = None,
|
||||
context: str | None = None,
|
||||
max_tokens: int = 4096,
|
||||
response_schema: dict | None = None,
|
||||
request_context: "RequestContext",
|
||||
) -> ReflectResult:
|
||||
"""
|
||||
@@ -3087,19 +3096,22 @@ Guidelines:
|
||||
3. Retrieves existing opinions (bank's formed perspectives)
|
||||
4. Uses LLM to formulate an answer
|
||||
5. Extracts and stores any new opinions formed during reflection
|
||||
6. Returns plain text answer and the facts used
|
||||
6. Optionally generates structured output based on response_schema
|
||||
7. Returns plain text answer and the facts used
|
||||
|
||||
Args:
|
||||
bank_id: bank identifier
|
||||
query: Question to answer
|
||||
budget: Budget level for memory exploration (low=100, mid=300, high=600 units)
|
||||
context: Additional context string to include in LLM prompt (not used in recall)
|
||||
response_schema: Optional JSON Schema for structured output
|
||||
|
||||
Returns:
|
||||
ReflectResult containing:
|
||||
- text: Plain text answer (no markdown)
|
||||
- based_on: Dict with 'world', 'experience', and 'opinion' fact lists (MemoryFact objects)
|
||||
- new_opinions: List of newly formed opinions
|
||||
- structured_output: Optional dict if response_schema was provided
|
||||
"""
|
||||
# Use cached LLM config
|
||||
if self._llm_config is None:
|
||||
@@ -3177,21 +3189,53 @@ Guidelines:
|
||||
log_buffer.append(f"[REFLECT {reflect_id}] Prompt: {len(prompt)} chars")
|
||||
|
||||
system_message = think_utils.get_system_message(disposition)
|
||||
messages = [{"role": "system", "content": system_message}, {"role": "user", "content": prompt}]
|
||||
|
||||
# Prepare response_format if schema provided
|
||||
response_format = None
|
||||
if response_schema is not None:
|
||||
# Wrapper class to provide Pydantic-like interface for raw JSON schemas
|
||||
class JsonSchemaWrapper:
|
||||
def __init__(self, schema: dict):
|
||||
self._schema = schema
|
||||
|
||||
def model_json_schema(self):
|
||||
return self._schema
|
||||
|
||||
response_format = JsonSchemaWrapper(response_schema)
|
||||
|
||||
llm_start = time.time()
|
||||
answer_text = await self._llm_config.call(
|
||||
messages=[{"role": "system", "content": system_message}, {"role": "user", "content": prompt}],
|
||||
scope="memory_think",
|
||||
temperature=0.9,
|
||||
max_completion_tokens=1000,
|
||||
result = await self._llm_config.call(
|
||||
messages=messages,
|
||||
scope="memory_reflect",
|
||||
max_completion_tokens=max_tokens,
|
||||
response_format=response_format,
|
||||
skip_validation=True if response_format else False,
|
||||
# Don't enforce strict_schema - not all providers support it and may retry forever
|
||||
# Soft enforcement (schema in prompt + json_object mode) is sufficient
|
||||
strict_schema=False,
|
||||
)
|
||||
llm_time = time.time() - llm_start
|
||||
|
||||
answer_text = answer_text.strip()
|
||||
# Handle response based on whether structured output was requested
|
||||
if response_schema is not None:
|
||||
structured_output = result
|
||||
answer_text = "" # Empty for backward compatibility
|
||||
log_buffer.append(f"[REFLECT {reflect_id}] Structured output generated")
|
||||
else:
|
||||
structured_output = None
|
||||
answer_text = result.strip()
|
||||
|
||||
# Submit form_opinion task for background processing
|
||||
# Pass tenant_id from request context for internal authentication in background task
|
||||
await self._task_backend.submit_task(
|
||||
{"type": "form_opinion", "bank_id": bank_id, "answer_text": answer_text, "query": query}
|
||||
{
|
||||
"type": "form_opinion",
|
||||
"bank_id": bank_id,
|
||||
"answer_text": answer_text,
|
||||
"query": query,
|
||||
"tenant_id": getattr(request_context, "tenant_id", None) if request_context else None,
|
||||
}
|
||||
)
|
||||
|
||||
total_time = time.time() - reflect_start
|
||||
@@ -3205,6 +3249,7 @@ Guidelines:
|
||||
text=answer_text,
|
||||
based_on={"world": world_results, "experience": agent_results, "opinion": opinion_results},
|
||||
new_opinions=[], # Opinions are being extracted asynchronously
|
||||
structured_output=structured_output,
|
||||
)
|
||||
|
||||
# Call post-operation hook if validator is configured
|
||||
@@ -3228,7 +3273,9 @@ Guidelines:
|
||||
|
||||
return result
|
||||
|
||||
async def _extract_and_store_opinions_async(self, bank_id: str, answer_text: str, query: str):
|
||||
async def _extract_and_store_opinions_async(
|
||||
self, bank_id: str, answer_text: str, query: str, tenant_id: str | None = None
|
||||
):
|
||||
"""
|
||||
Background task to extract and store opinions from think response.
|
||||
|
||||
@@ -3238,6 +3285,7 @@ Guidelines:
|
||||
bank_id: bank IDentifier
|
||||
answer_text: The generated answer text
|
||||
query: The original query
|
||||
tenant_id: Tenant identifier for internal authentication
|
||||
"""
|
||||
try:
|
||||
# Extract opinions from the answer
|
||||
@@ -3248,10 +3296,11 @@ Guidelines:
|
||||
from datetime import datetime
|
||||
|
||||
current_time = datetime.now(UTC)
|
||||
# Use internal request context for background tasks
|
||||
# Use internal context with tenant_id for background authentication
|
||||
# Extension can check internal=True to bypass normal auth
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
internal_context = RequestContext()
|
||||
internal_context = RequestContext(tenant_id=tenant_id, internal=True)
|
||||
for opinion in new_opinions:
|
||||
await self.retain_async(
|
||||
bank_id=bank_id,
|
||||
@@ -3572,7 +3621,7 @@ Guidelines:
|
||||
self,
|
||||
bank_id: str,
|
||||
entity_ids: list[str],
|
||||
min_facts: int = 5,
|
||||
min_facts: int | None = None,
|
||||
conn=None,
|
||||
request_context: "RequestContext | None" = None,
|
||||
) -> None:
|
||||
@@ -3584,12 +3633,16 @@ Guidelines:
|
||||
Args:
|
||||
bank_id: Bank identifier
|
||||
entity_ids: List of entity IDs to process
|
||||
min_facts: Minimum facts required to regenerate observations
|
||||
min_facts: Minimum facts required to regenerate observations (uses config default if None)
|
||||
conn: Optional database connection (for transactional atomicity)
|
||||
"""
|
||||
if not bank_id or not entity_ids:
|
||||
return
|
||||
|
||||
# Use config default if min_facts not specified
|
||||
if min_facts is None:
|
||||
min_facts = get_config().observation_min_facts
|
||||
|
||||
# Convert to UUIDs
|
||||
entity_uuids = [uuid.UUID(eid) if isinstance(eid, str) else eid for eid in entity_ids]
|
||||
|
||||
|
||||
@@ -123,7 +123,8 @@ class ReflectResult(BaseModel):
|
||||
Result from a reflect operation.
|
||||
|
||||
Contains the formulated answer, the facts it was based on (organized by type),
|
||||
and any new opinions that were formed during the reflection process.
|
||||
any new opinions that were formed during the reflection process, and optionally
|
||||
structured output if a response schema was provided.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
@@ -145,6 +146,7 @@ class ReflectResult(BaseModel):
|
||||
"opinion": [],
|
||||
},
|
||||
"new_opinions": ["Machine learning has great potential in healthcare"],
|
||||
"structured_output": {"summary": "ML in healthcare", "confidence": 0.9},
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -154,6 +156,10 @@ class ReflectResult(BaseModel):
|
||||
description="Facts used to formulate the answer, organized by type (world, experience, opinion)"
|
||||
)
|
||||
new_opinions: list[str] = Field(default_factory=list, description="List of newly formed opinions during reflection")
|
||||
structured_output: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Structured output parsed according to the provided response schema. Only present when response_schema was provided.",
|
||||
)
|
||||
|
||||
|
||||
class Opinion(BaseModel):
|
||||
|
||||
@@ -9,6 +9,7 @@ import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from ...config import get_config
|
||||
from ..memory_engine import fq_table
|
||||
from ..search import observation_utils
|
||||
from . import embedding_utils
|
||||
@@ -49,8 +50,9 @@ async def regenerate_observations_batch(
|
||||
entity_links: Entity links from this batch
|
||||
log_buffer: Optional log buffer for timing
|
||||
"""
|
||||
TOP_N_ENTITIES = 5
|
||||
MIN_FACTS_THRESHOLD = 5
|
||||
config = get_config()
|
||||
TOP_N_ENTITIES = config.observation_top_entities
|
||||
MIN_FACTS_THRESHOLD = config.observation_min_facts
|
||||
|
||||
if not entity_links:
|
||||
return
|
||||
|
||||
@@ -98,7 +98,14 @@ class DefaultExtensionContext(ExtensionContext):
|
||||
"""Run migrations for a specific schema."""
|
||||
from hindsight_api.migrations import run_migrations
|
||||
|
||||
run_migrations(self._database_url, schema=schema)
|
||||
# Prefer getting URL from memory engine (handles pg0 case where URL is set after init)
|
||||
db_url = self._database_url
|
||||
if self._memory_engine is not None:
|
||||
engine_url = getattr(self._memory_engine, "db_url", None)
|
||||
if engine_url:
|
||||
db_url = engine_url
|
||||
|
||||
run_migrations(db_url, schema=schema)
|
||||
|
||||
def get_memory_engine(self) -> "MemoryEngineInterface":
|
||||
"""Get the memory engine interface."""
|
||||
|
||||
@@ -17,8 +17,9 @@ if TYPE_CHECKING:
|
||||
class OperationValidationError(Exception):
|
||||
"""Raised when an operation fails validation."""
|
||||
|
||||
def __init__(self, reason: str):
|
||||
def __init__(self, reason: str, status_code: int = 403):
|
||||
self.reason = reason
|
||||
self.status_code = status_code
|
||||
super().__init__(f"Operation validation failed: {reason}")
|
||||
|
||||
|
||||
@@ -28,6 +29,7 @@ class ValidationResult:
|
||||
|
||||
allowed: bool
|
||||
reason: str | None = None
|
||||
status_code: int = 403 # Default to Forbidden
|
||||
|
||||
@classmethod
|
||||
def accept(cls) -> "ValidationResult":
|
||||
@@ -35,9 +37,9 @@ class ValidationResult:
|
||||
return cls(allowed=True)
|
||||
|
||||
@classmethod
|
||||
def reject(cls, reason: str) -> "ValidationResult":
|
||||
"""Create a rejected validation result with a reason."""
|
||||
return cls(allowed=False, reason=reason)
|
||||
def reject(cls, reason: str, status_code: int = 403) -> "ValidationResult":
|
||||
"""Create a rejected validation result with a reason and HTTP status code."""
|
||||
return cls(allowed=False, reason=reason, status_code=status_code)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -31,6 +31,7 @@ from .daemon import (
|
||||
IdleTimeoutMiddleware,
|
||||
daemonize,
|
||||
)
|
||||
from .extensions import DefaultExtensionContext, OperationValidatorExtension, TenantExtension, load_extension
|
||||
|
||||
# Filter deprecation warnings from third-party libraries
|
||||
warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
|
||||
@@ -168,6 +169,8 @@ def main():
|
||||
llm_api_key=config.llm_api_key,
|
||||
llm_model=config.llm_model,
|
||||
llm_base_url=config.llm_base_url,
|
||||
llm_max_concurrent=config.llm_max_concurrent,
|
||||
llm_timeout=config.llm_timeout,
|
||||
embeddings_provider=config.embeddings_provider,
|
||||
embeddings_local_model=config.embeddings_local_model,
|
||||
embeddings_tei_url=config.embeddings_tei_url,
|
||||
@@ -179,6 +182,8 @@ def main():
|
||||
log_level=args.log_level,
|
||||
mcp_enabled=config.mcp_enabled,
|
||||
graph_retriever=config.graph_retriever,
|
||||
observation_min_facts=config.observation_min_facts,
|
||||
observation_top_entities=config.observation_top_entities,
|
||||
skip_llm_verification=config.skip_llm_verification,
|
||||
lazy_reranker=config.lazy_reranker,
|
||||
)
|
||||
@@ -191,8 +196,31 @@ def main():
|
||||
signal.signal(signal.SIGINT, _signal_handler)
|
||||
signal.signal(signal.SIGTERM, _signal_handler)
|
||||
|
||||
# Load operation validator extension if configured
|
||||
operation_validator = load_extension("OPERATION_VALIDATOR", OperationValidatorExtension)
|
||||
if operation_validator:
|
||||
import logging
|
||||
|
||||
logging.info(f"Loaded operation validator: {operation_validator.__class__.__name__}")
|
||||
|
||||
# Load tenant extension if configured
|
||||
tenant_extension = load_extension("TENANT", TenantExtension)
|
||||
if tenant_extension:
|
||||
import logging
|
||||
|
||||
logging.info(f"Loaded tenant extension: {tenant_extension.__class__.__name__}")
|
||||
|
||||
# Create MemoryEngine (reads configuration from environment)
|
||||
_memory = MemoryEngine()
|
||||
_memory = MemoryEngine(operation_validator=operation_validator, tenant_extension=tenant_extension)
|
||||
|
||||
# Set extension context on tenant extension (needed for schema provisioning)
|
||||
if tenant_extension:
|
||||
extension_context = DefaultExtensionContext(
|
||||
database_url=config.database_url,
|
||||
memory_engine=_memory,
|
||||
)
|
||||
tenant_extension.set_context(extension_context)
|
||||
logging.info("Extension context set on tenant extension")
|
||||
|
||||
# Create FastAPI app
|
||||
app = create_app(
|
||||
|
||||
@@ -18,6 +18,9 @@ class RequestContext:
|
||||
"""
|
||||
|
||||
api_key: str | None = None
|
||||
api_key_id: str | None = None # UUID of the API key used for authentication
|
||||
tenant_id: str | None = None # Tenant identifier (set by extension after auth)
|
||||
internal: bool = False # True for background/internal operations (not user-visible)
|
||||
|
||||
|
||||
from pgvector.sqlalchemy import Vector
|
||||
|
||||
@@ -37,6 +37,7 @@ dependencies = [
|
||||
"opentelemetry-exporter-prometheus>=0.41b0",
|
||||
"dateparser>=1.2.2",
|
||||
"google-genai>=1.0.0",
|
||||
"anthropic>=0.40.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -608,3 +608,167 @@ async def test_async_retain_parallel(api_client):
|
||||
assert response.status_code == 200
|
||||
results = response.json()["results"]
|
||||
assert len(results) > 0, f"Should find memories for document {i}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_structured_output(api_client):
|
||||
"""Test reflect endpoint with structured output via response_schema.
|
||||
|
||||
When response_schema is provided, the reflect endpoint should return
|
||||
both the natural language text response and a structured_output field
|
||||
containing the response parsed according to the provided JSON schema.
|
||||
"""
|
||||
test_bank_id = f"reflect_structured_test_{datetime.now().timestamp()}"
|
||||
|
||||
# Store some memories to reflect on
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/memories",
|
||||
json={
|
||||
"items": [
|
||||
{
|
||||
"content": "Alice is a senior machine learning engineer with 8 years of experience.",
|
||||
"context": "team member info"
|
||||
},
|
||||
{
|
||||
"content": "Bob is a junior data scientist who joined last month.",
|
||||
"context": "team member info"
|
||||
},
|
||||
{
|
||||
"content": "The team uses Python and TensorFlow for most projects.",
|
||||
"context": "tech stack"
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Define a JSON schema for structured output
|
||||
response_schema = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"team_members": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": "string"},
|
||||
"role": {"type": "string"},
|
||||
"experience_level": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"technologies": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"}
|
||||
},
|
||||
"summary": {"type": "string"}
|
||||
},
|
||||
"required": ["team_members", "summary"]
|
||||
}
|
||||
|
||||
# Call reflect with response_schema
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/reflect",
|
||||
json={
|
||||
"query": "Give me an overview of the team and their tech stack",
|
||||
"response_schema": response_schema
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
|
||||
# Verify text field exists (empty when using structured output)
|
||||
assert "text" in result
|
||||
assert result["text"] == ""
|
||||
|
||||
# Verify structured output exists and has expected structure
|
||||
assert "structured_output" in result
|
||||
assert result["structured_output"] is not None
|
||||
|
||||
structured = result["structured_output"]
|
||||
assert "team_members" in structured
|
||||
assert "summary" in structured
|
||||
assert isinstance(structured["team_members"], list)
|
||||
assert isinstance(structured["summary"], str)
|
||||
|
||||
# Verify team members have the expected fields
|
||||
if len(structured["team_members"]) > 0:
|
||||
member = structured["team_members"][0]
|
||||
assert "name" in member or "role" in member # At least some fields should be present
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_without_structured_output(api_client):
|
||||
"""Test that reflect works normally without response_schema.
|
||||
|
||||
When response_schema is not provided, the structured_output field
|
||||
should be null/None in the response.
|
||||
"""
|
||||
test_bank_id = f"reflect_no_structured_test_{datetime.now().timestamp()}"
|
||||
|
||||
# Store a memory
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/memories",
|
||||
json={
|
||||
"items": [
|
||||
{
|
||||
"content": "The project deadline is next Friday.",
|
||||
"context": "project timeline"
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Call reflect without response_schema
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/reflect",
|
||||
json={
|
||||
"query": "When is the project deadline?"
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
|
||||
# Verify response has text but structured_output is null
|
||||
assert "text" in result
|
||||
assert len(result["text"]) > 0
|
||||
assert result.get("structured_output") is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reflect_with_max_tokens(api_client):
|
||||
"""Test reflect endpoint with custom max_tokens parameter.
|
||||
|
||||
The max_tokens parameter controls the maximum tokens for the LLM response.
|
||||
"""
|
||||
test_bank_id = f"reflect_max_tokens_test_{datetime.now().timestamp()}"
|
||||
|
||||
# Store a memory
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/memories",
|
||||
json={
|
||||
"items": [
|
||||
{
|
||||
"content": "Python is a popular programming language for data science and machine learning.",
|
||||
"context": "tech"
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Call reflect with custom max_tokens
|
||||
response = await api_client.post(
|
||||
f"/v1/default/banks/{test_bank_id}/reflect",
|
||||
json={
|
||||
"query": "What is Python used for?",
|
||||
"max_tokens": 500
|
||||
}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
|
||||
# Verify response has text
|
||||
assert "text" in result
|
||||
assert len(result["text"]) > 0
|
||||
|
||||
@@ -354,7 +354,9 @@ impl App {
|
||||
query: query_text,
|
||||
budget: Some(query_budget),
|
||||
context: None,
|
||||
max_tokens: 4096,
|
||||
include: None,
|
||||
response_schema: None,
|
||||
};
|
||||
|
||||
let result = client.reflect(&bank_id, &request, false)
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::ui;
|
||||
|
||||
// Import types from generated client
|
||||
use hindsight_client::types::{Budget, ChunkIncludeOptions, IncludeOptions};
|
||||
use serde_json;
|
||||
|
||||
// Helper function to parse budget string to Budget enum
|
||||
fn parse_budget(budget: &str) -> Budget {
|
||||
@@ -86,6 +87,8 @@ pub fn reflect(
|
||||
query: String,
|
||||
budget: String,
|
||||
context: Option<String>,
|
||||
max_tokens: Option<i64>,
|
||||
schema_path: Option<PathBuf>,
|
||||
verbose: bool,
|
||||
output_format: OutputFormat,
|
||||
) -> Result<()> {
|
||||
@@ -95,11 +98,24 @@ pub fn reflect(
|
||||
None
|
||||
};
|
||||
|
||||
// Load and parse schema if provided
|
||||
let response_schema = if let Some(path) = schema_path {
|
||||
let schema_content = fs::read_to_string(&path)
|
||||
.with_context(|| format!("Failed to read schema file: {}", path.display()))?;
|
||||
let schema: serde_json::Map<String, serde_json::Value> = serde_json::from_str(&schema_content)
|
||||
.with_context(|| format!("Failed to parse JSON schema from: {}", path.display()))?;
|
||||
Some(schema)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let request = ReflectRequest {
|
||||
query,
|
||||
budget: Some(parse_budget(&budget)),
|
||||
context,
|
||||
max_tokens: max_tokens.unwrap_or(4096),
|
||||
include: None,
|
||||
response_schema,
|
||||
};
|
||||
|
||||
let response = client.reflect(agent_id, &request, verbose);
|
||||
|
||||
@@ -206,6 +206,14 @@ enum MemoryCommands {
|
||||
/// Additional context
|
||||
#[arg(short = 'c', long)]
|
||||
context: Option<String>,
|
||||
|
||||
/// Maximum tokens for the response (server default: 4096)
|
||||
#[arg(short = 'm', long)]
|
||||
max_tokens: Option<i64>,
|
||||
|
||||
/// Path to JSON schema file for structured output
|
||||
#[arg(short = 's', long)]
|
||||
schema: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// Store (retain) a single memory
|
||||
@@ -421,8 +429,8 @@ fn run() -> Result<()> {
|
||||
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)
|
||||
}
|
||||
MemoryCommands::Reflect { bank_id, query, budget, context } => {
|
||||
commands::memory::reflect(&client, &bank_id, query, budget, context, verbose, output_format)
|
||||
MemoryCommands::Reflect { bank_id, query, budget, context, max_tokens, schema } => {
|
||||
commands::memory::reflect(&client, &bank_id, query, budget, context, max_tokens, schema, verbose, output_format)
|
||||
}
|
||||
MemoryCommands::Retain { bank_id, content, doc_id, context, r#async } => {
|
||||
commands::memory::retain(&client, &bank_id, content, doc_id, context, r#async, verbose, output_format)
|
||||
|
||||
@@ -175,6 +175,16 @@ pub fn print_think_response(response: &ReflectResponse) {
|
||||
if !response.based_on.is_empty() {
|
||||
println!("{}", dim(&format!("Based on {} memory units", response.based_on.len())));
|
||||
}
|
||||
|
||||
// Display structured output if present
|
||||
if let Some(structured) = &response.structured_output {
|
||||
println!();
|
||||
println!("{}", gradient_text("─── Structured Output ───"));
|
||||
println!();
|
||||
if let Ok(json) = serde_json::to_string_pretty(structured) {
|
||||
println!("{}", json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print_trace_info(trace: &serde_json::Map<String, serde_json::Value>) {
|
||||
|
||||
@@ -102,53 +102,4 @@ hindsight_client_api/models/update_disposition_request.py
|
||||
hindsight_client_api/models/validation_error.py
|
||||
hindsight_client_api/models/validation_error_loc_inner.py
|
||||
hindsight_client_api/rest.py
|
||||
hindsight_client_api/test/__init__.py
|
||||
hindsight_client_api/test/test_add_background_request.py
|
||||
hindsight_client_api/test/test_background_response.py
|
||||
hindsight_client_api/test/test_bank_list_item.py
|
||||
hindsight_client_api/test/test_bank_list_response.py
|
||||
hindsight_client_api/test/test_bank_profile_response.py
|
||||
hindsight_client_api/test/test_bank_stats_response.py
|
||||
hindsight_client_api/test/test_banks_api.py
|
||||
hindsight_client_api/test/test_budget.py
|
||||
hindsight_client_api/test/test_cancel_operation_response.py
|
||||
hindsight_client_api/test/test_chunk_data.py
|
||||
hindsight_client_api/test/test_chunk_include_options.py
|
||||
hindsight_client_api/test/test_chunk_response.py
|
||||
hindsight_client_api/test/test_create_bank_request.py
|
||||
hindsight_client_api/test/test_delete_document_response.py
|
||||
hindsight_client_api/test/test_delete_response.py
|
||||
hindsight_client_api/test/test_disposition_traits.py
|
||||
hindsight_client_api/test/test_document_response.py
|
||||
hindsight_client_api/test/test_documents_api.py
|
||||
hindsight_client_api/test/test_entities_api.py
|
||||
hindsight_client_api/test/test_entity_detail_response.py
|
||||
hindsight_client_api/test/test_entity_include_options.py
|
||||
hindsight_client_api/test/test_entity_list_item.py
|
||||
hindsight_client_api/test/test_entity_list_response.py
|
||||
hindsight_client_api/test/test_entity_observation_response.py
|
||||
hindsight_client_api/test/test_entity_state_response.py
|
||||
hindsight_client_api/test/test_graph_data_response.py
|
||||
hindsight_client_api/test/test_http_validation_error.py
|
||||
hindsight_client_api/test/test_include_options.py
|
||||
hindsight_client_api/test/test_list_documents_response.py
|
||||
hindsight_client_api/test/test_list_memory_units_response.py
|
||||
hindsight_client_api/test/test_memory_api.py
|
||||
hindsight_client_api/test/test_memory_item.py
|
||||
hindsight_client_api/test/test_monitoring_api.py
|
||||
hindsight_client_api/test/test_operation_response.py
|
||||
hindsight_client_api/test/test_operations_api.py
|
||||
hindsight_client_api/test/test_operations_list_response.py
|
||||
hindsight_client_api/test/test_recall_request.py
|
||||
hindsight_client_api/test/test_recall_response.py
|
||||
hindsight_client_api/test/test_recall_result.py
|
||||
hindsight_client_api/test/test_reflect_fact.py
|
||||
hindsight_client_api/test/test_reflect_include_options.py
|
||||
hindsight_client_api/test/test_reflect_request.py
|
||||
hindsight_client_api/test/test_reflect_response.py
|
||||
hindsight_client_api/test/test_retain_request.py
|
||||
hindsight_client_api/test/test_retain_response.py
|
||||
hindsight_client_api/test/test_update_disposition_request.py
|
||||
hindsight_client_api/test/test_validation_error.py
|
||||
hindsight_client_api/test/test_validation_error_loc_inner.py
|
||||
hindsight_client_api_README.md
|
||||
|
||||
@@ -229,6 +229,8 @@ class Hindsight:
|
||||
query: str,
|
||||
budget: str = "low",
|
||||
context: Optional[str] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
response_schema: Optional[Dict[str, Any]] = None,
|
||||
) -> ReflectResponse:
|
||||
"""
|
||||
Generate a contextual answer based on bank identity and memories.
|
||||
@@ -238,14 +240,21 @@ class Hindsight:
|
||||
query: The question or prompt
|
||||
budget: Budget level for reflection - "low", "mid", or "high" (default: "low")
|
||||
context: Optional additional context
|
||||
max_tokens: Maximum tokens for the response (server default: 4096)
|
||||
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.
|
||||
|
||||
Returns:
|
||||
ReflectResponse with answer text and optionally facts used
|
||||
ReflectResponse with answer text, optionally facts used, and optionally
|
||||
structured_output if response_schema was provided
|
||||
"""
|
||||
request_obj = reflect_request.ReflectRequest(
|
||||
query=query,
|
||||
budget=budget,
|
||||
context=context,
|
||||
max_tokens=max_tokens,
|
||||
response_schema=response_schema,
|
||||
)
|
||||
|
||||
return _run_async(self._memory_api.reflect(bank_id, request_obj))
|
||||
|
||||
@@ -9,7 +9,9 @@ Name | Type | Description | Notes
|
||||
**query** | **str** | |
|
||||
**budget** | [**Budget**](Budget.md) | | [optional]
|
||||
**context** | **str** | | [optional]
|
||||
**max_tokens** | **int** | Maximum tokens for the response | [optional] [default to 4096]
|
||||
**include** | [**ReflectIncludeOptions**](ReflectIncludeOptions.md) | Options for including additional data (disabled by default) | [optional]
|
||||
**response_schema** | **Dict[str, object]** | | [optional]
|
||||
|
||||
## Example
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**text** | **str** | |
|
||||
**based_on** | [**List[ReflectFact]**](ReflectFact.md) | | [optional] [default to []]
|
||||
**structured_output** | **Dict[str, object]** | | [optional]
|
||||
|
||||
## Example
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictStr
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictInt, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from hindsight_client_api.models.budget import Budget
|
||||
from hindsight_client_api.models.reflect_include_options import ReflectIncludeOptions
|
||||
@@ -31,8 +31,10 @@ class ReflectRequest(BaseModel):
|
||||
query: StrictStr
|
||||
budget: Optional[Budget] = None
|
||||
context: Optional[StrictStr] = None
|
||||
max_tokens: Optional[StrictInt] = Field(default=4096, description="Maximum tokens for the response")
|
||||
include: Optional[ReflectIncludeOptions] = Field(default=None, description="Options for including additional data (disabled by default)")
|
||||
__properties: ClassVar[List[str]] = ["query", "budget", "context", "include"]
|
||||
response_schema: Optional[Dict[str, Any]] = None
|
||||
__properties: ClassVar[List[str]] = ["query", "budget", "context", "max_tokens", "include", "response_schema"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -81,6 +83,11 @@ class ReflectRequest(BaseModel):
|
||||
if self.context is None and "context" in self.model_fields_set:
|
||||
_dict['context'] = None
|
||||
|
||||
# set to None if response_schema (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.response_schema is None and "response_schema" in self.model_fields_set:
|
||||
_dict['response_schema'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
@@ -96,7 +103,9 @@ class ReflectRequest(BaseModel):
|
||||
"query": obj.get("query"),
|
||||
"budget": obj.get("budget"),
|
||||
"context": obj.get("context"),
|
||||
"include": ReflectIncludeOptions.from_dict(obj["include"]) if obj.get("include") is not None else None
|
||||
"max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 4096,
|
||||
"include": ReflectIncludeOptions.from_dict(obj["include"]) if obj.get("include") is not None else None,
|
||||
"response_schema": obj.get("response_schema")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ class ReflectResponse(BaseModel):
|
||||
""" # noqa: E501
|
||||
text: StrictStr
|
||||
based_on: Optional[List[ReflectFact]] = None
|
||||
__properties: ClassVar[List[str]] = ["text", "based_on"]
|
||||
structured_output: Optional[Dict[str, Any]] = None
|
||||
__properties: ClassVar[List[str]] = ["text", "based_on", "structured_output"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -77,6 +78,11 @@ class ReflectResponse(BaseModel):
|
||||
if _item_based_on:
|
||||
_items.append(_item_based_on.to_dict())
|
||||
_dict['based_on'] = _items
|
||||
# set to None if structured_output (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.structured_output is None and "structured_output" in self.model_fields_set:
|
||||
_dict['structured_output'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
@@ -90,7 +96,8 @@ class ReflectResponse(BaseModel):
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"text": obj.get("text"),
|
||||
"based_on": [ReflectFact.from_dict(_item) for _item in obj["based_on"]] if obj.get("based_on") is not None else None
|
||||
"based_on": [ReflectFact.from_dict(_item) for _item in obj["based_on"]] if obj.get("based_on") is not None else None,
|
||||
"structured_output": obj.get("structured_output")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -173,6 +173,51 @@ class TestReflect:
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
|
||||
def test_reflect_with_max_tokens(self, client, bank_id):
|
||||
"""Test reflect with custom max_tokens parameter."""
|
||||
response = client.reflect(
|
||||
bank_id=bank_id,
|
||||
query="What do you think about Python?",
|
||||
max_tokens=500,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert response.text is not None
|
||||
assert len(response.text) > 0
|
||||
|
||||
def test_reflect_with_structured_output(self, client, bank_id):
|
||||
"""Test reflect with structured output via response_schema.
|
||||
|
||||
When response_schema is provided, the response returns structured_output
|
||||
field parsed according to the provided JSON schema. The text field is empty
|
||||
since only a single LLM call is made for structured output.
|
||||
"""
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Define schema using Pydantic model
|
||||
class RecommendationResponse(BaseModel):
|
||||
recommendation: str
|
||||
reasons: list[str]
|
||||
confidence: Optional[str] = None # Optional for LLM flexibility
|
||||
|
||||
response = client.reflect(
|
||||
bank_id=bank_id,
|
||||
query="What programming language should I learn for data science?",
|
||||
response_schema=RecommendationResponse.model_json_schema(),
|
||||
max_tokens=10000,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
# Text is empty when using structured output (single LLM call)
|
||||
assert response.text == ""
|
||||
|
||||
# Verify structured output is present and can be parsed into model
|
||||
assert response.structured_output is not None
|
||||
result = RecommendationResponse.model_validate(response.structured_output)
|
||||
assert result.recommendation
|
||||
assert isinstance(result.reasons, list)
|
||||
|
||||
|
||||
class TestListMemories:
|
||||
"""Tests for listing memories."""
|
||||
|
||||
@@ -101,7 +101,9 @@ mod tests {
|
||||
query: "What do you know about Alice?".to_string(),
|
||||
budget: None,
|
||||
context: None,
|
||||
max_tokens: 4096,
|
||||
include: None,
|
||||
response_schema: None,
|
||||
};
|
||||
let reflect_response = client
|
||||
.reflect(&bank_id, None, &reflect_request)
|
||||
|
||||
@@ -898,10 +898,24 @@ export type ReflectRequest = {
|
||||
* Context
|
||||
*/
|
||||
context?: string | null;
|
||||
/**
|
||||
* Max Tokens
|
||||
*
|
||||
* Maximum tokens for the response
|
||||
*/
|
||||
max_tokens?: number;
|
||||
/**
|
||||
* Options for including additional data (disabled by default)
|
||||
*/
|
||||
include?: ReflectIncludeOptions;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
response_schema?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -918,6 +932,14 @@ export type ReflectResponse = {
|
||||
* Based On
|
||||
*/
|
||||
based_on?: Array<ReflectFact>;
|
||||
/**
|
||||
* Structured Output
|
||||
*
|
||||
* Structured output parsed according to the request's response_schema. Only present when response_schema was provided in the request.
|
||||
*/
|
||||
structured_output?: {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
"public"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --turbopack -p $(node -e \"const net=require('net');const s=net.createServer();s.listen(0,()=>{console.log(s.address().port);s.close()})\")",
|
||||
"build": "next build && npm run build:standalone",
|
||||
"build:standalone": "rm -rf standalone && STANDALONE_ROOT=$(find .next/standalone -path '*/node_modules' -prune -o -name 'server.js' -print | head -1 | xargs dirname) && cp -r \"$STANDALONE_ROOT\" standalone && cp -r .next/standalone/node_modules standalone/node_modules && mkdir -p standalone/.next && cp -r .next/static standalone/.next/static && mkdir -p standalone/public && cp -r public/* standalone/public/ 2>/dev/null || true",
|
||||
"start": "next start",
|
||||
|
||||
@@ -52,6 +52,8 @@ Make sure you've completed the [Quick Start](./quickstart) to install the client
|
||||
| `query` | string | required | Question or prompt |
|
||||
| `budget` | string | "low" | Budget level: "low", "mid", "high" |
|
||||
| `context` | string | None | Additional context for the query |
|
||||
| `max_tokens` | int | 4096 | Maximum tokens for the response |
|
||||
| `response_schema` | object | None | JSON Schema for [structured output](#structured-output) |
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="python" label="Python">
|
||||
@@ -127,3 +129,107 @@ This enables:
|
||||
- **Transparency** — users see why the bank said something
|
||||
- **Verification** — check if the response is grounded in facts
|
||||
- **Debugging** — understand retrieval quality
|
||||
|
||||
## Structured Output
|
||||
|
||||
For applications that need to process responses programmatically, you can request structured output by providing a JSON Schema via `response_schema`. When provided, the response includes a `structured_output` field with the LLM response parsed according to the schema. The `text` field will be empty since only a single LLM call is made for efficiency.
|
||||
|
||||
The easiest way to define a schema is using **Pydantic models**:
|
||||
|
||||
<Tabs>
|
||||
<TabItem value="python" label="Python">
|
||||
|
||||
```python
|
||||
from pydantic import BaseModel
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
# Define your response structure with Pydantic
|
||||
class HiringRecommendation(BaseModel):
|
||||
recommendation: str
|
||||
confidence: str # "low", "medium", "high"
|
||||
key_factors: list[str]
|
||||
risks: list[str] = []
|
||||
|
||||
with Hindsight() as client:
|
||||
response = client.reflect(
|
||||
bank_id="hiring-team",
|
||||
query="Should we hire Alice for the ML team lead position?",
|
||||
response_schema=HiringRecommendation.model_json_schema(),
|
||||
)
|
||||
|
||||
# Parse structured output into Pydantic model
|
||||
result = HiringRecommendation.model_validate(response.structured_output)
|
||||
print(f"Recommendation: {result.recommendation}")
|
||||
print(f"Confidence: {result.confidence}")
|
||||
print(f"Key factors: {result.key_factors}")
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="node" label="Node.js">
|
||||
|
||||
```javascript
|
||||
import { Hindsight } from "@anthropic-ai/hindsight";
|
||||
|
||||
const client = new Hindsight();
|
||||
|
||||
// Define JSON schema directly
|
||||
const responseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
recommendation: { type: "string" },
|
||||
confidence: { type: "string", enum: ["low", "medium", "high"] },
|
||||
key_factors: { type: "array", items: { type: "string" } },
|
||||
risks: { type: "array", items: { type: "string" } },
|
||||
},
|
||||
required: ["recommendation", "confidence", "key_factors"],
|
||||
};
|
||||
|
||||
const response = await client.reflect({
|
||||
bankId: "hiring-team",
|
||||
query: "Should we hire Alice for the ML team lead position?",
|
||||
responseSchema: responseSchema,
|
||||
});
|
||||
|
||||
// Structured output
|
||||
console.log(response.structuredOutput.recommendation);
|
||||
console.log(response.structuredOutput.keyFactors);
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="cli" label="CLI">
|
||||
|
||||
First, create a JSON schema file `schema.json`:
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"recommendation": {"type": "string"},
|
||||
"confidence": {"type": "string", "enum": ["low", "medium", "high"]},
|
||||
"key_factors": {"type": "array", "items": {"type": "string"}}
|
||||
},
|
||||
"required": ["recommendation", "confidence", "key_factors"]
|
||||
}
|
||||
```
|
||||
|
||||
Then use the `--schema` flag:
|
||||
```bash
|
||||
hindsight memory reflect hiring-team \
|
||||
"Should we hire Alice for the ML team lead position?" \
|
||||
--schema schema.json
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</Tabs>
|
||||
|
||||
| Use Case | Why Structured Output Helps |
|
||||
|----------|----------------------------|
|
||||
| **Decision pipelines** | Parse recommendations into workflow systems |
|
||||
| **Dashboards** | Extract confidence scores, risk factors for visualization |
|
||||
| **Multi-agent systems** | Pass structured data between agents |
|
||||
| **Auditing** | Log structured decisions with clear reasoning |
|
||||
|
||||
**Tips:**
|
||||
- Use Pydantic's `model_json_schema()` for type-safe schema generation
|
||||
- Use `model_validate()` to parse the response back into your Pydantic model
|
||||
- Keep schemas focused — extract only what you need
|
||||
- Use `Optional` fields for data that may not always be available
|
||||
|
||||
@@ -27,10 +27,12 @@ If not provided, the server uses embedded `pg0` — convenient for development b
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_LLM_PROVIDER` | Provider: `groq`, `openai`, `gemini`, `ollama` | `openai` |
|
||||
| `HINDSIGHT_API_LLM_PROVIDER` | Provider: `openai`, `anthropic`, `gemini`, `groq`, `ollama`, `lmstudio` | `openai` |
|
||||
| `HINDSIGHT_API_LLM_API_KEY` | API key for LLM provider | - |
|
||||
| `HINDSIGHT_API_LLM_MODEL` | Model name | `gpt-5-mini` |
|
||||
| `HINDSIGHT_API_LLM_BASE_URL` | Custom LLM endpoint | Provider default |
|
||||
| `HINDSIGHT_API_LLM_MAX_CONCURRENT` | Max concurrent LLM requests | `32` |
|
||||
| `HINDSIGHT_API_LLM_TIMEOUT` | LLM request timeout in seconds | `120` |
|
||||
|
||||
**Provider Examples**
|
||||
|
||||
@@ -50,10 +52,20 @@ export HINDSIGHT_API_LLM_PROVIDER=gemini
|
||||
export HINDSIGHT_API_LLM_API_KEY=xxxxxxxxxxxx
|
||||
export HINDSIGHT_API_LLM_MODEL=gemini-2.0-flash
|
||||
|
||||
# Anthropic
|
||||
export HINDSIGHT_API_LLM_PROVIDER=anthropic
|
||||
export HINDSIGHT_API_LLM_API_KEY=sk-ant-xxxxxxxxxxxx
|
||||
export HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-20250514
|
||||
|
||||
# Ollama (local, no API key)
|
||||
export HINDSIGHT_API_LLM_PROVIDER=ollama
|
||||
export HINDSIGHT_API_LLM_BASE_URL=http://localhost:11434/v1
|
||||
export HINDSIGHT_API_LLM_MODEL=gpt-oss-20b
|
||||
export HINDSIGHT_API_LLM_MODEL=llama3
|
||||
|
||||
# LM Studio (local, no API key)
|
||||
export HINDSIGHT_API_LLM_PROVIDER=lmstudio
|
||||
export HINDSIGHT_API_LLM_BASE_URL=http://localhost:1234/v1
|
||||
export HINDSIGHT_API_LLM_MODEL=your-local-model
|
||||
|
||||
# OpenAI-compatible endpoint
|
||||
export HINDSIGHT_API_LLM_PROVIDER=openai
|
||||
@@ -109,7 +121,43 @@ export HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
|
||||
| `HINDSIGHT_API_HOST` | Bind address | `0.0.0.0` |
|
||||
| `HINDSIGHT_API_PORT` | Server port | `8888` |
|
||||
| `HINDSIGHT_API_LOG_LEVEL` | Log level: `debug`, `info`, `warning`, `error` | `info` |
|
||||
| `HINDSIGHT_API_MCP_ENABLED` | Enable MCP server | `true` |
|
||||
| `HINDSIGHT_API_MCP_ENABLED` | Enable MCP server at `/mcp/{bank_id}/` | `true` |
|
||||
|
||||
### Retrieval
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_GRAPH_RETRIEVER` | Graph retrieval algorithm: `bfs` or `mpfp` | `bfs` |
|
||||
|
||||
### Entity Observations
|
||||
|
||||
Controls when the system generates entity observations (summaries about entities mentioned in retained content).
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_OBSERVATION_MIN_FACTS` | Minimum facts about an entity before generating observations | `5` |
|
||||
| `HINDSIGHT_API_OBSERVATION_TOP_ENTITIES` | Max entities to process per retain batch | `5` |
|
||||
|
||||
### Local MCP Server
|
||||
|
||||
Configuration for the local MCP server (`hindsight-local-mcp` command).
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_MCP_LOCAL_BANK_ID` | Memory bank ID for local MCP | `mcp` |
|
||||
| `HINDSIGHT_API_MCP_INSTRUCTIONS` | Additional instructions appended to retain/recall tool descriptions | - |
|
||||
|
||||
```bash
|
||||
# Example: instruct MCP to also store assistant actions
|
||||
export HINDSIGHT_API_MCP_INSTRUCTIONS="Also store every action you take, including tool calls and decisions made."
|
||||
```
|
||||
|
||||
### Performance Optimization
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `HINDSIGHT_API_SKIP_LLM_VERIFICATION` | Skip LLM connection check on startup | `false` |
|
||||
| `HINDSIGHT_API_LAZY_RERANKER` | Lazy-load reranker model (faster startup) | `false` |
|
||||
|
||||
### Programmatic Configuration
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ All local models (embedding, cross-encoder) are automatically downloaded from Hu
|
||||
|
||||
Used for fact extraction, entity resolution, opinion generation, and answer synthesis.
|
||||
|
||||
**Supported providers:** OpenAI, Gemini, Groq, Ollama, and **any OpenAI-compatible API**
|
||||
**Supported providers:** OpenAI, Anthropic, Gemini, Groq, Ollama, LM Studio, and **any OpenAI-compatible API**
|
||||
|
||||
:::tip OpenAI-Compatible Providers
|
||||
Hindsight works with any provider that exposes an OpenAI-compatible API (e.g., Azure OpenAI). Simply set `HINDSIGHT_API_LLM_PROVIDER=openai` and configure `HINDSIGHT_API_LLM_BASE_URL` to point to your provider's endpoint.
|
||||
@@ -39,6 +39,8 @@ The following models have been tested and verified to work correctly with Hindsi
|
||||
| **OpenAI** | `gpt-4.1-mini` |
|
||||
| **OpenAI** | `gpt-4.1-nano` |
|
||||
| **OpenAI** | `gpt-4o-mini` |
|
||||
| **Anthropic** | `claude-sonnet-4-20250514` |
|
||||
| **Anthropic** | `claude-3-5-sonnet-20241022` |
|
||||
| **Gemini** | `gemini-3-pro-preview` |
|
||||
| **Gemini** | `gemini-2.5-flash` |
|
||||
| **Gemini** | `gemini-2.5-flash-lite` |
|
||||
@@ -67,10 +69,20 @@ export HINDSIGHT_API_LLM_PROVIDER=gemini
|
||||
export HINDSIGHT_API_LLM_API_KEY=xxxxxxxxxxxx
|
||||
export HINDSIGHT_API_LLM_MODEL=gemini-2.0-flash
|
||||
|
||||
# Anthropic
|
||||
export HINDSIGHT_API_LLM_PROVIDER=anthropic
|
||||
export HINDSIGHT_API_LLM_API_KEY=sk-ant-xxxxxxxxxxxx
|
||||
export HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-20250514
|
||||
|
||||
# Ollama (local)
|
||||
export HINDSIGHT_API_LLM_PROVIDER=ollama
|
||||
export HINDSIGHT_API_LLM_BASE_URL=http://localhost:11434/v1
|
||||
export HINDSIGHT_API_LLM_MODEL=gpt-oss-20b
|
||||
export HINDSIGHT_API_LLM_MODEL=llama3
|
||||
|
||||
# LM Studio (local)
|
||||
export HINDSIGHT_API_LLM_PROVIDER=lmstudio
|
||||
export HINDSIGHT_API_LLM_BASE_URL=http://localhost:1234/v1
|
||||
export HINDSIGHT_API_LLM_MODEL=your-local-model
|
||||
```
|
||||
|
||||
**Note:** The LLM is the primary bottleneck for retain operations. See [Performance](./performance) for optimization strategies.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
---
|
||||
sidebar_position: 3
|
||||
---
|
||||
|
||||
# Skills
|
||||
|
||||
Hindsight provides an Agent Skill that gives AI coding assistants persistent memory across sessions. Skills are reusable prompt templates that agents can load when needed to gain specialized capabilities.
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | Skills Directory |
|
||||
|----------|-----------------|
|
||||
| [Claude Code](https://docs.anthropic.com/en/docs/claude-code) | `~/.claude/skills/` |
|
||||
| [OpenCode](https://github.com/opencode-ai/opencode) | `~/.opencode/skills/` |
|
||||
| [Codex CLI](https://github.com/openai/codex) | `~/.codex/skills/` |
|
||||
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
curl -fsSL https://hindsight.vectorize.io/get-skill | bash
|
||||
```
|
||||
|
||||
The installer will:
|
||||
1. Prompt you to select your AI coding assistant
|
||||
2. Run the LLM provider configuration
|
||||
3. Install the skill to the appropriate directory
|
||||
|
||||
### Install for a Specific Platform
|
||||
|
||||
```bash
|
||||
# Claude Code
|
||||
curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --app claude
|
||||
|
||||
# OpenCode
|
||||
curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --app opencode
|
||||
|
||||
# Codex CLI
|
||||
curl -fsSL https://hindsight.vectorize.io/get-skill | bash -s -- --app codex
|
||||
```
|
||||
|
||||
## What the Skill Provides
|
||||
|
||||
Once installed, your AI assistant gains the ability to:
|
||||
|
||||
- **Retain** - Store user preferences, learnings, and procedure outcomes
|
||||
- **Recall** - Search for relevant context before starting tasks
|
||||
- **Reflect** - Synthesize memories into contextual answers
|
||||
|
||||
The skill uses the `hindsight-embed` CLI which runs a lightweight local daemon with an embedded database.
|
||||
|
||||
## How Skills Work
|
||||
|
||||
Skills are **model-invoked**, meaning the AI assistant automatically decides when to use them based on the context of your conversation. You don't need to explicitly trigger the skill.
|
||||
|
||||
The assistant will:
|
||||
- **Store** when you share preferences, when tasks succeed/fail, or when learnings emerge
|
||||
- **Recall** before starting non-trivial tasks to get relevant context
|
||||
|
||||
### What Gets Stored
|
||||
|
||||
The skill is optimized to store:
|
||||
|
||||
| Category | Examples |
|
||||
|----------|----------|
|
||||
| **User Preferences** | Coding style, tool preferences, language choices |
|
||||
| **Procedure Outcomes** | Commands that worked, configurations that resolved issues |
|
||||
| **Learnings** | Bug solutions, workarounds, architecture decisions |
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
AI Coding Assistant
|
||||
│
|
||||
▼
|
||||
Hindsight Skill (SKILL.md)
|
||||
│
|
||||
▼
|
||||
hindsight-embed CLI
|
||||
│
|
||||
▼
|
||||
Local Daemon (auto-started)
|
||||
│
|
||||
▼
|
||||
Embedded PostgreSQL (~/.pg0/hindsight-embed/)
|
||||
```
|
||||
|
||||
All data stays on your machine. The daemon auto-starts when needed and shuts down after inactivity.
|
||||
|
||||
## Configuration
|
||||
|
||||
The skill uses configuration stored in `~/.hindsight/config.env`. Reconfigure anytime:
|
||||
|
||||
```bash
|
||||
uvx hindsight-embed configure
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Skill not activating
|
||||
|
||||
The skill activates based on its description matching your request. Try being explicit:
|
||||
- "Remember that..." triggers storage
|
||||
- "What do you know about..." triggers recall
|
||||
|
||||
### Daemon issues
|
||||
|
||||
```bash
|
||||
uvx hindsight-embed daemon status
|
||||
uvx hindsight-embed daemon logs
|
||||
```
|
||||
|
||||
### Reconfigure
|
||||
|
||||
```bash
|
||||
uvx hindsight-embed configure
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+ (for `uvx`)
|
||||
- An LLM API key (OpenAI, Anthropic, Groq, etc.)
|
||||
@@ -3269,9 +3269,28 @@
|
||||
],
|
||||
"title": "Context"
|
||||
},
|
||||
"max_tokens": {
|
||||
"type": "integer",
|
||||
"title": "Max Tokens",
|
||||
"description": "Maximum tokens for the response",
|
||||
"default": 4096
|
||||
},
|
||||
"include": {
|
||||
"$ref": "#/components/schemas/ReflectIncludeOptions",
|
||||
"description": "Options for including additional data (disabled by default)"
|
||||
},
|
||||
"response_schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Response Schema",
|
||||
"description": "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."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -3286,7 +3305,26 @@
|
||||
"include": {
|
||||
"facts": {}
|
||||
},
|
||||
"query": "What do you think about artificial intelligence?"
|
||||
"max_tokens": 4096,
|
||||
"query": "What do you think about artificial intelligence?",
|
||||
"response_schema": {
|
||||
"properties": {
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"key_points": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"summary",
|
||||
"key_points"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReflectResponse": {
|
||||
@@ -3302,6 +3340,19 @@
|
||||
"type": "array",
|
||||
"title": "Based On",
|
||||
"default": []
|
||||
},
|
||||
"structured_output": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Structured Output",
|
||||
"description": "Structured output parsed according to the request's response_schema. Only present when response_schema was provided in the request."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -3323,6 +3374,13 @@
|
||||
"type": "experience"
|
||||
}
|
||||
],
|
||||
"structured_output": {
|
||||
"key_points": [
|
||||
"Used in healthcare",
|
||||
"Discussed recently"
|
||||
],
|
||||
"summary": "AI is transformative"
|
||||
},
|
||||
"text": "Based on my understanding, AI is a transformative technology..."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -172,6 +172,11 @@ const sidebars: SidebarsConfig = {
|
||||
id: 'sdks/integrations/litellm',
|
||||
label: 'LiteLLM',
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
id: 'sdks/integrations/skills',
|
||||
label: 'Skills',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -65,77 +65,78 @@ print_banner() {
|
||||
# Embedded SKILL.md content
|
||||
SKILL_CONTENT='---
|
||||
name: hindsight
|
||||
description: Give your agent persistent memory that works like human memory. Store facts, preferences, and context that persist across sessions.
|
||||
description: Store user preferences, learnings from tasks, and procedure outcomes. Use to remember what works and recall context before new tasks.
|
||||
---
|
||||
|
||||
# Hindsight Memory Skill
|
||||
|
||||
You have access to persistent memory via the `hindsight-embed` CLI. Use it to remember important information about the user and recall it when relevant.
|
||||
|
||||
## Setup (first time only)
|
||||
|
||||
Run: `uvx hindsight-embed configure`
|
||||
|
||||
This will configure your LLM provider and start a local daemon that manages your memory bank.
|
||||
You have persistent memory via the `hindsight-embed` CLI. **Proactively store learnings and recall context** to provide better assistance.
|
||||
|
||||
## Commands
|
||||
|
||||
The CLI uses a bank ID to organize memories. Use `default` for general memories or create project-specific banks.
|
||||
|
||||
### Store a memory
|
||||
|
||||
Use `memory retain` to store important facts, preferences, decisions, or context:
|
||||
Use `memory retain` to store what you learn:
|
||||
|
||||
```bash
|
||||
uvx hindsight-embed memory retain default "User prefers dark mode for all UIs"
|
||||
uvx hindsight-embed memory retain default "Project uses Python 3.11 with FastAPI" --context work
|
||||
uvx hindsight-embed memory retain myproject "API uses JWT authentication"
|
||||
uvx hindsight-embed memory retain default "User prefers TypeScript with strict mode"
|
||||
uvx hindsight-embed memory retain default "Running tests requires NODE_ENV=test" --context procedures
|
||||
uvx hindsight-embed memory retain default "Build failed when using Node 18, works with Node 20" --context learnings
|
||||
```
|
||||
|
||||
### Recall memories
|
||||
|
||||
Use `memory recall` to search for relevant memories before starting tasks:
|
||||
Use `memory recall` BEFORE starting tasks to get relevant context:
|
||||
|
||||
```bash
|
||||
uvx hindsight-embed memory recall default "What are the user'"'"'s UI preferences?"
|
||||
uvx hindsight-embed memory recall default "What tech stack does this project use?"
|
||||
uvx hindsight-embed memory recall default "user preferences for this project"
|
||||
uvx hindsight-embed memory recall default "what issues have we encountered before"
|
||||
```
|
||||
|
||||
### Reflect on memories
|
||||
|
||||
Use `memory reflect` for contextual answers that synthesize multiple memories:
|
||||
Use `memory reflect` to synthesize context:
|
||||
|
||||
```bash
|
||||
uvx hindsight-embed memory reflect default "How should I set up the dev environment?"
|
||||
uvx hindsight-embed memory reflect default "How should I approach this task based on past experience?"
|
||||
```
|
||||
|
||||
### Other commands
|
||||
## IMPORTANT: When to Store Memories
|
||||
|
||||
```bash
|
||||
uvx hindsight-embed bank list # List all memory banks
|
||||
uvx hindsight-embed daemon status # Check daemon status
|
||||
uvx hindsight-embed --help # Full CLI help
|
||||
```
|
||||
**Always store** after you learn something valuable:
|
||||
|
||||
## When to Use
|
||||
### User Preferences
|
||||
- Coding style (indentation, naming conventions, language preferences)
|
||||
- Tool preferences (editors, linters, formatters)
|
||||
- Communication preferences
|
||||
- Project conventions
|
||||
|
||||
### Store memories when you learn:
|
||||
- User preferences (coding style, tools, UI preferences)
|
||||
- Project context (tech stack, architecture decisions)
|
||||
- Personal information the user shares (name, role, company)
|
||||
- Important decisions or outcomes
|
||||
### Procedure Outcomes
|
||||
- Steps that successfully completed a task
|
||||
- Commands that worked (or failed) and why
|
||||
- Workarounds discovered
|
||||
- Configuration that resolved issues
|
||||
|
||||
### Recall memories when:
|
||||
- Starting a new task (get relevant context first)
|
||||
- Making decisions that should consider user preferences
|
||||
- Working on a project where past context would help
|
||||
### Learnings from Tasks
|
||||
- Bugs encountered and their solutions
|
||||
- Performance optimizations that worked
|
||||
- Architecture decisions and rationale
|
||||
- Dependencies or version requirements
|
||||
|
||||
## IMPORTANT: When to Recall Memories
|
||||
|
||||
**Always recall** before:
|
||||
- Starting any non-trivial task
|
||||
- Making decisions about implementation
|
||||
- Suggesting tools, libraries, or approaches
|
||||
- Writing code in a new area of the project
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Be specific**: Store "User prefers 2-space indentation" not "User has preferences"
|
||||
2. **Recall first**: Before starting tasks, recall relevant context
|
||||
3. **Use context tags**: Organize with `--context` (work, personal, preferences)
|
||||
4. **Use project banks**: Create separate banks for different projects
|
||||
1. **Store immediately**: When you discover something, store it right away
|
||||
2. **Be specific**: Store "npm test requires --experimental-vm-modules flag" not "tests need a flag"
|
||||
3. **Include outcomes**: Store what worked AND what did not work
|
||||
4. **Recall first**: Always check for relevant context before starting work
|
||||
'
|
||||
|
||||
# Get skills directory for app (bash 3.x compatible)
|
||||
|
||||
@@ -3269,9 +3269,28 @@
|
||||
],
|
||||
"title": "Context"
|
||||
},
|
||||
"max_tokens": {
|
||||
"type": "integer",
|
||||
"title": "Max Tokens",
|
||||
"description": "Maximum tokens for the response",
|
||||
"default": 4096
|
||||
},
|
||||
"include": {
|
||||
"$ref": "#/components/schemas/ReflectIncludeOptions",
|
||||
"description": "Options for including additional data (disabled by default)"
|
||||
},
|
||||
"response_schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Response Schema",
|
||||
"description": "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."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -3286,7 +3305,26 @@
|
||||
"include": {
|
||||
"facts": {}
|
||||
},
|
||||
"query": "What do you think about artificial intelligence?"
|
||||
"max_tokens": 4096,
|
||||
"query": "What do you think about artificial intelligence?",
|
||||
"response_schema": {
|
||||
"properties": {
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"key_points": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"summary",
|
||||
"key_points"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReflectResponse": {
|
||||
@@ -3302,6 +3340,19 @@
|
||||
"type": "array",
|
||||
"title": "Based On",
|
||||
"default": []
|
||||
},
|
||||
"structured_output": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Structured Output",
|
||||
"description": "Structured output parsed according to the request's response_schema. Only present when response_schema was provided in the request."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -3323,6 +3374,13 @@
|
||||
"type": "experience"
|
||||
}
|
||||
],
|
||||
"structured_output": {
|
||||
"key_points": [
|
||||
"Used in healthcare",
|
||||
"Discussed recently"
|
||||
],
|
||||
"summary": "AI is transformative"
|
||||
},
|
||||
"text": "Based on my understanding, AI is a transformative technology..."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -70,7 +70,7 @@ class Server:
|
||||
|
||||
Args:
|
||||
db_url: Database URL. Use "pg0" for embedded PostgreSQL.
|
||||
llm_provider: LLM provider ("groq", "openai", "ollama")
|
||||
llm_provider: LLM provider ("groq", "openai", "ollama", "gemini", "anthropic", "lmstudio")
|
||||
llm_api_key: API key for the LLM provider
|
||||
llm_model: Model name to use
|
||||
llm_base_url: Optional custom base URL for LLM API
|
||||
@@ -236,7 +236,7 @@ def start_server(
|
||||
|
||||
Args:
|
||||
db_url: Database URL. Use "pg0" for embedded PostgreSQL.
|
||||
llm_provider: LLM provider ("groq", "openai", "ollama")
|
||||
llm_provider: LLM provider ("groq", "openai", "ollama", "gemini", "anthropic", "lmstudio")
|
||||
llm_api_key: API key for the LLM provider
|
||||
llm_model: Model name to use
|
||||
llm_base_url: Optional custom base URL for LLM API
|
||||
|
||||
+59
-1
@@ -3269,9 +3269,28 @@
|
||||
],
|
||||
"title": "Context"
|
||||
},
|
||||
"max_tokens": {
|
||||
"type": "integer",
|
||||
"title": "Max Tokens",
|
||||
"description": "Maximum tokens for the response",
|
||||
"default": 4096
|
||||
},
|
||||
"include": {
|
||||
"$ref": "#/components/schemas/ReflectIncludeOptions",
|
||||
"description": "Options for including additional data (disabled by default)"
|
||||
},
|
||||
"response_schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Response Schema",
|
||||
"description": "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."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -3286,7 +3305,26 @@
|
||||
"include": {
|
||||
"facts": {}
|
||||
},
|
||||
"query": "What do you think about artificial intelligence?"
|
||||
"max_tokens": 4096,
|
||||
"query": "What do you think about artificial intelligence?",
|
||||
"response_schema": {
|
||||
"properties": {
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"key_points": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"summary",
|
||||
"key_points"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"ReflectResponse": {
|
||||
@@ -3302,6 +3340,19 @@
|
||||
"type": "array",
|
||||
"title": "Based On",
|
||||
"default": []
|
||||
},
|
||||
"structured_output": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Structured Output",
|
||||
"description": "Structured output parsed according to the request's response_schema. Only present when response_schema was provided in the request."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -3323,6 +3374,13 @@
|
||||
"type": "experience"
|
||||
}
|
||||
],
|
||||
"structured_output": {
|
||||
"key_points": [
|
||||
"Used in healthcare",
|
||||
"Discussed recently"
|
||||
],
|
||||
"summary": "AI is transformative"
|
||||
},
|
||||
"text": "Based on my understanding, AI is a transformative technology..."
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -13,7 +13,7 @@
|
||||
},
|
||||
"hindsight-clients/typescript": {
|
||||
"name": "@vectorize-io/hindsight-client",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.16",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@hey-api/openapi-ts": "^0.88.0",
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"hindsight-control-plane": {
|
||||
"name": "@vectorize-io/hindsight-control-plane",
|
||||
"version": "0.1.14",
|
||||
"version": "0.1.16",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
|
||||
@@ -31,6 +31,11 @@ run_task() {
|
||||
NAMES+=("$name")
|
||||
}
|
||||
|
||||
echo " Syncing Python dependencies..."
|
||||
# Run uv sync first to avoid race conditions when multiple uv run commands
|
||||
# try to reinstall local packages in parallel (e.g., after version bump)
|
||||
uv sync --quiet
|
||||
|
||||
echo " Running lints in parallel..."
|
||||
|
||||
# Node/TypeScript tasks
|
||||
|
||||
Reference in New Issue
Block a user