Compare commits
29
Commits
embed-issues
...
vertex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4017b3ad8e | ||
|
|
eef8f16ead | ||
|
|
26d773e8d1 | ||
|
|
42ff43a712 | ||
|
|
fea6f67ac5 | ||
|
|
9c95a1ac1d | ||
|
|
15540075b2 | ||
|
|
3f211f0729 | ||
|
|
8781c9fbfe | ||
|
|
12e9a3d305 | ||
|
|
c16ccc2c22 | ||
|
|
a7c094d436 | ||
|
|
b8f06a09fb | ||
|
|
b43ef98686 | ||
|
|
f17703fb37 | ||
|
|
cfcc23c152 | ||
|
|
7300d5be4b | ||
|
|
81c82d9b93 | ||
|
|
7551e65e55 | ||
|
|
94cc0a1270 | ||
|
|
67c47881cb | ||
|
|
2b72e1fd68 | ||
|
|
d2b797fff8 | ||
|
|
fccbdfef16 | ||
|
|
20f2b92069 | ||
|
|
1bf90358c3 | ||
|
|
2118d0a7cd | ||
|
|
e5fc6eedb6 | ||
|
|
bb0e0316a7 |
+9
-1
@@ -2,7 +2,7 @@
|
||||
# Copy this file to .env and fill in your values
|
||||
|
||||
# LLM Configuration (Required)
|
||||
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio
|
||||
# Supported providers: openai, groq, ollama, gemini, anthropic, lmstudio, vertexai
|
||||
HINDSIGHT_API_LLM_PROVIDER=openai
|
||||
HINDSIGHT_API_LLM_API_KEY=your-api-key-here
|
||||
HINDSIGHT_API_LLM_MODEL=o3-mini
|
||||
@@ -13,6 +13,13 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
|
||||
# HINDSIGHT_API_LLM_API_KEY=your-anthropic-api-key
|
||||
# HINDSIGHT_API_LLM_MODEL=claude-sonnet-4-20250514
|
||||
|
||||
# Example: Google Vertex AI configuration
|
||||
# HINDSIGHT_API_LLM_PROVIDER=vertexai
|
||||
# HINDSIGHT_API_LLM_MODEL=google/gemini-2.0-flash-001
|
||||
# HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID=your-gcp-project-id
|
||||
# HINDSIGHT_API_LLM_VERTEXAI_REGION=us-central1
|
||||
# HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY=/path/to/service-account-key.json # Optional, uses ADC if not set
|
||||
|
||||
# Example: LM Studio local configuration (Qwen 2.5 32B recommended)
|
||||
# HINDSIGHT_API_LLM_PROVIDER=lmstudio
|
||||
# HINDSIGHT_API_LLM_API_KEY=lmstudio
|
||||
@@ -26,6 +33,7 @@ HINDSIGHT_API_LOG_LEVEL=info
|
||||
|
||||
# Database (Optional - uses embedded pg0 by default)
|
||||
# HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db
|
||||
# HINDSIGHT_API_DATABASE_SCHEMA=public # PostgreSQL schema name (default: public)
|
||||
|
||||
# Embeddings Configuration (Optional - uses local by default)
|
||||
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
|
||||
|
||||
@@ -139,6 +139,55 @@ jobs:
|
||||
path: hindsight-clients/typescript/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-moltbot-integration:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/moltbot
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/moltbot
|
||||
run: npm run build
|
||||
|
||||
- name: Publish to npm
|
||||
working-directory: ./hindsight-integrations/moltbot
|
||||
run: |
|
||||
set +e
|
||||
OUTPUT=$(npm publish --access public 2>&1)
|
||||
EXIT_CODE=$?
|
||||
echo "$OUTPUT"
|
||||
if [ $EXIT_CODE -ne 0 ]; then
|
||||
if echo "$OUTPUT" | grep -q "cannot publish over"; then
|
||||
echo "Package version already published, skipping..."
|
||||
exit 0
|
||||
fi
|
||||
exit $EXIT_CODE
|
||||
fi
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
- name: Pack for GitHub release
|
||||
working-directory: ./hindsight-integrations/moltbot
|
||||
run: npm pack
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: moltbot-integration
|
||||
path: hindsight-integrations/moltbot/*.tgz
|
||||
retention-days: 1
|
||||
|
||||
release-control-plane:
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm
|
||||
@@ -366,7 +415,7 @@ jobs:
|
||||
|
||||
create-github-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [release-python-packages, release-typescript-client, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
|
||||
needs: [release-python-packages, release-typescript-client, release-moltbot-integration, release-control-plane, release-rust-cli, release-docker-images, release-helm-chart]
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -389,6 +438,12 @@ jobs:
|
||||
name: typescript-client
|
||||
path: ./artifacts/typescript-client
|
||||
|
||||
- name: Download Moltbot Integration
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: moltbot-integration
|
||||
path: ./artifacts/moltbot-integration
|
||||
|
||||
- name: Download Control Plane
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -430,6 +485,8 @@ jobs:
|
||||
cp artifacts/python-packages/hindsight-embed/dist/* release-assets/ || true
|
||||
# TypeScript client
|
||||
cp artifacts/typescript-client/*.tgz release-assets/ || true
|
||||
# Moltbot Integration
|
||||
cp artifacts/moltbot-integration/*.tgz release-assets/ || true
|
||||
# Control Plane
|
||||
cp artifacts/control-plane/*.tgz release-assets/ || true
|
||||
# Rust CLI binaries
|
||||
|
||||
@@ -82,6 +82,29 @@ jobs:
|
||||
- name: Build TypeScript client
|
||||
run: npm run build --workspace=hindsight-clients/typescript
|
||||
|
||||
build-moltbot-integration:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/moltbot
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/moltbot
|
||||
run: npm test
|
||||
|
||||
- name: Build
|
||||
working-directory: ./hindsight-integrations/moltbot
|
||||
run: npm run build
|
||||
|
||||
build-control-plane:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
+4
-1
@@ -45,9 +45,12 @@ hindsight-docs/static/llms-full.txt
|
||||
|
||||
hindsight-dev/benchmarks/locomo/results/
|
||||
hindsight-dev/benchmarks/longmemeval/results/
|
||||
hindsight-dev/benchmarks/consolidation/results/
|
||||
benchmarks/results/
|
||||
hindsight-cli/target
|
||||
hindsight-clients/rust/target
|
||||
.claude
|
||||
whats-next.md
|
||||
TASK.md
|
||||
CHANGELOG.md
|
||||
# Changelog is now tracked in hindsight-docs/src/pages/changelog.md
|
||||
# CHANGELOG.md
|
||||
@@ -1,6 +1,6 @@
|
||||
<div align="center">
|
||||
|
||||

|
||||

|
||||
|
||||
[Documentation](https://hindsight.vectorize.io) • [Paper](https://arxiv.org/abs/2512.12818) • [Cookbook](https://hindsight.vectorize.io/cookbook) • [Hindsight Cloud](https://vectorize.io/hindsight/cloud)
|
||||
|
||||
@@ -17,55 +17,31 @@
|
||||
|
||||
## What is Hindsight?
|
||||
|
||||
Hindsight™ is an agent memory system built to create smarter agents that learn over time. It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph and delivers state-of-the-art performance on long term memory tasks.
|
||||
Hindsight™ is an agent memory system built to create smarter agents that learn over time. Most agent memory systems focus on recalling conversation history. Hindsight is focused on making agents that learn, not just remember.
|
||||
|
||||
Hindsight addresses common challenges that have frustrated AI engineers building agents to automate tasks and assist users with conversational interfaces. Many of these challenges stem directly from a lack of memory.
|
||||
|
||||
- **Inconsistency:** Agents complete tasks successfully one time, then fail when asked to complete the same task again. Memory gives the agent a mechanism to remember what worked and what didn't and to use that information to reduce errors and improve consistency.
|
||||
- **Hallucinations:** Long term memory can be seeded with external knowledge to ground agent behavior in reliable sources to augment training data.
|
||||
- **Cognitive Overload:** As workflows get complex, retrievals, tool calls, user messages and agent responses can grow to fill the context window leading to context rot. Short term memory optimization allows agents to reduce tokens and focus context by removing irrelevant details.
|
||||
<video src="https://github.com/user-attachments/assets/923b798d-3581-4897-bb62-9cfa5a931682" controls></video>
|
||||
|
||||
## How is Hindsight Different From Other Memory Systems?
|
||||
|
||||

|
||||
|
||||
Most agent memory implementation rely on basic vector search or sometimes use a knowledge graph. Hindsight uses biomimetic data structures to organize agent memories in a way that is more like how human memory works:
|
||||
|
||||
- **World:** Facts about the world ("The stove gets hot")
|
||||
- **Experiences:** Agent's own experiences ("I touched the stove and it really hurt")
|
||||
- **Opinion:** Beliefs with confidence scores ("I shouldn't touch the stove again" - .99 confidence)
|
||||
- **Observation:** Complex mental models derived by reflecting on facts and experiences ("Curling irons, ovens, and fire are also hot. I shouldn't touch those either.")
|
||||
|
||||
Memories in Hindsight are stored in banks (i.e. memory banks). When memories are added to Hindsight, they are pushed into either the world facts or experiences memory pathway. They are then represented as a combination of entities, relationships, and time series with sparse/dense vector representations to aid in later recall.
|
||||
|
||||
Hindsight provides three simple methods to interact with the system:
|
||||
|
||||
- **Retain:** Provide information to Hindsight that you want it to remember
|
||||
- **Recall:** Retrieve memories from Hindsight
|
||||
- **Reflect:** Reflect on memories and experiences to generate new observations and insights from existing memories.
|
||||
|
||||
### Agent Memory That Learns
|
||||
|
||||
A key goal of Hindsight is to build agent memory that enables agents to learn and improve over time. This is the role of the `reflect` operation which provides the agent to form broader opinions and observations over time.
|
||||
|
||||
For example, imagine a product support agent that is helping a user troubleshoot a problem. It uses a `search-documentation` tool it found on an MCP server. Later in the conversation, the agent discovers that the documentation returned from the tool wasn't for the product the user was asking about. The agent now has an experience in its memory bank. And just like humans, we want that agent to learn from its experience.
|
||||
|
||||
As the agent gains more experiences, `reflect` allows the agent to form observations about what worked, what didn't, and what to do differently the next time it encounters a similar task.
|
||||
|
||||
---
|
||||
It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph and delivers state-of-the-art performance on long term memory tasks.
|
||||
|
||||
## Memory Performance & Accuracy
|
||||
|
||||
Hindsight has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational
|
||||
AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of December 2025 is shown here:
|
||||
Hindsight is the most accurate agent memory system ever tested according to benchmark performance. It has achieved state-of-the-art performance on the LongMemEval benchmark, widely used to assess memory system performance across a variety of conversational AI scenarios. The current reported performance of Hindsight and other agent memory solutions as of January 2026 is shown here:
|
||||
|
||||

|
||||
|
||||
The benchmark performance data for Hindsight and GPT-4o (full context) have been reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors.
|
||||
The benchmark performance data for Hindsight has been independently reproduced by research collaborators at the Virginia Tech [Sanghani Center for Artificial Intelligence and Data Analytics](https://sanghani.cs.vt.edu/) and The Washington Post. Other scores are self-reported by software vendors.
|
||||
|
||||
A thorough examination of the techniques implemented in Hindsight and detailed breakdowns of benchmark performance are [available on arXiv](https://arxiv.org/abs/2512.12818). This research is currently being prepared for conference submission and the wider peer review process.
|
||||
Hindsight is being used in production at Fortune 500 enterprises and by a growing number of AI startups.
|
||||
|
||||
## Adding Hindsight to Your AI Agents
|
||||
|
||||
The easiest way use Hindsight with an existing agent is with the LLM Wrapper. You can add memory to your agent with 2 lines of code. That will swap your current LLM client out with the Hindsight wrapper. After that, memories will be stored and retrieved automatically as you make LLM calls.
|
||||
|
||||
If you need more control over how and when your agent stores and recalls memories, there's also a simple API you can integrate with using the SDKs or directly via HTTP.
|
||||
|
||||

|
||||
|
||||
The benchmark results from this research can be inspected in our [visual benchmark explorer](https://hindsight-benchmarks.vercel.app). As additional improvements are made to Hindsight, new benchmark data will be available for review using this same tool.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -148,8 +124,45 @@ await client.recall('my-bank', 'What does Alice like?');
|
||||
|
||||
---
|
||||
|
||||
## Use Cases
|
||||
|
||||
|
||||
Hindsight is built to support conversational AI agents as well as agents that are intended to perform tasks autonomously. The ideal use case for Hindsight are agents that require a blend of these features such as AI employees that need to handle open-ended tasks, change behavior based on user feedback, and learn to perform complex tasks to automate work at a level that approximates a human work. Hindsight can be used with simple AI workflows like those built with n8n and other similar tools, but may be overkill for such applications.
|
||||
|
||||
### Per-User Memories and Chat History
|
||||
|
||||
One of the simpler use cases you can use Hindsight for is to personalize AI chatbots and other conversational agents by storing and recalling memories associated with individual users.
|
||||
|
||||
The requirements for this use case usually look something like this:
|
||||
|
||||

|
||||
|
||||
<video src="https://github.com/user-attachments/assets/4805e8e1-e7d1-47c6-a4f8-2344a5ec8906" controls></video>
|
||||
|
||||
Satisfying these requirements in Hindsight is straightforward. When new user inputs and tool calls are ingested into Hindsight using the retain operation, custom metadata can be used to enrich the new memories. Metadata provides a convenient way to isolate memories that need to be restricted to a given user. Once these are fed into the retain operation, any raw memories and mental models that get created can be filtered when retrieving relevant memories.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Architecture & Operations
|
||||
|
||||

|
||||
|
||||
Most agent memory implementation rely on basic vector search or sometimes use a knowledge graph. Hindsight uses biomimetic data structures to organize agent memories in a way that is more like how human memory works:
|
||||
|
||||
- **World:** Facts about the world ("The stove gets hot")
|
||||
- **Experiences:** Agent's own experiences ("I touched the stove and it really hurt")
|
||||
- **Mental Models:** Learned understanding of the agent's world formed by reflecting on raw memories and experiences.
|
||||
|
||||
Memories in Hindsight are stored in banks (i.e. memory banks). When memories are added to Hindsight, they are pushed into either the world facts or experiences memory pathway. They are then represented as a combination of entities, relationships, and time series with sparse/dense vector representations to aid in later recall.
|
||||
|
||||
Hindsight provides three simple methods to interact with the system:
|
||||
|
||||
- **Retain:** Provide information to Hindsight that you want it to remember
|
||||
- **Recall:** Retrieve memories from Hindsight
|
||||
- **Reflect:** Reflect on memories and experiences to generate new observations and insights from existing memories.
|
||||
|
||||
### Retain
|
||||
|
||||
The `retain` operation is used to push new memories into Hindsight. It tells Hindsight to _retain_ the information you pass in as an input.
|
||||
@@ -208,7 +221,7 @@ The final output is trimmed as needed to fit within the token limit.
|
||||
|
||||
### Reflect
|
||||
|
||||
The reflect operation is used to perform a more thorough analysis of existing memories. This allows the agent to form new connections between memories which are then persisted as opinions and/or observations. When building agents, the reflect operation is a key capability to enable the agent to learn from its experiences.
|
||||
The reflect operation is used to perform a more thorough analysis of existing memories. This allows the agent to form new connections between memories and build a more thorough understanding of its world.
|
||||
|
||||
For example, the `reflect` operation can be used to support use cases such as:
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ apiVersion: v2
|
||||
name: hindsight
|
||||
description: Hindsight helm chart
|
||||
type: application
|
||||
version: 0.3.0
|
||||
appVersion: "0.3.0"
|
||||
version: 0.4.2
|
||||
appVersion: "0.4.2"
|
||||
keywords:
|
||||
- ai
|
||||
- memory
|
||||
|
||||
@@ -46,4 +46,4 @@ __all__ = [
|
||||
"RemoteTEICrossEncoder",
|
||||
"LLMConfig",
|
||||
]
|
||||
__version__ = "0.1.0"
|
||||
__version__ = "0.4.2"
|
||||
|
||||
@@ -1323,7 +1323,7 @@ class VersionResponse(BaseModel):
|
||||
model_config = ConfigDict(
|
||||
json_schema_extra={
|
||||
"example": {
|
||||
"api_version": "1.0.0",
|
||||
"api_version": "0.4.0",
|
||||
"features": {
|
||||
"observations": False,
|
||||
"mcp": True,
|
||||
@@ -1567,11 +1567,12 @@ def _register_routes(app: FastAPI):
|
||||
Returns version info and feature flags that can be used by clients
|
||||
to determine which capabilities are available.
|
||||
"""
|
||||
from hindsight_api import __version__
|
||||
from hindsight_api.config import get_config
|
||||
|
||||
config = get_config()
|
||||
return VersionResponse(
|
||||
api_version="1.0.0",
|
||||
api_version=__version__,
|
||||
features=FeaturesInfo(
|
||||
observations=config.enable_observations,
|
||||
mcp=config.mcp_enabled,
|
||||
|
||||
@@ -20,11 +20,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Environment variable names
|
||||
ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL"
|
||||
ENV_DATABASE_SCHEMA = "HINDSIGHT_API_DATABASE_SCHEMA"
|
||||
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_MAX_RETRIES = "HINDSIGHT_API_LLM_MAX_RETRIES"
|
||||
ENV_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_LLM_INITIAL_BACKOFF"
|
||||
ENV_LLM_MAX_BACKOFF = "HINDSIGHT_API_LLM_MAX_BACKOFF"
|
||||
ENV_LLM_TIMEOUT = "HINDSIGHT_API_LLM_TIMEOUT"
|
||||
ENV_LLM_GROQ_SERVICE_TIER = "HINDSIGHT_API_LLM_GROQ_SERVICE_TIER"
|
||||
|
||||
@@ -33,19 +37,35 @@ ENV_RETAIN_LLM_PROVIDER = "HINDSIGHT_API_RETAIN_LLM_PROVIDER"
|
||||
ENV_RETAIN_LLM_API_KEY = "HINDSIGHT_API_RETAIN_LLM_API_KEY"
|
||||
ENV_RETAIN_LLM_MODEL = "HINDSIGHT_API_RETAIN_LLM_MODEL"
|
||||
ENV_RETAIN_LLM_BASE_URL = "HINDSIGHT_API_RETAIN_LLM_BASE_URL"
|
||||
ENV_RETAIN_LLM_MAX_CONCURRENT = "HINDSIGHT_API_RETAIN_LLM_MAX_CONCURRENT"
|
||||
ENV_RETAIN_LLM_MAX_RETRIES = "HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES"
|
||||
ENV_RETAIN_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_RETAIN_LLM_INITIAL_BACKOFF"
|
||||
ENV_RETAIN_LLM_MAX_BACKOFF = "HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF"
|
||||
ENV_RETAIN_LLM_TIMEOUT = "HINDSIGHT_API_RETAIN_LLM_TIMEOUT"
|
||||
|
||||
ENV_REFLECT_LLM_PROVIDER = "HINDSIGHT_API_REFLECT_LLM_PROVIDER"
|
||||
ENV_REFLECT_LLM_API_KEY = "HINDSIGHT_API_REFLECT_LLM_API_KEY"
|
||||
ENV_REFLECT_LLM_MODEL = "HINDSIGHT_API_REFLECT_LLM_MODEL"
|
||||
ENV_REFLECT_LLM_BASE_URL = "HINDSIGHT_API_REFLECT_LLM_BASE_URL"
|
||||
ENV_REFLECT_LLM_MAX_CONCURRENT = "HINDSIGHT_API_REFLECT_LLM_MAX_CONCURRENT"
|
||||
ENV_REFLECT_LLM_MAX_RETRIES = "HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES"
|
||||
ENV_REFLECT_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_REFLECT_LLM_INITIAL_BACKOFF"
|
||||
ENV_REFLECT_LLM_MAX_BACKOFF = "HINDSIGHT_API_REFLECT_LLM_MAX_BACKOFF"
|
||||
ENV_REFLECT_LLM_TIMEOUT = "HINDSIGHT_API_REFLECT_LLM_TIMEOUT"
|
||||
|
||||
ENV_CONSOLIDATION_LLM_PROVIDER = "HINDSIGHT_API_CONSOLIDATION_LLM_PROVIDER"
|
||||
ENV_CONSOLIDATION_LLM_API_KEY = "HINDSIGHT_API_CONSOLIDATION_LLM_API_KEY"
|
||||
ENV_CONSOLIDATION_LLM_MODEL = "HINDSIGHT_API_CONSOLIDATION_LLM_MODEL"
|
||||
ENV_CONSOLIDATION_LLM_BASE_URL = "HINDSIGHT_API_CONSOLIDATION_LLM_BASE_URL"
|
||||
ENV_CONSOLIDATION_LLM_MAX_CONCURRENT = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_CONCURRENT"
|
||||
ENV_CONSOLIDATION_LLM_MAX_RETRIES = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_RETRIES"
|
||||
ENV_CONSOLIDATION_LLM_INITIAL_BACKOFF = "HINDSIGHT_API_CONSOLIDATION_LLM_INITIAL_BACKOFF"
|
||||
ENV_CONSOLIDATION_LLM_MAX_BACKOFF = "HINDSIGHT_API_CONSOLIDATION_LLM_MAX_BACKOFF"
|
||||
ENV_CONSOLIDATION_LLM_TIMEOUT = "HINDSIGHT_API_CONSOLIDATION_LLM_TIMEOUT"
|
||||
|
||||
ENV_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
|
||||
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
|
||||
ENV_EMBEDDINGS_LOCAL_FORCE_CPU = "HINDSIGHT_API_EMBEDDINGS_LOCAL_FORCE_CPU"
|
||||
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
|
||||
ENV_EMBEDDINGS_OPENAI_API_KEY = "HINDSIGHT_API_EMBEDDINGS_OPENAI_API_KEY"
|
||||
ENV_EMBEDDINGS_OPENAI_MODEL = "HINDSIGHT_API_EMBEDDINGS_OPENAI_MODEL"
|
||||
@@ -65,6 +85,7 @@ ENV_RERANKER_LITELLM_MODEL = "HINDSIGHT_API_RERANKER_LITELLM_MODEL"
|
||||
|
||||
ENV_RERANKER_PROVIDER = "HINDSIGHT_API_RERANKER_PROVIDER"
|
||||
ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
|
||||
ENV_RERANKER_LOCAL_FORCE_CPU = "HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU"
|
||||
ENV_RERANKER_LOCAL_MAX_CONCURRENT = "HINDSIGHT_API_RERANKER_LOCAL_MAX_CONCURRENT"
|
||||
ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL"
|
||||
ENV_RERANKER_TEI_BATCH_SIZE = "HINDSIGHT_API_RERANKER_TEI_BATCH_SIZE"
|
||||
@@ -87,6 +108,11 @@ ENV_MCP_LOCAL_BANK_ID = "HINDSIGHT_API_MCP_LOCAL_BANK_ID"
|
||||
ENV_MCP_INSTRUCTIONS = "HINDSIGHT_API_MCP_INSTRUCTIONS"
|
||||
ENV_MENTAL_MODEL_REFRESH_CONCURRENCY = "HINDSIGHT_API_MENTAL_MODEL_REFRESH_CONCURRENCY"
|
||||
|
||||
# Vertex AI configuration
|
||||
ENV_LLM_VERTEXAI_PROJECT_ID = "HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"
|
||||
ENV_LLM_VERTEXAI_REGION = "HINDSIGHT_API_LLM_VERTEXAI_REGION"
|
||||
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = "HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY"
|
||||
|
||||
# Retain settings
|
||||
ENV_RETAIN_MAX_COMPLETION_TOKENS = "HINDSIGHT_API_RETAIN_MAX_COMPLETION_TOKENS"
|
||||
ENV_RETAIN_CHUNK_SIZE = "HINDSIGHT_API_RETAIN_CHUNK_SIZE"
|
||||
@@ -98,6 +124,7 @@ ENV_RETAIN_OBSERVATIONS_ASYNC = "HINDSIGHT_API_RETAIN_OBSERVATIONS_ASYNC"
|
||||
# Observations settings (consolidated knowledge from facts)
|
||||
ENV_ENABLE_OBSERVATIONS = "HINDSIGHT_API_ENABLE_OBSERVATIONS"
|
||||
ENV_CONSOLIDATION_BATCH_SIZE = "HINDSIGHT_API_CONSOLIDATION_BATCH_SIZE"
|
||||
ENV_CONSOLIDATION_MAX_TOKENS = "HINDSIGHT_API_CONSOLIDATION_MAX_TOKENS"
|
||||
|
||||
# Optimization flags
|
||||
ENV_SKIP_LLM_VERIFICATION = "HINDSIGHT_API_SKIP_LLM_VERIFICATION"
|
||||
@@ -125,18 +152,29 @@ ENV_REFLECT_MAX_ITERATIONS = "HINDSIGHT_API_REFLECT_MAX_ITERATIONS"
|
||||
|
||||
# Default values
|
||||
DEFAULT_DATABASE_URL = "pg0"
|
||||
DEFAULT_DATABASE_SCHEMA = "public"
|
||||
DEFAULT_LLM_PROVIDER = "openai"
|
||||
DEFAULT_LLM_MODEL = "gpt-5-mini"
|
||||
DEFAULT_LLM_MAX_CONCURRENT = 32
|
||||
DEFAULT_LLM_MAX_RETRIES = 10 # Max retry attempts for LLM API calls
|
||||
DEFAULT_LLM_INITIAL_BACKOFF = 1.0 # Initial backoff in seconds for retry exponential backoff
|
||||
DEFAULT_LLM_MAX_BACKOFF = 60.0 # Max backoff cap in seconds for retry exponential backoff
|
||||
DEFAULT_LLM_TIMEOUT = 120.0 # seconds
|
||||
|
||||
# Vertex AI defaults
|
||||
DEFAULT_LLM_VERTEXAI_PROJECT_ID = None # Required for Vertex AI
|
||||
DEFAULT_LLM_VERTEXAI_REGION = "us-central1"
|
||||
DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY = None # Optional, uses ADC if not set
|
||||
|
||||
DEFAULT_EMBEDDINGS_PROVIDER = "local"
|
||||
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
|
||||
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU = False # Force CPU mode for local embeddings (avoids MPS/XPC issues on macOS)
|
||||
DEFAULT_EMBEDDINGS_OPENAI_MODEL = "text-embedding-3-small"
|
||||
DEFAULT_EMBEDDING_DIMENSION = 384
|
||||
|
||||
DEFAULT_RERANKER_PROVIDER = "local"
|
||||
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
|
||||
DEFAULT_RERANKER_LOCAL_FORCE_CPU = False # Force CPU mode for local reranker (avoids MPS/XPC issues on macOS)
|
||||
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT = 4 # Limit concurrent CPU-bound reranking to prevent thrashing
|
||||
DEFAULT_RERANKER_TEI_BATCH_SIZE = 128
|
||||
DEFAULT_RERANKER_TEI_MAX_CONCURRENT = 8
|
||||
@@ -177,6 +215,7 @@ DEFAULT_RETAIN_OBSERVATIONS_ASYNC = False # Run observation generation async (a
|
||||
# Observations defaults (consolidated knowledge from facts)
|
||||
DEFAULT_ENABLE_OBSERVATIONS = True # Observations enabled by default
|
||||
DEFAULT_CONSOLIDATION_BATCH_SIZE = 50 # Memories to load per batch (internal memory optimization)
|
||||
DEFAULT_CONSOLIDATION_MAX_TOKENS = 1024 # Max tokens for recall when finding related observations
|
||||
|
||||
# Database migrations
|
||||
DEFAULT_RUN_MIGRATIONS_ON_STARTUP = True
|
||||
@@ -270,6 +309,7 @@ class HindsightConfig:
|
||||
|
||||
# Database
|
||||
database_url: str
|
||||
database_schema: str
|
||||
|
||||
# LLM (default, used as fallback for per-operation config)
|
||||
llm_provider: str
|
||||
@@ -277,27 +317,51 @@ class HindsightConfig:
|
||||
llm_model: str
|
||||
llm_base_url: str | None
|
||||
llm_max_concurrent: int
|
||||
llm_max_retries: int
|
||||
llm_initial_backoff: float
|
||||
llm_max_backoff: float
|
||||
llm_timeout: float
|
||||
|
||||
# Vertex AI configuration
|
||||
llm_vertexai_project_id: str | None
|
||||
llm_vertexai_region: str
|
||||
llm_vertexai_service_account_key: str | None
|
||||
|
||||
# Per-operation LLM configuration (None = use default LLM config)
|
||||
retain_llm_provider: str | None
|
||||
retain_llm_api_key: str | None
|
||||
retain_llm_model: str | None
|
||||
retain_llm_base_url: str | None
|
||||
retain_llm_max_concurrent: int | None
|
||||
retain_llm_max_retries: int | None
|
||||
retain_llm_initial_backoff: float | None
|
||||
retain_llm_max_backoff: float | None
|
||||
retain_llm_timeout: float | None
|
||||
|
||||
reflect_llm_provider: str | None
|
||||
reflect_llm_api_key: str | None
|
||||
reflect_llm_model: str | None
|
||||
reflect_llm_base_url: str | None
|
||||
reflect_llm_max_concurrent: int | None
|
||||
reflect_llm_max_retries: int | None
|
||||
reflect_llm_initial_backoff: float | None
|
||||
reflect_llm_max_backoff: float | None
|
||||
reflect_llm_timeout: float | None
|
||||
|
||||
consolidation_llm_provider: str | None
|
||||
consolidation_llm_api_key: str | None
|
||||
consolidation_llm_model: str | None
|
||||
consolidation_llm_base_url: str | None
|
||||
consolidation_llm_max_concurrent: int | None
|
||||
consolidation_llm_max_retries: int | None
|
||||
consolidation_llm_initial_backoff: float | None
|
||||
consolidation_llm_max_backoff: float | None
|
||||
consolidation_llm_timeout: float | None
|
||||
|
||||
# Embeddings
|
||||
embeddings_provider: str
|
||||
embeddings_local_model: str
|
||||
embeddings_local_force_cpu: bool
|
||||
embeddings_tei_url: str | None
|
||||
embeddings_openai_base_url: str | None
|
||||
embeddings_cohere_base_url: str | None
|
||||
@@ -305,6 +369,8 @@ class HindsightConfig:
|
||||
# Reranker
|
||||
reranker_provider: str
|
||||
reranker_local_model: str
|
||||
reranker_local_force_cpu: bool
|
||||
reranker_local_max_concurrent: int
|
||||
reranker_tei_url: str | None
|
||||
reranker_tei_batch_size: int
|
||||
reranker_tei_max_concurrent: int
|
||||
@@ -336,6 +402,7 @@ class HindsightConfig:
|
||||
# Observations settings (consolidated knowledge from facts)
|
||||
enable_observations: bool
|
||||
consolidation_batch_size: int
|
||||
consolidation_max_tokens: int
|
||||
|
||||
# Optimization flags
|
||||
skip_llm_verification: bool
|
||||
@@ -367,35 +434,98 @@ class HindsightConfig:
|
||||
return cls(
|
||||
# Database
|
||||
database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL),
|
||||
database_schema=os.getenv(ENV_DATABASE_SCHEMA, DEFAULT_DATABASE_SCHEMA),
|
||||
# LLM
|
||||
llm_provider=os.getenv(ENV_LLM_PROVIDER, DEFAULT_LLM_PROVIDER),
|
||||
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_max_retries=int(os.getenv(ENV_LLM_MAX_RETRIES, str(DEFAULT_LLM_MAX_RETRIES))),
|
||||
llm_initial_backoff=float(os.getenv(ENV_LLM_INITIAL_BACKOFF, str(DEFAULT_LLM_INITIAL_BACKOFF))),
|
||||
llm_max_backoff=float(os.getenv(ENV_LLM_MAX_BACKOFF, str(DEFAULT_LLM_MAX_BACKOFF))),
|
||||
llm_timeout=float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))),
|
||||
# Vertex AI
|
||||
llm_vertexai_project_id=os.getenv(ENV_LLM_VERTEXAI_PROJECT_ID) or DEFAULT_LLM_VERTEXAI_PROJECT_ID,
|
||||
llm_vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION, DEFAULT_LLM_VERTEXAI_REGION),
|
||||
llm_vertexai_service_account_key=os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY)
|
||||
or DEFAULT_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
|
||||
# Per-operation LLM config (None = use default)
|
||||
retain_llm_provider=os.getenv(ENV_RETAIN_LLM_PROVIDER) or None,
|
||||
retain_llm_api_key=os.getenv(ENV_RETAIN_LLM_API_KEY) or None,
|
||||
retain_llm_model=os.getenv(ENV_RETAIN_LLM_MODEL) or None,
|
||||
retain_llm_base_url=os.getenv(ENV_RETAIN_LLM_BASE_URL) or None,
|
||||
retain_llm_max_concurrent=int(os.getenv(ENV_RETAIN_LLM_MAX_CONCURRENT))
|
||||
if os.getenv(ENV_RETAIN_LLM_MAX_CONCURRENT)
|
||||
else None,
|
||||
retain_llm_max_retries=int(os.getenv(ENV_RETAIN_LLM_MAX_RETRIES))
|
||||
if os.getenv(ENV_RETAIN_LLM_MAX_RETRIES)
|
||||
else None,
|
||||
retain_llm_initial_backoff=float(os.getenv(ENV_RETAIN_LLM_INITIAL_BACKOFF))
|
||||
if os.getenv(ENV_RETAIN_LLM_INITIAL_BACKOFF)
|
||||
else None,
|
||||
retain_llm_max_backoff=float(os.getenv(ENV_RETAIN_LLM_MAX_BACKOFF))
|
||||
if os.getenv(ENV_RETAIN_LLM_MAX_BACKOFF)
|
||||
else None,
|
||||
retain_llm_timeout=float(os.getenv(ENV_RETAIN_LLM_TIMEOUT)) if os.getenv(ENV_RETAIN_LLM_TIMEOUT) else None,
|
||||
reflect_llm_provider=os.getenv(ENV_REFLECT_LLM_PROVIDER) or None,
|
||||
reflect_llm_api_key=os.getenv(ENV_REFLECT_LLM_API_KEY) or None,
|
||||
reflect_llm_model=os.getenv(ENV_REFLECT_LLM_MODEL) or None,
|
||||
reflect_llm_base_url=os.getenv(ENV_REFLECT_LLM_BASE_URL) or None,
|
||||
reflect_llm_max_concurrent=int(os.getenv(ENV_REFLECT_LLM_MAX_CONCURRENT))
|
||||
if os.getenv(ENV_REFLECT_LLM_MAX_CONCURRENT)
|
||||
else None,
|
||||
reflect_llm_max_retries=int(os.getenv(ENV_REFLECT_LLM_MAX_RETRIES))
|
||||
if os.getenv(ENV_REFLECT_LLM_MAX_RETRIES)
|
||||
else None,
|
||||
reflect_llm_initial_backoff=float(os.getenv(ENV_REFLECT_LLM_INITIAL_BACKOFF))
|
||||
if os.getenv(ENV_REFLECT_LLM_INITIAL_BACKOFF)
|
||||
else None,
|
||||
reflect_llm_max_backoff=float(os.getenv(ENV_REFLECT_LLM_MAX_BACKOFF))
|
||||
if os.getenv(ENV_REFLECT_LLM_MAX_BACKOFF)
|
||||
else None,
|
||||
reflect_llm_timeout=float(os.getenv(ENV_REFLECT_LLM_TIMEOUT))
|
||||
if os.getenv(ENV_REFLECT_LLM_TIMEOUT)
|
||||
else None,
|
||||
consolidation_llm_provider=os.getenv(ENV_CONSOLIDATION_LLM_PROVIDER) or None,
|
||||
consolidation_llm_api_key=os.getenv(ENV_CONSOLIDATION_LLM_API_KEY) or None,
|
||||
consolidation_llm_model=os.getenv(ENV_CONSOLIDATION_LLM_MODEL) or None,
|
||||
consolidation_llm_base_url=os.getenv(ENV_CONSOLIDATION_LLM_BASE_URL) or None,
|
||||
consolidation_llm_max_concurrent=int(os.getenv(ENV_CONSOLIDATION_LLM_MAX_CONCURRENT))
|
||||
if os.getenv(ENV_CONSOLIDATION_LLM_MAX_CONCURRENT)
|
||||
else None,
|
||||
consolidation_llm_max_retries=int(os.getenv(ENV_CONSOLIDATION_LLM_MAX_RETRIES))
|
||||
if os.getenv(ENV_CONSOLIDATION_LLM_MAX_RETRIES)
|
||||
else None,
|
||||
consolidation_llm_initial_backoff=float(os.getenv(ENV_CONSOLIDATION_LLM_INITIAL_BACKOFF))
|
||||
if os.getenv(ENV_CONSOLIDATION_LLM_INITIAL_BACKOFF)
|
||||
else None,
|
||||
consolidation_llm_max_backoff=float(os.getenv(ENV_CONSOLIDATION_LLM_MAX_BACKOFF))
|
||||
if os.getenv(ENV_CONSOLIDATION_LLM_MAX_BACKOFF)
|
||||
else None,
|
||||
consolidation_llm_timeout=float(os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT))
|
||||
if os.getenv(ENV_CONSOLIDATION_LLM_TIMEOUT)
|
||||
else None,
|
||||
# Embeddings
|
||||
embeddings_provider=os.getenv(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER),
|
||||
embeddings_local_model=os.getenv(ENV_EMBEDDINGS_LOCAL_MODEL, DEFAULT_EMBEDDINGS_LOCAL_MODEL),
|
||||
embeddings_local_force_cpu=os.getenv(
|
||||
ENV_EMBEDDINGS_LOCAL_FORCE_CPU, str(DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU)
|
||||
).lower()
|
||||
in ("true", "1"),
|
||||
embeddings_tei_url=os.getenv(ENV_EMBEDDINGS_TEI_URL),
|
||||
embeddings_openai_base_url=os.getenv(ENV_EMBEDDINGS_OPENAI_BASE_URL) or None,
|
||||
embeddings_cohere_base_url=os.getenv(ENV_EMBEDDINGS_COHERE_BASE_URL) or None,
|
||||
# Reranker
|
||||
reranker_provider=os.getenv(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER),
|
||||
reranker_local_model=os.getenv(ENV_RERANKER_LOCAL_MODEL, DEFAULT_RERANKER_LOCAL_MODEL),
|
||||
reranker_local_force_cpu=os.getenv(
|
||||
ENV_RERANKER_LOCAL_FORCE_CPU, str(DEFAULT_RERANKER_LOCAL_FORCE_CPU)
|
||||
).lower()
|
||||
in ("true", "1"),
|
||||
reranker_local_max_concurrent=int(
|
||||
os.getenv(ENV_RERANKER_LOCAL_MAX_CONCURRENT, str(DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT))
|
||||
),
|
||||
reranker_tei_url=os.getenv(ENV_RERANKER_TEI_URL),
|
||||
reranker_tei_batch_size=int(os.getenv(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE))),
|
||||
reranker_tei_max_concurrent=int(
|
||||
@@ -444,6 +574,9 @@ class HindsightConfig:
|
||||
consolidation_batch_size=int(
|
||||
os.getenv(ENV_CONSOLIDATION_BATCH_SIZE, str(DEFAULT_CONSOLIDATION_BATCH_SIZE))
|
||||
),
|
||||
consolidation_max_tokens=int(
|
||||
os.getenv(ENV_CONSOLIDATION_MAX_TOKENS, str(DEFAULT_CONSOLIDATION_MAX_TOKENS))
|
||||
),
|
||||
# Database migrations
|
||||
run_migrations_on_startup=os.getenv(ENV_RUN_MIGRATIONS_ON_STARTUP, "true").lower() == "true",
|
||||
# Database connection pool
|
||||
@@ -515,7 +648,7 @@ class HindsightConfig:
|
||||
|
||||
def log_config(self) -> None:
|
||||
"""Log the current configuration (without sensitive values)."""
|
||||
logger.info(f"Database: {self.database_url}")
|
||||
logger.info(f"Database: {self.database_url} (schema: {self.database_schema})")
|
||||
logger.info(f"LLM: provider={self.llm_provider}, model={self.llm_model}")
|
||||
if self.retain_llm_provider or self.retain_llm_model:
|
||||
retain_provider = self.retain_llm_provider or self.llm_provider
|
||||
|
||||
@@ -52,7 +52,10 @@ class IdleTimeoutMiddleware:
|
||||
logger.info(f"Idle timeout reached ({self.idle_timeout}s), shutting down daemon")
|
||||
# Give a moment for any in-flight requests
|
||||
await asyncio.sleep(1)
|
||||
os._exit(0)
|
||||
# Send SIGTERM to ourselves to trigger graceful shutdown
|
||||
import signal
|
||||
|
||||
os.kill(os.getpid(), signal.SIGTERM)
|
||||
|
||||
|
||||
class DaemonLock:
|
||||
|
||||
@@ -144,10 +144,14 @@ async def run_consolidation_job(
|
||||
}
|
||||
|
||||
batch_num = 0
|
||||
last_progress_timings = {} # Track timings at last progress log
|
||||
while True:
|
||||
batch_num += 1
|
||||
batch_start = time.time()
|
||||
|
||||
# Snapshot timings at batch start for per-batch calculation
|
||||
batch_start_timings = perf.timings.copy()
|
||||
|
||||
# Fetch next batch of unconsolidated memories
|
||||
async with pool.acquire() as conn:
|
||||
t0 = time.time()
|
||||
@@ -217,19 +221,44 @@ async def run_consolidation_job(
|
||||
elif action == "skipped":
|
||||
stats["skipped"] += 1
|
||||
|
||||
# Log progress periodically
|
||||
# Log progress periodically with timing breakdown
|
||||
if stats["memories_processed"] % 10 == 0:
|
||||
# Calculate timing deltas since last progress log
|
||||
timing_parts = []
|
||||
for key in ["recall", "llm", "embedding", "db_write"]:
|
||||
if key in perf.timings:
|
||||
delta = perf.timings[key] - last_progress_timings.get(key, 0)
|
||||
timing_parts.append(f"{key}={delta:.2f}s")
|
||||
|
||||
timing_str = f" | {', '.join(timing_parts)}" if timing_parts else ""
|
||||
logger.info(
|
||||
f"[CONSOLIDATION] bank={bank_id} progress: "
|
||||
f"{stats['memories_processed']}/{total_count} memories processed"
|
||||
f"{stats['memories_processed']}/{total_count} memories processed{timing_str}"
|
||||
)
|
||||
|
||||
# Update last progress snapshot
|
||||
last_progress_timings = perf.timings.copy()
|
||||
|
||||
batch_time = time.time() - batch_start
|
||||
perf.log(
|
||||
f"[2] Batch {batch_num}: {len(memories)} memories in {batch_time:.3f}s "
|
||||
f"(avg {batch_time / len(memories):.3f}s/memory)"
|
||||
)
|
||||
|
||||
# Log timing breakdown after each batch (delta from batch start)
|
||||
timing_parts = []
|
||||
for key in ["recall", "llm", "embedding", "db_write"]:
|
||||
if key in perf.timings:
|
||||
delta = perf.timings[key] - batch_start_timings.get(key, 0)
|
||||
timing_parts.append(f"{key}={delta:.3f}s")
|
||||
|
||||
if timing_parts:
|
||||
avg_per_memory = batch_time / len(memories) if memories else 0
|
||||
logger.info(
|
||||
f"[CONSOLIDATION] bank={bank_id} batch {batch_num}/{len(memories)} memories: "
|
||||
f"{', '.join(timing_parts)} | avg={avg_per_memory:.3f}s/memory"
|
||||
)
|
||||
|
||||
# Build summary
|
||||
perf.log(
|
||||
f"[3] Results: {stats['memories_processed']} memories -> "
|
||||
@@ -639,28 +668,27 @@ async def _find_related_observations(
|
||||
request_context: "RequestContext",
|
||||
) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Find observations related to the given query using the full recall system.
|
||||
Find observations related to the given query using optimized recall.
|
||||
|
||||
IMPORTANT: We do NOT filter by tags here. Consolidation needs to see ALL
|
||||
potentially related observations regardless of scope, so the LLM can
|
||||
decide on tag routing (same scope update vs cross-scope create).
|
||||
|
||||
This leverages:
|
||||
- Semantic search (embedding similarity)
|
||||
- BM25 text search (keyword matching)
|
||||
- Entity-based retrieval (shared entities)
|
||||
- Graph traversal (connected via entity links)
|
||||
Uses max_tokens to naturally limit observations (no artificial count limit).
|
||||
Includes source memories with dates for LLM context.
|
||||
|
||||
Returns:
|
||||
List of related observations with their tags for LLM tag routing
|
||||
List of related observations with their tags, source memories, and dates
|
||||
"""
|
||||
# Use recall to find related observations
|
||||
# NO tags parameter - we want ALL observations regardless of scope
|
||||
# Use low max_tokens since we only need observations, not memories
|
||||
# Use recall to find related observations with token budget
|
||||
# max_tokens naturally limits how many observations are returned
|
||||
from ...config import get_config
|
||||
|
||||
config = get_config()
|
||||
recall_result = await memory_engine.recall_async(
|
||||
bank_id=bank_id,
|
||||
query=query,
|
||||
max_tokens=5000, # Token budget for observations
|
||||
max_tokens=config.consolidation_max_tokens, # Token budget for observations (configurable)
|
||||
fact_type=["observation"], # Only retrieve observations
|
||||
request_context=request_context,
|
||||
_quiet=True, # Suppress logging
|
||||
@@ -668,43 +696,82 @@ async def _find_related_observations(
|
||||
)
|
||||
|
||||
# If no observations returned, return empty list
|
||||
# When fact_type=["observation"], results come back in `results` field
|
||||
if not recall_result.results:
|
||||
return []
|
||||
|
||||
# Trust recall's relevance filtering - fetch full data for each observation
|
||||
# Batch fetch all observations in a single query (no artificial limit)
|
||||
observation_ids = [uuid.UUID(obs.id) for obs in recall_result.results]
|
||||
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at,
|
||||
occurred_start, occurred_end, mentioned_at
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = ANY($1) AND bank_id = $2 AND fact_type = 'observation'
|
||||
""",
|
||||
observation_ids,
|
||||
bank_id,
|
||||
)
|
||||
|
||||
# Build results list preserving recall order
|
||||
id_to_row = {row["id"]: row for row in rows}
|
||||
results = []
|
||||
|
||||
for obs in recall_result.results:
|
||||
# Fetch full observation data from DB to get history, source_memory_ids, tags
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT id, text, proof_count, history, tags, source_memory_ids, created_at, updated_at
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = $1 AND bank_id = $2 AND fact_type = 'observation'
|
||||
""",
|
||||
uuid.UUID(obs.id),
|
||||
bank_id,
|
||||
)
|
||||
obs_id = uuid.UUID(obs.id)
|
||||
if obs_id not in id_to_row:
|
||||
continue
|
||||
|
||||
if row:
|
||||
history = row["history"]
|
||||
if isinstance(history, str):
|
||||
history = json.loads(history)
|
||||
elif history is None:
|
||||
history = []
|
||||
row = id_to_row[obs_id]
|
||||
history = row["history"]
|
||||
if isinstance(history, str):
|
||||
history = json.loads(history)
|
||||
elif history is None:
|
||||
history = []
|
||||
|
||||
results.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"text": row["text"],
|
||||
"proof_count": row["proof_count"] or 1,
|
||||
"history": history,
|
||||
"tags": row["tags"] or [], # Include tags for LLM tag routing
|
||||
"source_memory_ids": row["source_memory_ids"] or [],
|
||||
"similarity": 1.0, # Retrieved via recall so assumed relevant
|
||||
}
|
||||
# Fetch source memories to include their text and dates
|
||||
source_memory_ids = row["source_memory_ids"] or []
|
||||
source_memories = []
|
||||
|
||||
if source_memory_ids:
|
||||
source_rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT text, occurred_start, occurred_end, mentioned_at, event_date
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE id = ANY($1) AND bank_id = $2
|
||||
ORDER BY created_at ASC
|
||||
LIMIT 5
|
||||
""",
|
||||
source_memory_ids[:5], # Limit to first 5 source memories for token efficiency
|
||||
bank_id,
|
||||
)
|
||||
|
||||
for src_row in source_rows:
|
||||
source_memories.append(
|
||||
{
|
||||
"text": src_row["text"],
|
||||
"occurred_start": src_row["occurred_start"],
|
||||
"occurred_end": src_row["occurred_end"],
|
||||
"mentioned_at": src_row["mentioned_at"],
|
||||
"event_date": src_row["event_date"],
|
||||
}
|
||||
)
|
||||
|
||||
results.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"text": row["text"],
|
||||
"proof_count": row["proof_count"] or 1,
|
||||
"tags": row["tags"] or [],
|
||||
"source_memories": source_memories,
|
||||
"occurred_start": row["occurred_start"],
|
||||
"occurred_end": row["occurred_end"],
|
||||
"mentioned_at": row["mentioned_at"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -732,14 +799,43 @@ async def _consolidate_with_llm(
|
||||
- {"action": "create", "text": "...", "reason": "..."}
|
||||
- [] if fact is purely ephemeral (no durable knowledge)
|
||||
"""
|
||||
# Format observations WITH their tags (or "None" if empty)
|
||||
# Format observations as JSON with source memories and dates
|
||||
if observations:
|
||||
observations_text = "\n".join(
|
||||
f'- ID: {obs["id"]}, Tags: {json.dumps(obs["tags"])}, Text: "{obs["text"]}" (proof_count: {obs["proof_count"]})'
|
||||
for obs in observations
|
||||
)
|
||||
obs_list = []
|
||||
for obs in observations:
|
||||
obs_data = {
|
||||
"id": str(obs["id"]),
|
||||
"text": obs["text"],
|
||||
"proof_count": obs["proof_count"],
|
||||
"tags": obs["tags"],
|
||||
"created_at": obs["created_at"].isoformat() if obs.get("created_at") else None,
|
||||
"updated_at": obs["updated_at"].isoformat() if obs.get("updated_at") else None,
|
||||
}
|
||||
|
||||
# Include temporal info if available
|
||||
if obs.get("occurred_start"):
|
||||
obs_data["occurred_start"] = obs["occurred_start"].isoformat()
|
||||
if obs.get("occurred_end"):
|
||||
obs_data["occurred_end"] = obs["occurred_end"].isoformat()
|
||||
if obs.get("mentioned_at"):
|
||||
obs_data["mentioned_at"] = obs["mentioned_at"].isoformat()
|
||||
|
||||
# Include source memories (up to 3 for brevity)
|
||||
if obs.get("source_memories"):
|
||||
obs_data["source_memories"] = [
|
||||
{
|
||||
"text": sm["text"],
|
||||
"event_date": sm["event_date"].isoformat() if sm.get("event_date") else None,
|
||||
"occurred_start": sm["occurred_start"].isoformat() if sm.get("occurred_start") else None,
|
||||
}
|
||||
for sm in obs["source_memories"][:3] # Limit to 3 for token efficiency
|
||||
]
|
||||
|
||||
obs_list.append(obs_data)
|
||||
|
||||
observations_text = json.dumps(obs_list, indent=2)
|
||||
else:
|
||||
observations_text = "None (this is a new topic - create if fact contains durable knowledge)"
|
||||
observations_text = "[]"
|
||||
|
||||
# Only include mission section if mission is set and not the default
|
||||
mission_section = ""
|
||||
|
||||
@@ -47,23 +47,31 @@ CONSOLIDATION_USER_PROMPT = """Analyze this new fact and consolidate into knowle
|
||||
{mission_section}
|
||||
NEW FACT: {fact_text}
|
||||
|
||||
EXISTING OBSERVATIONS:
|
||||
EXISTING OBSERVATIONS (JSON array with source memories and dates):
|
||||
{observations_text}
|
||||
|
||||
Instructions:
|
||||
1. First, extract the DURABLE KNOWLEDGE from the fact (not ephemeral state like "user is at X")
|
||||
2. Then compare with existing observations:
|
||||
- If an observation covers the same topic: UPDATE it with the new knowledge
|
||||
- If no observation covers the topic: CREATE a new one
|
||||
Each observation includes:
|
||||
- id: unique identifier for updating
|
||||
- text: the observation content
|
||||
- proof_count: number of supporting memories
|
||||
- tags: visibility scope (handled automatically)
|
||||
- created_at/updated_at: when observation was created/modified
|
||||
- occurred_start/occurred_end: temporal range of source facts
|
||||
- source_memories: array of supporting facts with their text and dates
|
||||
|
||||
Output JSON array of actions (ALWAYS an array, even for single action):
|
||||
Instructions:
|
||||
1. Extract DURABLE KNOWLEDGE from the new fact (not ephemeral state)
|
||||
2. Review source_memories in existing observations to understand evidence
|
||||
3. Check dates to detect contradictions or updates
|
||||
4. Compare with observations:
|
||||
- Same topic → UPDATE with learning_id
|
||||
- New topic → CREATE new observation
|
||||
- Purely ephemeral → return []
|
||||
|
||||
Output JSON array of actions:
|
||||
[
|
||||
{{"action": "update", "learning_id": "uuid", "text": "updated durable knowledge", "reason": "..."}},
|
||||
{{"action": "update", "learning_id": "uuid-from-observations", "text": "updated knowledge", "reason": "..."}},
|
||||
{{"action": "create", "text": "new durable knowledge", "reason": "..."}}
|
||||
]
|
||||
|
||||
If NO consolidation is needed (fact is purely ephemeral with no durable knowledge):
|
||||
[]
|
||||
|
||||
If no observations exist and fact contains durable knowledge:
|
||||
[{{"action": "create", "text": "durable knowledge text", "reason": "new topic"}}]"""
|
||||
Return [] if fact contains no durable knowledge."""
|
||||
|
||||
@@ -20,6 +20,7 @@ from ..config import (
|
||||
DEFAULT_RERANKER_FLASHRANK_CACHE_DIR,
|
||||
DEFAULT_RERANKER_FLASHRANK_MODEL,
|
||||
DEFAULT_RERANKER_LITELLM_MODEL,
|
||||
DEFAULT_RERANKER_LOCAL_FORCE_CPU,
|
||||
DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT,
|
||||
DEFAULT_RERANKER_LOCAL_MODEL,
|
||||
DEFAULT_RERANKER_PROVIDER,
|
||||
@@ -33,6 +34,7 @@ from ..config import (
|
||||
ENV_RERANKER_FLASHRANK_CACHE_DIR,
|
||||
ENV_RERANKER_FLASHRANK_MODEL,
|
||||
ENV_RERANKER_LITELLM_MODEL,
|
||||
ENV_RERANKER_LOCAL_FORCE_CPU,
|
||||
ENV_RERANKER_LOCAL_MAX_CONCURRENT,
|
||||
ENV_RERANKER_LOCAL_MODEL,
|
||||
ENV_RERANKER_PROVIDER,
|
||||
@@ -99,7 +101,7 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
_executor: ThreadPoolExecutor | None = None
|
||||
_max_concurrent: int = 4 # Limit concurrent CPU-bound reranking calls
|
||||
|
||||
def __init__(self, model_name: str | None = None, max_concurrent: int = 4):
|
||||
def __init__(self, model_name: str | None = None, max_concurrent: int = 4, force_cpu: bool = False):
|
||||
"""
|
||||
Initialize local SentenceTransformers cross-encoder.
|
||||
|
||||
@@ -108,8 +110,11 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
Default: cross-encoder/ms-marco-MiniLM-L-6-v2
|
||||
max_concurrent: Maximum concurrent reranking calls (default: 2).
|
||||
Higher values may cause CPU thrashing under load.
|
||||
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
|
||||
Default: False
|
||||
"""
|
||||
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
|
||||
self.force_cpu = force_cpu
|
||||
self._model = None
|
||||
LocalSTCrossEncoder._max_concurrent = max_concurrent
|
||||
|
||||
@@ -139,13 +144,23 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
# after loading, which conflicts with accelerate's device_map handling.
|
||||
import torch
|
||||
|
||||
# Check for GPU (CUDA) or Apple Silicon (MPS)
|
||||
has_gpu = torch.cuda.is_available() or (hasattr(torch.backends, "mps") and torch.backends.mps.is_available())
|
||||
|
||||
if has_gpu:
|
||||
device = None # Let sentence-transformers auto-detect GPU/MPS
|
||||
else:
|
||||
# Force CPU mode if configured (used in daemon mode to avoid MPS/XPC issues on macOS)
|
||||
if self.force_cpu:
|
||||
device = "cpu"
|
||||
logger.info("Reranker: forcing CPU mode (HINDSIGHT_API_RERANKER_LOCAL_FORCE_CPU=1)")
|
||||
else:
|
||||
# Check for GPU (CUDA) or Apple Silicon (MPS)
|
||||
# Wrap in try-except to gracefully handle any device detection issues
|
||||
# (e.g., in CI environments or when PyTorch is built without GPU support)
|
||||
device = "cpu" # Default to CPU
|
||||
try:
|
||||
has_gpu = torch.cuda.is_available() or (
|
||||
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
|
||||
)
|
||||
if has_gpu:
|
||||
device = None # Let sentence-transformers auto-detect GPU/MPS
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
|
||||
|
||||
self._model = CrossEncoder(
|
||||
self.model_name,
|
||||
@@ -163,6 +178,11 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
else:
|
||||
logger.info("Reranker: local provider initialized (using existing executor)")
|
||||
|
||||
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""Synchronous prediction wrapper for thread pool execution."""
|
||||
scores = self._model.predict(pairs, show_progress_bar=False)
|
||||
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
|
||||
|
||||
async def predict(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""
|
||||
Score query-document pairs for relevance.
|
||||
@@ -180,11 +200,11 @@ class LocalSTCrossEncoder(CrossEncoderModel):
|
||||
|
||||
# Use dedicated executor - limited workers naturally limits concurrency
|
||||
loop = asyncio.get_event_loop()
|
||||
scores = await loop.run_in_executor(
|
||||
return await loop.run_in_executor(
|
||||
LocalSTCrossEncoder._executor,
|
||||
lambda: self._model.predict(pairs, show_progress_bar=False),
|
||||
self._predict_sync,
|
||||
pairs,
|
||||
)
|
||||
return scores.tolist() if hasattr(scores, "tolist") else list(scores)
|
||||
|
||||
|
||||
class RemoteTEICrossEncoder(CrossEncoderModel):
|
||||
@@ -594,7 +614,7 @@ class FlashRankCrossEncoder(CrossEncoderModel):
|
||||
return
|
||||
|
||||
try:
|
||||
from flashrank import Ranker # type: ignore[import-untyped]
|
||||
from flashrank import Ranker
|
||||
except ImportError:
|
||||
raise ImportError("flashrank is required for FlashRankCrossEncoder. Install it with: pip install flashrank")
|
||||
|
||||
@@ -621,7 +641,7 @@ class FlashRankCrossEncoder(CrossEncoderModel):
|
||||
|
||||
def _predict_sync(self, pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""Synchronous predict - processes each query group."""
|
||||
from flashrank import RerankRequest # type: ignore[import-untyped]
|
||||
from flashrank import RerankRequest
|
||||
|
||||
if not pairs:
|
||||
return []
|
||||
@@ -783,29 +803,33 @@ class LiteLLMCrossEncoder(CrossEncoderModel):
|
||||
|
||||
def create_cross_encoder_from_env() -> CrossEncoderModel:
|
||||
"""
|
||||
Create a CrossEncoderModel instance based on environment variables.
|
||||
Create a CrossEncoderModel instance based on configuration.
|
||||
|
||||
See hindsight_api.config for environment variable names and defaults.
|
||||
Reads configuration via get_config() to ensure consistency across the codebase.
|
||||
|
||||
Returns:
|
||||
Configured CrossEncoderModel instance
|
||||
"""
|
||||
provider = os.environ.get(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER).lower()
|
||||
from ..config import get_config
|
||||
|
||||
config = get_config()
|
||||
provider = config.reranker_provider.lower()
|
||||
|
||||
if provider == "tei":
|
||||
url = os.environ.get(ENV_RERANKER_TEI_URL)
|
||||
url = config.reranker_tei_url
|
||||
if not url:
|
||||
raise ValueError(f"{ENV_RERANKER_TEI_URL} is required when {ENV_RERANKER_PROVIDER} is 'tei'")
|
||||
batch_size = int(os.environ.get(ENV_RERANKER_TEI_BATCH_SIZE, str(DEFAULT_RERANKER_TEI_BATCH_SIZE)))
|
||||
max_concurrent = int(os.environ.get(ENV_RERANKER_TEI_MAX_CONCURRENT, str(DEFAULT_RERANKER_TEI_MAX_CONCURRENT)))
|
||||
return RemoteTEICrossEncoder(base_url=url, batch_size=batch_size, max_concurrent=max_concurrent)
|
||||
return RemoteTEICrossEncoder(
|
||||
base_url=url,
|
||||
batch_size=config.reranker_tei_batch_size,
|
||||
max_concurrent=config.reranker_tei_max_concurrent,
|
||||
)
|
||||
elif provider == "local":
|
||||
model = os.environ.get(ENV_RERANKER_LOCAL_MODEL)
|
||||
model_name = model or DEFAULT_RERANKER_LOCAL_MODEL
|
||||
max_concurrent = int(
|
||||
os.environ.get(ENV_RERANKER_LOCAL_MAX_CONCURRENT, str(DEFAULT_RERANKER_LOCAL_MAX_CONCURRENT))
|
||||
return LocalSTCrossEncoder(
|
||||
model_name=config.reranker_local_model,
|
||||
max_concurrent=config.reranker_local_max_concurrent,
|
||||
force_cpu=config.reranker_local_force_cpu,
|
||||
)
|
||||
return LocalSTCrossEncoder(model_name=model_name, max_concurrent=max_concurrent)
|
||||
elif provider == "cohere":
|
||||
api_key = os.environ.get(ENV_COHERE_API_KEY)
|
||||
if not api_key:
|
||||
|
||||
@@ -18,6 +18,7 @@ import httpx
|
||||
from ..config import (
|
||||
DEFAULT_EMBEDDINGS_COHERE_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LITELLM_MODEL,
|
||||
DEFAULT_EMBEDDINGS_LOCAL_FORCE_CPU,
|
||||
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
|
||||
DEFAULT_EMBEDDINGS_OPENAI_MODEL,
|
||||
DEFAULT_EMBEDDINGS_PROVIDER,
|
||||
@@ -26,6 +27,7 @@ from ..config import (
|
||||
ENV_EMBEDDINGS_COHERE_BASE_URL,
|
||||
ENV_EMBEDDINGS_COHERE_MODEL,
|
||||
ENV_EMBEDDINGS_LITELLM_MODEL,
|
||||
ENV_EMBEDDINGS_LOCAL_FORCE_CPU,
|
||||
ENV_EMBEDDINGS_LOCAL_MODEL,
|
||||
ENV_EMBEDDINGS_OPENAI_API_KEY,
|
||||
ENV_EMBEDDINGS_OPENAI_BASE_URL,
|
||||
@@ -92,15 +94,18 @@ class LocalSTEmbeddings(Embeddings):
|
||||
The embedding dimension is auto-detected from the model.
|
||||
"""
|
||||
|
||||
def __init__(self, model_name: str | None = None):
|
||||
def __init__(self, model_name: str | None = None, force_cpu: bool = False):
|
||||
"""
|
||||
Initialize local SentenceTransformers embeddings.
|
||||
|
||||
Args:
|
||||
model_name: Name of the SentenceTransformer model to use.
|
||||
Default: BAAI/bge-small-en-v1.5
|
||||
force_cpu: Force CPU mode (avoids MPS/XPC issues on macOS in daemon mode).
|
||||
Default: False
|
||||
"""
|
||||
self.model_name = model_name or DEFAULT_EMBEDDINGS_LOCAL_MODEL
|
||||
self.force_cpu = force_cpu
|
||||
self._model = None
|
||||
self._dimension: int | None = None
|
||||
|
||||
@@ -134,13 +139,23 @@ class LocalSTEmbeddings(Embeddings):
|
||||
# which can cause issues when accelerate is installed but no GPU is available.
|
||||
import torch
|
||||
|
||||
# Check for GPU (CUDA) or Apple Silicon (MPS)
|
||||
has_gpu = torch.cuda.is_available() or (hasattr(torch.backends, "mps") and torch.backends.mps.is_available())
|
||||
|
||||
if has_gpu:
|
||||
device = None # Let sentence-transformers auto-detect GPU/MPS
|
||||
else:
|
||||
# Force CPU mode if configured (used in daemon mode to avoid MPS/XPC issues on macOS)
|
||||
if self.force_cpu:
|
||||
device = "cpu"
|
||||
logger.info("Embeddings: forcing CPU mode")
|
||||
else:
|
||||
# Check for GPU (CUDA) or Apple Silicon (MPS)
|
||||
# Wrap in try-except to gracefully handle any device detection issues
|
||||
# (e.g., in CI environments or when PyTorch is built without GPU support)
|
||||
device = "cpu" # Default to CPU
|
||||
try:
|
||||
has_gpu = torch.cuda.is_available() or (
|
||||
hasattr(torch.backends, "mps") and torch.backends.mps.is_available()
|
||||
)
|
||||
if has_gpu:
|
||||
device = None # Let sentence-transformers auto-detect GPU/MPS
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to detect GPU/MPS, falling back to CPU: {e}")
|
||||
|
||||
self._model = SentenceTransformer(
|
||||
self.model_name,
|
||||
@@ -163,6 +178,7 @@ class LocalSTEmbeddings(Embeddings):
|
||||
"""
|
||||
if self._model is None:
|
||||
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
|
||||
|
||||
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
|
||||
return [emb.tolist() for emb in embeddings]
|
||||
|
||||
@@ -529,7 +545,7 @@ class CohereEmbeddings(Embeddings):
|
||||
model=self.model,
|
||||
input_type=self.input_type,
|
||||
)
|
||||
if response.embeddings:
|
||||
if response.embeddings and isinstance(response.embeddings, list):
|
||||
self._dimension = len(response.embeddings[0])
|
||||
|
||||
logger.info(f"Embeddings: Cohere provider initialized (model: {self.model}, dim: {self._dimension})")
|
||||
@@ -686,24 +702,28 @@ class LiteLLMEmbeddings(Embeddings):
|
||||
|
||||
def create_embeddings_from_env() -> Embeddings:
|
||||
"""
|
||||
Create an Embeddings instance based on environment variables.
|
||||
Create an Embeddings instance based on configuration.
|
||||
|
||||
See hindsight_api.config for environment variable names and defaults.
|
||||
Reads configuration via get_config() to ensure consistency across the codebase.
|
||||
|
||||
Returns:
|
||||
Configured Embeddings instance
|
||||
"""
|
||||
provider = os.environ.get(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER).lower()
|
||||
from ..config import get_config
|
||||
|
||||
config = get_config()
|
||||
provider = config.embeddings_provider.lower()
|
||||
|
||||
if provider == "tei":
|
||||
url = os.environ.get(ENV_EMBEDDINGS_TEI_URL)
|
||||
url = config.embeddings_tei_url
|
||||
if not url:
|
||||
raise ValueError(f"{ENV_EMBEDDINGS_TEI_URL} is required when {ENV_EMBEDDINGS_PROVIDER} is 'tei'")
|
||||
return RemoteTEIEmbeddings(base_url=url)
|
||||
elif provider == "local":
|
||||
model = os.environ.get(ENV_EMBEDDINGS_LOCAL_MODEL)
|
||||
model_name = model or DEFAULT_EMBEDDINGS_LOCAL_MODEL
|
||||
return LocalSTEmbeddings(model_name=model_name)
|
||||
return LocalSTEmbeddings(
|
||||
model_name=config.embeddings_local_model,
|
||||
force_cpu=config.embeddings_local_force_cpu,
|
||||
)
|
||||
elif provider == "openai":
|
||||
# Use dedicated embeddings API key, or fall back to LLM API key
|
||||
api_key = os.environ.get(ENV_EMBEDDINGS_OPENAI_API_KEY) or os.environ.get(ENV_LLM_API_KEY)
|
||||
|
||||
@@ -16,6 +16,15 @@ from google.genai import errors as genai_errors
|
||||
from google.genai import types as genai_types
|
||||
from openai import APIConnectionError, APIStatusError, AsyncOpenAI, LengthFinishReasonError
|
||||
|
||||
# Vertex AI imports (conditional)
|
||||
try:
|
||||
import google.auth
|
||||
from google.oauth2 import service_account
|
||||
|
||||
VERTEXAI_AVAILABLE = True
|
||||
except ImportError:
|
||||
VERTEXAI_AVAILABLE = False
|
||||
|
||||
from ..config import (
|
||||
DEFAULT_LLM_MAX_CONCURRENT,
|
||||
DEFAULT_LLM_TIMEOUT,
|
||||
@@ -88,7 +97,7 @@ class LLMProvider:
|
||||
self.groq_service_tier = groq_service_tier or os.getenv(ENV_LLM_GROQ_SERVICE_TIER, "auto")
|
||||
|
||||
# Validate provider
|
||||
valid_providers = ["openai", "groq", "ollama", "gemini", "anthropic", "lmstudio", "mock"]
|
||||
valid_providers = ["openai", "groq", "ollama", "gemini", "anthropic", "lmstudio", "vertexai", "mock"]
|
||||
if self.provider not in valid_providers:
|
||||
raise ValueError(f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}")
|
||||
|
||||
@@ -96,6 +105,9 @@ class LLMProvider:
|
||||
self._mock_calls: list[dict] = []
|
||||
self._mock_response: Any = None
|
||||
|
||||
# Vertex AI token refresher
|
||||
self._vertexai_refresher: Any = None
|
||||
|
||||
# Set default base URLs
|
||||
if not self.base_url:
|
||||
if self.provider == "groq":
|
||||
@@ -105,8 +117,65 @@ class LLMProvider:
|
||||
elif self.provider == "lmstudio":
|
||||
self.base_url = "http://localhost:1234/v1"
|
||||
|
||||
# Validate API key (not needed for ollama, lmstudio, or mock)
|
||||
if self.provider not in ("ollama", "lmstudio", "mock") and not self.api_key:
|
||||
# Handle Vertex AI provider
|
||||
if self.provider == "vertexai":
|
||||
if not VERTEXAI_AVAILABLE:
|
||||
raise ValueError("Vertex AI requires 'google-auth' package. Install with: pip install google-auth")
|
||||
|
||||
from ..config import get_config
|
||||
|
||||
config = get_config()
|
||||
|
||||
project_id = config.llm_vertexai_project_id
|
||||
if not project_id:
|
||||
raise ValueError(
|
||||
"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID is required for Vertex AI provider. "
|
||||
"Set it to your GCP project ID."
|
||||
)
|
||||
|
||||
region = config.llm_vertexai_region or "us-central1"
|
||||
service_account_key = config.llm_vertexai_service_account_key
|
||||
|
||||
# Try ADC first
|
||||
credentials = None
|
||||
auth_method = None
|
||||
|
||||
try:
|
||||
credentials, _ = google.auth.default(scopes=["https://www.googleapis.com/auth/cloud-platform"])
|
||||
auth_method = "ADC"
|
||||
logger.info("Vertex AI: Using Application Default Credentials")
|
||||
except google.auth.exceptions.DefaultCredentialsError:
|
||||
logger.debug("Vertex AI: ADC not available, trying service account")
|
||||
|
||||
# Fall back to service account key file
|
||||
if credentials is None and service_account_key:
|
||||
try:
|
||||
credentials = service_account.Credentials.from_service_account_file(
|
||||
service_account_key,
|
||||
scopes=["https://www.googleapis.com/auth/cloud-platform"],
|
||||
)
|
||||
auth_method = "Service Account"
|
||||
logger.info(f"Vertex AI: Using service account key: {service_account_key}")
|
||||
except Exception as e:
|
||||
logger.error(f"Vertex AI: Failed to load service account key: {e}")
|
||||
|
||||
if credentials is None:
|
||||
raise ValueError(
|
||||
"Vertex AI authentication failed. Either:\n"
|
||||
" 1. Set up ADC: gcloud auth application-default login\n"
|
||||
" 2. Set HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY to path of service account JSON key"
|
||||
)
|
||||
|
||||
# Initialize token refresher
|
||||
from .vertexai_token_refresher import VertexAITokenRefresher
|
||||
|
||||
self._vertexai_refresher = VertexAITokenRefresher(credentials, project_id, region)
|
||||
self.base_url = self._vertexai_refresher.get_base_url()
|
||||
|
||||
logger.info(f"Vertex AI: project={project_id}, region={region}, auth={auth_method}")
|
||||
|
||||
# Validate API key (not needed for ollama, lmstudio, vertexai, or mock)
|
||||
if self.provider not in ("ollama", "lmstudio", "vertexai", "mock") 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)
|
||||
@@ -132,6 +201,31 @@ class LLMProvider:
|
||||
if self.timeout:
|
||||
anthropic_kwargs["timeout"] = self.timeout
|
||||
self._anthropic_client = AsyncAnthropic(**anthropic_kwargs)
|
||||
elif self.provider == "vertexai":
|
||||
# Custom transport for token injection
|
||||
class TokenInjectingTransport(httpx.AsyncHTTPTransport):
|
||||
def __init__(self, refresher, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._refresher = refresher
|
||||
|
||||
async def handle_async_request(self, request):
|
||||
token = self._refresher.get_token()
|
||||
request.headers["Authorization"] = f"Bearer {token}"
|
||||
return await super().handle_async_request(request)
|
||||
|
||||
transport = TokenInjectingTransport(self._vertexai_refresher)
|
||||
client_kwargs = {
|
||||
"api_key": "dummy", # Required by AsyncOpenAI but unused (we inject token via transport)
|
||||
"base_url": self.base_url,
|
||||
"max_retries": 0,
|
||||
"http_client": httpx.AsyncClient(transport=transport),
|
||||
}
|
||||
if self.timeout:
|
||||
client_kwargs["timeout"] = self.timeout
|
||||
self._client = AsyncOpenAI(**client_kwargs)
|
||||
|
||||
# Start background refresh
|
||||
self._vertexai_refresher.start_refresh_task()
|
||||
elif self.provider in ("ollama", "lmstudio"):
|
||||
# Use dummy key if not provided for local
|
||||
api_key = self.api_key or "local"
|
||||
@@ -342,11 +436,13 @@ class LLMProvider:
|
||||
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
|
||||
first_msg = call_params["messages"][0]
|
||||
if isinstance(first_msg, dict) and isinstance(first_msg.get("content"), str):
|
||||
first_msg["content"] += schema_msg
|
||||
elif call_params["messages"]:
|
||||
call_params["messages"][0]["content"] = (
|
||||
schema_msg + "\n\n" + call_params["messages"][0]["content"]
|
||||
)
|
||||
first_msg = call_params["messages"][0]
|
||||
if isinstance(first_msg, dict) and isinstance(first_msg.get("content"), str):
|
||||
first_msg["content"] = schema_msg + "\n\n" + first_msg["content"]
|
||||
if self.provider not in ("lmstudio", "ollama"):
|
||||
# LM Studio and Ollama don't support json_object response format reliably
|
||||
# We rely on the schema in the system message instead
|
||||
@@ -917,18 +1013,20 @@ class LLMProvider:
|
||||
tool_calls: list[LLMToolCall] = []
|
||||
|
||||
if response.candidates and response.candidates[0].content:
|
||||
for part in response.candidates[0].content.parts:
|
||||
if hasattr(part, "text") and part.text:
|
||||
content = part.text
|
||||
if hasattr(part, "function_call") and part.function_call:
|
||||
fc = part.function_call
|
||||
tool_calls.append(
|
||||
LLMToolCall(
|
||||
id=f"gemini_{len(tool_calls)}",
|
||||
name=fc.name,
|
||||
arguments=dict(fc.args) if fc.args else {},
|
||||
parts = response.candidates[0].content.parts
|
||||
if parts:
|
||||
for part in parts:
|
||||
if hasattr(part, "text") and part.text:
|
||||
content = part.text
|
||||
if hasattr(part, "function_call") and part.function_call:
|
||||
fc = part.function_call
|
||||
tool_calls.append(
|
||||
LLMToolCall(
|
||||
id=f"gemini_{len(tool_calls)}",
|
||||
name=fc.name,
|
||||
arguments=dict(fc.args) if fc.args else {},
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
finish_reason = "tool_calls" if tool_calls else "stop"
|
||||
|
||||
@@ -1504,6 +1602,12 @@ class LLMProvider:
|
||||
"""Clear the recorded mock calls."""
|
||||
self._mock_calls = []
|
||||
|
||||
async def cleanup(self) -> None:
|
||||
"""Clean up resources (e.g., stop token refresh tasks)."""
|
||||
if self._vertexai_refresher is not None:
|
||||
await self._vertexai_refresher.stop()
|
||||
logger.debug("Vertex AI token refresher stopped")
|
||||
|
||||
@classmethod
|
||||
def for_memory(cls) -> "LLMProvider":
|
||||
"""Create provider for memory operations from environment variables."""
|
||||
|
||||
@@ -23,12 +23,17 @@ from ..metrics import get_metrics_collector
|
||||
from .db_budget import budgeted_operation
|
||||
|
||||
# Context variable for current schema (async-safe, per-task isolation)
|
||||
_current_schema: contextvars.ContextVar[str] = contextvars.ContextVar("current_schema", default="public")
|
||||
# Note: default is None, actual default comes from config via get_current_schema()
|
||||
_current_schema: contextvars.ContextVar[str | None] = contextvars.ContextVar("current_schema", default=None)
|
||||
|
||||
|
||||
def get_current_schema() -> str:
|
||||
"""Get the current schema from context (default: 'public')."""
|
||||
return _current_schema.get()
|
||||
"""Get the current schema from context (falls back to config default)."""
|
||||
schema = _current_schema.get()
|
||||
if schema is None:
|
||||
# Fall back to configured default schema
|
||||
return get_config().database_schema
|
||||
return schema
|
||||
|
||||
|
||||
def fq_table(table_name: str) -> str:
|
||||
@@ -784,7 +789,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
kwargs = {"name": self._pg0_instance_name}
|
||||
if self._pg0_port is not None:
|
||||
kwargs["port"] = self._pg0_port
|
||||
pg0 = EmbeddedPostgres(**kwargs) # type: ignore[invalid-argument-type] - dict kwargs
|
||||
pg0 = EmbeddedPostgres(**kwargs)
|
||||
# Check if pg0 is already running before we start it
|
||||
was_already_running = await pg0.is_running()
|
||||
self.db_url = await pg0.ensure_running()
|
||||
@@ -881,11 +886,12 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if not self.db_url:
|
||||
raise ValueError("Database URL is required for migrations")
|
||||
logger.info("Running database migrations...")
|
||||
run_migrations(self.db_url)
|
||||
# Use configured database schema for migrations (defaults to "public")
|
||||
run_migrations(self.db_url, schema=get_config().database_schema)
|
||||
|
||||
# Ensure embedding column dimension matches the model's dimension
|
||||
# This is done after migrations and after embeddings.initialize()
|
||||
ensure_embedding_dimension(self.db_url, self.embeddings.dimension)
|
||||
ensure_embedding_dimension(self.db_url, self.embeddings.dimension, schema=get_config().database_schema)
|
||||
|
||||
logger.info(f"Connecting to PostgreSQL at {self.db_url}")
|
||||
|
||||
@@ -1177,7 +1183,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
List of created unit IDs
|
||||
"""
|
||||
# Build content dict
|
||||
content_dict: RetainContentDict = {"content": content, "context": context} # type: ignore[typeddict-item] - building incrementally
|
||||
content_dict: RetainContentDict = {"content": content, "context": context}
|
||||
if event_date:
|
||||
content_dict["event_date"] = event_date
|
||||
if document_id:
|
||||
@@ -2764,7 +2770,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
param_count += 1
|
||||
units = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, event_date, context, occurred_start, occurred_end, mentioned_at, document_id, chunk_id, fact_type, tags, created_at, proof_count
|
||||
SELECT id, text, event_date, context, occurred_start, occurred_end, mentioned_at, document_id, chunk_id, fact_type, tags, created_at, proof_count, source_memory_ids
|
||||
FROM {fq_table("memory_units")}
|
||||
{where_clause}
|
||||
ORDER BY mentioned_at DESC NULLS LAST, event_date DESC
|
||||
@@ -2777,7 +2783,18 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Get links, filtering to only include links between units of the selected agent
|
||||
# Use DISTINCT ON with LEAST/GREATEST to deduplicate bidirectional links
|
||||
unit_ids = [row["id"] for row in units]
|
||||
if unit_ids:
|
||||
unit_id_set = set(unit_ids)
|
||||
|
||||
# Collect source memory IDs from observations
|
||||
source_memory_ids = []
|
||||
for unit in units:
|
||||
if unit["source_memory_ids"]:
|
||||
source_memory_ids.extend(unit["source_memory_ids"])
|
||||
source_memory_ids = list(set(source_memory_ids)) # Deduplicate
|
||||
|
||||
# Fetch links involving both visible units AND source memories
|
||||
all_relevant_ids = unit_ids + source_memory_ids
|
||||
if all_relevant_ids:
|
||||
links = await conn.fetch(
|
||||
f"""
|
||||
SELECT DISTINCT ON (LEAST(ml.from_unit_id, ml.to_unit_id), GREATEST(ml.from_unit_id, ml.to_unit_id), ml.link_type, COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid))
|
||||
@@ -2788,14 +2805,69 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
e.canonical_name as entity_name
|
||||
FROM {fq_table("memory_links")} ml
|
||||
LEFT JOIN {fq_table("entities")} e ON ml.entity_id = e.id
|
||||
WHERE ml.from_unit_id = ANY($1::uuid[]) AND ml.to_unit_id = ANY($1::uuid[])
|
||||
WHERE ml.from_unit_id = ANY($1::uuid[]) OR ml.to_unit_id = ANY($1::uuid[])
|
||||
ORDER BY LEAST(ml.from_unit_id, ml.to_unit_id), GREATEST(ml.from_unit_id, ml.to_unit_id), ml.link_type, COALESCE(ml.entity_id, '00000000-0000-0000-0000-000000000000'::uuid), ml.weight DESC
|
||||
""",
|
||||
unit_ids,
|
||||
all_relevant_ids,
|
||||
)
|
||||
else:
|
||||
links = []
|
||||
|
||||
# Copy links from source memories to observations
|
||||
# Observations inherit links from their source memories via source_memory_ids
|
||||
# Build a map from source_id to observation_ids
|
||||
source_to_observations = {}
|
||||
for unit in units:
|
||||
if unit["source_memory_ids"]:
|
||||
for source_id in unit["source_memory_ids"]:
|
||||
if source_id not in source_to_observations:
|
||||
source_to_observations[source_id] = []
|
||||
source_to_observations[source_id].append(unit["id"])
|
||||
|
||||
copied_links = []
|
||||
for link in links:
|
||||
from_id = link["from_unit_id"]
|
||||
to_id = link["to_unit_id"]
|
||||
|
||||
# Get observations that should inherit this link
|
||||
from_observations = source_to_observations.get(from_id, [])
|
||||
to_observations = source_to_observations.get(to_id, [])
|
||||
|
||||
# If from_id is a source memory, copy links to its observations
|
||||
if from_observations:
|
||||
for obs_id in from_observations:
|
||||
# Only include if the target is visible
|
||||
if to_id in unit_id_set or to_observations:
|
||||
target = to_observations[0] if to_observations and to_id not in unit_id_set else to_id
|
||||
if target in unit_id_set:
|
||||
copied_links.append(
|
||||
{
|
||||
"from_unit_id": obs_id,
|
||||
"to_unit_id": target,
|
||||
"link_type": link["link_type"],
|
||||
"weight": link["weight"],
|
||||
"entity_name": link["entity_name"],
|
||||
}
|
||||
)
|
||||
|
||||
# If to_id is a source memory, copy links to its observations
|
||||
if to_observations and from_id in unit_id_set:
|
||||
for obs_id in to_observations:
|
||||
copied_links.append(
|
||||
{
|
||||
"from_unit_id": from_id,
|
||||
"to_unit_id": obs_id,
|
||||
"link_type": link["link_type"],
|
||||
"weight": link["weight"],
|
||||
"entity_name": link["entity_name"],
|
||||
}
|
||||
)
|
||||
|
||||
# Keep only direct links between visible nodes
|
||||
direct_links = [
|
||||
link for link in links if link["from_unit_id"] in unit_id_set and link["to_unit_id"] in unit_id_set
|
||||
]
|
||||
|
||||
# Get entity information
|
||||
unit_entities = await conn.fetch(f"""
|
||||
SELECT ue.unit_id, e.canonical_name
|
||||
@@ -2813,6 +2885,18 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
entity_map[unit_id] = []
|
||||
entity_map[unit_id].append(entity_name)
|
||||
|
||||
# For observations, inherit entities from source memories
|
||||
for unit in units:
|
||||
if unit["source_memory_ids"] and unit["id"] not in entity_map:
|
||||
# Collect entities from all source memories
|
||||
source_entities = []
|
||||
for source_id in unit["source_memory_ids"]:
|
||||
if source_id in entity_map:
|
||||
source_entities.extend(entity_map[source_id])
|
||||
if source_entities:
|
||||
# Deduplicate while preserving order
|
||||
entity_map[unit["id"]] = list(dict.fromkeys(source_entities))
|
||||
|
||||
# Build nodes
|
||||
nodes = []
|
||||
for row in units:
|
||||
@@ -2846,14 +2930,15 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
}
|
||||
)
|
||||
|
||||
# Build edges
|
||||
# Build edges (combine direct links and copied links from sources)
|
||||
edges = []
|
||||
for row in links:
|
||||
all_links = direct_links + copied_links
|
||||
for row in all_links:
|
||||
from_id = str(row["from_unit_id"])
|
||||
to_id = str(row["to_unit_id"])
|
||||
link_type = row["link_type"]
|
||||
weight = row["weight"]
|
||||
entity_name = row["entity_name"]
|
||||
entity_name = row.get("entity_name")
|
||||
|
||||
# Color by link type
|
||||
if link_type == "temporal":
|
||||
|
||||
@@ -58,6 +58,7 @@ def _normalize_tool_name(name: str) -> str:
|
||||
- 'functions.done' (OpenAI-style prefix)
|
||||
- 'call=functions.done' (some models)
|
||||
- 'call=done' (some models)
|
||||
- 'done<|channel|>commentary' (malformed special tokens appended)
|
||||
|
||||
Returns the normalized tool name (e.g., 'done', 'recall', etc.)
|
||||
"""
|
||||
@@ -69,6 +70,11 @@ def _normalize_tool_name(name: str) -> str:
|
||||
if name.startswith("functions."):
|
||||
name = name[len("functions.") :]
|
||||
|
||||
# Handle malformed special tokens appended to tool name
|
||||
# e.g., 'done<|channel|>commentary' -> 'done'
|
||||
if "<|" in name:
|
||||
name = name.split("<|")[0]
|
||||
|
||||
return name
|
||||
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ async def tool_search_mental_models(
|
||||
next_param += 1
|
||||
|
||||
if exclude_ids:
|
||||
filters += f" AND id != ALL(${next_param}::uuid[])"
|
||||
filters += f" AND id != ALL(${next_param}::text[])"
|
||||
params.append(exclude_ids)
|
||||
next_param += 1
|
||||
|
||||
|
||||
@@ -782,12 +782,28 @@ Text:
|
||||
usage = TokenUsage() # Track cumulative usage across retries
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
# Use retain-specific overrides if set, otherwise fall back to global LLM config
|
||||
max_retries = (
|
||||
config.retain_llm_max_retries if config.retain_llm_max_retries is not None else config.llm_max_retries
|
||||
)
|
||||
initial_backoff = (
|
||||
config.retain_llm_initial_backoff
|
||||
if config.retain_llm_initial_backoff is not None
|
||||
else config.llm_initial_backoff
|
||||
)
|
||||
max_backoff = (
|
||||
config.retain_llm_max_backoff if config.retain_llm_max_backoff is not None else config.llm_max_backoff
|
||||
)
|
||||
|
||||
extraction_response_json, call_usage = await llm_config.call(
|
||||
messages=[{"role": "system", "content": prompt}, {"role": "user", "content": user_message}],
|
||||
response_format=response_schema,
|
||||
scope="memory_extract_facts",
|
||||
temperature=0.1,
|
||||
max_completion_tokens=config.retain_max_completion_tokens,
|
||||
max_retries=max_retries,
|
||||
initial_backoff=initial_backoff,
|
||||
max_backoff=max_backoff,
|
||||
skip_validation=True, # Get raw JSON, we'll validate leniently
|
||||
return_usage=True,
|
||||
)
|
||||
|
||||
@@ -155,7 +155,6 @@ class LinkExpansionRetriever(GraphRetriever):
|
||||
all_seeds.extend(temporal_seeds)
|
||||
|
||||
if not all_seeds:
|
||||
logger.info("[LinkExpansion] No seeds found, returning empty results")
|
||||
return [], timings
|
||||
|
||||
seed_ids = list({s.id for s in all_seeds})
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Vertex AI token refresher with background refresh and caching."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VertexAITokenRefresher:
|
||||
"""
|
||||
Background token refresher for Vertex AI.
|
||||
|
||||
Refreshes Google Cloud access tokens every 50 minutes to ensure they don't expire (60-min default).
|
||||
Thread-safe token caching for concurrent access from multiple async tasks.
|
||||
"""
|
||||
|
||||
def __init__(self, credentials: Any, project_id: str, region: str):
|
||||
"""
|
||||
Initialize the token refresher.
|
||||
|
||||
Args:
|
||||
credentials: Google Cloud credentials object (from google.auth.default or service_account)
|
||||
project_id: GCP project ID
|
||||
region: GCP region (e.g., "us-central1")
|
||||
"""
|
||||
self._credentials = credentials
|
||||
self._project_id = project_id
|
||||
self._region = region
|
||||
|
||||
# Thread-safe token cache
|
||||
self._token: str | None = None
|
||||
self._token_expiry: datetime | None = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Background refresh task
|
||||
self._refresh_task: asyncio.Task | None = None
|
||||
self._stop_event = asyncio.Event()
|
||||
|
||||
# Initial token fetch (synchronous, must complete before returning)
|
||||
self._refresh_token_sync()
|
||||
|
||||
def _refresh_token_sync(self) -> None:
|
||||
"""Synchronously refresh the token (thread-safe)."""
|
||||
try:
|
||||
import google.auth.transport.requests
|
||||
|
||||
request = google.auth.transport.requests.Request()
|
||||
self._credentials.refresh(request)
|
||||
|
||||
with self._lock:
|
||||
self._token = self._credentials.token
|
||||
self._token_expiry = self._credentials.expiry
|
||||
|
||||
logger.debug(f"Vertex AI token refreshed, expires at {self._token_expiry}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to refresh Vertex AI token: {e}")
|
||||
raise
|
||||
|
||||
async def _refresh_loop(self) -> None:
|
||||
"""Background refresh loop (runs every 50 minutes)."""
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
# Wait 50 minutes or until stop event
|
||||
await asyncio.wait_for(self._stop_event.wait(), timeout=50 * 60)
|
||||
# If we get here, stop was signaled
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
# 50 minutes passed, refresh token
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
await loop.run_in_executor(None, self._refresh_token_sync)
|
||||
except Exception as e:
|
||||
logger.error(f"Background token refresh failed: {e}")
|
||||
# Continue loop - next API call will fail with auth error
|
||||
|
||||
def start_refresh_task(self) -> None:
|
||||
"""Start the background refresh task."""
|
||||
if self._refresh_task is None or self._refresh_task.done():
|
||||
self._refresh_task = asyncio.create_task(self._refresh_loop())
|
||||
logger.info("Vertex AI token refresh task started (refreshes every 50 minutes)")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the background refresh task."""
|
||||
if self._refresh_task is not None and not self._refresh_task.done():
|
||||
self._stop_event.set()
|
||||
try:
|
||||
await asyncio.wait_for(self._refresh_task, timeout=5.0)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Vertex AI token refresh task did not stop within 5 seconds")
|
||||
logger.info("Vertex AI token refresh task stopped")
|
||||
|
||||
def get_token(self) -> str:
|
||||
"""
|
||||
Get current access token (thread-safe).
|
||||
|
||||
Returns:
|
||||
Current Google Cloud access token
|
||||
|
||||
Raises:
|
||||
RuntimeError: If token is not available
|
||||
"""
|
||||
with self._lock:
|
||||
if self._token is None:
|
||||
raise RuntimeError("Vertex AI token not available")
|
||||
return self._token
|
||||
|
||||
def get_base_url(self) -> str:
|
||||
"""
|
||||
Get the Vertex AI OpenAI-compatible endpoint URL.
|
||||
|
||||
Returns:
|
||||
Base URL for Vertex AI OpenAI API
|
||||
"""
|
||||
return (
|
||||
f"https://{self._region}-aiplatform.googleapis.com/v1beta1/"
|
||||
f"projects/{self._project_id}/locations/{self._region}/endpoints/openapi"
|
||||
)
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Built-in tenant extension implementations."""
|
||||
|
||||
from hindsight_api.config import get_config
|
||||
from hindsight_api.extensions.tenant import AuthenticationError, Tenant, TenantContext, TenantExtension
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
@@ -10,11 +11,13 @@ class ApiKeyTenantExtension(TenantExtension):
|
||||
|
||||
This is a simple implementation that:
|
||||
1. Validates the API key matches HINDSIGHT_API_TENANT_API_KEY
|
||||
2. Returns 'public' as the schema for all authenticated requests
|
||||
2. Returns the configured schema (HINDSIGHT_API_DATABASE_SCHEMA, default 'public')
|
||||
for all authenticated requests
|
||||
|
||||
Configuration:
|
||||
HINDSIGHT_API_TENANT_EXTENSION=hindsight_api.extensions.builtin.tenant:ApiKeyTenantExtension
|
||||
HINDSIGHT_API_TENANT_API_KEY=your-secret-key
|
||||
HINDSIGHT_API_DATABASE_SCHEMA=your-schema (optional, defaults to 'public')
|
||||
|
||||
For multi-tenant setups with separate schemas per tenant, implement a custom
|
||||
TenantExtension that looks up the schema based on the API key or token claims.
|
||||
@@ -27,11 +30,11 @@ class ApiKeyTenantExtension(TenantExtension):
|
||||
raise ValueError("HINDSIGHT_API_TENANT_API_KEY is required when using ApiKeyTenantExtension")
|
||||
|
||||
async def authenticate(self, context: RequestContext) -> TenantContext:
|
||||
"""Validate API key and return public schema context."""
|
||||
"""Validate API key and return configured schema context."""
|
||||
if context.api_key != self.expected_api_key:
|
||||
raise AuthenticationError("Invalid API key")
|
||||
return TenantContext(schema_name="public")
|
||||
return TenantContext(schema_name=get_config().database_schema)
|
||||
|
||||
async def list_tenants(self) -> list[Tenant]:
|
||||
"""Return public schema for single-tenant setup."""
|
||||
return [Tenant(schema="public")]
|
||||
"""Return configured schema for single-tenant setup."""
|
||||
return [Tenant(schema=get_config().database_schema)]
|
||||
|
||||
@@ -170,31 +170,56 @@ def main():
|
||||
if args.log_level != config.log_level:
|
||||
config = HindsightConfig(
|
||||
database_url=config.database_url,
|
||||
database_schema=config.database_schema,
|
||||
llm_provider=config.llm_provider,
|
||||
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_max_retries=config.llm_max_retries,
|
||||
llm_initial_backoff=config.llm_initial_backoff,
|
||||
llm_max_backoff=config.llm_max_backoff,
|
||||
llm_timeout=config.llm_timeout,
|
||||
llm_vertexai_project_id=config.llm_vertexai_project_id,
|
||||
llm_vertexai_region=config.llm_vertexai_region,
|
||||
llm_vertexai_service_account_key=config.llm_vertexai_service_account_key,
|
||||
retain_llm_provider=config.retain_llm_provider,
|
||||
retain_llm_api_key=config.retain_llm_api_key,
|
||||
retain_llm_model=config.retain_llm_model,
|
||||
retain_llm_base_url=config.retain_llm_base_url,
|
||||
retain_llm_max_concurrent=config.retain_llm_max_concurrent,
|
||||
retain_llm_max_retries=config.retain_llm_max_retries,
|
||||
retain_llm_initial_backoff=config.retain_llm_initial_backoff,
|
||||
retain_llm_max_backoff=config.retain_llm_max_backoff,
|
||||
retain_llm_timeout=config.retain_llm_timeout,
|
||||
reflect_llm_provider=config.reflect_llm_provider,
|
||||
reflect_llm_api_key=config.reflect_llm_api_key,
|
||||
reflect_llm_model=config.reflect_llm_model,
|
||||
reflect_llm_base_url=config.reflect_llm_base_url,
|
||||
reflect_llm_max_concurrent=config.reflect_llm_max_concurrent,
|
||||
reflect_llm_max_retries=config.reflect_llm_max_retries,
|
||||
reflect_llm_initial_backoff=config.reflect_llm_initial_backoff,
|
||||
reflect_llm_max_backoff=config.reflect_llm_max_backoff,
|
||||
reflect_llm_timeout=config.reflect_llm_timeout,
|
||||
consolidation_llm_provider=config.consolidation_llm_provider,
|
||||
consolidation_llm_api_key=config.consolidation_llm_api_key,
|
||||
consolidation_llm_model=config.consolidation_llm_model,
|
||||
consolidation_llm_base_url=config.consolidation_llm_base_url,
|
||||
consolidation_llm_max_concurrent=config.consolidation_llm_max_concurrent,
|
||||
consolidation_llm_max_retries=config.consolidation_llm_max_retries,
|
||||
consolidation_llm_initial_backoff=config.consolidation_llm_initial_backoff,
|
||||
consolidation_llm_max_backoff=config.consolidation_llm_max_backoff,
|
||||
consolidation_llm_timeout=config.consolidation_llm_timeout,
|
||||
embeddings_provider=config.embeddings_provider,
|
||||
embeddings_local_model=config.embeddings_local_model,
|
||||
embeddings_local_force_cpu=config.embeddings_local_force_cpu,
|
||||
embeddings_tei_url=config.embeddings_tei_url,
|
||||
embeddings_openai_base_url=config.embeddings_openai_base_url,
|
||||
embeddings_cohere_base_url=config.embeddings_cohere_base_url,
|
||||
reranker_provider=config.reranker_provider,
|
||||
reranker_local_model=config.reranker_local_model,
|
||||
reranker_local_force_cpu=config.reranker_local_force_cpu,
|
||||
reranker_local_max_concurrent=config.reranker_local_max_concurrent,
|
||||
reranker_tei_url=config.reranker_tei_url,
|
||||
reranker_tei_batch_size=config.reranker_tei_batch_size,
|
||||
reranker_tei_max_concurrent=config.reranker_tei_max_concurrent,
|
||||
@@ -217,6 +242,7 @@ def main():
|
||||
retain_observations_async=config.retain_observations_async,
|
||||
enable_observations=config.enable_observations,
|
||||
consolidation_batch_size=config.consolidation_batch_size,
|
||||
consolidation_max_tokens=config.consolidation_max_tokens,
|
||||
skip_llm_verification=config.skip_llm_verification,
|
||||
lazy_reranker=config.lazy_reranker,
|
||||
run_migrations_on_startup=config.run_migrations_on_startup,
|
||||
@@ -341,6 +367,7 @@ def main():
|
||||
# Start idle checker in daemon mode
|
||||
if idle_middleware is not None:
|
||||
# Start the idle checker in a background thread with its own event loop
|
||||
import logging
|
||||
import threading
|
||||
|
||||
def run_idle_checker():
|
||||
@@ -351,12 +378,12 @@ def main():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(idle_middleware._check_idle())
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logging.error(f"Idle checker error: {e}", exc_info=True)
|
||||
|
||||
threading.Thread(target=run_idle_checker, daemon=True).start()
|
||||
|
||||
uvicorn.run(**uvicorn_config) # type: ignore[invalid-argument-type] - dict kwargs
|
||||
uvicorn.run(**uvicorn_config)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -40,7 +40,7 @@ class EmbeddedPostgres:
|
||||
# Only set port if explicitly specified
|
||||
if self.port is not None:
|
||||
kwargs["port"] = self.port
|
||||
self._pg0 = Pg0(**kwargs) # type: ignore[invalid-argument-type] - dict kwargs
|
||||
self._pg0 = Pg0(**kwargs)
|
||||
return self._pg0
|
||||
|
||||
async def start(self, max_retries: int = 5, retry_delay: float = 4.0) -> str:
|
||||
|
||||
@@ -183,21 +183,25 @@ def main():
|
||||
|
||||
from ..extensions import TenantExtension, load_extension
|
||||
|
||||
# Load tenant extension BEFORE creating MemoryEngine so it can
|
||||
# set correct schema context during task execution. Without this,
|
||||
# _authenticate_tenant sees no extension and resets schema to "public",
|
||||
# causing worker writes to land in the wrong schema.
|
||||
tenant_extension = load_extension("TENANT", TenantExtension)
|
||||
|
||||
# Initialize MemoryEngine
|
||||
# Workers use SyncTaskBackend because they execute tasks directly,
|
||||
# they don't need to store tasks (they poll from DB)
|
||||
memory = MemoryEngine(
|
||||
run_migrations=False, # Workers don't run migrations
|
||||
task_backend=SyncTaskBackend(),
|
||||
tenant_extension=tenant_extension,
|
||||
)
|
||||
|
||||
await memory.initialize()
|
||||
|
||||
print(f"Database connected: {config.database_url}")
|
||||
|
||||
# Load tenant extension for dynamic schema discovery
|
||||
tenant_extension = load_extension("TENANT", TenantExtension)
|
||||
|
||||
if tenant_extension:
|
||||
print("Tenant extension loaded - schemas will be discovered dynamically on each poll")
|
||||
else:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
description = "Hindsight: Agent Memory That Works Like Human Memory"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -34,6 +34,7 @@ dependencies = [
|
||||
"opentelemetry-exporter-prometheus>=0.41b0",
|
||||
"dateparser>=1.2.2",
|
||||
"google-genai>=1.0.0",
|
||||
"google-auth>=2.0.0",
|
||||
"anthropic>=0.40.0",
|
||||
"typer>=0.9.0",
|
||||
"cohere>=5.0.0",
|
||||
@@ -141,6 +142,11 @@ known-third-party = ["alembic"]
|
||||
quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.uv]
|
||||
# Allow uv to search all configured indexes for packages, not just the first one
|
||||
# This prevents dependency resolution failures when using pytorch index + PyPI
|
||||
index-strategy = "unsafe-best-match"
|
||||
|
||||
[tool.ty]
|
||||
# Type checking configuration
|
||||
# ty is an extremely fast Python type checker from Astral (same team as ruff/uv)
|
||||
|
||||
@@ -1897,3 +1897,93 @@ class TestMentalModelRefreshAfterConsolidation:
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_endpoint_observations_inherit_links_and_entities(
|
||||
self, memory: MemoryEngine, request_context
|
||||
):
|
||||
"""Test that graph endpoint shows links and entities for observations filtered by type.
|
||||
|
||||
When filtering graph by type=observation:
|
||||
- Observations should inherit links from their source memories
|
||||
- Observations should show entities inherited from source memories
|
||||
- Even when source memories are not visible, their links should be copied to observations
|
||||
"""
|
||||
bank_id = f"test-graph-obs-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Create the bank
|
||||
await memory.get_bank_profile(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
# Retain content that will create world facts with shared entities
|
||||
# This should create facts that are linked by shared entities
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Alice works at Google as a software engineer.",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content="Bob also works at Google in the sales department.",
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Wait for consolidation to create observations
|
||||
import asyncio
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Get graph data filtered by observation type only
|
||||
graph_data = await memory.get_graph_data(
|
||||
bank_id=bank_id,
|
||||
fact_type="observation",
|
||||
limit=1000,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
# Should have observations
|
||||
assert graph_data["total_units"] > 0, "Should have observations"
|
||||
assert len(graph_data["nodes"]) > 0, "Should have observation nodes"
|
||||
|
||||
# Verify all nodes are observations
|
||||
for row in graph_data["table_rows"]:
|
||||
assert row["fact_type"] == "observation", f"All nodes should be observations, got {row['fact_type']}"
|
||||
|
||||
# Should have edges (inherited from source memories)
|
||||
# Even though we're only showing observations, they should inherit links from their sources
|
||||
assert len(graph_data["edges"]) > 0, (
|
||||
"Observations should have edges inherited from source memories. "
|
||||
f"Found {len(graph_data['edges'])} edges"
|
||||
)
|
||||
|
||||
# Should have entities (inherited from source memories)
|
||||
observations_with_entities = [
|
||||
row for row in graph_data["table_rows"] if row["entities"] and row["entities"] != "None"
|
||||
]
|
||||
assert len(observations_with_entities) > 0, (
|
||||
"Observations should inherit entities from source memories. "
|
||||
f"Found {len(observations_with_entities)} observations with entities"
|
||||
)
|
||||
|
||||
# Verify entities contain expected values
|
||||
all_entities = " ".join([row["entities"] for row in graph_data["table_rows"]])
|
||||
assert "Alice" in all_entities or "Bob" in all_entities or "Google" in all_entities, (
|
||||
f"Expected to find Alice, Bob, or Google in entities, got: {all_entities}"
|
||||
)
|
||||
|
||||
# Verify edge types are valid
|
||||
valid_link_types = {"semantic", "temporal", "entity"}
|
||||
for edge in graph_data["edges"]:
|
||||
link_type = edge["data"]["linkType"]
|
||||
assert link_type in valid_link_types, f"Invalid link type: {link_type}"
|
||||
|
||||
# Verify all edges connect visible observation nodes
|
||||
visible_node_ids = {row["id"] for row in graph_data["table_rows"]}
|
||||
for edge in graph_data["edges"]:
|
||||
source_id = edge["data"]["source"]
|
||||
target_id = edge["data"]["target"]
|
||||
assert source_id in visible_node_ids, f"Edge source {source_id[:8]} not in visible nodes"
|
||||
assert target_id in visible_node_ids, f"Edge target {target_id[:8]} not in visible nodes"
|
||||
|
||||
# Cleanup
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@@ -1063,3 +1063,38 @@ async def test_retain_async_no_usage(api_client):
|
||||
|
||||
# Usage should be None for async operations
|
||||
assert result.get("usage") is None, "Async retain should not include usage"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_version_endpoint_returns_correct_version(api_client):
|
||||
"""Test that the /version endpoint returns the correct API version.
|
||||
|
||||
The version should match the __version__ defined in hindsight_api.__init__.py
|
||||
and should not be a hardcoded string.
|
||||
"""
|
||||
from hindsight_api import __version__
|
||||
|
||||
# Call the /version endpoint
|
||||
response = await api_client.get("/version")
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
|
||||
# Verify response structure
|
||||
assert "api_version" in result, "Response should include 'api_version' field"
|
||||
assert "features" in result, "Response should include 'features' field"
|
||||
|
||||
# Verify the version matches the package version
|
||||
assert result["api_version"] == __version__, (
|
||||
f"API version should be {__version__}, got {result['api_version']}"
|
||||
)
|
||||
|
||||
# Verify features field structure
|
||||
features = result["features"]
|
||||
assert "observations" in features
|
||||
assert "mcp" in features
|
||||
assert "worker" in features
|
||||
assert isinstance(features["observations"], bool)
|
||||
assert isinstance(features["mcp"], bool)
|
||||
assert isinstance(features["worker"], bool)
|
||||
|
||||
print(f"Version endpoint returned: api_version={result['api_version']}, features={features}")
|
||||
|
||||
@@ -275,3 +275,88 @@ class TestReflectUsesReflectLLMConfig:
|
||||
|
||||
# Verify it's different from the retain config
|
||||
assert engine._reflect_llm_config.model != engine._retain_llm_config.model
|
||||
|
||||
|
||||
class TestRetryAndBackoffConfiguration:
|
||||
"""Test retry and backoff configuration options."""
|
||||
|
||||
def test_global_retry_backoff_config_defaults(self):
|
||||
"""Test that global retry/backoff settings have correct defaults."""
|
||||
from hindsight_api.config import get_config
|
||||
|
||||
config = get_config()
|
||||
|
||||
# Verify global defaults
|
||||
assert config.llm_max_retries == 10
|
||||
assert config.llm_initial_backoff == 1.0
|
||||
assert config.llm_max_backoff == 60.0
|
||||
|
||||
def test_per_operation_retry_backoff_config_from_env(self):
|
||||
"""Test that per-operation retry/backoff settings are loaded from environment."""
|
||||
from hindsight_api.config import clear_config_cache
|
||||
|
||||
# Set per-operation overrides
|
||||
os.environ["HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES"] = "3"
|
||||
os.environ["HINDSIGHT_API_RETAIN_LLM_INITIAL_BACKOFF"] = "2.0"
|
||||
os.environ["HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF"] = "120.0"
|
||||
os.environ["HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES"] = "5"
|
||||
os.environ["HINDSIGHT_API_REFLECT_LLM_INITIAL_BACKOFF"] = "1.5"
|
||||
os.environ["HINDSIGHT_API_REFLECT_LLM_MAX_BACKOFF"] = "90.0"
|
||||
|
||||
try:
|
||||
clear_config_cache()
|
||||
from hindsight_api.config import get_config
|
||||
|
||||
config = get_config()
|
||||
|
||||
# Verify retain overrides
|
||||
assert config.retain_llm_max_retries == 3
|
||||
assert config.retain_llm_initial_backoff == 2.0
|
||||
assert config.retain_llm_max_backoff == 120.0
|
||||
|
||||
# Verify reflect overrides
|
||||
assert config.reflect_llm_max_retries == 5
|
||||
assert config.reflect_llm_initial_backoff == 1.5
|
||||
assert config.reflect_llm_max_backoff == 90.0
|
||||
|
||||
# Verify global defaults remain unchanged
|
||||
assert config.llm_max_retries == 10
|
||||
assert config.llm_initial_backoff == 1.0
|
||||
assert config.llm_max_backoff == 60.0
|
||||
finally:
|
||||
# Clean up
|
||||
os.environ.pop("HINDSIGHT_API_RETAIN_LLM_MAX_RETRIES", None)
|
||||
os.environ.pop("HINDSIGHT_API_RETAIN_LLM_INITIAL_BACKOFF", None)
|
||||
os.environ.pop("HINDSIGHT_API_RETAIN_LLM_MAX_BACKOFF", None)
|
||||
os.environ.pop("HINDSIGHT_API_REFLECT_LLM_MAX_RETRIES", None)
|
||||
os.environ.pop("HINDSIGHT_API_REFLECT_LLM_INITIAL_BACKOFF", None)
|
||||
os.environ.pop("HINDSIGHT_API_REFLECT_LLM_MAX_BACKOFF", None)
|
||||
clear_config_cache()
|
||||
|
||||
def test_per_operation_retry_backoff_fallback_to_global(self):
|
||||
"""Test that per-operation settings fall back to global when not set."""
|
||||
from hindsight_api.config import clear_config_cache, get_config
|
||||
|
||||
# Set only global values
|
||||
os.environ["HINDSIGHT_API_LLM_MAX_RETRIES"] = "7"
|
||||
os.environ["HINDSIGHT_API_LLM_INITIAL_BACKOFF"] = "3.0"
|
||||
os.environ["HINDSIGHT_API_LLM_MAX_BACKOFF"] = "180.0"
|
||||
|
||||
try:
|
||||
clear_config_cache()
|
||||
config = get_config()
|
||||
|
||||
# Per-operation should be None (will fall back to global at runtime)
|
||||
assert config.retain_llm_max_retries is None
|
||||
assert config.retain_llm_initial_backoff is None
|
||||
assert config.retain_llm_max_backoff is None
|
||||
|
||||
# Global values should be set
|
||||
assert config.llm_max_retries == 7
|
||||
assert config.llm_initial_backoff == 3.0
|
||||
assert config.llm_max_backoff == 180.0
|
||||
finally:
|
||||
os.environ.pop("HINDSIGHT_API_LLM_MAX_RETRIES", None)
|
||||
os.environ.pop("HINDSIGHT_API_LLM_INITIAL_BACKOFF", None)
|
||||
os.environ.pop("HINDSIGHT_API_LLM_MAX_BACKOFF", None)
|
||||
clear_config_cache()
|
||||
|
||||
@@ -163,6 +163,12 @@ class TestToolNameNormalization:
|
||||
assert _normalize_tool_name("call=functions.recall") == "recall"
|
||||
assert _normalize_tool_name("call=functions.search_observations") == "search_observations"
|
||||
|
||||
def test_normalize_special_token_suffix(self):
|
||||
"""Tool names with malformed special tokens should be normalized."""
|
||||
assert _normalize_tool_name("done<|channel|>commentary") == "done"
|
||||
assert _normalize_tool_name("recall<|endoftext|>") == "recall"
|
||||
assert _normalize_tool_name("search_observations<|im_end|>extra") == "search_observations"
|
||||
|
||||
def test_is_done_tool(self):
|
||||
"""Test _is_done_tool helper."""
|
||||
# Standard
|
||||
@@ -174,9 +180,14 @@ class TestToolNameNormalization:
|
||||
assert _is_done_tool("call=done") is True
|
||||
assert _is_done_tool("call=functions.done") is True
|
||||
|
||||
# With malformed special tokens
|
||||
assert _is_done_tool("done<|channel|>commentary") is True
|
||||
assert _is_done_tool("done<|endoftext|>") is True
|
||||
|
||||
# Not done
|
||||
assert _is_done_tool("functions.recall") is False
|
||||
assert _is_done_tool("call=functions.recall") is False
|
||||
assert _is_done_tool("recall<|channel|>done") is False
|
||||
|
||||
|
||||
class TestReflectAgentMocked:
|
||||
|
||||
@@ -527,6 +527,7 @@ class TestRemoteTEICrossEncoderConfig:
|
||||
"""Test creating encoder from environment variables."""
|
||||
import os
|
||||
|
||||
from hindsight_api.config import clear_config_cache
|
||||
from hindsight_api.engine.cross_encoder import create_cross_encoder_from_env
|
||||
|
||||
with patch.dict(
|
||||
@@ -538,6 +539,7 @@ class TestRemoteTEICrossEncoderConfig:
|
||||
"HINDSIGHT_API_RERANKER_TEI_MAX_CONCURRENT": "16",
|
||||
},
|
||||
):
|
||||
clear_config_cache() # Clear cache to pick up patched env vars
|
||||
encoder = create_cross_encoder_from_env()
|
||||
|
||||
assert isinstance(encoder, RemoteTEICrossEncoder)
|
||||
@@ -545,6 +547,8 @@ class TestRemoteTEICrossEncoderConfig:
|
||||
assert encoder.batch_size == 256
|
||||
assert encoder.max_concurrent == 16
|
||||
|
||||
clear_config_cache() # Clear cache after test
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# TEI Reranker Performance Benchmark Tests
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
Test Vertex AI provider integration including token refresh and API calls.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# Skip all tests if google-auth not available
|
||||
pytest.importorskip("google.auth")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_refresher_initialization():
|
||||
"""Test token refresher initialization with mocked credentials."""
|
||||
from hindsight_api.engine.vertexai_token_refresher import VertexAITokenRefresher
|
||||
|
||||
# Mock credentials
|
||||
mock_credentials = MagicMock()
|
||||
mock_credentials.token = "test-token-123"
|
||||
mock_credentials.expiry = None
|
||||
|
||||
with patch("google.auth.transport.requests.Request"):
|
||||
refresher = VertexAITokenRefresher(mock_credentials, "test-project", "us-central1")
|
||||
|
||||
# Verify token was fetched
|
||||
assert refresher.get_token() == "test-token-123"
|
||||
|
||||
# Verify base URL is correctly formatted
|
||||
expected_url = (
|
||||
"https://us-central1-aiplatform.googleapis.com/v1beta1/"
|
||||
"projects/test-project/locations/us-central1/endpoints/openapi"
|
||||
)
|
||||
assert refresher.get_base_url() == expected_url
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_refresher_background_refresh():
|
||||
"""Test that background refresh task starts and stops correctly."""
|
||||
from hindsight_api.engine.vertexai_token_refresher import VertexAITokenRefresher
|
||||
|
||||
# Mock credentials
|
||||
mock_credentials = MagicMock()
|
||||
mock_credentials.token = "test-token-123"
|
||||
mock_credentials.expiry = None
|
||||
|
||||
with patch("google.auth.transport.requests.Request"):
|
||||
refresher = VertexAITokenRefresher(mock_credentials, "test-project", "us-central1")
|
||||
|
||||
# Start refresh task
|
||||
refresher.start_refresh_task()
|
||||
assert refresher._refresh_task is not None
|
||||
assert not refresher._refresh_task.done()
|
||||
|
||||
# Stop refresh task
|
||||
await refresher.stop()
|
||||
assert refresher._refresh_task.done()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_refresher_thread_safety():
|
||||
"""Test that token access is thread-safe."""
|
||||
from hindsight_api.engine.vertexai_token_refresher import VertexAITokenRefresher
|
||||
|
||||
# Mock credentials
|
||||
mock_credentials = MagicMock()
|
||||
mock_credentials.token = "test-token-123"
|
||||
mock_credentials.expiry = None
|
||||
|
||||
with patch("google.auth.transport.requests.Request"):
|
||||
refresher = VertexAITokenRefresher(mock_credentials, "test-project", "us-central1")
|
||||
|
||||
# Access token from multiple tasks concurrently
|
||||
async def get_token_task():
|
||||
return refresher.get_token()
|
||||
|
||||
results = await asyncio.gather(*[get_token_task() for _ in range(10)])
|
||||
|
||||
# All should return the same token
|
||||
assert all(token == "test-token-123" for token in results)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_token_refresher_no_token_error():
|
||||
"""Test that getting token without refresh raises error."""
|
||||
from hindsight_api.engine.vertexai_token_refresher import VertexAITokenRefresher
|
||||
|
||||
# Mock credentials that fail to refresh
|
||||
mock_credentials = MagicMock()
|
||||
mock_credentials.token = None
|
||||
|
||||
with patch("google.auth.transport.requests.Request") as mock_request:
|
||||
mock_request.side_effect = Exception("Refresh failed")
|
||||
|
||||
with pytest.raises(Exception, match="Refresh failed"):
|
||||
VertexAITokenRefresher(mock_credentials, "test-project", "us-central1")
|
||||
|
||||
|
||||
def test_llm_wrapper_vertexai_missing_dependency():
|
||||
"""Test error when google-auth is not available."""
|
||||
from hindsight_api.engine import llm_wrapper
|
||||
|
||||
# Temporarily disable Vertex AI availability
|
||||
original_available = llm_wrapper.VERTEXAI_AVAILABLE
|
||||
try:
|
||||
llm_wrapper.VERTEXAI_AVAILABLE = False
|
||||
|
||||
with pytest.raises(ValueError, match="google-auth"):
|
||||
from hindsight_api.engine.llm_wrapper import LLMProvider
|
||||
|
||||
LLMProvider(
|
||||
provider="vertexai",
|
||||
api_key="",
|
||||
base_url="",
|
||||
model="google/gemini-2.0-flash-001",
|
||||
)
|
||||
finally:
|
||||
llm_wrapper.VERTEXAI_AVAILABLE = original_available
|
||||
|
||||
|
||||
def test_llm_wrapper_vertexai_missing_project_id():
|
||||
"""Test error when project ID is not configured."""
|
||||
with patch.dict(os.environ, {"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID": ""}, clear=False):
|
||||
# Clear config cache to reload from env
|
||||
from hindsight_api.config import clear_config_cache
|
||||
|
||||
clear_config_cache()
|
||||
|
||||
with pytest.raises(ValueError, match="HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"):
|
||||
from hindsight_api.engine.llm_wrapper import LLMProvider
|
||||
|
||||
LLMProvider(
|
||||
provider="vertexai",
|
||||
api_key="",
|
||||
base_url="",
|
||||
model="google/gemini-2.0-flash-001",
|
||||
)
|
||||
|
||||
# Restore config cache
|
||||
clear_config_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_wrapper_vertexai_adc_auth():
|
||||
"""Test Vertex AI with ADC authentication (mocked)."""
|
||||
from hindsight_api.engine.llm_wrapper import LLMProvider
|
||||
|
||||
mock_credentials = MagicMock()
|
||||
mock_credentials.token = "test-token-adc"
|
||||
mock_credentials.expiry = None
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID": "test-project"},
|
||||
clear=False,
|
||||
):
|
||||
# Clear config cache to reload from env
|
||||
from hindsight_api.config import clear_config_cache
|
||||
|
||||
clear_config_cache()
|
||||
|
||||
with patch("google.auth.default", return_value=(mock_credentials, "test-project")):
|
||||
with patch("google.auth.transport.requests.Request"):
|
||||
provider = LLMProvider(
|
||||
provider="vertexai",
|
||||
api_key="",
|
||||
base_url="",
|
||||
model="google/gemini-2.0-flash-001",
|
||||
)
|
||||
|
||||
assert provider.provider == "vertexai"
|
||||
assert provider._vertexai_refresher is not None
|
||||
assert "aiplatform.googleapis.com" in provider.base_url
|
||||
|
||||
# Cleanup
|
||||
await provider.cleanup()
|
||||
|
||||
# Restore config cache
|
||||
clear_config_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_wrapper_vertexai_sa_auth():
|
||||
"""Test Vertex AI with service account authentication (mocked)."""
|
||||
from hindsight_api.engine.llm_wrapper import LLMProvider
|
||||
import google.auth.exceptions
|
||||
|
||||
mock_credentials = MagicMock()
|
||||
mock_credentials.token = "test-token-sa"
|
||||
mock_credentials.expiry = None
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID": "test-project",
|
||||
"HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY": "/path/to/key.json",
|
||||
},
|
||||
clear=False,
|
||||
):
|
||||
# Clear config cache to reload from env
|
||||
from hindsight_api.config import clear_config_cache
|
||||
|
||||
clear_config_cache()
|
||||
|
||||
# Mock ADC failure, SA success
|
||||
with patch(
|
||||
"google.auth.default",
|
||||
side_effect=google.auth.exceptions.DefaultCredentialsError("ADC not available"),
|
||||
):
|
||||
with patch(
|
||||
"google.oauth2.service_account.Credentials.from_service_account_file",
|
||||
return_value=mock_credentials,
|
||||
):
|
||||
with patch("google.auth.transport.requests.Request"):
|
||||
provider = LLMProvider(
|
||||
provider="vertexai",
|
||||
api_key="",
|
||||
base_url="",
|
||||
model="google/gemini-2.0-flash-001",
|
||||
)
|
||||
|
||||
assert provider.provider == "vertexai"
|
||||
assert provider._vertexai_refresher is not None
|
||||
|
||||
# Cleanup
|
||||
await provider.cleanup()
|
||||
|
||||
# Restore config cache
|
||||
clear_config_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_llm_wrapper_vertexai_auth_failure():
|
||||
"""Test Vertex AI with both ADC and SA auth failing."""
|
||||
import google.auth.exceptions
|
||||
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{"HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID": "test-project"},
|
||||
clear=False,
|
||||
):
|
||||
# Clear config cache to reload from env
|
||||
from hindsight_api.config import clear_config_cache
|
||||
|
||||
clear_config_cache()
|
||||
|
||||
# Mock both ADC and SA failures
|
||||
with patch(
|
||||
"google.auth.default",
|
||||
side_effect=google.auth.exceptions.DefaultCredentialsError("ADC failed"),
|
||||
):
|
||||
with pytest.raises(ValueError, match="authentication failed"):
|
||||
from hindsight_api.engine.llm_wrapper import LLMProvider
|
||||
|
||||
LLMProvider(
|
||||
provider="vertexai",
|
||||
api_key="",
|
||||
base_url="",
|
||||
model="google/gemini-2.0-flash-001",
|
||||
)
|
||||
|
||||
# Restore config cache
|
||||
clear_config_cache()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv("HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID"),
|
||||
reason="Vertex AI integration tests require HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID",
|
||||
)
|
||||
async def test_vertexai_integration_actual_api():
|
||||
"""
|
||||
Integration test with actual Vertex AI API.
|
||||
|
||||
Requires:
|
||||
- HINDSIGHT_API_LLM_VERTEXAI_PROJECT_ID
|
||||
- ADC or HINDSIGHT_API_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY
|
||||
"""
|
||||
from hindsight_api.engine.llm_wrapper import LLMProvider
|
||||
|
||||
provider = LLMProvider(
|
||||
provider="vertexai",
|
||||
api_key="",
|
||||
base_url="",
|
||||
model="google/gemini-2.0-flash-001",
|
||||
)
|
||||
|
||||
try:
|
||||
# Simple test call
|
||||
response = await provider.call(
|
||||
messages=[{"role": "user", "content": "Say 'ok' and nothing else"}],
|
||||
max_completion_tokens=10,
|
||||
)
|
||||
|
||||
assert response is not None
|
||||
assert isinstance(response, str)
|
||||
assert len(response) > 0
|
||||
|
||||
finally:
|
||||
# Cleanup
|
||||
await provider.cleanup()
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "hindsight-cli"
|
||||
version = "0.3.0"
|
||||
version = "0.4.2"
|
||||
edition = "2021"
|
||||
authors = ["Hindsight Team"]
|
||||
description = "A beautiful CLI for Hindsight - semantic memory system"
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
use std::process::Command;
|
||||
|
||||
#[test]
|
||||
fn test_cli_help() {
|
||||
let output = Command::new("cargo")
|
||||
.args(["run", "--", "--help"])
|
||||
.output()
|
||||
.expect("Failed to execute command");
|
||||
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("Hindsight CLI"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cli_version() {
|
||||
let output = Command::new("cargo")
|
||||
.args(["run", "--", "--version"])
|
||||
.output()
|
||||
.expect("Failed to execute command");
|
||||
|
||||
assert!(output.status.success());
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("hindsight"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_command_without_config() {
|
||||
// Test that the ui command handles missing config gracefully
|
||||
// Create a temp home directory with no config
|
||||
let temp_dir = std::env::temp_dir().join(format!("hindsight-test-ui-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
|
||||
|
||||
let output = Command::new("cargo")
|
||||
.args(["run", "--", "ui"])
|
||||
.env_remove("HINDSIGHT_API_URL")
|
||||
.env_remove("HINDSIGHT_API_KEY")
|
||||
.env("HOME", &temp_dir)
|
||||
.output()
|
||||
.expect("Failed to execute command");
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
// Either it fails with a config error or it succeeds if there's a default config
|
||||
// Just verify it doesn't crash unexpectedly
|
||||
assert!(
|
||||
!output.status.success()
|
||||
|| stdout.contains("Launching Hindsight Control Plane UI")
|
||||
|| stderr.contains("Configuration error")
|
||||
|| stderr.contains("HINDSIGHT_API_URL"),
|
||||
"Unexpected output - stdout: {}, stderr: {}",
|
||||
stdout,
|
||||
stderr
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
std::fs::remove_dir_all(&temp_dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ui_command_with_config() {
|
||||
// This test is skipped by default since it requires a running control plane
|
||||
// and would block for a long time. The other tests cover the basic functionality.
|
||||
// To run this test manually:
|
||||
// 1. Build the control plane: cd hindsight-control-plane && npm run build
|
||||
// 2. Run: cargo test test_ui_command_with_config -- --ignored
|
||||
|
||||
// Just verify that the ui command accepts the configuration
|
||||
let temp_dir = std::env::temp_dir().join(format!("hindsight-test-ui-valid-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
|
||||
|
||||
// Write a minimal config
|
||||
let config_dir = temp_dir.join(".config").join("hindsight");
|
||||
std::fs::create_dir_all(&config_dir).expect("Failed to create config dir");
|
||||
let config_file = config_dir.join("config");
|
||||
std::fs::write(&config_file, "api_url=http://localhost:8888\napi_key=test-key\n")
|
||||
.expect("Failed to write config");
|
||||
|
||||
let output = Command::new("cargo")
|
||||
.args(["run", "--", "ui", "--help"])
|
||||
.env("HOME", &temp_dir)
|
||||
.output()
|
||||
.expect("Failed to execute command");
|
||||
|
||||
// The --help should work regardless
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("Hindsight CLI") || output.status.success());
|
||||
|
||||
// Cleanup
|
||||
std::fs::remove_dir_all(&temp_dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_configure_command() {
|
||||
// Test that configure command creates/updates config
|
||||
let temp_dir = std::env::temp_dir().join(format!("hindsight-test-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&temp_dir).expect("Failed to create temp dir");
|
||||
|
||||
let output = Command::new("cargo")
|
||||
.args([
|
||||
"run",
|
||||
"--",
|
||||
"configure",
|
||||
"--api-url",
|
||||
"http://localhost:9999",
|
||||
"--api-key",
|
||||
"test-key-123"
|
||||
])
|
||||
.env("HOME", &temp_dir)
|
||||
.output()
|
||||
.expect("Failed to execute command");
|
||||
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"Configure command failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(stdout.contains("Configuration saved") || stdout.contains("success"));
|
||||
|
||||
// Cleanup
|
||||
std::fs::remove_dir_all(&temp_dir).ok();
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
@@ -489,7 +489,7 @@ class Configuration:
|
||||
return "Python SDK Debug Report:\n"\
|
||||
"OS: {env}\n"\
|
||||
"Python Version: {pyversion}\n"\
|
||||
"Version of the API: 0.1.0\n"\
|
||||
"Version of the API: 0.4.2\n"\
|
||||
"SDK Package Version: 0.0.7".\
|
||||
format(env=sys.platform, pyversion=sys.version)
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 0.1.0
|
||||
The version of the OpenAPI document: 0.4.2
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user