Compare commits

..
5 Commits
Author SHA1 Message Date
Nicolò Boschi b899e5598f speed up batch writes 2025-12-04 16:44:48 +01:00
Nicolò Boschi d9837e2ffb Release v0.0.18
- Update version to 0.0.18 in all components
- Python packages: hindsight-api, hindsight-dev, hindsight-dev/benchmarks
- Python client: hindsight-clients/python
- TypeScript client: hindsight-clients/typescript
- Rust CLI: hindsight-cli
- Control Plane: hindsight-control-plane
- Helm chart
2025-12-04 16:12:37 +01:00
Nicolò Boschi 3c1c76cb94 remove uuid 2025-12-04 16:12:11 +01:00
Nicolò Boschi c1d37d115a remove locomo 2025-12-04 16:04:09 +01:00
Nicolò Boschi fc5b4998f7 fix docker image 2025-12-04 15:57:59 +01:00
1845 changed files with 17401 additions and 18472 deletions
-16
View File
@@ -13,19 +13,3 @@ HINDSIGHT_API_LOG_LEVEL=info
# Database (Optional - uses embedded pg0 by default)
# HINDSIGHT_API_DATABASE_URL=postgresql://user:pass@host:5432/db
# Embeddings Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
# HINDSIGHT_API_EMBEDDINGS_PROVIDER=local
# For local provider:
# HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL=BAAI/bge-small-en-v1.5
# For TEI provider:
# HINDSIGHT_API_EMBEDDINGS_TEI_URL=http://localhost:8080
# Reranker Configuration (Optional - uses local by default)
# Provider: "local" (default) or "tei" (HuggingFace Text Embeddings Inference)
# HINDSIGHT_API_RERANKER_PROVIDER=local
# For local provider:
# HINDSIGHT_API_RERANKER_LOCAL_MODEL=cross-encoder/ms-marco-MiniLM-L-6-v2
# For TEI provider:
# HINDSIGHT_API_RERANKER_TEI_URL=http://localhost:8081
-11
View File
@@ -1,11 +0,0 @@
name: 'Setup pg0'
description: 'Install pg0 embedded PostgreSQL'
runs:
using: 'composite'
steps:
- name: Install pg0
shell: bash
run: |
curl -fsSL https://raw.githubusercontent.com/vectorize-io/pg0/main/install.sh | bash
echo "$HOME/.pg0/bin" >> $GITHUB_PATH
+49 -253
View File
@@ -4,11 +4,55 @@ on:
pull_request:
branches: [ main ]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-python-packages:
runs-on: ubuntu-latest
strategy:
matrix:
include:
- name: hindsight-all
path: hindsight
- name: hindsight-api
path: hindsight-api
- name: hindsight-client
path: hindsight-clients/python
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Build ${{ matrix.name }}
working-directory: ./${{ matrix.path }}
run: uv build
build-typescript-client:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
working-directory: ./hindsight-clients/typescript
run: npm ci
- name: Build TypeScript client
working-directory: ./hindsight-clients/typescript
run: npm run build
build-docs:
runs-on: ubuntu-latest
@@ -103,11 +147,11 @@ jobs:
test-api:
runs-on: ubuntu-latest
needs: [build-python-packages]
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
@@ -116,20 +160,12 @@ jobs:
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Install pg0
uses: ./.github/actions/setup-pg0
- name: Build API
working-directory: ./hindsight-api
run: uv build
- name: Install dependencies
working-directory: ./hindsight-api
run: uv sync --extra test
@@ -137,243 +173,3 @@ jobs:
- name: Run tests
working-directory: ./hindsight-api
run: uv run pytest tests -v
test-python-client:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Install pg0
uses: ./.github/actions/setup-pg0
- name: Build API
working-directory: ./hindsight-api
run: uv build
- name: Build Python client
working-directory: ./hindsight-clients/python
run: uv build
- name: Install client test dependencies
working-directory: ./hindsight-clients/python
run: uv sync --extra test
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync
- name: Create .env file
run: |
cat > .env << EOF
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
EOF
- name: Start API server
run: |
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
echo "Waiting for API server to be ready..."
for i in {1..60}; do
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
echo "API server is ready after ${i}s"
break
fi
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
sleep 1
done
- name: Run Python client tests
working-directory: ./hindsight-clients/python
run: uv run pytest tests -v
- name: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-typescript-client:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install pg0
uses: ./.github/actions/setup-pg0
- name: Build API
working-directory: ./hindsight-api
run: uv build
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync
- name: Install TypeScript client dependencies
working-directory: ./hindsight-clients/typescript
run: npm ci
- name: Build TypeScript client
working-directory: ./hindsight-clients/typescript
run: npm run build
- name: Create .env file
run: |
cat > .env << EOF
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
EOF
- name: Start API server
run: |
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
echo "Waiting for API server to be ready..."
for i in {1..60}; do
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
echo "API server is ready after ${i}s"
break
fi
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
sleep 1
done
- name: Run TypeScript client tests
working-directory: ./hindsight-clients/typescript
run: npm test
- name: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
test-rust-client:
runs-on: ubuntu-latest
env:
HINDSIGHT_API_LLM_PROVIDER: groq
HINDSIGHT_API_LLM_API_KEY: ${{ secrets.GROQ_API_KEY }}
HINDSIGHT_API_LLM_MODEL: openai/gpt-oss-20b
HINDSIGHT_API_URL: http://localhost:8888
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
prune-cache: false
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version-file: ".python-version"
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
hindsight-clients/rust/target
key: ${{ runner.os }}-cargo-client-${{ hashFiles('hindsight-clients/rust/Cargo.lock') }}
- name: Install pg0
uses: ./.github/actions/setup-pg0
- name: Build API
working-directory: ./hindsight-api
run: uv build
- name: Install API dependencies
working-directory: ./hindsight-api
run: uv sync
- name: Create .env file
run: |
cat > .env << EOF
HINDSIGHT_API_LLM_PROVIDER=${{ env.HINDSIGHT_API_LLM_PROVIDER }}
HINDSIGHT_API_LLM_API_KEY=${{ env.HINDSIGHT_API_LLM_API_KEY }}
HINDSIGHT_API_LLM_MODEL=${{ env.HINDSIGHT_API_LLM_MODEL }}
EOF
- name: Start API server
run: |
./scripts/dev/start-api.sh > /tmp/api-server.log 2>&1 &
echo "Waiting for API server to be ready..."
for i in {1..60}; do
if curl -sf http://localhost:8888/health > /dev/null 2>&1; then
echo "API server is ready after ${i}s"
break
fi
if [ $i -eq 60 ]; then
echo "API server failed to start after 60s"
cat /tmp/api-server.log
exit 1
fi
sleep 1
done
- name: Run Rust client tests
working-directory: ./hindsight-clients/rust
run: cargo test --lib
- name: Show API server logs
if: always()
run: |
echo "=== API Server Logs ==="
cat /tmp/api-server.log || echo "No API server log found"
+1 -3
View File
@@ -31,6 +31,4 @@ logs/
hindsight-dev/benchmarks/locomo/results/
hindsight-dev/benchmarks/longmemeval/results/
hindsight-cli/target
hindsight-clients/rust/target
hindsight-dev/benchmarks/longmemeval/results/
-147
View File
@@ -1,147 +0,0 @@
# AGENTS.md
This document captures architectural decisions and coding conventions for the Hindsight project.
## Documentation
- **Main documentation**: [hindsight-docs/docs/developer/](./hindsight-docs/docs/developer/)
- **Use case patterns**: [hindsight-docs/docs/cookbook/](./hindsight-docs/docs/cookbook/)
- **API reference**: Auto-generated from OpenAPI spec
## Project Structure
```
hindsight/ # Python package for embedded usage
hindsight-api/ # FastAPI server (core memory engine)
hindsight-cli/ # Rust CLI client
hindsight-control-plane/ # Next.js admin UI
hindsight-docs/ # Docusaurus documentation site
hindsight-dev/ # Development tools and benchmarks
hindsight-integrations/ # Framework integrations (LangChain, etc.)
hindsight-clients/ # Generated API clients (Python, TypeScript, Rust)
```
## Core Concepts
### Memory Banks
- Each bank is an isolated memory store (like a "brain" for one user/agent)
- Banks contain: memory units (facts), entities, documents, entity links
- Banks have a **disposition** (personality traits) and **background** (context)
- Bank isolation is strict - no cross-bank data leakage
### Memory Types
- **World facts**: General knowledge ("The sky is blue")
- **Experience facts**: Personal experiences ("I visited Paris in 2023")
- **Opinion facts**: Beliefs with confidence scores ("Paris is beautiful" - 0.9 confidence)
### Operations
- **Retain**: Store new memories (extracts facts, entities, relationships)
- **Recall**: Retrieve memories (semantic, BM25, graph, temporal search)
- **Reflect**: Deep analysis to form new insights/opinions
## API Design Decisions
### Single Bank Per Request
- All API endpoints (`recall`, `reflect`, `retain`) operate on a single bank
- Multi-bank queries are the **client/agent's responsibility** to orchestrate
- This keeps the API simple and the isolation model clear
### Disposition Traits (3-trait system)
- **Skepticism** (1-5): How skeptical vs trusting when forming opinions
- **Literalism** (1-5): How literally to interpret information
- **Empathy** (1-5): How much to consider emotional context
- These influence the `reflect` operation, not `recall`
- Background info also only affects `reflect` (opinion formation)
## Multi-Bank Architecture Patterns
See [hindsight-docs/docs/cookbook/](./hindsight-docs/docs/cookbook/) for detailed guides:
- **Per-User Memory**: One bank per user, simplest pattern
- **Support Agent + Shared Knowledge**: User bank + shared docs bank, client orchestrates
## Developer Guide
### Running the API Server
```bash
# From project root
./scripts/dev/start-api.sh
# With options
./scripts/dev/start-api.sh --reload --port 8888 --log-level debug
```
### Running Tests
```bash
# API tests
cd hindsight-api
uv run pytest tests/
# Specific test
uv run pytest tests/test_http_api_integration.py -v
```
### Generating OpenAPI Spec
After changing API endpoints, regenerate the OpenAPI spec and docs:
```bash
./scripts/generate-openapi.sh
```
This will:
1. Generate `openapi.json` at project root
2. Copy to `hindsight-docs/openapi.json`
3. Regenerate API reference documentation
### Generating API Clients
After updating the OpenAPI spec, regenerate all clients:
```bash
./scripts/generate-clients.sh
```
This generates:
- **Rust client**: `hindsight-clients/rust/` (via progenitor in build.rs)
- **Python client**: `hindsight-clients/python/` (via openapi-generator Docker)
- **TypeScript client**: `hindsight-clients/typescript/` (via @hey-api/openapi-ts)
Note: The maintained wrapper `hindsight_client.py` and `README.md` are preserved during regeneration.
### Running the Documentation Site
```bash
./scripts/dev/start-docs.sh
```
### Running the Control Plane
```bash
./scripts/dev/start-control-plane.sh
```
## Code Style
### Python (hindsight-api)
- Use `uv` for package management
- Async throughout (asyncpg, async FastAPI endpoints)
- Pydantic models for request/response validation
- No py files at project root - maintain clean directory structure
### TypeScript (control-plane, clients)
- Next.js with App Router for control plane
- Tailwind CSS with shadcn/ui components
### Rust (CLI)
- Async with tokio
- reqwest for HTTP client
- progenitor for API client generation
## Database
- PostgreSQL with pgvector extension
- Schema managed via Alembic migrations in `hindsight-api/alembic/`, db migrations happen during api startup, no manual commands
- Key tables: `banks`, `memory_units`, `documents`, `entities`, `entity_links`
+933
View File
@@ -0,0 +1,933 @@
# Hindsight: A Unified Memory System for AI Agents with Temporal Retrieval and Personality-Driven Reasoning
## Abstract
We present **Hindsight**, a comprehensive memory architecture for conversational AI agents that combines multi-strategy retrieval with personality-driven reasoning to enable both high-recall factual search and consistent, trait-based opinion formation. The system consists of two integrated components: **TEMPR (Temporal Entity Memory Priming Retrieval)** for memory recall, and **CARA (Coherent Adaptive Reasoning Agents)** for personality-aware reflection. TEMPR achieves strong retrieval performance through four parallel search strategies—semantic vector search, BM25 keyword matching, graph-based spreading activation incorporating multiple link types (entity, semantic, temporal, causal), and temporal-aware graph traversal—achieving 73.50% on LoComo and 80.60% on LongMemEval benchmarks, with particularly strong performance on multi-hop reasoning (+15.8% over baseline). CARA builds on TEMPR's four-network architecture (world facts, bank experiences, opinions, and observations) to enable personality-driven reasoning using the Big Five model, allowing agents to form and evolve opinions influenced by configurable traits while maintaining epistemic clarity between objective information and subjective beliefs. A novel observation paradigm automatically synthesizes entity-level summaries from multiple facts, creating structured mental models of people, organizations, and concepts without personality influence. The combination enables AI agents with long-term memory that can both retrieve information accurately and reason consistently with stable character traits.
---
# Part I: Recall - TEMPR (Temporal Entity Memory Priming Retrieval)
## 1. Introduction to Recall
Conversational AI agents face a fundamental challenge: maintaining coherent, context-aware memories across extended interactions. Traditional search systems are optimized for human users with top-k ranking and relevance feedback, but AI agents have fundamentally different requirements: they need to retrieve variable amounts of information based on reasoning complexity while respecting LLM context windows. Existing approaches rely either on vector similarity search, which captures semantic relationships but misses entity-level connections, or on keyword matching, which provides precision but lacks conceptual understanding. Neither approach adequately handles the temporal aspects of memory or entity-based reasoning that enable multi-hop information discovery.
We propose TEMPR, a memory retrieval architecture designed specifically for AI agents that combines established information retrieval techniques—semantic vector search, BM25 keyword matching, spreading activation graph traversal (Anderson 1983), and neural reranking—into a unified system optimized for agent workflows. The key architectural choices are:
1. **Agent-Optimized Interface**: budget and max_tokens parameters instead of traditional top-k ranking
2. **Comprehensive Narrative Fact Extraction with Temporal Ranges**: LLM-powered extraction that creates self-contained narrative facts preserving full conversational context, extracting temporal ranges (occurred_start/end) to distinguish point events from periods
3. **Entity-Aware Graph Structure with Multiple Link Types**: LLM-based entity resolution and linking that connects memories through shared identities, along with temporal, semantic, and causal link types
4. **Four-Way Parallel Retrieval**: Semantic, keyword, graph-based (spreading activation), and temporal range retrieval strategies executed in parallel and fused using RRF (Cormack et al. 2009)
5. **Neural Cross-Encoder Reranking**: Learned query-document relevance with temporal awareness and token budget filtering
This combination of techniques enables agents to discover indirectly related information through graph traversal while maintaining temporal awareness, achieving strong performance on multi-hop reasoning tasks.
### 1.1 Contributions
Our key contributions for the recall system are:
1. **Agent-Optimized Retrieval Interface**: Unlike traditional top-k search optimized for human users, we introduce budget and max_tokens parameters that allow AI agents to dynamically trade off latency for recall based on reasoning complexity and context window constraints
2. **Four-Way Parallel Retrieval**: We combine semantic vector search, BM25 keyword matching, graph-based spreading activation (Anderson 1983), and temporal-aware graph traversal into a unified parallel retrieval pipeline using Reciprocal Rank Fusion (Cormack et al. 2009) and neural cross-encoder reranking. The graph traversal incorporates multiple link types (entity, semantic, temporal, causal) with configurable weighting during activation spreading.
3. **LLM-Based Knowledge Graph Construction with Temporal Ranges**: We leverage open-source LLMs for comprehensive narrative fact extraction, entity recognition, and entity disambiguation. The system extracts temporal ranges (occurred_start, occurred_end) to represent both point events and extended periods, distinguishing when facts occurred from when they were mentioned.
4. **Strong Performance on Multi-Hop Reasoning**: 73.50% on LoComo and 80.60% on LongMemEval, with particularly strong performance on multi-hop queries (+15.8% over Mem0), demonstrating the effectiveness of combining these techniques for discovering indirectly related information in conversational contexts
## 2. Memory Organization
### 2.1 Four Memory Networks
TEMPR organizes memories into four distinct networks for epistemic clarity:
**World Network** (fact_type='world'): Objective information about the world
- Example: "Alice works at Google in Mountain View on the AI team"
- Stores facts received from external sources
- No confidence scores (facts are information received, not beliefs)
**Bank Network** (fact_type='bank'): Biographical information about the agent itself
- Example: "I recommended Yosemite National Park to Alice for hiking"
- Stores the agent's own actions and experiences
- Uses first-person perspective ("I recommended..." not "The agent recommended...")
**Opinion Network** (fact_type='opinion'): Subjective beliefs formed by the agent
- Example: "Python is better for data science because of libraries like pandas (confidence: 0.85)"
- Stores judgments and opinions with confidence scores
- Evolved through opinion reinforcement when new evidence arrives
- Influenced by personality traits (see Part II: Reflect)
**Observation Network** (fact_type='observation'): Synthesized entity summaries
- Example: "Alice is a software engineer at Google specializing in machine learning"
- Objective syntheses from multiple facts about an entity
- Generated WITHOUT personality influence (unlike opinions)
- Automatically created and updated in background processes
- Provides structured "mental models" of entities
This separation provides:
- **Epistemic Clarity**: Facts represent information encountered; opinions represent personality-driven judgments; observations represent objective syntheses
- **Traceability**: Opinion reinforcement traces facts; observations trace entity-related facts
- **Debugging**: Developers can separately inspect factual knowledge, formed beliefs, and entity models
- **Confidence Semantics**: Facts and observations lack confidence scores; opinions have confidence scores representing conviction strength
- **Personality Independence**: Observations remain objective while opinions reflect personality
### 2.2 Memory Unit Structure
Each memory is represented as a self-contained node with:
- id: Unique UUID
- bank_id: Identifier for the memory bank this memory belongs to
- text: Self-contained comprehensive narrative fact
- embedding: 384-dimensional vector (BAAI/bge-small-en-v1.5)
- event_date: Timestamp when the fact became true (maintained for backward compatibility)
- occurred_start: Timestamp when the fact/event started (temporal range support)
- occurred_end: Timestamp when the fact/event ended (temporal range support)
- mentioned_at: Timestamp when the fact was mentioned/learned
- context: Optional contextual metadata
- fact_type: One of 'world', 'bank', 'opinion'
- confidence_score: For opinions only, strength of conviction (0.0-1.0)
- access_count: Frequency-based importance signal
- search_vector: Full-text search tsvector for BM25 ranking
### 2.3 LLM-Powered Comprehensive Narrative Fact Extraction
TEMPR employs **LLM-powered comprehensive narrative fact extraction** using open-source models. This approach provides more context-aware extraction compared to traditional rule-based NLP pipelines, though at higher computational cost.
#### 2.3.1 Extraction Principles
**Chunking Strategy**: TEMPR uses a coarse-grained chunking approach, extracting 2-5 comprehensive facts per conversation rather than dozens of atomic fragments. This is a deliberate tradeoff: larger chunks preserve more context and narrative flow, at the cost of reduced precision when only a small portion of the chunk is relevant.
Each fact should:
1. **Capture entire conversations or exchanges** - Include the full back-and-forth discussion
2. **Be narrative and comprehensive** - Tell the complete story with all context
3. **Be self-contained** - Readable without the original text
4. **Include all participants** - WHO said/did WHAT, with their reasoning
5. **Preserve the flow** - Keep related exchanges together in one fact
**Example Comparison**:
**Fragmented Approach** (traditional):
- "Bob suggested Summer Vibes"
- "Alice wanted something unique"
- "They considered Sunset Sessions"
- "Alice likes Beach Beats"
- "They chose Beach Beats"
**Comprehensive Approach** (TEMPR):
- "Alice and Bob discussed naming their summer party playlist. Bob suggested 'Summer Vibes' because it's catchy and seasonal, but Alice wanted something more unique. Bob then proposed 'Sunset Sessions' and 'Beach Beats', with Alice favoring 'Beach Beats' for its playful and fun tone. They ultimately decided on 'Beach Beats' as the final name."
#### 2.3.2 Open-Source LLM Extraction Pipeline
The extraction process leverages open-source LLMs with structured output (Pydantic schemas). This follows the established practice of using LLMs for information extraction, which has been shown to improve context understanding compared to rule-based NLP pipelines, particularly for:
- Coreference resolution in conversational text
- Domain-specific entity recognition
- Maintaining narrative coherence across multi-turn exchanges
**LLM Extraction Steps**:
1. **Pronoun Resolution**: "She loves hiking" → "Alice loves hiking"
2. **Temporal Normalization**: "last year" → "in 2023" (absolute dates)
3. **Temporal Range Extraction**: Identify when facts occurred vs. when mentioned
- Point events: "on July 14" → occurred_start = occurred_end = 2023-07-14
- Period events: "in February 2023" → occurred_start = 2023-02-01, occurred_end = 2023-02-28
- Vague periods: "lately" → estimated range based on context
- mentioned_at = conversation date (when fact was learned)
4. **Participant Attribution**: Preserve WHO said/did WHAT
5. **Reasoning Preservation**: Include WHY decisions were made
6. **Fact Type Classification**: Determine fact categories (world, bank, opinion)
7. **Entity Extraction**: Identify all entities (PERSON, ORG, LOCATION, PRODUCT, CONCEPT)
**Temporal Augmentation**: Before embedding, facts are augmented with readable temporal information:
- Original: "Alice started working at Google"
- Augmented for embedding: "Alice started working at Google (happened in November 2023)"
This augmentation helps semantic search understand temporal relevance without modifying the stored fact text.
### 2.4 Entity Resolution and Linking
Entity resolution creates strong connections between memories that share common entities, solving the problem where semantically dissimilar facts are related through shared identities.
#### 2.4.1 LLM-Based Entity Recognition
TEMPR uses the same open-source LLM that performs fact extraction to also identify and extract entities during the narrative fact creation process. This unified approach eliminates the brittleness of traditional NER pipelines that struggle with domain-specific entities, novel names, and context-dependent disambiguation.
**Entity Types**:
- PERSON: "Alice", "Bob Chen"
- ORGANIZATION: "Google", "Stanford University"
- LOCATION: "Yosemite National Park", "California"
- PRODUCT: "Python", "pandas library"
- CONCEPT: "machine learning", "remote work"
- OTHER: Miscellaneous proper nouns
#### 2.4.2 LLM-Based Entity Disambiguation
Multiple mentions of entities (e.g., "Alice", "Alice Chen", "Alice C.") must be resolved to a single canonical entity. TEMPR uses the LLM to perform entity disambiguation, analyzing the surrounding context to determine if two entity mentions refer to the same entity. This handles complex cases like:
- Nicknames and formal names ("Bob" vs. "Robert Chen")
- Partial mentions ("Alice" vs. "Alice Chen")
- Context-dependent disambiguation ("Apple the company" vs. "apple the fruit")
The LLM considers multiple signals:
- **Name Similarity**: String similarity using Levenshtein distance
- **Co-occurrence Patterns**: Entities mentioned together frequently are likely distinct
- **Temporal Proximity**: Recent mentions are more likely to refer to the same entity
#### 2.4.3 Entity Link Structure
Each entity creates a link_type='entity' edge between all memories mentioning it:
**Properties**:
- weight=1.0 (constant, no temporal decay)
- entity_id: Reference to resolved canonical entity
- Bidirectional connections between all mentioning memories
**Impact on Retrieval**: Entity links enable graph traversal to discover indirectly related facts:
**Example Query**: "What does Alice do?"
1. **Semantic Match**: "Alice works at Google in Mountain View..." (direct match)
2. **Entity Traversal**: Follow entity links for "Alice" →
- "Alice loves hiking in Yosemite..." (different semantic space)
- "I recommended technical books to Alice" (Bank Network, via "Alice")
3. **Chained Traversal**: Follow "Google" entity →
- "Google's office in Mountain View has excellent amenities"
### 2.5 Link Types and Graph Structure
The memory graph contains four types of edges connecting memory units:
#### 2.5.1 Temporal Links
Temporal links connect memories close in time, enabling temporal reasoning:
**Creation Logic**:
**Properties**:
- Decays linearly with time distance
- Minimum weight 0.3 to maintain some connectivity
- Enables "What happened around the same time?" queries
#### 2.5.2 Semantic Links
Semantic links connect memories with similar meanings:
**Creation Logic**:
**Properties**:
- Uses pgvector HNSW index for efficient nearest-neighbor search
- Higher threshold (0.7) than retrieval (0.3) to avoid over-connection
- Weight equals cosine similarity score
#### 2.5.3 Entity Links
Entity links (described in Section 2.4.3) create the strongest connections:
**Properties**:
- weight=1.0 (constant, never decays)
- Connects all memories mentioning the same resolved entity
- Most reliable traversal path during graph search
#### 2.5.4 Causal Links
Causal links represent identified cause-effect relationships between facts. During fact extraction, the LLM attempts to identify causal relationships between facts extracted from the same conversation. These links are incorporated as one component of the graph retrieval system.
**Causal Relationship Types**:
- causes: This fact directly causes the target fact
- caused_by: This fact was caused by the target fact (inverse of causes)
- enables: This fact enables or allows the target fact to happen
- prevents: This fact prevents or blocks the target fact
**Properties**:
- weight: Strength of causal relationship ∈ [0.0, 1.0] (default 1.0)
- Directional edges (from cause to effect)
- Prioritized during graph traversal with 2x activation boost
**Role in Retrieval**: Causal links provide an additional signal during graph-based retrieval. When present, they allow the system to traverse explanatory relationships in addition to semantic, temporal, and entity-based connections.
**Example**: For a query "Why does Alice spend time in the garden?", the system may find both direct semantic matches ("Alice spends time in the garden to find comfort") and traverse causal links to related facts ("Alice lost her friend Karlie in February 2023").
**Graph Density**: Each memory unit typically has:
- 5-10 temporal links (to nearby memories)
- 3-5 semantic links (to similar content)
- Variable entity links (depending on entity mention frequency)
- 0-3 causal links (when causal relationships are identified)
### 2.6 The Observation Paradigm
A critical challenge in long-term memory systems is maintaining structured, high-level understanding of entities (people, organizations, places, concepts) without re-reading all individual facts each time. Traditional approaches either retrieve all entity-related facts (expensive, noisy) or maintain no entity-level state (losing structured understanding). Hindsight introduces **observations**—automatically synthesized entity summaries that provide structured "mental models" without personality influence.
#### 2.6.1 Motivation and Design
**The Problem**: When a system accumulates dozens of facts about an entity like "Alice," queries about Alice must either:
1. Retrieve all 50+ individual facts (expensive, overwhelming)
2. Rely only on top-k semantic matches (may miss key attributes)
3. Manually maintain entity profiles (doesn't scale, requires human curation)
**The Solution**: Observations provide a fourth fact type that synthesizes multiple facts into coherent, objective entity summaries, automatically maintained as new information arrives.
**Key Properties**:
- **Objective Synthesis**: Generated WITHOUT personality influence (unlike opinions)
- **Entity-Scoped**: Each observation is about a single entity
- **Automatic Maintenance**: Generated in background after fact ingestion
- **Multi-Fact Fusion**: Combines information scattered across multiple facts
- **Response Augmentation**: NOT used for retrieval/search, but returned alongside results when include_entities=True to provide entity context
#### 2.6.2 Observation Generation
Observations are generated through an LLM-powered synthesis process:
**Trigger**: When new facts mentioning an entity are ingested via retain(), a background task is queued to regenerate observations for that entity.
**Process**:
**LLM Prompt Structure**:
**Example Transformation**:
**Input Facts**:
- "Alice works at Google"
- "Alice is a software engineer"
- "Alice specializes in ML and deep learning"
- "Alice joined Google in 2023"
- "Alice is detail-oriented and methodical"
**Generated Observations**:
- "Alice is a software engineer at Google specializing in machine learning and deep learning"
- "Alice joined Google in 2023"
- "Alice is detail-oriented and methodical in her approach"
#### 2.6.3 Storage and Retrieval
**Storage**: Observations are stored as regular memory_units with fact_type='observation':
**Entity Links**: Observations are linked to their entity via the entity_links table, enabling efficient lookup of all observations for an entity.
**Important**: Observations are NOT used during the retrieval/search process itself. They do not participate in the 4-way parallel search (semantic, keyword, graph, temporal). Instead, they are **response augmentations**—additional context returned alongside search results.
**Response Augmentation**: When calling recall() with include_entities=True:
**Response Structure**:
#### 2.6.4 Observations vs. Opinions
A critical distinction separates observations from opinions:
| Dimension | Observations | Opinions |
|-----------|-------------|----------|
| **Influence** | No personality influence | Influenced by Big Five traits |
| **Purpose** | Objective entity summaries | Subjective beliefs and judgments |
| **Confidence** | No confidence score | Confidence score (0.0-1.0) |
| **Generation** | Background synthesis from facts | Formed during reflect() reasoning |
| **Update Mechanism** | Regenerated when entity facts change | Updated via opinion reinforcement |
| **Example** | "Alice is a software engineer at Google" | "Alice is an excellent engineer" |
**Why Both?**: Observations provide factual entity understanding for retrieval contexts, while opinions represent the memory bank's personality-driven beliefs for reasoning contexts. A memory bank can have objective observations about Alice (she works at Google, specializes in ML) AND personality-influenced opinions about Alice (she's a talented engineer, she'd be great for project X).
#### 2.6.5 Background Processing
Observation generation is asynchronous to avoid blocking retain() operations:
**Flow**:
This design ensures low-latency writes while maintaining fresh entity summaries.
#### 2.6.6 Benefits and Use Cases
**Benefits**:
1. **Contextual Entity Summaries**: After retrieving facts that mention entities, observations provide synthesized context about those entities without requiring separate queries
2. **Structured Entity Understanding**: Provides coherent mental models of entities as response augmentation
3. **Token Efficiency**: 3-5 observations provide more structured context than retrieving all entity-related facts
4. **Objective Grounding**: When reflecting with personality, observations provide objective entity context
5. **Scalability**: Automatically maintained as facts accumulate, always fresh when needed
6. **Separation of Concerns**: Search focuses on relevant facts through semantic similarity, keyword matching, and graph traversal; observations provide entity context post-retrieval
**Note on Observation Stability**: While observations are regenerated when entity facts change, the core retrieval mechanism remains grounded in the original facts. The four-way parallel search (semantic, keyword, graph, temporal) retrieves facts based on query relevance, semantic co-occurrence, and entity relationships—not based on observations. This ensures that the most relevant factual information is surfaced regardless of how observations may evolve over time.
**Use Cases**:
**Multi-Agent Conversations**: When retrieving facts that mention people, observations provide shared, objective entity context:
**Entity-Centric Queries**: "Tell me about Alice" retrieves facts about Alice, and observations provide synthesized entity summary in the response.
**Contextual Reasoning**: When forming opinions during reflect(), observations provide factual entity grounding alongside retrieved facts.
**Knowledge Graph Interfaces**: Observations can be exposed as structured entity profiles in UIs or APIs via dedicated entity endpoints.
## 3. Retrieval Architecture
Our retrieval pipeline addresses the fundamental challenge of long-term memory: achieving both **high recall** (finding all relevant information) and **high precision** (ranking the most relevant items first).
### 3.1 Four-Way Parallel Retrieval
We execute four complementary retrieval strategies in parallel, each capturing different aspects of relevance:
#### 3.1.1 Semantic Retrieval (Vector Similarity)
**Method**: Cosine similarity between query embedding and memory embeddings
**Index**: pgvector HNSW (Hierarchical Navigable Small World)
**Threshold**: ≥ 0.3 similarity
**Implementation**:
**Advantages**:
- Captures conceptual similarity
- Handles synonyms and paraphrasing
- Language-model understanding of meaning
**Limitations**:
- Misses exact proper nouns if not in training data
- Cannot reason about temporal relationships
- Weak at entity disambiguation
#### 3.1.2 Keyword Retrieval (BM25 Full-Text Search)
**Method**: PostgreSQL full-text search with BM25 ranking (ts_rank_cd)
**Index**: GIN index on to_tsvector('english', text)
**Advantages**:
- High precision for proper nouns and technical terms
- Exact phrase matching
- Fast execution with GIN index
**Limitations**:
- No semantic understanding
- Requires exact or stemmed matches
**Complementarity**: Semantic + Keyword achieves >90% recall: vector search catches concepts, BM25 catches exact names.
#### 3.1.3 Graph Retrieval (Spreading Activation)
**Method**: Activation spreading from semantic entry points through the memory graph, following the spreading activation model of memory (Anderson 1983).
**Algorithm**:
**Decay Mechanism**: Activation decays by 0.8 per hop, limiting spread to ~4-5 hops.
**Link Weighting with Causal Boosting**:
- **Causal links**: Base weight × 2.0 boost (causes/caused_by) or × 1.5 boost (enables/prevents)
- **Entity links**: weight 1.0 (no boost, already strong signal)
- **Semantic links**: weight ∈ [0.7, 1.0] (cosine similarity, no boost)
- **Temporal links**: weight ∈ [0.3, 1.0] (time-based decay, no boost)
**Advantages**:
- Discovers indirectly related facts through graph connectivity
- Leverages entity links to traverse knowledge graph
- Finds context-adjacent memories via temporal links
- Prioritizes explanatory relationships through causal boosting
#### 3.1.4 Temporal Graph Retrieval (Time-Constrained + Spreading)
**Activation Condition**: Only triggered when temporal constraint detected in query
**Temporal Parsing**: Uses google/flan-t5-small (80M parameters) to extract temporal constraints from natural language queries:
- "last spring" → 2024-03-01 to 2024-05-31
- "in June" → 2024-06-01 to 2024-06-30
- "last year" → 2024-01-01 to 2024-12-31
- "between March and May" → 2025-03-01 to 2025-05-31
**Temporal Range Matching**: Facts are matched against time constraints using their temporal range (occurred_start, occurred_end):
**Algorithm**:
### 3.2 Reciprocal Rank Fusion (RRF)
After parallel retrieval, we merge 3-4 ranked lists using Reciprocal Rank Fusion (Cormack et al. 2009):
**Algorithm**:
**Advantages over Score-Based Fusion**:
- **Rank-based**: Position matters more than absolute scores
- **Robust to missing items**: Missing from a list contributes 0, not a penalty
- **Multi-evidence weighting**: Items appearing in multiple lists rank higher
### 3.3 Neural Cross-Encoder Reranking
After RRF fusion, TEMPR applies neural cross-encoder reranking to refine precision:
**Model**: cross-encoder/ms-marco-MiniLM-L-6-v2 (pretrained on MS MARCO passage ranking)
**Algorithm**:
**Advantages**:
- Learns query-document relevance patterns from supervised data
- Considers full query-document interaction
- Temporal awareness through formatted date context
### 3.4 Token Budget Filtering
Final stage applies token budget filtering to limit context window usage:
**Algorithm**:
**Purpose**: Ensures retrieved facts fit within LLM context windows while maximizing information density.
### 3.5 Complete Retrieval Pipeline
**End-to-End Flow**:
## 4. Evaluation
We evaluate TEMPR on two established long-term memory benchmarks: LoComo (Long-term Conversation Memory) and LongMemEval.
### 4.1 LoComo Benchmark
LoComo evaluates conversational memory systems across four dimensions: single-hop queries, multi-hop queries, open-domain queries, and temporal queries.
**Results**:
| Method | Single Hop J ↑ | Multi-Hop J ↑ | Open Domain J ↑ | Temporal J ↑ | Overall |
|--------|---------------|---------------|-----------------|--------------|---------|
| A-Mem* | 39.79 | 18.85 | 54.05 | 31.08 | 48.38 |
| LangMem | 62.23 | 47.92 | 71.12 | 23.43 | 58.10 |
| Zep (Mem0 paper) | 61.70 | 41.35 | 76.60 | 49.31 | 65.99 |
| OpenAI | 63.79 | 42.92 | 62.29 | 21.71 | 52.90 |
| Mem0 | 67.13 | 51.15 | 72.93 | 55.51 | 66.88 |
| Mem0 w/ Graph | 65.71 | 47.19 | 75.71 | 58.13 | 68.44 |
| **TEMPR** | **73.20** | **66.90** | **78.60** | **56.30** | **73.50** |
**Analysis**: TEMPR achieves strong performance across all query types:
- **Single-Hop (+6.1% vs Mem0)**: Superior performance due to comprehensive narrative facts and BM25 keyword matching
- **Multi-Hop (+15.8% vs Mem0)**: Largest improvement, demonstrating effectiveness of graph-based spreading activation
- **Open Domain (+2.9% vs Mem0)**: Strong performance through multi-strategy parallel retrieval
- **Temporal (-1.8% vs Mem0 w/ Graph)**: Competitive temporal reasoning
### 4.2 LongMemEval Benchmark
LongMemEval assesses memory systems across six dimensions:
**Results**:
| Method | Single-Session Preference | Single-Session Assistant | Temporal Reasoning | Multi-Session | Knowledge Update | Single-Session User | Overall |
|--------|--------------------------|-------------------------|-------------------|---------------|-----------------|-------------------|---------|
| Zep gpt-4o-mini | 53.30% | 75.00% | 54.10% | 47.40% | 74.40% | 92.90% | 63.80% |
| Zep gpt-4o | 56.70% | 80.40% | 62.40% | 57.90% | 83.30% | 92.90% | 71.00% |
| **TEMPR** | **83.30%** | **80.40%** | **75.90%** | **75.20%** | **85.90%** | **92.90%** | **80.60%** |
| Mastra gpt-4o | 46.70% | 100.00% | 75.20% | 76.70% | 84.60% | 97.10% | 80.05% |
**Analysis**: TEMPR achieves competitive performance:
- **Single-Session Preference (+26.6% vs Zep gpt-4o)**: Dramatic improvement enabled by comprehensive narrative facts
- **Temporal Reasoning (+13.5% vs Zep gpt-4o)**: Strong performance through dedicated temporal graph retrieval
- **Multi-Session (+17.3% vs Zep gpt-4o)**: Entity-aware graph linking maintains consistency
The 80.60% overall score represents a 9.6 percentage point improvement over Zep gpt-4o (71.00%).
---
# Part II: Reflect - CARA (Coherent Adaptive Reasoning Agents)
## 5. Introduction to Reflect
Conversational AI agents increasingly need to maintain consistent perspectives and form judgments that reflect stable character traits. Current systems either provide purely objective information retrieval without perspective, or generate responses that lack consistency across interactions. Human conversation partners expect agents to have stable viewpoints, preferences, and reasoning styles—characteristics that emerge from personality.
We propose CARA (Coherent Adaptive Reasoning Agents), a personality framework that addresses these limitations through:
1. **Big Five Personality Integration**: Configurable traits (OCEAN model) that influence how agents interpret facts and form opinions
2. **TEMPR Memory Integration**: Leverages TEMPR's three-network architecture (world facts, bank experiences, opinions) for sophisticated memory access
3. **Opinion Reinforcement**: Dynamic belief updating when new evidence reinforces, weakens, or contradicts existing opinions
4. **Personality Bias Control**: Adjustable influence strength allowing agents to range from objective to strongly personality-driven
5. **Background Merging**: LLM-powered integration of biographical information with intelligent conflict resolution
This architecture enables agents to maintain consistent identities while allowing beliefs to evolve naturally with new information.
### 5.1 Motivation
Consider an agent discussing remote work. With high openness (0.9) and low conscientiousness (0.2), the agent might form the opinion: "Remote work enables creative flexibility and spontaneous innovation." The same facts presented to an agent with low openness (0.2) and high conscientiousness (0.9) might yield: "Remote work lacks the structure and accountability needed for consistent performance."
Both agents access identical factual information, but personality traits bias how they weight different aspects (flexibility vs. structure) and what conclusions they draw. This mirrors human reasoning—our personalities influence what we attend to and how we integrate information into our worldview.
### 5.2 Contributions
Our key contributions for the reflect system are:
1. **Personality-Aware Reasoning**: A prompt engineering framework that injects Big Five traits into LLM reasoning, demonstrating how personality consistently biases opinion formation
2. **TEMPR-Based Three-Network Architecture**: Integration with TEMPR to manage three distinct networks (world facts, bank experiences, opinions), enabling architectural separation between objective information and subjective beliefs with epistemic clarity and traceability
3. **Opinion Reinforcement Mechanism**: An automatic belief update system that adjusts confidence scores when new evidence arrives, creating dynamic belief systems that evolve with information
4. **Background Merging with Conflict Resolution**: An LLM-powered method for maintaining coherent agent identities when new biographical information contradicts existing background
5. **Bias Strength Control**: A meta-parameter that allows tuning personality influence from objective (0.0) to strongly subjective (1.0), enabling task-appropriate personality expression
## 6. Personality Model
### 6.1 Big Five Framework
We adopt the **Big Five** personality model (OCEAN), which is empirically validated across cultures and provides continuous trait dimensions:
**Trait Dimensions** (each 0.0-1.0):
1. **Openness (O)**: Receptiveness to new ideas, creativity, abstract thinking
- High: "I embrace novel approaches", "innovation over tradition"
- Low: "I prefer proven methods", "tradition over experimentation"
2. **Conscientiousness (C)**: Organization, goal-directed behavior, dependability
- High: "I plan systematically", "evidence-based decisions"
- Low: "I work flexibly", "intuition-based decisions"
3. **Extraversion (E)**: Sociability, assertiveness, energy from interaction
- High: "I seek collaboration", "enthusiastic communication"
- Low: "I prefer solitude", "measured communication"
4. **Agreeableness (A)**: Cooperation, empathy, conflict avoidance
- High: "I seek consensus", "consider social harmony"
- Low: "I express dissent", "prioritize accuracy over harmony"
5. **Neuroticism (N)**: Emotional sensitivity, anxiety, stress response
- High: "I consider risks carefully", "emotionally engaged"
- Low: "I remain calm under uncertainty", "emotionally detached"
**Bias Strength** (0.0-1.0): Meta-parameter controlling how much personality influences opinions
- 0.0: Neutral, fact-based reasoning (no personality bias)
- 0.5: Moderate personality influence, balanced with objective analysis
- 1.0: Strong personality influence, facts filtered through trait lens
### 6.2 Psychological Basis
The Big Five model has several advantages for AI agents:
1. **Empirical Validation**: Decades of psychological research demonstrate cross-cultural stability and predictive validity
2. **Continuous Dimensions**: Unlike categorical types, continuous scales allow fine-grained personality tuning
3. **Behavioral Prediction**: Traits predict information processing styles, decision-making approaches, and communication preferences
4. **Interpretability**: Well-understood trait meanings enable users to anticipate agent behavior
**Trait Influence on Reasoning**:
- **High Openness**: Favors novel solutions, abstract thinking, considers unconventional perspectives
- **High Conscientiousness**: Emphasizes systematic analysis, evidence quality, long-term consequences
- **High Extraversion**: Considers social aspects, collaborative solutions, enthusiastic expression
- **High Agreeableness**: Weights harmony, considers multiple viewpoints, seeks consensus
- **High Neuroticism**: Attends to risks, emotional implications, uncertainty
## 7. Bank Profile Structure
### 7.1 Profile Schema
Each memory bank has an associated profile containing identity information:
**Name Field**: Memory bank's name used in prompts and self-reference ("Your name: Marcus")
**Personality Field**: JSONB containing six continuous values (five traits + bias strength)
**Background Field**: First-person narrative describing the agent's biographical context:
- "I am a software engineer with 10 years of startup experience"
- "I was born in Texas and value innovation over tradition"
- "I am a creative artist interested in digital media"
### 7.2 Trait Description Generation
Personality traits are translated into natural language descriptions for LLM prompts:
**Example Output** (openness=0.9, conscientiousness=0.2, extraversion=0.7, agreeableness=0.3, neuroticism=0.5):
This verbalization makes traits interpretable to the LLM, enabling personality-biased reasoning.
## 8. Opinion Network and Opinion Formation
### 8.1 Opinion Structure
Opinions are stored as memory units in the dedicated opinion network (fact_type='opinion'):
**Core Attributes**:
- text: The opinion statement with explicit reasoning
- confidence_score: Opinion strength and resistance to change (0.0-1.0)
- event_date: When the opinion was formed
- bank_id: Which memory bank holds this opinion
- entities: Mentioned entities (for reinforcement triggering)
**Example Opinion**:
**Fact vs. Opinion Separation**:
A critical architectural distinction separates **facts** (objective information stored in world/bank networks) from **opinions** (subjective beliefs stored in the opinion network). This separation provides:
1. **Epistemic Clarity**: Facts represent information encountered; opinions represent judgments formed
2. **Traceability**: Opinion reinforcement can trace which facts influenced belief updates
3. **Debugging**: Developers can separately inspect factual knowledge vs. formed beliefs
4. **Confidence Semantics**: Facts lack confidence scores; opinions have confidence scores
### 8.2 Opinion Formation
Opinions are generated during "reflect" operations—when the agent is asked to reason about a topic and form a judgment.
**Formation Process**:
1. Retrieve relevant facts from all memory networks (world, bank, existing opinions) using TEMPR
2. Inject bank profile (name, personality, background) into LLM prompt
3. Generate reasoning with personality bias applied
4. Extract new opinions from response using structured output
5. Store opinions with confidence scores in opinion network
**Prompt Structure** (bias_strength=0.8):
### 8.3 System Message Adaptation
The system message adjusts based on bias strength to control personality influence:
**High bias (≥0.7)**:
**Moderate bias (0.4-0.7)**:
**Low bias (<0.4)**:
### 8.4 Confidence Score Semantics
Confidence scores represent opinion strength—how firmly the agent holds the belief:
- **0.9-1.0**: Very strong conviction, deeply held belief
- **0.7-0.9**: Strong conviction, firmly held opinion
- **0.5-0.7**: Moderate conviction, open to revision
- **0.3-0.5**: Weak conviction, easily influenced
- **0.0-0.3**: Very weak conviction, highly malleable
**LLM Generation**: Confidence scores are extracted using structured output (Pydantic schema):
## 9. Opinion Reinforcement
### 9.1 Motivation
Human beliefs evolve as we encounter new information. Supporting evidence strengthens beliefs, contradictory evidence weakens them, and sufficient contradiction causes belief revision. Opinion reinforcement implements this dynamic belief updating.
### 9.2 Reinforcement Mechanism
When new facts are ingested (via retain), the system:
1. **Identify Related Opinions**: Find existing opinions that mention entities in the new facts
2. **Evaluate Evidence Relationship**: Use LLM to determine if new facts:
- **Reinforce**: Support the existing opinion (increase confidence)
- **Weaken**: Contradict the existing opinion (decrease confidence)
- **Contradict**: Strongly contradict, requiring opinion revision
- **Neutral**: Unrelated or no clear relationship
3. **Update Opinions**: Adjust confidence scores or revise opinion text based on evaluation
**Example Reinforcement**:
**Existing Opinion** (confidence: 0.7):
**New Fact**:
**LLM Evaluation**: "This evidence REINFORCES the opinion with strong quantitative support."
**Updated Opinion** (confidence: 0.85):
### 9.3 Reinforcement Algorithm
### 9.4 Reinforcement Guarantees
**Consistency**: Opinions are only updated when new facts genuinely relate to existing beliefs
**Personality Coherence**: Reinforcement evaluation incorporates bank personality, ensuring updates align with trait-driven reasoning
**Transparency**: Each update records the triggering facts and reasoning, providing an audit trail
**Bounded Updates**: Confidence changes are bounded (±0.1-0.15 per update) to prevent extreme swings
## 10. Background Merging
### 10.1 Challenge
Memory bank backgrounds accumulate biographical information over time. New information may:
- **Complement**: Add new facts without contradiction
- **Conflict**: Contradict existing facts ("born in Texas" vs. "born in Colorado")
- **Refine**: Provide more specific versions of existing facts
Naive concatenation creates incoherent backgrounds with contradictions. We need intelligent merging.
### 10.2 LLM-Powered Merging
We use an LLM to merge backgrounds with conflict resolution:
**Merge Rules**:
1. **New overwrites old** when contradictory
2. **Add non-conflicting** information
3. **Maintain first-person** perspective ("I..." not "You...")
4. **Keep concise** (under 500 characters)
**Prompt Template**:
**Example Merges**:
**Conflict Resolution**:
- Current: "I was born in Colorado"
- New: "You were born in Texas"
- Result: "I was born in Texas"
**Addition**:
- Current: "I was born in Texas"
- New: "I have 10 years of startup experience"
- Result: "I was born in Texas. I have 10 years of startup experience."
### 10.3 First-Person Normalization
Users may provide background in second person ("You are..."), but internal storage maintains first person for consistency in prompts.
**Normalization**: LLM automatically converts:
- "You are a creative engineer" → "I am a creative engineer"
- "You were born in 1990" → "I was born in 1990"
- "You value innovation" → "I value innovation"
## 11. Personality-Driven Reasoning Examples
### 11.1 Example: Remote Work Discussion
**Scenario**: Two memory banks with opposite personalities discuss remote work given identical facts.
**Facts** (both banks receive):
- "Remote work eliminates commute time (average 1 hour/day saved)"
- "Office work provides spontaneous collaboration and mentorship"
- "Studies show 65% of remote workers report higher productivity"
- "Some managers report difficulty monitoring remote employee performance"
**Bank A** (High Openness=0.9, Low Conscientiousness=0.2, bias=0.8):
**Bank B** (Low Openness=0.2, High Conscientiousness=0.9, bias=0.8):
**Analysis**: Both banks accessed identical facts but formed opposite conclusions based on personality:
- Bank A (high openness) weighted autonomy, flexibility, innovation
- Bank B (high conscientiousness) weighted structure, monitoring, discipline
### 11.2 Example: Opinion Evolution
**Scenario**: Bank forms initial opinion, then encounters reinforcing and contradictory evidence.
**Initial State** (t=0):
**Reinforcement** (t=1):
- New Fact: "Python dominates AI/ML with 75% market share; TensorFlow and PyTorch are Python-first"
- Update: Confidence → 0.85, text adds "Python's dominance in AI/ML frameworks..."
**Partial Contradiction** (t=2):
- New Fact: "Julia offers 10x faster numerical computation; increasingly adopted in research"
- Update: Confidence → 0.75, text revised to include nuance about specialized languages
**Strong Contradiction** (t=3):
- New Fact: "Major tech companies migrating data pipelines to Rust for performance"
- Update: Confidence → 0.55, text revised to acknowledge Python's shifting role
**Trajectory**: The opinion evolved from strong conviction (0.7 → 0.85) to weaker, more malleable belief (0.55) as evidence accumulated.
# Part III: Unified Hindsight Architecture
## 13. Integration: TEMPR + CARA
The Hindsight system integrates TEMPR (recall) and CARA (reflect) into a unified architecture:
### 13.1 Three Core Operations
**1. Retain** (retain()): Store information into memory banks
- LLM-powered fact extraction with temporal ranges
- Entity recognition and resolution
- Graph link construction (temporal, semantic, entity, causal)
- Automatic opinion reinforcement for existing beliefs
**2. Recall** (recall()): Retrieve memories using multi-strategy search
- Four-way parallel retrieval (semantic, keyword, graph, temporal)
- Reciprocal Rank Fusion
- Neural cross-encoder reranking
- Token budget filtering
**3. Reflect** (reflect()): Generate personality-aware responses
- Retrieves relevant memories from all networks using TEMPR
- Loads bank personality and background
- Generates response influenced by Big Five traits
- Forms new opinions with confidence scores
- Stores opinions for future retrieval
### 13.2 Unified Data Flow
### 13.3 PostgreSQL Schema
The system uses PostgreSQL with pgvector for storage:
## 14. System Properties
### 14.1 Epistemic Clarity
The three-network architecture provides clear separation:
- **World**: What the bank knows about the world
- **Bank**: What the bank has done
- **Opinion**: What the bank believes
This enables:
- Transparent reasoning (trace opinions back to facts)
- Debugging (identify missing facts vs. flawed reasoning)
- Confidence calibration (opinions have confidence, facts don't)
### 14.2 Temporal Awareness
Multi-dimensional temporal representation:
- occurred_start / occurred_end: When events actually happened
- mentioned_at: When the bank learned about it
- event_date: Backward compatibility
Enables:
- Precise historical queries ("What happened in June?")
- Recency-aware ranking (newer mentions prioritized)
- Period matching (events spanning weeks or months)
### 14.3 Entity-Aware Reasoning
LLM-based entity resolution creates knowledge graph:
- Connects semantically distant facts through shared entities
- Enables multi-hop discovery ("Alice's manager's team")
- Disambiguates mentions ("Alice" vs. "Alice Chen")
### 14.4 Multiple Link Types
The graph incorporates multiple relationship types:
- Entity links connect memories mentioning the same entities
- Semantic links connect conceptually similar memories
- Temporal links connect temporally proximate memories
- Causal links represent identified cause-effect relationships
- Links are weighted differently during graph traversal
### 14.5 Personality Consistency
Big Five traits ensure stable reasoning style:
- Configurable bias strength (objective to subjective)
- Trait-appropriate opinion formation
- Consistent voice across interactions
### 14.6 Dynamic Belief Systems
Opinion reinforcement enables belief evolution:
- Confidence increases with supporting evidence
- Confidence decreases with contradictory evidence
- Opinion text revised when strongly contradicted
- Audit trail of belief changes
## 15. Conclusion
We present Hindsight, a unified memory architecture for AI agents that combines TEMPR's multi-strategy retrieval with CARA's personality-driven reasoning. The system achieves strong performance on established benchmarks (73.50% on LoComo, 80.60% on LongMemEval) while enabling personality-consistent opinion formation through the Big Five model.
The integration of four parallel search strategies (semantic, keyword, graph with multiple link types, temporal) with three-network architecture (world, bank, opinion) and opinion reinforcement creates a comprehensive memory system that:
- Retrieves information with high recall and precision
- Maintains epistemic clarity between facts and beliefs
- Enables personality-driven reasoning with stable traits
- Supports dynamic belief evolution with evidence
Real-world deployment in sports content generation demonstrates the system's ability to maintain consistent yet adaptive perspectives across extended interactions. Future work will explore personality evolution, multi-agent belief systems, and richer personality models incorporating values and cultural factors.
By combining temporal-aware retrieval with personality-driven reasoning, Hindsight moves toward conversational agents that exhibit not just memory and intelligence, but character—stable traits and evolving beliefs that enable more natural, trustworthy human-AI interaction.
## References
1. Anderson, J. R. (1983). A spreading activation theory of memory. *Journal of Verbal Learning and Verbal Behavior*, 22(3), 261-295.
2. Cormack, G. V., Clarke, C. L., & Buettcher, S. (2009). Reciprocal rank fusion outperforms condorcet and individual rank learning methods. In *SIGIR'09* (pp. 758-759).
3. McCrae, R. R., & Costa, P. T. (1997). Personality trait structure as a human universal. *American Psychologist*, 52(5), 509.
4. Goldberg, L. R. (1993). The structure of phenotypic personality traits. *American Psychologist*, 48(1), 26.
5. Malkov, Y. A., & Yashunin, D. A. (2018). Efficient and robust approximate nearest neighbor search using hierarchical navigable small world graphs. *IEEE Transactions on Pattern Analysis and Machine Intelligence*, 42(4), 824-836.
6. Robertson, S., & Zaragoza, H. (2009). The probabilistic relevance framework: BM25 and beyond. *Foundations and Trends in Information Retrieval*, 3(4), 333-489.
7. Brown, T. B., Mann, B., Ryder, N., Subbiah, M., Kaplan, J., Dhariwal, P., ... & Amodei, D. (2020). Language models are few-shot learners. *Advances in Neural Information Processing Systems*, 33, 1877-1901.
8. Petroni, F., Rocktäschel, T., Riedel, S., Lewis, P., Bakhtin, A., Wu, Y., & Miller, A. (2019). Language models as knowledge bases?. In *Proceedings of EMNLP-IJCNLP* (pp. 2463-2473).
+39 -187
View File
@@ -1,253 +1,105 @@
<div align="center">
# Hindsight
**Agent Memory that Works Like Human Memory**
[![CI](https://github.com/vectorize-io/hindsight/actions/workflows/test.yml/badge.svg)](https://github.com/vectorize-io/hindsight/actions/workflows/test.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![PyPI - hindsight-client](https://img.shields.io/pypi/v/hindsight-client?label=hindsight-client)](https://pypi.org/project/hindsight-client/)
[![PyPI - hindsight-api](https://img.shields.io/pypi/v/hindsight-api?label=hindsight-api)](https://pypi.org/project/hindsight-api/)
[![PyPI - hindsight-all](https://img.shields.io/pypi/v/hindsight-all?label=hindsight-all)](https://pypi.org/project/hindsight-all/)
[![npm](https://img.shields.io/npm/v/@vectorize-io/hindsight-client)](https://www.npmjs.com/package/@vectorize-io/hindsight-client)
[![Slack Community](https://img.shields.io/badge/Slack-Join%20Community-4A154B?logo=slack)](https://join.slack.com/t/hindsight-space/shared_invite/zt-3klo21kua-VUCC_zHP5rIcXFB1_5yw6A)
[Documentation](https://vectorize-io.github.io/hindsight) • [Paper](./Hindsight.pdf) • [Examples](./examples)
**Long-term memory for AI agents.**
</div>
## Why Hindsight?
---
AI assistants forget everything between sessions. Every conversation starts from zero—no context about who you are, what you've discussed, or what the memory bank has learned. This isn't just inconvenient; it fundamentally limits what AI memory banks can do.
## What is Hindsight?
**The problem is harder than it looks:**
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.
- **Simple vector search isn't enough** — "What did Alice do last spring?" requires temporal reasoning, not just semantic similarity
- **Facts get disconnected** — Knowing "Alice works at Google" and "Google is in Mountain View" should let you answer "Where does Alice work?" even if you never stored that directly
- **Memory banks need opinions** — A coding assistant that remembers "the user prefers functional programming" should weigh that when making recommendations
- **Context matters** — The same information means different things to different memory banks with different personalities
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.
Hindsight solves these problems with a memory system designed specifically for AI memory banks.
- **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.
## How Hindsight Works
![Overview](./hindsight-docs/static/img/hindsight-overview.png)
Hindsight organizes memory into four networks to mimic the way 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.")
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.
Memories in Hindsight are stored in banks (e.g. memory banks). When memories are retained, they are transformed to construct a series of search indexes, time series data, and entity/relationship graphs.
---
## Quick Start
### Docker (recommended)
### Option 1: Docker (recommended)
Get the full experience with the API and Control Plane UI:
```bash
export OPENAI_API_KEY=your-key
docker run -p 8888:8888 -p 9999:9999 \
-e HINDSIGHT_API_LLM_PROVIDER=openai \
-e HINDSIGHT_API_LLM_API_KEY=$OPENAI_API_KEY \
-e HINDSIGHT_API_LLM_MODEL=gpt-4o-mini \
-v $HOME/.hindsight-docker:/home/hindsight/.pg0 \
ghcr.io/vectorize-io/hindsight
```
API: http://localhost:8888
UI: http://localhost:9999
- **API**: http://localhost:8888
- **Control Plane UI**: http://localhost:9999
Install client:
Then use the Python client:
```bash
pip install hindsight-client
# or
npm install @vectorize-io/hindsight-client
```
Python example:
```python
from hindsight import HindsightClient
client = HindsightClient(base_url="http://localhost:8888")
# Store
# Store memories
client.retain(bank_id="my-agent", content="Alice works at Google as a software engineer")
client.retain(bank_id="my-agent", content="Alice mentioned she loves hiking in the mountains")
# Query
results = client.recall(bank_id="my-agent", query="What does Alice do?")
# Query with temporal reasoning
results = client.recall(bank_id="my-agent", query="What does Alice do for work?")
# Reflect
# Get a synthesized perspective
response = client.reflect(bank_id="my-agent", query="Tell me about Alice")
print(response.text)
```
### Python (embedded, no Docker)
### Option 2: Embedded (no docker/server required)
For quick prototyping, run everything in-process:
```bash
pip install hindsight-all
export OPENAI_API_KEY=your-key
```
```python
import os
from hindsight import HindsightServer, HindsightClient
with HindsightServer(
llm_provider="openai",
llm_model="gpt-4o-mini",
llm_api_key=os.environ["OPENAI_API_KEY"]
) as server:
with HindsightServer(llm_provider="openai", llm_model="gpt-4o-mini", llm_api_key=os.environ["OPENAI_API_KEY"]) as server:
client = HindsightClient(base_url=server.url)
client.retain(bank_id="my-agent", content="Alice works at Google")
results = client.recall(bank_id="my-agent", query="Where does Alice work?")
client.retain(bank_id="my-user", content="User prefers functional programming")
response = client.reflect(bank_id="my-user", query="What coding style should I use?")
print(response.text)
```
### TypeScript
```bash
npm install @vectorize-io/hindsight-client
```
```typescript
import { HindsightClient } from '@vectorize-io/hindsight-client';
const client = new HindsightClient({ baseUrl: 'http://localhost:8888' });
await client.retain('my-agent', 'Alice loves hiking in Yosemite');
const response = await client.recall('my-agent', 'What does Alice like?');
```
---
## Architecture & Operations
### 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.
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
# Simple
client.retain(
bank_id="my-bank",
content="Alice works at Google as a software engineer"
)
# With context and timestamp
client.retain(
bank_id="my-bank",
content="Alice got promoted to senior engineer",
context="career update",
timestamp="2025-06-15T10:00:00Z"
)
```
Behind the scenes, the retain operation uses an LLM to extract key facts, temporal data, entities, and relationships. It passes these through a normalization process to transform extracted data into canonical entities, time series, and search indexes along with metadata. These representations create the pathways for accurate memory retrieval in the recall and reflect operations.
![Retain Operation](hindsight-docs/static/img/retain-operation.png)
### Recall
The recall operation is used to retrieve memories. These memories can come from any of the memory types (world, experiences, etc.)
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
# Simple
client.recall(bank_id="my-bank", query="What does Alice do?")
# Temporal
results = client.recall(bank_id="my-bank", query="What happened in June?")
```
## Documentation
Recall performs 4 retrieval strategies in parallel:
- Semantic: Vector similarity
- Keyword: BM25 exact matching
- Graph: Entity/temporal/causal links
- Temporal: Time range filtering
Full documentation: [vectorize-io.github.io/hindsight](https://vectorize-io.github.io/hindsight)
![Retain Operation](hindsight-docs/static/img/recall-operation.png)
The individual results from the retrievals are merged, then ordered by relevance using reciprocal rank fusion and a cross-encoder reranking model.
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.
For example, the `reflect` operation can be used to support use cases such as:
- An **AI Project Manager** reflecting on what risks need to be mitigated on a project.
- A **Sales Agent** reflecting on why certain outreach messages have gotten responses while others haven't.
- A **Support Agent** reflecting on opportunities where customers have questions not answered by current product documentation.
The `reflect` operation can also be used to handle on-demand question answering or analysis which require more deep thinking.
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
client.reflect(bank_id="my-bank", query="What should I know about Alice?")
```
![Retain Operation](hindsight-docs/static/img/reflect-operation.png)
## Integrations
### Examples
[Examples Repo]([./examples](https://github.com/vectorize-io/hindsight-cookbook)) includes:
- Basic usage
- Multi-session conversations
- Temporal queries
- Entity reasoning
- Opinion tracking
- Production setup (Docker Compose + monitoring)
---
## Resources
**Documentation:** [vectorize-io.github.io/hindsight](https://vectorize-io.github.io/hindsight)
**Clients:**
- [Python](http://hindsight.vectorize.io/sdks/python)
- [Node.js](http://hindsight.vectorize.io/sdks/nodejs)
- [REST API](http://hindsight.vectorize.io/api-reference)
**Community:**
- [Slack](https://join.slack.com/t/hindsight-space/shared_invite/zt-3klo21kua-VUCC_zHP5rIcXFB1_5yw6A)
- [GitHub Issues](https://github.com/vectorize-io/hindsight/issues)
---
- [Architecture](https://vectorize-io.github.io/hindsight/#what-hindsight-does) — How ingestion, storage, and retrieval work
- [Python Client](https://vectorize-io.github.io/hindsight/sdks/python) — Full API reference
- [API Reference](https://vectorize-io.github.io/hindsight/api-reference) — REST API endpoints
- [Personality](https://vectorize-io.github.io/hindsight/developer/personality) — Big Five traits and opinion formation
## Contributing
See [CONTRIBUTING.md](./CONTRIBUTING.md).
We welcome contributions! See [CONTRIBUTING.md](./CONTRIBUTING.md) for guidelines.
## License
MIT — see [LICENSE](./LICENSE)
---
Built by [Vectorize.io](https://vectorize.io)
MIT
+2 -1
View File
@@ -40,8 +40,9 @@ WORKDIR /app/api
# Sync dependencies (will create lock file if needed)
RUN uv sync
# Copy source code (alembic migrations are inside hindsight_api/)
# Copy source code and alembic migrations
COPY hindsight-api/hindsight_api ./hindsight_api
COPY hindsight-api/alembic ./alembic
# =============================================================================
# Stage: SDK Builder (needed for Control Plane)
+1 -1
View File
@@ -26,7 +26,7 @@ PIDS=()
# Start API if enabled
if [ "$ENABLE_API" = "true" ]; then
cd /app/api
hindsight-api 2>&1 | sed -u 's/^/[api] /' &
python -m hindsight_api.web.server 2>&1 | sed -u 's/^/[api] /' &
API_PID=$!
PIDS+=($API_PID)
+2 -2
View File
@@ -2,8 +2,8 @@ apiVersion: v2
name: hindsight
description: A Helm chart for Hindsight - temporal-semantic-entity memory system for AI agents
type: application
version: 0.1.0
appVersion: "0.1.0"
version: 0.0.18
appVersion: "0.0.18"
keywords:
- ai
- memory
@@ -105,8 +105,6 @@ def run_migrations_offline() -> None:
def run_migrations_online() -> None:
"""Run migrations in 'online' mode with synchronous engine."""
from sqlalchemy import event, text
get_database_url() # Process and set the database URL in config
connectable = engine_from_config(
@@ -115,19 +113,7 @@ def run_migrations_online() -> None:
poolclass=pool.NullPool,
)
# Add event listener to ensure connection is in read-write mode
# This is needed for Supabase which may start connections in read-only mode
@event.listens_for(connectable, "connect")
def set_read_write_mode(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE")
cursor.close()
with connectable.connect() as connection:
# Also explicitly set read-write mode on this connection
connection.execute(text("SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE"))
connection.commit() # Commit the SET command
context.configure(
connection=connection,
target_metadata=target_metadata
@@ -136,9 +122,6 @@ def run_migrations_online() -> None:
with context.begin_transaction():
context.run_migrations()
# Explicit commit to ensure changes are persisted (especially for Supabase)
connection.commit()
if context.is_offline_mode():
run_migrations_offline()
+2 -10
View File
@@ -16,15 +16,11 @@ from .engine.search.trace import (
SearchPhaseMetrics,
)
from .engine.search.tracer import SearchTracer
from .engine.embeddings import Embeddings, LocalSTEmbeddings, RemoteTEIEmbeddings
from .engine.cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder
from .engine.embeddings import Embeddings, SentenceTransformersEmbeddings
from .engine.llm_wrapper import LLMConfig
from .config import HindsightConfig, get_config
__all__ = [
"MemoryEngine",
"HindsightConfig",
"get_config",
"SearchTrace",
"SearchTracer",
"QueryInfo",
@@ -36,11 +32,7 @@ __all__ = [
"SearchSummary",
"SearchPhaseMetrics",
"Embeddings",
"LocalSTEmbeddings",
"RemoteTEIEmbeddings",
"CrossEncoderModel",
"LocalSTCrossEncoder",
"RemoteTEICrossEncoder",
"SentenceTransformersEmbeddings",
"LLMConfig",
]
__version__ = "0.1.0"
@@ -1,48 +0,0 @@
"""Rename fact_type 'bank' to 'experience'
Revision ID: d9f6a3b4c5e2
Revises: c8e5f2a3b4d1
Create Date: 2024-12-04 15:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd9f6a3b4c5e2'
down_revision = 'c8e5f2a3b4d1'
branch_labels = None
depends_on = None
def upgrade():
# Drop old check constraint FIRST (before updating data)
op.drop_constraint('memory_units_fact_type_check', 'memory_units', type_='check')
# Update existing 'bank' values to 'experience'
op.execute("UPDATE memory_units SET fact_type = 'experience' WHERE fact_type = 'bank'")
# Also update any 'interactions' values (in case of partial migration)
op.execute("UPDATE memory_units SET fact_type = 'experience' WHERE fact_type = 'interactions'")
# Create new check constraint with 'experience' instead of 'bank'
op.create_check_constraint(
'memory_units_fact_type_check',
'memory_units',
"fact_type IN ('world', 'experience', 'opinion', 'observation')"
)
def downgrade():
# Drop new check constraint FIRST
op.drop_constraint('memory_units_fact_type_check', 'memory_units', type_='check')
# Update 'experience' back to 'bank'
op.execute("UPDATE memory_units SET fact_type = 'bank' WHERE fact_type = 'experience'")
# Recreate old check constraint
op.create_check_constraint(
'memory_units_fact_type_check',
'memory_units',
"fact_type IN ('world', 'bank', 'opinion', 'observation')"
)
@@ -1,62 +0,0 @@
"""disposition_to_3_traits
Revision ID: e0a1b2c3d4e5
Revises: rename_personality
Create Date: 2024-12-08
Migrate disposition traits from Big Five (openness, conscientiousness, extraversion,
agreeableness, neuroticism, bias_strength with 0-1 float values) to the new 3-trait
system (skepticism, literalism, empathy with 1-5 integer values).
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'e0a1b2c3d4e5'
down_revision: Union[str, Sequence[str], None] = 'rename_personality'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Convert Big Five disposition to 3-trait disposition."""
conn = op.get_bind()
# Update all existing banks to use the new disposition format
# Convert from old format to new format with reasonable mappings:
# - skepticism: derived from inverse of agreeableness (skeptical people are less agreeable)
# - literalism: derived from conscientiousness (detail-oriented people are more literal)
# - empathy: derived from agreeableness + inverse of neuroticism
# Default all to 3 (neutral) for simplicity
conn.execute(sa.text("""
UPDATE banks
SET disposition = '{"skepticism": 3, "literalism": 3, "empathy": 3}'::jsonb
WHERE disposition IS NOT NULL
"""))
# Update the default for new banks
conn.execute(sa.text("""
ALTER TABLE banks
ALTER COLUMN disposition SET DEFAULT '{"skepticism": 3, "literalism": 3, "empathy": 3}'::jsonb
"""))
def downgrade() -> None:
"""Convert back to Big Five disposition."""
conn = op.get_bind()
# Revert to Big Five format with default values
conn.execute(sa.text("""
UPDATE banks
SET disposition = '{"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.5, "agreeableness": 0.5, "neuroticism": 0.5, "bias_strength": 0.5}'::jsonb
WHERE disposition IS NOT NULL
"""))
# Update the default for new banks
conn.execute(sa.text("""
ALTER TABLE banks
ALTER COLUMN disposition SET DEFAULT '{"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.5, "agreeableness": 0.5, "neuroticism": 0.5, "bias_strength": 0.5}'::jsonb
"""))
@@ -1,65 +0,0 @@
"""rename_personality_to_disposition
Revision ID: rename_personality
Revises: d9f6a3b4c5e2
Create Date: 2024-12-04
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'rename_personality'
down_revision: Union[str, Sequence[str], None] = 'd9f6a3b4c5e2'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Rename personality column to disposition in banks table (if it exists)."""
conn = op.get_bind()
# Check if 'personality' column exists (old database)
result = conn.execute(sa.text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'banks' AND column_name = 'personality'
"""))
has_personality = result.fetchone() is not None
# Check if 'disposition' column exists (new database)
result = conn.execute(sa.text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'banks' AND column_name = 'disposition'
"""))
has_disposition = result.fetchone() is not None
if has_personality and not has_disposition:
# Old database: rename personality -> disposition
op.alter_column('banks', 'personality', new_column_name='disposition')
elif not has_personality and not has_disposition:
# Neither exists (shouldn't happen, but be safe): add disposition column
op.add_column('banks', sa.Column(
'disposition',
postgresql.JSONB(astext_type=sa.Text()),
server_default=sa.text("'{}'::jsonb"),
nullable=False
))
# else: disposition already exists, nothing to do
def downgrade() -> None:
"""Revert disposition column back to personality."""
conn = op.get_bind()
result = conn.execute(sa.text("""
SELECT column_name
FROM information_schema.columns
WHERE table_name = 'banks' AND column_name = 'disposition'
"""))
if result.fetchone():
op.alter_column('banks', 'disposition', new_column_name='personality')
+6 -4
View File
@@ -17,17 +17,18 @@ def create_app(
http_api_enabled: bool = True,
mcp_api_enabled: bool = False,
mcp_mount_path: str = "/mcp",
run_migrations: bool = True,
initialize_memory: bool = True
) -> FastAPI:
"""
Create and configure the unified Hindsight API application.
Args:
memory: MemoryEngine instance (already initialized with required parameters).
Migrations are controlled by the MemoryEngine's run_migrations parameter.
memory: MemoryEngine instance (already initialized with required parameters)
http_api_enabled: Whether to enable HTTP REST API endpoints (default: True)
mcp_api_enabled: Whether to enable MCP server (default: False)
mcp_mount_path: Path to mount MCP server (default: /mcp)
run_migrations: Whether to run database migrations on startup (default: True)
initialize_memory: Whether to initialize memory system on startup (default: True)
Returns:
@@ -49,6 +50,7 @@ def create_app(
from .http import create_app as create_http_app
app = create_http_app(
memory=memory,
run_migrations=run_migrations,
initialize_memory=initialize_memory
)
logger.info("HTTP REST API enabled")
@@ -85,7 +87,7 @@ from .http import (
ReflectRequest,
ReflectResponse,
CreateBankRequest,
DispositionTraits,
PersonalityTraits,
)
__all__ = [
@@ -98,5 +100,5 @@ __all__ = [
"ReflectRequest",
"ReflectResponse",
"CreateBankRequest",
"DispositionTraits",
"PersonalityTraits",
]
+213 -161
View File
@@ -36,13 +36,27 @@ from pydantic import BaseModel, Field, ConfigDict
from hindsight_api import MemoryEngine
from hindsight_api.engine.memory_engine import Budget
from hindsight_api.engine.db_utils import acquire_with_retry
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
from hindsight_api.metrics import get_metrics_collector, initialize_metrics, create_metrics_collector
logger = logging.getLogger(__name__)
class MetadataFilter(BaseModel):
"""Filter for metadata fields. Matches records where (key=value) OR (key not set) when match_unset=True."""
model_config = ConfigDict(json_schema_extra={
"example": {
"key": "source",
"value": "slack",
"match_unset": True
}
})
key: str = Field(description="Metadata key to filter on")
value: Optional[str] = Field(default=None, description="Value to match. If None with match_unset=True, matches any record where key is not set.")
match_unset: bool = Field(default=True, description="If True, also match records where this metadata key is not set")
class EntityIncludeOptions(BaseModel):
"""Options for including entity observations in recall results."""
max_tokens: int = Field(default=500, description="Maximum tokens for entity observations")
@@ -70,11 +84,12 @@ class RecallRequest(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"query": "What did Alice say about machine learning?",
"types": ["world", "experience"],
"types": ["world", "bank"],
"budget": "mid",
"max_tokens": 4096,
"trace": True,
"query_timestamp": "2023-05-30T23:40:00",
"filters": [{"key": "source", "value": "slack", "match_unset": True}],
"include": {
"entities": {
"max_tokens": 500
@@ -89,6 +104,7 @@ class RecallRequest(BaseModel):
max_tokens: int = 4096
trace: bool = False
query_timestamp: Optional[str] = Field(default=None, description="ISO format date string (e.g., '2023-05-30T23:40:00')")
filters: Optional[List[MetadataFilter]] = Field(default=None, description="Filter by metadata. Multiple filters are ANDed together.")
include: IncludeOptions = Field(default_factory=IncludeOptions, description="Options for including additional data (entities are included by default)")
@@ -115,7 +131,7 @@ class RecallResult(BaseModel):
id: str
text: str
type: Optional[str] = None # fact type: world, experience, opinion, observation
type: Optional[str] = None # fact type: world, agent, opinion, observation
entities: Optional[List[str]] = None # Entity names mentioned in this fact
context: Optional[str] = None
occurred_start: Optional[str] = None # ISO format date when the event started
@@ -346,6 +362,7 @@ class ReflectRequest(BaseModel):
"query": "What do you think about artificial intelligence?",
"budget": "low",
"context": "This is for a research paper on AI ethics",
"filters": [{"key": "source", "value": "slack", "match_unset": True}],
"include": {
"facts": {}
}
@@ -355,6 +372,7 @@ class ReflectRequest(BaseModel):
query: str
budget: Budget = Budget.LOW
context: Optional[str] = None
filters: Optional[List[MetadataFilter]] = Field(default=None, description="Filter by metadata. Multiple filters are ANDed together.")
include: ReflectIncludeOptions = Field(default_factory=ReflectIncludeOptions, description="Options for including additional data (disabled by default)")
@@ -379,7 +397,7 @@ class ReflectFact(BaseModel):
id: Optional[str] = None
text: str
type: Optional[str] = None # fact type: world, experience, opinion
type: Optional[str] = None # fact type: world, agent, opinion
context: Optional[str] = None
occurred_start: Optional[str] = None
occurred_end: Optional[str] = None
@@ -399,7 +417,7 @@ class ReflectResponse(BaseModel):
{
"id": "456",
"text": "I discussed AI applications last week",
"type": "experience"
"type": "bank"
}
]
}
@@ -420,19 +438,25 @@ class BanksResponse(BaseModel):
banks: List[str]
class DispositionTraits(BaseModel):
"""Disposition traits that influence how memories are formed and interpreted."""
class PersonalityTraits(BaseModel):
"""Personality traits based on Big Five model."""
model_config = ConfigDict(json_schema_extra={
"example": {
"skepticism": 3,
"literalism": 3,
"empathy": 3
"openness": 0.8,
"conscientiousness": 0.6,
"extraversion": 0.5,
"agreeableness": 0.7,
"neuroticism": 0.3,
"bias_strength": 0.7
}
})
skepticism: int = Field(ge=1, le=5, description="How skeptical vs trusting (1=trusting, 5=skeptical)")
literalism: int = Field(ge=1, le=5, description="How literally to interpret information (1=flexible, 5=literal)")
empathy: int = Field(ge=1, le=5, description="How much to consider emotional context (1=detached, 5=empathetic)")
openness: float = Field(ge=0.0, le=1.0, description="Openness to experience (0-1)")
conscientiousness: float = Field(ge=0.0, le=1.0, description="Conscientiousness (0-1)")
extraversion: float = Field(ge=0.0, le=1.0, description="Extraversion (0-1)")
agreeableness: float = Field(ge=0.0, le=1.0, description="Agreeableness (0-1)")
neuroticism: float = Field(ge=0.0, le=1.0, description="Neuroticism (0-1)")
bias_strength: float = Field(ge=0.0, le=1.0, description="How strongly personality influences opinions (0-1)")
class BankProfileResponse(BaseModel):
@@ -441,10 +465,13 @@ class BankProfileResponse(BaseModel):
"example": {
"bank_id": "user123",
"name": "Alice",
"disposition": {
"skepticism": 3,
"literalism": 3,
"empathy": 3
"personality": {
"openness": 0.8,
"conscientiousness": 0.6,
"extraversion": 0.5,
"agreeableness": 0.7,
"neuroticism": 0.3,
"bias_strength": 0.7
},
"background": "I am a software engineer with 10 years of experience in startups"
}
@@ -452,13 +479,13 @@ class BankProfileResponse(BaseModel):
bank_id: str
name: str
disposition: DispositionTraits
personality: PersonalityTraits
background: str
class UpdateDispositionRequest(BaseModel):
"""Request model for updating disposition traits."""
disposition: DispositionTraits
class UpdatePersonalityRequest(BaseModel):
"""Request model for updating personality traits."""
personality: PersonalityTraits
class AddBackgroundRequest(BaseModel):
@@ -466,14 +493,14 @@ class AddBackgroundRequest(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"content": "I was born in Texas",
"update_disposition": True
"update_personality": True
}
})
content: str = Field(description="New background information to add or merge")
update_disposition: bool = Field(
update_personality: bool = Field(
default=True,
description="If true, infer disposition traits from the merged background (default: true)"
description="If true, infer Big Five personality traits from the merged background (default: true)"
)
@@ -482,23 +509,26 @@ class BackgroundResponse(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"background": "I was born in Texas. I am a software engineer with 10 years of experience.",
"disposition": {
"skepticism": 3,
"literalism": 3,
"empathy": 3
"personality": {
"openness": 0.7,
"conscientiousness": 0.6,
"extraversion": 0.5,
"agreeableness": 0.8,
"neuroticism": 0.4,
"bias_strength": 0.6
}
}
})
background: str
disposition: Optional[DispositionTraits] = None
personality: Optional[PersonalityTraits] = None
class BankListItem(BaseModel):
"""Bank list item with profile summary."""
bank_id: str
name: str
disposition: DispositionTraits
personality: PersonalityTraits
background: str
created_at: Optional[str] = None
updated_at: Optional[str] = None
@@ -512,10 +542,13 @@ class BankListResponse(BaseModel):
{
"bank_id": "user123",
"name": "Alice",
"disposition": {
"skepticism": 3,
"literalism": 3,
"empathy": 3
"personality": {
"openness": 0.5,
"conscientiousness": 0.5,
"extraversion": 0.5,
"agreeableness": 0.5,
"neuroticism": 0.5,
"bias_strength": 0.5
},
"background": "I am a software engineer",
"created_at": "2024-01-15T10:30:00Z",
@@ -533,17 +566,20 @@ class CreateBankRequest(BaseModel):
model_config = ConfigDict(json_schema_extra={
"example": {
"name": "Alice",
"disposition": {
"skepticism": 3,
"literalism": 3,
"empathy": 3
"personality": {
"openness": 0.8,
"conscientiousness": 0.6,
"extraversion": 0.5,
"agreeableness": 0.7,
"neuroticism": 0.3,
"bias_strength": 0.7
},
"background": "I am a creative software engineer with 10 years of experience"
}
})
name: Optional[str] = None
disposition: Optional[DispositionTraits] = None
personality: Optional[PersonalityTraits] = None
background: Optional[str] = None
@@ -679,13 +715,13 @@ class DeleteResponse(BaseModel):
success: bool
def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
def create_app(memory: MemoryEngine, run_migrations: bool = True, initialize_memory: bool = True) -> FastAPI:
"""
Create and configure the FastAPI application.
Args:
memory: MemoryEngine instance (already initialized with required parameters).
Migrations are controlled by the MemoryEngine's run_migrations parameter.
memory: MemoryEngine instance (already initialized with required parameters)
run_migrations: Whether to run database migrations on startup (default: True)
initialize_memory: Whether to initialize memory system on startup (default: True)
Returns:
@@ -716,11 +752,16 @@ def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
app.state.prometheus_reader = None
# Metrics collector is already initialized as no-op by default
# Startup: Initialize database and memory system (migrations run inside initialize if enabled)
# Startup: Initialize database and memory system
if initialize_memory:
await memory.initialize()
logging.info("Memory system initialized")
if run_migrations:
from hindsight_api.migrations import run_migrations as do_migrations
do_migrations(memory.db_url)
logging.info("Database migrations applied")
yield
@@ -729,11 +770,9 @@ def create_app(memory: MemoryEngine, initialize_memory: bool = True) -> FastAPI:
await memory.close()
logging.info("Memory system closed")
from hindsight_api import __version__
app = FastAPI(
title="Hindsight HTTP API",
version=__version__,
version="1.0.0",
description="HTTP API for Hindsight",
contact={
"name": "Memory System",
@@ -794,9 +833,8 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/graph",
response_model=GraphDataResponse,
summary="Get memory graph data",
description="Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). Limited to 1000 most recent items.",
operation_id="get_graph",
tags=["Memory"]
description="Retrieve graph data for visualization, optionally filtered by type (world/agent/opinion). Limited to 1000 most recent items.",
operation_id="get_graph"
)
async def api_graph(bank_id: str,
type: Optional[str] = None
@@ -817,8 +855,7 @@ def _register_routes(app: FastAPI):
response_model=ListMemoryUnitsResponse,
summary="List memory units",
description="List memory units with pagination and optional full-text search. Supports filtering by type. Results are sorted by most recent first (mentioned_at DESC, then created_at DESC).",
operation_id="list_memories",
tags=["Memory"]
operation_id="list_memories"
)
async def api_list(bank_id: str,
type: Optional[str] = None,
@@ -834,7 +871,7 @@ def _register_routes(app: FastAPI):
Args:
bank_id: Memory Bank ID (from path)
type: Filter by fact type (world, experience, opinion)
type: Filter by fact type (world, agent, opinion)
q: Search query for full-text search (searches text and context)
limit: Maximum number of results (default: 100)
offset: Offset for pagination (default: 0)
@@ -859,22 +896,34 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/memories/recall",
response_model=RecallResponse,
summary="Recall memory",
description="Recall memory using semantic similarity and spreading activation.\n\n"
"The type parameter is optional and must be one of:\n"
"- `world`: General knowledge about people, places, events, and things that happen\n"
"- `experience`: Memories about experience, conversations, actions taken, and tasks performed\n"
"- `opinion`: The bank's formed beliefs, perspectives, and viewpoints\n\n"
"Set `include_entities=true` to get entity observations alongside recall results.",
operation_id="recall_memories",
tags=["Memory"]
description="""
Recall memory using semantic similarity and spreading activation.
The type parameter is optional and must be one of:
- 'world': General knowledge about people, places, events, and things that happen
- 'bank': Memories about what the AI agent did, actions taken, and tasks performed
- 'opinion': The bank's formed beliefs, perspectives, and viewpoints
Set include_entities=true to get entity observations alongside recall results.
""",
operation_id="recall_memories"
)
async def api_recall(bank_id: str, request: RecallRequest):
"""Run a recall and return results with trace."""
metrics = get_metrics_collector()
try:
# Default to world, experience, opinion if not specified (exclude observation by default)
fact_types = request.types if request.types else list(VALID_RECALL_FACT_TYPES)
# Validate types
valid_fact_types = ["world", "bank", "opinion"]
# Default to world, agent, opinion if not specified (exclude observation by default)
fact_types = request.types if request.types else ["world", "bank", "opinion"]
for ft in fact_types:
if ft not in valid_fact_types:
raise HTTPException(
status_code=400,
detail=f"Invalid type '{ft}'. Must be one of: {', '.join(valid_fact_types)}"
)
# Parse query_timestamp if provided
question_date = None
@@ -973,16 +1022,18 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/reflect",
response_model=ReflectResponse,
summary="Reflect and generate answer",
description="Reflect and formulate an answer using bank identity, world facts, and opinions.\n\n"
"This endpoint:\n"
"1. Retrieves experience (conversations and events)\n"
"2. Retrieves world facts relevant to the query\n"
"3. Retrieves existing opinions (bank's perspectives)\n"
"4. Uses LLM to formulate a contextual answer\n"
"5. Extracts and stores any new opinions formed\n"
"6. Returns plain text answer, the facts used, and new opinions",
operation_id="reflect",
tags=["Memory"]
description="""
Reflect and formulate an answer using bank identity, world facts, and opinions.
This endpoint:
1. Retrieves agent facts (bank's identity)
2. Retrieves world facts relevant to the query
3. Retrieves existing opinions (bank's perspectives)
4. Uses LLM to formulate a contextual answer
5. Extracts and stores any new opinions formed
6. Returns plain text answer, the facts used, and new opinions
""",
operation_id="reflect"
)
async def api_reflect(bank_id: str, request: ReflectRequest):
metrics = get_metrics_collector()
@@ -1028,8 +1079,7 @@ def _register_routes(app: FastAPI):
response_model=BankListResponse,
summary="List all memory banks",
description="Get a list of all agents with their profiles",
operation_id="list_banks",
tags=["Banks"]
operation_id="list_banks"
)
async def api_list_banks():
"""Get list of all banks with their profiles."""
@@ -1046,8 +1096,7 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/stats",
summary="Get statistics for memory bank",
description="Get statistics about nodes and links for a specific agent",
operation_id="get_agent_stats",
tags=["Banks"]
operation_id="get_agent_stats"
)
async def api_stats(bank_id: str):
"""Get statistics about memory nodes and links for a memory bank."""
@@ -1168,8 +1217,7 @@ def _register_routes(app: FastAPI):
response_model=EntityListResponse,
summary="List entities",
description="List all entities (people, organizations, etc.) known by the bank, ordered by mention count.",
operation_id="list_entities",
tags=["Entities"]
operation_id="list_entities"
)
async def api_list_entities(bank_id: str,
limit: int = Query(default=100, description="Maximum number of entities to return")
@@ -1191,8 +1239,7 @@ def _register_routes(app: FastAPI):
response_model=EntityDetailResponse,
summary="Get entity details",
description="Get detailed information about an entity including observations (mental model).",
operation_id="get_entity",
tags=["Entities"]
operation_id="get_entity"
)
async def api_get_entity(bank_id: str, entity_id: str):
"""Get entity details with observations."""
@@ -1242,8 +1289,7 @@ def _register_routes(app: FastAPI):
response_model=EntityDetailResponse,
summary="Regenerate entity observations",
description="Regenerate observations for an entity based on all facts mentioning it.",
operation_id="regenerate_entity_observations",
tags=["Entities"]
operation_id="regenerate_entity_observations"
)
async def api_regenerate_entity_observations(bank_id: str, entity_id: str):
"""Regenerate observations for an entity."""
@@ -1300,8 +1346,7 @@ def _register_routes(app: FastAPI):
response_model=ListDocumentsResponse,
summary="List documents",
description="List documents with pagination and optional search. Documents are the source content from which memory units are extracted.",
operation_id="list_documents",
tags=["Documents"]
operation_id="list_documents"
)
async def api_list_documents(bank_id: str,
q: Optional[str] = None,
@@ -1337,8 +1382,7 @@ def _register_routes(app: FastAPI):
response_model=DocumentResponse,
summary="Get document details",
description="Get a specific document including its original text",
operation_id="get_document",
tags=["Documents"]
operation_id="get_document"
)
async def api_get_document(bank_id: str,
document_id: str
@@ -1369,8 +1413,7 @@ def _register_routes(app: FastAPI):
response_model=ChunkResponse,
summary="Get chunk details",
description="Get a specific chunk by its ID",
operation_id="get_chunk",
tags=["Documents"]
operation_id="get_chunk"
)
async def api_get_chunk(chunk_id: str):
"""
@@ -1396,14 +1439,17 @@ def _register_routes(app: FastAPI):
@app.delete(
"/v1/default/banks/{bank_id}/documents/{document_id}",
summary="Delete a document",
description="Delete a document and all its associated memory units and links.\n\n"
"This will cascade delete:\n"
"- The document itself\n"
"- All memory units extracted from this document\n"
"- All links (temporal, semantic, entity) associated with those memory units\n\n"
"This operation cannot be undone.",
operation_id="delete_document",
tags=["Documents"]
description="""
Delete a document and all its associated memory units and links.
This will cascade delete:
- The document itself
- All memory units extracted from this document
- All links (temporal, semantic, entity) associated with those memory units
This operation cannot be undone.
""",
operation_id="delete_document"
)
async def api_delete_document(bank_id: str,
document_id: str
@@ -1440,8 +1486,7 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/operations",
summary="List async operations",
description="Get a list of all async operations (pending and failed) for a specific agent, including error messages for failed operations",
operation_id="list_operations",
tags=["Operations"]
operation_id="list_operations"
)
async def api_list_operations(bank_id: str):
"""List all async operations (pending and failed) for a memory bank."""
@@ -1485,8 +1530,7 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/operations/{operation_id}",
summary="Cancel a pending async operation",
description="Cancel a pending async operation by removing it from the queue",
operation_id="cancel_operation",
tags=["Operations"]
operation_id="cancel_operation"
)
async def api_cancel_operation(bank_id: str, operation_id: str):
"""Cancel a pending async operation."""
@@ -1535,20 +1579,19 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/profile",
response_model=BankProfileResponse,
summary="Get memory bank profile",
description="Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists.",
operation_id="get_bank_profile",
tags=["Banks"]
description="Get personality traits and background for a memory bank. Auto-creates agent with defaults if not exists.",
operation_id="get_bank_profile"
)
async def api_get_bank_profile(bank_id: str):
"""Get memory bank profile (disposition + background)."""
"""Get memory bank profile (personality + background)."""
try:
profile = await app.state.memory.get_bank_profile(bank_id)
# Convert DispositionTraits object to dict for Pydantic
disposition_dict = profile["disposition"].model_dump() if hasattr(profile["disposition"], 'model_dump') else dict(profile["disposition"])
# Convert PersonalityTraits object to dict for Pydantic
personality_dict = profile["personality"].model_dump() if hasattr(profile["personality"], 'model_dump') else dict(profile["personality"])
return BankProfileResponse(
bank_id=bank_id,
name=profile["name"],
disposition=DispositionTraits(**disposition_dict),
personality=PersonalityTraits(**personality_dict),
background=profile["background"]
)
except Exception as e:
@@ -1561,29 +1604,28 @@ def _register_routes(app: FastAPI):
@app.put(
"/v1/default/banks/{bank_id}/profile",
response_model=BankProfileResponse,
summary="Update memory bank disposition",
description="Update bank's disposition traits (skepticism, literalism, empathy)",
operation_id="update_bank_disposition",
tags=["Banks"]
summary="Update memory bank personality",
description="Update bank's Big Five personality traits and bias strength",
operation_id="update_bank_personality"
)
async def api_update_bank_disposition(bank_id: str,
request: UpdateDispositionRequest
async def api_update_bank_personality(bank_id: str,
request: UpdatePersonalityRequest
):
"""Update bank disposition traits."""
"""Update bank personality traits."""
try:
# Update disposition
await app.state.memory.update_bank_disposition(
# Update personality
await app.state.memory.update_bank_personality(
bank_id,
request.disposition.model_dump()
request.personality.model_dump()
)
# Get updated profile
profile = await app.state.memory.get_bank_profile(bank_id)
disposition_dict = profile["disposition"].model_dump() if hasattr(profile["disposition"], 'model_dump') else dict(profile["disposition"])
personality_dict = profile["personality"].model_dump() if hasattr(profile["personality"], 'model_dump') else dict(profile["personality"])
return BankProfileResponse(
bank_id=bank_id,
name=profile["name"],
disposition=DispositionTraits(**disposition_dict),
personality=PersonalityTraits(**personality_dict),
background=profile["background"]
)
except Exception as e:
@@ -1597,24 +1639,23 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/background",
response_model=BackgroundResponse,
summary="Add/merge memory bank background",
description="Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits.",
operation_id="add_bank_background",
tags=["Banks"]
description="Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers personality traits.",
operation_id="add_bank_background"
)
async def api_add_bank_background(bank_id: str,
request: AddBackgroundRequest
):
"""Add or merge bank background information. Optionally infer disposition traits."""
"""Add or merge bank background information. Optionally infer personality traits."""
try:
result = await app.state.memory.merge_bank_background(
bank_id,
request.content,
update_disposition=request.update_disposition
update_personality=request.update_personality
)
response = BackgroundResponse(background=result["background"])
if "disposition" in result:
response.disposition = DispositionTraits(**result["disposition"])
if "personality" in result:
response.personality = PersonalityTraits(**result["personality"])
return response
except Exception as e:
@@ -1628,14 +1669,13 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}",
response_model=BankProfileResponse,
summary="Create or update memory bank",
description="Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults.",
operation_id="create_or_update_bank",
tags=["Banks"]
description="Create a new agent or update existing agent with personality and background. Auto-fills missing fields with defaults.",
operation_id="create_or_update_bank"
)
async def api_create_or_update_bank(bank_id: str,
request: CreateBankRequest
):
"""Create or update an agent with disposition and background."""
"""Create or update an agent with personality and background."""
try:
# Get existing profile or create with defaults
profile = await app.state.memory.get_bank_profile(bank_id)
@@ -1656,13 +1696,13 @@ def _register_routes(app: FastAPI):
)
profile["name"] = request.name
# Update disposition if provided
if request.disposition is not None:
await app.state.memory.update_bank_disposition(
# Update personality if provided
if request.personality is not None:
await app.state.memory.update_bank_personality(
bank_id,
request.disposition.model_dump()
request.personality.model_dump()
)
profile["disposition"] = request.disposition.model_dump()
profile["personality"] = request.personality.model_dump()
# Update background if provided (replace, not merge)
if request.background is not None:
@@ -1682,11 +1722,11 @@ def _register_routes(app: FastAPI):
# Get final profile
final_profile = await app.state.memory.get_bank_profile(bank_id)
disposition_dict = final_profile["disposition"].model_dump() if hasattr(final_profile["disposition"], 'model_dump') else dict(final_profile["disposition"])
personality_dict = final_profile["personality"].model_dump() if hasattr(final_profile["personality"], 'model_dump') else dict(final_profile["personality"])
return BankProfileResponse(
bank_id=bank_id,
name=final_profile["name"],
disposition=DispositionTraits(**disposition_dict),
personality=PersonalityTraits(**personality_dict),
background=final_profile["background"]
)
except Exception as e:
@@ -1700,26 +1740,39 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/memories",
response_model=RetainResponse,
summary="Retain memories",
description="Retain memory items with automatic fact extraction.\n\n"
"This is the main endpoint for storing memories. It supports both synchronous and asynchronous processing via the `async` parameter.\n\n"
"**Features:**\n"
"- Efficient batch processing\n"
"- Automatic fact extraction from natural language\n"
"- Entity recognition and linking\n"
"- Document tracking with automatic upsert (when document_id is provided)\n"
"- Temporal and semantic linking\n"
"- Optional asynchronous processing\n\n"
"**The system automatically:**\n"
"1. Extracts semantic facts from the content\n"
"2. Generates embeddings\n"
"3. Deduplicates similar facts\n"
"4. Creates temporal, semantic, and entity links\n"
"5. Tracks document metadata\n\n"
"**When `async=true`:** Returns immediately after queuing. Use the operations endpoint to monitor progress.\n\n"
"**When `async=false` (default):** Waits for processing to complete.\n\n"
"**Note:** If a memory item has a `document_id` that already exists, the old document and its memory units will be deleted before creating new ones (upsert behavior).",
operation_id="retain_memories",
tags=["Memory"]
description="""
Retain memory items with automatic fact extraction.
This is the main endpoint for storing memories. It supports both synchronous and asynchronous processing
via the async parameter.
Features:
- Efficient batch processing
- Automatic fact extraction from natural language
- Entity recognition and linking
- Document tracking with automatic upsert (when document_id is provided on items)
- Temporal and semantic linking
- Optional asynchronous processing
The system automatically:
1. Extracts semantic facts from the content
2. Generates embeddings
3. Deduplicates similar facts
4. Creates temporal, semantic, and entity links
5. Tracks document metadata
When async=true:
- Returns immediately after queuing the task
- Processing happens in the background
- Use the operations endpoint to monitor progress
When async=false (default):
- Waits for processing to complete
- Returns after all memories are stored
Note: If a memory item has a document_id that already exists, the old document and its memory units will be deleted before creating new ones (upsert behavior). Items with the same document_id are grouped together for efficient processing.
""",
operation_id="retain_memories"
)
async def api_retain(bank_id: str, request: RetainRequest):
"""Retain memories with optional async processing."""
@@ -1799,12 +1852,11 @@ def _register_routes(app: FastAPI):
"/v1/default/banks/{bank_id}/memories",
response_model=DeleteResponse,
summary="Clear memory bank memories",
description="Delete memory units for a memory bank. Optionally filter by type (world, experience, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved.",
operation_id="clear_bank_memories",
tags=["Memory"]
description="Delete memory units for a memory bank. Optionally filter by type (world, agent, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (personality and background) will be preserved.",
operation_id="clear_bank_memories"
)
async def api_clear_bank_memories(bank_id: str,
type: Optional[str] = Query(None, description="Optional fact type filter (world, experience, opinion)")
type: Optional[str] = Query(None, description="Optional fact type filter (world, agent, opinion)")
):
"""Clear memories for a memory bank, optionally filtered by type."""
try:
+1 -2
View File
@@ -8,7 +8,6 @@ from typing import Optional
from fastmcp import FastMCP
from hindsight_api import MemoryEngine
from hindsight_api.engine.response_models import VALID_RECALL_FACT_TYPES
# Configure logging from HINDSIGHT_API_LOG_LEVEL environment variable
_log_level_str = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
@@ -91,7 +90,7 @@ def create_mcp_server(memory: MemoryEngine) -> FastMCP:
search_result = await memory.recall_async(
bank_id=bank_id,
query=query,
fact_type=list(VALID_RECALL_FACT_TYPES),
fact_type=["world", "bank", "opinion"],
budget=Budget.LOW
)
+128
View File
@@ -0,0 +1,128 @@
"""
Command-line interface for Hindsight API.
Run the server with:
hindsight-api
Stop with Ctrl+C.
"""
import argparse
import asyncio
import atexit
import os
import signal
import sys
from typing import Optional
import uvicorn
from . import MemoryEngine
from .api import create_app
# Disable tokenizers parallelism to avoid warnings
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# Global reference for cleanup
_memory: Optional[MemoryEngine] = None
def _cleanup():
"""Synchronous cleanup function to stop resources on exit."""
global _memory
if _memory is not None and _memory._pg0 is not None:
try:
loop = asyncio.new_event_loop()
loop.run_until_complete(_memory._pg0.stop())
loop.close()
print("\npg0 stopped.")
except Exception as e:
print(f"\nError stopping pg0: {e}")
def _signal_handler(signum, frame):
"""Handle SIGINT/SIGTERM to ensure cleanup."""
print(f"\nReceived signal {signum}, shutting down...")
_cleanup()
sys.exit(0)
def main():
"""Main entry point for the CLI."""
global _memory
parser = argparse.ArgumentParser(
prog="hindsight-api",
description="Hindsight API Server",
)
parser.add_argument(
"--host", default="0.0.0.0",
help="Host to bind to (default: 0.0.0.0)"
)
parser.add_argument(
"--port", type=int, default=8888,
help="Port to bind to (default: 8888)"
)
parser.add_argument(
"--log-level", default="info",
choices=["critical", "error", "warning", "info", "debug", "trace"],
help="Log level (default: info)"
)
parser.add_argument(
"--access-log", action="store_true",
help="Enable access log"
)
args = parser.parse_args()
# Register cleanup handlers
atexit.register(_cleanup)
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
# Get configuration from environment variables
db_url = os.getenv("HINDSIGHT_API_DATABASE_URL", "pg0")
llm_provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq")
llm_api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY", "")
llm_model = os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-20b")
llm_base_url = os.getenv("HINDSIGHT_API_LLM_BASE_URL") or None
# Create MemoryEngine
_memory = MemoryEngine(
db_url=db_url,
memory_llm_provider=llm_provider,
memory_llm_api_key=llm_api_key,
memory_llm_model=llm_model,
memory_llm_base_url=llm_base_url,
)
# Create FastAPI app
app = create_app(
memory=_memory,
http_api_enabled=True,
mcp_api_enabled=True,
mcp_mount_path="/mcp",
run_migrations=True,
initialize_memory=True,
)
# Prepare uvicorn config
uvicorn_config = {
"app": app,
"host": args.host,
"port": args.port,
"log_level": args.log_level,
"access_log": args.access_log,
}
print(f"\nStarting Hindsight API...")
print(f" URL: http://{args.host}:{args.port}")
print(f" Database: {db_url}")
print(f" LLM Provider: {llm_provider}")
print()
uvicorn.run(**uvicorn_config)
if __name__ == "__main__":
main()
-154
View File
@@ -1,154 +0,0 @@
"""
Centralized configuration for Hindsight API.
All environment variables and their defaults are defined here.
"""
import os
from dataclasses import dataclass
from typing import Optional
import logging
logger = logging.getLogger(__name__)
# Environment variable names
ENV_DATABASE_URL = "HINDSIGHT_API_DATABASE_URL"
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_EMBEDDINGS_PROVIDER = "HINDSIGHT_API_EMBEDDINGS_PROVIDER"
ENV_EMBEDDINGS_LOCAL_MODEL = "HINDSIGHT_API_EMBEDDINGS_LOCAL_MODEL"
ENV_EMBEDDINGS_TEI_URL = "HINDSIGHT_API_EMBEDDINGS_TEI_URL"
ENV_RERANKER_PROVIDER = "HINDSIGHT_API_RERANKER_PROVIDER"
ENV_RERANKER_LOCAL_MODEL = "HINDSIGHT_API_RERANKER_LOCAL_MODEL"
ENV_RERANKER_TEI_URL = "HINDSIGHT_API_RERANKER_TEI_URL"
ENV_HOST = "HINDSIGHT_API_HOST"
ENV_PORT = "HINDSIGHT_API_PORT"
ENV_LOG_LEVEL = "HINDSIGHT_API_LOG_LEVEL"
ENV_MCP_ENABLED = "HINDSIGHT_API_MCP_ENABLED"
# Default values
DEFAULT_DATABASE_URL = "pg0"
DEFAULT_LLM_PROVIDER = "groq"
DEFAULT_LLM_MODEL = "openai/gpt-oss-20b"
DEFAULT_EMBEDDINGS_PROVIDER = "local"
DEFAULT_EMBEDDINGS_LOCAL_MODEL = "BAAI/bge-small-en-v1.5"
DEFAULT_RERANKER_PROVIDER = "local"
DEFAULT_RERANKER_LOCAL_MODEL = "cross-encoder/ms-marco-MiniLM-L-6-v2"
DEFAULT_HOST = "0.0.0.0"
DEFAULT_PORT = 8888
DEFAULT_LOG_LEVEL = "info"
DEFAULT_MCP_ENABLED = True
# Required embedding dimension for database schema
EMBEDDING_DIMENSION = 384
@dataclass
class HindsightConfig:
"""Configuration container for Hindsight API."""
# Database
database_url: str
# LLM
llm_provider: str
llm_api_key: Optional[str]
llm_model: str
llm_base_url: Optional[str]
# Embeddings
embeddings_provider: str
embeddings_local_model: str
embeddings_tei_url: Optional[str]
# Reranker
reranker_provider: str
reranker_local_model: str
reranker_tei_url: Optional[str]
# Server
host: str
port: int
log_level: str
mcp_enabled: bool
@classmethod
def from_env(cls) -> "HindsightConfig":
"""Create configuration from environment variables."""
return cls(
# Database
database_url=os.getenv(ENV_DATABASE_URL, DEFAULT_DATABASE_URL),
# 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,
# 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_tei_url=os.getenv(ENV_EMBEDDINGS_TEI_URL),
# 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_tei_url=os.getenv(ENV_RERANKER_TEI_URL),
# Server
host=os.getenv(ENV_HOST, DEFAULT_HOST),
port=int(os.getenv(ENV_PORT, DEFAULT_PORT)),
log_level=os.getenv(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL),
mcp_enabled=os.getenv(ENV_MCP_ENABLED, str(DEFAULT_MCP_ENABLED)).lower() == "true",
)
def get_llm_base_url(self) -> str:
"""Get the LLM base URL, with provider-specific defaults."""
if self.llm_base_url:
return self.llm_base_url
provider = self.llm_provider.lower()
if provider == "groq":
return "https://api.groq.com/openai/v1"
elif provider == "ollama":
return "http://localhost:11434/v1"
else:
return ""
def get_python_log_level(self) -> int:
"""Get the Python logging level from the configured log level string."""
log_level_map = {
"critical": logging.CRITICAL,
"error": logging.ERROR,
"warning": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
"trace": logging.DEBUG, # Python doesn't have TRACE, use DEBUG
}
return log_level_map.get(self.log_level.lower(), logging.INFO)
def configure_logging(self) -> None:
"""Configure Python logging based on the log level."""
logging.basicConfig(
level=self.get_python_log_level(),
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s"
)
def log_config(self) -> None:
"""Log the current configuration (without sensitive values)."""
logger.info(f"Database: {self.database_url}")
logger.info(f"LLM: provider={self.llm_provider}, model={self.llm_model}")
logger.info(f"Embeddings: provider={self.embeddings_provider}")
logger.info(f"Reranker: provider={self.reranker_provider}")
def get_config() -> HindsightConfig:
"""Get the current configuration from environment variables."""
return HindsightConfig.from_env()
@@ -9,8 +9,7 @@ This package contains all the implementation details of the memory engine:
from .memory_engine import MemoryEngine
from .db_utils import acquire_with_retry
from .embeddings import Embeddings, LocalSTEmbeddings, RemoteTEIEmbeddings
from .cross_encoder import CrossEncoderModel, LocalSTCrossEncoder, RemoteTEICrossEncoder
from .embeddings import Embeddings, SentenceTransformersEmbeddings
from .search.trace import (
SearchTrace,
QueryInfo,
@@ -30,11 +29,7 @@ __all__ = [
"MemoryEngine",
"acquire_with_retry",
"Embeddings",
"LocalSTEmbeddings",
"RemoteTEIEmbeddings",
"CrossEncoderModel",
"LocalSTCrossEncoder",
"RemoteTEICrossEncoder",
"SentenceTransformersEmbeddings",
"SearchTrace",
"SearchTracer",
"QueryInfo",
@@ -2,23 +2,10 @@
Cross-encoder abstraction for reranking.
Provides an interface for reranking with different backends.
Configuration via environment variables - see hindsight_api.config for all env var names.
"""
from abc import ABC, abstractmethod
from typing import List, Tuple, Optional
from typing import List, Tuple
import logging
import os
import httpx
from ..config import (
ENV_RERANKER_PROVIDER,
ENV_RERANKER_LOCAL_MODEL,
ENV_RERANKER_TEI_URL,
DEFAULT_RERANKER_PROVIDER,
DEFAULT_RERANKER_LOCAL_MODEL,
)
logger = logging.getLogger(__name__)
@@ -30,18 +17,12 @@ class CrossEncoderModel(ABC):
Cross-encoders take query-document pairs and return relevance scores.
"""
@property
@abstractmethod
def provider_name(self) -> str:
"""Return a human-readable name for this provider (e.g., 'local', 'tei')."""
pass
@abstractmethod
async def initialize(self) -> None:
def load(self) -> None:
"""
Initialize the cross-encoder model asynchronously.
Load the cross-encoder model.
This should be called during startup to load/connect to the model
This should be called during initialization to load the model
and avoid cold start latency on first predict() call.
"""
pass
@@ -60,11 +41,11 @@ class CrossEncoderModel(ABC):
pass
class LocalSTCrossEncoder(CrossEncoderModel):
class SentenceTransformersCrossEncoder(CrossEncoderModel):
"""
Local cross-encoder implementation using SentenceTransformers.
Cross-encoder implementation using SentenceTransformers.
Call initialize() during startup to load the model and avoid cold starts.
Call load() during initialization to load the model and avoid cold starts.
Default model is cross-encoder/ms-marco-MiniLM-L-6-v2:
- Fast inference (~80ms for 100 pairs on CPU)
@@ -72,22 +53,18 @@ class LocalSTCrossEncoder(CrossEncoderModel):
- Trained for passage re-ranking
"""
def __init__(self, model_name: Optional[str] = None):
def __init__(self, model_name: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"):
"""
Initialize local SentenceTransformers cross-encoder.
Initialize SentenceTransformers cross-encoder.
Args:
model_name: Name of the CrossEncoder model to use.
Default: cross-encoder/ms-marco-MiniLM-L-6-v2
"""
self.model_name = model_name or DEFAULT_RERANKER_LOCAL_MODEL
self.model_name = model_name
self._model = None
@property
def provider_name(self) -> str:
return "local"
async def initialize(self) -> None:
def load(self) -> None:
"""Load the cross-encoder model."""
if self._model is not None:
return
@@ -96,18 +73,13 @@ class LocalSTCrossEncoder(CrossEncoderModel):
from sentence_transformers import CrossEncoder
except ImportError:
raise ImportError(
"sentence-transformers is required for LocalSTCrossEncoder. "
"sentence-transformers is required for SentenceTransformersCrossEncoder. "
"Install it with: pip install sentence-transformers"
)
logger.info(f"Reranker: initializing local provider with model {self.model_name}")
# Disable lazy loading (meta tensors) which causes issues with newer transformers/accelerate
# Setting low_cpu_mem_usage=False and device_map=None ensures tensors are fully materialized
self._model = CrossEncoder(
self.model_name,
model_kwargs={"low_cpu_mem_usage": False, "device_map": None},
)
logger.info("Reranker: local provider initialized")
logger.info(f"Loading cross-encoder model: {self.model_name}...")
self._model = CrossEncoder(self.model_name)
logger.info("Cross-encoder model loaded")
def predict(self, pairs: List[Tuple[str, str]]) -> List[float]:
"""
@@ -120,187 +92,6 @@ class LocalSTCrossEncoder(CrossEncoderModel):
List of relevance scores (raw logits from the model)
"""
if self._model is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
self.load()
scores = self._model.predict(pairs, show_progress_bar=False)
return scores.tolist() if hasattr(scores, 'tolist') else list(scores)
class RemoteTEICrossEncoder(CrossEncoderModel):
"""
Remote cross-encoder implementation using HuggingFace Text Embeddings Inference (TEI) HTTP API.
TEI supports reranking via the /rerank endpoint.
See: https://github.com/huggingface/text-embeddings-inference
Note: The TEI server must be running a cross-encoder/reranker model.
"""
def __init__(
self,
base_url: str,
timeout: float = 30.0,
batch_size: int = 32,
max_retries: int = 3,
retry_delay: float = 0.5,
):
"""
Initialize remote TEI cross-encoder client.
Args:
base_url: Base URL of the TEI server (e.g., "http://localhost:8080")
timeout: Request timeout in seconds (default: 30.0)
batch_size: Maximum batch size for rerank requests (default: 32)
max_retries: Maximum number of retries for failed requests (default: 3)
retry_delay: Initial delay between retries in seconds, doubles each retry (default: 0.5)
"""
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.batch_size = batch_size
self.max_retries = max_retries
self.retry_delay = retry_delay
self._client: Optional[httpx.Client] = None
self._model_id: Optional[str] = None
@property
def provider_name(self) -> str:
return "tei"
def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response:
"""Make an HTTP request with automatic retries on transient errors."""
import time
last_error = None
delay = self.retry_delay
for attempt in range(self.max_retries + 1):
try:
if method == "GET":
response = self._client.get(url, **kwargs)
else:
response = self._client.post(url, **kwargs)
response.raise_for_status()
return response
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e:
last_error = e
if attempt < self.max_retries:
logger.warning(f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
time.sleep(delay)
delay *= 2 # Exponential backoff
except httpx.HTTPStatusError as e:
# Retry on 5xx server errors
if e.response.status_code >= 500 and attempt < self.max_retries:
last_error = e
logger.warning(f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
time.sleep(delay)
delay *= 2
else:
raise
raise last_error
async def initialize(self) -> None:
"""Initialize the HTTP client and verify server connectivity."""
if self._client is not None:
return
logger.info(f"Reranker: initializing TEI provider at {self.base_url}")
self._client = httpx.Client(timeout=self.timeout)
# Verify server is reachable and get model info
try:
response = self._request_with_retry("GET", f"{self.base_url}/info")
info = response.json()
self._model_id = info.get("model_id", "unknown")
logger.info(f"Reranker: TEI provider initialized (model: {self._model_id})")
except httpx.HTTPError as e:
raise RuntimeError(f"Failed to connect to TEI server at {self.base_url}: {e}")
def predict(self, pairs: List[Tuple[str, str]]) -> List[float]:
"""
Score query-document pairs using the remote TEI reranker.
Args:
pairs: List of (query, document) tuples to score
Returns:
List of relevance scores
"""
if self._client is None:
raise RuntimeError("Reranker not initialized. Call initialize() first.")
if not pairs:
return []
all_scores = []
# Process in batches
for i in range(0, len(pairs), self.batch_size):
batch = pairs[i:i + self.batch_size]
# TEI rerank endpoint expects query and texts separately
# All pairs in a batch should have the same query for optimal performance
# but we handle mixed queries by making separate requests per unique query
query_groups: dict[str, list[tuple[int, str]]] = {}
for idx, (query, text) in enumerate(batch):
if query not in query_groups:
query_groups[query] = []
query_groups[query].append((idx, text))
batch_scores = [0.0] * len(batch)
for query, indexed_texts in query_groups.items():
texts = [text for _, text in indexed_texts]
indices = [idx for idx, _ in indexed_texts]
try:
response = self._request_with_retry(
"POST",
f"{self.base_url}/rerank",
json={
"query": query,
"texts": texts,
"return_text": False,
},
)
results = response.json()
# TEI returns results sorted by score descending, with original index
for result in results:
original_idx = result["index"]
score = result["score"]
# Map back to batch position
batch_scores[indices[original_idx]] = score
except httpx.HTTPError as e:
raise RuntimeError(f"TEI rerank request failed: {e}")
all_scores.extend(batch_scores)
return all_scores
def create_cross_encoder_from_env() -> CrossEncoderModel:
"""
Create a CrossEncoderModel instance based on environment variables.
See hindsight_api.config for environment variable names and defaults.
Returns:
Configured CrossEncoderModel instance
"""
provider = os.environ.get(ENV_RERANKER_PROVIDER, DEFAULT_RERANKER_PROVIDER).lower()
if provider == "tei":
url = os.environ.get(ENV_RERANKER_TEI_URL)
if not url:
raise ValueError(
f"{ENV_RERANKER_TEI_URL} is required when {ENV_RERANKER_PROVIDER} is 'tei'"
)
return RemoteTEICrossEncoder(base_url=url)
elif provider == "local":
model = os.environ.get(ENV_RERANKER_LOCAL_MODEL)
model_name = model or DEFAULT_RERANKER_LOCAL_MODEL
return LocalSTCrossEncoder(model_name=model_name)
else:
raise ValueError(
f"Unknown reranker provider: {provider}. Supported: 'local', 'tei'"
)
+19 -198
View File
@@ -5,27 +5,16 @@ Provides an interface for generating embeddings with different backends.
IMPORTANT: All embeddings must produce 384-dimensional vectors to match
the database schema (pgvector column defined as vector(384)).
Configuration via environment variables - see hindsight_api.config for all env var names.
"""
from abc import ABC, abstractmethod
from typing import List, Optional
from typing import List
import logging
import os
import httpx
from ..config import (
ENV_EMBEDDINGS_PROVIDER,
ENV_EMBEDDINGS_LOCAL_MODEL,
ENV_EMBEDDINGS_TEI_URL,
DEFAULT_EMBEDDINGS_PROVIDER,
DEFAULT_EMBEDDINGS_LOCAL_MODEL,
EMBEDDING_DIMENSION,
)
logger = logging.getLogger(__name__)
# Fixed embedding dimension required by database schema
EMBEDDING_DIMENSION = 384
class Embeddings(ABC):
"""
@@ -35,18 +24,12 @@ class Embeddings(ABC):
the database schema.
"""
@property
@abstractmethod
def provider_name(self) -> str:
"""Return a human-readable name for this provider (e.g., 'local', 'tei')."""
pass
@abstractmethod
async def initialize(self) -> None:
def load(self) -> None:
"""
Initialize the embedding model asynchronously.
Load the embedding model.
This should be called during startup to load/connect to the model
This should be called during initialization to load the model
and avoid cold start latency on first encode() call.
"""
pass
@@ -65,33 +48,29 @@ class Embeddings(ABC):
pass
class LocalSTEmbeddings(Embeddings):
class SentenceTransformersEmbeddings(Embeddings):
"""
Local embeddings implementation using SentenceTransformers.
Embeddings implementation using SentenceTransformers.
Call initialize() during startup to load the model and avoid cold starts.
Call load() during initialization to load the model and avoid cold starts.
Default model is BAAI/bge-small-en-v1.5 which produces 384-dimensional
embeddings matching the database schema.
"""
def __init__(self, model_name: Optional[str] = None):
def __init__(self, model_name: str = "BAAI/bge-small-en-v1.5"):
"""
Initialize local SentenceTransformers embeddings.
Initialize SentenceTransformers embeddings.
Args:
model_name: Name of the SentenceTransformer model to use.
Must produce 384-dimensional embeddings.
Default: BAAI/bge-small-en-v1.5
"""
self.model_name = model_name or DEFAULT_EMBEDDINGS_LOCAL_MODEL
self.model_name = model_name
self._model = None
@property
def provider_name(self) -> str:
return "local"
async def initialize(self) -> None:
def load(self) -> None:
"""Load the embedding model."""
if self._model is not None:
return
@@ -100,17 +79,12 @@ class LocalSTEmbeddings(Embeddings):
from sentence_transformers import SentenceTransformer
except ImportError:
raise ImportError(
"sentence-transformers is required for LocalSTEmbeddings. "
"sentence-transformers is required for SentenceTransformersEmbeddings. "
"Install it with: pip install sentence-transformers"
)
logger.info(f"Embeddings: initializing local provider with model {self.model_name}")
# Disable lazy loading (meta tensors) which causes issues with newer transformers/accelerate
# Setting low_cpu_mem_usage=False and device_map=None ensures tensors are fully materialized
self._model = SentenceTransformer(
self.model_name,
model_kwargs={"low_cpu_mem_usage": False, "device_map": None},
)
logger.info(f"Loading embedding model: {self.model_name}...")
self._model = SentenceTransformer(self.model_name)
# Validate dimension matches database schema
model_dim = self._model.get_sentence_embedding_dimension()
@@ -121,7 +95,7 @@ class LocalSTEmbeddings(Embeddings):
f"Use a model that produces {EMBEDDING_DIMENSION}-dimensional embeddings."
)
logger.info(f"Embeddings: local provider initialized (dim: {model_dim})")
logger.info(f"Model loaded (embedding dim: {model_dim})")
def encode(self, texts: List[str]) -> List[List[float]]:
"""
@@ -134,159 +108,6 @@ class LocalSTEmbeddings(Embeddings):
List of 384-dimensional embedding vectors
"""
if self._model is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
self.load()
embeddings = self._model.encode(texts, convert_to_numpy=True, show_progress_bar=False)
return [emb.tolist() for emb in embeddings]
class RemoteTEIEmbeddings(Embeddings):
"""
Remote embeddings implementation using HuggingFace Text Embeddings Inference (TEI) HTTP API.
TEI provides a high-performance inference server for embedding models.
See: https://github.com/huggingface/text-embeddings-inference
The server should be running a model that produces 384-dimensional embeddings.
"""
def __init__(
self,
base_url: str,
timeout: float = 30.0,
batch_size: int = 32,
max_retries: int = 3,
retry_delay: float = 0.5,
):
"""
Initialize remote TEI embeddings client.
Args:
base_url: Base URL of the TEI server (e.g., "http://localhost:8080")
timeout: Request timeout in seconds (default: 30.0)
batch_size: Maximum batch size for embedding requests (default: 32)
max_retries: Maximum number of retries for failed requests (default: 3)
retry_delay: Initial delay between retries in seconds, doubles each retry (default: 0.5)
"""
self.base_url = base_url.rstrip("/")
self.timeout = timeout
self.batch_size = batch_size
self.max_retries = max_retries
self.retry_delay = retry_delay
self._client: Optional[httpx.Client] = None
self._model_id: Optional[str] = None
@property
def provider_name(self) -> str:
return "tei"
def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response:
"""Make an HTTP request with automatic retries on transient errors."""
import time
last_error = None
delay = self.retry_delay
for attempt in range(self.max_retries + 1):
try:
if method == "GET":
response = self._client.get(url, **kwargs)
else:
response = self._client.post(url, **kwargs)
response.raise_for_status()
return response
except (httpx.ConnectError, httpx.ReadTimeout, httpx.WriteTimeout) as e:
last_error = e
if attempt < self.max_retries:
logger.warning(f"TEI request failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
time.sleep(delay)
delay *= 2 # Exponential backoff
except httpx.HTTPStatusError as e:
# Retry on 5xx server errors
if e.response.status_code >= 500 and attempt < self.max_retries:
last_error = e
logger.warning(f"TEI server error (attempt {attempt + 1}/{self.max_retries + 1}): {e}. Retrying in {delay}s...")
time.sleep(delay)
delay *= 2
else:
raise
raise last_error
async def initialize(self) -> None:
"""Initialize the HTTP client and verify server connectivity."""
if self._client is not None:
return
logger.info(f"Embeddings: initializing TEI provider at {self.base_url}")
self._client = httpx.Client(timeout=self.timeout)
# Verify server is reachable and get model info
try:
response = self._request_with_retry("GET", f"{self.base_url}/info")
info = response.json()
self._model_id = info.get("model_id", "unknown")
logger.info(f"Embeddings: TEI provider initialized (model: {self._model_id})")
except httpx.HTTPError as e:
raise RuntimeError(f"Failed to connect to TEI server at {self.base_url}: {e}")
def encode(self, texts: List[str]) -> List[List[float]]:
"""
Generate embeddings using the remote TEI server.
Args:
texts: List of text strings to encode
Returns:
List of embedding vectors
"""
if self._client is None:
raise RuntimeError("Embeddings not initialized. Call initialize() first.")
if not texts:
return []
all_embeddings = []
# Process in batches
for i in range(0, len(texts), self.batch_size):
batch = texts[i:i + self.batch_size]
try:
response = self._request_with_retry(
"POST",
f"{self.base_url}/embed",
json={"inputs": batch},
)
batch_embeddings = response.json()
all_embeddings.extend(batch_embeddings)
except httpx.HTTPError as e:
raise RuntimeError(f"TEI embedding request failed: {e}")
return all_embeddings
def create_embeddings_from_env() -> Embeddings:
"""
Create an Embeddings instance based on environment variables.
See hindsight_api.config for environment variable names and defaults.
Returns:
Configured Embeddings instance
"""
provider = os.environ.get(ENV_EMBEDDINGS_PROVIDER, DEFAULT_EMBEDDINGS_PROVIDER).lower()
if provider == "tei":
url = os.environ.get(ENV_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)
else:
raise ValueError(
f"Unknown embeddings provider: {provider}. Supported: 'local', 'tei'"
)
@@ -126,20 +126,18 @@ class EntityResolver:
# Resolve each entity using pre-fetched candidates
entity_ids = [None] * len(entities_data)
entities_to_update = [] # (entity_id, event_date)
entities_to_create = [] # (idx, entity_data, event_date)
entities_to_update = [] # (entity_id, unit_event_date)
entities_to_create = [] # (idx, entity_data)
for idx, entity_data in enumerate(entities_data):
entity_text = entity_data['text']
nearby_entities = entity_data.get('nearby_entities', [])
# Use per-entity date if available, otherwise fall back to batch-level date
entity_event_date = entity_data.get('event_date', unit_event_date)
candidates = all_candidates.get(entity_text, [])
if not candidates:
# Will create new entity
entities_to_create.append((idx, entity_data, entity_event_date))
entities_to_create.append((idx, entity_data))
continue
# Score candidates
@@ -167,9 +165,9 @@ class EntityResolver:
score += co_entity_score * 0.3
# 3. Temporal proximity (0-0.2)
if last_seen and entity_event_date:
if last_seen:
# Normalize timezone awareness for comparison
event_date_utc = entity_event_date if entity_event_date.tzinfo else entity_event_date.replace(tzinfo=timezone.utc)
event_date_utc = unit_event_date if unit_event_date.tzinfo else unit_event_date.replace(tzinfo=timezone.utc)
last_seen_utc = last_seen if last_seen.tzinfo else last_seen.replace(tzinfo=timezone.utc)
days_diff = abs((event_date_utc - last_seen_utc).total_seconds() / 86400)
if days_diff < 7:
@@ -185,9 +183,9 @@ class EntityResolver:
if best_score > threshold:
entity_ids[idx] = best_candidate
entities_to_update.append((best_candidate, entity_event_date))
entities_to_update.append((best_candidate, unit_event_date))
else:
entities_to_create.append((idx, entity_data, entity_event_date))
entities_to_create.append((idx, entity_data))
# Batch update existing entities
if entities_to_update:
@@ -201,54 +199,29 @@ class EntityResolver:
entities_to_update
)
# Batch create new entities using COPY + INSERT for maximum speed
# This handles duplicates via ON CONFLICT and returns all IDs
# Create new entities using INSERT ... ON CONFLICT to handle race conditions
# This ensures that if two concurrent transactions try to create the same entity,
# only one succeeds and the other gets the existing ID
if entities_to_create:
# Group entities by canonical name (lowercase) to handle duplicates within batch
# For duplicates, we only insert once and reuse the ID
unique_entities = {} # lowercase_name -> (entity_data, event_date, [indices])
for idx, entity_data, event_date in entities_to_create:
name_lower = entity_data['text'].lower()
if name_lower not in unique_entities:
unique_entities[name_lower] = (entity_data, event_date, [idx])
else:
# Same entity appears multiple times - add index to list
unique_entities[name_lower][2].append(idx)
# Batch insert unique entities and get their IDs
# Use a single query with unnest for speed
entity_names = []
entity_dates = []
indices_map = [] # Maps result index -> list of original indices
for name_lower, (entity_data, event_date, indices) in unique_entities.items():
entity_names.append(entity_data['text'])
entity_dates.append(event_date)
indices_map.append(indices)
# Batch INSERT ... ON CONFLICT with RETURNING
# This is much faster than individual inserts
rows = await conn.fetch(
"""
INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count)
SELECT $1, name, event_date, event_date, 1
FROM unnest($2::text[], $3::timestamptz[]) AS t(name, event_date)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO UPDATE SET
mention_count = entities.mention_count + 1,
last_seen = EXCLUDED.last_seen
RETURNING id
""",
bank_id,
entity_names,
entity_dates
)
# Map returned IDs back to original indices
for result_idx, row in enumerate(rows):
entity_id = row['id']
for original_idx in indices_map[result_idx]:
entity_ids[original_idx] = entity_id
for idx, entity_data in entities_to_create:
# Use INSERT ... ON CONFLICT to atomically get-or-create
# The unique index is on (bank_id, LOWER(canonical_name))
row = await conn.fetchrow(
"""
INSERT INTO entities (bank_id, canonical_name, first_seen, last_seen, mention_count)
VALUES ($1, $2, $3, $4, 1)
ON CONFLICT (bank_id, LOWER(canonical_name))
DO UPDATE SET
mention_count = entities.mention_count + 1,
last_seen = EXCLUDED.last_seen
RETURNING id
""",
bank_id,
entity_data['text'],
unit_event_date,
unit_event_date
)
entity_ids[idx] = row['id']
return entity_ids
+85 -102
View File
@@ -5,12 +5,9 @@ import os
import time
import asyncio
from typing import Optional, Any, Dict, List
from openai import AsyncOpenAI, RateLimitError, APIError, APIStatusError, APIConnectionError, LengthFinishReasonError
from openai import AsyncOpenAI, RateLimitError, APIError, APIStatusError, LengthFinishReasonError
import logging
# Seed applied to every Groq request for deterministic behavior.
DEFAULT_LLM_SEED = 4242
logger = logging.getLogger(__name__)
# Disable httpx logging
@@ -31,12 +28,8 @@ class OutputTooLongError(Exception):
pass
class LLMProvider:
"""
Unified LLM provider using OpenAI-compatible API.
Supports OpenAI, Groq, and Ollama (any OpenAI-compatible endpoint).
"""
class LLMConfig:
"""Configuration for an LLM provider."""
def __init__(
self,
@@ -44,29 +37,25 @@ class LLMProvider:
api_key: str,
base_url: str,
model: str,
reasoning_effort: str = "low",
):
"""
Initialize LLM provider.
Initialize LLM configuration.
Args:
provider: Provider name ("openai", "groq", "ollama").
api_key: API key.
base_url: Base URL for the API.
model: Model name.
reasoning_effort: Reasoning effort level for supported providers.
provider: Provider name ("openai", "groq", "ollama"). Required.
api_key: API key. Required.
base_url: Base URL. Required.
model: Model name. Required.
"""
self.provider = provider.lower()
self.api_key = api_key
self.base_url = base_url
self.model = model
self.reasoning_effort = reasoning_effort
# Validate provider
valid_providers = ["openai", "groq", "ollama"]
if self.provider not in valid_providers:
if self.provider not in ["openai", "groq", "ollama"]:
raise ValueError(
f"Invalid LLM provider: {self.provider}. Must be one of: {', '.join(valid_providers)}"
f"Invalid LLM provider: {self.provider}. Must be 'openai', 'groq', or 'ollama'."
)
# Set default base URLs
@@ -78,13 +67,18 @@ class LLMProvider:
# Validate API key (not needed for ollama)
if self.provider != "ollama" and not self.api_key:
raise ValueError(f"API key not found for {self.provider}")
raise ValueError(
f"API key not found for {self.provider}"
)
# Create OpenAI-compatible client for all providers
# Create client (private - use .call() method instead)
# Disable automatic retries - we handle retries in the call() method
if self.provider == "ollama":
self._client = AsyncOpenAI(api_key="ollama", base_url=self.base_url, max_retries=0)
else:
elif self.base_url:
self._client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url, max_retries=0)
else:
self._client = AsyncOpenAI(api_key=self.api_key, max_retries=0)
logger.info(
f"Initialized LLM: provider={self.provider}, model={self.model}, base_url={self.base_url}"
@@ -94,134 +88,121 @@ class LLMProvider:
self,
messages: List[Dict[str, str]],
response_format: Optional[Any] = None,
max_completion_tokens: Optional[int] = None,
temperature: Optional[float] = None,
scope: str = "memory",
max_retries: int = 10,
initial_backoff: float = 1.0,
max_backoff: float = 60.0,
skip_validation: bool = False,
**kwargs
) -> Any:
"""
Make an LLM API call with retry logic.
Make an LLM API call with consistent configuration and retry logic.
Args:
messages: List of message dicts with 'role' and 'content'.
response_format: Optional Pydantic model for structured output.
max_completion_tokens: Maximum tokens in response.
temperature: Sampling temperature (0.0-2.0).
scope: Scope identifier for tracking.
max_retries: Maximum retry attempts.
initial_backoff: Initial backoff time in seconds.
max_backoff: Maximum backoff time in seconds.
skip_validation: Return raw JSON without Pydantic validation.
messages: List of message dicts with 'role' and 'content'
response_format: Optional Pydantic model for structured output
scope: Scope identifier (e.g., 'memory', 'judge') for future tracking
max_retries: Maximum number of retry attempts (default: 5)
initial_backoff: Initial backoff time in seconds (default: 1.0)
max_backoff: Maximum backoff time in seconds (default: 60.0)
**kwargs: Additional parameters to pass to the API (temperature, max_tokens, etc.)
Returns:
Parsed response if response_format is provided, otherwise text content.
Parsed response if response_format is provided, otherwise the text content
Raises:
OutputTooLongError: If output exceeds token limits.
Exception: Re-raises API errors after retries exhausted.
Exception: Re-raises any API errors after all retries are exhausted
"""
# Use global semaphore to limit concurrent requests
async with _global_llm_semaphore:
start_time = time.time()
import json
call_params = {
"model": self.model,
"messages": messages,
**kwargs
}
if max_completion_tokens is not None:
call_params["max_completion_tokens"] = max_completion_tokens
if temperature is not None:
call_params["temperature"] = temperature
# Provider-specific parameters
if self.provider == "groq":
call_params["seed"] = DEFAULT_LLM_SEED
call_params["extra_body"] = {
"service_tier": "auto",
"reasoning_effort": self.reasoning_effort,
"include_reasoning": False,
"reasoning_effort": "low", # Reduce reasoning overhead
"include_reasoning": False, # Disable hidden reasoning tokens
}
last_exception = None
for attempt in range(max_retries + 1):
try:
# Use the appropriate response format
if response_format is not None:
# Add schema to system message for JSON mode
# Use JSON mode instead of strict parse for flexibility with optional fields
# This allows the LLM to omit optional fields without validation errors
import json
# Add schema to the system message
if hasattr(response_format, 'model_json_schema'):
schema = response_format.model_json_schema()
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
# Add schema to the system message if present, otherwise prepend as user message
if call_params['messages'] and call_params['messages'][0].get('role') == 'system':
call_params['messages'][0]['content'] += schema_msg
elif call_params['messages']:
call_params['messages'][0]['content'] = schema_msg + "\n\n" + call_params['messages'][0]['content']
else:
# No system message, add schema instruction to first user message
if call_params['messages']:
call_params['messages'][0]['content'] = schema_msg + "\n\n" + call_params['messages'][0]['content']
call_params['response_format'] = {"type": "json_object"}
response = await self._client.chat.completions.create(**call_params)
# Parse the JSON response
content = response.choices[0].message.content
json_data = json.loads(content)
# Return raw JSON if skip_validation is True, otherwise validate with Pydantic
if skip_validation:
result = json_data
else:
result = response_format.model_validate(json_data)
else:
# Standard completion and return text content
response = await self._client.chat.completions.create(**call_params)
result = response.choices[0].message.content
# Log slow calls
# Log call details only if it takes more than 5 seconds
duration = time.time() - start_time
usage = response.usage
if duration > 10.0:
ratio = max(1, usage.completion_tokens) / usage.prompt_tokens
cached_tokens = 0
if hasattr(usage, 'prompt_tokens_details') and usage.prompt_tokens_details:
cached_tokens = getattr(usage.prompt_tokens_details, 'cached_tokens', 0) or 0
cache_info = f", cached_tokens={cached_tokens}" if cached_tokens > 0 else ""
logger.info(
f"slow llm call: model={self.provider}/{self.model}, "
f"input_tokens={usage.prompt_tokens}, output_tokens={usage.completion_tokens}, "
f"total_tokens={usage.total_tokens}{cache_info}, time={duration:.3f}s, ratio out/in={ratio:.2f}"
f"total_tokens={usage.total_tokens}, time={duration:.3f}s, ratio out/in={ratio:.2f}"
)
return result
except LengthFinishReasonError as e:
# Output exceeded token limits - raise bridge exception for caller to handle
logger.warning(f"LLM output exceeded token limits: {str(e)}")
raise OutputTooLongError(
f"LLM output exceeded token limits. Input may need to be split into smaller chunks."
) from e
except APIConnectionError as e:
last_exception = e
if attempt < max_retries:
logger.warning(f"Connection error, retrying... (attempt {attempt + 1}/{max_retries + 1})")
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
await asyncio.sleep(backoff)
continue
else:
logger.error(f"Connection error after {max_retries + 1} attempts: {str(e)}")
raise
except APIStatusError as e:
# Fast fail on 4xx client errors (except 429 rate limit and 498 which is treated as server error)
if 400 <= e.status_code < 500 and e.status_code not in (429, 498):
logger.error(f"Client error (HTTP {e.status_code}), not retrying: {str(e)}")
raise
last_exception = e
if attempt < max_retries:
# Calculate exponential backoff with jitter
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
# Add jitter (±20%)
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
sleep_time = backoff + jitter
# Only log if it's a non-retryable error or final attempt
# Silent retry for common transient errors like capacity exceeded
await asyncio.sleep(sleep_time)
else:
# Log only on final failed attempt
logger.error(f"API error after {max_retries + 1} attempts: {str(e)}")
raise
@@ -229,58 +210,60 @@ class LLMProvider:
logger.error(f"Unexpected error during LLM call: {type(e).__name__}: {str(e)}")
raise
# This should never be reached, but just in case
if last_exception:
raise last_exception
raise RuntimeError(f"LLM call failed after all retries with no exception captured")
@classmethod
def for_memory(cls) -> "LLMProvider":
"""Create provider for memory operations from environment variables."""
def for_memory(cls) -> "LLMConfig":
"""Create configuration for memory operations from environment variables."""
provider = os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq")
api_key = os.getenv("HINDSIGHT_API_LLM_API_KEY")
base_url = os.getenv("HINDSIGHT_API_LLM_BASE_URL", "")
base_url = os.getenv("HINDSIGHT_API_LLM_BASE_URL")
model = os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b")
return cls(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort="low"
)
@classmethod
def for_answer_generation(cls) -> "LLMProvider":
"""Create provider for answer generation. Falls back to memory config if not set."""
provider = os.getenv("HINDSIGHT_API_ANSWER_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
api_key = os.getenv("HINDSIGHT_API_ANSWER_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY"))
base_url = os.getenv("HINDSIGHT_API_ANSWER_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
model = os.getenv("HINDSIGHT_API_ANSWER_LLM_MODEL", os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"))
# Set default base URL if not provided
if not base_url:
if provider == "groq":
base_url = "https://api.groq.com/openai/v1"
elif provider == "ollama":
base_url = "http://localhost:11434/v1"
else:
base_url = ""
return cls(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort="high"
)
@classmethod
def for_judge(cls) -> "LLMProvider":
"""Create provider for judge/evaluator operations. Falls back to memory config if not set."""
def for_judge(cls) -> "LLMConfig":
"""
Create configuration for judge/evaluator operations from environment variables.
Falls back to memory LLM config if judge-specific config not set.
"""
# Check if judge-specific config exists, otherwise fall back to memory config
provider = os.getenv("HINDSIGHT_API_JUDGE_LLM_PROVIDER", os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"))
api_key = os.getenv("HINDSIGHT_API_JUDGE_LLM_API_KEY", os.getenv("HINDSIGHT_API_LLM_API_KEY"))
base_url = os.getenv("HINDSIGHT_API_JUDGE_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL", ""))
base_url = os.getenv("HINDSIGHT_API_JUDGE_LLM_BASE_URL", os.getenv("HINDSIGHT_API_LLM_BASE_URL"))
model = os.getenv("HINDSIGHT_API_JUDGE_LLM_MODEL", os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"))
# Set default base URL if not provided
if not base_url:
if provider == "groq":
base_url = "https://api.groq.com/openai/v1"
elif provider == "ollama":
base_url = "http://localhost:11434/v1"
else:
base_url = ""
return cls(
provider=provider,
api_key=api_key,
base_url=base_url,
model=model,
reasoning_effort="high"
)
# Backwards compatibility alias
LLMConfig = LLMProvider
@@ -11,20 +11,17 @@ This implements a sophisticated memory architecture that combines:
import json
import os
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Tuple, Union, TypedDict, TYPE_CHECKING
from typing import Any, Dict, List, Optional, Tuple, Union, TypedDict
import asyncpg
import asyncio
from .embeddings import Embeddings, create_embeddings_from_env
from .cross_encoder import CrossEncoderModel, create_cross_encoder_from_env
from .embeddings import Embeddings, SentenceTransformersEmbeddings
from .cross_encoder import CrossEncoderModel
import time
import numpy as np
import uuid
import logging
from pydantic import BaseModel, Field
if TYPE_CHECKING:
from ..config import HindsightConfig
class RetainContentDict(TypedDict, total=False):
"""Type definition for content items in retain_batch_async.
@@ -51,7 +48,7 @@ from .entity_resolver import EntityResolver
from .retain import embedding_utils, bank_utils
from .search import think_utils, observation_utils
from .llm_wrapper import LLMConfig
from .response_models import RecallResult as RecallResultModel, ReflectResult, MemoryFact, EntityState, EntityObservation, VALID_RECALL_FACT_TYPES
from .response_models import RecallResult as RecallResultModel, ReflectResult, MemoryFact, EntityState, EntityObservation
from .task_backend import TaskBackend, AsyncIOQueueBackend
from .search.reranking import CrossEncoderReranker
from ..pg0 import EmbeddedPostgres
@@ -97,15 +94,15 @@ class MemoryEngine:
- Embedding generation for semantic search
- Entity, temporal, and semantic link creation
- Think operations for formulating answers with opinions
- bank profile and disposition management
- bank profile and personality management
"""
def __init__(
self,
db_url: Optional[str] = None,
memory_llm_provider: Optional[str] = None,
memory_llm_api_key: Optional[str] = None,
memory_llm_model: Optional[str] = None,
db_url: str,
memory_llm_provider: str,
memory_llm_api_key: str,
memory_llm_model: str,
memory_llm_base_url: Optional[str] = None,
embeddings: Optional[Embeddings] = None,
cross_encoder: Optional[CrossEncoderModel] = None,
@@ -113,67 +110,35 @@ class MemoryEngine:
pool_min_size: int = 5,
pool_max_size: int = 100,
task_backend: Optional[TaskBackend] = None,
run_migrations: bool = True,
):
"""
Initialize the temporal + semantic memory system.
All parameters are optional and will be read from environment variables if not provided.
See hindsight_api.config for environment variable names and defaults.
Args:
db_url: PostgreSQL connection URL. Defaults to HINDSIGHT_API_DATABASE_URL env var or "pg0".
Also supports pg0 URLs: "pg0" or "pg0://instance-name" or "pg0://instance-name:port"
memory_llm_provider: LLM provider. Defaults to HINDSIGHT_API_LLM_PROVIDER env var or "groq".
memory_llm_api_key: API key for the LLM provider. Defaults to HINDSIGHT_API_LLM_API_KEY env var.
memory_llm_model: Model name. Defaults to HINDSIGHT_API_LLM_MODEL env var.
memory_llm_base_url: Base URL for the LLM API. Defaults based on provider.
embeddings: Embeddings implementation. If not provided, created from env vars.
cross_encoder: Cross-encoder model. If not provided, created from env vars.
query_analyzer: Query analyzer implementation. If not provided, uses DateparserQueryAnalyzer.
db_url: PostgreSQL connection URL (postgresql://user:pass@host:port/dbname). Required.
memory_llm_provider: LLM provider for memory operations: "openai", "groq", or "ollama". Required.
memory_llm_api_key: API key for the LLM provider. Required.
memory_llm_model: Model name to use for all memory operations (put/think/opinions). Required.
memory_llm_base_url: Base URL for the LLM API. Optional. Defaults based on provider:
- groq: https://api.groq.com/openai/v1
- ollama: http://localhost:11434/v1
embeddings: Embeddings implementation to use. If not provided, uses SentenceTransformersEmbeddings
cross_encoder: Cross-encoder model for reranking. If not provided, uses default when cross-encoder reranker is selected
query_analyzer: Query analyzer implementation to use. If not provided, uses TransformerQueryAnalyzer
pool_min_size: Minimum number of connections in the pool (default: 5)
pool_max_size: Maximum number of connections in the pool (default: 100)
task_backend: Custom task backend. If not provided, uses AsyncIOQueueBackend.
run_migrations: Whether to run database migrations during initialize(). Default: True
Increase for parallel think/search operations (e.g., 200-300 for 100+ parallel thinks)
task_backend: Custom task backend for async task execution. If not provided, uses AsyncIOQueueBackend
"""
# Load config from environment for any missing parameters
from ..config import get_config
config = get_config()
# Apply defaults from config
db_url = db_url or config.database_url
memory_llm_provider = memory_llm_provider or config.llm_provider
memory_llm_api_key = memory_llm_api_key or config.llm_api_key
memory_llm_model = memory_llm_model or config.llm_model
memory_llm_base_url = memory_llm_base_url or config.get_llm_base_url() or None
if not db_url:
raise ValueError("Database url is required")
# Track pg0 instance (if used)
self._pg0: Optional[EmbeddedPostgres] = None
self._pg0_instance_name: Optional[str] = None
# Initialize PostgreSQL connection URL
# The actual URL will be set during initialize() after starting the server
# Supports: "pg0" (default instance), "pg0://instance-name" (named instance), or regular postgresql:// URL
if db_url == "pg0":
self._use_pg0 = True
self._pg0_instance_name = "hindsight"
self._pg0_port = None # Use default port
self.db_url = None
elif db_url.startswith("pg0://"):
self._use_pg0 = True
# Parse instance name and optional port: pg0://instance-name or pg0://instance-name:port
url_part = db_url[6:] # Remove "pg0://"
if ":" in url_part:
self._pg0_instance_name, port_str = url_part.rsplit(":", 1)
self._pg0_port = int(port_str)
else:
self._pg0_instance_name = url_part or "hindsight"
self._pg0_port = None # Use default port
self.db_url = None
else:
self._use_pg0 = False
self._pg0_instance_name = None
self._pg0_port = None
self.db_url = db_url
self._use_pg0 = db_url == "pg0"
self.db_url = db_url if not self._use_pg0 else None
# Set default base URL if not provided
@@ -190,16 +155,15 @@ class MemoryEngine:
self._initialized = False
self._pool_min_size = pool_min_size
self._pool_max_size = pool_max_size
self._run_migrations = run_migrations
# Initialize entity resolver (will be created in initialize())
self.entity_resolver = None
# Initialize embeddings (from env vars if not provided)
# Initialize embeddings
if embeddings is not None:
self.embeddings = embeddings
else:
self.embeddings = create_embeddings_from_env()
self.embeddings = SentenceTransformersEmbeddings("BAAI/bge-small-en-v1.5")
# Initialize query analyzer
if query_analyzer is not None:
@@ -414,58 +378,35 @@ class MemoryEngine:
async def start_pg0():
"""Start pg0 if configured."""
if self._use_pg0:
kwargs = {"name": self._pg0_instance_name}
if self._pg0_port is not None:
kwargs["port"] = self._pg0_port
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()
# Only track pg0 (to stop later) if WE started it
if not was_already_running:
self._pg0 = pg0
self._pg0 = EmbeddedPostgres()
self.db_url = await self._pg0.ensure_running()
async def init_embeddings():
"""Initialize embedding model."""
# For local providers, run in thread pool to avoid blocking event loop
if self.embeddings.provider_name == "local":
await loop.run_in_executor(
None,
lambda: asyncio.run(self.embeddings.initialize())
)
else:
await self.embeddings.initialize()
def load_embeddings():
"""Load embedding model (CPU-bound)."""
self.embeddings.load()
async def init_cross_encoder():
"""Initialize cross-encoder model."""
cross_encoder = self._cross_encoder_reranker.cross_encoder
# For local providers, run in thread pool to avoid blocking event loop
if cross_encoder.provider_name == "local":
await loop.run_in_executor(
None,
lambda: asyncio.run(cross_encoder.initialize())
)
else:
await cross_encoder.initialize()
def load_cross_encoder():
"""Load cross-encoder model (CPU-bound)."""
self._cross_encoder_reranker.cross_encoder.load()
async def init_query_analyzer():
"""Initialize query analyzer model."""
# Query analyzer load is sync and CPU-bound
await loop.run_in_executor(None, self.query_analyzer.load)
def load_query_analyzer():
"""Load query analyzer model (CPU-bound)."""
self.query_analyzer.load()
# Run pg0 and all model initializations in parallel
await asyncio.gather(
start_pg0(),
init_embeddings(),
init_cross_encoder(),
init_query_analyzer(),
)
# Run pg0 and all model loads in parallel
# pg0 is async (IO-bound), models are sync (CPU-bound in thread pool)
# Use 3 workers to load all models concurrently
with concurrent.futures.ThreadPoolExecutor(max_workers=3) as executor:
# Start all tasks
pg0_task = asyncio.create_task(start_pg0())
embeddings_future = loop.run_in_executor(executor, load_embeddings)
cross_encoder_future = loop.run_in_executor(executor, load_cross_encoder)
query_analyzer_future = loop.run_in_executor(executor, load_query_analyzer)
# Run database migrations if enabled
if self._run_migrations:
from ..migrations import run_migrations
logger.info("Running database migrations...")
run_migrations(self.db_url)
# Wait for all to complete
await asyncio.gather(
pg0_task, embeddings_future, cross_encoder_future, query_analyzer_future
)
logger.info(f"Connecting to PostgreSQL at {self.db_url}")
@@ -735,7 +676,7 @@ class MemoryEngine:
context: Context about when/why this memory was formed
event_date: When the event occurred (defaults to now)
document_id: Optional document ID for tracking (always upserts if document already exists)
fact_type_override: Override fact type ('world', 'experience', 'opinion')
fact_type_override: Override fact type ('world', 'bank', 'opinion')
confidence_score: Confidence score for opinions (0.0 to 1.0)
Returns:
@@ -787,7 +728,7 @@ class MemoryEngine:
- "document_id" (optional): Document ID for this specific content item
document_id: **DEPRECATED** - Use "document_id" key in each content dict instead.
Applies the same document_id to ALL content items that don't specify their own.
fact_type_override: Override fact type for all facts ('world', 'experience', 'opinion')
fact_type_override: Override fact type for all facts ('world', 'bank', 'opinion')
confidence_score: Confidence score for opinions (0.0 to 1.0)
Returns:
@@ -928,6 +869,7 @@ class MemoryEngine:
task_backend=self._task_backend,
format_date_fn=self._format_readable_date,
duplicate_checker_fn=self._find_duplicate_facts_batch,
regenerate_observations_fn=self._regenerate_observations_sync,
bank_id=bank_id,
contents_dicts=contents,
document_id=document_id,
@@ -954,7 +896,7 @@ class MemoryEngine:
Args:
bank_id: bank ID to recall for
query: Recall query
fact_type: Required filter for fact type ('world', 'experience', or 'opinion')
fact_type: Required filter for fact type ('world', 'agent', or 'opinion')
budget: Budget level for graph traversal (low=100, mid=300, high=600 units)
max_tokens: Maximum tokens to return (counts only 'text' field, default 4096)
enable_trace: If True, returns detailed trace object
@@ -994,7 +936,7 @@ class MemoryEngine:
Args:
bank_id: bank ID to recall for
query: Recall query
fact_type: List of fact types to recall (e.g., ['world', 'experience'])
fact_type: List of fact types to recall (e.g., ['world', 'bank'])
budget: Budget level for graph traversal (low=100, mid=300, high=600 units)
max_tokens: Maximum tokens to return (counts only 'text' field, default 4096)
Results are returned until token budget is reached, stopping before
@@ -1013,19 +955,11 @@ class MemoryEngine:
- entities: Optional dict of entity states (if include_entities=True)
- chunks: Optional dict of chunks (if include_chunks=True)
"""
# Validate fact types early
invalid_types = set(fact_type) - VALID_RECALL_FACT_TYPES
if invalid_types:
raise ValueError(
f"Invalid fact type(s): {', '.join(sorted(invalid_types))}. "
f"Must be one of: {', '.join(sorted(VALID_RECALL_FACT_TYPES))}"
)
# Map budget enum to thinking_budget number
budget_mapping = {
Budget.LOW: 100,
Budget.MID: 300,
Budget.HIGH: 1000
Budget.HIGH: 600
}
thinking_budget = budget_mapping[budget]
@@ -1106,12 +1040,12 @@ class MemoryEngine:
tracer.start()
pool = await self._get_pool()
recall_start = time.time()
search_start = time.time()
# Buffer logs for clean output in concurrent scenarios
recall_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}"
search_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}"
log_buffer = []
log_buffer.append(f"[RECALL {recall_id}] Query: '{query[:50]}...' (budget={thinking_budget}, max_tokens={max_tokens})")
log_buffer.append(f"[SEARCH {search_id}] Query: '{query[:50]}...' (budget={thinking_budget}, max_tokens={max_tokens})")
try:
# Step 1: Generate query embedding (for semantic search)
@@ -1154,7 +1088,7 @@ class MemoryEngine:
for idx, (ft_semantic, ft_bm25, ft_graph, ft_temporal, ft_timings, ft_temporal_constraint) in enumerate(all_retrievals):
# Log fact types in this retrieval batch
ft_name = fact_type[idx] if idx < len(fact_type) else "unknown"
logger.debug(f"[RECALL {recall_id}] Fact type '{ft_name}': semantic={len(ft_semantic)}, bm25={len(ft_bm25)}, graph={len(ft_graph)}, temporal={len(ft_temporal) if ft_temporal else 0}")
logger.debug(f"[SEARCH {search_id}] Fact type '{ft_name}': semantic={len(ft_semantic)}, bm25={len(ft_bm25)}, graph={len(ft_graph)}, temporal={len(ft_temporal) if ft_temporal else 0}")
semantic_results.extend(ft_semantic)
bm25_results.extend(ft_bm25)
@@ -1275,6 +1209,7 @@ class MemoryEngine:
# Step 4: Rerank using cross-encoder (MergedCandidate -> ScoredResult)
step_start = time.time()
reranker_instance = self._cross_encoder_reranker
log_buffer.append(f" [4] Using cross-encoder reranker")
# Rerank using cross-encoder
scored_results = reranker_instance.rerank(query, merged_candidates)
@@ -1399,7 +1334,12 @@ class MemoryEngine:
ft = sr.retrieval.fact_type
fact_type_counts[ft] = fact_type_counts.get(ft, 0) + 1
total_time = time.time() - search_start
fact_type_summary = ", ".join([f"{ft}={count}" for ft, count in sorted(fact_type_counts.items())])
log_buffer.append(f"[SEARCH {search_id}] Complete: {len(top_scored)} results ({fact_type_summary}) ({total_tokens} tokens) in {total_time:.3f}s")
# Log all buffered logs at once
logger.info("\n" + "\n".join(log_buffer))
# Convert ScoredResult to dicts with ISO datetime strings
top_results_dicts = []
@@ -1461,12 +1401,11 @@ class MemoryEngine:
mentioned_at=result_dict.get("mentioned_at"),
document_id=result_dict.get("document_id"),
chunk_id=result_dict.get("chunk_id"),
activation=result_dict.get("weight") # Use final weight as activation
))
# Fetch entity observations if requested
entities_dict = None
total_entity_tokens = 0
total_chunk_tokens = 0
if include_entities and fact_entity_map:
# Collect unique entities in order of fact relevance (preserving order from top_scored)
# Use a list to maintain order, but track seen entities to avoid duplicates
@@ -1486,6 +1425,7 @@ class MemoryEngine:
# Fetch observations for each entity (respect token budget, in order)
entities_dict = {}
total_entity_tokens = 0
encoding = _get_tiktoken_encoding()
for entity_id, entity_name in entities_ordered:
@@ -1545,6 +1485,7 @@ class MemoryEngine:
# Apply token limit and build chunks_dict in the order of chunk_ids_ordered
chunks_dict = {}
total_chunk_tokens = 0
encoding = _get_tiktoken_encoding()
for chunk_id in chunk_ids_ordered:
@@ -1584,17 +1525,10 @@ class MemoryEngine:
trace = tracer.finalize(top_results_dicts)
trace_dict = trace.to_dict() if trace else None
# Log final recall stats
total_time = time.time() - recall_start
num_chunks = len(chunks_dict) if chunks_dict else 0
num_entities = len(entities_dict) if entities_dict else 0
log_buffer.append(f"[RECALL {recall_id}] Complete: {len(top_scored)} facts ({total_tokens} tok), {num_chunks} chunks ({total_chunk_tokens} tok), {num_entities} entities ({total_entity_tokens} tok) | {fact_type_summary} | {total_time:.3f}s")
logger.info("\n" + "\n".join(log_buffer))
return RecallResultModel(results=memory_facts, trace=trace_dict, entities=entities_dict, chunks=chunks_dict)
except Exception as e:
log_buffer.append(f"[RECALL {recall_id}] ERROR after {time.time() - recall_start:.3f}s: {str(e)}")
log_buffer.append(f"[SEARCH {search_id}] ERROR after {time.time() - search_start:.3f}s: {str(e)}")
logger.error("\n" + "\n".join(log_buffer))
raise Exception(f"Failed to search memories: {str(e)}")
@@ -1748,15 +1682,13 @@ class MemoryEngine:
Args:
bank_id: bank ID to delete
fact_type: Optional fact type filter (world, experience, opinion). If provided, only deletes memories of that type.
fact_type: Optional fact type filter (world, bank, opinion). If provided, only deletes memories of that type.
Returns:
Dictionary with counts of deleted items
"""
pool = await self._get_pool()
async with acquire_with_retry(pool) as conn:
# Ensure connection is not in read-only mode (can happen with connection poolers)
await conn.execute("SET SESSION CHARACTERISTICS AS TRANSACTION READ WRITE")
async with conn.transaction():
try:
if fact_type:
@@ -1806,7 +1738,7 @@ class MemoryEngine:
Args:
bank_id: Filter by bank ID
fact_type: Filter by fact type (world, experience, opinion)
fact_type: Filter by fact type (world, bank, opinion)
Returns:
Dict with nodes, edges, and table_rows
@@ -1980,7 +1912,7 @@ class MemoryEngine:
Args:
bank_id: Filter by bank ID
fact_type: Filter by fact type (world, experience, opinion)
fact_type: Filter by fact type (world, bank, opinion)
search_query: Full-text search query (searches text and context fields)
limit: Maximum number of results to return
offset: Offset for pagination
@@ -2553,55 +2485,55 @@ Guidelines:
async def get_bank_profile(self, bank_id: str) -> "bank_utils.BankProfile":
"""
Get bank profile (name, disposition + background).
Get bank profile (name, personality + background).
Auto-creates agent with default values if not exists.
Args:
bank_id: bank IDentifier
Returns:
BankProfile with name, typed DispositionTraits, and background
BankProfile with name, typed PersonalityTraits, and background
"""
pool = await self._get_pool()
return await bank_utils.get_bank_profile(pool, bank_id)
async def update_bank_disposition(
async def update_bank_personality(
self,
bank_id: str,
disposition: Dict[str, int]
personality: Dict[str, float]
) -> None:
"""
Update bank disposition traits.
Update bank personality traits.
Args:
bank_id: bank IDentifier
disposition: Dict with skepticism, literalism, empathy (all 1-5)
personality: Dict with Big Five traits + bias_strength (all 0-1)
"""
pool = await self._get_pool()
await bank_utils.update_bank_disposition(pool, bank_id, disposition)
await bank_utils.update_bank_personality(pool, bank_id, personality)
async def merge_bank_background(
self,
bank_id: str,
new_info: str,
update_disposition: bool = True
update_personality: bool = True
) -> dict:
"""
Merge new background information with existing background using LLM.
Normalizes to first person ("I") and resolves conflicts.
Optionally infers disposition traits from the merged background.
Optionally infers personality traits from the merged background.
Args:
bank_id: bank IDentifier
new_info: New background information to add/merge
update_disposition: If True, infer Big Five traits from background (default: True)
update_personality: If True, infer Big Five traits from background (default: True)
Returns:
Dict with 'background' (str) and optionally 'disposition' (dict) keys
Dict with 'background' (str) and optionally 'personality' (dict) keys
"""
pool = await self._get_pool()
return await bank_utils.merge_bank_background(
pool, self._llm_config, bank_id, new_info, update_disposition
pool, self._llm_config, bank_id, new_info, update_personality
)
async def list_banks(self) -> list:
@@ -2609,7 +2541,7 @@ Guidelines:
List all agents in the system.
Returns:
List of dicts with bank_id, name, disposition, background, created_at, updated_at
List of dicts with bank_id, name, personality, background, created_at, updated_at
"""
pool = await self._get_pool()
return await bank_utils.list_banks(pool)
@@ -2627,7 +2559,7 @@ Guidelines:
Reflect and formulate an answer using bank identity, world facts, and opinions.
This method:
1. Retrieves experience (conversations and events)
1. Retrieves agent facts (bank's identity and past actions)
2. Retrieves world facts (general knowledge)
3. Retrieves existing opinions (bank's formed perspectives)
4. Uses LLM to formulate an answer
@@ -2643,49 +2575,45 @@ Guidelines:
Returns:
ReflectResult containing:
- text: Plain text answer (no markdown)
- based_on: Dict with 'world', 'experience', and 'opinion' fact lists (MemoryFact objects)
- based_on: Dict with 'world', 'agent', and 'opinion' fact lists (MemoryFact objects)
- new_opinions: List of newly formed opinions
"""
# Use cached LLM config
if self._llm_config is None:
raise ValueError("Memory LLM API key not set. Set HINDSIGHT_API_LLM_API_KEY environment variable.")
reflect_start = time.time()
reflect_id = f"{bank_id[:8]}-{int(time.time() * 1000) % 100000}"
log_buffer = []
log_buffer.append(f"[REFLECT {reflect_id}] Query: '{query[:50]}...'")
# Steps 1-3: Run multi-fact-type search (12-way retrieval: 4 methods × 3 fact types)
recall_start = time.time()
search_result = await self.recall_async(
bank_id=bank_id,
query=query,
budget=budget,
max_tokens=4096,
enable_trace=False,
fact_type=['experience', 'world', 'opinion'],
fact_type=['agent', 'world', 'opinion'],
include_entities=True
)
recall_time = time.time() - recall_start
all_results = search_result.results
logger.info(f"[THINK] Search returned {len(all_results)} results")
# Split results by fact type for structured response
agent_results = [r for r in all_results if r.fact_type == 'experience']
agent_results = [r for r in all_results if r.fact_type == 'bank']
world_results = [r for r in all_results if r.fact_type == 'world']
opinion_results = [r for r in all_results if r.fact_type == 'opinion']
log_buffer.append(f"[REFLECT {reflect_id}] Recall: {len(all_results)} facts (experience={len(agent_results)}, world={len(world_results)}, opinion={len(opinion_results)}) in {recall_time:.3f}s")
logger.info(f"[THINK] Split results - agent: {len(agent_results)}, world: {len(world_results)}, opinion: {len(opinion_results)}")
# Format facts for LLM
agent_facts_text = think_utils.format_facts_for_prompt(agent_results)
world_facts_text = think_utils.format_facts_for_prompt(world_results)
opinion_facts_text = think_utils.format_facts_for_prompt(opinion_results)
# Get bank profile (name, disposition + background)
logger.info(f"[THINK] Formatted facts - agent: {len(agent_facts_text)} chars, world: {len(world_facts_text)} chars, opinion: {len(opinion_facts_text)} chars")
# Get bank profile (name, personality + background)
profile = await self.get_bank_profile(bank_id)
name = profile["name"]
disposition = profile["disposition"] # Typed as DispositionTraits
personality = profile["personality"] # Typed as PersonalityTraits
background = profile["background"]
# Build the prompt
@@ -2695,16 +2623,15 @@ Guidelines:
opinion_facts_text=opinion_facts_text,
query=query,
name=name,
disposition=disposition,
personality=personality,
background=background,
context=context,
)
log_buffer.append(f"[REFLECT {reflect_id}] Prompt: {len(prompt)} chars")
logger.info(f"[THINK] Full prompt length: {len(prompt)} chars")
system_message = think_utils.get_system_message(disposition)
system_message = think_utils.get_system_message(personality)
llm_start = time.time()
answer_text = await self._llm_config.call(
messages=[
{"role": "system", "content": system_message},
@@ -2712,9 +2639,8 @@ Guidelines:
],
scope="memory_think",
temperature=0.9,
max_completion_tokens=1000
max_tokens=1000
)
llm_time = time.time() - llm_start
answer_text = answer_text.strip()
@@ -2726,16 +2652,12 @@ Guidelines:
'query': query
})
total_time = time.time() - reflect_start
log_buffer.append(f"[REFLECT {reflect_id}] Complete: {len(answer_text)} chars response, LLM {llm_time:.3f}s, total {total_time:.3f}s")
logger.info("\n" + "\n".join(log_buffer))
# Return response with facts split by type
return ReflectResult(
text=answer_text,
based_on={
"world": world_results,
"experience": agent_results,
"agent": agent_results,
"opinion": opinion_results
},
new_opinions=[] # Opinions are being extracted asynchronously
@@ -2778,7 +2700,7 @@ Guidelines:
)
except Exception as e:
logger.warning(f"[REFLECT] Failed to extract/store opinions: {str(e)}")
logger.warning(f"[THINK] Failed to extract/store opinions: {str(e)}")
async def get_entity_observations(
self,
@@ -2904,8 +2826,7 @@ Guidelines:
bank_id: str,
entity_id: str,
entity_name: str,
version: str | None = None,
conn=None
version: str | None = None
) -> List[str]:
"""
Regenerate observations for an entity by:
@@ -2920,57 +2841,42 @@ Guidelines:
entity_id: Entity UUID
entity_name: Canonical name of the entity
version: Entity's last_seen timestamp when task was created (for deduplication)
conn: Optional database connection (for transactional atomicity with caller)
Returns:
List of created observation IDs
"""
pool = await self._get_pool()
entity_uuid = uuid.UUID(entity_id)
# Helper to run a query with provided conn or acquire one
async def fetch_with_conn(query, *args):
if conn is not None:
return await conn.fetch(query, *args)
else:
async with acquire_with_retry(pool) as acquired_conn:
return await acquired_conn.fetch(query, *args)
async def fetchval_with_conn(query, *args):
if conn is not None:
return await conn.fetchval(query, *args)
else:
async with acquire_with_retry(pool) as acquired_conn:
return await acquired_conn.fetchval(query, *args)
# Step 1: Check version for deduplication
if version:
current_last_seen = await fetchval_with_conn(
"""
SELECT last_seen
FROM entities
WHERE id = $1 AND bank_id = $2
""",
entity_uuid, bank_id
)
async with acquire_with_retry(pool) as conn:
current_last_seen = await conn.fetchval(
"""
SELECT last_seen
FROM entities
WHERE id = $1 AND bank_id = $2
""",
uuid.UUID(entity_id), bank_id
)
if current_last_seen and current_last_seen.isoformat() != version:
return []
if current_last_seen and current_last_seen.isoformat() != version:
return []
# Step 2: Get all facts mentioning this entity (exclude observations themselves)
rows = await fetch_with_conn(
"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.fact_type
FROM memory_units mu
JOIN unit_entities ue ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
AND ue.entity_id = $2
AND mu.fact_type IN ('world', 'experience')
ORDER BY mu.occurred_start DESC
LIMIT 50
""",
bank_id, entity_uuid
)
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.fact_type
FROM memory_units mu
JOIN unit_entities ue ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
AND ue.entity_id = $2
AND mu.fact_type IN ('world', 'agent')
ORDER BY mu.occurred_start DESC
LIMIT 50
""",
bank_id, uuid.UUID(entity_id)
)
if not rows:
return []
@@ -2997,173 +2903,120 @@ Guidelines:
if not observations:
return []
# Step 4: Delete old observations and insert new ones
# If conn provided, we're already in a transaction - don't start another
# If conn is None, acquire one and start a transaction
async def do_db_operations(db_conn):
# Delete old observations for this entity
await db_conn.execute(
"""
DELETE FROM memory_units
WHERE id IN (
SELECT mu.id
FROM memory_units mu
JOIN unit_entities ue ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
AND mu.fact_type = 'observation'
AND ue.entity_id = $2
)
""",
bank_id, entity_uuid
)
# Generate embeddings for new observations
embeddings = await embedding_utils.generate_embeddings_batch(
self.embeddings, observations
)
# Insert new observations
current_time = utcnow()
created_ids = []
for obs_text, embedding in zip(observations, embeddings):
result = await db_conn.fetchrow(
# Step 4: Delete old observations and insert new ones in a transaction
async with acquire_with_retry(pool) as conn:
async with conn.transaction():
# Delete old observations for this entity
await conn.execute(
"""
INSERT INTO memory_units (
bank_id, text, embedding, context, event_date,
occurred_start, occurred_end, mentioned_at,
fact_type, access_count
DELETE FROM memory_units
WHERE id IN (
SELECT mu.id
FROM memory_units mu
JOIN unit_entities ue ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
AND mu.fact_type = 'observation'
AND ue.entity_id = $2
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'observation', 0)
RETURNING id
""",
bank_id,
obs_text,
str(embedding),
f"observation about {entity_name}",
current_time,
current_time,
current_time,
current_time
)
obs_id = str(result['id'])
created_ids.append(obs_id)
# Link observation to entity
await db_conn.execute(
"""
INSERT INTO unit_entities (unit_id, entity_id)
VALUES ($1, $2)
""",
uuid.UUID(obs_id), entity_uuid
bank_id, uuid.UUID(entity_id)
)
return created_ids
# Generate embeddings for new observations
embeddings = await embedding_utils.generate_embeddings_batch(
self.embeddings, observations
)
if conn is not None:
# Use provided connection (already in a transaction)
return await do_db_operations(conn)
else:
# Acquire connection and start our own transaction
async with acquire_with_retry(pool) as acquired_conn:
async with acquired_conn.transaction():
return await do_db_operations(acquired_conn)
# Insert new observations
current_time = utcnow()
created_ids = []
for obs_text, embedding in zip(observations, embeddings):
result = await conn.fetchrow(
"""
INSERT INTO memory_units (
bank_id, text, embedding, context, event_date,
occurred_start, occurred_end, mentioned_at,
fact_type, access_count
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'observation', 0)
RETURNING id
""",
bank_id,
obs_text,
str(embedding),
f"observation about {entity_name}",
current_time,
current_time,
current_time,
current_time
)
obs_id = str(result['id'])
created_ids.append(obs_id)
# Link observation to entity
await conn.execute(
"""
INSERT INTO unit_entities (unit_id, entity_id)
VALUES ($1, $2)
""",
uuid.UUID(obs_id), uuid.UUID(entity_id)
)
# Single consolidated log line
logger.info(f"[OBSERVATIONS] {entity_name}: {len(facts)} facts -> {len(created_ids)} observations")
return created_ids
async def _regenerate_observations_sync(
self,
bank_id: str,
entity_ids: List[str],
min_facts: int = 5,
conn=None
min_facts: int = 5
) -> None:
"""
Regenerate observations for entities synchronously (called during retain).
Processes entities in PARALLEL for faster execution.
Args:
bank_id: Bank identifier
entity_ids: List of entity IDs to process
min_facts: Minimum facts required to regenerate observations
conn: Optional database connection (for transactional atomicity)
"""
if not bank_id or not entity_ids:
return
# Convert to UUIDs
entity_uuids = [uuid.UUID(eid) if isinstance(eid, str) else eid for eid in entity_ids]
pool = await self._get_pool()
async with pool.acquire() as conn:
for entity_id in entity_ids:
try:
entity_uuid = uuid.UUID(entity_id) if isinstance(entity_id, str) else entity_id
# Use provided connection or acquire a new one
if conn is not None:
# Use the provided connection (transactional with caller)
entity_rows = await conn.fetch(
"""
SELECT id, canonical_name FROM entities
WHERE id = ANY($1) AND bank_id = $2
""",
entity_uuids, bank_id
)
entity_names = {row['id']: row['canonical_name'] for row in entity_rows}
# Check if entity exists
entity_exists = await conn.fetchrow(
"SELECT canonical_name FROM entities WHERE id = $1 AND bank_id = $2",
entity_uuid, bank_id
)
fact_counts = await conn.fetch(
"""
SELECT ue.entity_id, COUNT(*) as cnt
FROM unit_entities ue
JOIN memory_units mu ON ue.unit_id = mu.id
WHERE ue.entity_id = ANY($1) AND mu.bank_id = $2
GROUP BY ue.entity_id
""",
entity_uuids, bank_id
)
entity_fact_counts = {row['entity_id']: row['cnt'] for row in fact_counts}
else:
# Acquire a new connection (standalone call)
pool = await self._get_pool()
async with pool.acquire() as acquired_conn:
entity_rows = await acquired_conn.fetch(
"""
SELECT id, canonical_name FROM entities
WHERE id = ANY($1) AND bank_id = $2
""",
entity_uuids, bank_id
)
entity_names = {row['id']: row['canonical_name'] for row in entity_rows}
if not entity_exists:
logger.debug(f"[OBSERVATIONS] Entity {entity_id} not yet in bank {bank_id}, skipping")
continue
fact_counts = await acquired_conn.fetch(
"""
SELECT ue.entity_id, COUNT(*) as cnt
FROM unit_entities ue
JOIN memory_units mu ON ue.unit_id = mu.id
WHERE ue.entity_id = ANY($1) AND mu.bank_id = $2
GROUP BY ue.entity_id
""",
entity_uuids, bank_id
)
entity_fact_counts = {row['entity_id']: row['cnt'] for row in fact_counts}
entity_name = entity_exists['canonical_name']
# Filter entities that meet the threshold
entities_to_process = []
for entity_id in entity_ids:
entity_uuid = uuid.UUID(entity_id) if isinstance(entity_id, str) else entity_id
if entity_uuid not in entity_names:
continue
fact_count = entity_fact_counts.get(entity_uuid, 0)
if fact_count >= min_facts:
entities_to_process.append((entity_id, entity_names[entity_uuid]))
# Count facts linked to this entity
fact_count = await conn.fetchval(
"SELECT COUNT(*) FROM unit_entities WHERE entity_id = $1",
entity_uuid
) or 0
if not entities_to_process:
return
# Only regenerate if entity has enough facts
if fact_count >= min_facts:
await self.regenerate_entity_observations(bank_id, entity_id, entity_name, version=None)
else:
logger.debug(f"[OBSERVATIONS] Skipping {entity_name} ({fact_count} facts < {min_facts} threshold)")
# Process all entities in PARALLEL (LLM calls are the bottleneck)
async def process_entity(entity_id: str, entity_name: str):
try:
await self.regenerate_entity_observations(bank_id, entity_id, entity_name, version=None, conn=conn)
except Exception as e:
logger.error(f"[OBSERVATIONS] Error processing entity {entity_id}: {e}")
await asyncio.gather(*[
process_entity(eid, name) for eid, name in entities_to_process
])
except Exception as e:
logger.error(f"[OBSERVATIONS] Error processing entity {entity_id}: {e}")
continue
async def _handle_regenerate_observations(self, task_dict: Dict[str, Any]):
"""
@@ -10,28 +10,27 @@ from typing import Optional, List, Dict, Any
from pydantic import BaseModel, Field, ConfigDict
# Valid fact types for recall operations (excludes 'observation' which is internal)
VALID_RECALL_FACT_TYPES = frozenset(["world", "experience", "opinion"])
class DispositionTraits(BaseModel):
class PersonalityTraits(BaseModel):
"""
Disposition traits for a memory bank.
Personality traits for a bank using the Big Five model.
All traits are scored 1-5 where:
- skepticism: 1=trusting, 5=skeptical (how much to doubt or question information)
- literalism: 1=flexible interpretation, 5=literal interpretation (how strictly to interpret information)
- empathy: 1=detached, 5=empathetic (how much to consider emotional context)
All traits are scored 0.0-1.0 where higher values indicate stronger presence of the trait.
"""
skepticism: int = Field(ge=1, le=5, description="How skeptical vs trusting (1=trusting, 5=skeptical)")
literalism: int = Field(ge=1, le=5, description="How literally to interpret information (1=flexible, 5=literal)")
empathy: int = Field(ge=1, le=5, description="How much to consider emotional context (1=detached, 5=empathetic)")
openness: float = Field(description="Openness to experience (0.0-1.0)")
conscientiousness: float = Field(description="Conscientiousness and organization (0.0-1.0)")
extraversion: float = Field(description="Extraversion and sociability (0.0-1.0)")
agreeableness: float = Field(description="Agreeableness and cooperation (0.0-1.0)")
neuroticism: float = Field(description="Emotional sensitivity and neuroticism (0.0-1.0)")
bias_strength: float = Field(description="How strongly personality influences thinking (0.0-1.0)")
model_config = ConfigDict(json_schema_extra={
"example": {
"skepticism": 3,
"literalism": 3,
"empathy": 3
"openness": 0.8,
"conscientiousness": 0.6,
"extraversion": 0.4,
"agreeableness": 0.7,
"neuroticism": 0.3,
"bias_strength": 0.5
}
})
@@ -62,7 +61,7 @@ class MemoryFact(BaseModel):
id: str = Field(description="Unique identifier for the memory fact")
text: str = Field(description="The actual text content of the memory")
fact_type: str = Field(description="Type of fact: 'world', 'experience', 'opinion', or 'observation'")
fact_type: str = Field(description="Type of fact: 'world', 'bank', 'opinion', or 'observation'")
entities: Optional[List[str]] = Field(None, description="Entity names mentioned in this fact")
context: Optional[str] = Field(None, description="Additional context for the memory")
occurred_start: Optional[str] = Field(None, description="ISO format date when the event started occurring")
@@ -72,6 +71,9 @@ class MemoryFact(BaseModel):
metadata: Optional[Dict[str, str]] = Field(None, description="User-defined metadata")
chunk_id: Optional[str] = Field(None, description="ID of the chunk this fact was extracted from (format: bank_id_document_id_chunk_index)")
# Internal metrics (used by system but may not be exposed in API)
activation: Optional[float] = Field(None, description="Internal activation score")
class ChunkInfo(BaseModel):
"""Information about a chunk."""
@@ -140,7 +142,7 @@ class ReflectResult(BaseModel):
"occurred_end": "2024-01-15T10:30:00Z"
}
],
"experience": [],
"agent": [],
"opinion": []
},
"new_opinions": [
@@ -151,7 +153,7 @@ class ReflectResult(BaseModel):
text: str = Field(description="The formulated answer text")
based_on: Dict[str, List[MemoryFact]] = Field(
description="Facts used to formulate the answer, organized by type (world, experience, opinion)"
description="Facts used to formulate the answer, organized by type (world, agent, opinion)"
)
new_opinions: List[str] = Field(
default_factory=list,
@@ -1,5 +1,5 @@
"""
bank profile utilities for disposition and background management.
bank profile utilities for personality and background management.
"""
import json
@@ -8,33 +8,36 @@ import re
from typing import Dict, Optional, TypedDict
from pydantic import BaseModel, Field
from ..db_utils import acquire_with_retry
from ..response_models import DispositionTraits
from ..response_models import PersonalityTraits
logger = logging.getLogger(__name__)
DEFAULT_DISPOSITION = {
"skepticism": 3,
"literalism": 3,
"empathy": 3,
DEFAULT_PERSONALITY = {
"openness": 0.5,
"conscientiousness": 0.5,
"extraversion": 0.5,
"agreeableness": 0.5,
"neuroticism": 0.5,
"bias_strength": 0.5,
}
class BankProfile(TypedDict):
"""Type for bank profile data."""
name: str
disposition: DispositionTraits
personality: PersonalityTraits
background: str
class BackgroundMergeResponse(BaseModel):
"""LLM response for background merge with disposition inference."""
"""LLM response for background merge with personality inference."""
background: str = Field(description="Merged background in first person perspective")
disposition: DispositionTraits = Field(description="Inferred disposition traits (skepticism, literalism, empathy)")
personality: PersonalityTraits = Field(description="Inferred Big Five personality traits")
async def get_bank_profile(pool, bank_id: str) -> BankProfile:
"""
Get bank profile (name, disposition + background).
Get bank profile (name, personality + background).
Auto-creates bank with default values if not exists.
Args:
@@ -42,13 +45,13 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
bank_id: bank IDentifier
Returns:
BankProfile with name, typed DispositionTraits, and background
BankProfile with name, typed PersonalityTraits, and background
"""
async with acquire_with_retry(pool) as conn:
# Try to get existing bank
row = await conn.fetchrow(
"""
SELECT name, disposition, background
SELECT name, personality, background
FROM banks WHERE bank_id = $1
""",
bank_id
@@ -56,48 +59,48 @@ async def get_bank_profile(pool, bank_id: str) -> BankProfile:
if row:
# asyncpg returns JSONB as a string, so parse it
disposition_data = row["disposition"]
if isinstance(disposition_data, str):
disposition_data = json.loads(disposition_data)
personality_data = row["personality"]
if isinstance(personality_data, str):
personality_data = json.loads(personality_data)
return BankProfile(
name=row["name"],
disposition=DispositionTraits(**disposition_data),
personality=PersonalityTraits(**personality_data),
background=row["background"]
)
# Bank doesn't exist, create with defaults
await conn.execute(
"""
INSERT INTO banks (bank_id, name, disposition, background)
INSERT INTO banks (bank_id, name, personality, background)
VALUES ($1, $2, $3::jsonb, $4)
ON CONFLICT (bank_id) DO NOTHING
""",
bank_id,
bank_id, # Default name is the bank_id
json.dumps(DEFAULT_DISPOSITION),
json.dumps(DEFAULT_PERSONALITY),
""
)
return BankProfile(
name=bank_id,
disposition=DispositionTraits(**DEFAULT_DISPOSITION),
personality=PersonalityTraits(**DEFAULT_PERSONALITY),
background=""
)
async def update_bank_disposition(
async def update_bank_personality(
pool,
bank_id: str,
disposition: Dict[str, int]
personality: Dict[str, float]
) -> None:
"""
Update bank disposition traits.
Update bank personality traits.
Args:
pool: Database connection pool
bank_id: bank IDentifier
disposition: Dict with skepticism, literalism, empathy (all 1-5)
personality: Dict with Big Five traits + bias_strength (all 0-1)
"""
# Ensure bank exists first
await get_bank_profile(pool, bank_id)
@@ -106,12 +109,12 @@ async def update_bank_disposition(
await conn.execute(
"""
UPDATE banks
SET disposition = $2::jsonb,
SET personality = $2::jsonb,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
json.dumps(disposition)
json.dumps(personality)
)
@@ -120,53 +123,53 @@ async def merge_bank_background(
llm_config,
bank_id: str,
new_info: str,
update_disposition: bool = True
update_personality: bool = True
) -> dict:
"""
Merge new background information with existing background using LLM.
Normalizes to first person ("I") and resolves conflicts.
Optionally infers disposition traits from the merged background.
Optionally infers personality traits from the merged background.
Args:
pool: Database connection pool
llm_config: LLM configuration for background merging
bank_id: bank IDentifier
new_info: New background information to add/merge
update_disposition: If True, infer Big Five traits from background (default: True)
update_personality: If True, infer Big Five traits from background (default: True)
Returns:
Dict with 'background' (str) and optionally 'disposition' (dict) keys
Dict with 'background' (str) and optionally 'personality' (dict) keys
"""
# Get current profile
profile = await get_bank_profile(pool, bank_id)
current_background = profile["background"]
# Use LLM to merge backgrounds and optionally infer disposition
# Use LLM to merge backgrounds and optionally infer personality
result = await _llm_merge_background(
llm_config,
current_background,
new_info,
infer_disposition=update_disposition
infer_personality=update_personality
)
merged_background = result["background"]
inferred_disposition = result.get("disposition")
inferred_personality = result.get("personality")
# Update in database
async with acquire_with_retry(pool) as conn:
if inferred_disposition:
# Update both background and disposition
if inferred_personality:
# Update both background and personality
await conn.execute(
"""
UPDATE banks
SET background = $2,
disposition = $3::jsonb,
personality = $3::jsonb,
updated_at = NOW()
WHERE bank_id = $1
""",
bank_id,
merged_background,
json.dumps(inferred_disposition)
json.dumps(inferred_personality)
)
else:
# Update only background
@@ -182,8 +185,8 @@ async def merge_bank_background(
)
response = {"background": merged_background}
if inferred_disposition:
response["disposition"] = inferred_disposition
if inferred_personality:
response["personality"] = inferred_personality
return response
@@ -192,23 +195,23 @@ async def _llm_merge_background(
llm_config,
current: str,
new_info: str,
infer_disposition: bool = False
infer_personality: bool = False
) -> dict:
"""
Use LLM to intelligently merge background information.
Optionally infer Big Five disposition traits from the merged background.
Optionally infer Big Five personality traits from the merged background.
Args:
llm_config: LLM configuration to use
current: Current background text
new_info: New information to merge
infer_disposition: If True, also infer disposition traits
infer_personality: If True, also infer personality traits
Returns:
Dict with 'background' (str) and optionally 'disposition' (dict) keys
Dict with 'background' (str) and optionally 'personality' (dict) keys
"""
if infer_disposition:
prompt = f"""You are helping maintain a memory bank's background/profile and infer their disposition. You MUST respond with ONLY valid JSON.
if infer_personality:
prompt = f"""You are helping maintain a memory bank's background/profile and infer their personality. You MUST respond with ONLY valid JSON.
Current background: {current if current else "(empty)"}
@@ -220,30 +223,36 @@ Instructions:
3. Keep additions that don't conflict
4. Output in FIRST PERSON ("I") perspective
5. Be concise - keep merged background under 500 characters
6. Infer disposition traits from the merged background (each 1-5 integer):
- Skepticism: 1-5 (1=trusting, takes things at face value; 5=skeptical, questions everything)
- Literalism: 1-5 (1=flexible interpretation, reads between lines; 5=literal, exact interpretation)
- Empathy: 1-5 (1=detached, focuses on facts; 5=empathetic, considers emotional context)
6. Infer Big Five personality traits from the merged background:
- Openness: 0.0-1.0 (creativity, curiosity, openness to new ideas)
- Conscientiousness: 0.0-1.0 (organization, discipline, goal-directed)
- Extraversion: 0.0-1.0 (sociability, assertiveness, energy from others)
- Agreeableness: 0.0-1.0 (cooperation, empathy, consideration)
- Neuroticism: 0.0-1.0 (emotional sensitivity, anxiety, stress response)
- Bias Strength: 0.0-1.0 (how much personality influences opinions)
CRITICAL: You MUST respond with ONLY a valid JSON object. No markdown, no code blocks, no explanations. Just the JSON.
Format:
{{
"background": "the merged background text in first person",
"disposition": {{
"skepticism": 3,
"literalism": 3,
"empathy": 3
"personality": {{
"openness": 0.7,
"conscientiousness": 0.6,
"extraversion": 0.5,
"agreeableness": 0.8,
"neuroticism": 0.4,
"bias_strength": 0.6
}}
}}
Trait inference examples:
- "I'm a lawyer" → skepticism: 4, literalism: 5, empathy: 2
- "I'm a therapist" → skepticism: 2, literalism: 2, empathy: 5
- "I'm an engineer" → skepticism: 3, literalism: 4, empathy: 3
- "I've been burned before by trusting people" → skepticism: 5, literalism: 3, empathy: 3
- "I try to understand what people really mean" → skepticism: 3, literalism: 2, empathy: 4
- "I take contracts very seriously" → skepticism: 4, literalism: 5, empathy: 2"""
- "creative artist" → openness: 0.8+, bias_strength: 0.6
- "organized engineer" → conscientiousness: 0.8+, openness: 0.5-0.6
- "startup founder" → openness: 0.8+, extraversion: 0.7+, neuroticism: 0.3-0.4
- "risk-averse analyst" → openness: 0.3-0.4, conscientiousness: 0.8+, neuroticism: 0.6+
- "rational and diligent" → conscientiousness: 0.7+, openness: 0.6+
- "passionate and dramatic" → extraversion: 0.7+, neuroticism: 0.6+, openness: 0.7+"""
else:
prompt = f"""You are helping maintain a memory bank's background/profile.
@@ -265,38 +274,38 @@ Merged background:"""
# Prepare messages
messages = [{"role": "user", "content": prompt}]
if infer_disposition:
# Use structured output with Pydantic model for disposition inference
if infer_personality:
# Use structured output with Pydantic model for personality inference
try:
parsed = await llm_config.call(
messages=messages,
response_format=BackgroundMergeResponse,
scope="bank_background",
temperature=0.3,
max_completion_tokens=8192
max_tokens=8192
)
logger.info(f"Successfully got structured response: background={parsed.background[:100]}")
# Convert Pydantic model to dict format
return {
"background": parsed.background,
"disposition": parsed.disposition.model_dump()
"personality": parsed.personality.model_dump()
}
except Exception as e:
logger.warning(f"Structured output failed, falling back to manual parsing: {e}")
# Fall through to manual parsing below
# Manual parsing fallback or non-disposition merge
# Manual parsing fallback or non-personality merge
content = await llm_config.call(
messages=messages,
scope="bank_background",
temperature=0.3,
max_completion_tokens=8192
max_tokens=8192
)
logger.info(f"LLM response for background merge (first 500 chars): {content[:500]}")
if infer_disposition:
if infer_personality:
# Parse JSON response - try multiple extraction methods
result = None
@@ -321,7 +330,7 @@ Merged background:"""
# Method 3: Find nested JSON structure
if result is None:
# Look for JSON object with nested structure
json_match = re.search(r'\{[^{}]*"background"[^{}]*"disposition"[^{}]*\{[^{}]*\}[^{}]*\}', content, re.DOTALL)
json_match = re.search(r'\{[^{}]*"background"[^{}]*"personality"[^{}]*\{[^{}]*\}[^{}]*\}', content, re.DOTALL)
if json_match:
try:
result = json.loads(json_match.group())
@@ -332,22 +341,23 @@ Merged background:"""
# All parsing methods failed - use fallback
if result is None:
logger.warning(f"Failed to extract JSON from LLM response. Raw content: {content[:200]}")
# Fallback: use new_info as background with default disposition
# Fallback: use new_info as background with default personality
return {
"background": new_info if new_info else current if current else "",
"disposition": DEFAULT_DISPOSITION.copy()
"personality": DEFAULT_PERSONALITY.copy()
}
# Validate disposition values
disposition = result.get("disposition", {})
for key in ["skepticism", "literalism", "empathy"]:
if key not in disposition:
disposition[key] = 3 # Default to neutral
# Validate personality values
personality = result.get("personality", {})
for key in ["openness", "conscientiousness", "extraversion",
"agreeableness", "neuroticism", "bias_strength"]:
if key not in personality:
personality[key] = 0.5 # Default to neutral
else:
# Clamp to [1, 5] and convert to int
disposition[key] = max(1, min(5, int(disposition[key])))
# Clamp to [0, 1]
personality[key] = max(0.0, min(1.0, float(personality[key])))
result["disposition"] = disposition
result["personality"] = personality
# Ensure background exists
if "background" not in result or not result["background"]:
@@ -370,8 +380,8 @@ Merged background:"""
merged = new_info
result = {"background": merged}
if infer_disposition:
result["disposition"] = DEFAULT_DISPOSITION.copy()
if infer_personality:
result["personality"] = DEFAULT_PERSONALITY.copy()
return result
@@ -383,12 +393,12 @@ async def list_banks(pool) -> list:
pool: Database connection pool
Returns:
List of dicts with bank_id, name, disposition, background, created_at, updated_at
List of dicts with bank_id, name, personality, background, created_at, updated_at
"""
async with acquire_with_retry(pool) as conn:
rows = await conn.fetch(
"""
SELECT bank_id, name, disposition, background, created_at, updated_at
SELECT bank_id, name, personality, background, created_at, updated_at
FROM banks
ORDER BY updated_at DESC
"""
@@ -397,14 +407,14 @@ async def list_banks(pool) -> list:
result = []
for row in rows:
# asyncpg returns JSONB as a string, so parse it
disposition_data = row["disposition"]
if isinstance(disposition_data, str):
disposition_data = json.loads(disposition_data)
personality_data = row["personality"]
if isinstance(personality_data, str):
personality_data = json.loads(personality_data)
result.append({
"bank_id": row["bank_id"],
"name": row["name"],
"disposition": disposition_data,
"personality": personality_data,
"background": row["background"],
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
@@ -7,7 +7,7 @@ import logging
from typing import List, Tuple, Dict, Any
from uuid import UUID
from .types import ProcessedFact, EntityRef, EntityLink
from .types import ProcessedFact, EntityRef
from . import link_utils
logger = logging.getLogger(__name__)
@@ -20,7 +20,7 @@ async def process_entities_batch(
unit_ids: List[str],
facts: List[ProcessedFact],
log_buffer: List[str] = None
) -> List[EntityLink]:
) -> List[Tuple[str, str, float]]:
"""
Process entities for all facts and create entity links.
@@ -39,7 +39,7 @@ async def process_entities_batch(
log_buffer: Optional buffer for detailed logging
Returns:
List of EntityLink objects for batch insertion
List of entity link tuples: (unit_id, entity_id, confidence)
"""
if not unit_ids or not facts:
return []
@@ -75,14 +75,14 @@ async def process_entities_batch(
async def insert_entity_links_batch(
conn,
entity_links: List[EntityLink]
entity_links: List[Tuple[str, str, float]]
) -> None:
"""
Insert entity links in batch.
Args:
conn: Database connection
entity_links: List of EntityLink objects
entity_links: List of (unit_id, entity_id, confidence) tuples
"""
if not entity_links:
return
@@ -50,7 +50,7 @@ class Fact(BaseModel):
"""
# Required fields
fact: str = Field(description="Combined fact text: what | when | where | who | why")
fact_type: Literal["world", "experience", "opinion"] = Field(description="Perspective: world/experience/opinion")
fact_type: Literal["world", "bank", "opinion"] = Field(description="Perspective: world/bank/opinion")
# Optional temporal fields
occurred_start: Optional[str] = None
@@ -164,13 +164,13 @@ class ExtractedFact(BaseModel):
# Classification (CRITICAL - required)
# Note: LLM uses "assistant" but we convert to "bank" for storage
fact_type: Literal["world", "assistant"] = Field(
description="'world' = about the user/others (background, experiences). 'assistant' = experience with the assistant."
description="'world' = about the user/others (background, experiences). 'assistant' = interactions with the assistant."
)
# Entities - extracted from fact content
# Entities - extracted from 'who' field
entities: Optional[List[Entity]] = Field(
default=None,
description="Named entities, objects, AND abstract concepts from the fact. Include: people names, organizations, places, significant objects (e.g., 'coffee maker', 'car'), AND abstract concepts/themes (e.g., 'friendship', 'career growth', 'loss', 'celebration'). Extract anything that could help link related facts together."
description="Named entities from 'who': people names, organizations, places. NOT generic relations."
)
causal_relations: Optional[List[CausalRelation]] = Field(
default=None,
@@ -325,7 +325,7 @@ async def _extract_facts_from_chunk(
Note: event_date parameter is kept for backward compatibility but not used in prompt.
The LLM extracts temporal information from the context string instead.
"""
memory_bank_context = f"\n- Your name: {agent_name}" if agent_name and extract_opinions else ""
agent_context = f"\n- Your name: {agent_name}" if agent_name else ""
# Determine which fact types to extract based on the flag
# Note: We use "assistant" in the prompt but convert to "bank" for storage
@@ -339,7 +339,7 @@ async def _extract_facts_from_chunk(
{fact_types_instruction}
Context: {context if context else 'none'}{agent_context}
══════════════════════════════════════════════════════════════════════════
FACT FORMAT - ALL FIVE DIMENSIONS REQUIRED - MAXIMUM VERBOSITY
@@ -382,42 +382,13 @@ WRONG output:
- where: (missing) ← WRONG - include the location!
══════════════════════════════════════════════════════════════════════════
FACT_KIND CLASSIFICATION (CRITICAL FOR TEMPORAL HANDLING)
TEMPORAL HANDLING
══════════════════════════════════════════════════════════════════════════
⚠️ MUST set fact_kind correctly - this determines whether occurred_start/end are set!
fact_kind="event" - USE FOR:
- Actions that happened at a specific time: "went to", "attended", "visited", "bought", "made"
- Past events: "yesterday I...", "last week...", "in March 2020..."
- Future plans with dates: "will go to", "scheduled for"
- Examples: "I went to a pottery workshop" → event
"Alice visited Paris in February" → event
"I bought a new car yesterday" → event
"The user graduated from MIT in March 2020" → event
fact_kind="conversation" - USE FOR:
- Ongoing states: "works as", "lives in", "is married to"
- Preferences: "loves", "prefers", "enjoys"
- Traits/abilities: "speaks fluent French", "knows Python"
- Examples: "I love Italian food" → conversation
"Alice works at Google" → conversation
"I prefer outdoor dining" → conversation
══════════════════════════════════════════════════════════════════════════
TEMPORAL HANDLING (CRITICAL - USE EVENT DATE AS REFERENCE)
══════════════════════════════════════════════════════════════════════════
⚠️ IMPORTANT: Use the "Event Date" provided in the input as your reference point!
All relative dates ("yesterday", "last week", "recently") must be resolved relative to the Event Date, NOT today's date.
For EVENTS (fact_kind="event") - MUST SET BOTH occurred_start AND occurred_end:
- Convert relative dates → absolute using Event Date as reference
- If Event Date is "Saturday, March 15, 2020", then "yesterday" = Friday, March 14, 2020
- Dates mentioned in text (e.g., "in March 2020") should use THAT year, not current year
For EVENTS (fact_kind="event"):
- Convert relative dates → absolute WITH DAY OF WEEK: "yesterday" on Saturday March 15 → "Friday, March 14, 2024"
- Always include the day name (Monday, Tuesday, etc.) in the 'when' field
- Set occurred_start AND occurred_end to WHEN IT HAPPENED (not when mentioned)
- For single-day/point events: set occurred_end = occurred_start (same timestamp)
- Set occurred_start/occurred_end to WHEN IT HAPPENED (not when mentioned)
For CONVERSATIONS (fact_kind="conversation"):
- General info, preferences, ongoing states → NO occurred dates
@@ -444,32 +415,20 @@ Example: "I love Italian food and prefer outdoor dining"
→ Fact 2: what="User prefers outdoor dining", who="user", why="This is a dining preference", entities=["user"]
══════════════════════════════════════════════════════════════════════════
ENTITIES - INCLUDE PEOPLE, PLACES, OBJECTS, AND CONCEPTS (CRITICAL)
ENTITIES - INCLUDE "user" (CRITICAL)
══════════════════════════════════════════════════════════════════════════
Extract entities that help link related facts together. Include:
1. "user" - when the fact is about the user
2. People names - Emily, Dr. Smith, etc.
3. Organizations/Places - IKEA, Goodwill, New York, etc.
4. Specific objects - coffee maker, toaster, car, laptop, kitchen, etc.
5. Abstract concepts - themes, values, emotions, or ideas that capture the essence of the fact:
- "friendship" for facts about friends helping each other, bonding, loyalty
- "career growth" for facts about promotions, learning new skills, job changes
- "loss" or "grief" for facts about death, endings, saying goodbye
- "celebration" for facts about parties, achievements, milestones
- "trust" or "betrayal" for facts involving those themes
When a fact is ABOUT the user (their preferences, plans, experiences), ALWAYS include "user" in entities!
✅ CORRECT: entities=["user", "coffee maker", "Goodwill", "kitchen"] for "User donated their coffee maker to Goodwill"
✅ CORRECT: entities=["user", "Emily", "friendship"] for "Emily helped user move to a new apartment"
✅ CORRECT: entities=["user", "promotion", "career growth"] for "User got promoted to senior engineer"
✅ CORRECT: entities=["user", "grandmother", "loss", "grief"] for "User's grandmother passed away last week"
❌ WRONG: entities=["user", "Emily"] only - missing the "friendship" concept that links to other friendship facts!
✅ CORRECT: entities=["user"] for "User loves coffee"
✅ CORRECT: entities=["user", "Emily"] for "User attended Emily's wedding"
❌ WRONG: entities=[] for facts about the user
══════════════════════════════════════════════════════════════════════════
EXAMPLES
══════════════════════════════════════════════════════════════════════════
Example 1 - World Facts (Event Date: Tuesday, June 10, 2024):
Example 1 - World Facts (Context: June 10, 2024):
Input: "I'm planning my wedding and want a small outdoor ceremony. I just got back from my college roommate Emily's wedding - she married Sarah at a rooftop garden, it was so romantic!"
Output facts:
@@ -479,23 +438,22 @@ Output facts:
- who: "user"
- why: "User prefers intimate outdoor settings"
- fact_type: "world", fact_kind: "conversation"
- entities: ["user", "wedding", "outdoor ceremony"]
- entities: ["user"]
2. User planning wedding
- what: "User is planning their own wedding"
- who: "user"
- why: "Inspired by Emily's ceremony"
- fact_type: "world", fact_kind: "conversation"
- entities: ["user", "wedding"]
- entities: ["user"]
3. Emily's wedding (THE EVENT - note occurred_start AND occurred_end both set)
3. Emily's wedding (THE EVENT)
- what: "Emily got married to Sarah at a rooftop garden ceremony in the city"
- who: "Emily (user's college roommate), Sarah (Emily's partner)"
- why: "User found it romantic and beautiful"
- fact_type: "world", fact_kind: "event"
- occurred_start: "2024-06-09T00:00:00Z" (recently, user "just got back" - relative to Event Date June 10, 2024)
- occurred_end: "2024-06-09T23:59:59Z" (same day - point event)
- entities: ["user", "Emily", "Sarah", "wedding", "rooftop garden"]
- occurred_start: "2024-06-09T00:00:00Z" (recently, user "just got back")
- entities: ["user", "Emily", "Sarah"]
Example 2 - Assistant Facts (Context: March 5, 2024):
Input: "User: My API is really slow when we have 1000+ concurrent users. What can I do?
@@ -507,23 +465,7 @@ Output fact:
- who: "user, assistant"
- why: "User asked how to fix slow API performance with 1000+ concurrent users, expected 70-80% reduction in database load"
- fact_type: "assistant", fact_kind: "conversation"
- entities: ["user", "API", "Redis"]
Example 3 - Kitchen Items with Concept Inference (Event Date: Thursday, May 30, 2024):
Input: "I finally donated my old coffee maker to Goodwill. I upgraded to that new espresso machine last month and the old one was just taking up counter space."
Output fact:
- what: "User donated their old coffee maker to Goodwill after upgrading to a new espresso machine"
- when: "Thursday, May 30, 2024"
- who: "user"
- why: "The old coffee maker was taking up counter space after the upgrade"
- fact_type: "world", fact_kind: "event"
- occurred_start: "2024-05-30T00:00:00Z" (uses Event Date year)
- occurred_end: "2024-05-30T23:59:59Z" (same day - point event)
- entities: ["user", "coffee maker", "Goodwill", "espresso machine", "kitchen"]
Note: "kitchen" is inferred as a concept because coffee makers and espresso machines are kitchen appliances.
This links the fact to other kitchen-related facts (toaster, faucet, kitchen mat, etc.) via the shared "kitchen" entity.
- entities: ["user"]
Note how the "why" field captures the FULL STORY: what the user asked AND what outcome was expected!
@@ -554,7 +496,6 @@ WHAT TO EXTRACT vs SKIP
# Format event_date with day of week for better temporal reasoning
event_date_formatted = event_date.strftime('%A, %B %d, %Y') # e.g., "Monday, June 10, 2024"
user_message = f"""Extract facts from the following text chunk.
{memory_bank_context}
Chunk: {chunk_index + 1}/{total_chunks}
Event Date: {event_date_formatted} ({event_date.isoformat()})
@@ -579,7 +520,7 @@ Text:
response_format=FactExtractionResponse,
scope="memory_extract_facts",
temperature=0.1,
max_completion_tokens=65000,
max_tokens=65000,
skip_validation=True, # Get raw JSON, we'll validate leniently
)
@@ -640,20 +581,20 @@ Text:
continue
# Critical field: fact_type
# LLM uses "assistant" but we convert to "experience" for storage
# LLM uses "assistant" but we convert to "bank" for storage
fact_type = llm_fact.get('fact_type')
# Convert "assistant" → "experience" for storage
# Convert "assistant" → "bank" for storage
if fact_type == 'assistant':
fact_type = 'experience'
fact_type = 'bank'
# Validate fact_type (after conversion)
if fact_type not in ['world', 'experience', 'opinion']:
if fact_type not in ['world', 'bank', 'opinion']:
# Try to fix common mistakes - check if they swapped fact_type and fact_kind
fact_kind = llm_fact.get('fact_kind')
if fact_kind == 'assistant':
fact_type = 'experience'
elif fact_kind in ['world', 'experience', 'opinion']:
fact_type = 'bank'
elif fact_kind in ['world', 'bank', 'opinion']:
fact_type = fact_kind
else:
# Default to 'world' if we can't determine
@@ -687,11 +628,8 @@ Text:
occurred_end = get_value('occurred_end')
if occurred_start:
fact_data['occurred_start'] = occurred_start
# For point events: if occurred_end not set, default to occurred_start
if occurred_end:
fact_data['occurred_end'] = occurred_end
else:
fact_data['occurred_end'] = occurred_start
if occurred_end:
fact_data['occurred_end'] = occurred_end
# Add entities if present (validate as Entity objects)
# LLM sometimes returns strings instead of {"text": "..."} format
@@ -112,13 +112,13 @@ async def ensure_bank_exists(conn, bank_id: str) -> None:
"""
await conn.execute(
"""
INSERT INTO banks (bank_id, disposition, background)
INSERT INTO banks (bank_id, personality, background)
VALUES ($1, $2::jsonb, $3)
ON CONFLICT (bank_id) DO UPDATE
SET updated_at = NOW()
""",
bank_id,
'{"skepticism": 3, "literalism": 3, "empathy": 3}',
'{"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.5, "agreeableness": 0.5, "neuroticism": 0.5, "bias_strength": 0.5}',
""
)
@@ -16,7 +16,7 @@ async def create_temporal_links_batch(
conn,
bank_id: str,
unit_ids: List[str]
) -> int:
) -> None:
"""
Create temporal links between facts.
@@ -26,14 +26,11 @@ async def create_temporal_links_batch(
conn: Database connection
bank_id: Bank identifier
unit_ids: List of unit IDs to create links for
Returns:
Number of temporal links created
"""
if not unit_ids:
return 0
return
return await link_utils.create_temporal_links_batch_per_fact(
await link_utils.create_temporal_links_batch_per_fact(
conn,
bank_id,
unit_ids,
@@ -46,7 +43,7 @@ async def create_semantic_links_batch(
bank_id: str,
unit_ids: List[str],
embeddings: List[List[float]]
) -> int:
) -> None:
"""
Create semantic links between facts.
@@ -57,17 +54,14 @@ async def create_semantic_links_batch(
bank_id: Bank identifier
unit_ids: List of unit IDs to create links for
embeddings: List of embedding vectors (same length as unit_ids)
Returns:
Number of semantic links created
"""
if not unit_ids or not embeddings:
return 0
return
if len(unit_ids) != len(embeddings):
raise ValueError(f"Mismatch between unit_ids ({len(unit_ids)}) and embeddings ({len(embeddings)})")
return await link_utils.create_semantic_links_batch(
await link_utils.create_semantic_links_batch(
conn,
bank_id,
unit_ids,
@@ -6,9 +6,6 @@ import time
import logging
from typing import List
from datetime import timedelta, datetime, timezone
from uuid import UUID
from .types import EntityLink
logger = logging.getLogger(__name__)
@@ -110,18 +107,7 @@ def compute_temporal_query_bounds(
def _log(log_buffer, message, level='info'):
"""Helper to log to buffer if available, otherwise use logger.
Args:
log_buffer: Buffer to append messages to (for main output)
message: The log message
level: 'info', 'debug', 'warning', or 'error'. Debug messages are not added to buffer.
"""
if level == 'debug':
# Debug messages only go to logger, not to buffer
logger.debug(message)
return
"""Helper to log to buffer if available, otherwise use logger."""
if log_buffer is not None:
log_buffer.append(message)
else:
@@ -179,7 +165,7 @@ async def extract_entities_batch_optimized(
all_entities.append(formatted_entities)
total_entities = sum(len(ents) for ents in all_entities)
_log(log_buffer, f" [6.1] Process LLM entities: {total_entities} entities from {len(sentences)} facts in {time.time() - substep_start:.3f}s", level='debug')
_log(log_buffer, f" [6.1] Process LLM entities: {total_entities} entities from {len(sentences)} facts in {time.time() - substep_start:.3f}s")
# Step 2: Resolve entities in BATCH (much faster!)
substep_start = time.time()
@@ -201,28 +187,62 @@ async def extract_entities_batch_optimized(
'nearby_entities': entities,
})
entity_to_unit.append((unit_id, local_idx, fact_date))
_log(log_buffer, f" [6.2.1] Prepare entities: {len(all_entities_flat)} entities in {time.time() - substep_6_2_1_start:.3f}s", level='debug')
_log(log_buffer, f" [6.2.1] Prepare entities: {len(all_entities_flat)} entities in {time.time() - substep_6_2_1_start:.3f}s")
# Resolve ALL entities in one batch call
if all_entities_flat:
# [6.2.2] Batch resolve entities - single call with per-entity dates
# [6.2.2] Batch resolve entities
substep_6_2_2_start = time.time()
# Add per-entity dates to entity data for batch resolution
# Group by date for batch resolution (round to hour to reduce buckets)
entities_by_date = {}
for idx, (unit_id, local_idx, fact_date) in enumerate(entity_to_unit):
all_entities_flat[idx]['event_date'] = fact_date
# Round to hour to group facts from same time period
date_key = fact_date.replace(minute=0, second=0, microsecond=0)
if date_key not in entities_by_date:
entities_by_date[date_key] = []
entities_by_date[date_key].append((idx, all_entities_flat[idx]))
# Resolve ALL entities in ONE batch call (much faster than sequential buckets)
# INSERT ... ON CONFLICT handles any race conditions at the DB level
resolved_entity_ids = await entity_resolver.resolve_entities_batch(
bank_id=bank_id,
entities_data=all_entities_flat,
context=context,
unit_event_date=None, # Not used when per-entity dates provided
conn=conn # Use main transaction connection
)
_log(log_buffer, f" [6.2.2] Grouped into {len(entities_by_date)} date buckets, resolving in parallel...")
_log(log_buffer, f" [6.2.2] Resolve entities: {len(all_entities_flat)} entities in single batch in {time.time() - substep_6_2_2_start:.3f}s", level='debug')
# Resolve all date groups in PARALLEL using asyncio.gather
resolved_entity_ids = [None] * len(all_entities_flat)
# Prepare all resolution tasks
async def resolve_date_bucket(date_idx, date_key, entities_group):
date_bucket_start = time.time()
indices = [idx for idx, _ in entities_group]
entities_data = [entity_data for _, entity_data in entities_group]
# Use the first fact's date for this bucket (all should be in same hour)
fact_date = entity_to_unit[indices[0]][2]
# Pass conn=None to let each parallel task acquire its own connection
batch_resolved = await entity_resolver.resolve_entities_batch(
bank_id=bank_id,
entities_data=entities_data,
context=context,
unit_event_date=fact_date,
conn=None # Each task gets its own connection from pool
)
if len(entities_by_date) <= 10: # Only log individual buckets if there aren't too many
_log(log_buffer, f" [6.2.2.{date_idx}] Resolved {len(entities_data)} entities in {time.time() - date_bucket_start:.3f}s")
return indices, batch_resolved
# Execute all resolution tasks in parallel
import asyncio
tasks = [
resolve_date_bucket(date_idx, date_key, entities_group)
for date_idx, (date_key, entities_group) in enumerate(entities_by_date.items(), 1)
]
results = await asyncio.gather(*tasks)
# Map results back to resolved_entity_ids
for indices, batch_resolved in results:
for idx, entity_id in zip(indices, batch_resolved):
resolved_entity_ids[idx] = entity_id
_log(log_buffer, f" [6.2.2] Resolve entities: {len(all_entities_flat)} entities across {len(entities_by_date)} buckets in {time.time() - substep_6_2_2_start:.3f}s")
# [6.2.3] Create unit-entity links in BATCH
substep_6_2_3_start = time.time()
@@ -239,12 +259,12 @@ async def extract_entities_batch_optimized(
# Batch insert all unit-entity links (MUCH faster!)
await entity_resolver.link_units_to_entities_batch(unit_entity_pairs, conn=conn)
_log(log_buffer, f" [6.2.3] Create unit-entity links (batched): {len(unit_entity_pairs)} links in {time.time() - substep_6_2_3_start:.3f}s", level='debug')
_log(log_buffer, f" [6.2.3] Create unit-entity links (batched): {len(unit_entity_pairs)} links in {time.time() - substep_6_2_3_start:.3f}s")
_log(log_buffer, f" [6.2] Entity resolution (batched): {len(all_entities_flat)} entities resolved in {time.time() - step_6_2_start:.3f}s", level='debug')
_log(log_buffer, f" [6.2] Entity resolution (batched): {len(all_entities_flat)} entities resolved in {time.time() - step_6_2_start:.3f}s")
else:
unit_to_entity_ids = {}
_log(log_buffer, f" [6.2] Entity resolution (batched): 0 entities in {time.time() - step_6_2_start:.3f}s", level='debug')
_log(log_buffer, f" [6.2] Entity resolution (batched): 0 entities in {time.time() - step_6_2_start:.3f}s")
# Step 3: Create entity links between units that share entities
substep_start = time.time()
@@ -253,7 +273,7 @@ async def extract_entities_batch_optimized(
for entity_ids in unit_to_entity_ids.values():
all_entity_ids.update(entity_ids)
_log(log_buffer, f" [6.3] Creating entity links for {len(all_entity_ids)} unique entities...", level='debug')
_log(log_buffer, f" [6.3] Creating entity links for {len(all_entity_ids)} unique entities...")
# Find all units that reference these entities (ONE batched query)
entity_to_units = {}
@@ -269,7 +289,7 @@ async def extract_entities_batch_optimized(
""",
entity_id_list
)
_log(log_buffer, f" [6.3.1] Query unit_entities: {len(rows)} rows in {time.time() - query_start:.3f}s", level='debug')
_log(log_buffer, f" [6.3.1] Query unit_entities: {len(rows)} rows in {time.time() - query_start:.3f}s")
# Group by entity_id
group_start = time.time()
@@ -278,42 +298,21 @@ async def extract_entities_batch_optimized(
if entity_id not in entity_to_units:
entity_to_units[entity_id] = []
entity_to_units[entity_id].append(row['unit_id'])
_log(log_buffer, f" [6.3.2] Group by entity_id: {time.time() - group_start:.3f}s", level='debug')
_log(log_buffer, f" [6.3.2] Group by entity_id: {time.time() - group_start:.3f}s")
# Create bidirectional links between units that share entities
# OPTIMIZATION: Limit links per entity to avoid N² explosion
# Only link each new unit to the most recent MAX_LINKS_PER_ENTITY units
MAX_LINKS_PER_ENTITY = 50 # Limit to prevent explosion when entity appears in many facts
link_gen_start = time.time()
links: List[EntityLink] = []
new_unit_set = set(unit_ids) # Units from this batch
def to_uuid(val) -> UUID:
return UUID(val) if isinstance(val, str) else val
links = []
for entity_id, units_with_entity in entity_to_units.items():
entity_uuid = to_uuid(entity_id)
# Separate new units (from this batch) and existing units
new_units = [u for u in units_with_entity if str(u) in new_unit_set or u in new_unit_set]
existing_units = [u for u in units_with_entity if str(u) not in new_unit_set and u not in new_unit_set]
# For each pair of units with this entity, create bidirectional links
for i, unit_id_1 in enumerate(units_with_entity):
for unit_id_2 in units_with_entity[i+1:]:
# Bidirectional links
links.append((unit_id_1, unit_id_2, 'entity', 1.0, entity_id))
links.append((unit_id_2, unit_id_1, 'entity', 1.0, entity_id))
# Link new units to each other (within batch) - also limited
# For very common entities, limit within-batch links too
new_units_to_link = new_units[-MAX_LINKS_PER_ENTITY:] if len(new_units) > MAX_LINKS_PER_ENTITY else new_units
for i, unit_id_1 in enumerate(new_units_to_link):
for unit_id_2 in new_units_to_link[i+1:]:
links.append(EntityLink(from_unit_id=to_uuid(unit_id_1), to_unit_id=to_uuid(unit_id_2), entity_id=entity_uuid))
links.append(EntityLink(from_unit_id=to_uuid(unit_id_2), to_unit_id=to_uuid(unit_id_1), entity_id=entity_uuid))
# Link new units to LIMITED existing units (most recent)
existing_to_link = existing_units[-MAX_LINKS_PER_ENTITY:] # Take most recent
for new_unit in new_units:
for existing_unit in existing_to_link:
links.append(EntityLink(from_unit_id=to_uuid(new_unit), to_unit_id=to_uuid(existing_unit), entity_id=entity_uuid))
links.append(EntityLink(from_unit_id=to_uuid(existing_unit), to_unit_id=to_uuid(new_unit), entity_id=entity_uuid))
_log(log_buffer, f" [6.3.3] Generate {len(links)} links: {time.time() - link_gen_start:.3f}s", level='debug')
_log(log_buffer, f" [6.3] Entity link creation: {len(links)} links for {len(all_entity_ids)} unique entities in {time.time() - substep_start:.3f}s", level='debug')
_log(log_buffer, f" [6.3.3] Generate {len(links)} links: {time.time() - link_gen_start:.3f}s")
_log(log_buffer, f" [6.3] Entity link creation: {len(links)} links for {len(all_entity_ids)} unique entities in {time.time() - substep_start:.3f}s")
return links
@@ -330,7 +329,7 @@ async def create_temporal_links_batch_per_fact(
unit_ids: List[str],
time_window_hours: int = 24,
log_buffer: List[str] = None,
) -> int:
):
"""
Create temporal links for multiple units, each with their own event_date.
@@ -343,12 +342,9 @@ async def create_temporal_links_batch_per_fact(
unit_ids: List of unit IDs
time_window_hours: Time window in hours for temporal links
log_buffer: Optional buffer for logging
Returns:
Number of temporal links created
"""
if not unit_ids:
return 0
return
try:
import time as time_mod
@@ -404,8 +400,6 @@ async def create_temporal_links_batch_per_fact(
)
_log(log_buffer, f" [7.4] Insert {len(links)} temporal links: {time_mod.time() - insert_start:.3f}s")
return len(links)
except Exception as e:
logger.error(f"Failed to create temporal links: {str(e)}")
import traceback
@@ -421,7 +415,7 @@ async def create_semantic_links_batch(
top_k: int = 5,
threshold: float = 0.7,
log_buffer: List[str] = None,
) -> int:
):
"""
Create semantic links for multiple units efficiently.
@@ -435,12 +429,9 @@ async def create_semantic_links_batch(
top_k: Number of top similar units to link
threshold: Minimum similarity threshold
log_buffer: Optional buffer for logging
Returns:
Number of semantic links created
"""
if not unit_ids or not embeddings:
return 0
return
try:
import time as time_mod
@@ -531,8 +522,6 @@ async def create_semantic_links_batch(
)
_log(log_buffer, f" [8.3] Insert {len(all_links)} semantic links: {time_mod.time() - insert_start:.3f}s")
return len(all_links)
except Exception as e:
logger.error(f"Failed to create semantic links: {str(e)}")
import traceback
@@ -540,77 +529,53 @@ async def create_semantic_links_batch(
raise
async def insert_entity_links_batch(conn, links: List[EntityLink], chunk_size: int = 50000):
async def insert_entity_links_batch(conn, links: List[tuple], chunk_size: int = 5000):
"""
Insert all entity links using COPY to temp table + INSERT for maximum speed.
Insert all entity links in bulk using unnest for efficiency.
Uses PostgreSQL COPY (via copy_records_to_table) for bulk loading,
then INSERT ... ON CONFLICT from temp table. This is the fastest
method for bulk inserts with conflict handling.
Uses PostgreSQL unnest() to insert many rows in a single query,
which is much faster than executemany over high-latency connections.
Args:
conn: Database connection
links: List of EntityLink objects
chunk_size: Number of rows per batch (default 50000)
links: List of tuples (from_unit_id, to_unit_id, link_type, weight, entity_id)
chunk_size: Number of rows per batch (default 5000)
"""
if not links:
return
import uuid as uuid_mod
import time as time_mod
total_start = time_mod.time()
# Process in chunks to avoid query size limits
for i in range(0, len(links), chunk_size):
chunk = links[i:i + chunk_size]
# Create temp table for bulk loading
create_start = time_mod.time()
await conn.execute("""
CREATE TEMP TABLE IF NOT EXISTS _temp_entity_links (
from_unit_id uuid,
to_unit_id uuid,
link_type text,
weight float,
entity_id uuid
) ON COMMIT DROP
""")
logger.debug(f" [9.1] Create temp table: {time_mod.time() - create_start:.3f}s")
# Separate into arrays for unnest
from_ids = []
to_ids = []
link_types = []
weights = []
entity_ids = []
# Clear any existing data in temp table
truncate_start = time_mod.time()
await conn.execute("TRUNCATE _temp_entity_links")
logger.debug(f" [9.2] Truncate temp table: {time_mod.time() - truncate_start:.3f}s")
for from_id, to_id, link_type, weight, entity_id in chunk:
from_ids.append(uuid_mod.UUID(from_id) if isinstance(from_id, str) else from_id)
to_ids.append(uuid_mod.UUID(to_id) if isinstance(to_id, str) else to_id)
link_types.append(link_type)
weights.append(weight)
entity_ids.append(
uuid_mod.UUID(str(entity_id)) if entity_id and not isinstance(entity_id, uuid_mod.UUID)
else entity_id
)
# Convert EntityLink objects to tuples for COPY
convert_start = time_mod.time()
records = []
for link in links:
records.append((
link.from_unit_id,
link.to_unit_id,
link.link_type,
link.weight,
link.entity_id
))
logger.debug(f" [9.3] Convert {len(records)} records: {time_mod.time() - convert_start:.3f}s")
# Bulk load using COPY (fastest method)
copy_start = time_mod.time()
await conn.copy_records_to_table(
'_temp_entity_links',
records=records,
columns=['from_unit_id', 'to_unit_id', 'link_type', 'weight', 'entity_id']
)
logger.debug(f" [9.4] COPY {len(records)} records to temp table: {time_mod.time() - copy_start:.3f}s")
# Insert from temp table with ON CONFLICT (single query for all rows)
insert_start = time_mod.time()
await conn.execute("""
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
SELECT from_unit_id, to_unit_id, link_type, weight, entity_id
FROM _temp_entity_links
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""")
logger.debug(f" [9.5] INSERT from temp table: {time_mod.time() - insert_start:.3f}s")
logger.debug(f" [9.TOTAL] Entity links batch insert: {time_mod.time() - total_start:.3f}s")
# Use unnest to insert all rows in one query
await conn.execute(
"""
INSERT INTO memory_links (from_unit_id, to_unit_id, link_type, weight, entity_id)
SELECT * FROM unnest($1::uuid[], $2::uuid[], $3::text[], $4::float[], $5::uuid[])
ON CONFLICT (from_unit_id, to_unit_id, link_type, COALESCE(entity_id, '00000000-0000-0000-0000-000000000000'::uuid)) DO NOTHING
""",
from_ids, to_ids, link_types, weights, entity_ids
)
async def create_causal_links_batch(
@@ -1,264 +0,0 @@
"""
Observation regeneration for retain pipeline.
Regenerates entity observations as part of the retain transaction.
"""
import logging
import time
import uuid
from datetime import datetime, timezone
from typing import List, Dict, Optional
from ..search import observation_utils
from . import embedding_utils
from ..db_utils import acquire_with_retry
from .types import EntityLink
logger = logging.getLogger(__name__)
def utcnow():
"""Get current UTC time."""
return datetime.now(timezone.utc)
# Simple dataclass-like container for facts (avoid importing from memory_engine)
class MemoryFactForObservation:
def __init__(self, id: str, text: str, fact_type: str, context: str, occurred_start: Optional[str]):
self.id = id
self.text = text
self.fact_type = fact_type
self.context = context
self.occurred_start = occurred_start
async def regenerate_observations_batch(
conn,
embeddings_model,
llm_config,
bank_id: str,
entity_links: List[EntityLink],
log_buffer: List[str] = None
) -> None:
"""
Regenerate observations for top entities in this batch.
Called INSIDE the retain transaction for atomicity - if observations
fail, the entire retain batch is rolled back.
Args:
conn: Database connection (from the retain transaction)
embeddings_model: Embeddings model for generating observation embeddings
llm_config: LLM configuration for observation extraction
bank_id: Bank identifier
entity_links: Entity links from this batch
log_buffer: Optional log buffer for timing
"""
TOP_N_ENTITIES = 5
MIN_FACTS_THRESHOLD = 5
if not entity_links:
return
# Count mentions per entity in this batch
entity_mention_counts: Dict[str, int] = {}
for link in entity_links:
if link.entity_id:
entity_id = str(link.entity_id)
entity_mention_counts[entity_id] = entity_mention_counts.get(entity_id, 0) + 1
if not entity_mention_counts:
return
# Sort by mention count descending and take top N
sorted_entities = sorted(
entity_mention_counts.items(),
key=lambda x: x[1],
reverse=True
)
entities_to_process = [e[0] for e in sorted_entities[:TOP_N_ENTITIES]]
obs_start = time.time()
# Convert to UUIDs
entity_uuids = [uuid.UUID(eid) if isinstance(eid, str) else eid for eid in entities_to_process]
# Batch query for entity names
entity_rows = await conn.fetch(
"""
SELECT id, canonical_name FROM entities
WHERE id = ANY($1) AND bank_id = $2
""",
entity_uuids, bank_id
)
entity_names = {row['id']: row['canonical_name'] for row in entity_rows}
# Batch query for fact counts
fact_counts = await conn.fetch(
"""
SELECT ue.entity_id, COUNT(*) as cnt
FROM unit_entities ue
JOIN memory_units mu ON ue.unit_id = mu.id
WHERE ue.entity_id = ANY($1) AND mu.bank_id = $2
GROUP BY ue.entity_id
""",
entity_uuids, bank_id
)
entity_fact_counts = {row['entity_id']: row['cnt'] for row in fact_counts}
# Filter entities that meet the threshold
entities_with_names = []
for entity_id in entities_to_process:
entity_uuid = uuid.UUID(entity_id) if isinstance(entity_id, str) else entity_id
if entity_uuid not in entity_names:
continue
fact_count = entity_fact_counts.get(entity_uuid, 0)
if fact_count >= MIN_FACTS_THRESHOLD:
entities_with_names.append((entity_id, entity_names[entity_uuid]))
if not entities_with_names:
return
# Process entities SEQUENTIALLY (asyncpg doesn't allow concurrent queries on same connection)
# We must use the same connection to stay in the retain transaction
total_observations = 0
for entity_id, entity_name in entities_with_names:
try:
obs_ids = await _regenerate_entity_observations(
conn, embeddings_model, llm_config,
bank_id, entity_id, entity_name
)
total_observations += len(obs_ids)
except Exception as e:
logger.error(f"[OBSERVATIONS] Error processing entity {entity_id}: {e}")
obs_time = time.time() - obs_start
if log_buffer is not None:
log_buffer.append(f"[11] Observations: {total_observations} observations for {len(entities_with_names)} entities in {obs_time:.3f}s")
async def _regenerate_entity_observations(
conn,
embeddings_model,
llm_config,
bank_id: str,
entity_id: str,
entity_name: str
) -> List[str]:
"""
Regenerate observations for a single entity.
Uses the provided connection (part of retain transaction).
Args:
conn: Database connection (from the retain transaction)
embeddings_model: Embeddings model
llm_config: LLM configuration
bank_id: Bank identifier
entity_id: Entity UUID
entity_name: Canonical name of the entity
Returns:
List of created observation IDs
"""
entity_uuid = uuid.UUID(entity_id) if isinstance(entity_id, str) else entity_id
# Get all facts mentioning this entity (exclude observations themselves)
rows = await conn.fetch(
"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.fact_type
FROM memory_units mu
JOIN unit_entities ue ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
AND ue.entity_id = $2
AND mu.fact_type IN ('world', 'experience')
ORDER BY mu.occurred_start DESC
LIMIT 50
""",
bank_id, entity_uuid
)
if not rows:
return []
# Convert to fact objects for observation extraction
facts = []
for row in rows:
occurred_start = row['occurred_start'].isoformat() if row['occurred_start'] else None
facts.append(MemoryFactForObservation(
id=str(row['id']),
text=row['text'],
fact_type=row['fact_type'],
context=row['context'],
occurred_start=occurred_start
))
# Extract observations using LLM
observations = await observation_utils.extract_observations_from_facts(
llm_config,
entity_name,
facts
)
if not observations:
return []
# Delete old observations for this entity
await conn.execute(
"""
DELETE FROM memory_units
WHERE id IN (
SELECT mu.id
FROM memory_units mu
JOIN unit_entities ue ON mu.id = ue.unit_id
WHERE mu.bank_id = $1
AND mu.fact_type = 'observation'
AND ue.entity_id = $2
)
""",
bank_id, entity_uuid
)
# Generate embeddings for new observations
embeddings = await embedding_utils.generate_embeddings_batch(
embeddings_model, observations
)
# Insert new observations
current_time = utcnow()
created_ids = []
for obs_text, embedding in zip(observations, embeddings):
result = await conn.fetchrow(
"""
INSERT INTO memory_units (
bank_id, text, embedding, context, event_date,
occurred_start, occurred_end, mentioned_at,
fact_type, access_count
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'observation', 0)
RETURNING id
""",
bank_id,
obs_text,
str(embedding),
f"observation about {entity_name}",
current_time,
current_time,
current_time,
current_time
)
obs_id = str(result['id'])
created_ids.append(obs_id)
# Link observation to entity
await conn.execute(
"""
INSERT INTO unit_entities (unit_id, entity_id)
VALUES ($1, $2)
""",
uuid.UUID(obs_id), entity_uuid
)
return created_ids
@@ -17,7 +17,7 @@ def utcnow():
"""Get current UTC time."""
return datetime.now(timezone.utc)
from .types import RetainContent, ExtractedFact, ProcessedFact, EntityLink
from .types import RetainContent, ExtractedFact, ProcessedFact
from . import (
fact_extraction,
embedding_processing,
@@ -25,8 +25,7 @@ from . import (
chunk_storage,
fact_storage,
entity_processing,
link_creation,
observation_regeneration
link_creation
)
logger = logging.getLogger(__name__)
@@ -40,6 +39,7 @@ async def retain_batch(
task_backend,
format_date_fn,
duplicate_checker_fn,
regenerate_observations_fn,
bank_id: str,
contents_dicts: List[Dict[str, Any]],
document_id: Optional[str] = None,
@@ -58,6 +58,7 @@ async def retain_batch(
task_backend: Task backend for background jobs
format_date_fn: Function to format datetime to readable string
duplicate_checker_fn: Function to check for duplicate facts
regenerate_observations_fn: Async function to regenerate observations for entities
bank_id: Bank identifier
contents_dicts: List of content dictionaries
document_id: Optional document ID
@@ -287,59 +288,50 @@ async def retain_batch(
# Create temporal links
step_start = time.time()
temporal_link_count = await link_creation.create_temporal_links_batch(conn, bank_id, unit_ids)
log_buffer.append(f"[7] Temporal links: {temporal_link_count} links in {time.time() - step_start:.3f}s")
await link_creation.create_temporal_links_batch(conn, bank_id, unit_ids)
log_buffer.append(f"[7] Temporal links: {time.time() - step_start:.3f}s")
# Create semantic links
step_start = time.time()
embeddings_for_links = [fact.embedding for fact in non_duplicate_facts]
semantic_link_count = await link_creation.create_semantic_links_batch(conn, bank_id, unit_ids, embeddings_for_links)
log_buffer.append(f"[8] Semantic links: {semantic_link_count} links in {time.time() - step_start:.3f}s")
await link_creation.create_semantic_links_batch(conn, bank_id, unit_ids, embeddings_for_links)
log_buffer.append(f"[8] Semantic links: {time.time() - step_start:.3f}s")
# Insert entity links
step_start = time.time()
if entity_links:
await entity_processing.insert_entity_links_batch(conn, entity_links)
log_buffer.append(f"[9] Entity links: {len(entity_links) if entity_links else 0} links in {time.time() - step_start:.3f}s")
log_buffer.append(f"[9] Entity links: {time.time() - step_start:.3f}s")
# Create causal links
step_start = time.time()
causal_link_count = await link_creation.create_causal_links_batch(conn, unit_ids, non_duplicate_facts)
log_buffer.append(f"[10] Causal links: {causal_link_count} links in {time.time() - step_start:.3f}s")
# Regenerate observations INSIDE transaction for atomicity
await observation_regeneration.regenerate_observations_batch(
conn,
embeddings_model,
llm_config,
bank_id,
entity_links,
log_buffer
)
# Map results back to original content items
result_unit_ids = _map_results_to_contents(
contents, extracted_facts, is_duplicate_flags, unit_ids
)
# Trigger background tasks AFTER transaction commits (opinion reinforcement only)
total_time = time.time() - start_time
log_buffer.append(f"{'='*60}")
log_buffer.append(f"RETAIN_BATCH COMPLETE: {len(unit_ids)} units in {total_time:.3f}s")
if document_ids_added:
log_buffer.append(f"Documents: {', '.join(document_ids_added)}")
log_buffer.append(f"{'='*60}")
logger.info("\n" + "\n".join(log_buffer) + "\n")
# Trigger background tasks AFTER transaction commits
await _trigger_background_tasks(
task_backend,
regenerate_observations_fn,
bank_id,
unit_ids,
non_duplicate_facts
non_duplicate_facts,
entity_links
)
# Log final summary
total_time = time.time() - start_time
log_buffer.append(f"{'='*60}")
log_buffer.append(f"RETAIN_BATCH COMPLETE: {len(unit_ids)} units in {total_time:.3f}s")
if document_ids_added:
log_buffer.append(f"Documents: {', '.join(document_ids_added)}")
log_buffer.append(f"{'='*60}")
logger.info("\n" + "\n".join(log_buffer) + "\n")
return result_unit_ids
@@ -375,11 +367,13 @@ def _map_results_to_contents(
async def _trigger_background_tasks(
task_backend,
regenerate_observations_fn,
bank_id: str,
unit_ids: List[str],
facts: List[ProcessedFact],
entity_links: List
) -> None:
"""Trigger opinion reinforcement as background task (after transaction commits)."""
"""Trigger opinion reinforcement and observation regeneration (sync)."""
# Trigger opinion reinforcement if there are entities
fact_entities = [[e.name for e in fact.entities] for fact in facts]
if any(fact_entities):
@@ -390,3 +384,22 @@ async def _trigger_background_tasks(
'unit_texts': [fact.fact_text for fact in facts],
'unit_entities': fact_entities
})
# Regenerate observations synchronously for top entities
TOP_N_ENTITIES = 5
MIN_FACTS_THRESHOLD = 5
if entity_links and regenerate_observations_fn:
unique_entity_ids = set()
for link in entity_links:
# links are tuples: (unit_id, entity_id, confidence)
if len(link) >= 2 and link[1]:
unique_entity_ids.add(str(link[1]))
if unique_entity_ids:
# Run observation regeneration synchronously
await regenerate_observations_fn(
bank_id=bank_id,
entity_ids=list(unique_entity_ids)[:TOP_N_ENTITIES],
min_facts=MIN_FACTS_THRESHOLD
)
@@ -75,7 +75,7 @@ class ExtractedFact:
This is the raw output from fact extraction before processing.
"""
fact_text: str
fact_type: str # "world", "experience", "opinion", "observation"
fact_type: str # "world", "bank", "opinion", "observation"
entities: List[str] = field(default_factory=list)
occurred_start: Optional[datetime] = None
occurred_end: Optional[datetime] = None
@@ -176,20 +176,6 @@ class ProcessedFact:
)
@dataclass
class EntityLink:
"""
Link between two memory units through a shared entity.
Used for entity-based graph connections in the memory graph.
"""
from_unit_id: UUID
to_unit_id: UUID
entity_id: UUID
link_type: str = 'entity'
weight: float = 1.0
@dataclass
class RetainBatch:
"""
@@ -10,8 +10,10 @@ class CrossEncoderReranker:
"""
Neural reranking using a cross-encoder model.
Configured via environment variables (see cross_encoder.py).
Default local model is cross-encoder/ms-marco-MiniLM-L-6-v2.
Uses cross-encoder/ms-marco-MiniLM-L-6-v2 by default:
- Fast inference (~80ms for 100 pairs on CPU)
- Small model (80MB)
- Trained for passage re-ranking
"""
def __init__(self, cross_encoder=None):
@@ -19,12 +21,14 @@ class CrossEncoderReranker:
Initialize cross-encoder reranker.
Args:
cross_encoder: CrossEncoderModel instance. If None, creates one from
environment variables (defaults to local provider)
cross_encoder: CrossEncoderReranker instance. If None, uses default
SentenceTransformersCrossEncoder with ms-marco-MiniLM-L-6-v2
(loaded lazily for faster startup)
"""
if cross_encoder is None:
from hindsight_api.engine.cross_encoder import create_cross_encoder_from_env
cross_encoder = create_cross_encoder_from_env()
from hindsight_api.engine.cross_encoder import SentenceTransformersCrossEncoder
# Model is loaded lazily - call ensure_loaded() during initialize()
cross_encoder = SentenceTransformersCrossEncoder()
self.cross_encoder = cross_encoder
def rerank(
@@ -170,9 +170,9 @@ async def retrieve_graph(
batch_activations[unit_id] = activation
# Batch fetch neighbors for all nodes in this batch
# Fetch top weighted neighbors (batch_size * 20 = ~400 for good distribution)
# Fetch top weighted neighbors (batch_size * 10 = ~200 for good distribution)
if batch_nodes and budget_remaining > 0:
max_neighbors = len(batch_nodes) * 20
max_neighbors = len(batch_nodes) * 10
neighbors = await conn.fetch(
"""
SELECT mu.id, mu.text, mu.context, mu.occurred_start, mu.occurred_end, mu.mentioned_at,
@@ -9,7 +9,7 @@ from datetime import datetime, timezone
from typing import Dict, List, Any
from pydantic import BaseModel, Field
from ..response_models import ReflectResult, MemoryFact, DispositionTraits
from ..response_models import ReflectResult, MemoryFact, PersonalityTraits
logger = logging.getLogger(__name__)
@@ -28,48 +28,30 @@ class OpinionExtractionResponse(BaseModel):
)
def describe_trait_level(value: int) -> str:
"""Convert trait value (1-5) to descriptive text."""
levels = {
1: "very low",
2: "low",
3: "moderate",
4: "high",
5: "very high"
}
return levels.get(value, "moderate")
def describe_trait(name: str, value: float) -> str:
"""Convert trait value to descriptive text."""
if value >= 0.8:
return f"very high {name}"
elif value >= 0.6:
return f"high {name}"
elif value >= 0.4:
return f"moderate {name}"
elif value >= 0.2:
return f"low {name}"
else:
return f"very low {name}"
def build_disposition_description(disposition: DispositionTraits) -> str:
"""Build a disposition description string from disposition traits."""
skepticism_desc = {
1: "You are very trusting and tend to take information at face value.",
2: "You tend to trust information but may question obvious inconsistencies.",
3: "You have a balanced approach to information, neither too trusting nor too skeptical.",
4: "You are somewhat skeptical and often question the reliability of information.",
5: "You are highly skeptical and critically examine all information for accuracy and hidden motives."
}
def build_personality_description(personality: PersonalityTraits) -> str:
"""Build a personality description string from personality traits."""
return f"""Your personality traits:
- {describe_trait('openness to new ideas', personality.openness)}
- {describe_trait('conscientiousness and organization', personality.conscientiousness)}
- {describe_trait('extraversion and sociability', personality.extraversion)}
- {describe_trait('agreeableness and cooperation', personality.agreeableness)}
- {describe_trait('emotional sensitivity', personality.neuroticism)}
literalism_desc = {
1: "You interpret information very flexibly, reading between the lines and inferring intent.",
2: "You tend to consider context and implied meaning alongside literal statements.",
3: "You balance literal interpretation with contextual understanding.",
4: "You prefer to interpret information more literally and precisely.",
5: "You interpret information very literally and focus on exact wording and commitments."
}
empathy_desc = {
1: "You focus primarily on facts and data, setting aside emotional context.",
2: "You consider facts first but acknowledge emotional factors exist.",
3: "You balance factual analysis with emotional understanding.",
4: "You give significant weight to emotional context and human factors.",
5: "You strongly consider the emotional state and circumstances of others when forming memories."
}
return f"""Your disposition traits:
- Skepticism ({describe_trait_level(disposition.skepticism)}): {skepticism_desc.get(disposition.skepticism, skepticism_desc[3])}
- Literalism ({describe_trait_level(disposition.literalism)}): {literalism_desc.get(disposition.literalism, literalism_desc[3])}
- Empathy ({describe_trait_level(disposition.empathy)}): {empathy_desc.get(disposition.empathy, empathy_desc[3])}"""
Personality influence strength: {int(personality.bias_strength * 100)}% (how much your personality shapes your opinions)"""
def format_facts_for_prompt(facts: List[MemoryFact]) -> str:
@@ -96,6 +78,10 @@ def format_facts_for_prompt(facts: List[MemoryFact]) -> str:
elif isinstance(occurred_start, datetime):
fact_obj["occurred_start"] = occurred_start.strftime('%Y-%m-%d %H:%M:%S')
# Add activation if available
if fact.activation is not None:
fact_obj["score"] = fact.activation
formatted.append(fact_obj)
return json.dumps(formatted, indent=2)
@@ -107,12 +93,12 @@ def build_think_prompt(
opinion_facts_text: str,
query: str,
name: str,
disposition: DispositionTraits,
personality: PersonalityTraits,
background: str,
context: str = None,
) -> str:
"""Build the think prompt for the LLM."""
disposition_desc = build_disposition_description(disposition)
personality_desc = build_personality_description(personality)
name_section = f"""
@@ -146,39 +132,24 @@ WHAT I KNOW ABOUT THE WORLD:
MY EXISTING OPINIONS & BELIEFS:
{opinion_facts_text}
{context_section}{name_section}{disposition_desc}{background_section}
{context_section}{name_section}{personality_desc}{background_section}
QUESTION: {query}
Based on everything I know, believe, and who I am (including my name, disposition and background), here's what I genuinely think about this question. I'll draw on my experiences, knowledge, opinions, and personal traits to give you my honest perspective."""
Based on everything I know, believe, and who I am (including my name, personality and background), here's what I genuinely think about this question. I'll draw on my experiences, knowledge, opinions, and personal traits to give you my honest perspective."""
def get_system_message(disposition: DispositionTraits) -> str:
def get_system_message(personality: PersonalityTraits) -> str:
"""Get the system message for the think LLM call."""
# Build disposition-specific instructions based on trait values
instructions = []
bias_strength = personality.bias_strength
if bias_strength >= 0.7:
personality_instruction = "Your personality strongly influences your thinking. Let your traits guide how you interpret facts and form opinions. Don't be afraid to be biased based on your personality."
elif bias_strength >= 0.4:
personality_instruction = "Your personality moderately influences your thinking. Balance your personal traits with objective analysis."
else:
personality_instruction = "Your personality has minimal influence on your thinking. Focus primarily on facts while keeping your traits in mind."
# Skepticism influences how much to question/doubt information
if disposition.skepticism >= 4:
instructions.append("Be skeptical of claims and look for potential issues or inconsistencies.")
elif disposition.skepticism <= 2:
instructions.append("Trust the information provided and take statements at face value.")
# Literalism influences interpretation style
if disposition.literalism >= 4:
instructions.append("Interpret information literally and focus on exact commitments and wording.")
elif disposition.literalism <= 2:
instructions.append("Read between the lines and consider implied meaning and context.")
# Empathy influences consideration of emotional factors
if disposition.empathy >= 4:
instructions.append("Consider the emotional state and circumstances behind the information.")
elif disposition.empathy <= 2:
instructions.append("Focus on facts and outcomes rather than emotional context.")
disposition_instruction = " ".join(instructions) if instructions else "Balance your disposition traits when interpreting information."
return f"You are a person with your own thoughts, experiences, opinions, and disposition. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {disposition_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting."
return f"You are a person with your own thoughts, experiences, opinions, and personality. When asked a question, respond naturally like a human would - share what you genuinely think based on what you know and have experienced. {personality_instruction} Be direct, express your views confidently, and use 'I think', 'I believe', 'in my view', etc. Respond in plain text without markdown formatting."
async def extract_opinions_from_text(
@@ -100,7 +100,7 @@ class RetrievalResult(BaseModel):
text: str = Field(description="Memory unit text content")
context: str = Field(default="", description="Memory unit context")
event_date: Optional[datetime] = Field(default=None, description="When the memory occurred")
fact_type: Optional[str] = Field(default=None, description="Fact type (world, experience, opinion)")
fact_type: Optional[str] = Field(default=None, description="Fact type (world, bank, opinion)")
score: float = Field(description="Score from this retrieval method")
score_name: str = Field(description="Name of the score (e.g., 'similarity', 'bm25_score', 'activation')")
@@ -368,7 +368,7 @@ class SearchTracer:
# Extract score components (only include non-None values)
score_components = {}
for key in ["semantic_similarity", "bm25_score", "rrf_score", "recency_normalized", "frequency_normalized", "cross_encoder_score", "cross_encoder_score_normalized"]:
for key in ["semantic_similarity", "bm25_score", "rrf_score", "recency_normalized", "frequency_normalized"]:
if key in result and result[key] is not None:
score_components[key] = result[key]
-201
View File
@@ -1,201 +0,0 @@
"""
Command-line interface for Hindsight API.
Run the server with:
hindsight-api
Stop with Ctrl+C.
"""
import argparse
import asyncio
import atexit
import os
import signal
import sys
import warnings
from typing import Optional
import uvicorn
from . import MemoryEngine
from .api import create_app
from .config import get_config, HindsightConfig
# Filter deprecation warnings from third-party libraries
warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProtocol is deprecated")
# Disable tokenizers parallelism to avoid warnings
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# Global reference for cleanup
_memory: Optional[MemoryEngine] = None
def _cleanup():
"""Synchronous cleanup function to stop resources on exit."""
global _memory
if _memory is not None and _memory._pg0 is not None:
try:
loop = asyncio.new_event_loop()
loop.run_until_complete(_memory._pg0.stop())
loop.close()
print("\npg0 stopped.")
except Exception as e:
print(f"\nError stopping pg0: {e}")
def _signal_handler(signum, frame):
"""Handle SIGINT/SIGTERM to ensure cleanup."""
print(f"\nReceived signal {signum}, shutting down...")
_cleanup()
sys.exit(0)
def main():
"""Main entry point for the CLI."""
global _memory
# Load configuration from environment (for CLI args defaults)
config = get_config()
parser = argparse.ArgumentParser(
prog="hindsight-api",
description="Hindsight API Server",
)
# Server options
parser.add_argument(
"--host", default=config.host,
help=f"Host to bind to (default: {config.host}, env: HINDSIGHT_API_HOST)"
)
parser.add_argument(
"--port", type=int, default=config.port,
help=f"Port to bind to (default: {config.port}, env: HINDSIGHT_API_PORT)"
)
parser.add_argument(
"--log-level", default=config.log_level,
choices=["critical", "error", "warning", "info", "debug", "trace"],
help=f"Log level (default: {config.log_level}, env: HINDSIGHT_API_LOG_LEVEL)"
)
# Development options
parser.add_argument(
"--reload", action="store_true",
help="Enable auto-reload on code changes (development only)"
)
parser.add_argument(
"--workers", type=int, default=1,
help="Number of worker processes (default: 1)"
)
# Access log options
parser.add_argument(
"--access-log", action="store_true",
help="Enable access log"
)
parser.add_argument(
"--no-access-log", dest="access_log", action="store_false",
help="Disable access log (default)"
)
parser.set_defaults(access_log=False)
# Proxy options
parser.add_argument(
"--proxy-headers", action="store_true",
help="Enable X-Forwarded-Proto, X-Forwarded-For headers"
)
parser.add_argument(
"--forwarded-allow-ips", default=None,
help="Comma separated list of IPs to trust with proxy headers"
)
# SSL options
parser.add_argument(
"--ssl-keyfile", default=None,
help="SSL key file"
)
parser.add_argument(
"--ssl-certfile", default=None,
help="SSL certificate file"
)
args = parser.parse_args()
# Configure Python logging based on log level
# Update config with CLI override if provided
if args.log_level != config.log_level:
config = HindsightConfig(
database_url=config.database_url,
llm_provider=config.llm_provider,
llm_api_key=config.llm_api_key,
llm_model=config.llm_model,
llm_base_url=config.llm_base_url,
embeddings_provider=config.embeddings_provider,
embeddings_local_model=config.embeddings_local_model,
embeddings_tei_url=config.embeddings_tei_url,
reranker_provider=config.reranker_provider,
reranker_local_model=config.reranker_local_model,
reranker_tei_url=config.reranker_tei_url,
host=args.host,
port=args.port,
log_level=args.log_level,
mcp_enabled=config.mcp_enabled,
)
config.configure_logging()
# Register cleanup handlers
atexit.register(_cleanup)
signal.signal(signal.SIGINT, _signal_handler)
signal.signal(signal.SIGTERM, _signal_handler)
# Create MemoryEngine (reads configuration from environment)
_memory = MemoryEngine()
# Create FastAPI app
app = create_app(
memory=_memory,
http_api_enabled=True,
mcp_api_enabled=config.mcp_enabled,
mcp_mount_path="/mcp",
initialize_memory=True,
)
# Prepare uvicorn config
uvicorn_config = {
"app": app,
"host": args.host,
"port": args.port,
"log_level": args.log_level,
"access_log": args.access_log,
"proxy_headers": args.proxy_headers,
"ws": "wsproto", # Use wsproto instead of websockets to avoid deprecation warnings
}
# Add optional parameters if provided
if args.reload:
uvicorn_config["reload"] = True
if args.workers > 1:
uvicorn_config["workers"] = args.workers
if args.forwarded_allow_ips:
uvicorn_config["forwarded_allow_ips"] = args.forwarded_allow_ips
if args.ssl_keyfile:
uvicorn_config["ssl_keyfile"] = args.ssl_keyfile
if args.ssl_certfile:
uvicorn_config["ssl_certfile"] = args.ssl_certfile
print(f"\nStarting Hindsight API...")
print(f" URL: http://{args.host}:{args.port}")
print(f" Database: {config.database_url}")
print(f" LLM: {config.llm_provider} / {config.llm_model}")
print(f" Embeddings: {config.embeddings_provider}")
print(f" Reranker: {config.reranker_provider}")
if config.mcp_enabled:
print(f" MCP: enabled at /mcp")
print()
uvicorn.run(**uvicorn_config)
if __name__ == "__main__":
main()
+38 -60
View File
@@ -3,8 +3,8 @@ Database migration management using Alembic.
This module provides programmatic access to run database migrations
on application startup. It is designed to be safe for concurrent
execution using PostgreSQL advisory locks to coordinate between
distributed workers.
execution - Alembic uses PostgreSQL transactions to prevent
conflicts when multiple instances start simultaneously.
Important: All migrations must be backward-compatible to allow
safe rolling deployments.
@@ -19,51 +19,19 @@ from typing import Optional
from alembic import command
from alembic.config import Config
from sqlalchemy import create_engine, text
logger = logging.getLogger(__name__)
# Advisory lock ID for migrations (arbitrary unique number)
MIGRATION_LOCK_ID = 123456789
def _run_migrations_internal(database_url: str, script_location: str) -> None:
"""
Internal function to run migrations without locking.
"""
logger.info(f"Running database migrations to head...")
logger.info(f"Database URL: {database_url}")
logger.info(f"Script location: {script_location}")
# Create Alembic configuration programmatically (no alembic.ini needed)
alembic_cfg = Config()
# Set the script location (where alembic versions are stored)
alembic_cfg.set_main_option("script_location", script_location)
# Set the database URL
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
# Configure logging (optional, but helps with debugging)
# Uses Python's logging system instead of alembic.ini
alembic_cfg.set_main_option("prepend_sys_path", ".")
# Set path_separator to avoid deprecation warning
alembic_cfg.set_main_option("path_separator", "os")
# Run migrations to head (latest version)
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully")
def run_migrations(database_url: str, script_location: Optional[str] = None) -> None:
"""
Run database migrations to the latest version using programmatic Alembic configuration.
This function is safe to call from multiple distributed workers simultaneously:
- Uses PostgreSQL advisory lock to ensure only one worker runs migrations at a time
- Other workers wait for the lock, then verify migrations are complete
This function is safe to call on every application startup:
- Alembic checks the current schema version in the database
- Only missing migrations are applied
- PostgreSQL transactions prevent concurrent migration conflicts
- If schema is already up-to-date, this is a fast no-op
Args:
@@ -88,11 +56,11 @@ def run_migrations(database_url: str, script_location: Optional[str] = None) ->
try:
# Determine script location
if script_location is None:
# Default: use the alembic directory inside the hindsight_api package
# This file is in: hindsight_api/migrations.py
# Alembic is in: hindsight_api/alembic/
package_dir = Path(__file__).parent
script_location = str(package_dir / "alembic")
# Default: use the alembic directory in the hindsight_api package
# This file is in: hindsight-api/hindsight_api/migrations.py
# Default location is: hindsight-api/alembic
package_root = Path(__file__).parent.parent
script_location = str(package_root / "alembic")
script_path = Path(script_location)
if not script_path.exists():
@@ -101,22 +69,32 @@ def run_migrations(database_url: str, script_location: Optional[str] = None) ->
"Database migrations cannot be run."
)
# Use PostgreSQL advisory lock to coordinate between distributed workers
engine = create_engine(database_url)
with engine.connect() as conn:
# pg_advisory_lock blocks until the lock is acquired
# The lock is automatically released when the connection closes
logger.debug(f"Acquiring migration advisory lock (id={MIGRATION_LOCK_ID})...")
conn.execute(text(f"SELECT pg_advisory_lock({MIGRATION_LOCK_ID})"))
logger.debug("Migration advisory lock acquired")
logger.info(f"Running database migrations to head...")
logger.info(f"Database URL: {database_url}")
logger.info(f"Script location: {script_location}")
try:
# Run migrations while holding the lock
_run_migrations_internal(database_url, script_location)
finally:
# Explicitly release the lock (also released on connection close)
conn.execute(text(f"SELECT pg_advisory_unlock({MIGRATION_LOCK_ID})"))
logger.debug("Migration advisory lock released")
# Create Alembic configuration programmatically (no alembic.ini needed)
alembic_cfg = Config()
# Set the script location (where alembic versions are stored)
alembic_cfg.set_main_option("script_location", script_location)
# Set the database URL
alembic_cfg.set_main_option("sqlalchemy.url", database_url)
# Configure logging (optional, but helps with debugging)
# Uses Python's logging system instead of alembic.ini
alembic_cfg.set_main_option("prepend_sys_path", ".")
# Set path_separator to avoid deprecation warning
alembic_cfg.set_main_option("path_separator", "os")
# Run migrations to head (latest version)
# Note: Alembic may call sys.exit() on errors instead of raising exceptions
# We rely on the outer try/except and logging to catch issues
command.upgrade(alembic_cfg, "head")
logger.info("Database migrations completed successfully")
except FileNotFoundError:
logger.error(f"Alembic script location not found at {script_location}")
@@ -162,8 +140,8 @@ def check_migration_status(database_url: Optional[str] = None, script_location:
# Get head revision from migration scripts
if script_location is None:
package_dir = Path(__file__).parent
script_location = str(package_dir / "alembic")
package_root = Path(__file__).parent.parent
script_location = str(package_root / "alembic")
script_path = Path(script_location)
if not script_path.exists():
+5 -4
View File
@@ -104,7 +104,7 @@ class MemoryUnit(Base):
name="memory_units_document_fkey",
ondelete="CASCADE",
),
CheckConstraint("fact_type IN ('world', 'experience', 'opinion', 'observation')"),
CheckConstraint("fact_type IN ('world', 'bank', 'opinion', 'observation')"),
CheckConstraint("confidence_score IS NULL OR (confidence_score >= 0.0 AND confidence_score <= 1.0)"),
CheckConstraint(
"(fact_type = 'opinion' AND confidence_score IS NOT NULL) OR "
@@ -284,15 +284,16 @@ class MemoryLink(Base):
class Bank(Base):
"""Memory bank profiles with disposition traits and background."""
"""Memory bank profiles with personality traits and background."""
__tablename__ = "banks"
bank_id: Mapped[str] = mapped_column(Text, primary_key=True)
disposition: Mapped[dict] = mapped_column(
personality: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
server_default=sql_text(
'\'{"skepticism": 3, "literalism": 3, "empathy": 3}\'::jsonb'
'\'{"openness": 0.5, "conscientiousness": 0.5, "extraversion": 0.5, '
'"agreeableness": 0.5, "neuroticism": 0.5, "bias_strength": 0.5}\'::jsonb'
)
)
background: Mapped[str] = mapped_column(Text, nullable=False, server_default="")
+36 -17
View File
@@ -153,18 +153,46 @@ class EmbeddedPostgres:
"""
Ensure pg0 is available.
Checks PATH and default location. If not found, raises an error
instructing the user to install pg0 manually.
First checks PATH, then default location, then downloads if needed.
"""
if self.is_installed():
logger.debug(f"pg0 found at {self._binary_path}")
return
raise RuntimeError(
"pg0 is not installed. Please install it manually:\n"
" curl -fsSL https://github.com/vectorize-io/pg0/releases/latest/download/pg0-linux-amd64 -o ~/.local/bin/pg0 && chmod +x ~/.local/bin/pg0\n"
"Or visit: https://github.com/vectorize-io/pg0/releases"
)
logger.info("pg0 not found, downloading...")
# Log platform information
binary_name = get_platform_binary_name()
logger.info(f"Detected platform: system={platform.system()}, machine={platform.machine()}")
# Install to default location
install_dir = Path.home() / ".hindsight" / "bin"
install_dir.mkdir(parents=True, exist_ok=True)
install_path = install_dir / "pg0"
# Download the binary
download_url = get_download_url(self.version)
logger.info(f"Downloading from {download_url}")
try:
async with httpx.AsyncClient(follow_redirects=True, timeout=300.0) as client:
response = await client.get(download_url)
response.raise_for_status()
# Write binary to disk
with open(install_path, "wb") as f:
f.write(response.content)
# Make executable on Unix
if platform.system() != "Windows":
st = os.stat(install_path)
os.chmod(install_path, st.st_mode | stat.S_IEXEC)
self._binary_path = install_path
logger.info(f"Installed pg0 to {install_path}")
except httpx.HTTPError as e:
raise RuntimeError(f"Failed to download pg0: {e}") from e
def _run_command(self, *args: str, capture_output: bool = True) -> subprocess.CompletedProcess:
"""Run a pg0 command synchronously."""
@@ -199,13 +227,6 @@ class EmbeddedPostgres:
return match.group(1)
return None
async def _get_version(self) -> str:
"""Get the pg0 version."""
returncode, stdout, stderr = await self._run_command_async("--version", timeout=10)
if returncode == 0 and stdout:
return stdout.strip()
return "unknown"
async def start(self, max_retries: int = 3, retry_delay: float = 2.0) -> str:
"""
Start the PostgreSQL server with retry logic.
@@ -223,9 +244,7 @@ class EmbeddedPostgres:
if not self.is_installed():
raise RuntimeError("pg0 is not installed. Call ensure_installed() first.")
# Log pg0 version
version = await self._get_version()
logger.info(f"Starting embedded PostgreSQL with pg0 {version} (name: {self.name}, port: {self.port})...")
logger.info(f"Starting embedded PostgreSQL (name: {self.name}, port: {self.port})...")
last_error = None
for attempt in range(1, max_retries + 1):
-43
View File
@@ -1,43 +0,0 @@
"""
FastAPI server for Hindsight API.
This module provides the ASGI app for uvicorn import string usage:
uvicorn hindsight_api.server:app
For CLI usage, use the hindsight-api command instead.
"""
import os
import warnings
# Filter deprecation warnings from third-party libraries
warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProtocol is deprecated")
from hindsight_api import MemoryEngine
from hindsight_api.api import create_app
from hindsight_api.config import get_config
# Disable tokenizers parallelism to avoid warnings
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# Load configuration and configure logging
config = get_config()
config.configure_logging()
# Create app at module level (required for uvicorn import string)
# MemoryEngine reads configuration from environment variables automatically
_memory = MemoryEngine()
# Create unified app with both HTTP and optionally MCP
app = create_app(
memory=_memory,
http_api_enabled=True,
mcp_api_enabled=config.mcp_enabled,
mcp_mount_path="/mcp"
)
if __name__ == "__main__":
# When run directly, delegate to the CLI
from hindsight_api.main import main
main()
@@ -0,0 +1,12 @@
"""
Web interface for memory system.
Provides FastAPI app and visualization interface.
"""
from hindsight_api.api import create_app
# Note: Don't import app from .server here to avoid circular import warnings
# when running with `python -m hindsight_api.web.server`
# If you need the app, import it directly: from hindsight_api.web.server import app
__all__ = ["create_app"]
+109
View File
@@ -0,0 +1,109 @@
"""
FastAPI server for memory graph visualization and API.
Provides REST API endpoints for memory operations and serves
the interactive visualization interface.
"""
import warnings
# Filter deprecation warnings from third-party libraries
warnings.filterwarnings("ignore", message="websockets.legacy is deprecated")
warnings.filterwarnings("ignore", message="websockets.server.WebSocketServerProtocol is deprecated")
import logging
import os
import argparse
from hindsight_api import MemoryEngine
from hindsight_api.api import create_app
# Disable tokenizers parallelism to avoid warnings
os.environ["TOKENIZERS_PARALLELISM"] = "false"
# Create app at module level (required for uvicorn import string)
_memory = MemoryEngine(
db_url=os.getenv("HINDSIGHT_API_DATABASE_URL", "pg0"),
memory_llm_provider=os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"),
memory_llm_api_key=os.getenv("HINDSIGHT_API_LLM_API_KEY"),
memory_llm_model=os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"),
memory_llm_base_url=os.getenv("HINDSIGHT_API_LLM_BASE_URL") or None,
)
# Check if MCP should be enabled
mcp_enabled = os.getenv("HINDSIGHT_API_MCP_ENABLED", "true").lower() == "true"
# Create unified app with both HTTP and optionally MCP
app = create_app(
memory=_memory,
http_api_enabled=True,
mcp_api_enabled=mcp_enabled,
mcp_mount_path="/mcp"
)
if __name__ == "__main__":
import uvicorn
# Get log level from environment variable (default: info)
env_log_level = os.environ.get("HINDSIGHT_API_LOG_LEVEL", "info").lower()
if env_log_level not in ["critical", "error", "warning", "info", "debug", "trace"]:
env_log_level = "info"
# Parse CLI arguments
parser = argparse.ArgumentParser(description="Hindsight API Server")
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to (default: 0.0.0.0)")
parser.add_argument("--port", type=int, default=8888, help="Port to bind to (default: 8888)")
parser.add_argument("--reload", action="store_true", help="Enable auto-reload on code changes")
parser.add_argument("--workers", type=int, default=1, help="Number of worker processes (default: 1)")
parser.add_argument("--log-level", default=env_log_level, choices=["critical", "error", "warning", "info", "debug", "trace"],
help=f"Log level (default: {env_log_level}, from HINDSIGHT_API_LOG_LEVEL)")
parser.add_argument("--access-log", action="store_true", help="Enable access log")
parser.add_argument("--no-access-log", dest="access_log", action="store_false", help="Disable access log")
parser.add_argument("--proxy-headers", action="store_true", help="Enable X-Forwarded-Proto, X-Forwarded-For headers")
parser.add_argument("--forwarded-allow-ips", default=None, help="Comma separated list of IPs to trust with proxy headers")
parser.add_argument("--ssl-keyfile", default=None, help="SSL key file")
parser.add_argument("--ssl-certfile", default=None, help="SSL certificate file")
parser.set_defaults(access_log=False)
args = parser.parse_args()
# Configure Python logging based on log level
log_level_map = {
"critical": logging.CRITICAL,
"error": logging.ERROR,
"warning": logging.WARNING,
"info": logging.INFO,
"debug": logging.DEBUG,
"trace": logging.DEBUG, # Python doesn't have TRACE, use DEBUG
}
logging.basicConfig(
level=log_level_map.get(args.log_level, logging.INFO),
format="%(asctime)s - %(levelname)s - %(name)s - %(message)s"
)
logging.info(f"Starting Hindsight API on {args.host}:{args.port}")
app_ref = "hindsight_api.web.server:app"
# Prepare uvicorn config
uvicorn_config = {
"app": app_ref,
"host": args.host,
"port": args.port,
"reload": args.reload,
"workers": args.workers,
"log_level": args.log_level,
"access_log": args.access_log,
"proxy_headers": args.proxy_headers,
"ws": "wsproto", # Use wsproto instead of websockets to avoid deprecation warnings
}
# Add optional parameters if provided
if args.forwarded_allow_ips:
uvicorn_config["forwarded_allow_ips"] = args.forwarded_allow_ips
if args.ssl_keyfile:
uvicorn_config["ssl_keyfile"] = args.ssl_keyfile
if args.ssl_certfile:
uvicorn_config["ssl_certfile"] = args.ssl_certfile
uvicorn.run(**uvicorn_config)
+8 -21
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "hindsight-api"
version = "0.1.0"
version = "0.0.18"
description = "Temporal + Semantic + Entity Memory System for AI agents using PostgreSQL"
readme = "README.md"
requires-python = ">=3.11"
@@ -14,7 +14,7 @@ dependencies = [
"openai>=1.0.0",
"pydantic>=2.0.0",
"rich>=13.0.0",
"sentence-transformers>=3.0.0",
"sentence-transformers>=2.2.0",
"langchain-text-splitters>=0.3.0",
"fastapi[standard]>=0.120.3",
"uvicorn>=0.38.0",
@@ -35,7 +35,6 @@ dependencies = [
"opentelemetry-instrumentation-fastapi>=0.41b0",
"opentelemetry-exporter-prometheus>=0.41b0",
"dateparser>=1.2.2",
"google-genai>=1.0.0",
]
[project.optional-dependencies]
@@ -45,34 +44,21 @@ test = [
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.0.0",
"filelock>=3.0.0",
"testcontainers[postgres]>=4.0.0",
]
[project.scripts]
hindsight-api = "hindsight_api.main:main"
hindsight-api = "hindsight_api.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["hindsight_api"]
[tool.hatch.build.targets.wheel.sources]
"hindsight_api" = "hindsight_api"
[tool.hatch.build.targets.sdist]
include = [
"hindsight_api/**/*",
]
[tool.hatch.build]
include = [
"hindsight_api/**/*.py",
"hindsight_api/alembic/**/*",
]
[tool.pytest.ini_options]
log_cli = true
log_cli_level = "INFO"
log_cli_format = "%(asctime)s - %(levelname)s - %(name)s - %(message)s"
log_cli_format = "%(asctime)s %(levelname)s %(message)s"
log_cli_date_format = "%Y-%m-%d %H:%M:%S"
addopts = "--timeout 120 -n 8 --durations=10 -v"
addopts = "--timeout 60 -n auto --durations=10 -v"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
log_auto_indent = true
@@ -83,10 +69,11 @@ filterwarnings = [
[dependency-groups]
dev = [
"filelock>=3.20.0",
"pytest>=9.0.0",
"pytest-asyncio>=1.3.0",
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.8.0",
"python-dotenv>=1.2.1",
"filelock>=3.0.0",
"testcontainers>=4.13.3",
]
+46
View File
@@ -0,0 +1,46 @@
"""
Debug script to test chunk extraction.
"""
import asyncio
from datetime import datetime
from hindsight_api.engine.utils import extract_facts
from hindsight_api.engine.llm_wrapper import LLMConfig
import os
async def main():
# Set up LLM config
llm_config = LLMConfig.for_memory()
# Test content
long_content = """
Alice is a senior software engineer at TechCorp. She has been working there for 5 years.
Alice specializes in distributed systems and has led the development of the company's
microservices architecture. She is known for writing clean, well-documented code.
Bob joined the team last month as a junior developer. He is learning React and Node.js.
Bob is enthusiastic and asks great questions during code reviews. He recently completed
his first feature, which was a user authentication flow.
The team uses Kubernetes for container orchestration and deploys to AWS. They follow
agile methodologies with two-week sprints. Code reviews are mandatory before merging.
"""
# Extract facts and chunks
facts, chunks = await extract_facts(
text=long_content,
event_date=datetime(2024, 1, 15),
context="team overview",
llm_config=llm_config
)
print(f"\n=== Extracted {len(facts)} facts ===")
for i, fact in enumerate(facts):
print(f"{i+1}. {fact.fact[:100]}...")
print(f"\n=== Extracted {len(chunks)} chunks ===")
for i, (chunk_text, fact_count) in enumerate(chunks):
print(f"Chunk {i}: {fact_count} facts, {len(chunk_text)} chars")
print(f" Text: {chunk_text[:100]}...")
if __name__ == "__main__":
asyncio.run(main())
+69
View File
@@ -0,0 +1,69 @@
"""Test to verify mentioned_at uses event_date, not now()"""
import asyncio
from datetime import datetime, timezone, timedelta
from hindsight_api.engine.memory_engine import MemoryEngine
async def test_mentioned_at_uses_event_date():
"""Verify that mentioned_at is set to event_date, not now()"""
# Use a date that's clearly not "now"
past_date = datetime(2020, 1, 1, 12, 0, 0, tzinfo=timezone.utc)
memory = MemoryEngine()
await memory.initialize()
try:
bank_id = "test_mentioned_at_debug"
# Store with explicit past event_date
unit_ids = await memory.retain_async(
bank_id=bank_id,
content="Alex went to the store.",
context="test",
event_date=past_date
)
print(f"\n✅ Stored {len(unit_ids)} units")
# Recall and check mentioned_at
result = await memory.recall_async(
bank_id=bank_id,
query="store",
max_tokens=500
)
print(f"✅ Found {len(result.results)} facts")
for i, fact in enumerate(result.results, 1):
print(f"\nFact {i}:")
print(f" Text: {fact.text[:80]}...")
print(f" mentioned_at: {fact.mentioned_at}")
print(f" occurred_start: {fact.occurred_start}")
# Parse mentioned_at
if isinstance(fact.mentioned_at, str):
mentioned_dt = datetime.fromisoformat(fact.mentioned_at.replace('Z', '+00:00'))
else:
mentioned_dt = fact.mentioned_at
# Check if mentioned_at matches our event_date
time_diff = abs((mentioned_dt - past_date).total_seconds())
if time_diff < 60:
print(f" ✅ mentioned_at correctly set to event_date")
else:
print(f" ❌ mentioned_at is {mentioned_dt}, expected {past_date}")
print(f" Time difference: {time_diff} seconds")
# Check if it's close to now()
now_diff = abs((mentioned_dt - datetime.now(timezone.utc)).total_seconds())
if now_diff < 60:
print(f" ⚠️ mentioned_at is using now() instead of event_date!")
await memory.delete_bank(bank_id)
finally:
await memory.close()
if __name__ == "__main__":
asyncio.run(test_mentioned_at_uses_event_date())
@@ -0,0 +1,302 @@
# Retain Test Coverage Plan
## Current Test Coverage Analysis
### ✅ Currently Tested Features
1. **Basic Retention** (`test_retain.py`)
- Storing content with chunks
- Basic recall functionality
2. **Document Tracking** (`test_document_tracking.py`)
- Document creation and retrieval
- Document upsert (automatic replacement)
- Document deletion with cascade
- Memories without documents (backward compatibility)
3. **Batch Processing** (`test_batch_chunking.py`)
- Auto-chunking for large batches (>500k chars)
- Small batch processing without chunking
4. **Chunk and Entity Ordering** (`test_retain.py`)
- Chunks follow fact relevance order
- Entities follow fact relevance order
- Token limit truncation behavior
5. **Temporal Data** (`test_retain.py`) ✅ **COMPLETED**
- Event date storage as occurred_start
- Temporal ordering of facts
- Distinction between occurred_start and mentioned_at
- mentioned_at bug fix (was using event_date, now uses current timestamp)
6. **Context Tracking** (`test_retain.py`) ✅ **COMPLETED**
- Context preservation in storage
- Multiple contexts in batch operations
7. **Metadata Storage** (`test_retain.py`) ✅ **COMPLETED**
- Storage and retrieval of metadata (basic test)
- Note: Full metadata support depends on API implementation
8. **Batch Processing Edge Cases** (`test_retain.py`) ✅ **COMPLETED**
- Empty batch handling
- Single-item batch processing
- Mixed content sizes in batch
- Missing optional fields handling
9. **Multi-Document Batches** (`test_retain.py`) ✅ **COMPLETED**
- Multiple documents via separate retain calls
- Document upsert behavior
10. **Chunk Storage Advanced** (`test_retain.py`) ✅ **COMPLETED**
- Chunk-to-fact mapping via chunk_id
- Chunk ordering preservation (chunk_index)
- Chunk truncation behavior
---
## 🔴 Missing Test Coverage - Priority Features
### 1. **Fact Type Override**
**Feature**: `fact_type_override` parameter to force fact type
- Location: `memory_engine.py:593, 634`
- Use cases: Forcing 'opinion', 'world', or 'bank' facts
**Proposed Tests**:
```python
@pytest.mark.asyncio
async def test_fact_type_override_opinion(memory):
"""Test that fact_type_override='opinion' stores all facts as opinions."""
@pytest.mark.asyncio
async def test_fact_type_override_world(memory):
"""Test that fact_type_override='world' stores all facts as world facts."""
@pytest.mark.asyncio
async def test_fact_type_override_bank(memory):
"""Test that fact_type_override='bank' stores all facts as bank facts."""
```
---
### 2. **Confidence Scores for Opinions**
**Feature**: `confidence_score` parameter for opinion reliability
- Location: `memory_engine.py:594, 635`
- Use cases: Tracking opinion certainty
**Proposed Tests**:
```python
@pytest.mark.asyncio
async def test_confidence_score_storage(memory):
"""Test that confidence scores are stored and retrievable."""
# Store opinion with confidence 0.8
# Recall and verify confidence is preserved
@pytest.mark.asyncio
async def test_confidence_score_ranking(memory):
"""Test that higher confidence opinions rank higher in recall."""
# Store multiple opinions with different confidence scores
# Verify recall returns higher confidence first
```
---
### 3. **~~Temporal Data (event_date)~~** ✅ **IMPLEMENTED**
~~**Feature**: Track when events occurred vs when they were mentioned~~
- ~~Location: `memory_engine.py:591, occurred_start/occurred_end/mentioned_at`~~
- ~~Use cases: Temporal reasoning, time-based queries~~
- **Status**: All 3 tests implemented and passing
- **Bug Fixed**: mentioned_at was using event_date instead of current timestamp
---
### 4. **~~Context Tracking~~** ✅ **IMPLEMENTED**
~~**Feature**: Store context about why/how memory was formed~~
- ~~Location: `memory_engine.py:590`~~
- ~~Use cases: Understanding memory provenance~~
- **Status**: 2 tests implemented
---
### 5. **Entity Extraction and Linking**
**Feature**: Automatic entity detection and relationship tracking
- Location: `entity_processing.py`, `memory_engine.py:1741-1763`
**Proposed Tests**:
```python
@pytest.mark.asyncio
async def test_entity_extraction(memory):
"""Test that entities are automatically extracted from content."""
# Store "Alice works at Google"
# Verify "Alice" and "Google" are extracted as entities
@pytest.mark.asyncio
async def test_entity_linking_across_facts(memory):
"""Test that same entity is linked across multiple facts."""
# Store multiple facts mentioning "Alice"
# Verify they link to same entity_id
@pytest.mark.asyncio
async def test_entity_observations_generation(memory):
"""Test that entity observations are generated and updated."""
# Store facts about entity
# Check entity observations contain summaries
```
---
### 6. **Fact Deduplication**
**Feature**: Prevent storing duplicate/similar facts
- Location: `memory_engine.py:1014-1079` (deduplication check)
**Proposed Tests**:
```python
@pytest.mark.asyncio
async def test_exact_duplicate_prevention(memory):
"""Test that exact duplicate facts are not stored twice."""
# Store same fact twice
# Verify only one unit created
@pytest.mark.asyncio
async def test_similar_fact_deduplication(memory):
"""Test that semantically similar facts are deduplicated."""
# Store "Alice works at Google" and "Alice is employed by Google"
# Verify deduplication occurs based on similarity
@pytest.mark.asyncio
async def test_temporal_deduplication(memory):
"""Test that deduplication respects temporal windows."""
# Store similar facts with different timestamps
# Verify they're treated as separate if time difference is large
```
---
### 7. **Causal Relationships**
**Feature**: Track causal links between facts
- Location: `memory_engine.py:810` (all_causal_relations)
**Proposed Tests**:
```python
@pytest.mark.asyncio
async def test_causal_relationship_extraction(memory):
"""Test that causal relationships are extracted."""
# Store "Alice got promoted because she shipped the project"
# Verify causal link is extracted
@pytest.mark.asyncio
async def test_causal_relationship_recall(memory):
"""Test that causal relationships affect recall."""
# Store facts with causal links
# Query should surface related facts
```
---
### 8. **Embeddings and Vector Storage**
**Feature**: Generate and store embeddings for semantic search
- Location: `memory_engine.py:904-923`
**Proposed Tests**:
```python
@pytest.mark.asyncio
async def test_embedding_generation(memory):
"""Test that embeddings are generated for facts."""
# Store fact
# Query database to verify embedding exists
@pytest.mark.asyncio
async def test_semantic_similarity_search(memory):
"""Test that semantically similar facts are recalled together."""
# Store "Alice loves Python"
# Query "Who enjoys programming?"
# Verify Alice's fact is recalled via semantic similarity
```
---
### 9. **~~Metadata Storage~~** ✅ **IMPLEMENTED**
~~**Feature**: Store arbitrary metadata with facts~~
- ~~Location: `memory_engine.py:792, 811`~~
- **Status**: Basic metadata test implemented
- **Note**: Full metadata support depends on API layer implementation
---
### 10. **~~Batch Processing Edge Cases~~** ✅ **IMPLEMENTED**
~~**Feature**: Handle various batch sizes and edge cases~~
- **Status**: 4 tests implemented
- Empty batch handling
- Single-item batch
- Mixed content sizes
- Missing optional fields
---
### 11. **~~Multi-Document Batches~~** ✅ **IMPLEMENTED**
~~**Feature**: Process multiple documents in one batch call~~
- **Status**: 2 tests implemented
- Multiple documents via separate retain calls
- Document upsert behavior
---
### 12. **~~Chunk Storage Advanced~~** ✅ **IMPLEMENTED**
~~**Feature**: Chunk-level operations and queries~~
- **Status**: 3 tests implemented
- Chunk-to-fact mapping
- Chunk ordering preservation
- Chunk truncation behavior
---
## 🔵 Lower Priority / Edge Cases
### 13. **Error Handling**
- Invalid bank_id
- Malformed content
- Missing required fields
- Database connection failures
### 14. **Performance Tests**
- Large batch throughput
- Concurrent retention operations
- Memory usage under load
### 15. **Backward Compatibility**
- Retention without document_id
- Legacy API usage patterns
---
## Test Implementation Status
### ✅ Completed Tests (17 total tests implemented)
1. ~~Temporal data tests (3 tests)~~
2. ~~Context tracking tests (2 tests)~~
3. ~~Metadata tests (1 test - basic)~~
4. ~~Batch edge cases (4 tests)~~
5. ~~Multi-document batches (2 tests)~~
6. ~~Chunk storage advanced (3 tests)~~
7. ~~Bug Fix: mentioned_at now uses current timestamp~~
### 🟡 Not Implemented (Requires LLM or Complex Setup)
These tests depend on non-deterministic LLM behavior or require complex setup:
1. Fact type override tests (3 tests) - Depends on LLM classification
2. Confidence score tests (2 tests) - Depends on LLM opinion extraction
3. Entity extraction tests (3 tests) - Depends on LLM entity detection
4. Fact deduplication tests (3 tests) - Depends on LLM similarity detection
5. Causal relationships tests (2 tests) - Depends on LLM causal extraction
6. Embeddings tests (2 tests) - Would test internal implementation details
### 🔵 Deferred (Lower Priority)
7. Error handling (4 tests) - Infrastructure tests
8. Performance tests (3 tests) - Requires specific benchmarking setup
---
## Success Metrics
- **Coverage**: 95%+ line coverage for retain code paths
- **Reliability**: All tests pass consistently
- **Documentation**: Each test includes clear docstring explaining what it validates
- **Maintainability**: Tests are independent and can run in parallel
+36 -70
View File
@@ -3,20 +3,16 @@ Pytest configuration and shared fixtures.
"""
import pytest
import pytest_asyncio
import asyncio
import os
import filelock
from pathlib import Path
from dotenv import load_dotenv
from hindsight_api import MemoryEngine, LLMConfig, LocalSTEmbeddings
from hindsight_api import MemoryEngine, LLMConfig, SentenceTransformersEmbeddings
import asyncpg
from testcontainers.postgres import PostgresContainer
from hindsight_api.engine.cross_encoder import LocalSTCrossEncoder
from hindsight_api.engine.cross_encoder import SentenceTransformersCrossEncoder
from hindsight_api.engine.query_analyzer import DateparserQueryAnalyzer
from hindsight_api.pg0 import EmbeddedPostgres
# Default pg0 instance configuration for tests
DEFAULT_PG0_INSTANCE_NAME = "hindsight-test"
DEFAULT_PG0_PORT = 5556
# Load environment variables from .env at the start of test session
@@ -31,72 +27,45 @@ def pytest_configure(config):
@pytest.fixture(scope="session")
def db_url():
def postgres_container(tmp_path_factory, worker_id):
"""
Provide a PostgreSQL connection URL for tests.
Start a postgres container shared across all test workers.
Uses filelock to ensure only one worker starts the container.
If HINDSIGHT_API_DATABASE_URL is set, use it directly.
Otherwise, return None to indicate pg0 should be used (managed by pg0_instance fixture).
- worker_id == "master": running without -n (single process)
- worker_id == "gw0", "gw1", etc.: running with -n (parallel workers)
"""
return os.getenv("HINDSIGHT_API_DATABASE_URL")
@pytest.fixture(scope="session")
def pg0_db_url(db_url, tmp_path_factory, worker_id):
"""
Session-scoped fixture that ensures pg0 is running, migrations are applied,
and returns the database URL.
If HINDSIGHT_API_DATABASE_URL is set, uses that directly (no pg0 management).
Otherwise, starts pg0 once for the entire test session.
Uses filelock to ensure only one pytest-xdist worker starts pg0.
Migrations use PostgreSQL advisory locks internally, so they're safe to call
from multiple workers - only one will actually run migrations.
Note: We don't stop pg0 at the end because pytest-xdist runs workers in separate
processes that share the same pg0 instance. pg0 will persist for the next test run.
"""
if db_url:
# Use provided database URL directly
return db_url
# Get shared temp dir for coordination between xdist workers
# Get shared temp dir (same for all workers)
if worker_id == "master":
# Running without xdist (-n 0 or no -n flag)
root_tmp_dir = tmp_path_factory.getbasetemp()
else:
# Running with xdist - use parent dir shared by all workers
root_tmp_dir = tmp_path_factory.getbasetemp().parent
# Use a lock file to ensure only one worker starts pg0
lock_file = root_tmp_dir / "pg0_setup.lock"
url_file = root_tmp_dir / "pg0_url.txt"
db_url_file = root_tmp_dir / "postgres_url"
lock_file = root_tmp_dir / "postgres.lock"
container = None
with filelock.FileLock(str(lock_file)):
if url_file.exists():
# Another worker already started pg0
url = url_file.read_text().strip()
if db_url_file.exists():
# Another worker already started the container
db_url = db_url_file.read_text()
else:
# First worker - start pg0
pg0 = EmbeddedPostgres(name=DEFAULT_PG0_INSTANCE_NAME, port=DEFAULT_PG0_PORT)
# First worker - start the container
container = PostgresContainer("pgvector/pgvector:pg16")
container.start()
db_url = container.get_connection_url().replace("postgresql+psycopg2://", "postgresql://")
db_url_file.write_text(db_url)
# Run ensure_running in a new event loop
loop = asyncio.new_event_loop()
try:
url = loop.run_until_complete(pg0.ensure_running())
finally:
loop.close()
# Run migrations
from hindsight_api.migrations import run_migrations
run_migrations(db_url)
# Save URL for other workers
url_file.write_text(url)
os.environ["HINDSIGHT_API_DATABASE_URL"] = db_url
yield db_url
# Run migrations - uses PostgreSQL advisory lock internally,
# so safe to call from multiple workers (only one will actually run migrations)
from hindsight_api.migrations import run_migrations
run_migrations(url)
return url
# Only the worker that started the container stops it
if container is not None:
container.stop()
@pytest.fixture(scope="session")
@@ -111,14 +80,14 @@ def llm_config():
@pytest.fixture(scope="session")
def embeddings():
return LocalSTEmbeddings()
return SentenceTransformersEmbeddings("BAAI/bge-small-en-v1.5")
@pytest.fixture(scope="session")
def cross_encoder():
return LocalSTCrossEncoder()
return SentenceTransformersCrossEncoder()
@pytest.fixture(scope="session")
def query_analyzer():
@@ -128,7 +97,7 @@ def query_analyzer():
@pytest_asyncio.fixture(scope="function")
async def memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
async def memory(postgres_container, embeddings, cross_encoder, query_analyzer):
"""
Provide a MemoryEngine instance for each test.
@@ -137,13 +106,11 @@ async def memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
2. asyncpg pools are bound to the event loop that created them
3. Each test needs its own pool in its own event loop
Uses small pool sizes since tests run in parallel.
Uses pg0_db_url (a postgresql:// URL) directly, so MemoryEngine won't try to
manage pg0 lifecycle - that's handled by the session-scoped pg0_db_url fixture.
Migrations are disabled here since they're run once at session scope in pg0_db_url.
Uses small pool sizes since tests run in parallel and share a single
testcontainer PostgreSQL instance with limited resources.
"""
mem = MemoryEngine(
db_url=pg0_db_url, # Direct postgresql:// URL, not pg0://
db_url=postgres_container,
memory_llm_provider=os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"),
memory_llm_api_key=os.getenv("HINDSIGHT_API_LLM_API_KEY"),
memory_llm_model=os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-120b"),
@@ -153,7 +120,6 @@ async def memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
query_analyzer=query_analyzer,
pool_min_size=1,
pool_max_size=5,
run_migrations=False, # Migrations already run at session scope
)
await mem.initialize()
yield mem
@@ -161,4 +127,4 @@ async def memory(pg0_db_url, embeddings, cross_encoder, query_analyzer):
if mem._pool and not mem._pool._closing:
await mem.close()
except Exception:
pass
pass
+60 -45
View File
@@ -1,10 +1,10 @@
"""
Tests for agent management API (profile, disposition, background).
Tests for agent management API (profile, personality, background).
"""
import pytest
import uuid
from hindsight_api import MemoryEngine
from hindsight_api.api import CreateBankRequest, DispositionTraits
from hindsight_api.api import CreateBankRequest, PersonalityTraits
from hindsight_api.engine.memory_engine import Budget
@@ -18,42 +18,51 @@ class TestAgentProfile:
@pytest.mark.asyncio
async def test_get_agent_profile_creates_default(self, memory: MemoryEngine):
"""Test that getting a profile for a new agent creates default disposition."""
"""Test that getting a profile for a new agent creates default personality."""
bank_id = unique_agent_id("test_profile_default")
profile = await memory.get_bank_profile(bank_id)
assert profile is not None
assert "disposition" in profile
assert "personality" in profile
assert "background" in profile
disposition = profile["disposition"]
assert disposition.skepticism == 3
assert disposition.literalism == 3
assert disposition.empathy == 3
personality = profile["personality"]
assert personality.openness == 0.5
assert personality.conscientiousness == 0.5
assert personality.extraversion == 0.5
assert personality.agreeableness == 0.5
assert personality.neuroticism == 0.5
assert personality.bias_strength == 0.5
assert profile["background"] == ""
@pytest.mark.asyncio
async def test_update_agent_disposition(self, memory: MemoryEngine):
"""Test updating agent disposition traits."""
async def test_update_agent_personality(self, memory: MemoryEngine):
"""Test updating agent personality traits."""
bank_id = unique_agent_id("test_profile_update")
profile = await memory.get_bank_profile(bank_id)
assert profile["disposition"].skepticism == 3
assert profile["personality"].openness == 0.5
new_disposition = {
"skepticism": 5,
"literalism": 4,
"empathy": 2,
new_personality = {
"openness": 0.8,
"conscientiousness": 0.6,
"extraversion": 0.7,
"agreeableness": 0.4,
"neuroticism": 0.3,
"bias_strength": 0.9,
}
await memory.update_bank_disposition(bank_id, new_disposition)
await memory.update_bank_personality(bank_id, new_personality)
updated_profile = await memory.get_bank_profile(bank_id)
disposition = updated_profile["disposition"]
assert disposition.skepticism == new_disposition["skepticism"]
assert disposition.literalism == new_disposition["literalism"]
assert disposition.empathy == new_disposition["empathy"]
personality = updated_profile["personality"]
assert abs(personality.openness - new_personality["openness"]) < 0.001
assert abs(personality.conscientiousness - new_personality["conscientiousness"]) < 0.001
assert abs(personality.extraversion - new_personality["extraversion"]) < 0.001
assert abs(personality.agreeableness - new_personality["agreeableness"]) < 0.001
assert abs(personality.neuroticism - new_personality["neuroticism"]) < 0.001
assert abs(personality.bias_strength - new_personality["bias_strength"]) < 0.001
@pytest.mark.asyncio
async def test_list_agents(self, memory: MemoryEngine):
@@ -75,7 +84,7 @@ class TestAgentProfile:
for agent in agents:
assert "bank_id" in agent
assert "disposition" in agent
assert "personality" in agent
assert "background" in agent
assert "created_at" in agent
assert "updated_at" in agent
@@ -95,14 +104,14 @@ class TestAgentBackground:
result1 = await memory.merge_bank_background(
bank_id,
"I was born in Texas",
update_disposition=False
update_personality=False
)
assert "Texas" in result1["background"]
result2 = await memory.merge_bank_background(
bank_id,
"I have 10 years of startup experience",
update_disposition=False
update_personality=False
)
assert "Texas" in result2["background"] or "startup" in result2["background"]
@@ -117,14 +126,14 @@ class TestAgentBackground:
result1 = await memory.merge_bank_background(
bank_id,
"I was born in Colorado",
update_disposition=False
update_personality=False
)
assert "Colorado" in result1["background"]
result2 = await memory.merge_bank_background(
bank_id,
"You were born in Texas",
update_disposition=False
update_personality=False
)
assert "Texas" in result2["background"]
@@ -138,20 +147,23 @@ class TestAgentEndpoint:
bank_id = unique_agent_id("test_put_create")
request = CreateBankRequest(
disposition=DispositionTraits(
skepticism=4,
literalism=5,
empathy=2
personality=PersonalityTraits(
openness=0.8,
conscientiousness=0.6,
extraversion=0.5,
agreeableness=0.7,
neuroticism=0.3,
bias_strength=0.7
),
background="I am a creative software engineer"
)
profile = await memory.get_bank_profile(bank_id)
if request.disposition is not None:
await memory.update_bank_disposition(
if request.personality is not None:
await memory.update_bank_personality(
bank_id,
request.disposition.model_dump()
request.personality.model_dump()
)
if request.background is not None:
@@ -170,8 +182,8 @@ class TestAgentEndpoint:
final_profile = await memory.get_bank_profile(bank_id)
assert final_profile["disposition"].skepticism == 4
assert final_profile["disposition"].literalism == 5
assert final_profile["personality"].openness == 0.8
assert final_profile["personality"].bias_strength == 0.7
assert final_profile["background"] == "I am a creative software engineer"
@pytest.mark.asyncio
@@ -201,29 +213,32 @@ class TestAgentEndpoint:
final_profile = await memory.get_bank_profile(bank_id)
assert final_profile["disposition"].skepticism == 3 # Default
assert final_profile["personality"].openness == 0.5
assert final_profile["background"] == "I am a data scientist"
class TestAgentDispositionIntegration:
"""Tests for disposition integration with other features."""
class TestAgentPersonalityIntegration:
"""Tests for personality integration with other features."""
@pytest.mark.asyncio
async def test_think_uses_disposition(self, memory: MemoryEngine):
"""Test that THINK operation uses agent disposition."""
async def test_think_uses_personality(self, memory: MemoryEngine):
"""Test that THINK operation uses agent personality."""
bank_id = unique_agent_id("test_think")
disposition = {
"skepticism": 5, # Very skeptical
"literalism": 4, # High literalism
"empathy": 2, # Low empathy
personality = {
"openness": 0.9,
"conscientiousness": 0.2,
"extraversion": 0.8,
"agreeableness": 0.1,
"neuroticism": 0.7,
"bias_strength": 0.9,
}
await memory.update_bank_disposition(bank_id, disposition)
await memory.update_bank_personality(bank_id, personality)
await memory.merge_bank_background(
bank_id,
"I am a creative artist who values innovation over tradition",
update_disposition=False
update_personality=False
)
await memory.retain_batch_async(
+5 -1
View File
@@ -2,7 +2,7 @@
Test chunking functionality for large documents.
"""
import pytest
from hindsight_api.engine.retain.fact_extraction import chunk_text
from hindsight_api.engine.fact_extraction import chunk_text
def test_chunk_text_small():
@@ -43,6 +43,10 @@ def test_chunk_text_64k():
chunks = chunk_text(text, max_chars=120000)
print(f"\n64k text chunked into {len(chunks)} chunks")
for i, chunk in enumerate(chunks):
print(f" Chunk {i + 1}: {len(chunk)} characters")
# Should create at least 1 chunk (if text fits) or more
assert len(chunks) >= 1
@@ -43,7 +43,7 @@ Marcus felt anxious about the upcoming interview.
context = "Personal journal entry"
llm_config = LLMConfig.for_memory()
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
@@ -53,7 +53,11 @@ Marcus felt anxious about the upcoming interview.
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. {f['fact']}")
all_facts_text = " ".join([f['fact'].lower() for f in facts])
emotional_indicators = ["thrilled", "disappointed", "anxious", "positive feedback"]
found_emotions = [word for word in emotional_indicators if word in all_facts_text]
@@ -75,7 +79,7 @@ The music was so loud I could barely hear myself think.
context = "Personal experience"
llm_config = LLMConfig.for_memory()
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
@@ -85,7 +89,11 @@ The music was so loud I could barely hear myself think.
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. {f['fact']}")
all_facts_text = " ".join([f['fact'].lower() for f in facts])
sensory_indicators = ["bitter", "burnt", "bright orange", "loud", "stunning"]
found_sensory = [word for word in sensory_indicators if word in all_facts_text]
@@ -108,7 +116,7 @@ Maybe we should reconsider the timeline.
context = "Team discussion"
llm_config = LLMConfig.for_memory()
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
@@ -118,7 +126,11 @@ Maybe we should reconsider the timeline.
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. {f['fact']}")
all_facts_text = " ".join([f['fact'].lower() for f in facts])
cognitive_indicators = ["realized", "wasn't sure", "convinced", "maybe", "reconsider"]
found_cognitive = [word for word in cognitive_indicators if word in all_facts_text]
@@ -141,7 +153,7 @@ I'm unable to attend the conference due to scheduling conflicts.
context = "Personal profile discussion"
llm_config = LLMConfig.for_memory()
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
@@ -151,7 +163,11 @@ I'm unable to attend the conference due to scheduling conflicts.
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. {f['fact']}")
all_facts_text = " ".join([f['fact'].lower() for f in facts])
capability_indicators = ["can speak", "fluently", "struggles with", "expert in", "unable to"]
found_capability = [word for word in capability_indicators if word in all_facts_text]
@@ -173,7 +189,7 @@ Unlike last year, we're ahead of schedule.
context = "Project review"
llm_config = LLMConfig.for_memory()
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
@@ -183,7 +199,11 @@ Unlike last year, we're ahead of schedule.
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. {f['fact']}")
all_facts_text = " ".join([f['fact'].lower() for f in facts])
comparative_indicators = ["better than", "worse than", "unlike", "ahead of"]
found_comparative = [word for word in comparative_indicators if word in all_facts_text]
@@ -206,7 +226,7 @@ She's enthusiastic about the opportunity.
context = "Team meeting"
llm_config = LLMConfig.for_memory()
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
@@ -216,12 +236,16 @@ She's enthusiastic about the opportunity.
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. {f['fact']}")
all_facts_text = " ".join([f['fact'].lower() for f in facts])
attitudinal_indicators = ["skeptical", "surprised", "rolled his eyes", "enthusiastic"]
found_attitudinal = [word for word in attitudinal_indicators if word in all_facts_text]
assert len(found_attitudinal) >= 1, (
assert len(found_attitudinal) >= 2, (
f"Should preserve attitudinal/reactive dimension. "
f"Found: {found_attitudinal}"
)
@@ -239,7 +263,7 @@ I'm planning to switch careers because I'm not fulfilled in my current role.
context = "Personal goals discussion"
llm_config = LLMConfig.for_memory()
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
@@ -249,17 +273,17 @@ I'm planning to switch careers because I'm not fulfilled in my current role.
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. {f['fact']}")
# Check for goal/intention related content
intentional_indicators = [
"want", "aim", "goal", "plan", "because", "learn", "complete",
"build", "switch", "career", "mandarin", "china", "phd", "business"
]
all_facts_text = " ".join([f['fact'].lower() for f in facts])
intentional_indicators = ["want to", "aims to", "goal is", "planning to", "because"]
found_intentional = [word for word in intentional_indicators if word in all_facts_text]
assert len(found_intentional) >= 1, (
f"Should preserve intentional/motivational content. "
assert len(found_intentional) >= 2, (
f"Should preserve intentional/motivational dimension. "
f"Found: {found_intentional}"
)
@@ -276,7 +300,7 @@ Family is the most important thing to her.
context = "Personal values discussion"
llm_config = LLMConfig.for_memory()
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
@@ -286,7 +310,11 @@ Family is the most important thing to her.
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. {f['fact']}")
all_facts_text = " ".join([f['fact'].lower() for f in facts])
evaluative_indicators = ["prefer", "values", "hates", "important", "above all"]
found_evaluative = [word for word in evaluative_indicators if word in all_facts_text]
@@ -310,7 +338,7 @@ I prefer presenting in person rather than virtually because I can read the room
event_date = datetime(2024, 11, 13)
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=event_date,
context=context,
@@ -320,13 +348,15 @@ I prefer presenting in person rather than virtually because I can read the room
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. {f['fact']}")
# Check emotional - should capture positive/thrilled sentiment
has_emotional = any(term in all_facts_text for term in [
"thrilled", "positive feedback", "positive", "feedback", "enthusiastic"
])
assert has_emotional, "Should preserve emotional dimension"
all_facts_text = " ".join([f['fact'].lower() for f in facts])
# Check emotional
assert "thrilled" in all_facts_text or "positive feedback" in all_facts_text, \
"Should preserve emotional dimension (thrilled)"
# Check no vague temporal terms
prohibited_terms = ["recently", "soon", "lately"]
@@ -334,11 +364,13 @@ I prefer presenting in person rather than virtually because I can read the room
assert len(found_prohibited) == 0, \
f"Should NOT use vague temporal terms. Found: {found_prohibited}"
# Check preference - should capture the in-person vs virtual preference
has_preference = any(term in all_facts_text for term in [
"prefer", "rather than", "in person", "virtually", "read the room"
])
assert has_preference, "Should preserve preferential dimension"
# Check cognitive uncertainty
assert "wasn't sure" in all_facts_text or "unsure" in all_facts_text or "uncertain" in all_facts_text, \
"Should preserve cognitive uncertainty"
# Check preference
assert "prefer" in all_facts_text or "rather than" in all_facts_text, \
"Should preserve preferential dimension"
# =============================================================================
@@ -366,7 +398,7 @@ I'm planning to visit Tokyo next month.
event_date = datetime(2024, 11, 13)
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=event_date,
context=context,
@@ -376,7 +408,11 @@ I'm planning to visit Tokyo next month.
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. {f['fact']}")
all_facts_text = " ".join([f['fact'].lower() for f in facts])
# Should NOT contain vague temporal terms
prohibited_terms = ["recently", "soon", "lately", "a while ago", "some time ago"]
@@ -400,8 +436,8 @@ I'm planning to visit Tokyo next month.
"""
Test that the date field is calculated correctly for "last night" events.
Ideally: If conversation is on August 14, 2023 and text says "last night",
the date field should be August 13. We accept 13 or 14 as LLM may vary.
CRITICAL: If conversation is on August 14, 2023 and text says "last night",
the date field should be August 13, NOT August 14.
"""
text = """
Melanie: Hey Caroline! Last night was amazing! We celebrated my daughter's birthday
@@ -413,7 +449,7 @@ with a concert surrounded by music, joy and the warm summer breeze.
event_date = datetime(2023, 8, 14, 14, 24)
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=event_date,
context=context,
@@ -423,15 +459,19 @@ with a concert surrounded by music, joy and the warm summer breeze.
assert len(facts) > 0, "Should extract at least one fact"
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. Date: {f['occurred_start']} - {f['fact']}")
birthday_fact = None
for fact in facts:
if "birthday" in fact.fact.lower() or "concert" in fact.fact.lower():
if "birthday" in fact['fact'].lower() or "concert" in fact['fact'].lower():
birthday_fact = fact
break
assert birthday_fact is not None, "Should extract fact about birthday celebration"
fact_date_str = birthday_fact.occurred_start
fact_date_str = birthday_fact['occurred_start']
if 'T' in fact_date_str:
fact_date = datetime.fromisoformat(fact_date_str.replace('Z', '+00:00'))
@@ -440,9 +480,9 @@ with a concert surrounded by music, joy and the warm summer breeze.
assert fact_date.year == 2023, "Year should be 2023"
assert fact_date.month == 8, "Month should be August"
# Accept day 13 (ideal: last night) or 14 (conversation date) as valid
assert fact_date.day in (13, 14), (
f"Day should be 13 or 14 (around Aug 14 event), but got {fact_date.day}."
assert fact_date.day == 13, (
f"Day should be 13 (last night relative to Aug 14), but got {fact_date.day}. "
f"Date field should be when FACT occurred, not when mentioned!"
)
@pytest.mark.asyncio
@@ -457,7 +497,7 @@ Yesterday I went for a morning jog for the first time in a nearby park.
event_date = datetime(2024, 11, 13)
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=event_date,
context=context,
@@ -467,9 +507,13 @@ Yesterday I went for a morning jog for the first time in a nearby park.
assert len(facts) > 0, "Should extract at least one fact"
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. Date: {f['occurred_start']} - {f['fact']}")
jogging_fact = facts[0]
fact_date_str = jogging_fact.occurred_start
fact_date_str = jogging_fact['occurred_start']
if 'T' in fact_date_str:
fact_date = datetime.fromisoformat(fact_date_str.replace('Z', '+00:00'))
else:
@@ -477,12 +521,12 @@ Yesterday I went for a morning jog for the first time in a nearby park.
assert fact_date.year == 2024, "Year should be 2024"
assert fact_date.month == 11, "Month should be November"
# Accept day 12 (ideal: yesterday) or 13 (conversation date) as valid
assert fact_date.day in (12, 13), (
f"Day should be 12 or 13 (around Nov 13 event), but got {fact_date.day}."
assert fact_date.day == 12, (
f"Day should be 12 (yesterday relative to Nov 13), but got {fact_date.day}. "
f"Date field: {fact_date_str}"
)
all_facts_text = " ".join([f.fact.lower() for f in facts])
all_facts_text = " ".join([f['fact'].lower() for f in facts])
assert "first time" in all_facts_text or "first" in all_facts_text, \
"Should preserve 'first time' qualifier"
@@ -506,7 +550,7 @@ Yesterday I went for a morning jog for the first time in a nearby park.
This morning I had coffee with Alice.
"""
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=reference_date,
llm_config=llm_config,
@@ -514,29 +558,35 @@ Yesterday I went for a morning jog for the first time in a nearby park.
context="Personal diary"
)
print(f"\nExtracted {len(facts)} facts:")
for fact in facts:
print(f"- {fact['fact']}")
print(f" Date: {fact['occurred_start']}")
assert len(facts) > 0, "Should extract at least one fact"
for fact in facts:
assert fact.fact, "Each fact should have 'fact' field"
assert 'fact' in fact, "Each fact should have 'fact' field"
assert 'occurred_start' in fact, "Each fact should have 'occurred_start' field"
assert fact['occurred_start'], f"Date should not be empty for fact: {fact['fact']}"
# Check that facts were extracted - dates may or may not be populated
# depending on LLM behavior
dates = [f.occurred_start for f in facts if f.occurred_start]
# If dates were extracted, they should ideally be different for different events
if len(dates) >= 2:
unique_dates = set(dates)
# Just verify we got dates, don't require them to be unique
dates = [f['occurred_start'] for f in facts]
unique_dates = set(dates)
if len(facts) >= 3:
assert len(unique_dates) >= 2, "Should have different dates for different temporal facts"
print(f"\n All facts have absolute dates")
@pytest.mark.asyncio
async def test_extract_facts_with_no_temporal_info(self):
"""Test that facts without temporal info are still extracted."""
"""Test that facts without temporal info use the reference date."""
reference_date = datetime(2024, 3, 20, 14, 0, 0, tzinfo=timezone.utc)
llm_config = LLMConfig.for_memory()
text = "Alice works at Google. She loves Python programming."
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=reference_date,
llm_config=llm_config,
@@ -544,12 +594,15 @@ Yesterday I went for a morning jog for the first time in a nearby park.
context="General info"
)
print(f"\nExtracted {len(facts)} facts:")
for fact in facts:
print(f"- {fact['fact']}")
print(f" Date: {fact['occurred_start']}")
assert len(facts) > 0, "Should extract at least one fact"
# For facts without temporal info, occurred_start may be None or set to reference date
# We just verify that facts were extracted with content
for fact in facts:
assert fact.fact, "Each fact should have text content"
assert fact['occurred_start'], f"Fact should have a date: {fact['fact']}"
@pytest.mark.asyncio
async def test_extract_facts_with_absolute_dates(self):
@@ -563,7 +616,7 @@ Yesterday I went for a morning jog for the first time in a nearby park.
Bob will start his vacation on April 1st.
"""
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=reference_date,
llm_config=llm_config,
@@ -571,10 +624,15 @@ Yesterday I went for a morning jog for the first time in a nearby park.
context="Calendar events"
)
print(f"\nExtracted {len(facts)} facts:")
for fact in facts:
print(f"- {fact['fact']}")
print(f" Date: {fact['occurred_start']}")
assert len(facts) > 0, "Should extract at least one fact"
for fact in facts:
assert fact.occurred_start, f"Fact should have a date: {fact.fact}"
assert fact['occurred_start'], f"Fact should have a date: {fact['fact']}"
# =============================================================================
@@ -587,10 +645,9 @@ class TestLogicalInference:
@pytest.mark.asyncio
async def test_logical_inference_identity_connection(self):
"""
Test that the system extracts key information about loss and relationships.
Test that the system makes logical inferences to connect related information.
The LLM should extract facts about losing a friend and about Karlie.
Ideally it connects them, but we accept extracting both separately.
Example: "I lost a friend" + "this photo with Karlie" -> "I lost my friend Karlie"
"""
text = """
Deborah: The roses and dahlias bring me peace. I lost a friend last week,
@@ -614,7 +671,7 @@ great time! Every time I see it, I can't help but smile.
event_date = datetime(2023, 2, 23)
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=event_date,
context=context,
@@ -624,29 +681,31 @@ great time! Every time I see it, I can't help but smile.
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. {f['fact']}")
all_facts_text = " ".join([f['fact'].lower() for f in facts])
# Check that key information is extracted (Karlie and the loss)
has_karlie = "karlie" in all_facts_text
has_loss = any(word in all_facts_text for word in ["lost", "death", "passed", "died", "losing", "friend"])
has_hike = "hike" in all_facts_text or "hiking" in all_facts_text or "photo" in all_facts_text
has_loss = any(word in all_facts_text for word in ["lost", "death", "passed", "died", "losing"])
# At minimum, we should capture Karlie and either the loss or the hike memory
assert has_karlie or has_loss, (
f"Should mention either Karlie or the loss in facts. Facts: {[f.fact for f in facts]}"
)
assert has_karlie, "Should mention Karlie in the extracted facts"
assert has_loss, "Should mention the loss/death in the extracted facts"
# Check if inference was made (bonus - not required for pass)
connected_fact_found = False
for fact in facts:
fact_text = fact.fact.lower()
if "karlie" in fact_text and any(word in fact_text for word in ["lost", "death", "passed", "died", "losing", "friend"]):
fact_text = fact['fact'].lower()
if "karlie" in fact_text and any(word in fact_text for word in ["lost", "death", "passed", "died", "losing"]):
connected_fact_found = True
print(f"\n Found connected fact: {fact['fact']}")
break
# This is informational - test passes even without perfect inference
if not connected_fact_found and has_karlie and has_loss:
pass # Acceptable: facts extracted separately
assert connected_fact_found, (
"Should connect 'lost a friend' with 'Karlie' in the same fact. "
f"The inference should be: Karlie is the lost friend. "
f"Facts: {[f['fact'] for f in facts]}"
)
@pytest.mark.asyncio
async def test_logical_inference_pronoun_resolution(self):
@@ -664,7 +723,7 @@ I've learned so much from it.
context = "Personal update"
llm_config = LLMConfig.for_memory()
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
context=context,
@@ -674,7 +733,11 @@ I've learned so much from it.
assert len(facts) > 0, "Should extract at least one fact"
all_facts_text = " ".join([f.fact.lower() for f in facts])
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. {f['fact']}")
all_facts_text = " ".join([f['fact'].lower() for f in facts])
has_project = "project" in all_facts_text
has_qualities = any(word in all_facts_text for word in ["challenging", "rewarding", "learned"])
@@ -684,14 +747,15 @@ I've learned so much from it.
connected_fact_found = False
for fact in facts:
fact_text = fact.fact.lower()
fact_text = fact['fact'].lower()
if "project" in fact_text and any(word in fact_text for word in ["challenging", "rewarding"]):
connected_fact_found = True
print(f"\n Found connected fact: {fact['fact']}")
break
assert connected_fact_found, (
"Should resolve 'it' to 'the project' and connect characteristics in the same fact. "
f"Facts: {[f.fact for f in facts]}"
f"Facts: {[f['fact'] for f in facts]}"
)
@@ -727,7 +791,7 @@ Jamie: Congratulations! I'd love to read it.
llm_config = LLMConfig.for_memory()
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=transcript,
event_date=datetime(2024, 11, 13),
llm_config=llm_config,
@@ -737,26 +801,37 @@ Jamie: Congratulations! I'd love to read it.
assert len(facts) > 0, "Should extract at least one fact from the transcript"
# Check that we extracted meaningful content about AI research
all_facts_text = " ".join([f.fact.lower() for f in facts])
has_ai_content = any(term in all_facts_text for term in [
"ai", "safety", "interpretability", "research", "paper", "conference", "models"
])
assert has_ai_content, f"Should extract AI research content. Facts: {[f.fact for f in facts]}"
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. [{f['fact_type']}] {f['fact']}")
# Check fact type classification (flexible - may vary by LLM)
agent_facts = [f for f in facts if f.fact_type == "agent"]
experience_facts = [f for f in facts if f.fact_type == "experience"]
agent_facts = [f for f in facts if f["fact_type"] == "agent"]
# Accept either agent or experience facts as valid for first-person statements
first_person_facts = agent_facts + experience_facts
assert len(agent_facts) > 0, \
f"Should have at least one 'bank' fact when context identifies 'you (Marcus)'. " \
f"Got facts: {[f['fact'] + ' [' + f['fact_type'] + ']' for f in facts]}"
# If we have agent facts, verify they use first person
for agent_fact in agent_facts:
fact_text = agent_fact.fact
# Allow flexibility - fact may or may not start with "I"
if fact_text.startswith("I ") or " I " in fact_text:
pass # Good - uses first person
fact_text = agent_fact["fact"]
assert fact_text.startswith("I ") or " I " in fact_text, \
f"Agent facts must use first person ('I'). Got: {fact_text}"
third_person_pattern = r'\bMarcus\s+(said|worked|has|published|explained|believes|attended|completed)'
match = re.search(third_person_pattern, fact_text)
assert not match, \
f"Agent facts should use first person, not third person. " \
f"Found '{match.group()}' in: {fact_text}"
print(f"\n All {len(agent_facts)} agent facts use first person ('I')")
jamie_facts = [f for f in facts if "Jamie" in f["fact"] and "Jamie" == f["fact"].split()[0]]
if jamie_facts:
world_jamie_facts = [f for f in jamie_facts if f["fact_type"] == "world"]
assert len(world_jamie_facts) > 0, \
f"Jamie's statements should be 'world' facts. " \
f"Jamie facts: {[f['fact'] + ' [' + f['fact_type'] + ']' for f in jamie_facts]}"
print(f"\n Successfully classified {len(agent_facts)} agent facts and {len([f for f in facts if f['fact_type'] == 'world'])} world facts")
@pytest.mark.asyncio
async def test_agent_facts_without_explicit_context(self):
@@ -772,7 +847,7 @@ We presented our findings to the team yesterday.
llm_config = LLMConfig.for_memory()
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=text,
event_date=datetime(2024, 11, 13),
llm_config=llm_config,
@@ -782,9 +857,16 @@ We presented our findings to the team yesterday.
assert len(facts) > 0, "Should extract facts"
agent_facts = [f for f in facts if f.fact_type == "agent"]
agent_facts = [f for f in facts if f["fact_type"] == "agent"]
assert len(agent_facts) >= 0 # Just verify classification works
print(f"\n Extracted {len(facts)} total facts")
print(f"Agent facts: {len(agent_facts)}")
print(f"World facts: {len([f for f in facts if f['fact_type'] == 'world'])}")
if agent_facts:
print(f"\nAgent facts found:")
for f in agent_facts:
print(f" - {f['fact']}")
@pytest.mark.asyncio
async def test_speaker_attribution_predictions(self):
@@ -807,7 +889,7 @@ Jamie: [teasing] We'll see who's right, my Niners pick is solid.
llm_config = LLMConfig.for_memory()
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=transcript,
event_date=datetime(2024, 11, 14),
context=context,
@@ -817,22 +899,41 @@ Jamie: [teasing] We'll see who's right, my Niners pick is solid.
assert len(facts) > 0, "Should extract at least one fact"
# Check that predictions were extracted
all_facts_text = " ".join([f.fact.lower() for f in facts])
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. [{f['fact_type']}] {f['fact']}")
# Should capture at least some prediction content
has_prediction_content = any(term in all_facts_text for term in [
"rams", "niners", "49ers", "prediction", "win", "predict"
])
assert has_prediction_content, f"Should extract prediction content. Facts: {[f.fact for f in facts]}"
agent_facts = [f for f in facts if f["fact_type"] == "agent"]
jamie_facts = [f for f in facts if f["fact_type"] == "world" and "Jamie" in f["fact"]]
# Ideally, Marcus's prediction should be in agent facts, but we accept
# any reasonable extraction of the predictions
agent_facts = [f for f in facts if f.fact_type == "agent"]
if agent_facts:
agent_facts_text = " ".join([f.fact.lower() for f in agent_facts])
# If agent facts exist, they should relate to Marcus's statements
# (but we don't fail if classification varies)
print(f"\nAgent facts (Marcus): {len(agent_facts)}")
for f in agent_facts:
print(f" - {f['fact']}")
print(f"\nWorld facts (Jamie): {len(jamie_facts)}")
for f in jamie_facts:
print(f" - {f['fact']}")
agent_facts_text = " ".join([f["fact"].lower() for f in agent_facts])
assert "rams" in agent_facts_text or "twenty seven to twenty four" in agent_facts_text or "27" in agent_facts_text, \
f"Agent facts should contain Marcus's Rams prediction. Agent facts: {[f['fact'] for f in agent_facts]}"
has_niners_27_13 = False
for fact in agent_facts:
fact_lower = fact["fact"].lower()
if ("niners" in fact_lower or "49ers" in fact_lower) and ("27" in fact_lower or "twenty seven") and ("13" in fact_lower or "thirteen"):
has_niners_27_13 = True
print(f"\n ERROR: Found Jamie's Niners 27-13 prediction in agent facts: {fact['fact']}")
assert not has_niners_27_13, \
f"Agent facts should NOT contain Jamie's Niners 27-13 prediction! " \
f"Agent facts: {[f['fact'] for f in agent_facts]}"
if jamie_facts:
print(f"\n Jamie facts correctly classified as world facts")
print(f"\n Speaker attribution test passed: Predictions correctly attributed to their speakers")
@pytest.mark.asyncio
async def test_skip_podcast_meta_commentary(self):
@@ -866,7 +967,7 @@ so the algorithm learns to box out. See you next week!
llm_config = LLMConfig.for_memory()
facts, _ = await extract_facts_from_text(
facts = await extract_facts_from_text(
text=transcript,
event_date=datetime(2024, 11, 13),
llm_config=llm_config,
@@ -874,177 +975,194 @@ so the algorithm learns to box out. See you next week!
context=context
)
print(f"\nExtracted {len(facts)} facts:")
for i, f in enumerate(facts):
print(f"{i+1}. [{f['fact_type']}] {f['fact']}")
assert len(facts) > 0, "Should extract at least one fact"
# The main goal is to extract substantive content about AI research
# Meta-commentary filtering is ideal but not strictly required
all_facts_text = " ".join([f.fact.lower() for f in facts])
meta_phrases = [
"subscribe",
"leave a rating",
"tap follow",
"tell a friend",
"that's gonna do it",
"thanks for listening",
"see you next week",
"welcome everyone",
"before we dive in"
]
# Should extract the actual AI research content
has_substantive_content = any(term in all_facts_text for term in [
"interpretability", "ai", "safety", "research", "models", "decisions"
])
assert has_substantive_content, \
f"Should extract substantive AI research content. Facts: {[f.fact for f in facts]}"
for fact in facts:
fact_lower = fact["fact"].lower()
for phrase in meta_phrases:
assert phrase not in fact_lower, \
f"Fact should not contain meta-commentary phrase '{phrase}'. " \
f"Found in: {fact['fact']}"
content_facts = [f for f in facts if "interpretability" in f["fact"].lower()]
assert len(content_facts) > 0, \
"Should extract facts about the actual content discussed (interpretability)"
print(f"\n Successfully filtered out meta-commentary")
print(f" Extracted {len(content_facts)} facts about actual content")
# =============================================================================
# DISPOSITION INFERENCE TESTS
# PERSONALITY INFERENCE TESTS
# =============================================================================
class TestDispositionInference:
"""Tests for LLM-based disposition trait inference from background."""
class TestPersonalityInference:
"""Tests for LLM-based personality trait inference from background."""
@pytest.mark.asyncio
async def test_background_merge_with_disposition_inference(self, memory):
"""Test that background merge infers disposition traits by default."""
async def test_background_merge_with_personality_inference(self, memory):
"""Test that background merge infers personality traits by default."""
import uuid
bank_id = f"test_infer_{uuid.uuid4().hex[:8]}"
result = await memory.merge_bank_background(
bank_id,
"I am a creative software engineer who loves innovation and trying new technologies",
update_disposition=True
update_personality=True
)
assert "background" in result
assert "disposition" in result
assert "personality" in result
background = result["background"]
disposition = result["disposition"]
personality = result["personality"]
assert "creative" in background.lower() or "innovation" in background.lower()
# Check that new traits are present with valid values (1-5)
required_traits = ["skepticism", "literalism", "empathy"]
assert "openness" in personality
assert personality["openness"] > 0.5
assert 0.0 <= personality["openness"] <= 1.0
required_traits = ["openness", "conscientiousness", "extraversion",
"agreeableness", "neuroticism", "bias_strength"]
for trait in required_traits:
assert trait in disposition
assert 1 <= disposition[trait] <= 5
assert trait in personality
assert 0.0 <= personality[trait] <= 1.0
@pytest.mark.asyncio
async def test_background_merge_without_disposition_inference(self, memory):
"""Test that background merge skips disposition inference when disabled."""
async def test_background_merge_without_personality_inference(self, memory):
"""Test that background merge skips personality inference when disabled."""
import uuid
bank_id = f"test_no_infer_{uuid.uuid4().hex[:8]}"
initial_profile = await memory.get_bank_profile(bank_id)
initial_disposition = initial_profile["disposition"]
initial_personality = initial_profile["personality"]
result = await memory.merge_bank_background(
bank_id,
"I am a data scientist",
update_disposition=False
update_personality=False
)
assert "background" in result
assert "disposition" not in result
assert "personality" not in result
final_profile = await memory.get_bank_profile(bank_id)
final_disposition = final_profile["disposition"]
final_personality = final_profile["personality"]
assert initial_disposition == final_disposition
assert initial_personality == final_personality
@pytest.mark.asyncio
async def test_disposition_inference_for_lawyer(self, memory):
"""Test disposition inference for lawyer profile (high skepticism, high literalism)."""
async def test_personality_inference_for_organized_engineer(self, memory):
"""Test personality inference for organized/conscientious profile."""
import uuid
bank_id = f"test_lawyer_{uuid.uuid4().hex[:8]}"
bank_id = f"test_organized_{uuid.uuid4().hex[:8]}"
result = await memory.merge_bank_background(
bank_id,
"I am a lawyer who focuses on contract details and never takes claims at face value",
update_disposition=True
"I am a methodical engineer who values organization and systematic planning",
update_personality=True
)
disposition = result["disposition"]
personality = result["personality"]
# Lawyers should have higher skepticism and literalism
assert disposition["skepticism"] >= 3
assert disposition["literalism"] >= 3
assert personality["conscientiousness"] > 0.5
@pytest.mark.asyncio
async def test_disposition_inference_for_therapist(self, memory):
"""Test disposition inference for therapist profile (high empathy)."""
async def test_personality_inference_for_startup_founder(self, memory):
"""Test personality inference for entrepreneurial profile."""
import uuid
bank_id = f"test_therapist_{uuid.uuid4().hex[:8]}"
bank_id = f"test_founder_{uuid.uuid4().hex[:8]}"
result = await memory.merge_bank_background(
bank_id,
"I am a therapist who deeply understands and connects with people's emotional struggles",
update_disposition=True
"I am a startup founder who thrives on risk and social interaction",
update_personality=True
)
disposition = result["disposition"]
personality = result["personality"]
# Therapists should have higher empathy
assert disposition["empathy"] >= 3
assert personality["openness"] > 0.5
assert personality["extraversion"] > 0.5
@pytest.mark.asyncio
async def test_disposition_updates_in_database(self, memory):
"""Test that inferred disposition is actually stored in database."""
async def test_personality_updates_in_database(self, memory):
"""Test that inferred personality is actually stored in database."""
import uuid
bank_id = f"test_db_update_{uuid.uuid4().hex[:8]}"
result = await memory.merge_bank_background(
bank_id,
"I am an innovative designer",
update_disposition=True
update_personality=True
)
inferred_disposition = result["disposition"]
inferred_personality = result["personality"]
profile = await memory.get_bank_profile(bank_id)
db_disposition = profile["disposition"]
db_personality = profile["personality"]
# Compare values (db_disposition is a Pydantic model)
assert db_disposition.skepticism == inferred_disposition["skepticism"]
assert db_disposition.literalism == inferred_disposition["literalism"]
assert db_disposition.empathy == inferred_disposition["empathy"]
assert db_personality == inferred_personality
@pytest.mark.asyncio
async def test_multiple_background_merges_update_disposition(self, memory):
"""Test that each background merge can update disposition."""
async def test_multiple_background_merges_update_personality(self, memory):
"""Test that each background merge can update personality."""
import uuid
bank_id = f"test_multi_merge_{uuid.uuid4().hex[:8]}"
result1 = await memory.merge_bank_background(
bank_id,
"I am a software engineer",
update_disposition=True
update_personality=True
)
disposition1 = result1["disposition"]
personality1 = result1["personality"]
result2 = await memory.merge_bank_background(
bank_id,
"I love creative problem solving and innovation",
update_disposition=True
update_personality=True
)
disposition2 = result2["disposition"]
personality2 = result2["personality"]
assert "engineer" in result2["background"].lower() or "software" in result2["background"].lower()
assert "creative" in result2["background"].lower() or "innovation" in result2["background"].lower()
@pytest.mark.asyncio
async def test_background_merge_conflict_resolution_with_disposition(self, memory):
"""Test that conflicts are resolved and disposition reflects final background."""
async def test_background_merge_conflict_resolution_with_personality(self, memory):
"""Test that conflicts are resolved and personality reflects final background."""
import uuid
bank_id = f"test_conflict_{uuid.uuid4().hex[:8]}"
await memory.merge_bank_background(
bank_id,
"I was born in Colorado and prefer stability",
update_disposition=True
update_personality=True
)
result = await memory.merge_bank_background(
bank_id,
"You were born in Texas and are very skeptical of people",
update_disposition=True
"You were born in Texas and love taking risks",
update_personality=True
)
background = result["background"]
disposition = result["disposition"]
personality = result["personality"]
assert "texas" in background.lower()
# Higher skepticism expected from "very skeptical of people"
assert disposition["skepticism"] >= 3
assert personality["openness"] > 0.5
+13 -10
View File
@@ -19,11 +19,14 @@ async def test_fact_ordering_within_conversation(memory):
# Get/create agent (auto-creates with defaults)
await memory.get_bank_profile(bank_id)
# Update disposition to match Marcus
await memory.update_bank_disposition(bank_id, {
"skepticism": 3,
"literalism": 3,
"empathy": 3
# Update personality to match Marcus
await memory.update_bank_personality(bank_id, {
"openness": 0.7,
"conscientiousness": 0.6,
"extraversion": 0.8,
"agreeableness": 0.5,
"neuroticism": 0.3,
"bias_strength": 0.5
})
# A conversation where Marcus changes his position
@@ -50,7 +53,7 @@ Marcus: Yeah, I realized I was being too optimistic about their defense.
results = await memory.recall_async(
bank_id=bank_id,
query="Marcus prediction Rams",
fact_type=['opinion', 'experience', 'world'],
fact_type=['bank', 'world'],
budget=Budget.LOW,
max_tokens=8192
)
@@ -59,8 +62,8 @@ Marcus: Yeah, I realized I was being too optimistic about their defense.
for i, result in enumerate(results.results):
print(f"{i+1}. [{result.mentioned_at}] {result.text[:100]}")
# Get all opinion facts (Marcus's predictions/statements)
agent_facts = [r for r in results.results if r.fact_type == 'opinion']
# Get all agent facts (Marcus's statements)
agent_facts = [r for r in results.results if r.fact_type == 'bank']
print(f"\n=== Agent facts (Marcus's statements) ===")
for i, fact in enumerate(agent_facts):
@@ -153,13 +156,13 @@ Alice: I reconsidered the team's experience level.
results = await memory.recall_async(
bank_id=bank_id,
query="Alice preference React Vue",
fact_type=['opinion', 'experience'],
fact_type=['bank'],
budget=Budget.LOW,
max_tokens=8192
)
print(f"\n=== Retrieved {len(results.results)} agent facts ===")
agent_facts = [r for r in results.results if r.fact_type in ('opinion', 'experience')]
agent_facts = [r for r in results.results if r.fact_type == 'bank']
for i, fact in enumerate(agent_facts):
print(f"{i+1}. [{fact.mentioned_at}] {fact.text[:80]}")
@@ -13,8 +13,8 @@ from hindsight_api.api import create_app
@pytest_asyncio.fixture
async def api_client(memory):
"""Create an async test client for the FastAPI app."""
# Memory is already initialized by the conftest fixture (with migrations)
app = create_app(memory, initialize_memory=False)
# Memory is already initialized by the conftest fixture
app = create_app(memory, run_migrations=False, initialize_memory=False)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
yield client
@@ -54,13 +54,15 @@ async def test_full_api_workflow(api_client, test_bank_id):
assert response.status_code == 200
initial_banks_data = response.json()["banks"]
initial_banks = [a["bank_id"] for a in initial_banks_data]
print(f"Initial banks: {len(initial_banks)}")
# Get bank profile (creates default if not exists)
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
assert response.status_code == 200
profile = response.json()
assert "disposition" in profile
assert "personality" in profile
assert "background" in profile
print(f"Bank profile created with personality: {profile['personality']}")
# Add background
response = await api_client.post(
@@ -71,6 +73,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
)
assert response.status_code == 200
assert "software engineer" in response.json()["background"].lower()
print("Background added")
# ================================================================
# 2. Memory Storage
@@ -92,6 +95,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
put_result = response.json()
assert put_result["success"] is True
assert put_result["items_count"] == 1
print(f"Stored memory via batch endpoint")
# Store batch memories
response = await api_client.post(
@@ -113,6 +117,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
batch_result = response.json()
assert batch_result["success"] is True
assert batch_result["items_count"] == 2
print(f"Stored {batch_result['items_count']} items from batch put")
# ================================================================
# 3. Recall (Search)
@@ -130,6 +135,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
search_results = response.json()
assert "results" in search_results
assert len(search_results["results"]) > 0
print(f"Search returned {len(search_results['results'])} results")
# Verify we found Alice
found_alice = any("Alice" in r["text"] for r in search_results["results"])
@@ -153,6 +159,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
assert "text" in reflect_result
assert len(reflect_result["text"]) > 0
assert "based_on" in reflect_result
print(f"Reflect response: {reflect_result['text'][:100]}...")
# Verify the answer mentions team members
answer = reflect_result["text"].lower()
@@ -168,6 +175,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
graph_data = response.json()
assert "nodes" in graph_data
assert "edges" in graph_data
print(f"Graph has {len(graph_data['nodes'])} nodes and {len(graph_data['edges'])} edges")
# Get memory statistics
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/stats")
@@ -175,6 +183,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
stats = response.json()
assert "total_nodes" in stats
assert stats["total_nodes"] > 0
print(f"Total nodes: {stats['total_nodes']}")
# List memory units
response = await api_client.get(
@@ -185,6 +194,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
memory_units = response.json()
assert "items" in memory_units
assert len(memory_units["items"]) > 0
print(f"Listed {len(memory_units['items'])} memory units")
# ================================================================
# 6. Document Tracking
@@ -197,13 +207,14 @@ async def test_full_api_workflow(api_client, test_bank_id):
"items": [
{
"content": "Project timeline: MVP launch in Q1, Beta in Q2.",
"context": "product roadmap",
"document_id": "roadmap-2024-q1"
"context": "product roadmap"
}
]
],
"document_id": "roadmap-2024-q1"
}
)
assert response.status_code == 200
print("Stored memory with document tracking")
# List documents
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/documents")
@@ -211,6 +222,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
documents = response.json()
assert "items" in documents
assert len(documents["items"]) > 0
print(f"Tracked documents: {len(documents['items'])}")
# Get specific document
response = await api_client.get(
@@ -221,30 +233,36 @@ async def test_full_api_workflow(api_client, test_bank_id):
assert "id" in doc_info
assert doc_info["id"] == "roadmap-2024-q1"
assert doc_info["memory_unit_count"] > 0
print(f"Document has {doc_info['memory_unit_count']} memory units")
# Note: Document deletion is tested separately in test_document_deletion
# ================================================================
# 7. Update and Verify Bank Disposition
# 7. Update and Verify Bank Personality
# ================================================================
# Update disposition traits
# Update personality traits
response = await api_client.put(
f"/v1/default/banks/{test_bank_id}/profile",
json={
"disposition": {
"skepticism": 4,
"literalism": 3,
"empathy": 4
"personality": {
"openness": 0.8,
"conscientiousness": 0.7,
"extraversion": 0.6,
"agreeableness": 0.9,
"neuroticism": 0.3,
"bias_strength": 0.5
}
}
)
assert response.status_code == 200
print("Personality updated")
# Check profile again (should have updated disposition)
# Check profile again (should have updated personality)
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/profile")
assert response.status_code == 200
updated_profile = response.json()
assert "software engineer" in updated_profile["background"].lower()
print("Profile verified")
# ================================================================
# 8. Test Entity Endpoints
@@ -255,6 +273,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
assert response.status_code == 200
entities_data = response.json()
assert "items" in entities_data
print(f"Found {len(entities_data['items'])} entities")
# Get specific entity if any exist
if len(entities_data['items']) > 0:
@@ -265,12 +284,14 @@ async def test_full_api_workflow(api_client, test_bank_id):
assert response.status_code == 200
entity_detail = response.json()
assert "id" in entity_detail
print(f"Retrieved entity: {entity_detail.get('name', entity_id)}")
# Test regenerate observations
response = await api_client.post(
f"/v1/default/banks/{test_bank_id}/entities/{entity_id}/regenerate"
)
assert response.status_code == 200
print(f"Regenerated observations for entity {entity_id}")
# ================================================================
# 9. List All Banks (should include our test bank)
@@ -282,6 +303,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
final_banks = [a["bank_id"] for a in final_banks_data]
assert test_bank_id in final_banks
assert len(final_banks) >= len(initial_banks) + 1
print(f"Final bank count: {len(final_banks)}")
# ================================================================
# 10. Clean Up
@@ -289,6 +311,7 @@ async def test_full_api_workflow(api_client, test_bank_id):
# Note: No delete bank endpoint in API, so test data remains in DB
# Using timestamped bank IDs prevents conflicts between test runs
print(f"Integration test complete for bank {test_bank_id}")
@pytest.mark.asyncio
@@ -325,6 +348,8 @@ async def test_error_handling(api_client):
)
assert response.status_code == 404
print("Error handling tests passed")
@pytest.mark.asyncio
async def test_concurrent_requests(api_client):
@@ -367,6 +392,8 @@ async def test_concurrent_requests(api_client):
items = response.json()["items"]
assert len(items) >= 5
print(f"Concurrent test stored {len(items)} memory units")
@pytest.mark.asyncio
async def test_document_deletion(api_client):
@@ -380,13 +407,14 @@ async def test_document_deletion(api_client):
"items": [
{
"content": "The quarterly sales report shows a 25% increase in revenue.",
"context": "Q1 financial review",
"document_id": "sales-report-q1-2024"
"context": "Q1 financial review"
}
]
],
"document_id": "sales-report-q1-2024"
}
)
assert response.status_code == 200
print("Created document with memory units")
# Verify document exists
response = await api_client.get(
@@ -396,6 +424,7 @@ async def test_document_deletion(api_client):
doc_info = response.json()
initial_units = doc_info["memory_unit_count"]
assert initial_units > 0
print(f"Document has {initial_units} memory units")
# Delete the document
response = await api_client.delete(
@@ -406,12 +435,14 @@ async def test_document_deletion(api_client):
assert delete_result["success"] is True
assert delete_result["document_id"] == "sales-report-q1-2024"
assert delete_result["memory_units_deleted"] == initial_units
print(f"Successfully deleted document and {delete_result['memory_units_deleted']} memory units")
# Verify document is gone (should return 404)
response = await api_client.get(
f"/v1/default/banks/{test_bank_id}/documents/sales-report-q1-2024"
)
assert response.status_code == 404
print("Document deletion verified - returns 404")
# Verify document is not in the list
response = await api_client.get(f"/v1/default/banks/{test_bank_id}/documents")
@@ -419,9 +450,11 @@ async def test_document_deletion(api_client):
documents = response.json()
doc_ids = [doc["id"] for doc in documents["items"]]
assert "sales-report-q1-2024" not in doc_ids
print("Document not in list - verified")
# Try to delete again (should return 404)
response = await api_client.delete(
f"/v1/default/banks/{test_bank_id}/documents/sales-report-q1-2024"
)
assert response.status_code == 404
print("Double delete returns 404 - verified")
@@ -17,11 +17,12 @@ from hindsight_api.api import create_app
@pytest_asyncio.fixture
async def mcp_server(memory):
"""Start the FastAPI app with MCP enabled and return the SSE URL."""
# Memory is already initialized by the conftest fixture (with migrations)
app = create_app(
memory,
run_migrations=False,
initialize_memory=False,
mcp_api_enabled=True
mcp_enabled=True,
default_agent_id="test_mcp_agent"
)
# Use httpx to create a test server
+67 -225
View File
@@ -9,39 +9,32 @@ from datetime import datetime, timezone
@pytest.mark.asyncio
async def test_observation_generation_on_put(memory):
"""
Test that observations are generated SYNCHRONOUSLY when new facts are added.
Test that observations are generated when new facts are added.
Observations are generated during retain when:
- Entity has >= 5 facts (MIN_FACTS_THRESHOLD)
- Entity is in top 5 by mention count
This test stores enough facts to trigger automatic observation generation.
1. Store facts about an entity
2. Wait for background tasks (observation generation)
3. Verify observations were created and linked to the entity
"""
bank_id = f"test_obs_{datetime.now(timezone.utc).timestamp()}"
try:
# Store multiple facts about John to reach the MIN_FACTS_THRESHOLD (5)
# Each retain call should extract at least one fact about John
contents = [
"John is a software engineer at Google.",
"John is detail-oriented and methodical in his work.",
"John has been working on the AI team for 3 years.",
"John specializes in machine learning and deep learning.",
"John presented at the company conference last week.",
"John mentors junior engineers on the team.",
]
# Store some facts about an entity
await memory.retain_async(
bank_id=bank_id,
content="John is a software engineer at Google. He is detail-oriented and methodical.",
context="work info",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc)
)
for i, content in enumerate(contents):
await memory.retain_async(
bank_id=bank_id,
content=content,
context="work info",
event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc)
)
await memory.retain_async(
bank_id=bank_id,
content="John has been working on the AI team for 3 years. He specializes in machine learning.",
context="work info",
event_date=datetime(2024, 2, 1, tzinfo=timezone.utc)
)
# Observations are generated SYNCHRONOUSLY during retain,
# so they should be available immediately after retain completes.
# No need to wait for background tasks for observations.
# Wait for background tasks to complete (including observation generation)
await memory.wait_for_background_tasks()
# Find the John entity
pool = await memory._get_pool()
@@ -56,42 +49,32 @@ async def test_observation_generation_on_put(memory):
bank_id
)
# Also check the fact count for this entity
if entity_row:
fact_count = await conn.fetchval(
"""
SELECT COUNT(*) FROM unit_entities WHERE entity_id = $1
""",
entity_row['id']
)
print(f"\n=== Entity Facts ===")
print(f"Entity: {entity_row['canonical_name']} has {fact_count} linked facts")
if entity_row:
entity_id = str(entity_row['id'])
entity_name = entity_row['canonical_name']
print(f"\n=== Found Entity ===")
print(f"Entity: {entity_name} (id: {entity_id})")
assert entity_row is not None, "John entity should have been extracted"
# Get observations for the entity
observations = await memory.get_entity_observations(bank_id, entity_id, limit=10)
entity_id = str(entity_row['id'])
entity_name = entity_row['canonical_name']
print(f"\n=== Found Entity ===")
print(f"Entity: {entity_name} (id: {entity_id})")
print(f"\n=== Observations for {entity_name} ===")
print(f"Total observations: {len(observations)}")
for obs in observations:
print(f" - {obs.text}")
# Get observations for the entity - should be available immediately
observations = await memory.get_entity_observations(bank_id, entity_id, limit=10)
# Verify observations were created
if len(observations) > 0:
print(f"✓ Observations were successfully generated")
# Check that observations mention relevant content
obs_texts = " ".join([o.text.lower() for o in observations])
assert any(keyword in obs_texts for keyword in ["google", "engineer", "ai", "machine learning", "detail"]), \
"Observations should contain relevant information about John"
else:
print(f"⚠ Note: No observations were generated (this can happen if LLM extraction varies)")
print(f"\n=== Observations for {entity_name} ===")
print(f"Total observations: {len(observations)}")
for obs in observations:
print(f" - {obs.text}")
# Verify observations were created (requires >= 5 facts)
assert len(observations) > 0, \
f"Observations should have been generated synchronously during retain (entity has {fact_count} facts, threshold is 5)"
# Check that observations mention relevant content
obs_texts = " ".join([o.text.lower() for o in observations])
assert any(keyword in obs_texts for keyword in ["google", "engineer", "ai", "machine learning", "detail"]), \
"Observations should contain relevant information about John"
print(f"✓ Observations were successfully generated synchronously during retain")
else:
print(f"⚠ Note: No 'John' entity was extracted (LLM extraction may vary)")
finally:
# Cleanup
@@ -173,40 +156,34 @@ async def test_regenerate_entity_observations(memory):
async def test_search_with_include_entities(memory):
"""
Test that search with include_entities=True returns entity observations.
This test verifies that:
1. Observations are generated during retain (when entity has >= 5 facts)
2. Observations are returned in recall results with include_entities=True
"""
bank_id = f"test_search_ent_{datetime.now(timezone.utc).timestamp()}"
try:
# Store enough facts about Alice to trigger observation generation (>= 5 facts)
contents = [
"Alice is a data scientist who works on recommendation systems at Netflix.",
"Alice presented her research at the ML conference last month.",
"Alice is an expert in deep learning and neural networks.",
"Alice graduated from Stanford with a PhD in Computer Science.",
"Alice leads a team of 5 data scientists at Netflix.",
"Alice published a paper on collaborative filtering algorithms.",
]
# Store facts about entities
await memory.retain_async(
bank_id=bank_id,
content="Alice is a data scientist who works on recommendation systems at Netflix.",
context="work info",
event_date=datetime(2024, 1, 15, tzinfo=timezone.utc)
)
for i, content in enumerate(contents):
await memory.retain_async(
bank_id=bank_id,
content=content,
context="work info",
event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc)
)
await memory.retain_async(
bank_id=bank_id,
content="Alice presented her research at the ML conference last month. She is an expert in deep learning.",
context="work info",
event_date=datetime(2024, 2, 1, tzinfo=timezone.utc)
)
# Observations are generated synchronously during retain, no need to wait
# Wait for background tasks
await memory.wait_for_background_tasks()
# Search with include_entities=True
result = await memory.recall_async(
bank_id=bank_id,
query="What does Alice do?",
fact_type=["world", "experience"],
budget=Budget.LOW,
fact_type=["world", "agent"],
budget=Budget.LOW, # 30,
max_tokens=2000,
include_entities=True,
max_entity_tokens=500
@@ -219,7 +196,7 @@ async def test_search_with_include_entities(memory):
if fact.entities:
print(f" Entities: {', '.join(fact.entities)}")
print(f"\n=== Entity Observations in Recall ===")
print(f"\n=== Entity Observations ===")
if result.entities:
for name, state in result.entities.items():
print(f"\n{name}:")
@@ -233,26 +210,15 @@ async def test_search_with_include_entities(memory):
# Check if entities are included in facts
facts_with_entities = [f for f in result.results if f.entities]
assert len(facts_with_entities) > 0, "Some facts should have entity information"
print(f"{len(facts_with_entities)} facts have entity information")
if facts_with_entities:
print(f"{len(facts_with_entities)} facts have entity information")
# Check if entity observations are included in recall
assert result.entities is not None and len(result.entities) > 0, \
"Entity observations should be included in recall results"
print(f"✓ Entity observations included for {len(result.entities)} entities")
# Verify Alice entity has observations
alice_found = False
for name, state in result.entities.items():
assert state.canonical_name == name, "Entity canonical_name should match key"
assert state.entity_id, "Entity should have an ID"
if "alice" in name.lower():
alice_found = True
assert len(state.observations) > 0, \
"Alice should have observations (generated during retain)"
print(f"✓ Alice has {len(state.observations)} observations in recall result")
assert alice_found, "Alice entity should be in recall results"
# Check if entity observations are included
if result.entities:
print(f"Entity observations included for {len(result.entities)} entities")
for name, state in result.entities.items():
assert state.canonical_name == name, "Entity canonical_name should match key"
assert state.entity_id, "Entity should have an ID"
finally:
# Cleanup
@@ -371,127 +337,3 @@ async def test_observation_fact_type_in_database(memory):
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
@pytest.mark.asyncio
async def test_user_entity_prioritized_for_observations(memory):
"""
Test that the 'user' entity gets observations even when many other entities exist.
The retain pipeline only regenerates observations for TOP_N_ENTITIES (5) entities,
sorted by mention count. This test verifies that the most mentioned entity ('user')
gets prioritized and receives observations.
This is critical because 'user' is often the most important entity in personal memory.
"""
bank_id = f"test_user_priority_{datetime.now(timezone.utc).timestamp()}"
try:
# Create content where 'user' (the user) is mentioned many times
# along with several other entities
contents = [
# User mentioned frequently
"The user loves hiking in the mountains during summer.",
"The user works as a software engineer at Microsoft.",
"The user has a dog named Max who is a golden retriever.",
"The user enjoys cooking Italian food, especially pasta.",
"The user graduated from MIT with a Computer Science degree.",
"The user's favorite book is 'Dune' by Frank Herbert.",
# Other entities mentioned fewer times
"Sarah is a friend who works at Google.",
"Bob is a colleague from the data science team.",
"Tokyo is a city the user visited last year.",
"Python is the user's favorite programming language.",
]
# Retain all content in a single batch for efficiency
for i, content in enumerate(contents):
await memory.retain_async(
bank_id=bank_id,
content=content,
context="personal info",
event_date=datetime(2024, 1, 15 + i, tzinfo=timezone.utc)
)
# Observations are generated synchronously during retain
# Find the 'user' entity
pool = await memory._get_pool()
async with pool.acquire() as conn:
# Find user entity (may be named "user", "the user", etc.)
user_entity = await conn.fetchrow(
"""
SELECT e.id, e.canonical_name,
(SELECT COUNT(*) FROM unit_entities ue
JOIN memory_units mu ON ue.unit_id = mu.id
WHERE ue.entity_id = e.id AND mu.bank_id = $1) as fact_count
FROM entities e
WHERE e.bank_id = $1
AND LOWER(e.canonical_name) LIKE '%user%'
LIMIT 1
""",
bank_id
)
# Get all entities with their fact counts to verify prioritization
all_entities = await conn.fetch(
"""
SELECT e.id, e.canonical_name,
(SELECT COUNT(*) FROM unit_entities ue
JOIN memory_units mu ON ue.unit_id = mu.id
WHERE ue.entity_id = e.id AND mu.bank_id = $1) as fact_count
FROM entities e
WHERE e.bank_id = $1
ORDER BY fact_count DESC
""",
bank_id
)
print(f"\n=== Entities by Mention Count ===")
for entity in all_entities:
print(f" {entity['canonical_name']}: {entity['fact_count']} mentions")
# Verify user entity exists
assert user_entity is not None, "User entity should have been extracted"
user_entity_id = str(user_entity['id'])
user_entity_name = user_entity['canonical_name']
user_fact_count = user_entity['fact_count']
print(f"\n=== User Entity ===")
print(f"Entity: {user_entity_name} (id: {user_entity_id})")
print(f"Fact count: {user_fact_count}")
# Verify user has enough facts for observations (>= MIN_FACTS_THRESHOLD of 5)
assert user_fact_count >= 5, \
f"User entity should have at least 5 facts, but has {user_fact_count}"
# Get observations for user entity
observations = await memory.get_entity_observations(bank_id, user_entity_id, limit=10)
print(f"\n=== User Entity Observations ===")
print(f"Total observations: {len(observations)}")
for obs in observations:
print(f" - {obs.text}")
# Verify observations were generated for user (critical assertion)
assert len(observations) > 0, \
f"User entity should have observations (has {user_fact_count} facts, threshold is 5). " \
f"This may indicate that 'user' is not being prioritized in the top 5 entities by mention count."
# Verify observations mention relevant content about the user
obs_texts = " ".join([o.text.lower() for o in observations])
user_keywords = ["hiking", "software", "engineer", "dog", "max", "cooking",
"italian", "mit", "dune", "microsoft"]
matching_keywords = [k for k in user_keywords if k in obs_texts]
assert len(matching_keywords) > 0, \
f"Observations should contain relevant information about the user. Keywords found: {matching_keywords}"
print(f"✓ User entity was prioritized and received {len(observations)} observations")
print(f"✓ Observations contain relevant keywords: {matching_keywords}")
finally:
# Cleanup
pool = await memory._get_pool()
async with pool.acquire() as conn:
await conn.execute("DELETE FROM memory_units WHERE bank_id = $1", bank_id)
await conn.execute("DELETE FROM entities WHERE bank_id = $1", bank_id)
+26 -15
View File
@@ -1,13 +1,25 @@
"""Tests for temporal range support (occurred_start, occurred_end, mentioned_at)."""
import asyncio
import os
from datetime import datetime, timezone, timedelta
import pytest
from hindsight_api import MemoryEngine
from hindsight_api.engine.memory_engine import Budget
@pytest.mark.asyncio
async def test_temporal_ranges_are_written(memory):
async def test_temporal_ranges_are_written():
"""Test that occurred_start, occurred_end, and mentioned_at are actually written to database."""
# Initialize memory system
memory = MemoryEngine(
db_url=os.getenv("HINDSIGHT_API_DATABASE_URL", "postgresql://hindsight:hindsight_dev@localhost:5432/hindsight"),
memory_llm_provider=os.getenv("HINDSIGHT_API_LLM_PROVIDER", "groq"),
memory_llm_api_key=os.getenv("HINDSIGHT_API_LLM_API_KEY"),
memory_llm_model=os.getenv("HINDSIGHT_API_LLM_MODEL", "openai/gpt-oss-20b"),
)
await memory.initialize()
bank_id = "test_temporal_ranges"
# Clean up any existing data
@@ -93,26 +105,19 @@ async def test_temporal_ranges_are_written(memory):
print(f" occurred_start: {paris_fact['occurred_start']}")
print(f" occurred_end: {paris_fact['occurred_end']}")
# "In February 2024" is ambiguous - could be interpreted as:
# 1. A month-long period (Feb 1 - Feb 29) - ideal interpretation
# 2. A point event sometime in February - also valid
# We accept either interpretation as long as the dates are in February 2024
if paris_fact['occurred_start'] and paris_fact['occurred_end']:
time_diff_days = (paris_fact['occurred_end'] - paris_fact['occurred_start']).days
print(f" Duration: {time_diff_days} days")
# Verify the dates are in February 2024
assert paris_fact['occurred_start'].year == 2024, f"occurred_start should be 2024"
assert paris_fact['occurred_start'].month == 2, f"occurred_start should be in February"
else:
print(" Note: occurred_start/end not set (fact may not have been classified as event)")
# For "in February 2024", occurred_start should be ~Feb 1 and occurred_end should be ~Feb 28/29
# Check it spans at least 20 days (to account for variations)
time_diff_days = (paris_fact['occurred_end'] - paris_fact['occurred_start']).days
print(f" Duration: {time_diff_days} days")
assert time_diff_days >= 20, f"February should span at least 20 days, got {time_diff_days} days"
assert time_diff_days <= 31, f"February should not span more than 31 days, got {time_diff_days} days"
# Test search results also include temporal fields
print("\n=== Testing Search Results ===")
search_result = await memory.recall_async(
bank_id=bank_id,
query="pottery workshop",
fact_type=["world", "experience"],
fact_type=["event", "world"],
budget=Budget.LOW,
max_tokens=4096
)
@@ -133,3 +138,9 @@ async def test_temporal_ranges_are_written(memory):
# Clean up
await memory.delete_bank(bank_id)
await memory.close()
if __name__ == "__main__":
# Run tests
asyncio.run(test_temporal_ranges_are_written())
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hindsight-cli"
version = "0.1.0"
version = "0.0.18"
edition = "2021"
authors = ["Hindsight Team"]
description = "A beautiful CLI for Hindsight - semantic memory system"
+12 -3
View File
@@ -98,18 +98,18 @@ impl ApiClient {
let request = types::CreateBankRequest {
name: Some(name.to_string()),
background: None,
disposition: None,
personality: None,
};
let response = self.client.create_or_update_bank(agent_id, &request).await?;
Ok(response.into_inner())
})
}
pub fn add_background(&self, agent_id: &str, content: &str, update_disposition: bool, _verbose: bool) -> Result<types::BackgroundResponse> {
pub fn add_background(&self, agent_id: &str, content: &str, update_personality: bool, _verbose: bool) -> Result<types::BackgroundResponse> {
self.runtime.block_on(async {
let request = types::AddBackgroundRequest {
content: content.to_string(),
update_disposition,
update_personality,
};
let response = self.client.add_bank_background(agent_id, &request).await?;
Ok(response.into_inner())
@@ -235,12 +235,21 @@ impl ApiClient {
// Re-export types from the generated client for use in commands
pub use types::{
AddBackgroundRequest,
BackgroundResponse,
BankListItem,
BankProfileResponse,
CreateBankRequest,
DeleteResponse,
DocumentResponse,
ListDocumentsResponse,
MemoryItem,
PersonalityTraits,
RecallRequest,
RecallResponse,
RecallResult,
ReflectRequest,
ReflectResponse,
RetainRequest,
RetainResponse,
};
+11 -9
View File
@@ -198,11 +198,11 @@ pub fn update_background(
client: &ApiClient,
bank_id: &str,
content: &str,
no_update_disposition: bool,
no_update_personality: bool,
verbose: bool,
output_format: OutputFormat
) -> Result<()> {
let current_profile = if !no_update_disposition {
let current_profile = if !no_update_personality {
client.get_profile(bank_id, verbose).ok()
} else {
None
@@ -214,7 +214,7 @@ pub fn update_background(
None
};
let response = client.add_background(bank_id, content, !no_update_disposition, verbose);
let response = client.add_background(bank_id, content, !no_update_personality, verbose);
if let Some(sp) = spinner {
sp.finish_and_clear();
@@ -226,14 +226,16 @@ pub fn update_background(
ui::print_success("Background updated successfully");
println!("\n{}", profile.background);
if !no_update_disposition {
if !no_update_personality {
if let (Some(old_p), Some(new_p)) =
(current_profile.as_ref().map(|p| p.disposition.clone()), &profile.disposition)
(current_profile.as_ref().map(|p| p.personality.clone()), &profile.personality)
{
println!("\nDisposition changes:");
println!(" Skepticism: {}{}", old_p.skepticism, new_p.skepticism);
println!(" Literalism: {}{}", old_p.literalism, new_p.literalism);
println!(" Empathy: {}{}", old_p.empathy, new_p.empathy);
println!("\nPersonality changes:");
println!(" Openness: {:.2}{:.2}", old_p.openness, new_p.openness);
println!(" Conscientiousness: {:.2}{:.2}", old_p.conscientiousness, new_p.conscientiousness);
println!(" Extraversion: {:.2}{:.2}", old_p.extraversion, new_p.extraversion);
println!(" Agreeableness: {:.2}{:.2}", old_p.agreeableness, new_p.agreeableness);
println!(" Neuroticism: {:.2}{:.2}", old_p.neuroticism, new_p.neuroticism);
}
}
} else {
+3
View File
@@ -307,6 +307,7 @@ impl App {
max_tokens: self.query_max_tokens,
trace: false,
query_timestamp: None,
filters: None,
include: None,
};
@@ -325,6 +326,7 @@ impl App {
query: self.query_text.clone(),
budget: Some(self.query_budget.clone()),
context: None,
filters: None,
include: None,
};
@@ -593,6 +595,7 @@ impl App {
}
}
}
_ => {}
}
Ok(())
}
+2
View File
@@ -58,6 +58,7 @@ pub fn recall(
max_tokens,
trace,
query_timestamp: None,
filters: None,
include,
};
@@ -99,6 +100,7 @@ pub fn reflect(
query,
budget: Some(parse_budget(&budget)),
context,
filters: None,
include: None,
};
+7 -7
View File
@@ -100,7 +100,7 @@ enum BankCommands {
/// List all banks
List,
/// Get bank profile (disposition + background)
/// Get bank profile (personality + background)
Profile {
/// Bank ID
bank_id: String,
@@ -129,9 +129,9 @@ enum BankCommands {
/// Background content
content: String,
/// Skip automatic disposition inference
/// Skip automatic personality inference
#[arg(long)]
no_update_disposition: bool,
no_update_personality: bool,
},
}
@@ -145,8 +145,8 @@ enum MemoryCommands {
/// Search query
query: String,
/// Fact types to search (world, experience, opinion)
#[arg(short = 't', long, value_delimiter = ',', default_values = &["world", "experience", "opinion"])]
/// Fact types to search (world, bank, opinion)
#[arg(short = 't', long, value_delimiter = ',', default_values = &["world", "bank", "opinion"])]
fact_type: Vec<String>,
/// Thinking budget (low, mid, high)
@@ -381,8 +381,8 @@ fn run() -> Result<()> {
BankCommands::Profile { bank_id } => commands::bank::profile(&client, &bank_id, verbose, output_format),
BankCommands::Stats { bank_id } => commands::bank::stats(&client, &bank_id, verbose, output_format),
BankCommands::Name { bank_id, name } => commands::bank::update_name(&client, &bank_id, &name, verbose, output_format),
BankCommands::Background { bank_id, content, no_update_disposition } => {
commands::bank::update_background(&client, &bank_id, &content, no_update_disposition, verbose, output_format)
BankCommands::Background { bank_id, content, no_update_personality } => {
commands::bank::update_background(&client, &bank_id, &content, no_update_personality, verbose, output_format)
}
},
+26 -13
View File
@@ -205,21 +205,21 @@ pub fn print_profile(profile: &BankProfileResponse) {
println!();
}
// Print disposition traits
println!("{}", "─── Disposition Traits ───".bright_yellow());
// Print personality traits
println!("{}", "─── Personality Traits ───".bright_yellow());
println!();
// New 3-trait disposition system (values 1-5)
let traits: [(_, i64, _, _, _); 3] = [
("Skepticism", profile.disposition.skepticism.get() as i64, "🔍", "cyan", "1=trusting, 5=skeptical"),
("Literalism", profile.disposition.literalism.get() as i64, "📋", "yellow", "1=flexible, 5=literal"),
("Empathy", profile.disposition.empathy.get() as i64, "💚", "green", "1=detached, 5=empathetic"),
let traits = [
("Openness", profile.personality.openness, "🔓", "green"),
("Conscientiousness", profile.personality.conscientiousness, "📋", "yellow"),
("Extraversion", profile.personality.extraversion, "🗣️", "cyan"),
("Agreeableness", profile.personality.agreeableness, "🤝", "magenta"),
("Neuroticism", profile.personality.neuroticism, "😰", "yellow"),
];
for (name, value, emoji, color, desc) in &traits {
// Scale 1-5 to bar visualization (each point = 8 chars, total 40)
for (name, value, emoji, color) in &traits {
let bar_length = 40;
let filled = ((*value - 1) * 10) as usize; // 1->0, 2->10, 3->20, 4->30, 5->40
let filled = (*value * bar_length as f64) as usize;
let empty = bar_length - filled;
let bar = format!("{}{}", "".repeat(filled), "".repeat(empty));
@@ -231,14 +231,27 @@ pub fn print_profile(profile: &BankProfileResponse) {
_ => bar.bright_white(),
};
println!(" {} {:<12} [{}] {}/5",
println!(" {} {:<20} [{}] {:.0}%",
emoji,
name,
colored_bar,
value
value * 100.0
);
println!(" {}", desc.bright_black());
}
println!();
println!("{}", "Bias Strength:".bright_yellow());
let bias = profile.personality.bias_strength;
let bar_length = 40;
let filled = (bias * bar_length as f64) as usize;
let empty = bar_length - filled;
let bar = format!("{}{}", "".repeat(filled), "".repeat(empty));
println!(" 💪 {:<20} [{}] {:.0}%",
"Personality Influence",
bar.bright_green(),
bias * 100.0
);
println!(" {}", "(how much personality shapes opinions)".bright_black());
println!();
}
@@ -17,7 +17,6 @@ hindsight_client_api/docs/ChunkResponse.md
hindsight_client_api/docs/CreateBankRequest.md
hindsight_client_api/docs/DefaultApi.md
hindsight_client_api/docs/DeleteResponse.md
hindsight_client_api/docs/DispositionTraits.md
hindsight_client_api/docs/DocumentResponse.md
hindsight_client_api/docs/EntityDetailResponse.md
hindsight_client_api/docs/EntityIncludeOptions.md
@@ -31,7 +30,9 @@ hindsight_client_api/docs/IncludeOptions.md
hindsight_client_api/docs/ListDocumentsResponse.md
hindsight_client_api/docs/ListMemoryUnitsResponse.md
hindsight_client_api/docs/MemoryItem.md
hindsight_client_api/docs/MetadataFilter.md
hindsight_client_api/docs/MonitoringApi.md
hindsight_client_api/docs/PersonalityTraits.md
hindsight_client_api/docs/RecallRequest.md
hindsight_client_api/docs/RecallResponse.md
hindsight_client_api/docs/RecallResult.md
@@ -41,7 +42,7 @@ hindsight_client_api/docs/ReflectRequest.md
hindsight_client_api/docs/ReflectResponse.md
hindsight_client_api/docs/RetainRequest.md
hindsight_client_api/docs/RetainResponse.md
hindsight_client_api/docs/UpdateDispositionRequest.md
hindsight_client_api/docs/UpdatePersonalityRequest.md
hindsight_client_api/docs/ValidationError.md
hindsight_client_api/docs/ValidationErrorLocInner.md
hindsight_client_api/exceptions.py
@@ -57,7 +58,6 @@ hindsight_client_api/models/chunk_include_options.py
hindsight_client_api/models/chunk_response.py
hindsight_client_api/models/create_bank_request.py
hindsight_client_api/models/delete_response.py
hindsight_client_api/models/disposition_traits.py
hindsight_client_api/models/document_response.py
hindsight_client_api/models/entity_detail_response.py
hindsight_client_api/models/entity_include_options.py
@@ -71,6 +71,8 @@ hindsight_client_api/models/include_options.py
hindsight_client_api/models/list_documents_response.py
hindsight_client_api/models/list_memory_units_response.py
hindsight_client_api/models/memory_item.py
hindsight_client_api/models/metadata_filter.py
hindsight_client_api/models/personality_traits.py
hindsight_client_api/models/recall_request.py
hindsight_client_api/models/recall_response.py
hindsight_client_api/models/recall_result.py
@@ -80,7 +82,7 @@ hindsight_client_api/models/reflect_request.py
hindsight_client_api/models/reflect_response.py
hindsight_client_api/models/retain_request.py
hindsight_client_api/models/retain_response.py
hindsight_client_api/models/update_disposition_request.py
hindsight_client_api/models/update_personality_request.py
hindsight_client_api/models/validation_error.py
hindsight_client_api/models/validation_error_loc_inner.py
hindsight_client_api/rest.py
@@ -97,7 +99,6 @@ hindsight_client_api/test/test_chunk_response.py
hindsight_client_api/test/test_create_bank_request.py
hindsight_client_api/test/test_default_api.py
hindsight_client_api/test/test_delete_response.py
hindsight_client_api/test/test_disposition_traits.py
hindsight_client_api/test/test_document_response.py
hindsight_client_api/test/test_entity_detail_response.py
hindsight_client_api/test/test_entity_include_options.py
@@ -111,7 +112,9 @@ hindsight_client_api/test/test_include_options.py
hindsight_client_api/test/test_list_documents_response.py
hindsight_client_api/test/test_list_memory_units_response.py
hindsight_client_api/test/test_memory_item.py
hindsight_client_api/test/test_metadata_filter.py
hindsight_client_api/test/test_monitoring_api.py
hindsight_client_api/test/test_personality_traits.py
hindsight_client_api/test/test_recall_request.py
hindsight_client_api/test/test_recall_response.py
hindsight_client_api/test/test_recall_result.py
@@ -121,7 +124,7 @@ hindsight_client_api/test/test_reflect_request.py
hindsight_client_api/test/test_reflect_response.py
hindsight_client_api/test/test_retain_request.py
hindsight_client_api/test/test_retain_response.py
hindsight_client_api/test/test_update_disposition_request.py
hindsight_client_api/test/test_update_personality_request.py
hindsight_client_api/test/test_validation_error.py
hindsight_client_api/test/test_validation_error_loc_inner.py
hindsight_client_api_README.md
+39 -1
View File
@@ -1 +1,39 @@
# Hindsight Python Client
# Hindsight Python Client
Python client library for the Hindsight API.
## Installation
```bash
pip install hindsight-client
```
## Usage
```python
from hindsight_client import Hindsight
client = Hindsight(base_url="http://localhost:8888")
# Retain information
client.retain(
bank_id="my-bank",
content="Alice works at Google in Mountain View."
)
# Recall memories
results = client.recall(
bank_id="my-bank",
query="Where does Alice work?"
)
# Reflect and get an opinion
response = client.reflect(
bank_id="my-bank",
query="What do you think about Alice's career?"
)
```
## Documentation
For full documentation, visit [hindsight](https://github.com/vectorize-io/hindsight).
@@ -35,7 +35,7 @@ from hindsight_client_api.models.reflect_response import ReflectResponse
from hindsight_client_api.models.reflect_fact import ReflectFact
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
from hindsight_client_api.models.disposition_traits import DispositionTraits
from hindsight_client_api.models.personality_traits import PersonalityTraits
__all__ = [
"Hindsight",
@@ -47,5 +47,5 @@ __all__ = [
"ReflectFact",
"ListMemoryUnitsResponse",
"BankProfileResponse",
"DispositionTraits",
"PersonalityTraits",
]
@@ -164,7 +164,7 @@ class Hindsight:
Args:
bank_id: The memory bank ID
query: Search query
types: Optional list of fact types to filter (world, experience, opinion, observation)
types: Optional list of fact types to filter (world, agent, opinion, observation)
max_tokens: Maximum tokens in results (default: 4096)
budget: Budget level for recall - "low", "mid", or "high" (default: "mid")
@@ -229,7 +229,7 @@ class Hindsight:
Args:
bank_id: The memory bank ID
query: Search query
types: Optional list of fact types to filter (world, experience, opinion, observation)
types: Optional list of fact types to filter (world, agent, opinion, observation)
budget: Budget level - "low", "mid", or "high"
max_tokens: Maximum tokens in results
trace: Enable trace output
@@ -280,19 +280,19 @@ class Hindsight:
bank_id: str,
name: Optional[str] = None,
background: Optional[str] = None,
disposition: Optional[Dict[str, float]] = None,
personality: Optional[Dict[str, float]] = None,
) -> BankProfileResponse:
"""Create or update a memory bank."""
from hindsight_client_api.models import create_bank_request, disposition_traits
from hindsight_client_api.models import create_bank_request, personality_traits
disposition_obj = None
if disposition:
disposition_obj = disposition_traits.DispositionTraits(**disposition)
personality_obj = None
if personality:
personality_obj = personality_traits.PersonalityTraits(**personality)
request_obj = create_bank_request.CreateBankRequest(
name=name,
background=background,
disposition=disposition_obj,
personality=personality_obj,
)
return _run_async(self._api.create_or_update_bank(bank_id, request_obj))
@@ -379,7 +379,7 @@ class Hindsight:
Args:
bank_id: The memory bank ID
query: Search query
types: Optional list of fact types to filter (world, experience, opinion, observation)
types: Optional list of fact types to filter (world, agent, opinion, observation)
max_tokens: Maximum tokens in results (default: 4096)
budget: Budget level for recall - "low", "mid", or "high" (default: "mid")
@@ -40,7 +40,6 @@ __all__ = [
"ChunkResponse",
"CreateBankRequest",
"DeleteResponse",
"DispositionTraits",
"DocumentResponse",
"EntityDetailResponse",
"EntityIncludeOptions",
@@ -54,6 +53,8 @@ __all__ = [
"ListDocumentsResponse",
"ListMemoryUnitsResponse",
"MemoryItem",
"MetadataFilter",
"PersonalityTraits",
"RecallRequest",
"RecallResponse",
"RecallResult",
@@ -63,7 +64,7 @@ __all__ = [
"ReflectResponse",
"RetainRequest",
"RetainResponse",
"UpdateDispositionRequest",
"UpdatePersonalityRequest",
"ValidationError",
"ValidationErrorLocInner",
]
@@ -95,7 +96,6 @@ from hindsight_client_api.models.chunk_include_options import ChunkIncludeOption
from hindsight_client_api.models.chunk_response import ChunkResponse as ChunkResponse
from hindsight_client_api.models.create_bank_request import CreateBankRequest as CreateBankRequest
from hindsight_client_api.models.delete_response import DeleteResponse as DeleteResponse
from hindsight_client_api.models.disposition_traits import DispositionTraits as DispositionTraits
from hindsight_client_api.models.document_response import DocumentResponse as DocumentResponse
from hindsight_client_api.models.entity_detail_response import EntityDetailResponse as EntityDetailResponse
from hindsight_client_api.models.entity_include_options import EntityIncludeOptions as EntityIncludeOptions
@@ -109,6 +109,8 @@ from hindsight_client_api.models.include_options import IncludeOptions as Includ
from hindsight_client_api.models.list_documents_response import ListDocumentsResponse as ListDocumentsResponse
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse as ListMemoryUnitsResponse
from hindsight_client_api.models.memory_item import MemoryItem as MemoryItem
from hindsight_client_api.models.metadata_filter import MetadataFilter as MetadataFilter
from hindsight_client_api.models.personality_traits import PersonalityTraits as PersonalityTraits
from hindsight_client_api.models.recall_request import RecallRequest as RecallRequest
from hindsight_client_api.models.recall_response import RecallResponse as RecallResponse
from hindsight_client_api.models.recall_result import RecallResult as RecallResult
@@ -118,7 +120,7 @@ from hindsight_client_api.models.reflect_request import ReflectRequest as Reflec
from hindsight_client_api.models.reflect_response import ReflectResponse as ReflectResponse
from hindsight_client_api.models.retain_request import RetainRequest as RetainRequest
from hindsight_client_api.models.retain_response import RetainResponse as RetainResponse
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest as UpdateDispositionRequest
from hindsight_client_api.models.update_personality_request import UpdatePersonalityRequest as UpdatePersonalityRequest
from hindsight_client_api.models.validation_error import ValidationError as ValidationError
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner as ValidationErrorLocInner
@@ -38,7 +38,7 @@ from hindsight_client_api.models.reflect_request import ReflectRequest
from hindsight_client_api.models.reflect_response import ReflectResponse
from hindsight_client_api.models.retain_request import RetainRequest
from hindsight_client_api.models.retain_response import RetainResponse
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
from hindsight_client_api.models.update_personality_request import UpdatePersonalityRequest
from hindsight_client_api.api_client import ApiClient, RequestSerialized
from hindsight_client_api.api_response import ApiResponse
@@ -78,7 +78,7 @@ class DefaultApi:
) -> BackgroundResponse:
"""Add/merge memory bank background
Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits.
Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers personality traits.
:param bank_id: (required)
:type bank_id: str
@@ -150,7 +150,7 @@ class DefaultApi:
) -> ApiResponse[BackgroundResponse]:
"""Add/merge memory bank background
Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits.
Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers personality traits.
:param bank_id: (required)
:type bank_id: str
@@ -222,7 +222,7 @@ class DefaultApi:
) -> RESTResponseType:
"""Add/merge memory bank background
Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits.
Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers personality traits.
:param bank_id: (required)
:type bank_id: str
@@ -631,7 +631,7 @@ class DefaultApi:
async def clear_bank_memories(
self,
bank_id: StrictStr,
type: Annotated[Optional[StrictStr], Field(description="Optional fact type filter (world, experience, opinion)")] = None,
type: Annotated[Optional[StrictStr], Field(description="Optional fact type filter (world, agent, opinion)")] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
@@ -647,11 +647,11 @@ class DefaultApi:
) -> DeleteResponse:
"""Clear memory bank memories
Delete memory units for a memory bank. Optionally filter by type (world, experience, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved.
Delete memory units for a memory bank. Optionally filter by type (world, agent, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (personality and background) will be preserved.
:param bank_id: (required)
:type bank_id: str
:param type: Optional fact type filter (world, experience, opinion)
:param type: Optional fact type filter (world, agent, opinion)
:type type: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
@@ -703,7 +703,7 @@ class DefaultApi:
async def clear_bank_memories_with_http_info(
self,
bank_id: StrictStr,
type: Annotated[Optional[StrictStr], Field(description="Optional fact type filter (world, experience, opinion)")] = None,
type: Annotated[Optional[StrictStr], Field(description="Optional fact type filter (world, agent, opinion)")] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
@@ -719,11 +719,11 @@ class DefaultApi:
) -> ApiResponse[DeleteResponse]:
"""Clear memory bank memories
Delete memory units for a memory bank. Optionally filter by type (world, experience, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved.
Delete memory units for a memory bank. Optionally filter by type (world, agent, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (personality and background) will be preserved.
:param bank_id: (required)
:type bank_id: str
:param type: Optional fact type filter (world, experience, opinion)
:param type: Optional fact type filter (world, agent, opinion)
:type type: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
@@ -775,7 +775,7 @@ class DefaultApi:
async def clear_bank_memories_without_preload_content(
self,
bank_id: StrictStr,
type: Annotated[Optional[StrictStr], Field(description="Optional fact type filter (world, experience, opinion)")] = None,
type: Annotated[Optional[StrictStr], Field(description="Optional fact type filter (world, agent, opinion)")] = None,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
@@ -791,11 +791,11 @@ class DefaultApi:
) -> RESTResponseType:
"""Clear memory bank memories
Delete memory units for a memory bank. Optionally filter by type (world, experience, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved.
Delete memory units for a memory bank. Optionally filter by type (world, agent, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (personality and background) will be preserved.
:param bank_id: (required)
:type bank_id: str
:param type: Optional fact type filter (world, experience, opinion)
:param type: Optional fact type filter (world, agent, opinion)
:type type: str
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
@@ -927,7 +927,7 @@ class DefaultApi:
) -> BankProfileResponse:
"""Create or update memory bank
Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults.
Create a new agent or update existing agent with personality and background. Auto-fills missing fields with defaults.
:param bank_id: (required)
:type bank_id: str
@@ -999,7 +999,7 @@ class DefaultApi:
) -> ApiResponse[BankProfileResponse]:
"""Create or update memory bank
Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults.
Create a new agent or update existing agent with personality and background. Auto-fills missing fields with defaults.
:param bank_id: (required)
:type bank_id: str
@@ -1071,7 +1071,7 @@ class DefaultApi:
) -> RESTResponseType:
"""Create or update memory bank
Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults.
Create a new agent or update existing agent with personality and background. Auto-fills missing fields with defaults.
:param bank_id: (required)
:type bank_id: str
@@ -1758,7 +1758,7 @@ class DefaultApi:
) -> BankProfileResponse:
"""Get memory bank profile
Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists.
Get personality traits and background for a memory bank. Auto-creates agent with defaults if not exists.
:param bank_id: (required)
:type bank_id: str
@@ -1826,7 +1826,7 @@ class DefaultApi:
) -> ApiResponse[BankProfileResponse]:
"""Get memory bank profile
Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists.
Get personality traits and background for a memory bank. Auto-creates agent with defaults if not exists.
:param bank_id: (required)
:type bank_id: str
@@ -1894,7 +1894,7 @@ class DefaultApi:
) -> RESTResponseType:
"""Get memory bank profile
Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists.
Get personality traits and background for a memory bank. Auto-creates agent with defaults if not exists.
:param bank_id: (required)
:type bank_id: str
@@ -2841,7 +2841,7 @@ class DefaultApi:
) -> GraphDataResponse:
"""Get memory graph data
Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). Limited to 1000 most recent items.
Retrieve graph data for visualization, optionally filtered by type (world/agent/opinion). Limited to 1000 most recent items.
:param bank_id: (required)
:type bank_id: str
@@ -2913,7 +2913,7 @@ class DefaultApi:
) -> ApiResponse[GraphDataResponse]:
"""Get memory graph data
Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). Limited to 1000 most recent items.
Retrieve graph data for visualization, optionally filtered by type (world/agent/opinion). Limited to 1000 most recent items.
:param bank_id: (required)
:type bank_id: str
@@ -2985,7 +2985,7 @@ class DefaultApi:
) -> RESTResponseType:
"""Get memory graph data
Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). Limited to 1000 most recent items.
Retrieve graph data for visualization, optionally filtered by type (world/agent/opinion). Limited to 1000 most recent items.
:param bank_id: (required)
:type bank_id: str
@@ -4554,7 +4554,7 @@ class DefaultApi:
) -> RecallResponse:
"""Recall memory
Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - 'world': General knowledge about people, places, events, and things that happen - 'experience': Memories about experience, conversations, actions taken, and tasks performed - 'opinion': The bank's formed beliefs, perspectives, and viewpoints Set include_entities=true to get entity observations alongside recall results.
Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - 'world': General knowledge about people, places, events, and things that happen - 'agent': Memories about what the AI agent did, actions taken, and tasks performed - 'opinion': The bank's formed beliefs, perspectives, and viewpoints - 'observation': Synthesized observations about entities (generated automatically) Set include_entities=true to get entity observations alongside recall results.
:param bank_id: (required)
:type bank_id: str
@@ -4626,7 +4626,7 @@ class DefaultApi:
) -> ApiResponse[RecallResponse]:
"""Recall memory
Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - 'world': General knowledge about people, places, events, and things that happen - 'experience': Memories about experience, conversations, actions taken, and tasks performed - 'opinion': The bank's formed beliefs, perspectives, and viewpoints Set include_entities=true to get entity observations alongside recall results.
Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - 'world': General knowledge about people, places, events, and things that happen - 'agent': Memories about what the AI agent did, actions taken, and tasks performed - 'opinion': The bank's formed beliefs, perspectives, and viewpoints - 'observation': Synthesized observations about entities (generated automatically) Set include_entities=true to get entity observations alongside recall results.
:param bank_id: (required)
:type bank_id: str
@@ -4698,7 +4698,7 @@ class DefaultApi:
) -> RESTResponseType:
"""Recall memory
Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - 'world': General knowledge about people, places, events, and things that happen - 'experience': Memories about experience, conversations, actions taken, and tasks performed - 'opinion': The bank's formed beliefs, perspectives, and viewpoints Set include_entities=true to get entity observations alongside recall results.
Recall memory using semantic similarity and spreading activation. The type parameter is optional and must be one of: - 'world': General knowledge about people, places, events, and things that happen - 'agent': Memories about what the AI agent did, actions taken, and tasks performed - 'opinion': The bank's formed beliefs, perspectives, and viewpoints - 'observation': Synthesized observations about entities (generated automatically) Set include_entities=true to get entity observations alongside recall results.
:param bank_id: (required)
:type bank_id: str
@@ -4845,7 +4845,7 @@ class DefaultApi:
) -> ReflectResponse:
"""Reflect and generate answer
Reflect and formulate an answer using bank identity, world facts, and opinions. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query 3. Retrieves existing opinions (bank's perspectives) 4. Uses LLM to formulate a contextual answer 5. Extracts and stores any new opinions formed 6. Returns plain text answer, the facts used, and new opinions
Reflect and formulate an answer using bank identity, world facts, and opinions. This endpoint: 1. Retrieves agent facts (bank's identity) 2. Retrieves world facts relevant to the query 3. Retrieves existing opinions (bank's perspectives) 4. Uses LLM to formulate a contextual answer 5. Extracts and stores any new opinions formed 6. Returns plain text answer, the facts used, and new opinions
:param bank_id: (required)
:type bank_id: str
@@ -4917,7 +4917,7 @@ class DefaultApi:
) -> ApiResponse[ReflectResponse]:
"""Reflect and generate answer
Reflect and formulate an answer using bank identity, world facts, and opinions. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query 3. Retrieves existing opinions (bank's perspectives) 4. Uses LLM to formulate a contextual answer 5. Extracts and stores any new opinions formed 6. Returns plain text answer, the facts used, and new opinions
Reflect and formulate an answer using bank identity, world facts, and opinions. This endpoint: 1. Retrieves agent facts (bank's identity) 2. Retrieves world facts relevant to the query 3. Retrieves existing opinions (bank's perspectives) 4. Uses LLM to formulate a contextual answer 5. Extracts and stores any new opinions formed 6. Returns plain text answer, the facts used, and new opinions
:param bank_id: (required)
:type bank_id: str
@@ -4989,7 +4989,7 @@ class DefaultApi:
) -> RESTResponseType:
"""Reflect and generate answer
Reflect and formulate an answer using bank identity, world facts, and opinions. This endpoint: 1. Retrieves experience (conversations and events) 2. Retrieves world facts relevant to the query 3. Retrieves existing opinions (bank's perspectives) 4. Uses LLM to formulate a contextual answer 5. Extracts and stores any new opinions formed 6. Returns plain text answer, the facts used, and new opinions
Reflect and formulate an answer using bank identity, world facts, and opinions. This endpoint: 1. Retrieves agent facts (bank's identity) 2. Retrieves world facts relevant to the query 3. Retrieves existing opinions (bank's perspectives) 4. Uses LLM to formulate a contextual answer 5. Extracts and stores any new opinions formed 6. Returns plain text answer, the facts used, and new opinions
:param bank_id: (required)
:type bank_id: str
@@ -5686,10 +5686,10 @@ class DefaultApi:
@validate_call
async def update_bank_disposition(
async def update_bank_personality(
self,
bank_id: StrictStr,
update_disposition_request: UpdateDispositionRequest,
update_personality_request: UpdatePersonalityRequest,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
@@ -5703,14 +5703,14 @@ class DefaultApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> BankProfileResponse:
"""Update memory bank disposition
"""Update memory bank personality
Update bank's disposition traits (skepticism, literalism, empathy)
Update bank's Big Five personality traits and bias strength
:param bank_id: (required)
:type bank_id: str
:param update_disposition_request: (required)
:type update_disposition_request: UpdateDispositionRequest
:param update_personality_request: (required)
:type update_personality_request: UpdatePersonalityRequest
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
@@ -5733,9 +5733,9 @@ class DefaultApi:
:return: Returns the result object.
""" # noqa: E501
_param = self._update_bank_disposition_serialize(
_param = self._update_bank_personality_serialize(
bank_id=bank_id,
update_disposition_request=update_disposition_request,
update_personality_request=update_personality_request,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
@@ -5758,10 +5758,10 @@ class DefaultApi:
@validate_call
async def update_bank_disposition_with_http_info(
async def update_bank_personality_with_http_info(
self,
bank_id: StrictStr,
update_disposition_request: UpdateDispositionRequest,
update_personality_request: UpdatePersonalityRequest,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
@@ -5775,14 +5775,14 @@ class DefaultApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[BankProfileResponse]:
"""Update memory bank disposition
"""Update memory bank personality
Update bank's disposition traits (skepticism, literalism, empathy)
Update bank's Big Five personality traits and bias strength
:param bank_id: (required)
:type bank_id: str
:param update_disposition_request: (required)
:type update_disposition_request: UpdateDispositionRequest
:param update_personality_request: (required)
:type update_personality_request: UpdatePersonalityRequest
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
@@ -5805,9 +5805,9 @@ class DefaultApi:
:return: Returns the result object.
""" # noqa: E501
_param = self._update_bank_disposition_serialize(
_param = self._update_bank_personality_serialize(
bank_id=bank_id,
update_disposition_request=update_disposition_request,
update_personality_request=update_personality_request,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
@@ -5830,10 +5830,10 @@ class DefaultApi:
@validate_call
async def update_bank_disposition_without_preload_content(
async def update_bank_personality_without_preload_content(
self,
bank_id: StrictStr,
update_disposition_request: UpdateDispositionRequest,
update_personality_request: UpdatePersonalityRequest,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
@@ -5847,14 +5847,14 @@ class DefaultApi:
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Update memory bank disposition
"""Update memory bank personality
Update bank's disposition traits (skepticism, literalism, empathy)
Update bank's Big Five personality traits and bias strength
:param bank_id: (required)
:type bank_id: str
:param update_disposition_request: (required)
:type update_disposition_request: UpdateDispositionRequest
:param update_personality_request: (required)
:type update_personality_request: UpdatePersonalityRequest
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
@@ -5877,9 +5877,9 @@ class DefaultApi:
:return: Returns the result object.
""" # noqa: E501
_param = self._update_bank_disposition_serialize(
_param = self._update_bank_personality_serialize(
bank_id=bank_id,
update_disposition_request=update_disposition_request,
update_personality_request=update_personality_request,
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
@@ -5897,10 +5897,10 @@ class DefaultApi:
return response_data.response
def _update_bank_disposition_serialize(
def _update_bank_personality_serialize(
self,
bank_id,
update_disposition_request,
update_personality_request,
_request_auth,
_content_type,
_headers,
@@ -5928,8 +5928,8 @@ class DefaultApi:
# process the header parameters
# process the form parameters
# process the body parameter
if update_disposition_request is not None:
_body_params = update_disposition_request
if update_personality_request is not None:
_body_params = update_personality_request
# set the HTTP header `Accept`
@@ -36,251 +36,6 @@ class MonitoringApi:
self.api_client = api_client
@validate_call
async def health_endpoint_health_get(
self,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> object:
"""Health check endpoint
Checks the health of the API and database connection
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._health_endpoint_health_get_serialize(
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "object",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
).data
@validate_call
async def health_endpoint_health_get_with_http_info(
self,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> ApiResponse[object]:
"""Health check endpoint
Checks the health of the API and database connection
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._health_endpoint_health_get_serialize(
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "object",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
await response_data.read()
return self.api_client.response_deserialize(
response_data=response_data,
response_types_map=_response_types_map,
)
@validate_call
async def health_endpoint_health_get_without_preload_content(
self,
_request_timeout: Union[
None,
Annotated[StrictFloat, Field(gt=0)],
Tuple[
Annotated[StrictFloat, Field(gt=0)],
Annotated[StrictFloat, Field(gt=0)]
]
] = None,
_request_auth: Optional[Dict[StrictStr, Any]] = None,
_content_type: Optional[StrictStr] = None,
_headers: Optional[Dict[StrictStr, Any]] = None,
_host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0,
) -> RESTResponseType:
"""Health check endpoint
Checks the health of the API and database connection
:param _request_timeout: timeout setting for this request. If one
number provided, it will be total request
timeout. It can also be a pair (tuple) of
(connection, read) timeouts.
:type _request_timeout: int, tuple(int, int), optional
:param _request_auth: set to override the auth_settings for an a single
request; this effectively ignores the
authentication in the spec for a single request.
:type _request_auth: dict, optional
:param _content_type: force content-type for the request.
:type _content_type: str, Optional
:param _headers: set to override the headers for a single
request; this effectively ignores the headers
in the spec for a single request.
:type _headers: dict, optional
:param _host_index: set to override the host_index for a single
request; this effectively ignores the host_index
in the spec for a single request.
:type _host_index: int, optional
:return: Returns the result object.
""" # noqa: E501
_param = self._health_endpoint_health_get_serialize(
_request_auth=_request_auth,
_content_type=_content_type,
_headers=_headers,
_host_index=_host_index
)
_response_types_map: Dict[str, Optional[str]] = {
'200': "object",
}
response_data = await self.api_client.call_api(
*_param,
_request_timeout=_request_timeout
)
return response_data.response
def _health_endpoint_health_get_serialize(
self,
_request_auth,
_content_type,
_headers,
_host_index,
) -> RequestSerialized:
_host = None
_collection_formats: Dict[str, str] = {
}
_path_params: Dict[str, str] = {}
_query_params: List[Tuple[str, str]] = []
_header_params: Dict[str, Optional[str]] = _headers or {}
_form_params: List[Tuple[str, str]] = []
_files: Dict[
str, Union[str, bytes, List[str], List[bytes], List[Tuple[str, bytes]]]
] = {}
_body_params: Optional[bytes] = None
# process the path parameters
# process the query parameters
# process the header parameters
# process the form parameters
# process the body parameter
# set the HTTP header `Accept`
if 'Accept' not in _header_params:
_header_params['Accept'] = self.api_client.select_header_accept(
[
'application/json'
]
)
# authentication setting
_auth_settings: List[str] = [
]
return self.api_client.param_serialize(
method='GET',
resource_path='/health',
path_params=_path_params,
query_params=_query_params,
header_params=_header_params,
body=_body_params,
post_params=_form_params,
files=_files,
auth_settings=_auth_settings,
collection_formats=_collection_formats,
_host=_host,
_request_auth=_request_auth
)
@validate_call
async def metrics_endpoint_metrics_get(
self,
@@ -7,7 +7,7 @@ Request model for adding/merging background information.
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**content** | **str** | New background information to add or merge |
**update_disposition** | **bool** | If true, infer disposition traits from the merged background (default: true) | [optional] [default to True]
**update_personality** | **bool** | If true, infer Big Five personality traits from the merged background (default: true) | [optional] [default to True]
## Example
@@ -7,7 +7,7 @@ Response model for background update.
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**background** | **str** | |
**disposition** | [**DispositionTraits**](DispositionTraits.md) | | [optional]
**personality** | [**PersonalityTraits**](PersonalityTraits.md) | | [optional]
## Example
@@ -8,7 +8,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**bank_id** | **str** | |
**name** | **str** | |
**disposition** | [**DispositionTraits**](DispositionTraits.md) | |
**personality** | [**PersonalityTraits**](PersonalityTraits.md) | |
**background** | **str** | |
**created_at** | **str** | | [optional]
**updated_at** | **str** | | [optional]
@@ -8,7 +8,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**bank_id** | **str** | |
**name** | **str** | |
**disposition** | [**DispositionTraits**](DispositionTraits.md) | |
**personality** | [**PersonalityTraits**](PersonalityTraits.md) | |
**background** | **str** | |
## Example
@@ -7,7 +7,7 @@ Request model for creating/updating a bank.
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**name** | **str** | | [optional]
**disposition** | [**DispositionTraits**](DispositionTraits.md) | | [optional]
**personality** | [**PersonalityTraits**](PersonalityTraits.md) | | [optional]
**background** | **str** | | [optional]
## Example
@@ -24,7 +24,7 @@ Method | HTTP request | Description
[**reflect**](DefaultApi.md#reflect) | **POST** /v1/default/banks/{bank_id}/reflect | Reflect and generate answer
[**regenerate_entity_observations**](DefaultApi.md#regenerate_entity_observations) | **POST** /v1/default/banks/{bank_id}/entities/{entity_id}/regenerate | Regenerate entity observations
[**retain_memories**](DefaultApi.md#retain_memories) | **POST** /v1/default/banks/{bank_id}/memories | Retain memories
[**update_bank_disposition**](DefaultApi.md#update_bank_disposition) | **PUT** /v1/default/banks/{bank_id}/profile | Update memory bank disposition
[**update_bank_personality**](DefaultApi.md#update_bank_personality) | **PUT** /v1/default/banks/{bank_id}/profile | Update memory bank personality
# **add_bank_background**
@@ -32,7 +32,7 @@ Method | HTTP request | Description
Add/merge memory bank background
Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers disposition traits.
Add new background information or merge with existing. LLM intelligently resolves conflicts, normalizes to first person, and optionally infers personality traits.
### Example
@@ -174,7 +174,7 @@ No authorization required
Clear memory bank memories
Delete memory units for a memory bank. Optionally filter by type (world, experience, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (disposition and background) will be preserved.
Delete memory units for a memory bank. Optionally filter by type (world, agent, opinion) to delete only specific types. This is a destructive operation that cannot be undone. The bank profile (personality and background) will be preserved.
### Example
@@ -197,7 +197,7 @@ async with hindsight_client_api.ApiClient(configuration) as api_client:
# Create an instance of the API class
api_instance = hindsight_client_api.DefaultApi(api_client)
bank_id = 'bank_id_example' # str |
type = 'type_example' # str | Optional fact type filter (world, experience, opinion) (optional)
type = 'type_example' # str | Optional fact type filter (world, agent, opinion) (optional)
try:
# Clear memory bank memories
@@ -216,7 +216,7 @@ async with hindsight_client_api.ApiClient(configuration) as api_client:
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**bank_id** | **str**| |
**type** | **str**| Optional fact type filter (world, experience, opinion) | [optional]
**type** | **str**| Optional fact type filter (world, agent, opinion) | [optional]
### Return type
@@ -245,7 +245,7 @@ No authorization required
Create or update memory bank
Create a new agent or update existing agent with disposition and background. Auto-fills missing fields with defaults.
Create a new agent or update existing agent with personality and background. Auto-fills missing fields with defaults.
### Example
@@ -462,7 +462,7 @@ No authorization required
Get memory bank profile
Get disposition traits and background for a memory bank. Auto-creates agent with defaults if not exists.
Get personality traits and background for a memory bank. Auto-creates agent with defaults if not exists.
### Example
@@ -742,7 +742,7 @@ No authorization required
Get memory graph data
Retrieve graph data for visualization, optionally filtered by type (world/experience/opinion). Limited to 1000 most recent items.
Retrieve graph data for visualization, optionally filtered by type (world/agent/opinion). Limited to 1000 most recent items.
### Example
@@ -1172,8 +1172,9 @@ Recall memory using semantic similarity and spreading activation.
The type parameter is optional and must be one of:
- 'world': General knowledge about people, places, events, and things that happen
- 'experience': Memories about experience, conversations, actions taken, and tasks performed
- 'agent': Memories about what the AI agent did, actions taken, and tasks performed
- 'opinion': The bank's formed beliefs, perspectives, and viewpoints
- 'observation': Synthesized observations about entities (generated automatically)
Set include_entities=true to get entity observations alongside recall results.
@@ -1250,7 +1251,7 @@ Reflect and generate answer
Reflect and formulate an answer using bank identity, world facts, and opinions.
This endpoint:
1. Retrieves experience (conversations and events)
1. Retrieves agent facts (bank's identity)
2. Retrieves world facts relevant to the query
3. Retrieves existing opinions (bank's perspectives)
4. Uses LLM to formulate a contextual answer
@@ -1494,12 +1495,12 @@ No authorization required
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **update_bank_disposition**
> BankProfileResponse update_bank_disposition(bank_id, update_disposition_request)
# **update_bank_personality**
> BankProfileResponse update_bank_personality(bank_id, update_personality_request)
Update memory bank disposition
Update memory bank personality
Update bank's disposition traits (skepticism, literalism, empathy)
Update bank's Big Five personality traits and bias strength
### Example
@@ -1507,7 +1508,7 @@ Update bank's disposition traits (skepticism, literalism, empathy)
```python
import hindsight_client_api
from hindsight_client_api.models.bank_profile_response import BankProfileResponse
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
from hindsight_client_api.models.update_personality_request import UpdatePersonalityRequest
from hindsight_client_api.rest import ApiException
from pprint import pprint
@@ -1523,15 +1524,15 @@ async with hindsight_client_api.ApiClient(configuration) as api_client:
# Create an instance of the API class
api_instance = hindsight_client_api.DefaultApi(api_client)
bank_id = 'bank_id_example' # str |
update_disposition_request = hindsight_client_api.UpdateDispositionRequest() # UpdateDispositionRequest |
update_personality_request = hindsight_client_api.UpdatePersonalityRequest() # UpdatePersonalityRequest |
try:
# Update memory bank disposition
api_response = await api_instance.update_bank_disposition(bank_id, update_disposition_request)
print("The response of DefaultApi->update_bank_disposition:\n")
# Update memory bank personality
api_response = await api_instance.update_bank_personality(bank_id, update_personality_request)
print("The response of DefaultApi->update_bank_personality:\n")
pprint(api_response)
except Exception as e:
print("Exception when calling DefaultApi->update_bank_disposition: %s\n" % e)
print("Exception when calling DefaultApi->update_bank_personality: %s\n" % e)
```
@@ -1542,7 +1543,7 @@ async with hindsight_client_api.ApiClient(configuration) as api_client:
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**bank_id** | **str**| |
**update_disposition_request** | [**UpdateDispositionRequest**](UpdateDispositionRequest.md)| |
**update_personality_request** | [**UpdatePersonalityRequest**](UpdatePersonalityRequest.md)| |
### Return type
@@ -1,32 +0,0 @@
# DispositionTraits
Disposition traits that influence how memories are formed and interpreted.
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**skepticism** | **int** | How skeptical vs trusting (1&#x3D;trusting, 5&#x3D;skeptical) |
**literalism** | **int** | How literally to interpret information (1&#x3D;flexible, 5&#x3D;literal) |
**empathy** | **int** | How much to consider emotional context (1&#x3D;detached, 5&#x3D;empathetic) |
## Example
```python
from hindsight_client_api.models.disposition_traits import DispositionTraits
# TODO update the JSON string below
json = "{}"
# create an instance of DispositionTraits from a JSON string
disposition_traits_instance = DispositionTraits.from_json(json)
# print the JSON string representation of the object
print(DispositionTraits.to_json())
# convert the object into a dict
disposition_traits_dict = disposition_traits_instance.to_dict()
# create an instance of DispositionTraits from a dict
disposition_traits_from_dict = DispositionTraits.from_dict(disposition_traits_dict)
```
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
@@ -0,0 +1,32 @@
# MetadataFilter
Filter for metadata fields. Matches records where (key=value) OR (key not set) when match_unset=True.
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**key** | **str** | Metadata key to filter on |
**value** | **str** | | [optional]
**match_unset** | **bool** | If True, also match records where this metadata key is not set | [optional] [default to True]
## Example
```python
from hindsight_client_api.models.metadata_filter import MetadataFilter
# TODO update the JSON string below
json = "{}"
# create an instance of MetadataFilter from a JSON string
metadata_filter_instance = MetadataFilter.from_json(json)
# print the JSON string representation of the object
print(MetadataFilter.to_json())
# convert the object into a dict
metadata_filter_dict = metadata_filter_instance.to_dict()
# create an instance of MetadataFilter from a dict
metadata_filter_from_dict = MetadataFilter.from_dict(metadata_filter_dict)
```
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
@@ -4,73 +4,9 @@ All URIs are relative to *http://localhost*
Method | HTTP request | Description
------------- | ------------- | -------------
[**health_endpoint_health_get**](MonitoringApi.md#health_endpoint_health_get) | **GET** /health | Health check endpoint
[**metrics_endpoint_metrics_get**](MonitoringApi.md#metrics_endpoint_metrics_get) | **GET** /metrics | Prometheus metrics endpoint
# **health_endpoint_health_get**
> object health_endpoint_health_get()
Health check endpoint
Checks the health of the API and database connection
### Example
```python
import hindsight_client_api
from hindsight_client_api.rest import ApiException
from pprint import pprint
# Defining the host is optional and defaults to http://localhost
# See configuration.py for a list of all supported configuration parameters.
configuration = hindsight_client_api.Configuration(
host = "http://localhost"
)
# Enter a context with an instance of the API client
async with hindsight_client_api.ApiClient(configuration) as api_client:
# Create an instance of the API class
api_instance = hindsight_client_api.MonitoringApi(api_client)
try:
# Health check endpoint
api_response = await api_instance.health_endpoint_health_get()
print("The response of MonitoringApi->health_endpoint_health_get:\n")
pprint(api_response)
except Exception as e:
print("Exception when calling MonitoringApi->health_endpoint_health_get: %s\n" % e)
```
### Parameters
This endpoint does not need any parameter.
### Return type
**object**
### Authorization
No authorization required
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
### HTTP response details
| Status code | Description | Response headers |
|-------------|-------------|------------------|
**200** | Successful Response | - |
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **metrics_endpoint_metrics_get**
> object metrics_endpoint_metrics_get()
@@ -0,0 +1,35 @@
# PersonalityTraits
Personality traits based on Big Five model.
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**openness** | **float** | Openness to experience (0-1) |
**conscientiousness** | **float** | Conscientiousness (0-1) |
**extraversion** | **float** | Extraversion (0-1) |
**agreeableness** | **float** | Agreeableness (0-1) |
**neuroticism** | **float** | Neuroticism (0-1) |
**bias_strength** | **float** | How strongly personality influences opinions (0-1) |
## Example
```python
from hindsight_client_api.models.personality_traits import PersonalityTraits
# TODO update the JSON string below
json = "{}"
# create an instance of PersonalityTraits from a JSON string
personality_traits_instance = PersonalityTraits.from_json(json)
# print the JSON string representation of the object
print(PersonalityTraits.to_json())
# convert the object into a dict
personality_traits_dict = personality_traits_instance.to_dict()
# create an instance of PersonalityTraits from a dict
personality_traits_from_dict = PersonalityTraits.from_dict(personality_traits_dict)
```
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
@@ -12,6 +12,7 @@ Name | Type | Description | Notes
**max_tokens** | **int** | | [optional] [default to 4096]
**trace** | **bool** | | [optional] [default to False]
**query_timestamp** | **str** | | [optional]
**filters** | [**List[MetadataFilter]**](MetadataFilter.md) | | [optional]
**include** | [**IncludeOptions**](IncludeOptions.md) | Options for including additional data (entities are included by default) | [optional]
## Example
@@ -9,6 +9,7 @@ Name | Type | Description | Notes
**query** | **str** | |
**budget** | [**Budget**](Budget.md) | | [optional]
**context** | **str** | | [optional]
**filters** | [**List[MetadataFilter]**](MetadataFilter.md) | | [optional]
**include** | [**ReflectIncludeOptions**](ReflectIncludeOptions.md) | Options for including additional data (disabled by default) | [optional]
## Example
@@ -1,30 +0,0 @@
# UpdateDispositionRequest
Request model for updating disposition traits.
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**disposition** | [**DispositionTraits**](DispositionTraits.md) | |
## Example
```python
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
# TODO update the JSON string below
json = "{}"
# create an instance of UpdateDispositionRequest from a JSON string
update_disposition_request_instance = UpdateDispositionRequest.from_json(json)
# print the JSON string representation of the object
print(UpdateDispositionRequest.to_json())
# convert the object into a dict
update_disposition_request_dict = update_disposition_request_instance.to_dict()
# create an instance of UpdateDispositionRequest from a dict
update_disposition_request_from_dict = UpdateDispositionRequest.from_dict(update_disposition_request_dict)
```
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
@@ -0,0 +1,30 @@
# UpdatePersonalityRequest
Request model for updating personality traits.
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**personality** | [**PersonalityTraits**](PersonalityTraits.md) | |
## Example
```python
from hindsight_client_api.models.update_personality_request import UpdatePersonalityRequest
# TODO update the JSON string below
json = "{}"
# create an instance of UpdatePersonalityRequest from a JSON string
update_personality_request_instance = UpdatePersonalityRequest.from_json(json)
# print the JSON string representation of the object
print(UpdatePersonalityRequest.to_json())
# convert the object into a dict
update_personality_request_dict = update_personality_request_instance.to_dict()
# create an instance of UpdatePersonalityRequest from a dict
update_personality_request_from_dict = UpdatePersonalityRequest.from_dict(update_personality_request_dict)
```
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
@@ -24,7 +24,6 @@ from hindsight_client_api.models.chunk_include_options import ChunkIncludeOption
from hindsight_client_api.models.chunk_response import ChunkResponse
from hindsight_client_api.models.create_bank_request import CreateBankRequest
from hindsight_client_api.models.delete_response import DeleteResponse
from hindsight_client_api.models.disposition_traits import DispositionTraits
from hindsight_client_api.models.document_response import DocumentResponse
from hindsight_client_api.models.entity_detail_response import EntityDetailResponse
from hindsight_client_api.models.entity_include_options import EntityIncludeOptions
@@ -38,6 +37,8 @@ from hindsight_client_api.models.include_options import IncludeOptions
from hindsight_client_api.models.list_documents_response import ListDocumentsResponse
from hindsight_client_api.models.list_memory_units_response import ListMemoryUnitsResponse
from hindsight_client_api.models.memory_item import MemoryItem
from hindsight_client_api.models.metadata_filter import MetadataFilter
from hindsight_client_api.models.personality_traits import PersonalityTraits
from hindsight_client_api.models.recall_request import RecallRequest
from hindsight_client_api.models.recall_response import RecallResponse
from hindsight_client_api.models.recall_result import RecallResult
@@ -47,7 +48,7 @@ from hindsight_client_api.models.reflect_request import ReflectRequest
from hindsight_client_api.models.reflect_response import ReflectResponse
from hindsight_client_api.models.retain_request import RetainRequest
from hindsight_client_api.models.retain_response import RetainResponse
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
from hindsight_client_api.models.update_personality_request import UpdatePersonalityRequest
from hindsight_client_api.models.validation_error import ValidationError
from hindsight_client_api.models.validation_error_loc_inner import ValidationErrorLocInner
@@ -27,8 +27,8 @@ class AddBackgroundRequest(BaseModel):
Request model for adding/merging background information.
""" # noqa: E501
content: StrictStr = Field(description="New background information to add or merge")
update_disposition: Optional[StrictBool] = Field(default=True, description="If true, infer disposition traits from the merged background (default: true)")
__properties: ClassVar[List[str]] = ["content", "update_disposition"]
update_personality: Optional[StrictBool] = Field(default=True, description="If true, infer Big Five personality traits from the merged background (default: true)")
__properties: ClassVar[List[str]] = ["content", "update_personality"]
model_config = ConfigDict(
populate_by_name=True,
@@ -82,7 +82,7 @@ class AddBackgroundRequest(BaseModel):
_obj = cls.model_validate({
"content": obj.get("content"),
"update_disposition": obj.get("update_disposition") if obj.get("update_disposition") is not None else True
"update_personality": obj.get("update_personality") if obj.get("update_personality") is not None else True
})
return _obj
@@ -19,7 +19,7 @@ import json
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.disposition_traits import DispositionTraits
from hindsight_client_api.models.personality_traits import PersonalityTraits
from typing import Optional, Set
from typing_extensions import Self
@@ -28,8 +28,8 @@ class BackgroundResponse(BaseModel):
Response model for background update.
""" # noqa: E501
background: StrictStr
disposition: Optional[DispositionTraits] = None
__properties: ClassVar[List[str]] = ["background", "disposition"]
personality: Optional[PersonalityTraits] = None
__properties: ClassVar[List[str]] = ["background", "personality"]
model_config = ConfigDict(
populate_by_name=True,
@@ -70,13 +70,13 @@ class BackgroundResponse(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of disposition
if self.disposition:
_dict['disposition'] = self.disposition.to_dict()
# set to None if disposition (nullable) is None
# override the default output from pydantic by calling `to_dict()` of personality
if self.personality:
_dict['personality'] = self.personality.to_dict()
# set to None if personality (nullable) is None
# and model_fields_set contains the field
if self.disposition is None and "disposition" in self.model_fields_set:
_dict['disposition'] = None
if self.personality is None and "personality" in self.model_fields_set:
_dict['personality'] = None
return _dict
@@ -91,7 +91,7 @@ class BackgroundResponse(BaseModel):
_obj = cls.model_validate({
"background": obj.get("background"),
"disposition": DispositionTraits.from_dict(obj["disposition"]) if obj.get("disposition") is not None else None
"personality": PersonalityTraits.from_dict(obj["personality"]) if obj.get("personality") is not None else None
})
return _obj
@@ -19,7 +19,7 @@ import json
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.disposition_traits import DispositionTraits
from hindsight_client_api.models.personality_traits import PersonalityTraits
from typing import Optional, Set
from typing_extensions import Self
@@ -29,11 +29,11 @@ class BankListItem(BaseModel):
""" # noqa: E501
bank_id: StrictStr
name: StrictStr
disposition: DispositionTraits
personality: PersonalityTraits
background: StrictStr
created_at: Optional[StrictStr] = None
updated_at: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["bank_id", "name", "disposition", "background", "created_at", "updated_at"]
__properties: ClassVar[List[str]] = ["bank_id", "name", "personality", "background", "created_at", "updated_at"]
model_config = ConfigDict(
populate_by_name=True,
@@ -74,9 +74,9 @@ class BankListItem(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of disposition
if self.disposition:
_dict['disposition'] = self.disposition.to_dict()
# override the default output from pydantic by calling `to_dict()` of personality
if self.personality:
_dict['personality'] = self.personality.to_dict()
# set to None if created_at (nullable) is None
# and model_fields_set contains the field
if self.created_at is None and "created_at" in self.model_fields_set:
@@ -101,7 +101,7 @@ class BankListItem(BaseModel):
_obj = cls.model_validate({
"bank_id": obj.get("bank_id"),
"name": obj.get("name"),
"disposition": DispositionTraits.from_dict(obj["disposition"]) if obj.get("disposition") is not None else None,
"personality": PersonalityTraits.from_dict(obj["personality"]) if obj.get("personality") is not None else None,
"background": obj.get("background"),
"created_at": obj.get("created_at"),
"updated_at": obj.get("updated_at")
@@ -19,7 +19,7 @@ import json
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List
from hindsight_client_api.models.disposition_traits import DispositionTraits
from hindsight_client_api.models.personality_traits import PersonalityTraits
from typing import Optional, Set
from typing_extensions import Self
@@ -29,9 +29,9 @@ class BankProfileResponse(BaseModel):
""" # noqa: E501
bank_id: StrictStr
name: StrictStr
disposition: DispositionTraits
personality: PersonalityTraits
background: StrictStr
__properties: ClassVar[List[str]] = ["bank_id", "name", "disposition", "background"]
__properties: ClassVar[List[str]] = ["bank_id", "name", "personality", "background"]
model_config = ConfigDict(
populate_by_name=True,
@@ -72,9 +72,9 @@ class BankProfileResponse(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of disposition
if self.disposition:
_dict['disposition'] = self.disposition.to_dict()
# override the default output from pydantic by calling `to_dict()` of personality
if self.personality:
_dict['personality'] = self.personality.to_dict()
return _dict
@classmethod
@@ -89,7 +89,7 @@ class BankProfileResponse(BaseModel):
_obj = cls.model_validate({
"bank_id": obj.get("bank_id"),
"name": obj.get("name"),
"disposition": DispositionTraits.from_dict(obj["disposition"]) if obj.get("disposition") is not None else None,
"personality": PersonalityTraits.from_dict(obj["personality"]) if obj.get("personality") is not None else None,
"background": obj.get("background")
})
return _obj
@@ -19,7 +19,7 @@ import json
from pydantic import BaseModel, ConfigDict, StrictStr
from typing import Any, ClassVar, Dict, List, Optional
from hindsight_client_api.models.disposition_traits import DispositionTraits
from hindsight_client_api.models.personality_traits import PersonalityTraits
from typing import Optional, Set
from typing_extensions import Self
@@ -28,9 +28,9 @@ class CreateBankRequest(BaseModel):
Request model for creating/updating a bank.
""" # noqa: E501
name: Optional[StrictStr] = None
disposition: Optional[DispositionTraits] = None
personality: Optional[PersonalityTraits] = None
background: Optional[StrictStr] = None
__properties: ClassVar[List[str]] = ["name", "disposition", "background"]
__properties: ClassVar[List[str]] = ["name", "personality", "background"]
model_config = ConfigDict(
populate_by_name=True,
@@ -71,18 +71,18 @@ class CreateBankRequest(BaseModel):
exclude=excluded_fields,
exclude_none=True,
)
# override the default output from pydantic by calling `to_dict()` of disposition
if self.disposition:
_dict['disposition'] = self.disposition.to_dict()
# override the default output from pydantic by calling `to_dict()` of personality
if self.personality:
_dict['personality'] = self.personality.to_dict()
# set to None if name (nullable) is None
# and model_fields_set contains the field
if self.name is None and "name" in self.model_fields_set:
_dict['name'] = None
# set to None if disposition (nullable) is None
# set to None if personality (nullable) is None
# and model_fields_set contains the field
if self.disposition is None and "disposition" in self.model_fields_set:
_dict['disposition'] = None
if self.personality is None and "personality" in self.model_fields_set:
_dict['personality'] = None
# set to None if background (nullable) is None
# and model_fields_set contains the field
@@ -102,7 +102,7 @@ class CreateBankRequest(BaseModel):
_obj = cls.model_validate({
"name": obj.get("name"),
"disposition": DispositionTraits.from_dict(obj["disposition"]) if obj.get("disposition") is not None else None,
"personality": PersonalityTraits.from_dict(obj["personality"]) if obj.get("personality") is not None else None,
"background": obj.get("background")
})
return _obj

Some files were not shown because too many files have changed in this diff Show More