Compare commits
5
Commits
ci-fix
..
fix-docker
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b899e5598f | ||
|
|
d9837e2ffb | ||
|
|
3c1c76cb94 | ||
|
|
c1d37d115a | ||
|
|
fc5b4998f7 |
@@ -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
@@ -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
@@ -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/
|
||||
@@ -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).
|
||||
@@ -1,268 +0,0 @@
|
||||
<div align="center">
|
||||
|
||||
# Hindsight
|
||||
|
||||
**Agent Memory that Works Like Human Memory**
|
||||
|
||||
[](https://github.com/vectorize-io/hindsight/actions/workflows/test.yml)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://pypi.org/project/hindsight-client/)
|
||||
[](https://www.npmjs.com/package/@vectorize-io/hindsight-client)
|
||||
[](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)
|
||||
|
||||
</div>
|
||||
|
||||
---
|
||||
|
||||
## What is Hindsight?
|
||||
|
||||
Hindsight is an agent memory system built to create smarter agents that learn over time. It eliminates the shortcomings of alternative techniques such as RAG and knowledge graph.
|
||||
|
||||
Hindsight addresses common challenges that have frustrated AI engineers building agents to automate tasks and assist users with conversational interfaces. Many of these challenges stem directly from a lack of memory.
|
||||
|
||||
- **Inconsistency:** Agents complete tasks successfully one time, then fail when asked to complete the same task again. Memory gives the agent a mechanism to remember what worked and what didn't and to use that information to reduce errors and improve consistency.
|
||||
- **Hallucinations:** Long term memory can be seeded with external knowledge to ground agent behavior in reliable sources to augment training data.
|
||||
- **Cognitive Overload:** As workflows get complex, retrievals, tool calls, user messages and agent responses can grow to fill the context window leading to context rot. Short term memory optimization allows agents to reduce tokens and focus context by removing irrelevant details.
|
||||
|
||||
## How Hindsight Works
|
||||
|
||||

|
||||
|
||||
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)
|
||||
|
||||
```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
|
||||
|
||||
Install 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
|
||||
client.retain(bank_id="my-agent", content="Alice works at Google as a software engineer")
|
||||
|
||||
# Query
|
||||
results = client.recall(bank_id="my-agent", query="What does Alice do?")
|
||||
|
||||
# Reflect
|
||||
response = client.reflect(bank_id="my-agent", query="Tell me about Alice")
|
||||
print(response.text)
|
||||
```
|
||||
|
||||
### Python (embedded, no Docker)
|
||||
|
||||
```bash
|
||||
pip install hindsight-all
|
||||
```
|
||||
|
||||
```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:
|
||||
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?")
|
||||
```
|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
### 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?")
|
||||
|
||||
|
||||
```
|
||||
|
||||
Recall performs 4 retrieval strategies in parallel:
|
||||
- Semantic: Vector similarity
|
||||
- Keyword: BM25 exact matching
|
||||
- Graph: Entity/temporal/causal links
|
||||
- Temporal: Time range filtering
|
||||
|
||||

|
||||
|
||||
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?")
|
||||
```
|
||||
|
||||

|
||||
|
||||
## Integrations
|
||||
|
||||
### OpenAI Drop-in Replacement
|
||||
|
||||
```python
|
||||
from hindsight_openai import configure, OpenAI
|
||||
|
||||
configure(hindsight_api_url="http://localhost:8888", agent_id="my-assistant")
|
||||
|
||||
client = OpenAI(api_key="sk-...")
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4",
|
||||
messages=[{"role": "user", "content": "What did we discuss?"}]
|
||||
)
|
||||
# Memory automatically recalled and stored
|
||||
```
|
||||
|
||||
### Examples
|
||||
|
||||
[Examples directory](./examples) 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)
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](./LICENSE)
|
||||
|
||||
---
|
||||
|
||||
Built by [Vectorize.io](https://vectorize.io)
|
||||
@@ -35,7 +35,6 @@ 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
|
||||
```
|
||||
|
||||
@@ -90,7 +89,12 @@ with HindsightServer(llm_provider="openai", llm_model="gpt-4o-mini", llm_api_key
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation: [hindsight.vectorize.io](https://hindsight.vectorize.io)
|
||||
Full documentation: [vectorize-io.github.io/hindsight](https://vectorize-io.github.io/hindsight)
|
||||
|
||||
- [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
|
||||
|
||||
|
||||
@@ -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.0.21
|
||||
appVersion: "0.0.21"
|
||||
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()
|
||||
|
||||
@@ -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')
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -792,7 +833,7 @@ 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.",
|
||||
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,
|
||||
@@ -830,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)
|
||||
@@ -860,7 +901,7 @@ def _register_routes(app: FastAPI):
|
||||
|
||||
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
|
||||
- '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.
|
||||
@@ -872,8 +913,17 @@ def _register_routes(app: FastAPI):
|
||||
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
|
||||
@@ -976,7 +1026,7 @@ def _register_routes(app: FastAPI):
|
||||
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
|
||||
@@ -1529,19 +1579,19 @@ This operation cannot be undone.
|
||||
"/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.",
|
||||
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:
|
||||
@@ -1554,28 +1604,28 @@ This operation cannot be undone.
|
||||
@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"
|
||||
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:
|
||||
@@ -1589,23 +1639,23 @@ This operation cannot be undone.
|
||||
"/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.",
|
||||
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:
|
||||
@@ -1619,13 +1669,13 @@ This operation cannot be undone.
|
||||
"/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.",
|
||||
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)
|
||||
@@ -1646,13 +1696,13 @@ This operation cannot be undone.
|
||||
)
|
||||
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:
|
||||
@@ -1672,11 +1722,11 @@ This operation cannot be undone.
|
||||
|
||||
# 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:
|
||||
@@ -1802,11 +1852,11 @@ This operation cannot be undone.
|
||||
"/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.",
|
||||
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:
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -102,6 +102,7 @@ def main():
|
||||
http_api_enabled=True,
|
||||
mcp_api_enabled=True,
|
||||
mcp_mount_path="/mcp",
|
||||
run_migrations=True,
|
||||
initialize_memory=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -78,12 +78,7 @@ class SentenceTransformersCrossEncoder(CrossEncoderModel):
|
||||
)
|
||||
|
||||
logger.info(f"Loading cross-encoder model: {self.model_name}...")
|
||||
# Disable lazy loading (meta tensors) which causes issues with newer transformers/accelerate
|
||||
# Setting low_cpu_mem_usage=False and device_map=None ensures tensors are fully materialized
|
||||
self._model = CrossEncoder(
|
||||
self.model_name,
|
||||
model_kwargs={"low_cpu_mem_usage": False, "device_map": None},
|
||||
)
|
||||
self._model = CrossEncoder(self.model_name)
|
||||
logger.info("Cross-encoder model loaded")
|
||||
|
||||
def predict(self, pairs: List[Tuple[str, str]]) -> List[float]:
|
||||
|
||||
@@ -84,12 +84,7 @@ class SentenceTransformersEmbeddings(Embeddings):
|
||||
)
|
||||
|
||||
logger.info(f"Loading embedding 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},
|
||||
)
|
||||
self._model = SentenceTransformer(self.model_name)
|
||||
|
||||
# Validate dimension matches database schema
|
||||
model_dim = self._model.get_sentence_embedding_dimension()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -5,15 +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 google import genai
|
||||
from google.genai import types as genai_types
|
||||
from google.genai import errors as genai_errors
|
||||
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
|
||||
@@ -43,7 +37,6 @@ class LLMConfig:
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
model: str,
|
||||
reasoning_effort: str = "low",
|
||||
):
|
||||
"""
|
||||
Initialize LLM configuration.
|
||||
@@ -58,12 +51,11 @@ class LLMConfig:
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
self.model = model
|
||||
self.reasoning_effort = reasoning_effort
|
||||
|
||||
# Validate provider
|
||||
if self.provider not in ["openai", "groq", "ollama", "gemini"]:
|
||||
if self.provider not in ["openai", "groq", "ollama"]:
|
||||
raise ValueError(
|
||||
f"Invalid LLM provider: {self.provider}. Must be 'openai', 'groq', 'ollama', or 'gemini'."
|
||||
f"Invalid LLM provider: {self.provider}. Must be 'openai', 'groq', or 'ollama'."
|
||||
)
|
||||
|
||||
# Set default base URLs
|
||||
@@ -74,25 +66,19 @@ class LLMConfig:
|
||||
self.base_url = "http://localhost:11434/v1"
|
||||
|
||||
# Validate API key (not needed for ollama)
|
||||
if self.provider not in ["ollama"] and not self.api_key:
|
||||
if self.provider != "ollama" and not self.api_key:
|
||||
raise ValueError(
|
||||
f"API key not found for {self.provider}"
|
||||
)
|
||||
|
||||
# Create client (private - use .call() method instead)
|
||||
# Disable automatic retries - we handle retries in the call() method
|
||||
if self.provider == "gemini":
|
||||
self._gemini_client = genai.Client(api_key=self.api_key)
|
||||
self._client = None # Not used for Gemini
|
||||
elif self.provider == "ollama":
|
||||
if self.provider == "ollama":
|
||||
self._client = AsyncOpenAI(api_key="ollama", base_url=self.base_url, max_retries=0)
|
||||
self._gemini_client = None
|
||||
elif self.base_url:
|
||||
self._client = AsyncOpenAI(api_key=self.api_key, base_url=self.base_url, max_retries=0)
|
||||
self._gemini_client = None
|
||||
else:
|
||||
self._client = AsyncOpenAI(api_key=self.api_key, max_retries=0)
|
||||
self._gemini_client = None
|
||||
|
||||
logger.info(
|
||||
f"Initialized LLM: provider={self.provider}, model={self.model}, base_url={self.base_url}"
|
||||
@@ -130,25 +116,16 @@ class LLMConfig:
|
||||
# Use global semaphore to limit concurrent requests
|
||||
async with _global_llm_semaphore:
|
||||
start_time = time.time()
|
||||
import json
|
||||
|
||||
# Handle Gemini provider separately
|
||||
if self.provider == "gemini":
|
||||
return await self._call_gemini(messages, response_format, max_retries, initial_backoff, max_backoff, skip_validation, start_time, **kwargs)
|
||||
|
||||
call_params = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
**kwargs
|
||||
}
|
||||
|
||||
if self.provider == "groq":
|
||||
call_params["seed"] = DEFAULT_LLM_SEED
|
||||
|
||||
if self.provider == "groq":
|
||||
call_params["extra_body"] = {
|
||||
"service_tier": "auto",
|
||||
"reasoning_effort": self.reasoning_effort,
|
||||
"reasoning_effort": "low", # Reduce reasoning overhead
|
||||
"include_reasoning": False, # Disable hidden reasoning tokens
|
||||
}
|
||||
|
||||
@@ -160,6 +137,7 @@ class LLMConfig:
|
||||
if response_format is not None:
|
||||
# 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'):
|
||||
@@ -196,15 +174,10 @@ class LLMConfig:
|
||||
usage = response.usage
|
||||
if duration > 10.0:
|
||||
ratio = max(1, usage.completion_tokens) / usage.prompt_tokens
|
||||
# Check for cached tokens (OpenAI/Groq may include this)
|
||||
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
|
||||
@@ -216,18 +189,6 @@ class LLMConfig:
|
||||
f"LLM output exceeded token limits. Input may need to be split into smaller chunks."
|
||||
) from e
|
||||
|
||||
except APIConnectionError as e:
|
||||
# Handle connection errors (server disconnected, network issues) with retry
|
||||
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:
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
@@ -254,162 +215,6 @@ class LLMConfig:
|
||||
raise last_exception
|
||||
raise RuntimeError(f"LLM call failed after all retries with no exception captured")
|
||||
|
||||
async def _call_gemini(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
response_format: Optional[Any],
|
||||
max_retries: int,
|
||||
initial_backoff: float,
|
||||
max_backoff: float,
|
||||
skip_validation: bool,
|
||||
start_time: float,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""Handle Gemini-specific API calls using google-genai SDK."""
|
||||
import json
|
||||
|
||||
# Convert OpenAI-style messages to Gemini format
|
||||
# Gemini uses 'user' and 'model' roles, and system instructions are separate
|
||||
system_instruction = None
|
||||
gemini_contents = []
|
||||
|
||||
for msg in messages:
|
||||
role = msg.get('role', 'user')
|
||||
content = msg.get('content', '')
|
||||
|
||||
if role == 'system':
|
||||
# Accumulate system messages as system instruction
|
||||
if system_instruction:
|
||||
system_instruction += "\n\n" + content
|
||||
else:
|
||||
system_instruction = content
|
||||
elif role == 'assistant':
|
||||
gemini_contents.append(genai_types.Content(
|
||||
role="model",
|
||||
parts=[genai_types.Part(text=content)]
|
||||
))
|
||||
else: # user or any other role
|
||||
gemini_contents.append(genai_types.Content(
|
||||
role="user",
|
||||
parts=[genai_types.Part(text=content)]
|
||||
))
|
||||
|
||||
# Add JSON schema instruction if response_format is provided
|
||||
if response_format is not None and hasattr(response_format, 'model_json_schema'):
|
||||
schema = response_format.model_json_schema()
|
||||
schema_msg = f"\n\nYou must respond with valid JSON matching this schema:\n{json.dumps(schema, indent=2)}"
|
||||
if system_instruction:
|
||||
system_instruction += schema_msg
|
||||
else:
|
||||
system_instruction = schema_msg
|
||||
|
||||
# Build generation config
|
||||
config_kwargs = {}
|
||||
if system_instruction:
|
||||
config_kwargs['system_instruction'] = system_instruction
|
||||
if 'temperature' in kwargs:
|
||||
config_kwargs['temperature'] = kwargs['temperature']
|
||||
if 'max_tokens' in kwargs:
|
||||
config_kwargs['max_output_tokens'] = kwargs['max_tokens']
|
||||
if response_format is not None:
|
||||
config_kwargs['response_mime_type'] = 'application/json'
|
||||
# Pass the Pydantic model directly as response_schema for structured output
|
||||
config_kwargs['response_schema'] = response_format
|
||||
|
||||
generation_config = genai_types.GenerateContentConfig(**config_kwargs) if config_kwargs else None
|
||||
|
||||
last_exception = None
|
||||
|
||||
for attempt in range(max_retries + 1):
|
||||
try:
|
||||
response = await self._gemini_client.aio.models.generate_content(
|
||||
model=self.model,
|
||||
contents=gemini_contents,
|
||||
config=generation_config,
|
||||
)
|
||||
|
||||
content = response.text
|
||||
|
||||
# Handle empty/None response (can happen with content filtering or timeouts)
|
||||
if content is None:
|
||||
# Check if there's a block reason
|
||||
block_reason = None
|
||||
if hasattr(response, 'candidates') and response.candidates:
|
||||
candidate = response.candidates[0]
|
||||
if hasattr(candidate, 'finish_reason'):
|
||||
block_reason = candidate.finish_reason
|
||||
|
||||
if attempt < max_retries:
|
||||
logger.warning(f"Gemini returned empty response (reason: {block_reason}), retrying... (attempt {attempt + 1}/{max_retries + 1})")
|
||||
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
else:
|
||||
raise RuntimeError(f"Gemini returned empty response after {max_retries + 1} attempts (reason: {block_reason})")
|
||||
|
||||
if response_format is not None:
|
||||
# Parse the JSON response
|
||||
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:
|
||||
result = content
|
||||
|
||||
# Log call details only if it takes more than 10 seconds
|
||||
duration = time.time() - start_time
|
||||
if duration > 10.0 and hasattr(response, 'usage_metadata') and response.usage_metadata:
|
||||
usage = response.usage_metadata
|
||||
# Check for cached tokens (Gemini uses cached_content_token_count)
|
||||
cached_tokens = getattr(usage, 'cached_content_token_count', 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_token_count}, output_tokens={usage.candidates_token_count}{cache_info}, "
|
||||
f"time={duration:.3f}s"
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
# Handle truncated JSON responses (often from MAX_TOKENS) with retry
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
logger.warning(f"Gemini returned invalid JSON (truncated response?), retrying... (attempt {attempt + 1}/{max_retries + 1})")
|
||||
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
|
||||
await asyncio.sleep(backoff)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"Gemini returned invalid JSON after {max_retries + 1} attempts: {str(e)}")
|
||||
raise
|
||||
|
||||
except genai_errors.APIError as e:
|
||||
# Handle rate limits and server errors with retry
|
||||
if e.code in (429, 503, 500):
|
||||
last_exception = e
|
||||
if attempt < max_retries:
|
||||
backoff = min(initial_backoff * (2 ** attempt), max_backoff)
|
||||
jitter = backoff * 0.2 * (2 * (time.time() % 1) - 1)
|
||||
sleep_time = backoff + jitter
|
||||
await asyncio.sleep(sleep_time)
|
||||
else:
|
||||
logger.error(f"Gemini API error after {max_retries + 1} attempts: {str(e)}")
|
||||
raise
|
||||
else:
|
||||
logger.error(f"Gemini API error: {type(e).__name__}: {str(e)}")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error during Gemini call: {type(e).__name__}: {str(e)}")
|
||||
raise
|
||||
|
||||
if last_exception:
|
||||
raise last_exception
|
||||
raise RuntimeError(f"Gemini call failed after all retries with no exception captured")
|
||||
|
||||
@classmethod
|
||||
def for_memory(cls) -> "LLMConfig":
|
||||
"""Create configuration for memory operations from environment variables."""
|
||||
@@ -432,37 +237,6 @@ class LLMConfig:
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
reasoning_effort="low"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def for_answer_generation(cls) -> "LLMConfig":
|
||||
"""
|
||||
Create configuration for answer generation operations from environment variables.
|
||||
|
||||
Falls back to memory LLM config if answer-specific config not set.
|
||||
"""
|
||||
# Check if answer-specific config exists, otherwise fall back to memory config
|
||||
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
|
||||
@@ -492,5 +266,4 @@ class LLMConfig:
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
model=model,
|
||||
reasoning_effort="high"
|
||||
)
|
||||
|
||||
@@ -48,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
|
||||
@@ -94,7 +94,7 @@ 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__(
|
||||
@@ -110,14 +110,12 @@ 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.
|
||||
|
||||
Args:
|
||||
db_url: PostgreSQL connection URL (postgresql://user:pass@host:port/dbname). Required.
|
||||
Also supports pg0 URLs: "pg0" or "pg0://instance-name" or "pg0://instance-name:port"
|
||||
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.
|
||||
@@ -131,38 +129,16 @@ class MemoryEngine:
|
||||
pool_max_size: Maximum number of connections in the pool (default: 100)
|
||||
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
|
||||
run_migrations: Whether to run database migrations during initialize(). Default: True
|
||||
"""
|
||||
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
|
||||
@@ -179,7 +155,6 @@ 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
|
||||
@@ -403,16 +378,8 @@ 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()
|
||||
|
||||
def load_embeddings():
|
||||
"""Load embedding model (CPU-bound)."""
|
||||
@@ -441,12 +408,6 @@ class MemoryEngine:
|
||||
pg0_task, embeddings_future, cross_encoder_future, query_analyzer_future
|
||||
)
|
||||
|
||||
# Run database migrations if enabled
|
||||
if self._run_migrations:
|
||||
from ..migrations import run_migrations
|
||||
logger.info("Running database migrations...")
|
||||
run_migrations(self.db_url)
|
||||
|
||||
logger.info(f"Connecting to PostgreSQL at {self.db_url}")
|
||||
|
||||
# Create connection pool
|
||||
@@ -715,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:
|
||||
@@ -767,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:
|
||||
@@ -908,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,
|
||||
@@ -934,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
|
||||
@@ -974,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
|
||||
@@ -993,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]
|
||||
|
||||
@@ -1086,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)
|
||||
@@ -1134,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)
|
||||
@@ -1255,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)
|
||||
@@ -1379,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 = []
|
||||
@@ -1441,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
|
||||
@@ -1466,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:
|
||||
@@ -1525,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:
|
||||
@@ -1564,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)}")
|
||||
|
||||
@@ -1728,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:
|
||||
@@ -1786,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
|
||||
@@ -1960,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
|
||||
@@ -2533,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:
|
||||
@@ -2589,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)
|
||||
@@ -2607,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
|
||||
@@ -2623,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
|
||||
@@ -2675,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},
|
||||
@@ -2694,7 +2641,6 @@ Guidelines:
|
||||
temperature=0.9,
|
||||
max_tokens=1000
|
||||
)
|
||||
llm_time = time.time() - llm_start
|
||||
|
||||
answer_text = answer_text.strip()
|
||||
|
||||
@@ -2706,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
|
||||
@@ -2758,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,
|
||||
@@ -2884,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:
|
||||
@@ -2900,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 []
|
||||
@@ -2977,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,8 +274,8 @@ 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,
|
||||
@@ -280,13 +289,13 @@ Merged background:"""
|
||||
# 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",
|
||||
@@ -296,7 +305,7 @@ Merged background:"""
|
||||
|
||||
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()})
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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')")
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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}")
|
||||
|
||||
@@ -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="")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "hindsight-api"
|
||||
version = "0.0.21"
|
||||
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,6 +44,7 @@ test = [
|
||||
"pytest-timeout>=2.4.0",
|
||||
"pytest-xdist>=3.0.0",
|
||||
"filelock>=3.0.0",
|
||||
"testcontainers[postgres]>=4.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -56,9 +56,9 @@ packages = ["hindsight_api"]
|
||||
[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
|
||||
@@ -69,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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -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, SentenceTransformersEmbeddings
|
||||
import asyncpg
|
||||
from testcontainers.postgres import PostgresContainer
|
||||
|
||||
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")
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
[package]
|
||||
name = "hindsight-cli"
|
||||
version = "0.0.21"
|
||||
version = "0.0.18"
|
||||
edition = "2021"
|
||||
authors = ["Hindsight Team"]
|
||||
description = "A beautiful CLI for Hindsight - semantic memory system"
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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=trusting, 5=skeptical) |
|
||||
**literalism** | **int** | How literally to interpret information (1=flexible, 5=literal) |
|
||||
**empathy** | **int** | How much to consider emotional context (1=detached, 5=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
|
||||
|
||||
+18
-14
@@ -17,20 +17,19 @@ import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Any, ClassVar, Dict, List
|
||||
from typing_extensions import Annotated
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class DispositionTraits(BaseModel):
|
||||
class MetadataFilter(BaseModel):
|
||||
"""
|
||||
Disposition traits that influence how memories are formed and interpreted.
|
||||
Filter for metadata fields. Matches records where (key=value) OR (key not set) when match_unset=True.
|
||||
""" # noqa: E501
|
||||
skepticism: Annotated[int, Field(le=5, strict=True, ge=1)] = Field(description="How skeptical vs trusting (1=trusting, 5=skeptical)")
|
||||
literalism: Annotated[int, Field(le=5, strict=True, ge=1)] = Field(description="How literally to interpret information (1=flexible, 5=literal)")
|
||||
empathy: Annotated[int, Field(le=5, strict=True, ge=1)] = Field(description="How much to consider emotional context (1=detached, 5=empathetic)")
|
||||
__properties: ClassVar[List[str]] = ["skepticism", "literalism", "empathy"]
|
||||
key: StrictStr = Field(description="Metadata key to filter on")
|
||||
value: Optional[StrictStr] = None
|
||||
match_unset: Optional[StrictBool] = Field(default=True, description="If True, also match records where this metadata key is not set")
|
||||
__properties: ClassVar[List[str]] = ["key", "value", "match_unset"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -50,7 +49,7 @@ class DispositionTraits(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of DispositionTraits from a JSON string"""
|
||||
"""Create an instance of MetadataFilter from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -71,11 +70,16 @@ class DispositionTraits(BaseModel):
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# set to None if value (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.value is None and "value" in self.model_fields_set:
|
||||
_dict['value'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of DispositionTraits from a dict"""
|
||||
"""Create an instance of MetadataFilter from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
@@ -83,9 +87,9 @@ class DispositionTraits(BaseModel):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"skepticism": obj.get("skepticism"),
|
||||
"literalism": obj.get("literalism"),
|
||||
"empathy": obj.get("empathy")
|
||||
"key": obj.get("key"),
|
||||
"value": obj.get("value"),
|
||||
"match_unset": obj.get("match_unset") if obj.get("match_unset") is not None else True
|
||||
})
|
||||
return _obj
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 1.0.0
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
from __future__ import annotations
|
||||
import pprint
|
||||
import re # noqa: F401
|
||||
import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from typing import Any, ClassVar, Dict, List, Union
|
||||
from typing_extensions import Annotated
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
class PersonalityTraits(BaseModel):
|
||||
"""
|
||||
Personality traits based on Big Five model.
|
||||
""" # noqa: E501
|
||||
openness: Union[Annotated[float, Field(le=1.0, strict=True, ge=0.0)], Annotated[int, Field(le=1, strict=True, ge=0)]] = Field(description="Openness to experience (0-1)")
|
||||
conscientiousness: Union[Annotated[float, Field(le=1.0, strict=True, ge=0.0)], Annotated[int, Field(le=1, strict=True, ge=0)]] = Field(description="Conscientiousness (0-1)")
|
||||
extraversion: Union[Annotated[float, Field(le=1.0, strict=True, ge=0.0)], Annotated[int, Field(le=1, strict=True, ge=0)]] = Field(description="Extraversion (0-1)")
|
||||
agreeableness: Union[Annotated[float, Field(le=1.0, strict=True, ge=0.0)], Annotated[int, Field(le=1, strict=True, ge=0)]] = Field(description="Agreeableness (0-1)")
|
||||
neuroticism: Union[Annotated[float, Field(le=1.0, strict=True, ge=0.0)], Annotated[int, Field(le=1, strict=True, ge=0)]] = Field(description="Neuroticism (0-1)")
|
||||
bias_strength: Union[Annotated[float, Field(le=1.0, strict=True, ge=0.0)], Annotated[int, Field(le=1, strict=True, ge=0)]] = Field(description="How strongly personality influences opinions (0-1)")
|
||||
__properties: ClassVar[List[str]] = ["openness", "conscientiousness", "extraversion", "agreeableness", "neuroticism", "bias_strength"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
validate_assignment=True,
|
||||
protected_namespaces=(),
|
||||
)
|
||||
|
||||
|
||||
def to_str(self) -> str:
|
||||
"""Returns the string representation of the model using alias"""
|
||||
return pprint.pformat(self.model_dump(by_alias=True))
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""Returns the JSON representation of the model using alias"""
|
||||
# TODO: pydantic v2: use .model_dump_json(by_alias=True, exclude_unset=True) instead
|
||||
return json.dumps(self.to_dict())
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of PersonalityTraits from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Return the dictionary representation of the model using alias.
|
||||
|
||||
This has the following differences from calling pydantic's
|
||||
`self.model_dump(by_alias=True)`:
|
||||
|
||||
* `None` is only added to the output dict for nullable fields that
|
||||
were set at model initialization. Other fields with value `None`
|
||||
are ignored.
|
||||
"""
|
||||
excluded_fields: Set[str] = set([
|
||||
])
|
||||
|
||||
_dict = self.model_dump(
|
||||
by_alias=True,
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of PersonalityTraits from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
if not isinstance(obj, dict):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"openness": obj.get("openness"),
|
||||
"conscientiousness": obj.get("conscientiousness"),
|
||||
"extraversion": obj.get("extraversion"),
|
||||
"agreeableness": obj.get("agreeableness"),
|
||||
"neuroticism": obj.get("neuroticism"),
|
||||
"bias_strength": obj.get("bias_strength")
|
||||
})
|
||||
return _obj
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from pydantic import BaseModel, ConfigDict, Field, StrictBool, StrictInt, Strict
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from hindsight_client_api.models.budget import Budget
|
||||
from hindsight_client_api.models.include_options import IncludeOptions
|
||||
from hindsight_client_api.models.metadata_filter import MetadataFilter
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
|
||||
@@ -34,8 +35,9 @@ class RecallRequest(BaseModel):
|
||||
max_tokens: Optional[StrictInt] = 4096
|
||||
trace: Optional[StrictBool] = False
|
||||
query_timestamp: Optional[StrictStr] = None
|
||||
filters: Optional[List[MetadataFilter]] = None
|
||||
include: Optional[IncludeOptions] = Field(default=None, description="Options for including additional data (entities are included by default)")
|
||||
__properties: ClassVar[List[str]] = ["query", "types", "budget", "max_tokens", "trace", "query_timestamp", "include"]
|
||||
__properties: ClassVar[List[str]] = ["query", "types", "budget", "max_tokens", "trace", "query_timestamp", "filters", "include"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -76,6 +78,13 @@ class RecallRequest(BaseModel):
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# override the default output from pydantic by calling `to_dict()` of each item in filters (list)
|
||||
_items = []
|
||||
if self.filters:
|
||||
for _item_filters in self.filters:
|
||||
if _item_filters:
|
||||
_items.append(_item_filters.to_dict())
|
||||
_dict['filters'] = _items
|
||||
# override the default output from pydantic by calling `to_dict()` of include
|
||||
if self.include:
|
||||
_dict['include'] = self.include.to_dict()
|
||||
@@ -89,6 +98,11 @@ class RecallRequest(BaseModel):
|
||||
if self.query_timestamp is None and "query_timestamp" in self.model_fields_set:
|
||||
_dict['query_timestamp'] = None
|
||||
|
||||
# set to None if filters (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.filters is None and "filters" in self.model_fields_set:
|
||||
_dict['filters'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
@@ -107,6 +121,7 @@ class RecallRequest(BaseModel):
|
||||
"max_tokens": obj.get("max_tokens") if obj.get("max_tokens") is not None else 4096,
|
||||
"trace": obj.get("trace") if obj.get("trace") is not None else False,
|
||||
"query_timestamp": obj.get("query_timestamp"),
|
||||
"filters": [MetadataFilter.from_dict(_item) for _item in obj["filters"]] if obj.get("filters") is not None else None,
|
||||
"include": IncludeOptions.from_dict(obj["include"]) if obj.get("include") is not None else None
|
||||
})
|
||||
return _obj
|
||||
|
||||
@@ -20,6 +20,7 @@ import json
|
||||
from pydantic import BaseModel, ConfigDict, Field, StrictStr
|
||||
from typing import Any, ClassVar, Dict, List, Optional
|
||||
from hindsight_client_api.models.budget import Budget
|
||||
from hindsight_client_api.models.metadata_filter import MetadataFilter
|
||||
from hindsight_client_api.models.reflect_include_options import ReflectIncludeOptions
|
||||
from typing import Optional, Set
|
||||
from typing_extensions import Self
|
||||
@@ -31,8 +32,9 @@ class ReflectRequest(BaseModel):
|
||||
query: StrictStr
|
||||
budget: Optional[Budget] = None
|
||||
context: Optional[StrictStr] = None
|
||||
filters: Optional[List[MetadataFilter]] = None
|
||||
include: Optional[ReflectIncludeOptions] = Field(default=None, description="Options for including additional data (disabled by default)")
|
||||
__properties: ClassVar[List[str]] = ["query", "budget", "context", "include"]
|
||||
__properties: ClassVar[List[str]] = ["query", "budget", "context", "filters", "include"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -73,6 +75,13 @@ class ReflectRequest(BaseModel):
|
||||
exclude=excluded_fields,
|
||||
exclude_none=True,
|
||||
)
|
||||
# override the default output from pydantic by calling `to_dict()` of each item in filters (list)
|
||||
_items = []
|
||||
if self.filters:
|
||||
for _item_filters in self.filters:
|
||||
if _item_filters:
|
||||
_items.append(_item_filters.to_dict())
|
||||
_dict['filters'] = _items
|
||||
# override the default output from pydantic by calling `to_dict()` of include
|
||||
if self.include:
|
||||
_dict['include'] = self.include.to_dict()
|
||||
@@ -81,6 +90,11 @@ class ReflectRequest(BaseModel):
|
||||
if self.context is None and "context" in self.model_fields_set:
|
||||
_dict['context'] = None
|
||||
|
||||
# set to None if filters (nullable) is None
|
||||
# and model_fields_set contains the field
|
||||
if self.filters is None and "filters" in self.model_fields_set:
|
||||
_dict['filters'] = None
|
||||
|
||||
return _dict
|
||||
|
||||
@classmethod
|
||||
@@ -96,6 +110,7 @@ class ReflectRequest(BaseModel):
|
||||
"query": obj.get("query"),
|
||||
"budget": obj.get("budget"),
|
||||
"context": obj.get("context"),
|
||||
"filters": [MetadataFilter.from_dict(_item) for _item in obj["filters"]] if obj.get("filters") is not None else None,
|
||||
"include": ReflectIncludeOptions.from_dict(obj["include"]) if obj.get("include") is not None else None
|
||||
})
|
||||
return _obj
|
||||
|
||||
+11
-11
@@ -19,16 +19,16 @@ import json
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
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
|
||||
|
||||
class UpdateDispositionRequest(BaseModel):
|
||||
class UpdatePersonalityRequest(BaseModel):
|
||||
"""
|
||||
Request model for updating disposition traits.
|
||||
Request model for updating personality traits.
|
||||
""" # noqa: E501
|
||||
disposition: DispositionTraits
|
||||
__properties: ClassVar[List[str]] = ["disposition"]
|
||||
personality: PersonalityTraits
|
||||
__properties: ClassVar[List[str]] = ["personality"]
|
||||
|
||||
model_config = ConfigDict(
|
||||
populate_by_name=True,
|
||||
@@ -48,7 +48,7 @@ class UpdateDispositionRequest(BaseModel):
|
||||
|
||||
@classmethod
|
||||
def from_json(cls, json_str: str) -> Optional[Self]:
|
||||
"""Create an instance of UpdateDispositionRequest from a JSON string"""
|
||||
"""Create an instance of UpdatePersonalityRequest from a JSON string"""
|
||||
return cls.from_dict(json.loads(json_str))
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
@@ -69,14 +69,14 @@ class UpdateDispositionRequest(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
|
||||
def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]:
|
||||
"""Create an instance of UpdateDispositionRequest from a dict"""
|
||||
"""Create an instance of UpdatePersonalityRequest from a dict"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
@@ -84,7 +84,7 @@ class UpdateDispositionRequest(BaseModel):
|
||||
return cls.model_validate(obj)
|
||||
|
||||
_obj = cls.model_validate({
|
||||
"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
|
||||
|
||||
@@ -36,7 +36,7 @@ class TestAddBackgroundRequest(unittest.TestCase):
|
||||
if include_optional:
|
||||
return AddBackgroundRequest(
|
||||
content = '',
|
||||
update_disposition = True
|
||||
update_personality = True
|
||||
)
|
||||
else:
|
||||
return AddBackgroundRequest(
|
||||
|
||||
@@ -36,7 +36,7 @@ class TestBackgroundResponse(unittest.TestCase):
|
||||
if include_optional:
|
||||
return BackgroundResponse(
|
||||
background = '',
|
||||
disposition = {empathy=3, literalism=3, skepticism=3}
|
||||
personality = {agreeableness=0.7, bias_strength=0.7, conscientiousness=0.6, extraversion=0.5, neuroticism=0.3, openness=0.8}
|
||||
)
|
||||
else:
|
||||
return BackgroundResponse(
|
||||
|
||||
@@ -37,7 +37,7 @@ class TestBankListItem(unittest.TestCase):
|
||||
return BankListItem(
|
||||
bank_id = '',
|
||||
name = '',
|
||||
disposition = {empathy=3, literalism=3, skepticism=3},
|
||||
personality = {agreeableness=0.7, bias_strength=0.7, conscientiousness=0.6, extraversion=0.5, neuroticism=0.3, openness=0.8},
|
||||
background = '',
|
||||
created_at = '',
|
||||
updated_at = ''
|
||||
@@ -46,7 +46,7 @@ class TestBankListItem(unittest.TestCase):
|
||||
return BankListItem(
|
||||
bank_id = '',
|
||||
name = '',
|
||||
disposition = {empathy=3, literalism=3, skepticism=3},
|
||||
personality = {agreeableness=0.7, bias_strength=0.7, conscientiousness=0.6, extraversion=0.5, neuroticism=0.3, openness=0.8},
|
||||
background = '',
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -39,7 +39,7 @@ class TestBankListResponse(unittest.TestCase):
|
||||
hindsight_client_api.models.bank_list_item.BankListItem(
|
||||
bank_id = '',
|
||||
name = '',
|
||||
disposition = {empathy=3, literalism=3, skepticism=3},
|
||||
personality = {agreeableness=0.7, bias_strength=0.7, conscientiousness=0.6, extraversion=0.5, neuroticism=0.3, openness=0.8},
|
||||
background = '',
|
||||
created_at = '',
|
||||
updated_at = '', )
|
||||
@@ -51,7 +51,7 @@ class TestBankListResponse(unittest.TestCase):
|
||||
hindsight_client_api.models.bank_list_item.BankListItem(
|
||||
bank_id = '',
|
||||
name = '',
|
||||
disposition = {empathy=3, literalism=3, skepticism=3},
|
||||
personality = {agreeableness=0.7, bias_strength=0.7, conscientiousness=0.6, extraversion=0.5, neuroticism=0.3, openness=0.8},
|
||||
background = '',
|
||||
created_at = '',
|
||||
updated_at = '', )
|
||||
|
||||
@@ -37,14 +37,14 @@ class TestBankProfileResponse(unittest.TestCase):
|
||||
return BankProfileResponse(
|
||||
bank_id = '',
|
||||
name = '',
|
||||
disposition = {empathy=3, literalism=3, skepticism=3},
|
||||
personality = {agreeableness=0.7, bias_strength=0.7, conscientiousness=0.6, extraversion=0.5, neuroticism=0.3, openness=0.8},
|
||||
background = ''
|
||||
)
|
||||
else:
|
||||
return BankProfileResponse(
|
||||
bank_id = '',
|
||||
name = '',
|
||||
disposition = {empathy=3, literalism=3, skepticism=3},
|
||||
personality = {agreeableness=0.7, bias_strength=0.7, conscientiousness=0.6, extraversion=0.5, neuroticism=0.3, openness=0.8},
|
||||
background = '',
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -36,7 +36,7 @@ class TestCreateBankRequest(unittest.TestCase):
|
||||
if include_optional:
|
||||
return CreateBankRequest(
|
||||
name = '',
|
||||
disposition = {empathy=3, literalism=3, skepticism=3},
|
||||
personality = {agreeableness=0.7, bias_strength=0.7, conscientiousness=0.6, extraversion=0.5, neuroticism=0.3, openness=0.8},
|
||||
background = ''
|
||||
)
|
||||
else:
|
||||
|
||||
@@ -166,10 +166,10 @@ class TestDefaultApi(unittest.IsolatedAsyncioTestCase):
|
||||
"""
|
||||
pass
|
||||
|
||||
async def test_update_bank_disposition(self) -> None:
|
||||
"""Test case for update_bank_disposition
|
||||
async def test_update_bank_personality(self) -> None:
|
||||
"""Test case for update_bank_personality
|
||||
|
||||
Update memory bank disposition
|
||||
Update memory bank personality
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
+15
-17
@@ -14,10 +14,10 @@
|
||||
|
||||
import unittest
|
||||
|
||||
from hindsight_client_api.models.disposition_traits import DispositionTraits
|
||||
from hindsight_client_api.models.metadata_filter import MetadataFilter
|
||||
|
||||
class TestDispositionTraits(unittest.TestCase):
|
||||
"""DispositionTraits unit test stubs"""
|
||||
class TestMetadataFilter(unittest.TestCase):
|
||||
"""MetadataFilter unit test stubs"""
|
||||
|
||||
def setUp(self):
|
||||
pass
|
||||
@@ -25,30 +25,28 @@ class TestDispositionTraits(unittest.TestCase):
|
||||
def tearDown(self):
|
||||
pass
|
||||
|
||||
def make_instance(self, include_optional) -> DispositionTraits:
|
||||
"""Test DispositionTraits
|
||||
def make_instance(self, include_optional) -> MetadataFilter:
|
||||
"""Test MetadataFilter
|
||||
include_optional is a boolean, when False only required
|
||||
params are included, when True both required and
|
||||
optional params are included """
|
||||
# uncomment below to create an instance of `DispositionTraits`
|
||||
# uncomment below to create an instance of `MetadataFilter`
|
||||
"""
|
||||
model = DispositionTraits()
|
||||
model = MetadataFilter()
|
||||
if include_optional:
|
||||
return DispositionTraits(
|
||||
skepticism = 1.0,
|
||||
literalism = 1.0,
|
||||
empathy = 1.0
|
||||
return MetadataFilter(
|
||||
key = '',
|
||||
value = '',
|
||||
match_unset = True
|
||||
)
|
||||
else:
|
||||
return DispositionTraits(
|
||||
skepticism = 1.0,
|
||||
literalism = 1.0,
|
||||
empathy = 1.0,
|
||||
return MetadataFilter(
|
||||
key = '',
|
||||
)
|
||||
"""
|
||||
|
||||
def testDispositionTraits(self):
|
||||
"""Test DispositionTraits"""
|
||||
def testMetadataFilter(self):
|
||||
"""Test MetadataFilter"""
|
||||
# inst_req_only = self.make_instance(include_optional=False)
|
||||
# inst_req_and_optional = self.make_instance(include_optional=True)
|
||||
|
||||
@@ -26,13 +26,6 @@ class TestMonitoringApi(unittest.IsolatedAsyncioTestCase):
|
||||
async def asyncTearDown(self) -> None:
|
||||
await self.api.api_client.close()
|
||||
|
||||
async def test_health_endpoint_health_get(self) -> None:
|
||||
"""Test case for health_endpoint_health_get
|
||||
|
||||
Health check endpoint
|
||||
"""
|
||||
pass
|
||||
|
||||
async def test_metrics_endpoint_metrics_get(self) -> None:
|
||||
"""Test case for metrics_endpoint_metrics_get
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 1.0.0
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
import unittest
|
||||
|
||||
from hindsight_client_api.models.personality_traits import PersonalityTraits
|
||||
|
||||
class TestPersonalityTraits(unittest.TestCase):
|
||||
"""PersonalityTraits unit test stubs"""
|
||||
|
||||
def setUp(self):
|
||||
pass
|
||||
|
||||
def tearDown(self):
|
||||
pass
|
||||
|
||||
def make_instance(self, include_optional) -> PersonalityTraits:
|
||||
"""Test PersonalityTraits
|
||||
include_optional is a boolean, when False only required
|
||||
params are included, when True both required and
|
||||
optional params are included """
|
||||
# uncomment below to create an instance of `PersonalityTraits`
|
||||
"""
|
||||
model = PersonalityTraits()
|
||||
if include_optional:
|
||||
return PersonalityTraits(
|
||||
openness = 0.0,
|
||||
conscientiousness = 0.0,
|
||||
extraversion = 0.0,
|
||||
agreeableness = 0.0,
|
||||
neuroticism = 0.0,
|
||||
bias_strength = 0.0
|
||||
)
|
||||
else:
|
||||
return PersonalityTraits(
|
||||
openness = 0.0,
|
||||
conscientiousness = 0.0,
|
||||
extraversion = 0.0,
|
||||
agreeableness = 0.0,
|
||||
neuroticism = 0.0,
|
||||
bias_strength = 0.0,
|
||||
)
|
||||
"""
|
||||
|
||||
def testPersonalityTraits(self):
|
||||
"""Test PersonalityTraits"""
|
||||
# inst_req_only = self.make_instance(include_optional=False)
|
||||
# inst_req_and_optional = self.make_instance(include_optional=True)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -43,6 +43,9 @@ class TestRecallRequest(unittest.TestCase):
|
||||
max_tokens = 56,
|
||||
trace = True,
|
||||
query_timestamp = '',
|
||||
filters = [
|
||||
{key=source, match_unset=true, value=slack}
|
||||
],
|
||||
include = hindsight_client_api.models.include_options.IncludeOptions(
|
||||
entities = hindsight_client_api.models.entity_include_options.EntityIncludeOptions(
|
||||
max_tokens = 56, ),
|
||||
|
||||
@@ -38,6 +38,9 @@ class TestReflectRequest(unittest.TestCase):
|
||||
query = '',
|
||||
budget = 'low',
|
||||
context = '',
|
||||
filters = [
|
||||
{key=source, match_unset=true, value=slack}
|
||||
],
|
||||
include = hindsight_client_api.models.reflect_include_options.ReflectIncludeOptions(
|
||||
facts = hindsight_client_api.models.facts_include_options.FactsIncludeOptions(), )
|
||||
)
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 1.0.0
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
import unittest
|
||||
|
||||
from hindsight_client_api.models.update_disposition_request import UpdateDispositionRequest
|
||||
|
||||
class TestUpdateDispositionRequest(unittest.TestCase):
|
||||
"""UpdateDispositionRequest unit test stubs"""
|
||||
|
||||
def setUp(self):
|
||||
pass
|
||||
|
||||
def tearDown(self):
|
||||
pass
|
||||
|
||||
def make_instance(self, include_optional) -> UpdateDispositionRequest:
|
||||
"""Test UpdateDispositionRequest
|
||||
include_optional is a boolean, when False only required
|
||||
params are included, when True both required and
|
||||
optional params are included """
|
||||
# uncomment below to create an instance of `UpdateDispositionRequest`
|
||||
"""
|
||||
model = UpdateDispositionRequest()
|
||||
if include_optional:
|
||||
return UpdateDispositionRequest(
|
||||
disposition = {empathy=3, literalism=3, skepticism=3}
|
||||
)
|
||||
else:
|
||||
return UpdateDispositionRequest(
|
||||
disposition = {empathy=3, literalism=3, skepticism=3},
|
||||
)
|
||||
"""
|
||||
|
||||
def testUpdateDispositionRequest(self):
|
||||
"""Test UpdateDispositionRequest"""
|
||||
# inst_req_only = self.make_instance(include_optional=False)
|
||||
# inst_req_and_optional = self.make_instance(include_optional=True)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,52 @@
|
||||
# coding: utf-8
|
||||
|
||||
"""
|
||||
Hindsight HTTP API
|
||||
|
||||
HTTP API for Hindsight
|
||||
|
||||
The version of the OpenAPI document: 1.0.0
|
||||
Generated by OpenAPI Generator (https://openapi-generator.tech)
|
||||
|
||||
Do not edit the class manually.
|
||||
""" # noqa: E501
|
||||
|
||||
|
||||
import unittest
|
||||
|
||||
from hindsight_client_api.models.update_personality_request import UpdatePersonalityRequest
|
||||
|
||||
class TestUpdatePersonalityRequest(unittest.TestCase):
|
||||
"""UpdatePersonalityRequest unit test stubs"""
|
||||
|
||||
def setUp(self):
|
||||
pass
|
||||
|
||||
def tearDown(self):
|
||||
pass
|
||||
|
||||
def make_instance(self, include_optional) -> UpdatePersonalityRequest:
|
||||
"""Test UpdatePersonalityRequest
|
||||
include_optional is a boolean, when False only required
|
||||
params are included, when True both required and
|
||||
optional params are included """
|
||||
# uncomment below to create an instance of `UpdatePersonalityRequest`
|
||||
"""
|
||||
model = UpdatePersonalityRequest()
|
||||
if include_optional:
|
||||
return UpdatePersonalityRequest(
|
||||
personality = {agreeableness=0.7, bias_strength=0.7, conscientiousness=0.6, extraversion=0.5, neuroticism=0.3, openness=0.8}
|
||||
)
|
||||
else:
|
||||
return UpdatePersonalityRequest(
|
||||
personality = {agreeableness=0.7, bias_strength=0.7, conscientiousness=0.6, extraversion=0.5, neuroticism=0.3, openness=0.8},
|
||||
)
|
||||
"""
|
||||
|
||||
def testUpdatePersonalityRequest(self):
|
||||
"""Test UpdatePersonalityRequest"""
|
||||
# inst_req_only = self.make_instance(include_optional=False)
|
||||
# inst_req_and_optional = self.make_instance(include_optional=True)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user