ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
# Multi-Provider Support for User Memory System
|
||||
|
||||
The User Memory System now supports multiple LLM providers, allowing you to choose the best provider for your needs.
|
||||
|
||||
### Alibaba Cloud DashScope / Bailian (Qwen)
|
||||
|
||||
- **Provider names**: `dashscope`, `qwen`, or `bailian` (aliases)
|
||||
- **API key**: `DASHSCOPE_API_KEY`
|
||||
- **Default model**: `qwen3.7-plus`
|
||||
- **Base URL**: `https://dashscope.aliyuncs.com/compatible-mode/v1`
|
||||
- For an international-region key, set `DASHSCOPE_BASE_URL=https://dashscope-intl.aliyuncs.com/compatible-mode/v1`.
|
||||
|
||||
## Supported Providers
|
||||
|
||||
### 1. **Kimi/Moonshot** (Default)
|
||||
- **Provider names**: `kimi` or `moonshot`
|
||||
- **API Key**: `MOONSHOT_API_KEY`
|
||||
- **Base URL**: `https://api.moonshot.cn/v1`
|
||||
- **Default Model**: `kimi-k3`
|
||||
|
||||
### 2. **SiliconFlow**
|
||||
- **Provider name**: `siliconflow`
|
||||
- **API Key**: `SILICONFLOW_API_KEY`
|
||||
- **Base URL**: `https://api.siliconflow.cn/v1`
|
||||
- **Default Model**: `Qwen/Qwen3-235B-A22B-Thinking-2507`
|
||||
|
||||
### 3. **Doubao**
|
||||
- **Provider name**: `doubao`
|
||||
- **API Key**: `DOUBAO_API_KEY`
|
||||
- **Base URL**: `https://ark.cn-beijing.volces.com/api/v3`
|
||||
- **Default Model**: `doubao-seed-1-6-thinking-250715`
|
||||
|
||||
### 4. **OpenRouter**
|
||||
- **Provider name**: `openrouter`
|
||||
- **API Key**: `OPENROUTER_API_KEY`
|
||||
- **Base URL**: `https://openrouter.ai/api/v1`
|
||||
- **Default Model**: `google/gemini-3.5-flash`
|
||||
- **Supported Models**:
|
||||
- `google/gemini-3.5-flash` - Google's Gemini 3.5 Flash model
|
||||
- `openai/gpt-5` - OpenAI's GPT-5 model
|
||||
- `anthropic/claude-sonnet-4` - Anthropic's Claude Sonnet 4 model
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Set the appropriate API key for your chosen provider:
|
||||
|
||||
```bash
|
||||
# For Kimi/Moonshot
|
||||
export MOONSHOT_API_KEY="your-api-key-here"
|
||||
|
||||
# For SiliconFlow
|
||||
export SILICONFLOW_API_KEY="your-api-key-here"
|
||||
|
||||
# For Doubao
|
||||
export DOUBAO_API_KEY="your-api-key-here"
|
||||
|
||||
# For OpenRouter
|
||||
export OPENROUTER_API_KEY="your-api-key-here"
|
||||
|
||||
# Set default provider (optional, defaults to 'kimi')
|
||||
export PROVIDER="siliconflow"
|
||||
|
||||
# Override default model (optional)
|
||||
export MODEL_NAME="your-custom-model-name"
|
||||
```
|
||||
|
||||
### Command-Line Usage
|
||||
|
||||
```bash
|
||||
# Use default provider (from env or 'kimi')
|
||||
python main.py --mode interactive
|
||||
|
||||
# Specify provider
|
||||
python main.py --provider siliconflow --mode interactive
|
||||
|
||||
# Alibaba Cloud Model Studio / Bailian
|
||||
export DASHSCOPE_API_KEY="your-dashscope-key"
|
||||
python main.py --provider dashscope --mode interactive
|
||||
# `qwen` and `bailian` are accepted aliases for `dashscope`.
|
||||
|
||||
# Specify provider and model
|
||||
python main.py --provider doubao --model "doubao-seed-1-6-thinking-250715" --mode demo
|
||||
|
||||
# Full example with all options
|
||||
python main.py \
|
||||
--provider siliconflow \
|
||||
--model "Qwen/Qwen3-235B-A22B-Thinking-2507" \
|
||||
--memory-mode enhanced_notes \
|
||||
--mode interactive \
|
||||
--user my_user
|
||||
|
||||
# Using OpenRouter with specific models
|
||||
python main.py --provider openrouter --model "google/gemini-3.5-flash" --mode interactive
|
||||
python main.py --provider openrouter --model "openai/gpt-5" --mode demo
|
||||
python main.py --provider openrouter --model "anthropic/claude-sonnet-4" --mode evaluation
|
||||
```
|
||||
|
||||
## Python API Usage
|
||||
|
||||
### UserMemoryAgent
|
||||
|
||||
```python
|
||||
from agent import UserMemoryAgent, UserMemoryConfig
|
||||
from config import MemoryMode
|
||||
|
||||
# Using SiliconFlow
|
||||
agent = UserMemoryAgent(
|
||||
user_id="user123",
|
||||
provider="siliconflow",
|
||||
model="Qwen/Qwen3-235B-A22B-Thinking-2507", # Optional, uses default if not specified
|
||||
config=UserMemoryConfig(memory_mode=MemoryMode.NOTES)
|
||||
)
|
||||
|
||||
# Execute a task
|
||||
result = agent.execute_task("Remember that I prefer Python for programming")
|
||||
```
|
||||
|
||||
### ConversationalAgent
|
||||
|
||||
```python
|
||||
from conversational_agent import ConversationalAgent, ConversationConfig
|
||||
|
||||
# Using Doubao
|
||||
agent = ConversationalAgent(
|
||||
user_id="user456",
|
||||
provider="doubao",
|
||||
model="doubao-seed-1-6-thinking-250715", # Optional
|
||||
config=ConversationConfig(enable_memory_context=True),
|
||||
memory_mode=MemoryMode.ENHANCED_NOTES
|
||||
)
|
||||
|
||||
# Have a conversation
|
||||
response = agent.chat("Hello, I'm John and I work at TechCorp")
|
||||
```
|
||||
|
||||
### BackgroundMemoryProcessor
|
||||
|
||||
```python
|
||||
from background_memory_processor import BackgroundMemoryProcessor, MemoryProcessorConfig
|
||||
|
||||
# Using Kimi (default)
|
||||
processor = BackgroundMemoryProcessor(
|
||||
user_id="user789",
|
||||
provider="kimi", # or "moonshot"
|
||||
config=MemoryProcessorConfig(
|
||||
conversation_interval=2
|
||||
),
|
||||
memory_mode=MemoryMode.JSON_CARDS
|
||||
)
|
||||
|
||||
# Using OpenRouter with specific model
|
||||
processor = BackgroundMemoryProcessor(
|
||||
user_id="user_openrouter",
|
||||
provider="openrouter",
|
||||
model="google/gemini-3.5-flash", # or "openai/gpt-5", "anthropic/claude-sonnet-4"
|
||||
config=MemoryProcessorConfig(
|
||||
conversation_interval=1
|
||||
),
|
||||
memory_mode=MemoryMode.ENHANCED_NOTES
|
||||
)
|
||||
|
||||
# Start background processing
|
||||
processor.start_background_processing()
|
||||
```
|
||||
|
||||
## Testing Providers
|
||||
|
||||
Run the test script to verify provider configuration:
|
||||
|
||||
```bash
|
||||
python test_providers.py
|
||||
```
|
||||
|
||||
This will test each configured provider and show which ones are properly set up.
|
||||
|
||||
## Provider Selection Guidelines
|
||||
|
||||
Choose your provider based on:
|
||||
|
||||
1. **Kimi/Moonshot**: Best for Chinese language support and general tasks
|
||||
2. **SiliconFlow**: High-performance option with Qwen models
|
||||
3. **Doubao**: ByteDance's offering with strong reasoning capabilities
|
||||
4. **OpenRouter**: Access to multiple top-tier models including:
|
||||
- **Google Gemini 2.5 Pro**: Advanced multimodal understanding and reasoning
|
||||
- **OpenAI GPT-5**: Latest generation language model with superior capabilities
|
||||
- **Anthropic Claude Sonnet 4**: Strong reasoning with constitutional AI safety
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### API Key Not Found
|
||||
If you see an error about missing API keys:
|
||||
1. Check that the environment variable is set correctly
|
||||
2. Verify the API key is valid
|
||||
3. Ensure you're using the correct provider name
|
||||
|
||||
### Connection Errors
|
||||
If you encounter connection issues:
|
||||
1. Verify your network connection
|
||||
2. Check if the provider's API endpoint is accessible
|
||||
3. Ensure your API key has the necessary permissions
|
||||
|
||||
### Model Not Available
|
||||
If a model is not available:
|
||||
1. Check the provider's documentation for available models
|
||||
2. Use the default model by not specifying the `--model` parameter
|
||||
3. Update to a currently available model
|
||||
|
||||
## Adding New Providers
|
||||
|
||||
To add support for a new provider, update the following files:
|
||||
|
||||
1. **config.py**: Add API key and base URL configuration
|
||||
2. **agent.py**: Add provider case in `__init__` method
|
||||
3. **conversational_agent.py**: Add provider case in `__init__` method
|
||||
4. **background_memory_processor.py**: No changes needed (uses UserMemoryAgent)
|
||||
5. **main.py**: Add provider to choices in argparse
|
||||
|
||||
Example for adding a new provider:
|
||||
|
||||
```python
|
||||
# In agent.py __init__ method
|
||||
elif self.provider == "new_provider":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://api.newprovider.com/v1"
|
||||
)
|
||||
self.model = model or "default-model-name"
|
||||
elif self.provider == "openrouter":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://openrouter.ai/api/v1"
|
||||
)
|
||||
self.model = model or "google/gemini-3.5-flash"
|
||||
```
|
||||
@@ -0,0 +1,321 @@
|
||||
# User Memory System / 用户记忆系统
|
||||
|
||||
> Companion material for *AI Agents in Depth*, Chapter 3 — long-term user memory with separated conversation vs background processing, multiple memory modes, multi-provider support.
|
||||
> 配套《深入理解 AI Agent》第 3 章——长期用户记忆:对话与后台记忆处理分离、多种记忆模式、多模型提供商。
|
||||
|
||||
← [Chapter 3 index / 返回第 3 章目录](../README.md)
|
||||
|
||||
---
|
||||
|
||||
## Code map
|
||||
|
||||
- **Run first:** python main.py --mode demo --memory-mode enhanced_notes.
|
||||
- **Start here:** conversational_agent.py::ConversationalAgent.chat reads memory without directly persisting it.
|
||||
- **Core behavior:** background_memory_processor.py::BackgroundMemoryProcessor.process_recent_conversations extracts candidates and applies updates.
|
||||
- **State / protocol:** memory_manager.py owns mode-specific storage; conversation history remains separate.
|
||||
- **Verifier:** user-memory-evaluation and the evaluation mode compare evidence, not only generated summaries.
|
||||
- **Experiment variable:** notes, enhanced notes, JSON cards and advanced JSON cards.
|
||||
- **Skip on first pass:** provider adapters, streaming presentation and benchmark helpers.
|
||||
|
||||
## English
|
||||
|
||||
### Key features
|
||||
|
||||
- **Separated architecture**: conversational agent vs background memory processor
|
||||
- **Memory modes**: notes → enhanced notes → JSON cards → advanced JSON cards
|
||||
- **Providers**: Alibaba Cloud DashScope/Bailian (Qwen), Kimi/Moonshot, SiliconFlow, Doubao, OpenRouter
|
||||
- **React + tools** for structured memory ops
|
||||
- **Streaming** with tool calls
|
||||
- **Evaluation** integration with `user-memory-evaluation`
|
||||
- **Background processing** on conversation intervals
|
||||
- **Persistent** JSON storage + conversation history
|
||||
|
||||
### Installation
|
||||
|
||||
Python 3.12 with the root `ch3` extra, plus at least one LLM API key.
|
||||
|
||||
```bash
|
||||
# From the repository root: use the shared Chapter 3 environment
|
||||
uv sync --locked --python 3.12 --extra ch3
|
||||
|
||||
# Activate it before changing directories:
|
||||
# macOS/Linux:
|
||||
source .venv/bin/activate
|
||||
# Windows PowerShell: .venv\Scripts\Activate.ps1
|
||||
# Windows cmd: .venv\Scripts\activate.bat
|
||||
|
||||
# pip fallback when uv is not installed:
|
||||
# python -m pip install -e ".[ch3]"
|
||||
|
||||
cd chapter3/user-memory
|
||||
|
||||
# Single-project compatibility path, still supported during migration:
|
||||
# python -m pip install -r requirements.txt
|
||||
|
||||
cp env.example .env
|
||||
# DASHSCOPE_API_KEY / MOONSHOT_API_KEY / SILICONFLOW_API_KEY / DOUBAO_API_KEY / OPENROUTER_API_KEY
|
||||
```
|
||||
|
||||
### Quick start
|
||||
|
||||
```bash
|
||||
python quickstart.py
|
||||
|
||||
python main.py --mode interactive --user your_name
|
||||
# interactive: memory | process | save | reset | quit/exit
|
||||
|
||||
python main.py --mode demo --memory-mode enhanced_notes
|
||||
python main.py --mode evaluation --memory-mode advanced_json_cards
|
||||
```
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
User Interface
|
||||
→ Conversational Agent (dialogue, read memory, stream; no direct writes)
|
||||
→ Background Memory Processor (analyze, update via tools)
|
||||
→ Memory Manager (notes / JSON cards storage)
|
||||
```
|
||||
|
||||
**Core modules:** `conversational_agent.py`, `background_memory_processor.py`, `agent.py` (UserMemoryAgent + tools), `memory_manager.py`.
|
||||
|
||||
### Memory modes
|
||||
|
||||
1. **`notes`** — short facts/preferences
|
||||
2. **`enhanced_notes`** — contextual paragraphs
|
||||
3. **`json_cards`** — hierarchical JSON
|
||||
4. **`advanced_json_cards`** — full cards with backstory, person, relationship, timestamps
|
||||
|
||||
### Execution modes
|
||||
|
||||
```bash
|
||||
python main.py --mode interactive \
|
||||
--user john_doe \
|
||||
--memory-mode enhanced_notes \
|
||||
--conversation-interval 2
|
||||
|
||||
python main.py --mode demo --provider siliconflow --memory-mode json_cards
|
||||
python main.py --mode evaluation --memory-mode advanced_json_cards --provider kimi
|
||||
```
|
||||
|
||||
### Providers
|
||||
|
||||
| Provider | Models (examples) | Notes |
|
||||
|----------|-------------------|--------|
|
||||
| DashScope / Bailian (Qwen) | qwen3.7-plus | Alibaba Cloud Model Studio; `qwen` and `bailian` are aliases |
|
||||
| Kimi/Moonshot | kimi-k3 | Chinese, general |
|
||||
| SiliconFlow | Qwen3-235B-… | High performance |
|
||||
| Doubao | doubao-seed-1-6-thinking-… | ByteDance |
|
||||
| OpenRouter | Gemini / GPT / Claude | Multi-model |
|
||||
|
||||
```bash
|
||||
python main.py --provider siliconflow --model "Qwen/Qwen3-235B-A22B-Thinking-2507"
|
||||
python main.py --provider openrouter --model "google/gemini-3.5-flash"
|
||||
python main.py --provider doubao --model "doubao-seed-1-6-thinking-250715"
|
||||
python main.py --provider dashscope --model "qwen3.7-plus"
|
||||
```
|
||||
|
||||
### API usage
|
||||
|
||||
```python
|
||||
from conversational_agent import ConversationalAgent, ConversationConfig
|
||||
from config import MemoryMode
|
||||
|
||||
agent = ConversationalAgent(
|
||||
user_id="user123",
|
||||
provider="kimi",
|
||||
config=ConversationConfig(enable_memory_context=True, temperature=0.7),
|
||||
memory_mode=MemoryMode.ENHANCED_NOTES
|
||||
)
|
||||
response = agent.chat("Hi, I'm Alice and I work at TechCorp")
|
||||
```
|
||||
|
||||
```python
|
||||
from background_memory_processor import BackgroundMemoryProcessor, MemoryProcessorConfig
|
||||
|
||||
processor = BackgroundMemoryProcessor(
|
||||
user_id="user123",
|
||||
provider="kimi",
|
||||
config=MemoryProcessorConfig(conversation_interval=2, enable_auto_processing=True),
|
||||
memory_mode=MemoryMode.JSON_CARDS
|
||||
)
|
||||
processor.start_background_processing()
|
||||
results = processor.process_recent_conversations()
|
||||
```
|
||||
|
||||
```python
|
||||
from agent import UserMemoryAgent, UserMemoryConfig
|
||||
|
||||
agent = UserMemoryAgent(
|
||||
user_id="user123",
|
||||
provider="siliconflow",
|
||||
config=UserMemoryConfig(enable_memory_updates=True, memory_mode=MemoryMode.ADVANCED_JSON_CARDS)
|
||||
)
|
||||
result = agent.execute_task("Remember that I prefer Python and my email is john@example.com")
|
||||
```
|
||||
|
||||
### Evaluation
|
||||
|
||||
```bash
|
||||
python main.py --mode evaluation --memory-mode advanced_json_cards
|
||||
```
|
||||
|
||||
Uses test cases from `user-memory-evaluation` (histories → question → score/feedback; 60+ cases).
|
||||
|
||||
### Advanced configuration
|
||||
|
||||
```bash
|
||||
PROVIDER=kimi
|
||||
# For DashScope/Bailian, use PROVIDER=dashscope (or qwen/bailian) and set DASHSCOPE_API_KEY.
|
||||
MODEL_TEMPERATURE=0.3
|
||||
MODEL_MAX_TOKENS=4096
|
||||
MEMORY_MODE=enhanced_notes
|
||||
MAX_MEMORY_ITEMS=100
|
||||
MEMORY_UPDATE_TEMPERATURE=0.2
|
||||
SESSION_TIMEOUT=3600
|
||||
MAX_CONTEXT_LENGTH=8000
|
||||
MEMORY_STORAGE_DIR=data/memories
|
||||
CONVERSATION_HISTORY_DIR=data/conversations
|
||||
```
|
||||
|
||||
```bash
|
||||
python main.py \
|
||||
--mode interactive \
|
||||
--user custom_user \
|
||||
--memory-mode advanced_json_cards \
|
||||
--provider openrouter \
|
||||
--model "google/gemini-3.5-flash" \
|
||||
--conversation-interval 3 \
|
||||
--background-processing True \
|
||||
--no-verbose
|
||||
```
|
||||
|
||||
### Project structure
|
||||
|
||||
```
|
||||
user-memory/
|
||||
├── main.py, quickstart.py, agent.py
|
||||
├── conversational_agent.py, background_memory_processor.py
|
||||
├── memory_manager.py, config.py, conversation_history.py
|
||||
├── memory_operation_formatter.py, run_evaluation.py, locomo_benchmark.py
|
||||
├── PROVIDERS.md, requirements.txt, env.example
|
||||
├── data/{memories,conversations}/, logs/
|
||||
```
|
||||
|
||||
### Development smoke tests
|
||||
|
||||
```bash
|
||||
python quickstart.py
|
||||
python -c "from memory_manager import NotesMemoryManager; m=NotesMemoryManager('smoke'); print(m.consolidate_memories())"
|
||||
```
|
||||
|
||||
### Notes / license
|
||||
|
||||
Background processing is async; tools logged; streaming supported; state persists. Educational materials.
|
||||
|
||||
---
|
||||
|
||||
## 中文
|
||||
|
||||
### 关键特性
|
||||
|
||||
- **分离架构**:对话 Agent 与后台记忆处理器解耦
|
||||
- **多种记忆模式**:简单笔记 → 增强笔记 → JSON 卡片 → Advanced JSON Cards
|
||||
- **多提供商**:Kimi、SiliconFlow、豆包、OpenRouter
|
||||
- **React + 工具** 结构化记忆操作
|
||||
- **流式输出**、**评测集成**、**按间隔后台更新**、**JSON 持久化**
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
# 在仓库根目录使用统一的第 3 章环境
|
||||
uv sync --locked --python 3.12 --extra ch3
|
||||
|
||||
# 切换目录前先激活环境:
|
||||
# macOS/Linux:
|
||||
source .venv/bin/activate
|
||||
# Windows PowerShell:.venv\Scripts\Activate.ps1
|
||||
# Windows cmd:.venv\Scripts\activate.bat
|
||||
|
||||
# 未安装 uv 时可用 pip 兜底:
|
||||
# python -m pip install -e ".[ch3]"
|
||||
|
||||
cd chapter3/user-memory
|
||||
|
||||
# 迁移期间仍支持单项目兼容路径:
|
||||
# python -m pip install -r requirements.txt
|
||||
|
||||
cp env.example .env
|
||||
# 配置 MOONSHOT_API_KEY / SILICONFLOW_API_KEY / DOUBAO_API_KEY / OPENROUTER_API_KEY
|
||||
```
|
||||
|
||||
### 快速开始
|
||||
|
||||
```bash
|
||||
python quickstart.py
|
||||
python main.py --mode interactive --user your_name
|
||||
# memory | process | save | reset | quit/exit
|
||||
|
||||
python main.py --mode demo --memory-mode enhanced_notes
|
||||
python main.py --mode evaluation --memory-mode advanced_json_cards
|
||||
```
|
||||
|
||||
### 架构
|
||||
|
||||
用户界面 → **ConversationalAgent**(对话、读记忆、流式)+ **BackgroundMemoryProcessor**(分析并写记忆)→ **MemoryManager**(笔记/JSON 卡片)。
|
||||
|
||||
核心文件:`conversational_agent.py`、`background_memory_processor.py`、`agent.py`、`memory_manager.py`。
|
||||
|
||||
### 记忆模式
|
||||
|
||||
1. **`notes`** — 短事实
|
||||
2. **`enhanced_notes`** — 带上下文的段落
|
||||
3. **`json_cards`** — 层次化 JSON
|
||||
4. **`advanced_json_cards`** — 含 backstory / person / relationship 等完整卡片
|
||||
|
||||
### 运行模式
|
||||
|
||||
```bash
|
||||
python main.py --mode interactive --user john_doe --memory-mode enhanced_notes --conversation-interval 2
|
||||
python main.py --mode demo --provider siliconflow --memory-mode json_cards
|
||||
python main.py --mode evaluation --memory-mode advanced_json_cards --provider kimi
|
||||
```
|
||||
|
||||
### 提供商
|
||||
|
||||
见 English 表与 `--provider` / `--model` 示例。
|
||||
|
||||
### 编程接口
|
||||
|
||||
见 English 节 `ConversationalAgent` / `BackgroundMemoryProcessor` / `UserMemoryAgent` 示例。
|
||||
|
||||
### 评测
|
||||
|
||||
```bash
|
||||
python main.py --mode evaluation --memory-mode advanced_json_cards
|
||||
```
|
||||
|
||||
对接 `user-memory-evaluation` 的用例与打分。
|
||||
|
||||
### 高级配置与项目结构
|
||||
|
||||
环境变量、`main.py` CLI 参数、目录树与 English 节相同。
|
||||
|
||||
### 冒烟测试
|
||||
|
||||
```bash
|
||||
python quickstart.py
|
||||
python -c "from memory_manager import NotesMemoryManager; m=NotesMemoryManager('smoke'); print(m.consolidate_memories())"
|
||||
```
|
||||
|
||||
### 说明
|
||||
|
||||
记忆后台异步处理;工具调用可记录;支持流式;状态跨会话持久。教学材料。
|
||||
|
||||
---
|
||||
|
||||
## Notes / 说明
|
||||
|
||||
### OpenRouter 通用回退 / Universal OpenRouter fallback
|
||||
|
||||
Primary provider keys take precedence; else `OPENROUTER_API_KEY` routes chat LLM via OpenRouter with automatic model id mapping. See `env.example`. Related: [`../user-memory-evaluation/`](../user-memory-evaluation/), [`../mem0/`](../mem0/), [`../memobase/`](../memobase/).
|
||||
@@ -0,0 +1,969 @@
|
||||
"""
|
||||
User Memory Agent with Kimi K3 and React pattern
|
||||
Following the system-hint project's tool-based approach
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from openai import OpenAI
|
||||
from config import Config, MemoryMode, openrouter_model_id, PROVIDER_DEFAULT_MODELS
|
||||
from memory_manager import create_memory_manager, BaseMemoryManager
|
||||
from conversation_history import ConversationHistory, ConversationTurn
|
||||
|
||||
|
||||
def _reasoning_safe_temperature(model, requested=1.0):
|
||||
"""Reasoning models (Kimi K3, GPT-5, ...) only accept temperature=1.
|
||||
Return 1 for those; otherwise the requested value so non-reasoning
|
||||
providers (Doubao, DeepSeek, older Moonshot) are unchanged."""
|
||||
m = str(model or "").lower().replace("/", "-")
|
||||
return 1 if ("kimi-k3" in m or "gpt-5" in m) else requested
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ToolCall:
|
||||
"""Represents a single tool call with tracking"""
|
||||
tool_name: str
|
||||
arguments: Dict[str, Any]
|
||||
result: Optional[Any] = None
|
||||
error: Optional[str] = None
|
||||
timestamp: str = field(default_factory=lambda: datetime.now().isoformat())
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserMemoryConfig:
|
||||
"""Configuration for the user memory agent"""
|
||||
enable_memory_updates: bool = True
|
||||
enable_conversation_history: bool = True
|
||||
enable_memory_search: bool = True
|
||||
memory_mode: MemoryMode = MemoryMode.NOTES
|
||||
max_memory_context: int = 10 # Max memory items to include in context
|
||||
save_trajectory: bool = True
|
||||
trajectory_file: str = "memory_trajectory.json"
|
||||
|
||||
|
||||
class UserMemoryAgent:
|
||||
"""
|
||||
User Memory Agent with tool-based React pattern
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
user_id: str,
|
||||
api_key: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
config: Optional[UserMemoryConfig] = None,
|
||||
verbose: bool = True):
|
||||
"""
|
||||
Initialize the agent
|
||||
|
||||
Args:
|
||||
user_id: Unique user identifier
|
||||
api_key: API key (defaults to env based on provider)
|
||||
provider: LLM provider ('dashscope'/'qwen'/'bailian', 'siliconflow', 'doubao', 'kimi', 'moonshot')
|
||||
model: Model name (defaults to provider's default)
|
||||
config: Agent configuration
|
||||
verbose: Enable verbose logging
|
||||
"""
|
||||
self.user_id = user_id
|
||||
self.verbose = verbose
|
||||
self.config = config or UserMemoryConfig()
|
||||
|
||||
# Determine provider
|
||||
self.provider = (provider or Config.PROVIDER).lower()
|
||||
self.provider = {"qwen": "dashscope", "bailian": "dashscope"}.get(
|
||||
self.provider, self.provider
|
||||
)
|
||||
|
||||
# Get API key for provider
|
||||
api_key = api_key or Config.get_api_key(self.provider)
|
||||
|
||||
# Universal OpenRouter fallback: primary provider key absent but
|
||||
# OPENROUTER_API_KEY present -> route this agent through OpenRouter.
|
||||
if not api_key and self.provider != "openrouter" and Config.OPENROUTER_API_KEY:
|
||||
model = openrouter_model_id(model or PROVIDER_DEFAULT_MODELS.get(self.provider))
|
||||
self.provider = "openrouter"
|
||||
api_key = Config.OPENROUTER_API_KEY
|
||||
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"API key required for provider '{self.provider}'. Set the "
|
||||
f"provider's key or OPENROUTER_API_KEY to use the OpenRouter fallback."
|
||||
)
|
||||
|
||||
# Configure client based on provider
|
||||
if self.provider == "dashscope":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url=Config.DASHSCOPE_BASE_URL
|
||||
)
|
||||
self.model = model or PROVIDER_DEFAULT_MODELS["dashscope"]
|
||||
elif self.provider == "siliconflow":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://api.siliconflow.cn/v1"
|
||||
)
|
||||
self.model = model or "Qwen/Qwen3-235B-A22B-Thinking-2507"
|
||||
elif self.provider == "doubao":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://ark.cn-beijing.volces.com/api/v3"
|
||||
)
|
||||
self.model = model or os.getenv("ARK_MODEL", "doubao-seed-1-6-250615")
|
||||
elif self.provider == "kimi" or self.provider == "moonshot":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://api.moonshot.cn/v1"
|
||||
)
|
||||
self.model = model or "kimi-k3"
|
||||
elif self.provider == "openrouter":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://openrouter.ai/api/v1"
|
||||
)
|
||||
# Default to Gemini 3.5 Flash, but allow any of the supported models
|
||||
self.model = model or "google/gemini-3.5-flash"
|
||||
# Supported models: google/gemini-3.5-flash, openai/gpt-5, anthropic/claude-sonnet-4
|
||||
else:
|
||||
raise ValueError(f"Unsupported provider: {self.provider}. Use 'dashscope'/'qwen'/'bailian', 'siliconflow', 'doubao', 'kimi', 'moonshot', or 'openrouter'")
|
||||
|
||||
# Initialize memory manager
|
||||
self.memory_manager = create_memory_manager(user_id, self.config.memory_mode)
|
||||
|
||||
# Initialize conversation history
|
||||
self.conversation_history = ConversationHistory(user_id) if self.config.enable_conversation_history else None
|
||||
|
||||
# Track tool calls
|
||||
self.tool_calls: List[ToolCall] = []
|
||||
self.tool_call_counts: Dict[str, int] = {}
|
||||
|
||||
# Initialize conversation
|
||||
self.conversation = []
|
||||
self.session_id = self._start_session()
|
||||
|
||||
# Initialize system prompt
|
||||
self._init_system_prompt()
|
||||
|
||||
logger.info(f"UserMemoryAgent initialized for user {user_id} with {self.provider} provider using {self.model}")
|
||||
|
||||
def _start_session(self) -> str:
|
||||
"""Start a new session"""
|
||||
return f"session-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
def _init_system_prompt(self):
|
||||
"""Initialize the system prompt with memory context based on memory mode"""
|
||||
|
||||
base_prompt = """You are an intelligent assistant with persistent memory across conversations.
|
||||
You have access to various tools to manage user memories and search conversation history. If you want to add, update, or delete multiple memories, you should use multiple tool calls at once. After you have finished updating memories, you should output STOP without any other text.
|
||||
|
||||
The full history of the latest conversation is automatically loaded in the context below.
|
||||
|
||||
## Key Behaviors:
|
||||
1. All user memories are automatically loaded and shown in the "USER MEMORIES" section below
|
||||
2. Proactively update memories when learning new information about the user
|
||||
3. Reference relevant memories when responding
|
||||
4. Maintain consistency with previously stored information
|
||||
5. Be personalized based on what you know about the user
|
||||
|
||||
"""
|
||||
|
||||
# Add mode-specific memory instructions
|
||||
if self.config.memory_mode == MemoryMode.NOTES:
|
||||
memory_instructions = """## Memory Management:
|
||||
- All user memories are pre-loaded in the context below
|
||||
- Use `add_memory` to store new important information about the user
|
||||
- Use `update_memory` to modify existing memories
|
||||
- Use `delete_memory` to remove outdated or incorrect memories
|
||||
|
||||
Keep memories as simple facts or preferences."""
|
||||
|
||||
elif self.config.memory_mode == MemoryMode.ENHANCED_NOTES:
|
||||
memory_instructions = """## Memory Management:
|
||||
- All user memories are pre-loaded in the context below
|
||||
- Use `add_memory` to store new important information about the user
|
||||
- Use `update_memory` to modify existing memories
|
||||
- Use `delete_memory` to remove outdated or incorrect memories
|
||||
|
||||
IMPORTANT: Each note should contain all important factual information and user preferences in a complete, contextual manner.
|
||||
Notes can be full paragraphs that capture the complete context, not just simple key-value pairs.
|
||||
|
||||
Example of good enhanced notes:
|
||||
- "User works at TechCorp as a senior software engineer, specializing in machine learning. They've been there for 3 years and enjoy the collaborative culture."
|
||||
- "User's email is john.doe@techcorp.com for work and johndoe.personal@gmail.com for personal matters. They prefer work emails during business hours only."
|
||||
- "User has two children: Sarah (8 years old, loves soccer) and Michael (5 years old, interested in dinosaurs). Both attend Oakwood Elementary School."
|
||||
|
||||
Extract all factual information from the conversation that may be useful for future interactions."""
|
||||
|
||||
elif self.config.memory_mode == MemoryMode.JSON_CARDS:
|
||||
memory_instructions = """## Memory Management:
|
||||
- All user memories are pre-loaded in the context below
|
||||
- Use `add_memory` to store new memory cards with structured data
|
||||
- Use `update_memory` to modify existing memory cards
|
||||
- Use `delete_memory` to remove outdated memory cards
|
||||
|
||||
Memory cards use a hierarchical structure: category -> subcategory -> key -> value
|
||||
|
||||
Example operations:
|
||||
1. Adding a memory card:
|
||||
content: {"category": "personal", "subcategory": "contact", "key": "email", "value": "user@example.com"}
|
||||
|
||||
2. Updating a memory card:
|
||||
memory_id: "personal.contact.email"
|
||||
content: {"value": "newemail@example.com"}
|
||||
|
||||
3. Structure examples:
|
||||
- personal.preferences.coding_style -> "prefers functional programming"
|
||||
- work.projects.current -> "developing AI chatbot"
|
||||
- family.children.sarah -> {"age": 8, "interests": ["soccer", "reading"]}"""
|
||||
|
||||
elif self.config.memory_mode == MemoryMode.ADVANCED_JSON_CARDS:
|
||||
memory_instructions = """## Memory Management:
|
||||
- All user memory cards are pre-loaded in the context below
|
||||
- Use `add_memory` to store complete memory card objects
|
||||
- Use `update_memory` to modify existing memory cards
|
||||
- Use `delete_memory` to remove memory cards
|
||||
|
||||
Memory cards are complete JSON objects within categories. Each card MUST include:
|
||||
- backstory: Context about when/why this information was learned (1-2 sentences)
|
||||
- date_created: Current timestamp (YYYY-MM-DD HH:MM:SS)
|
||||
- person: Who this relates to (e.g., "John Smith (primary)", "Sarah Smith (daughter)")
|
||||
- relationship: Role/relationship (e.g., "primary account holder", "family member")
|
||||
- Additional relevant fields based on the information type
|
||||
|
||||
Example memory card operations:
|
||||
|
||||
1. Adding a complete memory card:
|
||||
content: {
|
||||
"category": "financial",
|
||||
"card_key": "bank_account_primary",
|
||||
"card": {
|
||||
"backstory": "User shared their banking details while setting up automatic bill payments",
|
||||
"date_created": "2024-01-15 10:30:00",
|
||||
"person": "John Smith (primary)",
|
||||
"relationship": "primary account holder",
|
||||
"bank_name": "Chase Bank",
|
||||
"account_type": "checking",
|
||||
"account_ending": "4567",
|
||||
"routing_number": "021000021",
|
||||
"purpose": "primary checking for bills"
|
||||
}
|
||||
}
|
||||
|
||||
2. Adding a medical memory card:
|
||||
content: {
|
||||
"category": "medical",
|
||||
"card_key": "doctor_dermatologist_sarah",
|
||||
"card": {
|
||||
"backstory": "User needed to schedule a dermatology appointment for their daughter's skin condition",
|
||||
"date_created": "2024-01-16 14:00:00",
|
||||
"person": "Sarah Smith (daughter)",
|
||||
"relationship": "family member",
|
||||
"doctor_name": "Dr. Emily Johnson",
|
||||
"specialty": "Pediatric Dermatology",
|
||||
"clinic": "Children's Health Center",
|
||||
"phone": "555-0123",
|
||||
"condition_treated": "eczema"
|
||||
}
|
||||
}
|
||||
|
||||
CRITICAL: The backstory and person fields prevent confusion. For example, without proper person identification,
|
||||
a dermatologist for a child might be mistakenly suggested for an elderly parent's Alzheimer's care."""
|
||||
|
||||
else:
|
||||
memory_instructions = """## Memory Management:
|
||||
- All user memories are pre-loaded in the context below
|
||||
- Use `add_memory` to store new important information about the user
|
||||
- Use `update_memory` to modify existing memories
|
||||
- Use `delete_memory` to remove outdated or incorrect memories"""
|
||||
|
||||
system_content = base_prompt + memory_instructions + """
|
||||
|
||||
Current Memory Context will be provided with each message."""
|
||||
|
||||
self.conversation = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": system_content
|
||||
}
|
||||
]
|
||||
|
||||
def _get_memory_context(self) -> str:
|
||||
"""Get current memory context as a string"""
|
||||
context_parts = []
|
||||
|
||||
# Add memory summary
|
||||
context_parts.append("=== USER MEMORIES ===")
|
||||
context_parts.append(self.memory_manager.get_context_string())
|
||||
context_parts.append("")
|
||||
|
||||
# Add ALL conversation history if available
|
||||
if self.conversation_history and self.config.enable_conversation_history:
|
||||
# Get ALL conversation history, not just recent
|
||||
all_conversations = self.conversation_history.conversations if hasattr(self.conversation_history, 'conversations') else []
|
||||
|
||||
if all_conversations:
|
||||
context_parts.append("=== FULL CONVERSATION HISTORY ===")
|
||||
context_parts.append(f"Total conversations: {len(all_conversations)}")
|
||||
context_parts.append("")
|
||||
|
||||
for turn in all_conversations:
|
||||
context_parts.append(f"[Session: {turn.session_id}, Turn {turn.turn_number}]")
|
||||
context_parts.append(f"User: {turn.user_message}")
|
||||
context_parts.append(f"Assistant: {turn.assistant_message}")
|
||||
context_parts.append("")
|
||||
|
||||
return "\n".join(context_parts)
|
||||
|
||||
def _get_tools_description(self) -> List[Dict[str, Any]]:
|
||||
"""Get tool descriptions for the model"""
|
||||
tools = []
|
||||
|
||||
# Memory management tools
|
||||
if self.config.enable_memory_updates:
|
||||
tools.extend([
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "add_memory",
|
||||
"description": "Add a new memory about the user",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "The memory content to store"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional tags for categorizing the memory"
|
||||
}
|
||||
},
|
||||
"required": ["content"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "update_memory",
|
||||
"description": "Update an existing memory",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the memory to update"
|
||||
},
|
||||
"content": {
|
||||
"type": "string",
|
||||
"description": "New content for the memory"
|
||||
},
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Optional new tags"
|
||||
}
|
||||
},
|
||||
"required": ["memory_id", "content"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "delete_memory",
|
||||
"description": "Delete a memory",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"memory_id": {
|
||||
"type": "string",
|
||||
"description": "ID of the memory to delete"
|
||||
}
|
||||
},
|
||||
"required": ["memory_id"]
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
|
||||
return tools
|
||||
|
||||
def _execute_tool(self, tool_name: str, arguments: Dict[str, Any]) -> Tuple[Any, Optional[str]]:
|
||||
"""
|
||||
Execute a tool and return the result
|
||||
|
||||
Returns:
|
||||
Tuple of (result, error_message)
|
||||
"""
|
||||
try:
|
||||
if tool_name == "add_memory":
|
||||
result = self._tool_add_memory(**arguments)
|
||||
elif tool_name == "update_memory":
|
||||
result = self._tool_update_memory(**arguments)
|
||||
elif tool_name == "delete_memory":
|
||||
result = self._tool_delete_memory(**arguments)
|
||||
else:
|
||||
error = f"Unknown tool: {tool_name}"
|
||||
return {"error": error}, error
|
||||
|
||||
return result, None
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Tool '{tool_name}' failed: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return {"error": error_msg}, error_msg
|
||||
|
||||
# Tool implementations
|
||||
def _tool_add_memory(self, content: Any, tags: List[str] = None) -> Dict[str, Any]:
|
||||
"""Add a new memory"""
|
||||
if self.config.memory_mode in [MemoryMode.NOTES, MemoryMode.ENHANCED_NOTES]:
|
||||
# Both basic and enhanced notes use the same storage, just different prompts
|
||||
if isinstance(content, dict):
|
||||
# If content is a dict, extract string representation
|
||||
content_str = str(content)
|
||||
else:
|
||||
content_str = content
|
||||
|
||||
memory_id = self.memory_manager.add_memory(
|
||||
content=content_str,
|
||||
session_id=self.session_id,
|
||||
tags=tags or []
|
||||
)
|
||||
|
||||
elif self.config.memory_mode == MemoryMode.JSON_CARDS:
|
||||
# Basic JSON cards mode
|
||||
if isinstance(content, dict):
|
||||
# Content should already have the structure
|
||||
memory_content = content
|
||||
else:
|
||||
try:
|
||||
parsed_content = json.loads(content)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
parsed_content = None
|
||||
|
||||
if isinstance(parsed_content, dict):
|
||||
memory_content = parsed_content
|
||||
else:
|
||||
# Fallback for legacy "key: value" style content.
|
||||
parts = str(content).split(':')
|
||||
if len(parts) >= 2:
|
||||
category = "personal"
|
||||
subcategory = "info"
|
||||
key = parts[0].strip().replace(' ', '_').lower()
|
||||
value = ':'.join(parts[1:]).strip()
|
||||
else:
|
||||
category = "general"
|
||||
subcategory = "notes"
|
||||
key = f"note_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
|
||||
value = content
|
||||
|
||||
memory_content = {
|
||||
'category': category,
|
||||
'subcategory': subcategory,
|
||||
'key': key,
|
||||
'value': value
|
||||
}
|
||||
|
||||
memory_id = self.memory_manager.add_memory(
|
||||
content=memory_content,
|
||||
session_id=self.session_id
|
||||
)
|
||||
|
||||
elif self.config.memory_mode == MemoryMode.ADVANCED_JSON_CARDS:
|
||||
# Advanced JSON cards mode
|
||||
if not isinstance(content, dict):
|
||||
try:
|
||||
content = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Advanced JSON cards mode requires properly structured JSON content"
|
||||
}
|
||||
|
||||
memory_id = self.memory_manager.add_memory(
|
||||
content=content,
|
||||
session_id=self.session_id
|
||||
)
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": f"Unknown memory mode: {self.config.memory_mode}"
|
||||
}
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"memory_id": memory_id,
|
||||
"message": f"Memory added successfully"
|
||||
}
|
||||
|
||||
def _tool_update_memory(self, memory_id: str, content: Any, tags: List[str] = None) -> Dict[str, Any]:
|
||||
"""Update an existing memory"""
|
||||
if self.config.memory_mode in [MemoryMode.NOTES, MemoryMode.ENHANCED_NOTES]:
|
||||
# Both basic and enhanced notes use the same storage
|
||||
if isinstance(content, dict):
|
||||
content_str = str(content)
|
||||
else:
|
||||
content_str = content
|
||||
|
||||
success = self.memory_manager.update_memory(
|
||||
memory_id=memory_id,
|
||||
content=content_str,
|
||||
session_id=self.session_id,
|
||||
tags=tags
|
||||
)
|
||||
|
||||
elif self.config.memory_mode == MemoryMode.JSON_CARDS:
|
||||
# Basic JSON cards mode
|
||||
if isinstance(content, dict):
|
||||
memory_content = content
|
||||
else:
|
||||
# For JSON cards, parse the memory_id and content
|
||||
parts = memory_id.split('.')
|
||||
if len(parts) == 3:
|
||||
memory_content = {'value': content}
|
||||
else:
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Invalid memory_id format for JSON cards"
|
||||
}
|
||||
|
||||
success = self.memory_manager.update_memory(
|
||||
memory_id=memory_id,
|
||||
content=memory_content,
|
||||
session_id=self.session_id
|
||||
)
|
||||
|
||||
elif self.config.memory_mode == MemoryMode.ADVANCED_JSON_CARDS:
|
||||
# Advanced JSON cards mode
|
||||
if not isinstance(content, dict):
|
||||
try:
|
||||
content = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {
|
||||
"success": False,
|
||||
"message": "Advanced JSON cards mode requires properly structured JSON content"
|
||||
}
|
||||
|
||||
success = self.memory_manager.update_memory(
|
||||
memory_id=memory_id,
|
||||
content=content,
|
||||
session_id=self.session_id
|
||||
)
|
||||
else:
|
||||
success = False
|
||||
|
||||
return {
|
||||
"success": success,
|
||||
"message": "Memory updated successfully" if success else "Memory not found"
|
||||
}
|
||||
|
||||
def _tool_delete_memory(self, memory_id: str) -> Dict[str, Any]:
|
||||
"""Delete a memory"""
|
||||
self.memory_manager.delete_memory(memory_id)
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Memory {memory_id} deleted"
|
||||
}
|
||||
|
||||
def _save_trajectory(self, iteration: int, final_answer: Optional[str] = None):
|
||||
"""Save current trajectory to file for debugging"""
|
||||
if not self.config.save_trajectory:
|
||||
return
|
||||
|
||||
trajectory_data = {
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"iteration": iteration,
|
||||
"user_id": self.user_id,
|
||||
"session_id": self.session_id,
|
||||
"model": self.model,
|
||||
"conversation": self.conversation,
|
||||
"tool_calls": [
|
||||
{
|
||||
"tool_name": call.tool_name,
|
||||
"arguments": call.arguments,
|
||||
"result": call.result,
|
||||
"error": call.error,
|
||||
"timestamp": call.timestamp
|
||||
}
|
||||
for call in self.tool_calls
|
||||
],
|
||||
"memory_state": self.memory_manager.get_context_string(),
|
||||
"final_answer": final_answer
|
||||
}
|
||||
|
||||
try:
|
||||
with open(self.config.trajectory_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(trajectory_data, f, indent=2, ensure_ascii=False)
|
||||
|
||||
if self.verbose:
|
||||
logger.info(f"Trajectory saved to {self.config.trajectory_file}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save trajectory: {e}")
|
||||
|
||||
def execute_task(self, task: str, max_iterations: int = 15) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute a task using React pattern with tool calls and streaming support
|
||||
|
||||
Args:
|
||||
task: The task/message from user
|
||||
max_iterations: Maximum number of tool call iterations
|
||||
|
||||
Returns:
|
||||
Task execution result
|
||||
"""
|
||||
# Add user message with memory context
|
||||
memory_context = self._get_memory_context()
|
||||
full_message = f"{task}\n\n{memory_context}"
|
||||
|
||||
# Log the full prompt
|
||||
logger.info(f"User request: {task}")
|
||||
if memory_context:
|
||||
logger.info(f"Memory context added: {memory_context}")
|
||||
|
||||
self.conversation.append({"role": "user", "content": full_message})
|
||||
|
||||
iteration = 0
|
||||
final_answer = None
|
||||
|
||||
while iteration < max_iterations:
|
||||
iteration += 1
|
||||
logger.info(f"Iteration {iteration}/{max_iterations}")
|
||||
|
||||
# Save trajectory
|
||||
self._save_trajectory(iteration)
|
||||
|
||||
logger.info(f"Sending streaming request to {self.provider.upper()} API")
|
||||
logger.info(f"Full conversation: {self.conversation}")
|
||||
logger.info(f"Tools available: {self._get_tools_description()}")
|
||||
|
||||
try:
|
||||
# Create streaming response
|
||||
stream = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=self.conversation,
|
||||
tools=self._get_tools_description(),
|
||||
tool_choice="auto",
|
||||
temperature=_reasoning_safe_temperature(self.model, 0.3),
|
||||
max_tokens=4096,
|
||||
stream=True # Enable streaming
|
||||
)
|
||||
|
||||
# Collect streaming data
|
||||
collected_content = []
|
||||
current_tool_calls = []
|
||||
|
||||
# Process the stream
|
||||
collected_reasoning = [] # Separate collection for reasoning content
|
||||
|
||||
for chunk in stream:
|
||||
if chunk.choices and chunk.choices[0].delta:
|
||||
delta = chunk.choices[0].delta
|
||||
|
||||
# Handle reasoning field (for o1 models and similar)
|
||||
if hasattr(delta, 'reasoning') and delta.reasoning:
|
||||
reasoning = delta.reasoning
|
||||
collected_reasoning.append(reasoning)
|
||||
|
||||
# Stream reasoning to console if verbose
|
||||
if self.verbose:
|
||||
if len(collected_reasoning) == 1: # First reasoning chunk
|
||||
print("\n🤔 Reasoning: ", end="", flush=True)
|
||||
print(reasoning, end="", flush=True)
|
||||
|
||||
# Handle regular content streaming
|
||||
if hasattr(delta, 'content') and delta.content:
|
||||
content = delta.content
|
||||
collected_content.append(content)
|
||||
|
||||
# Stream to console if verbose
|
||||
if self.verbose:
|
||||
if len(collected_content) == 1 and not collected_reasoning: # First content chunk
|
||||
print("\nAssistant: ", end="", flush=True)
|
||||
print(content, end="", flush=True)
|
||||
|
||||
# Handle tool calls in streaming
|
||||
if hasattr(delta, 'tool_calls') and delta.tool_calls:
|
||||
for tool_call_delta in delta.tool_calls:
|
||||
if tool_call_delta.index is not None:
|
||||
# Ensure we have enough tool calls in the list
|
||||
while len(current_tool_calls) <= tool_call_delta.index:
|
||||
current_tool_calls.append({
|
||||
"id": "",
|
||||
"type": "function",
|
||||
"function": {"name": "", "arguments": ""}
|
||||
})
|
||||
|
||||
# Update tool call data
|
||||
if tool_call_delta.id:
|
||||
current_tool_calls[tool_call_delta.index]["id"] = tool_call_delta.id
|
||||
if tool_call_delta.function:
|
||||
if tool_call_delta.function.name:
|
||||
current_tool_calls[tool_call_delta.index]["function"]["name"] = tool_call_delta.function.name
|
||||
# Print tool call name when first detected
|
||||
if self.verbose:
|
||||
print(f"\n🔧 Tool Call [{tool_call_delta.index}]: {tool_call_delta.function.name}", end="", flush=True)
|
||||
if tool_call_delta.function.arguments:
|
||||
current_tool_calls[tool_call_delta.index]["function"]["arguments"] += tool_call_delta.function.arguments
|
||||
# Stream tool arguments in verbose mode
|
||||
if self.verbose:
|
||||
# Print arguments as they stream (they come in chunks)
|
||||
print(tool_call_delta.function.arguments, end="", flush=True)
|
||||
|
||||
# Add newline after streaming content, reasoning or tool calls
|
||||
if self.verbose and (collected_content or collected_reasoning or current_tool_calls):
|
||||
print() # New line after streaming
|
||||
|
||||
# Construct complete message matching OpenAI API structure
|
||||
# Keep reasoning, content, and tool_calls as separate fields
|
||||
complete_message = {
|
||||
"role": "assistant"
|
||||
}
|
||||
|
||||
# Add reasoning field if present
|
||||
if collected_reasoning:
|
||||
reasoning_text = "".join(collected_reasoning)
|
||||
complete_message["reasoning"] = reasoning_text
|
||||
|
||||
# Add content field if present
|
||||
if collected_content:
|
||||
complete_message["content"] = "".join(collected_content)
|
||||
else:
|
||||
complete_message["content"] = None
|
||||
|
||||
# Add tool_calls field if present
|
||||
if current_tool_calls:
|
||||
complete_message["tool_calls"] = current_tool_calls
|
||||
|
||||
# Always append the message if it has reasoning, content, or tool calls
|
||||
# This preserves all assistant output including reasoning
|
||||
if complete_message.get("reasoning") or complete_message.get("content") or current_tool_calls:
|
||||
self.conversation.append(complete_message)
|
||||
|
||||
# Handle tool calls if present
|
||||
if current_tool_calls:
|
||||
for tool_call in current_tool_calls:
|
||||
function_name = tool_call["function"]["name"]
|
||||
# The assistant message with tool_calls is already in
|
||||
# self.conversation; bailing out on malformed arguments
|
||||
# would leave this tool_call_id unanswered and every
|
||||
# later request would be rejected by the provider.
|
||||
# Answer it with an error message instead.
|
||||
try:
|
||||
function_args = json.loads(tool_call["function"]["arguments"] or "{}")
|
||||
except json.JSONDecodeError as exc:
|
||||
error_msg = f"Invalid tool arguments (not valid JSON): {exc}"
|
||||
logger.info(f" ❌ Error: {error_msg}")
|
||||
if self.verbose:
|
||||
print(f"\n ❌ Tool Error: {error_msg}")
|
||||
self.conversation.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
"content": json.dumps({"error": error_msg})
|
||||
})
|
||||
continue
|
||||
|
||||
# Track tool call count
|
||||
self.tool_call_counts[function_name] = self.tool_call_counts.get(function_name, 0) + 1
|
||||
call_number = self.tool_call_counts[function_name]
|
||||
|
||||
logger.info(f"Executing tool: {function_name} (call #{call_number})")
|
||||
if self.verbose:
|
||||
print(f"\n⚡ Executing: {function_name} (call #{call_number})")
|
||||
|
||||
# Execute the tool
|
||||
result, error = self._execute_tool(function_name, function_args)
|
||||
|
||||
# Log and display result
|
||||
if error:
|
||||
logger.info(f" ❌ Error: {error}")
|
||||
if self.verbose:
|
||||
print(f"\n ❌ Tool Error: {error}")
|
||||
else:
|
||||
logger.info(f" ✅ Success: {json.dumps(result)[:200]}")
|
||||
if self.verbose:
|
||||
result_str = json.dumps(result, ensure_ascii=False)
|
||||
if len(result_str) > 200:
|
||||
result_str = result_str[:200] + "..."
|
||||
print(f"\n ✅ Tool Result: {result_str}")
|
||||
|
||||
# Record tool call
|
||||
tool_call_record = ToolCall(
|
||||
tool_name=function_name,
|
||||
arguments=function_args,
|
||||
result=result if not error else None,
|
||||
error=error
|
||||
)
|
||||
self.tool_calls.append(tool_call_record)
|
||||
|
||||
# Add tool result to conversation
|
||||
self.conversation.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call["id"],
|
||||
"content": json.dumps(result)
|
||||
})
|
||||
|
||||
# Continue to next iteration for more processing
|
||||
continue
|
||||
|
||||
elif complete_message.get("content") or complete_message.get("reasoning"):
|
||||
# No tool calls but has content or reasoning - this is the final answer
|
||||
# Prioritize content over reasoning for the final answer
|
||||
final_answer = complete_message.get("content") or complete_message.get("reasoning")
|
||||
|
||||
# Log both reasoning and content if present
|
||||
if complete_message.get("reasoning") and complete_message.get("content"):
|
||||
logger.info(f"Response complete with reasoning and content")
|
||||
else:
|
||||
logger.info(f"Response complete (no more tool calls): {final_answer}")
|
||||
|
||||
# Save conversation to history (use content if available, otherwise reasoning)
|
||||
if self.conversation_history:
|
||||
self.conversation_history.add_turn(
|
||||
session_id=self.session_id,
|
||||
user_message=task,
|
||||
assistant_message=final_answer
|
||||
)
|
||||
|
||||
# Save final trajectory
|
||||
self._save_trajectory(iteration, final_answer)
|
||||
break # Break when no more tool calls
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during streaming task execution: {str(e)}")
|
||||
self._save_trajectory(iteration)
|
||||
return {
|
||||
"error": str(e),
|
||||
"tool_calls": self.tool_calls,
|
||||
"iterations": iteration,
|
||||
"trajectory_file": self.config.trajectory_file if self.config.save_trajectory else None
|
||||
}
|
||||
|
||||
# Save final trajectory
|
||||
self._save_trajectory(iteration, final_answer)
|
||||
|
||||
# Prepare result with all relevant information
|
||||
result = {
|
||||
"final_answer": final_answer,
|
||||
"tool_calls": self.tool_calls,
|
||||
"iterations": iteration,
|
||||
"success": final_answer is not None,
|
||||
"memory_state": self.memory_manager.get_context_string(),
|
||||
"trajectory_file": self.config.trajectory_file if self.config.save_trajectory else None
|
||||
}
|
||||
|
||||
# Include reasoning if it was collected in the last message
|
||||
if self.conversation and isinstance(self.conversation[-1], dict):
|
||||
last_message = self.conversation[-1]
|
||||
if last_message.get("role") == "assistant" and last_message.get("reasoning"):
|
||||
result["reasoning"] = last_message["reasoning"]
|
||||
|
||||
return result
|
||||
|
||||
def chat(self, message: str, stream: bool = False) -> str:
|
||||
"""
|
||||
Simple chat interface (wraps execute_task for compatibility)
|
||||
|
||||
Args:
|
||||
message: User message
|
||||
stream: Whether to stream the response (only works when tools are disabled)
|
||||
|
||||
Returns:
|
||||
Assistant response
|
||||
"""
|
||||
if stream and not self.config.enable_memory_updates:
|
||||
# Stream response when tools are disabled
|
||||
return self._chat_stream(message)
|
||||
else:
|
||||
# Use regular execution with tools
|
||||
result = self.execute_task(message)
|
||||
return result.get('final_answer', result.get('error', 'I apologize, but I was unable to generate a response.'))
|
||||
|
||||
def _chat_stream(self, message: str) -> str:
|
||||
"""
|
||||
Stream chat response (only when tools are disabled)
|
||||
|
||||
Args:
|
||||
message: User message
|
||||
|
||||
Returns:
|
||||
Assistant response
|
||||
"""
|
||||
# Get memory context
|
||||
memory_context = self._get_memory_context()
|
||||
|
||||
if memory_context:
|
||||
full_message = f"Current Memory Context:\n{memory_context}\n\nUser Message: {message}"
|
||||
else:
|
||||
full_message = message
|
||||
|
||||
# Log the full prompt
|
||||
logger.info(f"User request: {message}")
|
||||
if memory_context:
|
||||
logger.info(f"Memory context added: {memory_context}")
|
||||
logger.info(f"Full prompt sent to API (streaming): {full_message}")
|
||||
|
||||
# Add to conversation
|
||||
self.conversation.append({"role": "user", "content": full_message})
|
||||
|
||||
try:
|
||||
# Stream the response
|
||||
stream = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=self.conversation,
|
||||
temperature=_reasoning_safe_temperature(self.model, 0.3),
|
||||
max_tokens=4096,
|
||||
stream=True
|
||||
)
|
||||
|
||||
# Collect and stream response
|
||||
assistant_message = ""
|
||||
logger.info("Streaming response...")
|
||||
print("\nAssistant: ", end='', flush=True)
|
||||
|
||||
for chunk in stream:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
delta = chunk.choices[0].delta.content
|
||||
assistant_message += delta
|
||||
# Stream output in real-time
|
||||
print(delta, end='', flush=True)
|
||||
|
||||
print() # New line after streaming
|
||||
|
||||
# Log the complete response
|
||||
logger.info(f"Assistant response (streamed): {assistant_message}")
|
||||
|
||||
# Add to conversation
|
||||
self.conversation.append({"role": "assistant", "content": assistant_message})
|
||||
|
||||
# Save to history if available
|
||||
if self.conversation_history:
|
||||
self.conversation_history.add_turn(
|
||||
session_id=self.session_id,
|
||||
user_message=message,
|
||||
assistant_message=assistant_message
|
||||
)
|
||||
|
||||
return assistant_message
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error during streaming: {str(e)}")
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
def reset(self):
|
||||
"""Reset the agent's state for a new conversation"""
|
||||
self.tool_calls = []
|
||||
self.tool_call_counts = {}
|
||||
self.session_id = self._start_session()
|
||||
self._init_system_prompt()
|
||||
logger.info("Agent state reset")
|
||||
@@ -0,0 +1,649 @@
|
||||
"""
|
||||
Background Memory Processor - Analyzes conversation context and updates memories
|
||||
Runs separately from the main conversational agent
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from openai import OpenAI
|
||||
from config import Config, MemoryMode
|
||||
from memory_manager import create_memory_manager, BaseMemoryManager
|
||||
from conversation_history import ConversationHistory
|
||||
from agent import UserMemoryAgent, UserMemoryConfig
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryUpdate:
|
||||
"""Represents a memory update decision"""
|
||||
action: str # 'add', 'update', 'delete', 'none'
|
||||
memory_id: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
reason: Optional[str] = None
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryProcessorConfig:
|
||||
"""Configuration for the background memory processor"""
|
||||
conversation_interval: int = 1 # Process after N conversation rounds (default: every round)
|
||||
min_conversation_turns: int = 1 # Minimum turns before processing
|
||||
context_window: int = 10 # Number of recent turns to analyze
|
||||
enable_auto_processing: bool = True
|
||||
temperature: float = 0.3 # Lower temperature for analysis
|
||||
output_operations: bool = True # Output detailed memory operations
|
||||
|
||||
|
||||
class BackgroundMemoryProcessor:
|
||||
"""
|
||||
Background processor that analyzes conversations and updates memory
|
||||
Runs separately from the main conversation flow
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
user_id: str,
|
||||
api_key: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
config: Optional[MemoryProcessorConfig] = None,
|
||||
memory_mode: MemoryMode = MemoryMode.NOTES,
|
||||
verbose: bool = True):
|
||||
"""
|
||||
Initialize the background memory processor
|
||||
|
||||
Args:
|
||||
user_id: Unique user identifier
|
||||
api_key: API key (defaults to env based on provider)
|
||||
provider: LLM provider ('dashscope'/'qwen'/'bailian', 'siliconflow', 'doubao', 'kimi', 'moonshot')
|
||||
model: Model name (defaults to provider's default)
|
||||
config: Processor configuration
|
||||
memory_mode: Memory storage mode
|
||||
verbose: Enable verbose logging
|
||||
"""
|
||||
self.user_id = user_id
|
||||
self.verbose = verbose
|
||||
self.config = config or MemoryProcessorConfig()
|
||||
self.memory_mode = memory_mode
|
||||
self.provider = provider
|
||||
self.model = model
|
||||
|
||||
# Initialize UserMemoryAgent for analysis
|
||||
agent_config = UserMemoryConfig(
|
||||
memory_mode=memory_mode,
|
||||
enable_memory_updates=True, # Agent will use its tools to update memory
|
||||
enable_memory_search=True, # Enable memory search tool
|
||||
enable_conversation_history=False,
|
||||
save_trajectory=False # Don't save trajectory for background processing
|
||||
)
|
||||
self.analysis_agent = UserMemoryAgent(
|
||||
user_id=user_id,
|
||||
api_key=api_key,
|
||||
provider=provider,
|
||||
model=model,
|
||||
config=agent_config,
|
||||
verbose=self.verbose
|
||||
)
|
||||
|
||||
# Initialize managers
|
||||
self.memory_manager = create_memory_manager(user_id, memory_mode)
|
||||
self.conversation_history = ConversationHistory(user_id)
|
||||
|
||||
# Background processing state
|
||||
self.processing_thread = None
|
||||
self.stop_processing = False
|
||||
self.last_processed_timestamp = None
|
||||
self.processing_lock = threading.Lock()
|
||||
self.conversation_count = 0 # Track conversation rounds
|
||||
self.last_processed_count = 0 # Track last processed conversation count
|
||||
self.processed_turn_ids = set() # Track which turns have been processed
|
||||
|
||||
logger.info(f"BackgroundMemoryProcessor initialized for user {user_id} with provider {provider or Config.PROVIDER}")
|
||||
|
||||
def analyze_conversation(self, conversation_context: List[Dict[str, str]]) -> List[MemoryUpdate]:
|
||||
"""
|
||||
Analyze conversation context and determine memory updates
|
||||
|
||||
Args:
|
||||
conversation_context: List of conversation messages
|
||||
|
||||
Returns:
|
||||
List of memory updates to apply
|
||||
"""
|
||||
if len(conversation_context) < self.config.min_conversation_turns * 2:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Use UserMemoryAgent to analyze the conversation
|
||||
if self.verbose:
|
||||
logger.info("Analyzing conversation using UserMemoryAgent...")
|
||||
|
||||
# Format the conversation for the agent
|
||||
conversation_str = "\n".join([
|
||||
f"{msg['role'].upper()}: {msg['content']}"
|
||||
for msg in conversation_context
|
||||
])
|
||||
|
||||
# Create a task for the agent to analyze and update memories
|
||||
task = f"""Analyze this recent conversation and update my memory accordingly.
|
||||
Extract any important facts, preferences, or information that should be remembered.
|
||||
|
||||
Recent Conversation:
|
||||
{conversation_str}
|
||||
|
||||
Please review this conversation and:
|
||||
1. Add any new important information as memories
|
||||
2. Update existing memories if there's new or changed information
|
||||
3. Delete any memories that are no longer accurate
|
||||
|
||||
Focus on extracting factual information that would be useful for future conversations."""
|
||||
|
||||
# Execute the task using the agent's tool system
|
||||
result = self.analysis_agent.execute_task(task)
|
||||
|
||||
if self.verbose:
|
||||
logger.info(f"Memory update task completed: {result.get('success', False)}")
|
||||
|
||||
# Since the agent directly updates memories via tools, we don't need to return updates
|
||||
# The memories are already updated in the memory manager
|
||||
# Return empty list as updates were applied directly
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to analyze conversation: {e}")
|
||||
return []
|
||||
|
||||
def apply_memory_updates(self, updates: List[MemoryUpdate]) -> Dict[str, Any]:
|
||||
"""
|
||||
Apply memory updates to the memory manager
|
||||
|
||||
Args:
|
||||
updates: List of memory updates to apply
|
||||
|
||||
Returns:
|
||||
Summary of applied updates
|
||||
"""
|
||||
results = {
|
||||
'added': 0,
|
||||
'updated': 0,
|
||||
'deleted': 0,
|
||||
'failed': 0,
|
||||
'details': []
|
||||
}
|
||||
|
||||
for update in updates:
|
||||
try:
|
||||
if update.action == 'add' and update.content:
|
||||
# Handle different memory modes
|
||||
if self.memory_mode in [MemoryMode.NOTES, MemoryMode.ENHANCED_NOTES]:
|
||||
memory_id = self.memory_manager.add_memory(
|
||||
content=update.content,
|
||||
session_id=f"background-{datetime.now().isoformat()}",
|
||||
tags=update.tags
|
||||
)
|
||||
elif self.memory_mode == MemoryMode.JSON_CARDS:
|
||||
# Parse content as JSON for JSON cards mode
|
||||
try:
|
||||
if isinstance(update.content, str):
|
||||
content_dict = json.loads(update.content)
|
||||
else:
|
||||
content_dict = update.content
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# Fallback to simple parsing
|
||||
parts = str(update.content).split(':')
|
||||
if len(parts) >= 2:
|
||||
content_dict = {
|
||||
'category': 'personal',
|
||||
'subcategory': 'info',
|
||||
'key': parts[0].strip().replace(' ', '_').lower(),
|
||||
'value': ':'.join(parts[1:]).strip()
|
||||
}
|
||||
else:
|
||||
content_dict = {
|
||||
'category': 'general',
|
||||
'subcategory': 'notes',
|
||||
'key': f"note_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
|
||||
'value': update.content
|
||||
}
|
||||
|
||||
memory_id = self.memory_manager.add_memory(
|
||||
content=content_dict,
|
||||
session_id=f"background-{datetime.now().isoformat()}"
|
||||
)
|
||||
elif self.memory_mode == MemoryMode.ADVANCED_JSON_CARDS:
|
||||
# For advanced JSON cards, expect proper structure
|
||||
try:
|
||||
if isinstance(update.content, str):
|
||||
content_dict = json.loads(update.content)
|
||||
else:
|
||||
content_dict = update.content
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# Skip if can't parse
|
||||
results['failed'] += 1
|
||||
continue
|
||||
|
||||
# Extract card data from the nested structure
|
||||
card_data = content_dict.get('card', {})
|
||||
|
||||
memory_id = self.memory_manager.add_memory(
|
||||
content=content_dict,
|
||||
session_id=f"background-{datetime.now().isoformat()}",
|
||||
backstory=update.reason or '',
|
||||
person=card_data.get('person', 'User'),
|
||||
relationship=card_data.get('relationship', 'primary account holder')
|
||||
)
|
||||
else:
|
||||
memory_id = self.memory_manager.add_memory(
|
||||
content=update.content,
|
||||
session_id=f"background-{datetime.now().isoformat()}",
|
||||
tags=update.tags
|
||||
)
|
||||
results['added'] += 1
|
||||
|
||||
# Format content for display
|
||||
if isinstance(update.content, dict):
|
||||
# For JSON modes, show a summary
|
||||
if self.memory_mode == MemoryMode.ADVANCED_JSON_CARDS:
|
||||
card_key = update.content.get('card_key', 'unknown')
|
||||
category = update.content.get('category', 'unknown')
|
||||
display_content = f"{category}.{card_key}"
|
||||
else:
|
||||
display_content = json.dumps(update.content, ensure_ascii=False)[:100]
|
||||
else:
|
||||
display_content = str(update.content)[:50]
|
||||
|
||||
results['details'].append(f"Added: {display_content}...")
|
||||
|
||||
# Always print to console for demo purposes
|
||||
print(f" 📝 [ADD] Memory: {display_content}")
|
||||
|
||||
if self.verbose:
|
||||
logger.info(f"Added memory: {display_content}")
|
||||
|
||||
elif update.action == 'update' and update.memory_id and update.content:
|
||||
# Handle different memory modes
|
||||
if self.memory_mode in [MemoryMode.NOTES, MemoryMode.ENHANCED_NOTES]:
|
||||
success = self.memory_manager.update_memory(
|
||||
memory_id=update.memory_id,
|
||||
content=update.content,
|
||||
session_id=f"background-{datetime.now().isoformat()}",
|
||||
tags=update.tags
|
||||
)
|
||||
elif self.memory_mode == MemoryMode.JSON_CARDS:
|
||||
# Parse content for JSON cards mode
|
||||
try:
|
||||
if isinstance(update.content, str):
|
||||
content_dict = json.loads(update.content)
|
||||
else:
|
||||
content_dict = update.content
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# Simple value update
|
||||
content_dict = {'value': update.content}
|
||||
|
||||
success = self.memory_manager.update_memory(
|
||||
memory_id=update.memory_id,
|
||||
content=content_dict,
|
||||
session_id=f"background-{datetime.now().isoformat()}"
|
||||
)
|
||||
elif self.memory_mode == MemoryMode.ADVANCED_JSON_CARDS:
|
||||
# For advanced JSON cards, expect proper structure
|
||||
try:
|
||||
if isinstance(update.content, str):
|
||||
content_dict = json.loads(update.content)
|
||||
else:
|
||||
content_dict = update.content
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
# Skip if can't parse
|
||||
results['failed'] += 1
|
||||
continue
|
||||
|
||||
success = self.memory_manager.update_memory(
|
||||
memory_id=update.memory_id,
|
||||
content=content_dict,
|
||||
session_id=f"background-{datetime.now().isoformat()}"
|
||||
)
|
||||
else:
|
||||
success = self.memory_manager.update_memory(
|
||||
memory_id=update.memory_id,
|
||||
content=update.content,
|
||||
session_id=f"background-{datetime.now().isoformat()}",
|
||||
tags=update.tags
|
||||
)
|
||||
if success:
|
||||
results['updated'] += 1
|
||||
|
||||
# Format content for display
|
||||
if isinstance(update.content, dict):
|
||||
if self.memory_mode == MemoryMode.ADVANCED_JSON_CARDS:
|
||||
card_key = update.content.get('card_key', 'unknown')
|
||||
category = update.content.get('category', 'unknown')
|
||||
display_content = f"{category}.{card_key}"
|
||||
else:
|
||||
display_content = json.dumps(update.content, ensure_ascii=False)[:100]
|
||||
else:
|
||||
display_content = str(update.content)[:50]
|
||||
|
||||
results['details'].append(f"Updated {update.memory_id}: {display_content}...")
|
||||
|
||||
# Always print to console for demo purposes
|
||||
print(f" ✏️ [UPDATE] Memory (ID: {update.memory_id[:8] if len(update.memory_id) > 8 else update.memory_id}): {display_content}")
|
||||
else:
|
||||
results['failed'] += 1
|
||||
|
||||
if self.verbose:
|
||||
if isinstance(update.content, dict):
|
||||
display_content = json.dumps(update.content, ensure_ascii=False)[:100]
|
||||
else:
|
||||
display_content = str(update.content)[:50]
|
||||
logger.info(f"Updated memory {update.memory_id}: {display_content}")
|
||||
|
||||
elif update.action == 'delete' and update.memory_id:
|
||||
self.memory_manager.delete_memory(update.memory_id)
|
||||
results['deleted'] += 1
|
||||
results['details'].append(f"Deleted: {update.memory_id}")
|
||||
|
||||
# Always print to console for demo purposes
|
||||
print(f" 🗑️ [DELETE] Memory ID: {update.memory_id}")
|
||||
|
||||
if self.verbose:
|
||||
logger.info(f"Deleted memory: {update.memory_id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply update {update.action}: {e}")
|
||||
results['failed'] += 1
|
||||
|
||||
return results
|
||||
|
||||
def process_recent_conversations(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Process recent conversations and update memories
|
||||
|
||||
Returns:
|
||||
Processing results with list of operations
|
||||
"""
|
||||
with self.processing_lock:
|
||||
# Mark the current conversations as accounted for up front.
|
||||
# Without this, an early return below leaves last_processed_count
|
||||
# stale, so should_process() stays True and the background loop
|
||||
# re-triggers every second forever.
|
||||
self.last_processed_count = self.conversation_count
|
||||
self.last_processed_timestamp = datetime.now()
|
||||
|
||||
# Reload history from disk: the main agent writes turns through its
|
||||
# own ConversationHistory instance, so this instance's in-memory
|
||||
# list is stale unless we re-read the file.
|
||||
self.conversation_history.load_history()
|
||||
|
||||
# Get recent conversation turns
|
||||
recent_turns = self.conversation_history.get_recent_turns(
|
||||
limit=self.config.context_window
|
||||
)
|
||||
|
||||
if not recent_turns:
|
||||
return {
|
||||
'message': 'No recent conversations to process',
|
||||
'operations': [],
|
||||
'summary': {'added': 0, 'updated': 0, 'deleted': 0}
|
||||
}
|
||||
|
||||
# Filter out already processed turns
|
||||
unprocessed_turns = []
|
||||
for turn in recent_turns:
|
||||
# Create a unique ID for each turn
|
||||
turn_id = f"{turn.session_id}_{turn.turn_number}_{turn.timestamp}"
|
||||
if turn_id not in self.processed_turn_ids:
|
||||
unprocessed_turns.append(turn)
|
||||
self.processed_turn_ids.add(turn_id)
|
||||
|
||||
# If all turns have been processed, nothing to do
|
||||
if not unprocessed_turns:
|
||||
return {
|
||||
'message': 'No new conversations to process',
|
||||
'operations': [],
|
||||
'summary': {'added': 0, 'updated': 0, 'deleted': 0}
|
||||
}
|
||||
|
||||
# Convert to conversation format
|
||||
conversation_context = []
|
||||
for turn in unprocessed_turns:
|
||||
conversation_context.append({
|
||||
'role': 'user',
|
||||
'content': turn.user_message
|
||||
})
|
||||
conversation_context.append({
|
||||
'role': 'assistant',
|
||||
'content': turn.assistant_message
|
||||
})
|
||||
|
||||
# Analyze conversation - this now directly updates memories via agent tools
|
||||
# The agent will process the conversation and use its tools to update memories
|
||||
_ = self.analyze_conversation(conversation_context)
|
||||
|
||||
# Get the tool call history from the agent to report what was done
|
||||
tool_calls = getattr(self.analysis_agent, 'tool_calls', [])
|
||||
|
||||
# Create operations list from tool calls
|
||||
operations = []
|
||||
summary = {'added': 0, 'updated': 0, 'deleted': 0}
|
||||
|
||||
for tool_call in tool_calls:
|
||||
if tool_call.tool_name == 'add_memory':
|
||||
operations.append({
|
||||
'action': 'add',
|
||||
'content': tool_call.arguments.get('content'),
|
||||
'result': tool_call.result
|
||||
})
|
||||
if tool_call.result and tool_call.result.get('success'):
|
||||
summary['added'] += 1
|
||||
elif tool_call.tool_name == 'update_memory':
|
||||
operations.append({
|
||||
'action': 'update',
|
||||
'memory_id': tool_call.arguments.get('memory_id'),
|
||||
'content': tool_call.arguments.get('content'),
|
||||
'result': tool_call.result
|
||||
})
|
||||
if tool_call.result and tool_call.result.get('success'):
|
||||
summary['updated'] += 1
|
||||
elif tool_call.tool_name == 'delete_memory':
|
||||
operations.append({
|
||||
'action': 'delete',
|
||||
'memory_id': tool_call.arguments.get('memory_id'),
|
||||
'result': tool_call.result
|
||||
})
|
||||
if tool_call.result and tool_call.result.get('success'):
|
||||
summary['deleted'] += 1
|
||||
|
||||
# Clear tool calls for next run
|
||||
self.analysis_agent.tool_calls = []
|
||||
|
||||
# Format final results
|
||||
final_results = {
|
||||
'analyzed_turns': len(unprocessed_turns),
|
||||
'operations': operations,
|
||||
'summary': summary,
|
||||
'details': operations # Operations are the details
|
||||
}
|
||||
|
||||
return final_results
|
||||
|
||||
def should_process(self) -> bool:
|
||||
"""
|
||||
Check if memory processing should be triggered based on conversation count
|
||||
|
||||
Returns:
|
||||
True if processing should occur
|
||||
"""
|
||||
if self.conversation_count == 0:
|
||||
return False
|
||||
|
||||
# Check if we've reached the conversation interval
|
||||
conversations_since_last = self.conversation_count - self.last_processed_count
|
||||
should_process = conversations_since_last >= self.config.conversation_interval
|
||||
|
||||
# Debug logging to understand the issue
|
||||
if should_process and self.verbose:
|
||||
logger.debug(f"Should process: conv_count={self.conversation_count}, last_processed={self.last_processed_count}, interval={self.config.conversation_interval}")
|
||||
|
||||
return should_process
|
||||
|
||||
def increment_conversation_count(self):
|
||||
"""
|
||||
Increment the conversation counter
|
||||
"""
|
||||
self.conversation_count += 1
|
||||
|
||||
if self.verbose:
|
||||
logger.info(f"Conversation count: {self.conversation_count}, Last processed: {self.last_processed_count}")
|
||||
|
||||
def _background_processing_loop(self):
|
||||
"""
|
||||
Background loop for automatic memory processing based on conversation count
|
||||
"""
|
||||
logger.info(f"Starting background memory processing (interval: every {self.config.conversation_interval} conversations)")
|
||||
|
||||
while not self.stop_processing:
|
||||
try:
|
||||
# Check every second if we should process
|
||||
time.sleep(1)
|
||||
|
||||
if self.stop_processing:
|
||||
break
|
||||
|
||||
# Check if we should process based on conversation count
|
||||
if self.should_process():
|
||||
if self.verbose:
|
||||
logger.info(f"Processing triggered: conversations={self.conversation_count}, last_processed={self.last_processed_count}")
|
||||
|
||||
results = self.process_recent_conversations()
|
||||
|
||||
if self.config.output_operations and results:
|
||||
self._output_operations(results)
|
||||
|
||||
if self.verbose:
|
||||
logger.info(f"Background processing results: {results.get('summary')}")
|
||||
logger.info(f"Updated last_processed_count to {self.last_processed_count}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in background processing: {e}")
|
||||
|
||||
logger.info("Background memory processing stopped")
|
||||
|
||||
def _output_operations(self, results: Dict[str, Any]):
|
||||
"""
|
||||
Output memory operations in a formatted way
|
||||
|
||||
Args:
|
||||
results: Processing results with operations
|
||||
"""
|
||||
operations = results.get('operations', [])
|
||||
summary = results.get('summary', {})
|
||||
|
||||
# Don't log anything if there's no actual conversation to process
|
||||
if results.get('message') in ['No recent conversations to process', 'No new conversations to process']:
|
||||
return
|
||||
|
||||
if not operations:
|
||||
# Only log when there were conversations analyzed but no updates needed
|
||||
if results.get('analyzed_turns', 0) > 0:
|
||||
logger.info("📝 Memory Operations: None (no updates needed)")
|
||||
return
|
||||
|
||||
logger.info(f"\n📝 Memory Operations ({len(operations)} total):")
|
||||
logger.info("-" * 50)
|
||||
|
||||
for i, op in enumerate(operations, 1):
|
||||
icon = {
|
||||
'add': '➕',
|
||||
'update': '📝',
|
||||
'delete': '🗑️'
|
||||
}.get(op['action'], '❓')
|
||||
|
||||
logger.info(f"{i}. {icon} {op['action'].upper()}")
|
||||
if op.get('content'):
|
||||
logger.info(f" Content: {op['content']}")
|
||||
if op.get('memory_id'):
|
||||
logger.info(f" Memory ID: {op['memory_id']}")
|
||||
if op.get('reason'):
|
||||
logger.info(f" Reason: {op['reason']}")
|
||||
if op.get('tags'):
|
||||
logger.info(f" Tags: {', '.join(op['tags'])}")
|
||||
logger.info("")
|
||||
|
||||
logger.info(f"Summary: {summary.get('added', 0)} added, {summary.get('updated', 0)} updated, {summary.get('deleted', 0)} deleted")
|
||||
logger.info("-" * 50)
|
||||
|
||||
def start_background_processing(self):
|
||||
"""Start the background memory processing thread"""
|
||||
if self.processing_thread and self.processing_thread.is_alive():
|
||||
logger.warning("Background processing already running")
|
||||
return
|
||||
|
||||
self.stop_processing = False
|
||||
# Clear processed turns when starting fresh
|
||||
self.processed_turn_ids.clear()
|
||||
self.processing_thread = threading.Thread(
|
||||
target=self._background_processing_loop,
|
||||
daemon=True
|
||||
)
|
||||
self.processing_thread.start()
|
||||
logger.info("Background memory processing started")
|
||||
|
||||
def stop_background_processing(self):
|
||||
"""Stop the background memory processing thread"""
|
||||
self.stop_processing = True
|
||||
if self.processing_thread:
|
||||
self.processing_thread.join(timeout=5)
|
||||
logger.info("Background memory processing stopped")
|
||||
|
||||
def process_conversation_batch(self, conversation_contexts: List[List[Dict[str, str]]]) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Process multiple conversation contexts in batch
|
||||
|
||||
Args:
|
||||
conversation_contexts: List of conversation contexts
|
||||
|
||||
Returns:
|
||||
List of processing results
|
||||
"""
|
||||
results = []
|
||||
|
||||
for context in conversation_contexts:
|
||||
updates = self.analyze_conversation(context)
|
||||
|
||||
operations = []
|
||||
for update in updates:
|
||||
operation = {
|
||||
'action': update.action,
|
||||
'content': update.content,
|
||||
}
|
||||
if update.memory_id:
|
||||
operation['memory_id'] = update.memory_id
|
||||
operations.append(operation)
|
||||
|
||||
if updates:
|
||||
apply_result = self.apply_memory_updates(updates)
|
||||
result = {
|
||||
'operations': operations,
|
||||
'summary': {
|
||||
'added': apply_result['added'],
|
||||
'updated': apply_result['updated'],
|
||||
'deleted': apply_result['deleted']
|
||||
}
|
||||
}
|
||||
else:
|
||||
result = {
|
||||
'message': 'No updates needed',
|
||||
'operations': [],
|
||||
'summary': {'added': 0, 'updated': 0, 'deleted': 0}
|
||||
}
|
||||
|
||||
results.append(result)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,231 @@
|
||||
"""
|
||||
Configuration module for User Memory System
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional, Dict, Any
|
||||
from dotenv import load_dotenv
|
||||
from enum import Enum
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _reasoning_safe_temperature(model, requested=1.0):
|
||||
"""Reasoning models (Kimi K3, GPT-5, ...) only accept temperature=1.
|
||||
Return 1 for those; otherwise the requested value so non-reasoning
|
||||
providers (Doubao, DeepSeek, older Moonshot) are unchanged."""
|
||||
m = str(model or "").lower().replace("/", "-")
|
||||
return 1 if ("kimi-k3" in m or "gpt-5" in m) else requested
|
||||
|
||||
|
||||
def openrouter_model_id(model) -> str:
|
||||
"""Map a provider-native model name to an OpenRouter model id, used by the
|
||||
universal OpenRouter fallback. An explicit OPENROUTER_MODEL env var wins."""
|
||||
override = os.getenv("OPENROUTER_MODEL")
|
||||
if override:
|
||||
return override
|
||||
m = (model or "").strip()
|
||||
if not m:
|
||||
return "openai/gpt-5.6-luna"
|
||||
if "/" in m:
|
||||
return m # already an OpenRouter-style id (e.g. openai/gpt-5.6-luna)
|
||||
ml = m.lower()
|
||||
if ml.startswith(("gpt-", "o1", "o3", "o4", "chatgpt")):
|
||||
return "openai/" + m
|
||||
if ml.startswith("claude-"):
|
||||
return "anthropic/claude-opus-4.8"
|
||||
if ml.startswith("kimi"):
|
||||
# kimi-k3 is not on OpenRouter; moonshotai/kimi-k2.6 is the closest hosted id.
|
||||
return "moonshotai/kimi-k2.6"
|
||||
# Provider-native ids (kimi-*/doubao-*/qwen/deepseek-*) not hosted on
|
||||
# OpenRouter under the same name -> a widely-available OpenAI chat model.
|
||||
return "openai/gpt-5.6-luna"
|
||||
|
||||
|
||||
# Default model per provider, used to map onto an OpenRouter model id when the
|
||||
# primary provider key is missing but OPENROUTER_API_KEY is present.
|
||||
PROVIDER_DEFAULT_MODELS = {
|
||||
"dashscope": "qwen3.7-plus",
|
||||
"siliconflow": "Qwen/Qwen3-235B-A22B-Thinking-2507",
|
||||
"doubao": os.getenv("ARK_MODEL", "doubao-seed-1-6-250615"),
|
||||
"kimi": "kimi-k3",
|
||||
"moonshot": "kimi-k3",
|
||||
}
|
||||
|
||||
|
||||
class MemoryMode(Enum):
|
||||
"""Memory management modes"""
|
||||
NOTES = "notes" # Simple notes/facts (basic)
|
||||
ENHANCED_NOTES = "enhanced_notes" # Enhanced notes with paragraphs containing full context
|
||||
JSON_CARDS = "json_cards" # Hierarchical JSON memory cards (basic)
|
||||
ADVANCED_JSON_CARDS = "advanced_json_cards" # Advanced JSON cards with complete card objects
|
||||
|
||||
|
||||
class Config:
|
||||
"""Configuration settings for the user memory system"""
|
||||
|
||||
# Provider Configuration
|
||||
PROVIDER: str = os.getenv("PROVIDER", "kimi").lower() # Default to kimi
|
||||
|
||||
# API Keys for different providers
|
||||
MOONSHOT_API_KEY: str = os.getenv("MOONSHOT_API_KEY", "") # For kimi/moonshot
|
||||
SILICONFLOW_API_KEY: str = os.getenv("SILICONFLOW_API_KEY", "")
|
||||
DASHSCOPE_API_KEY: str = os.getenv("DASHSCOPE_API_KEY", "")
|
||||
# Ark is the provider's canonical product name; older companion docs used
|
||||
# DOUBAO_API_KEY. Accept both without copying credentials into local files.
|
||||
DOUBAO_API_KEY: str = os.getenv("DOUBAO_API_KEY") or os.getenv("ARK_API_KEY", "")
|
||||
OPENROUTER_API_KEY: str = os.getenv("OPENROUTER_API_KEY", "")
|
||||
|
||||
# Base URLs for different providers
|
||||
MOONSHOT_BASE_URL: str = "https://api.moonshot.cn/v1"
|
||||
SILICONFLOW_BASE_URL: str = "https://api.siliconflow.cn/v1"
|
||||
DASHSCOPE_BASE_URL: str = os.getenv(
|
||||
"DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
)
|
||||
DOUBAO_BASE_URL: str = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
OPENROUTER_BASE_URL: str = "https://openrouter.ai/api/v1"
|
||||
|
||||
# Model Configuration
|
||||
MODEL_NAME: str = os.getenv("MODEL_NAME", "") # Can override default model for provider
|
||||
MODEL_TEMPERATURE: float = float(os.getenv("MODEL_TEMPERATURE", "0.3"))
|
||||
MODEL_MAX_TOKENS: int = int(os.getenv("MODEL_MAX_TOKENS", "4096"))
|
||||
|
||||
# Memory Configuration
|
||||
MEMORY_MODE: MemoryMode = MemoryMode(os.getenv("MEMORY_MODE", "notes").lower())
|
||||
MAX_MEMORY_ITEMS: int = int(os.getenv("MAX_MEMORY_ITEMS", "100"))
|
||||
MEMORY_UPDATE_TEMPERATURE: float = float(os.getenv("MEMORY_UPDATE_TEMPERATURE", "0.2"))
|
||||
|
||||
# Dify Configuration for conversation history search
|
||||
DIFY_API_KEY: str = os.getenv("DIFY_API_KEY", "")
|
||||
DIFY_BASE_URL: str = os.getenv("DIFY_BASE_URL", "https://api.dify.ai/v1")
|
||||
DIFY_DATASET_ID: str = os.getenv("DIFY_DATASET_ID", "")
|
||||
ENABLE_HISTORY_SEARCH: bool = os.getenv("ENABLE_HISTORY_SEARCH", "false").lower() == "true"
|
||||
|
||||
# Session Configuration
|
||||
SESSION_TIMEOUT: int = int(os.getenv("SESSION_TIMEOUT", "3600")) # seconds
|
||||
MAX_CONTEXT_LENGTH: int = int(os.getenv("MAX_CONTEXT_LENGTH", "8000")) # tokens
|
||||
|
||||
# LOCOMO Benchmark Configuration
|
||||
LOCOMO_DATASET_PATH: str = os.getenv("LOCOMO_DATASET_PATH", "data/locomo")
|
||||
LOCOMO_OUTPUT_DIR: str = os.getenv("LOCOMO_OUTPUT_DIR", "results/locomo")
|
||||
|
||||
# Logging Configuration
|
||||
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO")
|
||||
LOG_FILE: Optional[str] = os.getenv("LOG_FILE", "logs/user_memory.log")
|
||||
LOG_FORMAT: str = "%(asctime)s - %(levelname)s - %(name)s - %(message)s"
|
||||
|
||||
# Storage paths
|
||||
MEMORY_STORAGE_DIR: str = os.getenv("MEMORY_STORAGE_DIR", "data/memories")
|
||||
CONVERSATION_HISTORY_DIR: str = os.getenv("CONVERSATION_HISTORY_DIR", "data/conversations")
|
||||
|
||||
@classmethod
|
||||
def get_api_key(cls, provider: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Get API key for the specified provider
|
||||
|
||||
Args:
|
||||
provider: Provider name (defaults to configured provider)
|
||||
|
||||
Returns:
|
||||
API key or None if not found
|
||||
"""
|
||||
provider = (provider or cls.PROVIDER).lower()
|
||||
provider = {"qwen": "dashscope", "bailian": "dashscope"}.get(provider, provider)
|
||||
|
||||
if provider in ["kimi", "moonshot"]:
|
||||
return cls.MOONSHOT_API_KEY
|
||||
elif provider == "dashscope":
|
||||
return cls.DASHSCOPE_API_KEY
|
||||
elif provider == "siliconflow":
|
||||
return cls.SILICONFLOW_API_KEY
|
||||
elif provider == "doubao":
|
||||
return cls.DOUBAO_API_KEY
|
||||
elif provider == "openrouter":
|
||||
return cls.OPENROUTER_API_KEY
|
||||
else:
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def validate(cls, provider: Optional[str] = None) -> bool:
|
||||
"""
|
||||
Validate required configuration
|
||||
|
||||
Args:
|
||||
provider: Provider to validate (defaults to configured provider)
|
||||
|
||||
Returns:
|
||||
True if configuration is valid
|
||||
"""
|
||||
provider = (provider or cls.PROVIDER).lower()
|
||||
provider = {"qwen": "dashscope", "bailian": "dashscope"}.get(provider, provider)
|
||||
api_key = cls.get_api_key(provider)
|
||||
|
||||
if not api_key:
|
||||
print(f"ERROR: API key for provider '{provider}' is not set")
|
||||
if provider in ["kimi", "moonshot"]:
|
||||
print("Please set MOONSHOT_API_KEY in .env file or as environment variable")
|
||||
elif provider == "siliconflow":
|
||||
print("Please set SILICONFLOW_API_KEY in .env file or as environment variable")
|
||||
elif provider == "dashscope":
|
||||
print("Please set DASHSCOPE_API_KEY in .env file or as environment variable")
|
||||
elif provider == "doubao":
|
||||
print("Please set DOUBAO_API_KEY in .env file or as environment variable")
|
||||
elif provider == "openrouter":
|
||||
print("Please set OPENROUTER_API_KEY in .env file or as environment variable")
|
||||
else:
|
||||
print(f"Unknown provider: {provider}")
|
||||
return False
|
||||
|
||||
if cls.ENABLE_HISTORY_SEARCH and not cls.DIFY_API_KEY:
|
||||
print("WARNING: History search enabled but DIFY_API_KEY not set")
|
||||
print("History search will be disabled")
|
||||
cls.ENABLE_HISTORY_SEARCH = False
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def create_directories(cls):
|
||||
"""Create necessary directories if they don't exist"""
|
||||
os.makedirs(cls.MEMORY_STORAGE_DIR, exist_ok=True)
|
||||
os.makedirs(cls.CONVERSATION_HISTORY_DIR, exist_ok=True)
|
||||
os.makedirs(cls.LOCOMO_OUTPUT_DIR, exist_ok=True)
|
||||
os.makedirs(os.path.dirname(cls.LOG_FILE) or "logs", exist_ok=True)
|
||||
|
||||
@classmethod
|
||||
def get_model_config(cls) -> Dict[str, Any]:
|
||||
"""
|
||||
Get model configuration as dictionary
|
||||
|
||||
Returns:
|
||||
Model configuration dict
|
||||
"""
|
||||
return {
|
||||
"model": cls.MODEL_NAME,
|
||||
"temperature": _reasoning_safe_temperature(cls.MODEL_NAME, cls.MODEL_TEMPERATURE),
|
||||
"max_tokens": cls.MODEL_MAX_TOKENS
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def print_config(cls):
|
||||
"""Print current configuration (hiding sensitive data)"""
|
||||
print("\n" + "="*50)
|
||||
print("USER MEMORY SYSTEM CONFIGURATION")
|
||||
print("="*50)
|
||||
print(f"Provider: {cls.PROVIDER}")
|
||||
print(f"Model: {cls.MODEL_NAME or '(using provider default)'}")
|
||||
print(f"Memory Mode: {cls.MEMORY_MODE.value}")
|
||||
print(f"Max Memory Items: {cls.MAX_MEMORY_ITEMS}")
|
||||
print(f"History Search: {'Enabled' if cls.ENABLE_HISTORY_SEARCH else 'Disabled'}")
|
||||
|
||||
# Show which API keys are set
|
||||
print(f"\nAPI Keys:")
|
||||
print(f" Kimi/Moonshot: {'✓ Set' if cls.MOONSHOT_API_KEY else '✗ Not set'}")
|
||||
print(f" DashScope/Bailian: {'✓ Set' if cls.DASHSCOPE_API_KEY else '✗ Not set'}")
|
||||
print(f" SiliconFlow: {'✓ Set' if cls.SILICONFLOW_API_KEY else '✗ Not set'}")
|
||||
print(f" Doubao: {'✓ Set' if cls.DOUBAO_API_KEY else '✗ Not set'}")
|
||||
print(f" OpenRouter: {'✓ Set' if cls.OPENROUTER_API_KEY else '✗ Not set'}")
|
||||
print(f" Dify: {'✓ Set' if cls.DIFY_API_KEY else '✗ Not set'}")
|
||||
|
||||
print(f"\nLog Level: {cls.LOG_LEVEL}")
|
||||
print("="*50 + "\n")
|
||||
@@ -0,0 +1,352 @@
|
||||
"""
|
||||
Conversation History Management with optional Dify integration for vector search
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime
|
||||
import requests
|
||||
from config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationTurn:
|
||||
"""Represents a single conversation turn"""
|
||||
session_id: str
|
||||
user_message: str
|
||||
assistant_message: str
|
||||
timestamp: str
|
||||
turn_number: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'ConversationTurn':
|
||||
"""Create from dictionary"""
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class ConversationHistory:
|
||||
"""Manages conversation history with optional vector search"""
|
||||
|
||||
def __init__(self, user_id: str):
|
||||
"""
|
||||
Initialize conversation history manager
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
"""
|
||||
self.user_id = user_id
|
||||
self.history_file = os.path.join(
|
||||
Config.CONVERSATION_HISTORY_DIR,
|
||||
f"{user_id}_history.json"
|
||||
)
|
||||
self.conversations: List[ConversationTurn] = []
|
||||
self.load_history()
|
||||
|
||||
# Initialize Dify client if configured
|
||||
self.dify_client = None
|
||||
if Config.ENABLE_HISTORY_SEARCH and Config.DIFY_API_KEY:
|
||||
self.dify_client = DifySearchClient()
|
||||
|
||||
def load_history(self):
|
||||
"""Load conversation history from storage"""
|
||||
if os.path.exists(self.history_file):
|
||||
try:
|
||||
with open(self.history_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
self.conversations = [
|
||||
ConversationTurn.from_dict(turn)
|
||||
for turn in data.get('conversations', [])
|
||||
]
|
||||
logger.info(f"Loaded {len(self.conversations)} conversation turns for user {self.user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading conversation history: {e}")
|
||||
self.conversations = []
|
||||
else:
|
||||
self.conversations = []
|
||||
|
||||
def save_history(self):
|
||||
"""Save conversation history to storage"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.history_file) or ".", exist_ok=True)
|
||||
# Write to a temp file then atomically replace: a crash mid-dump
|
||||
# must not truncate the only copy of the persisted data.
|
||||
tmp_file = self.history_file + '.tmp'
|
||||
with open(tmp_file, 'w', encoding='utf-8') as f:
|
||||
data = {
|
||||
'user_id': self.user_id,
|
||||
'updated_at': datetime.now().isoformat(),
|
||||
'conversations': [turn.to_dict() for turn in self.conversations]
|
||||
}
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp_file, self.history_file)
|
||||
logger.info(f"Saved {len(self.conversations)} conversation turns")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving conversation history: {e}")
|
||||
|
||||
def add_turn(self, session_id: str, user_message: str, assistant_message: str):
|
||||
"""
|
||||
Add a conversation turn
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
user_message: User's message
|
||||
assistant_message: Assistant's response
|
||||
"""
|
||||
turn = ConversationTurn(
|
||||
session_id=session_id,
|
||||
user_message=user_message,
|
||||
assistant_message=assistant_message,
|
||||
timestamp=datetime.now().isoformat(),
|
||||
turn_number=len(self.conversations) + 1
|
||||
)
|
||||
self.conversations.append(turn)
|
||||
self.save_history()
|
||||
|
||||
# Index in Dify if available
|
||||
if self.dify_client:
|
||||
self.dify_client.index_conversation(turn)
|
||||
|
||||
def get_recent_turns(self, limit: int = 10) -> List[ConversationTurn]:
|
||||
"""
|
||||
Get recent conversation turns
|
||||
|
||||
Args:
|
||||
limit: Maximum number of turns to return
|
||||
|
||||
Returns:
|
||||
List of recent conversation turns
|
||||
"""
|
||||
# limit<=0 → []; list[-0:] would return the full list.
|
||||
if limit <= 0 or not self.conversations:
|
||||
return []
|
||||
return self.conversations[-limit:]
|
||||
|
||||
def get_session_turns(self, session_id: str) -> List[ConversationTurn]:
|
||||
"""
|
||||
Get all turns from a specific session
|
||||
|
||||
Args:
|
||||
session_id: Session identifier
|
||||
|
||||
Returns:
|
||||
List of conversation turns from the session
|
||||
"""
|
||||
return [turn for turn in self.conversations if turn.session_id == session_id]
|
||||
|
||||
def search_history(self, query: str, limit: int = 5) -> List[ConversationTurn]:
|
||||
"""
|
||||
Search conversation history
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
List of matching conversation turns
|
||||
"""
|
||||
if limit <= 0 or not self.conversations:
|
||||
return []
|
||||
|
||||
# Try vector search with Dify if available
|
||||
if self.dify_client:
|
||||
return self.dify_client.search_conversations(query, self.user_id, limit)
|
||||
|
||||
# Fallback to simple text search
|
||||
query_lower = query.lower()
|
||||
results = []
|
||||
|
||||
for turn in self.conversations:
|
||||
if (query_lower in turn.user_message.lower() or
|
||||
query_lower in turn.assistant_message.lower()):
|
||||
results.append(turn)
|
||||
if len(results) >= limit:
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class DifySearchClient:
|
||||
"""Client for Dify vector search integration"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Dify client"""
|
||||
self.api_key = Config.DIFY_API_KEY
|
||||
self.base_url = Config.DIFY_BASE_URL
|
||||
self.dataset_id = Config.DIFY_DATASET_ID
|
||||
|
||||
if not self.api_key or not self.dataset_id:
|
||||
logger.warning("Dify API key or dataset ID not configured")
|
||||
self.enabled = False
|
||||
else:
|
||||
self.enabled = True
|
||||
self.headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
def index_conversation(self, turn: ConversationTurn):
|
||||
"""
|
||||
Index a conversation turn in Dify
|
||||
|
||||
Args:
|
||||
turn: Conversation turn to index
|
||||
"""
|
||||
if not self.enabled:
|
||||
return
|
||||
|
||||
try:
|
||||
# Prepare document for indexing
|
||||
document = {
|
||||
'name': f"{turn.session_id}_{turn.turn_number}",
|
||||
'text': f"User: {turn.user_message}\n\nAssistant: {turn.assistant_message}",
|
||||
'metadata': {
|
||||
'session_id': turn.session_id,
|
||||
'timestamp': turn.timestamp,
|
||||
'turn_number': str(turn.turn_number)
|
||||
}
|
||||
}
|
||||
|
||||
# Index document in Dify
|
||||
url = f"{self.base_url}/datasets/{self.dataset_id}/documents"
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self.headers,
|
||||
json={'documents': [document]}, timeout=30
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.debug(f"Indexed conversation turn {turn.session_id}_{turn.turn_number}")
|
||||
else:
|
||||
logger.error(f"Failed to index conversation: {response.text}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error indexing conversation in Dify: {e}")
|
||||
|
||||
def search_conversations(
|
||||
self,
|
||||
query: str,
|
||||
user_id: str,
|
||||
limit: int = 5
|
||||
) -> List[ConversationTurn]:
|
||||
"""
|
||||
Search conversations using Dify vector search
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
user_id: User identifier
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
List of matching conversation turns
|
||||
"""
|
||||
if not self.enabled:
|
||||
return []
|
||||
|
||||
try:
|
||||
# Perform vector search
|
||||
url = f"{self.base_url}/datasets/{self.dataset_id}/search"
|
||||
payload = {
|
||||
'query': query,
|
||||
'limit': limit,
|
||||
'retrieval_model': {
|
||||
'search_method': 'semantic_search',
|
||||
'reranking_enable': True,
|
||||
'reranking_model': {
|
||||
'reranking_provider_name': 'cohere',
|
||||
'reranking_model_name': 'rerank-multilingual-v2.0'
|
||||
},
|
||||
'top_k': limit * 2,
|
||||
'score_threshold_enabled': True,
|
||||
'score_threshold': 0.5
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
url,
|
||||
headers=self.headers,
|
||||
json=payload, timeout=30
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
results = response.json()
|
||||
turns = []
|
||||
|
||||
for record in results.get('records', []):
|
||||
metadata = record.get('metadata', {})
|
||||
# Parse the text back into user and assistant messages
|
||||
text = record.get('segment', {}).get('content', '')
|
||||
parts = text.split('\n\nAssistant: ')
|
||||
|
||||
if len(parts) == 2:
|
||||
user_msg = parts[0].replace('User: ', '')
|
||||
assistant_msg = parts[1]
|
||||
|
||||
turn = ConversationTurn(
|
||||
session_id=metadata.get('session_id', ''),
|
||||
user_message=user_msg,
|
||||
assistant_message=assistant_msg,
|
||||
timestamp=metadata.get('timestamp', ''),
|
||||
turn_number=int(metadata.get('turn_number', 0))
|
||||
)
|
||||
turns.append(turn)
|
||||
|
||||
return turns
|
||||
else:
|
||||
logger.error(f"Failed to search conversations: {response.text}")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching conversations in Dify: {e}")
|
||||
return []
|
||||
|
||||
|
||||
class SimpleEmbeddingSearch:
|
||||
"""Simple embedding-based search without external dependencies"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize simple embedding search"""
|
||||
# This is a placeholder for a simple embedding search
|
||||
# In production, you might use sentence-transformers or similar
|
||||
pass
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
documents: List[str],
|
||||
limit: int = 5
|
||||
) -> List[int]:
|
||||
"""
|
||||
Simple text similarity search
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
documents: List of documents to search
|
||||
limit: Maximum number of results
|
||||
|
||||
Returns:
|
||||
Indices of top matching documents
|
||||
"""
|
||||
# Simple keyword-based scoring
|
||||
query_words = set(query.lower().split())
|
||||
scores = []
|
||||
|
||||
for i, doc in enumerate(documents):
|
||||
doc_words = set(doc.lower().split())
|
||||
# Calculate Jaccard similarity
|
||||
intersection = query_words & doc_words
|
||||
union = query_words | doc_words
|
||||
score = len(intersection) / len(union) if union else 0
|
||||
scores.append((i, score))
|
||||
|
||||
# Sort by score and return top indices
|
||||
scores.sort(key=lambda x: x[1], reverse=True)
|
||||
return [idx for idx, _ in scores[:limit]]
|
||||
@@ -0,0 +1,305 @@
|
||||
"""
|
||||
Conversational Agent - Focuses purely on conversation without direct memory management
|
||||
Memory updates are handled by a separate background process
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import List, Dict, Any, Optional
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import uuid
|
||||
from openai import OpenAI
|
||||
from config import Config, openrouter_model_id, PROVIDER_DEFAULT_MODELS
|
||||
from conversation_history import ConversationHistory, ConversationTurn
|
||||
from memory_manager import create_memory_manager, BaseMemoryManager, MemoryMode
|
||||
|
||||
|
||||
def _reasoning_safe_temperature(model, requested=1.0):
|
||||
"""Reasoning models (Kimi K3, GPT-5, ...) only accept temperature=1.
|
||||
Return 1 for those; otherwise the requested value so non-reasoning
|
||||
providers (Doubao, DeepSeek, older Moonshot) are unchanged."""
|
||||
m = str(model or "").lower().replace("/", "-")
|
||||
return 1 if ("kimi-k3" in m or "gpt-5" in m) else requested
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationConfig:
|
||||
"""Configuration for the conversational agent"""
|
||||
enable_memory_context: bool = True # Include memory in context but don't update
|
||||
enable_conversation_history: bool = True
|
||||
max_memory_context: int = 10
|
||||
temperature: float = 0.7
|
||||
max_tokens: int = 4096
|
||||
|
||||
|
||||
class ConversationalAgent:
|
||||
"""
|
||||
Pure conversational agent that focuses on dialogue
|
||||
Reads memory for context but doesn't update it directly
|
||||
"""
|
||||
|
||||
def __init__(self,
|
||||
user_id: str,
|
||||
api_key: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None,
|
||||
config: Optional[ConversationConfig] = None,
|
||||
memory_mode: MemoryMode = MemoryMode.NOTES,
|
||||
verbose: bool = True):
|
||||
"""
|
||||
Initialize the conversational agent
|
||||
|
||||
Args:
|
||||
user_id: Unique user identifier
|
||||
api_key: API key (defaults to env based on provider)
|
||||
provider: LLM provider ('dashscope'/'qwen'/'bailian', 'siliconflow', 'doubao', 'kimi', 'moonshot')
|
||||
model: Model name (defaults to provider's default)
|
||||
config: Agent configuration
|
||||
memory_mode: Memory storage mode
|
||||
verbose: Enable verbose logging
|
||||
"""
|
||||
self.user_id = user_id
|
||||
self.verbose = verbose
|
||||
self.config = config or ConversationConfig()
|
||||
self.memory_mode = memory_mode
|
||||
|
||||
# Determine provider
|
||||
self.provider = (provider or Config.PROVIDER).lower()
|
||||
self.provider = {"qwen": "dashscope", "bailian": "dashscope"}.get(
|
||||
self.provider, self.provider
|
||||
)
|
||||
|
||||
# Get API key for provider
|
||||
api_key = api_key or Config.get_api_key(self.provider)
|
||||
|
||||
# Universal OpenRouter fallback: primary provider key absent but
|
||||
# OPENROUTER_API_KEY present -> route this agent through OpenRouter.
|
||||
if not api_key and self.provider != "openrouter" and Config.OPENROUTER_API_KEY:
|
||||
model = openrouter_model_id(model or PROVIDER_DEFAULT_MODELS.get(self.provider))
|
||||
self.provider = "openrouter"
|
||||
api_key = Config.OPENROUTER_API_KEY
|
||||
|
||||
if not api_key:
|
||||
raise ValueError(
|
||||
f"API key required for provider '{self.provider}'. Set the "
|
||||
f"provider's key or OPENROUTER_API_KEY to use the OpenRouter fallback."
|
||||
)
|
||||
|
||||
# Configure client based on provider
|
||||
if self.provider == "dashscope":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url=Config.DASHSCOPE_BASE_URL
|
||||
)
|
||||
self.model = model or PROVIDER_DEFAULT_MODELS["dashscope"]
|
||||
elif self.provider == "siliconflow":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://api.siliconflow.cn/v1"
|
||||
)
|
||||
self.model = model or "Qwen/Qwen3-235B-A22B-Thinking-2507"
|
||||
elif self.provider == "doubao":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://ark.cn-beijing.volces.com/api/v3"
|
||||
)
|
||||
self.model = model or os.getenv("ARK_MODEL", "doubao-seed-1-6-250615")
|
||||
elif self.provider == "kimi" or self.provider == "moonshot":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://api.moonshot.cn/v1"
|
||||
)
|
||||
self.model = model or "kimi-k3"
|
||||
elif self.provider == "openrouter":
|
||||
self.client = OpenAI(
|
||||
api_key=api_key,
|
||||
base_url="https://openrouter.ai/api/v1"
|
||||
)
|
||||
# Default to Gemini 2.5 Pro, but allow any of the supported models
|
||||
self.model = model or "google/gemini-3.5-flash"
|
||||
# Supported models: google/gemini-3.5-flash, openai/gpt-5, anthropic/claude-sonnet-4
|
||||
else:
|
||||
raise ValueError(f"Unsupported provider: {self.provider}. Use 'dashscope'/'qwen'/'bailian', 'siliconflow', 'doubao', 'kimi', 'moonshot', or 'openrouter'")
|
||||
|
||||
# Initialize memory manager (read-only access)
|
||||
self.memory_manager = create_memory_manager(user_id, memory_mode)
|
||||
|
||||
# Initialize conversation history
|
||||
self.conversation_history = ConversationHistory(user_id) if self.config.enable_conversation_history else None
|
||||
|
||||
# Track current session
|
||||
self.session_id = self._generate_session_id()
|
||||
self.conversation = []
|
||||
|
||||
# Initialize system prompt
|
||||
self._init_system_prompt()
|
||||
|
||||
logger.info(f"ConversationalAgent initialized for user {user_id} with {self.provider} provider using {self.model}")
|
||||
|
||||
def _generate_session_id(self) -> str:
|
||||
"""Generate a unique session ID"""
|
||||
return f"session-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
def _init_system_prompt(self):
|
||||
"""Initialize the system prompt"""
|
||||
system_content = """You are a helpful and personalized assistant. You have access to information about the user from previous conversations, which helps you provide personalized and contextual responses.
|
||||
|
||||
You MUST analyze the context, user's questions and memories in detail, and provide a comprehensive and detailed response.
|
||||
"""
|
||||
|
||||
self.conversation = [
|
||||
{
|
||||
"role": "system",
|
||||
"content": system_content
|
||||
}
|
||||
]
|
||||
|
||||
def _get_memory_context(self) -> str:
|
||||
"""Get current memory context as a string"""
|
||||
if not self.config.enable_memory_context:
|
||||
return ""
|
||||
|
||||
context_parts = []
|
||||
|
||||
# The background processor writes memory through its own manager
|
||||
# instance; reload from disk so its updates are visible within the
|
||||
# session (same reason main.py reloads after processing, and the
|
||||
# same fix ConversationHistory got for issue #181).
|
||||
self.memory_manager.load_memory()
|
||||
|
||||
# Add memory summary
|
||||
memory_str = self.memory_manager.get_context_string()
|
||||
if memory_str:
|
||||
context_parts.append("=== USER CONTEXT ===")
|
||||
context_parts.append(memory_str)
|
||||
context_parts.append("")
|
||||
|
||||
# Keep raw conversation turns scoped to the active session. Persisted
|
||||
# turns from earlier sessions are input to the background memory
|
||||
# processor, but the conversational agent should learn about those
|
||||
# sessions only through the structured long-term memory above.
|
||||
if self.conversation_history:
|
||||
session_turns = self.conversation_history.get_session_turns(
|
||||
self.session_id
|
||||
)
|
||||
|
||||
if session_turns:
|
||||
context_parts.append("=== CURRENT SESSION HISTORY ===")
|
||||
context_parts.append(f"Total turns: {len(session_turns)}")
|
||||
context_parts.append("")
|
||||
|
||||
for turn in session_turns:
|
||||
context_parts.append(f"[Session: {turn.session_id}, Turn {turn.turn_number}, Time: {turn.timestamp}]")
|
||||
context_parts.append(f"User: {turn.user_message}")
|
||||
context_parts.append(f"Assistant: {turn.assistant_message}")
|
||||
context_parts.append("")
|
||||
|
||||
return "\n".join(context_parts)
|
||||
|
||||
def get_conversation_context(self) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Get the full conversation context for background memory processing
|
||||
|
||||
Returns:
|
||||
List of conversation messages
|
||||
"""
|
||||
# Return a copy of the conversation without system prompt
|
||||
return [msg for msg in self.conversation[1:] if msg.get('role') != 'system']
|
||||
|
||||
def chat(self, message: str) -> str:
|
||||
"""
|
||||
Have a conversation with the user
|
||||
|
||||
Args:
|
||||
message: User message
|
||||
|
||||
Returns:
|
||||
Assistant response
|
||||
"""
|
||||
# Add memory context to the user message
|
||||
memory_context = self._get_memory_context()
|
||||
|
||||
if memory_context:
|
||||
full_message = f"{message}\n\n{memory_context}"
|
||||
else:
|
||||
full_message = message
|
||||
|
||||
# Log the full prompt if verbose
|
||||
if self.verbose:
|
||||
logger.info(f"User request: {message}")
|
||||
if memory_context:
|
||||
logger.info(f"Memory context added: {memory_context}")
|
||||
logger.info(f"Full prompt sent to API: {full_message}")
|
||||
|
||||
# Persist only the raw message; the memory/history context block is
|
||||
# sent transiently as this call's last message. Persisting
|
||||
# full_message would embed the entire history inside every user turn
|
||||
# of a conversation that already contains the previous turns natively,
|
||||
# so tokens per turn would grow O(N^2) across the session.
|
||||
self.conversation.append({"role": "user", "content": message})
|
||||
api_messages = self.conversation[:-1] + [{"role": "user", "content": full_message}]
|
||||
|
||||
try:
|
||||
# Call the model with streaming
|
||||
stream = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=api_messages,
|
||||
temperature=_reasoning_safe_temperature(self.model, self.config.temperature),
|
||||
max_tokens=self.config.max_tokens,
|
||||
stream=True
|
||||
)
|
||||
|
||||
# Collect streamed response
|
||||
assistant_message = ""
|
||||
if self.verbose:
|
||||
logger.info("Streaming response...")
|
||||
|
||||
for chunk in stream:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
delta = chunk.choices[0].delta.content
|
||||
assistant_message += delta
|
||||
# Always stream output to show real-time response
|
||||
print(delta, end='', flush=True)
|
||||
|
||||
print() # New line after streaming
|
||||
|
||||
# Add assistant response to conversation
|
||||
self.conversation.append({
|
||||
"role": "assistant",
|
||||
"content": assistant_message
|
||||
})
|
||||
|
||||
# Save to conversation history
|
||||
if self.conversation_history:
|
||||
self.conversation_history.add_turn(
|
||||
session_id=self.session_id,
|
||||
user_message=message,
|
||||
assistant_message=assistant_message
|
||||
)
|
||||
|
||||
if self.verbose:
|
||||
logger.info(f"User: {message}")
|
||||
logger.info(f"Assistant: {assistant_message}")
|
||||
|
||||
return assistant_message
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Error during conversation: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return f"I apologize, but I encountered an error: {str(e)}"
|
||||
|
||||
def reset_session(self):
|
||||
"""Start a new conversation session"""
|
||||
self.session_id = self._generate_session_id()
|
||||
self._init_system_prompt()
|
||||
logger.info(f"Started new session: {self.session_id}")
|
||||
|
||||
def get_session_id(self) -> str:
|
||||
"""Get the current session ID"""
|
||||
return self.session_id
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"user_id": "default_user",
|
||||
"updated_at": "2025-09-18T21:54:06.558179",
|
||||
"conversations": [
|
||||
{
|
||||
"session_id": "session-5853d246",
|
||||
"user_message": "What was my checking account number again? I need it to set up my direct deposit at work.",
|
||||
"assistant_message": "Your First National Bank Premium Checking account number is **4429853327**.\n\nFor the direct-deposit form you’ll also need the routing number: **123006800**.",
|
||||
"timestamp": "2025-09-18T21:54:06.557899",
|
||||
"turn_number": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"user_id": "test_user_loop",
|
||||
"updated_at": "2026-07-29T21:11:56.728444",
|
||||
"conversations": [
|
||||
{
|
||||
"session_id": "session-1",
|
||||
"user_message": "你好,我是小明",
|
||||
"assistant_message": "你好小明!",
|
||||
"timestamp": "2026-07-29T21:11:56.728341",
|
||||
"turn_number": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
{
|
||||
"user_id": "default_user",
|
||||
"type": "advanced_json_cards",
|
||||
"updated_at": "2025-09-18T21:53:57.922717",
|
||||
"categories": {
|
||||
"financial": {
|
||||
"bank_account_first_national_checking": {
|
||||
"backstory": "User opened a Premium Checking account with First National Bank during a phone conversation",
|
||||
"date_created": "2024-12-19 10:00:00",
|
||||
"person": "Michael James Robertson (primary)",
|
||||
"relationship": "primary account holder",
|
||||
"bank_name": "First National Bank",
|
||||
"account_type": "Premium Checking",
|
||||
"account_number": "4429853327",
|
||||
"routing_number": "123006800",
|
||||
"minimum_balance": 2500,
|
||||
"interest_rate": "0.5% APY",
|
||||
"benefits": [
|
||||
"no monthly fees with minimum balance",
|
||||
"free checks",
|
||||
"free domestic wire transfers",
|
||||
"0.5% APY on balance"
|
||||
],
|
||||
"initial_deposit": 5000,
|
||||
"debit_card": true,
|
||||
"debit_card_pin": "4827",
|
||||
"online_banking_username": "MRobertson503",
|
||||
"_metadata": {
|
||||
"created_at": "2025-09-18T21:53:57.920563",
|
||||
"updated_at": "2025-09-18T21:53:57.920569",
|
||||
"source": "session-54cf7c3c"
|
||||
}
|
||||
},
|
||||
"bank_account_first_national_savings": {
|
||||
"backstory": "User opened a Basic Savings account alongside Premium Checking during the same conversation",
|
||||
"date_created": "2024-12-19 10:00:00",
|
||||
"person": "Michael James Robertson (primary)",
|
||||
"relationship": "primary account holder",
|
||||
"bank_name": "First National Bank",
|
||||
"account_type": "Basic Savings",
|
||||
"account_number": "4429853328",
|
||||
"minimum_balance": 100,
|
||||
"initial_deposit": 500,
|
||||
"auto_transfer": {
|
||||
"amount": 200,
|
||||
"frequency": "monthly",
|
||||
"day": 15,
|
||||
"from": "checking",
|
||||
"to": "savings"
|
||||
},
|
||||
"_metadata": {
|
||||
"created_at": "2025-09-18T21:53:57.921206",
|
||||
"updated_at": "2025-09-18T21:53:57.921208",
|
||||
"source": "session-54cf7c3c"
|
||||
}
|
||||
},
|
||||
"bank_account_wells_fargo": {
|
||||
"backstory": "User provided Wells Fargo account details for initial deposit transfer",
|
||||
"date_created": "2024-12-19 10:00:00",
|
||||
"person": "Michael James Robertson (primary)",
|
||||
"relationship": "primary account holder",
|
||||
"bank_name": "Wells Fargo",
|
||||
"account_number": "8847293001",
|
||||
"routing_number": "121000248",
|
||||
"purpose": "funding new First National Bank accounts",
|
||||
"_metadata": {
|
||||
"created_at": "2025-09-18T21:53:57.922292",
|
||||
"updated_at": "2025-09-18T21:53:57.922293",
|
||||
"source": "session-54cf7c3c"
|
||||
}
|
||||
}
|
||||
},
|
||||
"personal": {
|
||||
"personal_info_michael": {
|
||||
"backstory": "User provided personal information while opening bank accounts",
|
||||
"date_created": "2024-12-19 10:00:00",
|
||||
"person": "Michael James Robertson (primary)",
|
||||
"relationship": "primary account holder",
|
||||
"full_name": "Michael James Robertson",
|
||||
"date_of_birth": "1985-03-15",
|
||||
"ssn": "547-82-9163",
|
||||
"drivers_license": {
|
||||
"state": "Oregon",
|
||||
"number": "D758392"
|
||||
},
|
||||
"address": {
|
||||
"street": "1847 Maple Street, Apartment 3B",
|
||||
"city": "Portland",
|
||||
"state": "Oregon",
|
||||
"zip": "97205",
|
||||
"years_at_address": 2.5
|
||||
},
|
||||
"phone": "503-555-8924",
|
||||
"email": "mrobertson85@email.com",
|
||||
"birth_city": "Denver, Colorado",
|
||||
"mother_maiden_name": "Harrison",
|
||||
"first_pet": "Buddy (golden retriever)",
|
||||
"_metadata": {
|
||||
"created_at": "2025-09-18T21:53:57.921694",
|
||||
"updated_at": "2025-09-18T21:53:57.921695",
|
||||
"source": "session-54cf7c3c"
|
||||
}
|
||||
}
|
||||
},
|
||||
"employment": {
|
||||
"employment_techcorp": {
|
||||
"backstory": "User shared employment details during bank account opening process",
|
||||
"date_created": "2024-12-19 10:00:00",
|
||||
"person": "Michael James Robertson (primary)",
|
||||
"relationship": "primary account holder",
|
||||
"employer": "TechCorp Industries",
|
||||
"position": "Software Engineer",
|
||||
"employment_status": "full-time",
|
||||
"years_employed": 4,
|
||||
"annual_income": 125000,
|
||||
"direct_deposit_setup": true,
|
||||
"_metadata": {
|
||||
"created_at": "2025-09-18T21:53:57.922018",
|
||||
"updated_at": "2025-09-18T21:53:57.922019",
|
||||
"source": "session-54cf7c3c"
|
||||
}
|
||||
}
|
||||
},
|
||||
"preferences": {
|
||||
"banking_preferences": {
|
||||
"backstory": "User expressed preferences during bank account setup conversation",
|
||||
"date_created": "2024-12-19 10:00:00",
|
||||
"person": "Michael James Robertson (primary)",
|
||||
"relationship": "primary account holder",
|
||||
"statement_preference": "electronic",
|
||||
"check_design": "standard",
|
||||
"international_travel": true,
|
||||
"overdraft_protection": "linked to savings",
|
||||
"atm_fee_reimbursement": true,
|
||||
"foreign_transaction_fee": "3%",
|
||||
"security_questions": [
|
||||
"first pet name",
|
||||
"birth city",
|
||||
"mother's maiden name"
|
||||
],
|
||||
"_metadata": {
|
||||
"created_at": "2025-09-18T21:53:57.922667",
|
||||
"updated_at": "2025-09-18T21:53:57.922668",
|
||||
"source": "session-54cf7c3c"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Demonstration of conversation-based memory processing
|
||||
Shows how memory operations are triggered per conversation round
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
from conversational_agent import ConversationalAgent, ConversationConfig
|
||||
from background_memory_processor import BackgroundMemoryProcessor, MemoryProcessorConfig
|
||||
from config import Config, MemoryMode
|
||||
from memory_manager import create_memory_manager
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def demonstrate_conversation_processing():
|
||||
"""Demonstrate the conversation-based memory processing"""
|
||||
|
||||
print("="*70)
|
||||
print("DEMONSTRATION: Conversation-Based Memory Processing")
|
||||
print("="*70)
|
||||
|
||||
# Check API key
|
||||
if not Config.MOONSHOT_API_KEY:
|
||||
print("\n❌ Please set MOONSHOT_API_KEY environment variable")
|
||||
return
|
||||
|
||||
# Setup
|
||||
user_id = "demo_conv_user"
|
||||
memory_mode = MemoryMode.NOTES
|
||||
|
||||
print(f"\n📋 Configuration:")
|
||||
print(f" • User ID: {user_id}")
|
||||
print(f" • Memory Mode: {memory_mode.value}")
|
||||
print(f" • Processing: After EACH conversation round")
|
||||
print(f" • Output: List of memory operations\n")
|
||||
|
||||
# Initialize components
|
||||
agent = ConversationalAgent(
|
||||
user_id=user_id,
|
||||
memory_mode=memory_mode,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
processor = BackgroundMemoryProcessor(
|
||||
user_id=user_id,
|
||||
memory_mode=memory_mode,
|
||||
config=MemoryProcessorConfig(
|
||||
conversation_interval=1, # Process after each conversation
|
||||
min_conversation_turns=1,
|
||||
output_operations=True
|
||||
),
|
||||
verbose=False
|
||||
)
|
||||
|
||||
print("="*70)
|
||||
print("DEMONSTRATION BEGINS")
|
||||
print("="*70)
|
||||
|
||||
# Conversation rounds
|
||||
conversations = [
|
||||
{
|
||||
"round": 1,
|
||||
"message": "Hello! I'm Jennifer, a data scientist specializing in NLP and computer vision.",
|
||||
"expected_ops": ["add"]
|
||||
},
|
||||
{
|
||||
"round": 2,
|
||||
"message": "I work at DataCorp and use Python with scikit-learn and transformers daily.",
|
||||
"expected_ops": ["add"]
|
||||
},
|
||||
{
|
||||
"round": 3,
|
||||
"message": "Actually, let me correct that - I work at AI Innovations, not DataCorp.",
|
||||
"expected_ops": ["update"]
|
||||
},
|
||||
{
|
||||
"round": 4,
|
||||
"message": "I'm also learning Rust for high-performance computing tasks.",
|
||||
"expected_ops": ["add"]
|
||||
},
|
||||
{
|
||||
"round": 5,
|
||||
"message": "What programming languages do I know?",
|
||||
"expected_ops": [] # Query, no updates expected
|
||||
}
|
||||
]
|
||||
|
||||
for conv in conversations:
|
||||
print(f"\n{'='*70}")
|
||||
print(f"CONVERSATION ROUND {conv['round']}")
|
||||
print(f"{'='*70}")
|
||||
|
||||
# User message
|
||||
print(f"\n👤 User: {conv['message']}")
|
||||
|
||||
# Get response
|
||||
response = agent.chat(conv['message'])
|
||||
print(f"\n🤖 Assistant: {response[:200]}..." if len(response) > 200 else f"\n🤖 Assistant: {response}")
|
||||
|
||||
# Increment conversation counter
|
||||
processor.increment_conversation_count()
|
||||
|
||||
# Process memory
|
||||
print(f"\n📝 Processing Memory (Round {conv['round']})...")
|
||||
print("-"*50)
|
||||
|
||||
results = processor.process_recent_conversations()
|
||||
|
||||
# Display operations
|
||||
operations = results.get('operations', [])
|
||||
|
||||
if operations:
|
||||
print(f"Memory Operations: {len(operations)} operation(s)")
|
||||
print()
|
||||
for i, op in enumerate(operations, 1):
|
||||
icon = {
|
||||
'add': '➕ ADD',
|
||||
'update': '📝 UPDATE',
|
||||
'delete': '🗑️ DELETE'
|
||||
}.get(op['action'], '❓ UNKNOWN')
|
||||
|
||||
print(f"Operation {i}: {icon}")
|
||||
|
||||
if op.get('content'):
|
||||
content = op['content']
|
||||
if len(content) > 100:
|
||||
content = content[:97] + "..."
|
||||
print(f" Content: {content}")
|
||||
|
||||
if op.get('memory_id'):
|
||||
print(f" Memory ID: {op['memory_id']}")
|
||||
|
||||
if op.get('reason'):
|
||||
print(f" Reason: {op['reason'][:100]}...")
|
||||
|
||||
print()
|
||||
else:
|
||||
print("Memory Operations: None (no updates needed)")
|
||||
|
||||
# Show if operations match expectations
|
||||
actual_ops = [op['action'] for op in operations]
|
||||
expected = conv['expected_ops']
|
||||
|
||||
if set(actual_ops) == set(expected) or (not actual_ops and not expected):
|
||||
print("✅ Operations as expected")
|
||||
else:
|
||||
print(f"⚠️ Expected {expected}, got {actual_ops}")
|
||||
|
||||
# Final memory state
|
||||
print(f"\n{'='*70}")
|
||||
print("FINAL MEMORY STATE")
|
||||
print("="*70)
|
||||
|
||||
memory_manager = create_memory_manager(user_id, memory_mode)
|
||||
memory_content = memory_manager.get_context_string()
|
||||
|
||||
print("\nStored Memories:")
|
||||
print("-"*50)
|
||||
if memory_content:
|
||||
lines = memory_content.split('\n')
|
||||
for line in lines:
|
||||
if line.strip():
|
||||
print(f" • {line.strip()}")
|
||||
else:
|
||||
print(" (No memories)")
|
||||
|
||||
print(f"\n{'='*70}")
|
||||
print("SUMMARY")
|
||||
print("="*70)
|
||||
|
||||
print("\n✅ Demonstration Complete!")
|
||||
print("\nKey Points:")
|
||||
print(" 1. Memory processing occurs after EACH conversation round")
|
||||
print(" 2. Operations list shows exactly what changes (0, 1, or more operations)")
|
||||
print(" 3. Each operation includes action type, content")
|
||||
print(" 4. No memory updates for simple queries (demonstrating intelligent processing)")
|
||||
print(" 5. Updates are incremental and context-aware")
|
||||
|
||||
|
||||
def demonstrate_interval_processing():
|
||||
"""Demonstrate processing with different conversation intervals"""
|
||||
|
||||
print("\n" + "="*70)
|
||||
print("DEMONSTRATION: Variable Conversation Intervals")
|
||||
print("="*70)
|
||||
|
||||
if not Config.MOONSHOT_API_KEY:
|
||||
print("\n❌ Please set MOONSHOT_API_KEY environment variable")
|
||||
return
|
||||
|
||||
# Test with interval = 3 (process every 3 conversations)
|
||||
user_id = "demo_interval_user"
|
||||
|
||||
print(f"\n📋 Configuration:")
|
||||
print(f" • Conversation Interval: 3 (process every 3rd conversation)")
|
||||
print(f" • This simulates batched processing\n")
|
||||
|
||||
agent = ConversationalAgent(user_id=user_id, memory_mode=MemoryMode.NOTES)
|
||||
|
||||
processor = BackgroundMemoryProcessor(
|
||||
user_id=user_id,
|
||||
memory_mode=MemoryMode.NOTES,
|
||||
config=MemoryProcessorConfig(
|
||||
conversation_interval=3, # Process every 3 conversations
|
||||
min_conversation_turns=1
|
||||
),
|
||||
verbose=False
|
||||
)
|
||||
|
||||
messages = [
|
||||
"I'm Tom and I work in finance.",
|
||||
"I use Excel and Python for data analysis.",
|
||||
"I'm learning SQL for database work.", # Should trigger processing here
|
||||
"I also manage a team of 5 analysts.",
|
||||
"We focus on risk assessment.",
|
||||
"Our main tool is Bloomberg Terminal." # Should trigger processing here
|
||||
]
|
||||
|
||||
for i, msg in enumerate(messages, 1):
|
||||
print(f"\n[Round {i}] User: {msg}")
|
||||
response = agent.chat(msg)
|
||||
print(f"Assistant: {response[:100]}...")
|
||||
|
||||
processor.increment_conversation_count()
|
||||
|
||||
if processor.should_process():
|
||||
print(f"\n🔔 Processing triggered after conversation {i}!")
|
||||
results = processor.process_recent_conversations()
|
||||
ops = results.get('operations', [])
|
||||
print(f" Operations: {len(ops)} memory update(s)")
|
||||
for op in ops:
|
||||
print(f" - {op['action']}: {op.get('content', '')[:50]}...")
|
||||
else:
|
||||
remaining = 3 - (i % 3) if (i % 3) != 0 else 3
|
||||
print(f" [Will process in {remaining} more conversation(s)]")
|
||||
|
||||
print("\n✅ Interval demonstration complete!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Demonstrate conversation-based processing")
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=["single", "interval", "both"],
|
||||
default="single",
|
||||
help="Demonstration mode"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
Config.create_directories()
|
||||
|
||||
if args.mode == "single":
|
||||
demonstrate_conversation_processing()
|
||||
elif args.mode == "interval":
|
||||
demonstrate_interval_processing()
|
||||
else:
|
||||
demonstrate_conversation_processing()
|
||||
print("\n" + "="*70 + "\n")
|
||||
demonstrate_interval_processing()
|
||||
@@ -0,0 +1,54 @@
|
||||
# User Memory System Configuration
|
||||
|
||||
# Provider Selection (default: kimi)
|
||||
# Options: dashscope (== qwen/bailian), kimi (== moonshot), siliconflow, doubao, openrouter
|
||||
PROVIDER=kimi
|
||||
|
||||
# API Keys — set the one(s) for the provider(s) you use
|
||||
MOONSHOT_API_KEY=your_moonshot_api_key_here # for kimi / moonshot
|
||||
DASHSCOPE_API_KEY=your_dashscope_api_key_here # for dashscope / qwen / bailian
|
||||
SILICONFLOW_API_KEY=your_siliconflow_api_key_here
|
||||
DOUBAO_API_KEY=your_doubao_api_key_here
|
||||
# OpenRouter: usable as an explicit PROVIDER, and also a universal fallback —
|
||||
# if the configured provider's key is missing but OPENROUTER_API_KEY is set,
|
||||
# the agent auto-routes through OpenRouter (kimi-k3 -> moonshotai/kimi-k2.6;
|
||||
# set OPENROUTER_MODEL to override).
|
||||
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
||||
|
||||
# Model Settings
|
||||
# Leave MODEL_NAME empty to use each provider's built-in default
|
||||
# (dashscope -> qwen3.7-plus, kimi -> kimi-k3, siliconflow -> Qwen/Qwen3-235B-A22B-Thinking-2507,
|
||||
# doubao -> doubao-seed-1-6-thinking-250715, openrouter -> google/gemini-3.5-flash)
|
||||
MODEL_NAME=kimi-k3
|
||||
MODEL_TEMPERATURE=0.3
|
||||
# Reasoning models (e.g. kimi-k3, gpt-5) require max_tokens >= 2048 and only
|
||||
# accept temperature=1; the code applies temperature=1 automatically for them.
|
||||
MODEL_MAX_TOKENS=4096
|
||||
|
||||
# Memory Configuration
|
||||
# Options: notes, enhanced_notes, json_cards, advanced_json_cards
|
||||
MEMORY_MODE=notes
|
||||
MAX_MEMORY_ITEMS=100
|
||||
MEMORY_UPDATE_TEMPERATURE=0.2
|
||||
|
||||
# Dify Configuration (Optional - for conversation history search)
|
||||
DIFY_API_KEY=your_dify_api_key_here
|
||||
DIFY_BASE_URL=https://api.dify.ai/v1
|
||||
DIFY_DATASET_ID=your_dataset_id
|
||||
ENABLE_HISTORY_SEARCH=false
|
||||
|
||||
# Session Configuration
|
||||
SESSION_TIMEOUT=3600
|
||||
MAX_CONTEXT_LENGTH=8000
|
||||
|
||||
# LOCOMO Benchmark Paths
|
||||
LOCOMO_DATASET_PATH=data/locomo
|
||||
LOCOMO_OUTPUT_DIR=results/locomo
|
||||
|
||||
# Storage Paths
|
||||
MEMORY_STORAGE_DIR=data/memories
|
||||
CONVERSATION_HISTORY_DIR=data/conversations
|
||||
|
||||
# Logging
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FILE=logs/user_memory.log
|
||||
@@ -0,0 +1,549 @@
|
||||
"""
|
||||
LOCOMO Benchmark Integration for User Memory System
|
||||
Based on: https://github.com/AlibabaResearch/DAMO-ConvAI/tree/main/LOCOMO
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, List, Any, Tuple
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime
|
||||
import requests
|
||||
from agent import UserMemoryAgent
|
||||
from config import Config, MemoryMode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LOCOMOResult:
|
||||
"""Result of a LOCOMO benchmark test"""
|
||||
test_id: str
|
||||
memory_mode: str
|
||||
success: bool
|
||||
score: float
|
||||
response_time: float
|
||||
memory_retrievals: int
|
||||
memory_updates: int
|
||||
details: Dict[str, Any]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return asdict(self)
|
||||
|
||||
|
||||
class LOCOMOBenchmark:
|
||||
"""LOCOMO benchmark implementation for memory systems"""
|
||||
|
||||
def __init__(self, dataset_path: str = None):
|
||||
"""
|
||||
Initialize LOCOMO benchmark
|
||||
|
||||
Args:
|
||||
dataset_path: Path to LOCOMO dataset
|
||||
"""
|
||||
self.dataset_path = dataset_path or Config.LOCOMO_DATASET_PATH
|
||||
self.results_dir = Config.LOCOMO_OUTPUT_DIR
|
||||
self.test_cases = []
|
||||
self.load_dataset()
|
||||
|
||||
def load_dataset(self):
|
||||
"""Load LOCOMO dataset"""
|
||||
# Try to load from local file
|
||||
local_file = os.path.join(self.dataset_path, "locomo_test_cases.json")
|
||||
|
||||
if os.path.exists(local_file):
|
||||
with open(local_file, 'r', encoding='utf-8') as f:
|
||||
self.test_cases = json.load(f)
|
||||
logger.info(f"Loaded {len(self.test_cases)} LOCOMO test cases")
|
||||
else:
|
||||
# If local file doesn't exist, create sample test cases
|
||||
self.test_cases = self._create_sample_test_cases()
|
||||
os.makedirs(self.dataset_path, exist_ok=True)
|
||||
with open(local_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(self.test_cases, f, indent=2, ensure_ascii=False)
|
||||
logger.info(f"Created {len(self.test_cases)} sample LOCOMO test cases")
|
||||
|
||||
def _create_sample_test_cases(self) -> List[Dict[str, Any]]:
|
||||
"""Create sample test cases based on LOCOMO structure"""
|
||||
return [
|
||||
{
|
||||
"test_id": "personal_info_retention",
|
||||
"category": "memory_retention",
|
||||
"conversations": [
|
||||
{
|
||||
"turn": 1,
|
||||
"user": "Hi, I'm Alice. I work as a software engineer at TechCorp.",
|
||||
"expected_memory": ["name: Alice", "occupation: software engineer", "company: TechCorp"]
|
||||
},
|
||||
{
|
||||
"turn": 2,
|
||||
"user": "What do you remember about me?",
|
||||
"expected_response_contains": ["Alice", "software engineer", "TechCorp"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_id": "preference_tracking",
|
||||
"category": "preference_memory",
|
||||
"conversations": [
|
||||
{
|
||||
"turn": 1,
|
||||
"user": "I prefer Python over Java for backend development, and I love dark theme IDEs.",
|
||||
"expected_memory": ["language_preference: Python", "theme_preference: dark"]
|
||||
},
|
||||
{
|
||||
"turn": 2,
|
||||
"user": "What programming setup would you recommend for me?",
|
||||
"expected_response_contains": ["Python", "dark theme"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_id": "context_switching",
|
||||
"category": "context_management",
|
||||
"conversations": [
|
||||
{
|
||||
"turn": 1,
|
||||
"user": "I'm planning a trip to Tokyo next month for a tech conference.",
|
||||
"expected_memory": ["travel_plan: Tokyo", "purpose: tech conference"]
|
||||
},
|
||||
{
|
||||
"turn": 2,
|
||||
"user": "By the way, my favorite color is blue.",
|
||||
"expected_memory": ["favorite_color: blue"]
|
||||
},
|
||||
{
|
||||
"turn": 3,
|
||||
"user": "What should I pack for my trip?",
|
||||
"expected_response_contains": ["Tokyo", "conference", "tech"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_id": "memory_update",
|
||||
"category": "memory_modification",
|
||||
"conversations": [
|
||||
{
|
||||
"turn": 1,
|
||||
"user": "I live in San Francisco.",
|
||||
"expected_memory": ["location: San Francisco"]
|
||||
},
|
||||
{
|
||||
"turn": 2,
|
||||
"user": "Actually, I just moved to Seattle last week.",
|
||||
"expected_memory": ["location: Seattle"],
|
||||
"memory_should_not_contain": ["San Francisco"]
|
||||
},
|
||||
{
|
||||
"turn": 3,
|
||||
"user": "Where do I live?",
|
||||
"expected_response_contains": ["Seattle"],
|
||||
"response_should_not_contain": ["San Francisco"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_id": "multi_session_continuity",
|
||||
"category": "session_continuity",
|
||||
"sessions": [
|
||||
{
|
||||
"session_id": "session_1",
|
||||
"conversations": [
|
||||
{
|
||||
"turn": 1,
|
||||
"user": "I'm learning Spanish and practice 30 minutes every day.",
|
||||
"expected_memory": ["learning: Spanish", "practice_duration: 30 minutes daily"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"session_id": "session_2",
|
||||
"conversations": [
|
||||
{
|
||||
"turn": 1,
|
||||
"user": "How long have I been practicing my language studies?",
|
||||
"expected_response_contains": ["Spanish", "30 minutes"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_id": "complex_reasoning",
|
||||
"category": "reasoning_with_memory",
|
||||
"conversations": [
|
||||
{
|
||||
"turn": 1,
|
||||
"user": "I'm allergic to peanuts and shellfish.",
|
||||
"expected_memory": ["allergies: peanuts, shellfish"]
|
||||
},
|
||||
{
|
||||
"turn": 2,
|
||||
"user": "I'm thinking of trying Thai food. Any recommendations?",
|
||||
"expected_response_contains": ["avoid", "peanut", "shellfish"],
|
||||
"reasoning_check": "Should warn about common allergens in Thai cuisine"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_id": "temporal_memory",
|
||||
"category": "time_awareness",
|
||||
"conversations": [
|
||||
{
|
||||
"turn": 1,
|
||||
"user": "My birthday is on June 15th.",
|
||||
"expected_memory": ["birthday: June 15"]
|
||||
},
|
||||
{
|
||||
"turn": 2,
|
||||
"user": "How many days until my birthday?",
|
||||
"response_type": "temporal_calculation",
|
||||
"expected_response_contains": ["June 15"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"test_id": "conflicting_information",
|
||||
"category": "conflict_resolution",
|
||||
"conversations": [
|
||||
{
|
||||
"turn": 1,
|
||||
"user": "I have 2 cats named Fluffy and Whiskers.",
|
||||
"expected_memory": ["pets: 2 cats", "pet_names: Fluffy, Whiskers"]
|
||||
},
|
||||
{
|
||||
"turn": 2,
|
||||
"user": "I got a new dog yesterday! Now I have 3 pets.",
|
||||
"expected_memory": ["pets: 3 total", "has_dog: true"],
|
||||
"reasoning_check": "Should reconcile pet count"
|
||||
},
|
||||
{
|
||||
"turn": 3,
|
||||
"user": "Tell me about my pets.",
|
||||
"expected_response_contains": ["2 cats", "1 dog", "Fluffy", "Whiskers", "3 pets"]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
def run_single_test(
|
||||
self,
|
||||
agent: UserMemoryAgent,
|
||||
test_case: Dict[str, Any]
|
||||
) -> LOCOMOResult:
|
||||
"""
|
||||
Run a single test case
|
||||
|
||||
Args:
|
||||
agent: User memory agent
|
||||
test_case: Test case definition
|
||||
|
||||
Returns:
|
||||
Test result
|
||||
"""
|
||||
test_id = test_case.get("test_id", "unknown")
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
if "sessions" in test_case:
|
||||
# Multi-session test
|
||||
return self._run_multi_session_test(agent, test_case)
|
||||
else:
|
||||
# Single session test
|
||||
return self._run_single_session_test(agent, test_case)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error running test {test_id}: {e}")
|
||||
return LOCOMOResult(
|
||||
test_id=test_id,
|
||||
memory_mode=agent.memory_mode.value,
|
||||
success=False,
|
||||
score=0.0,
|
||||
response_time=time.time() - start_time,
|
||||
memory_retrievals=0,
|
||||
memory_updates=0,
|
||||
details={"error": str(e)}
|
||||
)
|
||||
|
||||
def _run_single_session_test(
|
||||
self,
|
||||
agent: UserMemoryAgent,
|
||||
test_case: Dict[str, Any]
|
||||
) -> LOCOMOResult:
|
||||
"""Run a single session test"""
|
||||
test_id = test_case.get("test_id", "unknown")
|
||||
start_time = time.time()
|
||||
|
||||
# Start new session
|
||||
session_id = agent.start_session()
|
||||
|
||||
total_score = 0.0
|
||||
total_checks = 0
|
||||
details = {
|
||||
"session_id": session_id,
|
||||
"turns": []
|
||||
}
|
||||
|
||||
# Run each conversation turn
|
||||
for conv in test_case.get("conversations", []):
|
||||
turn_num = conv.get("turn", 0)
|
||||
user_message = conv.get("user", "")
|
||||
|
||||
# Get agent response
|
||||
response = agent.chat(user_message)
|
||||
|
||||
turn_details = {
|
||||
"turn": turn_num,
|
||||
"user": user_message,
|
||||
"response": response,
|
||||
"checks": []
|
||||
}
|
||||
|
||||
# Check expected response contents
|
||||
if "expected_response_contains" in conv:
|
||||
for expected in conv["expected_response_contains"]:
|
||||
contains = expected.lower() in response.lower()
|
||||
turn_details["checks"].append({
|
||||
"type": "response_contains",
|
||||
"expected": expected,
|
||||
"found": contains
|
||||
})
|
||||
total_score += 1.0 if contains else 0.0
|
||||
total_checks += 1
|
||||
|
||||
# Check response should not contain
|
||||
if "response_should_not_contain" in conv:
|
||||
for unexpected in conv["response_should_not_contain"]:
|
||||
not_contains = unexpected.lower() not in response.lower()
|
||||
turn_details["checks"].append({
|
||||
"type": "response_not_contains",
|
||||
"unexpected": unexpected,
|
||||
"correct": not_contains
|
||||
})
|
||||
total_score += 1.0 if not_contains else 0.0
|
||||
total_checks += 1
|
||||
|
||||
# Check memory updates
|
||||
if "expected_memory" in conv:
|
||||
memory_summary = agent.get_memory_summary()
|
||||
for expected_mem in conv["expected_memory"]:
|
||||
contains = expected_mem.lower() in memory_summary.lower()
|
||||
turn_details["checks"].append({
|
||||
"type": "memory_contains",
|
||||
"expected": expected_mem,
|
||||
"found": contains
|
||||
})
|
||||
total_score += 1.0 if contains else 0.0
|
||||
total_checks += 1
|
||||
|
||||
details["turns"].append(turn_details)
|
||||
|
||||
# Calculate final score
|
||||
final_score = (total_score / total_checks) if total_checks > 0 else 0.0
|
||||
|
||||
return LOCOMOResult(
|
||||
test_id=test_id,
|
||||
memory_mode=agent.memory_mode.value,
|
||||
success=final_score >= 0.7, # 70% threshold for success
|
||||
score=final_score,
|
||||
response_time=time.time() - start_time,
|
||||
memory_retrievals=len(details["turns"]),
|
||||
memory_updates=len([t for t in details["turns"] if any(c["type"] == "memory_contains" for c in t.get("checks", []))]),
|
||||
details=details
|
||||
)
|
||||
|
||||
def _run_multi_session_test(
|
||||
self,
|
||||
agent: UserMemoryAgent,
|
||||
test_case: Dict[str, Any]
|
||||
) -> LOCOMOResult:
|
||||
"""Run a multi-session test"""
|
||||
test_id = test_case.get("test_id", "unknown")
|
||||
start_time = time.time()
|
||||
|
||||
total_score = 0.0
|
||||
total_checks = 0
|
||||
details = {
|
||||
"sessions": []
|
||||
}
|
||||
|
||||
# Run each session
|
||||
for session_def in test_case.get("sessions", []):
|
||||
# Start new session
|
||||
session_id = agent.start_session()
|
||||
|
||||
session_details = {
|
||||
"session_id": session_id,
|
||||
"turns": []
|
||||
}
|
||||
|
||||
# Run conversations in this session
|
||||
for conv in session_def.get("conversations", []):
|
||||
turn_num = conv.get("turn", 0)
|
||||
user_message = conv.get("user", "")
|
||||
|
||||
# Get agent response
|
||||
response = agent.chat(user_message)
|
||||
|
||||
turn_details = {
|
||||
"turn": turn_num,
|
||||
"user": user_message,
|
||||
"response": response,
|
||||
"checks": []
|
||||
}
|
||||
|
||||
# Check expected response contents
|
||||
if "expected_response_contains" in conv:
|
||||
for expected in conv["expected_response_contains"]:
|
||||
contains = expected.lower() in response.lower()
|
||||
turn_details["checks"].append({
|
||||
"type": "response_contains",
|
||||
"expected": expected,
|
||||
"found": contains
|
||||
})
|
||||
total_score += 1.0 if contains else 0.0
|
||||
total_checks += 1
|
||||
|
||||
# Check memory updates
|
||||
if "expected_memory" in conv:
|
||||
memory_summary = agent.get_memory_summary()
|
||||
for expected_mem in conv["expected_memory"]:
|
||||
contains = expected_mem.lower() in memory_summary.lower()
|
||||
turn_details["checks"].append({
|
||||
"type": "memory_contains",
|
||||
"expected": expected_mem,
|
||||
"found": contains
|
||||
})
|
||||
total_score += 1.0 if contains else 0.0
|
||||
total_checks += 1
|
||||
|
||||
session_details["turns"].append(turn_details)
|
||||
|
||||
details["sessions"].append(session_details)
|
||||
|
||||
# Calculate final score
|
||||
final_score = (total_score / total_checks) if total_checks > 0 else 0.0
|
||||
|
||||
return LOCOMOResult(
|
||||
test_id=test_id,
|
||||
memory_mode=agent.memory_mode.value,
|
||||
success=final_score >= 0.7,
|
||||
score=final_score,
|
||||
response_time=time.time() - start_time,
|
||||
memory_retrievals=sum(len(s["turns"]) for s in details["sessions"]),
|
||||
memory_updates=sum(len([t for t in s["turns"] if any(c["type"] == "memory_contains" for c in t.get("checks", []))]) for s in details["sessions"]),
|
||||
details=details
|
||||
)
|
||||
|
||||
def run_benchmark(
|
||||
self,
|
||||
memory_modes: List[MemoryMode] = None,
|
||||
test_ids: List[str] = None
|
||||
) -> Dict[str, List[LOCOMOResult]]:
|
||||
"""
|
||||
Run full benchmark
|
||||
|
||||
Args:
|
||||
memory_modes: List of memory modes to test (defaults to all)
|
||||
test_ids: Specific test IDs to run (defaults to all)
|
||||
|
||||
Returns:
|
||||
Results grouped by memory mode
|
||||
"""
|
||||
memory_modes = memory_modes or [MemoryMode.NOTES, MemoryMode.JSON_CARDS]
|
||||
test_cases_to_run = self.test_cases
|
||||
|
||||
if test_ids:
|
||||
test_cases_to_run = [tc for tc in self.test_cases if tc.get("test_id") in test_ids]
|
||||
|
||||
results = {}
|
||||
|
||||
for mode in memory_modes:
|
||||
logger.info(f"Running benchmark with {mode.value} memory mode")
|
||||
mode_results = []
|
||||
|
||||
for test_case in test_cases_to_run:
|
||||
# Create fresh agent for each test
|
||||
user_id = f"benchmark_user_{test_case.get('test_id', 'unknown')}"
|
||||
agent = UserMemoryAgent(
|
||||
user_id=user_id,
|
||||
memory_mode=mode,
|
||||
enable_streaming=False,
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# CRITICAL: Clear any existing memory for this user before test
|
||||
# This ensures clean test runs even if the same test_id is run multiple times
|
||||
if hasattr(agent, 'memory_manager') and hasattr(agent.memory_manager, 'clear_all_memories'):
|
||||
agent.memory_manager.clear_all_memories()
|
||||
logger.info(f"Cleared memory for user {user_id} before test")
|
||||
|
||||
# Run test
|
||||
result = self.run_single_test(agent, test_case)
|
||||
mode_results.append(result)
|
||||
|
||||
logger.info(f"Test {result.test_id}: {'✓' if result.success else '✗'} (Score: {result.score:.2f})")
|
||||
|
||||
results[mode.value] = mode_results
|
||||
|
||||
# Save results
|
||||
self._save_results(results)
|
||||
|
||||
return results
|
||||
|
||||
def _save_results(self, results: Dict[str, List[LOCOMOResult]]):
|
||||
"""Save benchmark results"""
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
result_file = os.path.join(
|
||||
self.results_dir,
|
||||
f"locomo_results_{timestamp}.json"
|
||||
)
|
||||
|
||||
os.makedirs(self.results_dir, exist_ok=True)
|
||||
|
||||
# Convert results to dict
|
||||
results_dict = {
|
||||
mode: [r.to_dict() for r in mode_results]
|
||||
for mode, mode_results in results.items()
|
||||
}
|
||||
|
||||
# Add summary statistics
|
||||
summary = {}
|
||||
for mode, mode_results in results.items():
|
||||
scores = [r.score for r in mode_results]
|
||||
summary[mode] = {
|
||||
"total_tests": len(mode_results),
|
||||
"passed": sum(1 for r in mode_results if r.success),
|
||||
"failed": sum(1 for r in mode_results if not r.success),
|
||||
"average_score": sum(scores) / len(scores) if scores else 0,
|
||||
"average_response_time": sum(r.response_time for r in mode_results) / len(mode_results) if mode_results else 0
|
||||
}
|
||||
|
||||
output = {
|
||||
"timestamp": timestamp,
|
||||
"summary": summary,
|
||||
"results": results_dict
|
||||
}
|
||||
|
||||
with open(result_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(output, f, indent=2, ensure_ascii=False)
|
||||
|
||||
logger.info(f"Results saved to {result_file}")
|
||||
|
||||
# Print summary
|
||||
print("\n" + "="*60)
|
||||
print("LOCOMO BENCHMARK SUMMARY")
|
||||
print("="*60)
|
||||
|
||||
for mode, stats in summary.items():
|
||||
print(f"\n{mode.upper()} Mode:")
|
||||
print(f" Total Tests: {stats['total_tests']}")
|
||||
print(f" Passed: {stats['passed']}")
|
||||
print(f" Failed: {stats['failed']}")
|
||||
print(f" Average Score: {stats['average_score']:.2%}")
|
||||
print(f" Average Response Time: {stats['average_response_time']:.2f}s")
|
||||
|
||||
print("="*60)
|
||||
@@ -0,0 +1,956 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Main entry point for User Memory System with Separated Architecture
|
||||
Conversational agent handles dialogue, background processor handles memory
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import logging
|
||||
import argparse
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from conversational_agent import ConversationalAgent, ConversationConfig
|
||||
from background_memory_processor import BackgroundMemoryProcessor, MemoryProcessorConfig
|
||||
from config import Config, MemoryMode
|
||||
|
||||
# Add evaluation framework support
|
||||
# We load it dynamically only when needed to avoid import conflicts
|
||||
EVALUATION_AVAILABLE = False
|
||||
UserMemoryEvaluationFramework = None
|
||||
TestCase = None
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def print_section(title: str):
|
||||
"""Print a formatted section header"""
|
||||
print("\n" + "="*80)
|
||||
print(f" {title}")
|
||||
print("="*80)
|
||||
|
||||
|
||||
def print_result(result: dict):
|
||||
"""Print formatted result"""
|
||||
if result.get('success'):
|
||||
print("\n✅ Task completed successfully!")
|
||||
if result.get('final_answer'):
|
||||
print("\n📝 Final Answer:")
|
||||
print("-"*40)
|
||||
print(result['final_answer'])
|
||||
else:
|
||||
print("\n❌ Task failed!")
|
||||
if result.get('error'):
|
||||
print(f"Error: {result['error']}")
|
||||
|
||||
print(f"\n📊 Statistics:")
|
||||
print(f" - Iterations: {result.get('iterations', 0)}")
|
||||
print(f" - Tool calls: {len(result.get('tool_calls', []))}")
|
||||
|
||||
if result.get('trajectory_file'):
|
||||
print(f"\n💾 Trajectory saved to: {result['trajectory_file']}")
|
||||
|
||||
# Show tool call summary
|
||||
if result.get('tool_calls'):
|
||||
print(f"\n🔧 Tool Call Summary:")
|
||||
tool_summary = {}
|
||||
for call in result['tool_calls']:
|
||||
tool_name = call.tool_name
|
||||
if tool_name not in tool_summary:
|
||||
tool_summary[tool_name] = {
|
||||
'count': 0,
|
||||
'success': 0,
|
||||
'failed': 0
|
||||
}
|
||||
tool_summary[tool_name]['count'] += 1
|
||||
if call.error:
|
||||
tool_summary[tool_name]['failed'] += 1
|
||||
else:
|
||||
tool_summary[tool_name]['success'] += 1
|
||||
|
||||
for tool_name, stats in tool_summary.items():
|
||||
print(f" - {tool_name}: {stats['count']} calls "
|
||||
f"({stats['success']} success, {stats['failed']} failed)")
|
||||
|
||||
# Show memory state
|
||||
if result.get('memory_state'):
|
||||
print(f"\n💭 Memory State:")
|
||||
print("-"*40)
|
||||
memory_preview = result['memory_state'][:500]
|
||||
if len(result['memory_state']) > 500:
|
||||
memory_preview += "..."
|
||||
print(memory_preview)
|
||||
|
||||
|
||||
def interactive_mode(user_id: str, memory_mode: MemoryMode = MemoryMode.NOTES,
|
||||
enable_background_processing: bool = True,
|
||||
conversation_interval: int = 1,
|
||||
provider: Optional[str] = None,
|
||||
model: Optional[str] = None):
|
||||
"""Run the agent in interactive mode with separated architecture"""
|
||||
print_section(f"Interactive Mode - Conversational Agent (User: {user_id})")
|
||||
|
||||
# Determine provider and get API key
|
||||
provider = (provider or Config.PROVIDER).lower()
|
||||
api_key = Config.get_api_key(provider)
|
||||
if not api_key:
|
||||
print(f"❌ Error: Please set API key for provider '{provider}'")
|
||||
if provider in ["kimi", "moonshot"]:
|
||||
print(" export MOONSHOT_API_KEY='your-api-key-here'")
|
||||
elif provider in ["dashscope", "qwen", "bailian"]:
|
||||
print(" export DASHSCOPE_API_KEY='your-api-key-here'")
|
||||
elif provider == "siliconflow":
|
||||
print(" export SILICONFLOW_API_KEY='your-api-key-here'")
|
||||
elif provider == "doubao":
|
||||
print(" export DOUBAO_API_KEY='your-api-key-here'")
|
||||
elif provider == "openrouter":
|
||||
print(" export OPENROUTER_API_KEY='your-api-key-here'")
|
||||
return
|
||||
|
||||
# Initialize conversational agent
|
||||
conv_config = ConversationConfig(
|
||||
enable_memory_context=True,
|
||||
enable_conversation_history=True
|
||||
)
|
||||
|
||||
agent = ConversationalAgent(
|
||||
user_id=user_id,
|
||||
api_key=api_key,
|
||||
provider=provider,
|
||||
model=model,
|
||||
config=conv_config,
|
||||
memory_mode=memory_mode,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Initialize and start background memory processor if enabled
|
||||
memory_processor = None
|
||||
if enable_background_processing:
|
||||
proc_config = MemoryProcessorConfig(
|
||||
conversation_interval=conversation_interval,
|
||||
min_conversation_turns=1,
|
||||
context_window=10,
|
||||
enable_auto_processing=True,
|
||||
output_operations=True
|
||||
)
|
||||
|
||||
memory_processor = BackgroundMemoryProcessor(
|
||||
user_id=user_id,
|
||||
api_key=api_key,
|
||||
provider=provider,
|
||||
model=model,
|
||||
config=proc_config,
|
||||
memory_mode=memory_mode,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
memory_processor.start_background_processing()
|
||||
print(f"\n🧠 Background memory processing enabled (every {conversation_interval} conversation{'s' if conversation_interval > 1 else ''})")
|
||||
|
||||
print("\n✅ Conversational agent initialized")
|
||||
print(f"📦 Memory Mode: {memory_mode.value}")
|
||||
print(f"🆔 Session: {agent.get_session_id()}")
|
||||
print(f"🔄 Background Processing: {'Enabled' if enable_background_processing else 'Disabled'}")
|
||||
if enable_background_processing:
|
||||
print(f"📊 Processing Trigger: Every {conversation_interval} conversation{'s' if conversation_interval > 1 else ''}")
|
||||
print("\nAvailable commands:")
|
||||
print(" 'memory' - Show current memory state")
|
||||
print(" 'process' - Manually trigger memory processing")
|
||||
print(" 'save' - Save memory immediately")
|
||||
print(" 'reset' - Start new conversation session")
|
||||
print(" 'quit' - Exit immediately without saving")
|
||||
print(" 'exit' - Exit immediately without saving")
|
||||
print("\nOr enter any message to chat.")
|
||||
|
||||
conversation_count = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
print("\n" + "-"*60)
|
||||
user_input = input("You > ").strip()
|
||||
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
if user_input.lower() in ['quit', 'exit']:
|
||||
# Immediate exit without saving
|
||||
if memory_processor:
|
||||
memory_processor.stop_background_processing()
|
||||
print("👋 Goodbye! (Exited without saving)")
|
||||
break
|
||||
|
||||
elif user_input.lower() == 'save':
|
||||
# Save memory immediately
|
||||
print("\n💾 Saving memory...")
|
||||
if memory_processor:
|
||||
results = memory_processor.process_recent_conversations()
|
||||
print(f"✅ Memory saved: {results}")
|
||||
else:
|
||||
print("⚠️ Background processing is disabled. Memory is saved after each conversation.")
|
||||
continue
|
||||
|
||||
elif user_input.lower() == 'memory':
|
||||
print("\n💭 Current Memory State:")
|
||||
print("-"*40)
|
||||
# Reload first: the background processor writes through its
|
||||
# own manager instance, so the agent's copy can be stale.
|
||||
agent.memory_manager.load_memory()
|
||||
print(agent.memory_manager.get_context_string())
|
||||
|
||||
elif user_input.lower() == 'process':
|
||||
if memory_processor:
|
||||
print("\n🔄 Manually triggering memory processing...")
|
||||
results = memory_processor.process_recent_conversations()
|
||||
|
||||
# Display operations
|
||||
operations = results.get('operations', [])
|
||||
if operations:
|
||||
print(f"\n📝 Memory Operations ({len(operations)} total):")
|
||||
for i, op in enumerate(operations, 1):
|
||||
icon = {'add': '➕', 'update': '📝', 'delete': '🗑️'}.get(op['action'], '❓')
|
||||
print(f"{i}. {icon} {op['action'].upper()}: {op.get('content', op.get('memory_id', 'N/A'))}")
|
||||
else:
|
||||
print("ℹ️ No memory updates needed")
|
||||
|
||||
summary = results.get('summary', {})
|
||||
print(f"\nSummary: {summary.get('added', 0)} added, {summary.get('updated', 0)} updated, {summary.get('deleted', 0)} deleted")
|
||||
else:
|
||||
print("❌ Background processing not enabled")
|
||||
|
||||
elif user_input.lower() == 'reset':
|
||||
agent.reset_session()
|
||||
print("✅ Started new conversation session")
|
||||
conversation_count = 0
|
||||
|
||||
else:
|
||||
# Have a conversation
|
||||
response = agent.chat(user_input)
|
||||
print(f"\n🤖 Assistant: {response}")
|
||||
conversation_count += 1
|
||||
|
||||
# Increment conversation counter in processor
|
||||
if memory_processor:
|
||||
memory_processor.increment_conversation_count()
|
||||
|
||||
# Check if processing will trigger
|
||||
if memory_processor.should_process():
|
||||
print(f"\n[Memory processing triggered after {conversation_interval} conversation{'s' if conversation_interval > 1 else ''}]")
|
||||
# Give a moment for background thread to process
|
||||
time.sleep(2)
|
||||
elif conversation_interval > 1:
|
||||
conversations_until_process = conversation_interval - (conversation_count % conversation_interval)
|
||||
if conversations_until_process < conversation_interval:
|
||||
print(f"\n[Memory processing in {conversations_until_process} more conversation{'s' if conversations_until_process > 1 else ''}]")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n⚠️ Interrupted. Type 'save' to save memory, or 'quit'/'exit' to exit immediately without saving.")
|
||||
except Exception as e:
|
||||
print(f"\n❌ Error: {str(e)}")
|
||||
logger.error(f"Error in interactive mode: {e}", exc_info=True)
|
||||
|
||||
# Cleanup
|
||||
if memory_processor:
|
||||
memory_processor.stop_background_processing()
|
||||
|
||||
|
||||
def demo_memory_system(memory_mode: MemoryMode = None, provider: Optional[str] = None, model: Optional[str] = None):
|
||||
"""Demonstrate the separated memory system architecture"""
|
||||
print_section("Demo: Separated Memory Architecture")
|
||||
|
||||
# Determine provider and get API key
|
||||
provider = (provider or Config.PROVIDER).lower()
|
||||
api_key = Config.get_api_key(provider)
|
||||
if not api_key:
|
||||
print(f"❌ Please set API key for provider '{provider}'")
|
||||
if provider in ["dashscope", "qwen", "bailian"]:
|
||||
print(" export DASHSCOPE_API_KEY='your-api-key-here'")
|
||||
return
|
||||
|
||||
# Create test user
|
||||
user_id = "demo_user"
|
||||
|
||||
# Use provided memory_mode or prompt for it
|
||||
if memory_mode is None:
|
||||
memory_mode = select_memory_mode_interactive()
|
||||
|
||||
# Initialize conversational agent
|
||||
conv_config = ConversationConfig(
|
||||
enable_memory_context=True,
|
||||
enable_conversation_history=True
|
||||
)
|
||||
|
||||
agent = ConversationalAgent(
|
||||
user_id=user_id,
|
||||
api_key=api_key,
|
||||
provider=provider,
|
||||
model=model,
|
||||
config=conv_config,
|
||||
memory_mode=memory_mode,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Initialize background processor
|
||||
proc_config = MemoryProcessorConfig(
|
||||
conversation_interval=2, # Process every 2 conversations for demo
|
||||
min_conversation_turns=1,
|
||||
output_operations=True
|
||||
)
|
||||
|
||||
processor = BackgroundMemoryProcessor(
|
||||
user_id=user_id,
|
||||
api_key=api_key,
|
||||
provider=provider,
|
||||
model=model,
|
||||
config=proc_config,
|
||||
memory_mode=memory_mode,
|
||||
verbose=True
|
||||
)
|
||||
|
||||
# Session 1: Have conversations
|
||||
print("\n📝 Session 1: Having conversations")
|
||||
print("-"*40)
|
||||
|
||||
messages = [
|
||||
"Hi! My name is Alice and I work as a product manager at TechCorp.",
|
||||
"I prefer Python for scripting and use VS Code as my IDE. I also like dark themes.",
|
||||
"I'm currently working on a new mobile app project for our company."
|
||||
]
|
||||
|
||||
for message in messages:
|
||||
print(f"\n👤 User: {message}")
|
||||
response = agent.chat(message)
|
||||
print(f"🤖 Assistant: {response[:200]}..." if len(response) > 200 else f"🤖 Assistant: {response}")
|
||||
time.sleep(1) # Brief pause between messages
|
||||
|
||||
# Process memories
|
||||
print("\n\n🔄 Processing conversation for memory updates...")
|
||||
print("-"*40)
|
||||
|
||||
# Increment conversation count to trigger processing
|
||||
for _ in range(len(messages)):
|
||||
processor.increment_conversation_count()
|
||||
|
||||
# Process conversations
|
||||
results = processor.process_recent_conversations()
|
||||
|
||||
# Display operations
|
||||
operations = results.get('operations', [])
|
||||
if operations:
|
||||
print(f"\n📝 Memory Operations ({len(operations)} total):")
|
||||
for i, op in enumerate(operations, 1):
|
||||
icon = {'add': '➕', 'update': '📝', 'delete': '🗑️'}.get(op['action'], '❓')
|
||||
print(f"{i}. {icon} {op['action'].upper()}: {op.get('content', op.get('memory_id', 'N/A'))}")
|
||||
else:
|
||||
print("ℹ️ No memory updates needed")
|
||||
|
||||
summary = results.get('summary', {})
|
||||
print(f"\n✅ Summary: {summary.get('added', 0)} added, {summary.get('updated', 0)} updated, {summary.get('deleted', 0)} deleted")
|
||||
|
||||
# Start new session to test memory persistence
|
||||
print("\n\n📝 Session 2: Testing memory persistence")
|
||||
print("-"*40)
|
||||
|
||||
agent.reset_session()
|
||||
|
||||
test_message = "What do you know about me and my work?"
|
||||
print(f"\n👤 User: {test_message}")
|
||||
response = agent.chat(test_message)
|
||||
print(f"🤖 Assistant: {response}")
|
||||
|
||||
# Show final memory state
|
||||
print("\n\n💭 Final Memory State:")
|
||||
print("-"*40)
|
||||
print(agent.memory_manager.get_context_string())
|
||||
|
||||
|
||||
def run_evaluation_mode(user_id: str, memory_mode: MemoryMode, verbose: bool = True, provider: Optional[str] = None, model: Optional[str] = None):
|
||||
"""Run evaluation mode using the evaluation framework"""
|
||||
|
||||
# Import the evaluation framework with proper module isolation
|
||||
from pathlib import Path
|
||||
|
||||
eval_framework_path = Path(__file__).parent.parent / "user-memory-evaluation"
|
||||
|
||||
try:
|
||||
# Save the current modules to avoid conflicts
|
||||
saved_modules = {}
|
||||
conflicting_modules = ['config', 'models', 'evaluator', 'framework']
|
||||
|
||||
# Temporarily remove conflicting modules from sys.modules
|
||||
for module_name in conflicting_modules:
|
||||
if module_name in sys.modules:
|
||||
saved_modules[module_name] = sys.modules[module_name]
|
||||
del sys.modules[module_name]
|
||||
|
||||
# Temporarily add evaluation framework path with highest priority
|
||||
original_path = sys.path.copy()
|
||||
sys.path.insert(0, str(eval_framework_path))
|
||||
|
||||
# Import evaluation framework modules
|
||||
import config as eval_config
|
||||
import models as eval_models
|
||||
import evaluator as eval_evaluator
|
||||
import framework as eval_framework
|
||||
|
||||
# Get the class we need
|
||||
framework_class = eval_framework.UserMemoryEvaluationFramework
|
||||
|
||||
# Restore original path
|
||||
sys.path = original_path
|
||||
|
||||
# Remove evaluation modules from sys.modules to avoid future conflicts
|
||||
for module_name in conflicting_modules:
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
|
||||
# Restore original modules
|
||||
for module_name, module in saved_modules.items():
|
||||
sys.modules[module_name] = module
|
||||
|
||||
except Exception as e:
|
||||
# Restore on error
|
||||
sys.path = original_path if 'original_path' in locals() else sys.path
|
||||
for module_name, module in saved_modules.items():
|
||||
sys.modules[module_name] = module
|
||||
|
||||
print(f"❌ Error: Could not load evaluation framework: {e}")
|
||||
print("Please ensure user-memory-evaluation is properly installed.")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
print_section("Evaluation Mode - Test Case Based Evaluation")
|
||||
|
||||
# Initialize evaluation framework
|
||||
framework = framework_class()
|
||||
|
||||
if not framework.test_suite:
|
||||
print("❌ Error: No test cases loaded")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"\n✅ Loaded {len(framework.test_suite.test_cases)} test cases")
|
||||
|
||||
# Determine provider and get API key
|
||||
provider = (provider or Config.PROVIDER).lower()
|
||||
api_key = Config.get_api_key(provider)
|
||||
if not api_key:
|
||||
print(f"❌ Error: Please set API key for provider '{provider}'")
|
||||
if provider in ["dashscope", "qwen", "bailian"]:
|
||||
print(" export DASHSCOPE_API_KEY='your-api-key-here'")
|
||||
sys.exit(1)
|
||||
|
||||
# Initialize agents without incorrect parameters
|
||||
# ConversationConfig is a dataclass and doesn't take parameters in __init__
|
||||
conv_config = ConversationConfig()
|
||||
conv_config.enable_memory_context = True
|
||||
conv_config.enable_conversation_history = True
|
||||
|
||||
mem_config = MemoryProcessorConfig()
|
||||
mem_config.verbose = verbose
|
||||
|
||||
# Initialize agents with correct parameters
|
||||
agent = ConversationalAgent(
|
||||
user_id=user_id,
|
||||
api_key=api_key,
|
||||
provider=provider,
|
||||
model=model,
|
||||
config=conv_config,
|
||||
memory_mode=memory_mode,
|
||||
verbose=verbose
|
||||
)
|
||||
processor = BackgroundMemoryProcessor(
|
||||
user_id=user_id,
|
||||
api_key=api_key,
|
||||
provider=provider,
|
||||
model=model,
|
||||
config=mem_config,
|
||||
memory_mode=memory_mode, # Pass memory_mode here!
|
||||
verbose=verbose
|
||||
)
|
||||
|
||||
while True:
|
||||
print("\n" + "-"*60)
|
||||
print("Options:")
|
||||
print("1. Run a test case")
|
||||
print("2. View current memory state")
|
||||
print("3. Clear memory and start fresh")
|
||||
print("4. Exit evaluation mode")
|
||||
|
||||
choice = input("\nEnter your choice (1-4): ").strip()
|
||||
|
||||
if choice == "1":
|
||||
# First list test cases, then let user choose
|
||||
print("\n📋 Available Test Cases:")
|
||||
framework.display_test_case_summary(show_full_titles=True, by_category=True)
|
||||
|
||||
# Now let user select a test case
|
||||
test_id = input("\nEnter test case ID to run (or 'cancel' to go back): ").strip()
|
||||
|
||||
if test_id.lower() == 'cancel':
|
||||
continue
|
||||
|
||||
test_case = framework.get_test_case(test_id)
|
||||
|
||||
if not test_case:
|
||||
print(f"❌ Test case '{test_id}' not found")
|
||||
continue
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Running Test Case: {test_case.title}")
|
||||
print(f"Category: {test_case.category}")
|
||||
print("="*60)
|
||||
|
||||
# CRITICAL: Clear ALL memory and conversation state before test
|
||||
print("\n🧹 Clearing all memory and conversation state before test...")
|
||||
|
||||
# 1. Clear memory managers for both agent and processor
|
||||
if hasattr(agent.memory_manager, 'clear_all_memories'):
|
||||
agent.memory_manager.clear_all_memories()
|
||||
# Verify memory is cleared
|
||||
memory_check = agent.memory_manager.get_context_string()
|
||||
if "No previous memory" not in memory_check:
|
||||
print(f" ⚠️ Warning: Agent memory may not be fully cleared")
|
||||
else:
|
||||
print(f" ✅ Agent memory cleared successfully")
|
||||
|
||||
if hasattr(processor.memory_manager, 'clear_all_memories'):
|
||||
processor.memory_manager.clear_all_memories()
|
||||
# Verify memory is cleared
|
||||
memory_check = processor.memory_manager.get_context_string()
|
||||
if "No previous memory" not in memory_check:
|
||||
print(f" ⚠️ Warning: Processor memory may not be fully cleared")
|
||||
else:
|
||||
print(f" ✅ Processor memory cleared successfully")
|
||||
|
||||
# 2. Clear conversation history completely
|
||||
if agent.conversation_history:
|
||||
agent.conversation_history.conversations = []
|
||||
agent.conversation_history.save_history()
|
||||
print(f" ✅ Cleared conversation history for user {user_id}")
|
||||
|
||||
if processor.conversation_history:
|
||||
processor.conversation_history.conversations = []
|
||||
processor.conversation_history.save_history()
|
||||
print(f" ✅ Cleared processor conversation history")
|
||||
|
||||
# 3. Reset agent conversation state
|
||||
agent.conversation = []
|
||||
agent._init_system_prompt()
|
||||
|
||||
# 4. Reset any tool call counts
|
||||
if hasattr(agent, 'tool_call_counts'):
|
||||
agent.tool_call_counts = {}
|
||||
|
||||
print(f" ✅ All memory and state cleared - ready for test case")
|
||||
|
||||
# Process conversation histories
|
||||
print(f"\n📚 Processing {len(test_case.conversation_histories)} conversation histories...")
|
||||
|
||||
# Build conversation contexts from test case histories
|
||||
conversation_contexts = []
|
||||
|
||||
for i, history in enumerate(test_case.conversation_histories, 1):
|
||||
print(f"\nConversation {i}/{len(test_case.conversation_histories)}: {history.conversation_id}")
|
||||
|
||||
# Build conversation context for this history
|
||||
conversation = []
|
||||
|
||||
# Process each message in the conversation
|
||||
for msg in history.messages:
|
||||
if msg.role.value == "user":
|
||||
conversation.append({"role": "user", "content": msg.content})
|
||||
elif msg.role.value == "assistant":
|
||||
conversation.append({"role": "assistant", "content": msg.content})
|
||||
|
||||
conversation_contexts.append(conversation)
|
||||
|
||||
# Also add to the agent's conversation history for context
|
||||
# This is needed for the agent to have context when answering the question
|
||||
if agent.conversation_history and hasattr(agent.conversation_history, 'add_turn'):
|
||||
# Add pairs of user/assistant messages
|
||||
user_msg = None
|
||||
for msg in history.messages:
|
||||
if msg.role.value == "user":
|
||||
user_msg = msg.content
|
||||
elif msg.role.value == "assistant" and user_msg:
|
||||
agent.conversation_history.add_turn(
|
||||
session_id=f"eval_{history.conversation_id}",
|
||||
user_message=user_msg,
|
||||
assistant_message=msg.content
|
||||
)
|
||||
user_msg = None
|
||||
|
||||
# Process all conversations through the memory processor
|
||||
if conversation_contexts:
|
||||
print(f"\n💾 Processing memory for all conversations...")
|
||||
try:
|
||||
results = processor.process_conversation_batch(conversation_contexts)
|
||||
|
||||
# Summarize results
|
||||
total_added = sum(r.get('summary', {}).get('added', 0) for r in results)
|
||||
total_updated = sum(r.get('summary', {}).get('updated', 0) for r in results)
|
||||
total_deleted = sum(r.get('summary', {}).get('deleted', 0) for r in results)
|
||||
|
||||
print(f" ✅ Memory processing complete:")
|
||||
print(f" - Added: {total_added} memories")
|
||||
print(f" - Updated: {total_updated} memories")
|
||||
print(f" - Deleted: {total_deleted} memories")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ Memory processing error: {e}")
|
||||
|
||||
# CRITICAL: Clear conversation history to simulate a new session
|
||||
# The evaluation should test whether STRUCTURED MEMORIES work,
|
||||
# not whether raw conversation history works.
|
||||
# The agent must rely only on processed memories to answer the question.
|
||||
if agent.conversation_history:
|
||||
# Save the current conversation history (for record keeping)
|
||||
saved_conversations = agent.conversation_history.conversations if hasattr(agent.conversation_history, 'conversations') else []
|
||||
# Clear the conversations list to simulate a fresh session
|
||||
agent.conversation_history.conversations = []
|
||||
print("\n🔄 Cleared conversation history - starting fresh session")
|
||||
print(" (Agent will use only structured memories)")
|
||||
|
||||
# Reset the agent's conversation to start fresh
|
||||
agent.conversation = []
|
||||
agent._init_system_prompt()
|
||||
|
||||
# CRITICAL: Reload the agent's memory manager to get the memories saved by the processor
|
||||
# The processor and agent have separate memory manager instances, so we need to reload
|
||||
# from file to get the memories that were just saved
|
||||
agent.memory_manager.load_memory()
|
||||
|
||||
# Display what memories are available
|
||||
memory_context = agent.memory_manager.get_context_string()
|
||||
if memory_context:
|
||||
print("\n💾 Available memories:")
|
||||
print("-"*40)
|
||||
print(memory_context[:500] + "..." if len(memory_context) > 500 else memory_context)
|
||||
print("-"*40)
|
||||
else:
|
||||
print("\n⚠️ No structured memories available")
|
||||
|
||||
# Now answer the user question
|
||||
print(f"\n{'='*60}")
|
||||
print("USER QUESTION:")
|
||||
print("-"*60)
|
||||
print(test_case.user_question)
|
||||
print("="*60)
|
||||
|
||||
# Get agent response (now only using structured memories)
|
||||
print("\n🤔 Generating response...")
|
||||
response = agent.chat(test_case.user_question)
|
||||
|
||||
print("\n📝 Agent Response:")
|
||||
print("-"*60)
|
||||
print(response)
|
||||
print("-"*60)
|
||||
|
||||
# Restore conversation history after evaluation
|
||||
if agent.conversation_history and 'saved_conversations' in locals():
|
||||
agent.conversation_history.conversations = saved_conversations
|
||||
|
||||
# Evaluate the response
|
||||
print("\n⚖️ Evaluating response...")
|
||||
result = framework.submit_and_evaluate(test_id, response)
|
||||
|
||||
if result:
|
||||
# Display evaluation result
|
||||
is_passed = result.passed if result.passed is not None else result.reward >= 0.6
|
||||
status = "✅ PASSED" if is_passed else "❌ FAILED"
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
print("EVALUATION RESULT:")
|
||||
print("-"*60)
|
||||
print(f"Status: {status}")
|
||||
print(f"Reward Score: {result.reward:.3f}/1.000")
|
||||
|
||||
if result.reasoning:
|
||||
print(f"\nReasoning:")
|
||||
print(result.reasoning)
|
||||
|
||||
if result.suggestions:
|
||||
print(f"\nSuggestions:")
|
||||
print(result.suggestions)
|
||||
print("="*60)
|
||||
else:
|
||||
print("❌ Evaluation failed")
|
||||
|
||||
# Clear conversation history for next test
|
||||
agent.conversation_history = []
|
||||
|
||||
elif choice == "2":
|
||||
# View current memory
|
||||
print("\n📄 Current Memory State:")
|
||||
print("-"*60)
|
||||
print(processor.memory_manager.get_context_string())
|
||||
|
||||
elif choice == "3":
|
||||
# Clear memory
|
||||
if input("\n⚠️ Are you sure you want to clear all memory? (yes/no): ").lower() == "yes":
|
||||
# Clear memory using the new method
|
||||
if hasattr(agent.memory_manager, 'clear_all_memories'):
|
||||
agent.memory_manager.clear_all_memories()
|
||||
if hasattr(processor.memory_manager, 'clear_all_memories'):
|
||||
processor.memory_manager.clear_all_memories()
|
||||
|
||||
# Clear conversation history
|
||||
if agent.conversation_history:
|
||||
agent.conversation_history.conversations = []
|
||||
agent.conversation_history.save_history()
|
||||
|
||||
# Reset agent conversation
|
||||
agent.conversation = []
|
||||
agent._init_system_prompt()
|
||||
|
||||
print("✅ Memory and conversation history cleared")
|
||||
|
||||
elif choice == "4":
|
||||
print("\nExiting evaluation mode...")
|
||||
break
|
||||
else:
|
||||
print(f"❌ Invalid choice: {choice}")
|
||||
|
||||
|
||||
def select_mode_interactive() -> str:
|
||||
"""
|
||||
Interactively prompt the user to select an execution mode
|
||||
|
||||
Returns:
|
||||
Selected mode string ('evaluation', 'interactive', or 'demo')
|
||||
"""
|
||||
print("\n" + "="*60)
|
||||
print(" 🚀 SELECT EXECUTION MODE")
|
||||
print("="*60)
|
||||
|
||||
print("\n1. Evaluation Mode")
|
||||
print(" - Run test cases from user-memory-evaluation framework")
|
||||
print(" - Test memory system with predefined scenarios")
|
||||
print(" - Get performance scores and feedback")
|
||||
|
||||
print("\n2. Interactive Mode")
|
||||
print(" - Chat with the agent in real-time")
|
||||
print(" - Memory processes automatically in background")
|
||||
print(" - Commands: memory, process, save, reset, quit/exit")
|
||||
|
||||
print("\n3. Demo Mode")
|
||||
print(" - Quick demonstration of memory system")
|
||||
print(" - Shows how conversations are processed into memories")
|
||||
print(" - Tests memory persistence across sessions")
|
||||
|
||||
print("\n" + "-"*60)
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = input("\nSelect mode (1-3): ").strip()
|
||||
|
||||
if choice == '1':
|
||||
print("✅ Selected: Evaluation Mode")
|
||||
return "evaluation"
|
||||
elif choice == '2':
|
||||
print("✅ Selected: Interactive Mode")
|
||||
return "interactive"
|
||||
elif choice == '3':
|
||||
print("✅ Selected: Demo Mode")
|
||||
return "demo"
|
||||
else:
|
||||
print("❌ Invalid choice. Please enter 1, 2, or 3.")
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n⚠️ Operation cancelled by user")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
|
||||
|
||||
def select_memory_mode_interactive() -> MemoryMode:
|
||||
"""
|
||||
Interactively prompt the user to select a memory mode
|
||||
|
||||
Returns:
|
||||
Selected MemoryMode
|
||||
"""
|
||||
print("\n" + "="*60)
|
||||
print(" 📝 SELECT MEMORY MODE")
|
||||
print("="*60)
|
||||
|
||||
print("\n1. Simple Notes (Basic)")
|
||||
print(" - Store simple facts and preferences")
|
||||
print(" - Each memory is a single line or fact")
|
||||
print(" - Example: 'User email: john@example.com'")
|
||||
|
||||
print("\n2. Enhanced Notes")
|
||||
print(" - Store comprehensive contextual information")
|
||||
print(" - Each memory can be a full paragraph with context")
|
||||
print(" - Example: 'User works at TechCorp as a senior engineer,")
|
||||
print(" specializing in ML for 3 years...'")
|
||||
|
||||
print("\n3. JSON Cards (Basic)")
|
||||
print(" - Hierarchical structured memory")
|
||||
print(" - Format: category → subcategory → key → value")
|
||||
print(" - Example: personal.contact.email → 'john@example.com'")
|
||||
|
||||
print("\n4. Advanced JSON Cards")
|
||||
print(" - Complete memory card objects with metadata")
|
||||
print(" - Each card includes backstory, person, relationship")
|
||||
print(" - Prevents confusion between different contexts")
|
||||
print(" - Example: Medical card for child vs elderly parent")
|
||||
|
||||
print("\n" + "-"*60)
|
||||
|
||||
while True:
|
||||
try:
|
||||
choice = input("\nSelect mode (1-4): ").strip()
|
||||
|
||||
if choice == '1':
|
||||
print("✅ Selected: Simple Notes Mode")
|
||||
return MemoryMode.NOTES
|
||||
elif choice == '2':
|
||||
print("✅ Selected: Enhanced Notes Mode")
|
||||
return MemoryMode.ENHANCED_NOTES
|
||||
elif choice == '3':
|
||||
print("✅ Selected: JSON Cards Mode")
|
||||
return MemoryMode.JSON_CARDS
|
||||
elif choice == '4':
|
||||
print("✅ Selected: Advanced JSON Cards Mode")
|
||||
return MemoryMode.ADVANCED_JSON_CARDS
|
||||
else:
|
||||
print("❌ Invalid choice. Please enter 1, 2, 3, or 4.")
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n⚠️ Operation cancelled by user")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function with command-line argument support"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="User Memory Agent with React Pattern - Following system-hint architecture"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--mode",
|
||||
choices=["interactive", "demo", "evaluation"],
|
||||
default=None,
|
||||
help="Execution mode (if not specified, prompts interactively)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--user",
|
||||
type=str,
|
||||
default="default_user",
|
||||
help="User ID for memory system (default: default_user)"
|
||||
)
|
||||
|
||||
|
||||
parser.add_argument(
|
||||
"--background-processing",
|
||||
type=bool,
|
||||
default=True,
|
||||
help="Enable background memory processing (default: True)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--conversation-interval",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Process memory after N conversations (default: 1 - every conversation)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--memory-mode",
|
||||
choices=["notes", "enhanced_notes", "json_cards", "advanced_json_cards"],
|
||||
help="Memory mode (prompts interactively if not specified)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--provider",
|
||||
choices=["dashscope", "qwen", "bailian", "siliconflow", "doubao", "kimi", "moonshot", "openrouter"],
|
||||
default=None,
|
||||
help="LLM provider (defaults to env PROVIDER or 'kimi')"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--model",
|
||||
type=str,
|
||||
default=None,
|
||||
help="Model name (defaults to provider's default model)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--no-verbose",
|
||||
action="store_true",
|
||||
help="Disable verbose output (verbose is enabled by default)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Set verbose based on no-verbose flag (default is verbose=True)
|
||||
verbose = not args.no_verbose
|
||||
|
||||
# Determine provider
|
||||
provider = args.provider or Config.PROVIDER
|
||||
|
||||
# Validate configuration
|
||||
if not Config.validate(provider):
|
||||
sys.exit(1)
|
||||
|
||||
# Create necessary directories
|
||||
Config.create_directories()
|
||||
|
||||
# Select execution mode if not specified
|
||||
execution_mode = args.mode
|
||||
if execution_mode is None:
|
||||
# Prompt user to select mode
|
||||
execution_mode = select_mode_interactive()
|
||||
|
||||
# Configure memory mode
|
||||
if args.memory_mode:
|
||||
# Mode specified via command line
|
||||
mode_map = {
|
||||
"notes": MemoryMode.NOTES,
|
||||
"enhanced_notes": MemoryMode.ENHANCED_NOTES,
|
||||
"json_cards": MemoryMode.JSON_CARDS,
|
||||
"advanced_json_cards": MemoryMode.ADVANCED_JSON_CARDS
|
||||
}
|
||||
memory_mode = mode_map[args.memory_mode]
|
||||
else:
|
||||
# Interactive mode selection
|
||||
memory_mode = select_memory_mode_interactive()
|
||||
|
||||
print("\n" + "🧠"*40)
|
||||
print(" USER MEMORY SYSTEM - SEPARATED ARCHITECTURE")
|
||||
print("🧠"*40)
|
||||
|
||||
if execution_mode == "demo":
|
||||
demo_memory_system(memory_mode, provider, args.model)
|
||||
|
||||
elif execution_mode == "evaluation":
|
||||
run_evaluation_mode(args.user, memory_mode, verbose, provider, args.model)
|
||||
|
||||
elif execution_mode == "interactive":
|
||||
interactive_mode(
|
||||
user_id=args.user,
|
||||
memory_mode=memory_mode,
|
||||
enable_background_processing=args.background_processing,
|
||||
conversation_interval=args.conversation_interval,
|
||||
provider=provider,
|
||||
model=args.model
|
||||
)
|
||||
|
||||
else:
|
||||
# This should not happen, but handle it gracefully
|
||||
print(f"❌ Unknown execution mode: {execution_mode}")
|
||||
sys.exit(1)
|
||||
|
||||
print("\n👋 Thank you for using User Memory Agent!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,298 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
用户记忆离线命令行工具 (memory_cli)
|
||||
|
||||
这是一个**离线**的记忆运维 CLI,直接操作 memory_manager 的持久化存储,
|
||||
无需任何大模型 API,即可演示用户记忆系统的完整生命周期:
|
||||
提取(手动写入)→ 存储 → 更新 → 去重 / 版本化冲突消解 → 跨会话回忆。
|
||||
|
||||
与 main.py 的分工:
|
||||
* main.py —— 完整的对话 / 后台记忆处理 / 评测流程,需要 LLM API。
|
||||
* memory_cli.py —— 单条记忆的增删查改与整理逻辑,纯本地可运行,
|
||||
便于在没有 API Key 的情况下检验存储、去重与冲突消解的行为。
|
||||
|
||||
子命令:
|
||||
add 写入一条记忆(模拟从某次会话中提取到的事实)
|
||||
query 按关键词检索记忆(跨会话回忆)
|
||||
update 按 ID 更新一条已有记忆
|
||||
consolidate 对记忆做去重与版本化冲突消解(无需 API)
|
||||
show 打印某个用户当前的全部记忆
|
||||
demo 运行一个多会话离线示例,展示记忆在后续会话中被复用
|
||||
extract 从一段对话中自动提取记忆(需要 LLM API)
|
||||
|
||||
示例:
|
||||
python memory_cli.py demo
|
||||
python memory_cli.py add --user alice --session s1 \
|
||||
--content "喜欢靠窗座位" --tags seat_preference
|
||||
python memory_cli.py query --user alice --query 座位
|
||||
python memory_cli.py consolidate --user alice
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from config import Config, MemoryMode
|
||||
from memory_manager import create_memory_manager
|
||||
|
||||
|
||||
# 记忆模式字符串 -> 枚举,供各子命令共用
|
||||
MODE_MAP = {
|
||||
"notes": MemoryMode.NOTES,
|
||||
"enhanced_notes": MemoryMode.ENHANCED_NOTES,
|
||||
"json_cards": MemoryMode.JSON_CARDS,
|
||||
"advanced_json_cards": MemoryMode.ADVANCED_JSON_CARDS,
|
||||
}
|
||||
|
||||
|
||||
def _apply_store_path(store_path):
|
||||
"""若指定了 --store-path,则重定向记忆存储目录(不影响默认数据)。"""
|
||||
if store_path:
|
||||
Config.MEMORY_STORAGE_DIR = store_path
|
||||
Config.create_directories()
|
||||
|
||||
|
||||
def _build_manager(args):
|
||||
"""按命令行参数构造对应的记忆管理器(先设置存储目录再实例化)。"""
|
||||
_apply_store_path(getattr(args, "store_path", None))
|
||||
mode = MODE_MAP[args.memory_mode] if getattr(args, "memory_mode", None) else Config.MEMORY_MODE
|
||||
manager = create_memory_manager(args.user, mode)
|
||||
manager.verbose = True
|
||||
return manager, mode
|
||||
|
||||
|
||||
def cmd_add(args):
|
||||
"""写入一条记忆。仅 notes / enhanced_notes 模式支持自由文本写入。"""
|
||||
manager, mode = _build_manager(args)
|
||||
if mode not in (MemoryMode.NOTES, MemoryMode.ENHANCED_NOTES):
|
||||
print("❌ add 子命令仅支持 notes / enhanced_notes 模式(JSON 卡片请用 main.py 的对话流程生成)")
|
||||
return 1
|
||||
tags = [t.strip() for t in args.tags.split(",") if t.strip()] if args.tags else []
|
||||
note_id = manager.add_memory(args.content, args.session, tags=tags)
|
||||
print(f"✅ 已写入记忆,ID={note_id}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_query(args):
|
||||
"""按关键词检索记忆——用于演示“在后续会话中回忆起用户信息”。"""
|
||||
manager, _ = _build_manager(args)
|
||||
results = manager.search_memories(args.query)
|
||||
if not results:
|
||||
print(f"🔍 未检索到与“{args.query}”相关的记忆")
|
||||
return 0
|
||||
print(f"🔍 检索到 {len(results)} 条与“{args.query}”相关的记忆:")
|
||||
for item in results:
|
||||
if hasattr(item, "content"): # MemoryNote
|
||||
tags = f" [tags: {', '.join(item.tags)}]" if item.tags else ""
|
||||
print(f" - ({item.note_id[:8]}) {item.content}{tags}")
|
||||
else: # (memory_path, data) tuple from JSON managers
|
||||
path, data = item
|
||||
print(f" - {path}: {data}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_update(args):
|
||||
"""按 ID 更新一条已有记忆(模拟用户提供了更新后的信息)。"""
|
||||
manager, _ = _build_manager(args)
|
||||
tags = [t.strip() for t in args.tags.split(",") if t.strip()] if args.tags else None
|
||||
ok = manager.update_memory(args.id, args.content, args.session, tags=tags)
|
||||
print("✅ 更新成功" if ok else "⚠️ 未找到对应 ID 的记忆,更新失败")
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def cmd_consolidate(args):
|
||||
"""去重 + 版本化冲突消解(纯离线,无需 API)。"""
|
||||
manager, _ = _build_manager(args)
|
||||
if not hasattr(manager, "consolidate_memories"):
|
||||
print("ℹ️ 当前记忆模式的整理由写入时的键覆盖自动完成,无需显式 consolidate。")
|
||||
return 0
|
||||
report = manager.consolidate_memories(resolve_conflicts=not args.no_conflict)
|
||||
print("\n===== 记忆整理报告 =====")
|
||||
print(f"整理前条数: {report['initial_count']}")
|
||||
print(f"删除重复项: {report['duplicates_removed']}")
|
||||
print(f"消解冲突数: {len(report['conflicts_resolved'])}")
|
||||
for c in report["conflicts_resolved"]:
|
||||
print(f" ⚔️ 属性“{c['attribute']}”: 保留「{c['kept']}」,"
|
||||
f"废弃 {c['superseded']}")
|
||||
print(f"整理后条数: {report['final_count']}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_show(args):
|
||||
"""打印某用户当前的全部记忆(即注入模型上下文的字符串)。"""
|
||||
manager, mode = _build_manager(args)
|
||||
print(f"\n===== 用户 {args.user} 的记忆(模式: {mode.value})=====")
|
||||
print(manager.get_context_string())
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_demo(args):
|
||||
"""多会话离线示例:写入 → 冲突/重复 → 整理 → 后续会话回忆。
|
||||
|
||||
使用独立的 user_id 和临时存储目录,绝不触碰 data/ 下的真实用户数据。
|
||||
"""
|
||||
import tempfile
|
||||
|
||||
Config.MEMORY_STORAGE_DIR = args.store_path or tempfile.mkdtemp(prefix="memcli_demo_")
|
||||
Config.create_directories()
|
||||
user_id = "demo_user"
|
||||
mgr = create_memory_manager(user_id, MemoryMode.NOTES)
|
||||
mgr.verbose = False
|
||||
# 从干净状态开始,避免重复运行 demo 时叠加旧数据
|
||||
if hasattr(mgr, "clear_all_memories"):
|
||||
mgr.notes = []
|
||||
|
||||
print("\n" + "=" * 62)
|
||||
print(" 用户记忆多会话演示(离线,无需 API)")
|
||||
print(f" 存储目录: {Config.MEMORY_STORAGE_DIR}")
|
||||
print("=" * 62)
|
||||
|
||||
# ---- 会话 1(较早):首次了解用户偏好 ----
|
||||
print("\n[会话 1 · 2024-03-01] 用户初次交流,Agent 提取到以下事实:")
|
||||
mgr.add_memory("用户偏好靠窗座位", "session_2024_03", tags=["seat_preference"])
|
||||
mgr.add_memory("用户家住北京朝阳区", "session_2024_03", tags=["home_address"])
|
||||
mgr.add_memory("用户喜欢川菜", "session_2024_03", tags=["food_preference"])
|
||||
for n in mgr.notes:
|
||||
print(f" + {n.content} [{n.tags[0]}]")
|
||||
|
||||
# ---- 会话 2(较晚):用户搬家(冲突)并重复提到座位偏好(重复)----
|
||||
print("\n[会话 2 · 2024-09-15] 用户提供了更新后的信息:")
|
||||
mgr.add_memory("用户已搬到上海浦东", "session_2024_09", tags=["home_address"])
|
||||
mgr.add_memory("用户偏好靠窗座位", "session_2024_09", tags=["seat_preference"]) # 重复
|
||||
print(" + 用户已搬到上海浦东 [home_address] (与会话1的北京住址冲突)")
|
||||
print(" + 用户偏好靠窗座位 [seat_preference] (与会话1重复)")
|
||||
print(f"\n 整理前共有 {len(mgr.notes)} 条记忆(含 1 条重复、1 处冲突)")
|
||||
|
||||
# ---- 记忆整理:去重 + 版本化冲突消解 ----
|
||||
print("\n[后台整理] 运行 consolidate_memories():去重 + 按更新时间消解冲突")
|
||||
report = mgr.consolidate_memories(resolve_conflicts=True)
|
||||
print(f" 删除重复: {report['duplicates_removed']} 条")
|
||||
for c in report["conflicts_resolved"]:
|
||||
print(f" 冲突消解: 属性“{c['attribute']}”保留「{c['kept']}」,废弃 {c['superseded']}")
|
||||
print(f" 整理后共有 {report['final_count']} 条记忆")
|
||||
|
||||
# ---- 会话 3(更晚):后续会话中回忆用户信息 ----
|
||||
print("\n[会话 3 · 2025-01-20] 用户问:“帮我订张机票,你还记得我住哪吗?”")
|
||||
hits = mgr.search_memories("home_address")
|
||||
recalled = hits[0].content if hits else "(无相关记忆)"
|
||||
print(f" Agent 检索记忆(home_address) → 回忆到:{recalled}")
|
||||
print(f" ✅ Agent 回复:已按您在上海浦东的地址为您推荐航班。")
|
||||
print(" (注意:这里回忆到的是消解冲突后的最新住址,而非会话1的旧址)")
|
||||
|
||||
print("\n最终记忆快照:")
|
||||
print(mgr.get_context_string())
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_extract(args):
|
||||
"""从一段对话中自动提取记忆——需要 LLM API(在线)。
|
||||
|
||||
此子命令的参数解析与校验可离线验证;实际提取会调用后台记忆处理器,
|
||||
需要配置对应 provider 的 API Key。
|
||||
"""
|
||||
provider = args.provider or Config.PROVIDER
|
||||
if not Config.get_api_key(provider):
|
||||
print(f"⚠️ extract 需要 LLM API:未检测到 provider '{provider}' 的 API Key。")
|
||||
print(" 请在 .env 中配置对应的 *_API_KEY 后重试(参数解析已通过)。")
|
||||
return 2
|
||||
|
||||
# 读取对话文本:--conversation 可为文件路径或直接的文本
|
||||
import os
|
||||
text = args.conversation
|
||||
if text and os.path.isfile(text):
|
||||
with open(text, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
if not text:
|
||||
print("❌ 请通过 --conversation 提供对话文本或文件路径")
|
||||
return 1
|
||||
|
||||
_apply_store_path(args.store_path)
|
||||
mode = MODE_MAP[args.memory_mode] if args.memory_mode else Config.MEMORY_MODE
|
||||
|
||||
from background_memory_processor import BackgroundMemoryProcessor
|
||||
processor = BackgroundMemoryProcessor(
|
||||
user_id=args.user, provider=provider, model=args.model, memory_mode=mode, verbose=True
|
||||
)
|
||||
# 将纯文本对话拆成 user/assistant 轮次交给处理器分析
|
||||
lines = [ln for ln in text.splitlines() if ln.strip()]
|
||||
conversation = [{"role": "user" if i % 2 == 0 else "assistant", "content": ln}
|
||||
for i, ln in enumerate(lines)]
|
||||
processor.analyze_conversation(conversation)
|
||||
print("\n✅ 提取完成,当前记忆:")
|
||||
print(processor.memory_manager.get_context_string())
|
||||
return 0
|
||||
|
||||
|
||||
def build_parser():
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="memory_cli.py",
|
||||
description="用户记忆离线命令行工具:增/查/改/整理记忆,演示跨会话记忆的存储与冲突消解(无需 API)。",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", metavar="子命令")
|
||||
|
||||
def add_common(p, need_mode=True):
|
||||
p.add_argument("--user", default="default_user", help="用户 ID(默认: default_user)")
|
||||
p.add_argument("--store-path", default=None,
|
||||
help="记忆存储目录(默认: data/memories,可指定其它路径以免影响真实数据)")
|
||||
if need_mode:
|
||||
p.add_argument("--memory-mode", choices=list(MODE_MAP.keys()), default=None,
|
||||
help="记忆存储格式(默认取环境变量 MEMORY_MODE)")
|
||||
|
||||
p_add = sub.add_parser("add", help="写入一条记忆(模拟从会话中提取到的事实)")
|
||||
add_common(p_add)
|
||||
p_add.add_argument("--session", default="cli_session", help="来源会话 ID(默认: cli_session)")
|
||||
p_add.add_argument("--content", required=True, help="记忆内容文本")
|
||||
p_add.add_argument("--tags", default=None, help="标签,逗号分隔;第一个标签作为冲突消解的属性键")
|
||||
p_add.set_defaults(func=cmd_add)
|
||||
|
||||
p_query = sub.add_parser("query", help="按关键词检索记忆(跨会话回忆)")
|
||||
add_common(p_query)
|
||||
p_query.add_argument("--query", required=True, help="检索关键词")
|
||||
p_query.set_defaults(func=cmd_query)
|
||||
|
||||
p_update = sub.add_parser("update", help="按 ID 更新一条已有记忆")
|
||||
add_common(p_update)
|
||||
p_update.add_argument("--id", required=True, help="要更新的记忆 ID")
|
||||
p_update.add_argument("--session", default="cli_session", help="本次更新的会话 ID")
|
||||
p_update.add_argument("--content", required=True, help="更新后的记忆内容")
|
||||
p_update.add_argument("--tags", default=None, help="更新后的标签,逗号分隔")
|
||||
p_update.set_defaults(func=cmd_update)
|
||||
|
||||
p_cons = sub.add_parser("consolidate", help="去重 + 版本化冲突消解(纯离线)")
|
||||
add_common(p_cons)
|
||||
p_cons.add_argument("--no-conflict", action="store_true",
|
||||
help="只做去重,不做冲突消解")
|
||||
p_cons.set_defaults(func=cmd_consolidate)
|
||||
|
||||
p_show = sub.add_parser("show", help="打印某用户当前的全部记忆")
|
||||
add_common(p_show)
|
||||
p_show.set_defaults(func=cmd_show)
|
||||
|
||||
p_demo = sub.add_parser("demo", help="多会话离线示例:写入→冲突/重复→整理→后续会话回忆")
|
||||
p_demo.add_argument("--store-path", default=None,
|
||||
help="演示数据的存储目录(默认: 临时目录,不触碰 data/)")
|
||||
p_demo.set_defaults(func=cmd_demo)
|
||||
|
||||
p_ext = sub.add_parser("extract", help="从对话中自动提取记忆(需要 LLM API)")
|
||||
add_common(p_ext)
|
||||
p_ext.add_argument("--conversation", required=True, help="对话文本或对话文件路径")
|
||||
p_ext.add_argument("--provider", default=None,
|
||||
choices=["dashscope", "qwen", "bailian", "siliconflow", "doubao", "kimi", "moonshot", "openrouter"],
|
||||
help="LLM 提供商(默认取环境变量 PROVIDER)")
|
||||
p_ext.add_argument("--model", default=None, help="模型名称(默认使用提供商默认模型)")
|
||||
p_ext.set_defaults(func=cmd_extract)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main():
|
||||
parser = build_parser()
|
||||
args = parser.parse_args()
|
||||
if not getattr(args, "command", None):
|
||||
parser.print_help()
|
||||
return 0
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,799 @@
|
||||
"""
|
||||
Memory Manager module for handling different memory mechanisms
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Dict, List, Any, Optional, Tuple
|
||||
from dataclasses import dataclass, field, asdict
|
||||
from abc import ABC, abstractmethod
|
||||
import logging
|
||||
from config import Config, MemoryMode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalize_text(text: str) -> str:
|
||||
"""Normalize text for duplicate detection: lowercase and collapse whitespace.
|
||||
|
||||
Used by the offline consolidation/dedup logic so that notes that differ only
|
||||
in casing or spacing are recognised as the same fact.
|
||||
"""
|
||||
return " ".join(str(text or "").lower().split())
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryNote:
|
||||
"""Represents a single memory note"""
|
||||
note_id: str
|
||||
content: str
|
||||
session_id: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
tags: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'MemoryNote':
|
||||
"""Create from dictionary"""
|
||||
return cls(**data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryCard:
|
||||
"""Represents a memory card in JSON structure"""
|
||||
category: str
|
||||
subcategory: str
|
||||
key: str
|
||||
value: Any
|
||||
session_id: str
|
||||
created_at: str
|
||||
updated_at: str
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary"""
|
||||
return asdict(self)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> 'MemoryCard':
|
||||
"""Create from dictionary"""
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class BaseMemoryManager(ABC):
|
||||
"""Base class for memory managers"""
|
||||
|
||||
def __init__(self, user_id: str, verbose: bool = False):
|
||||
"""
|
||||
Initialize memory manager
|
||||
|
||||
Args:
|
||||
user_id: Unique identifier for the user
|
||||
verbose: Whether to print detailed operations
|
||||
"""
|
||||
self.user_id = user_id
|
||||
self.verbose = verbose
|
||||
self.memory_file = os.path.join(Config.MEMORY_STORAGE_DIR, f"{user_id}_memory.json")
|
||||
self.load_memory()
|
||||
|
||||
@abstractmethod
|
||||
def load_memory(self):
|
||||
"""Load memory from storage"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def save_memory(self):
|
||||
"""Save memory to storage"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def add_memory(self, content: Any, session_id: str, **kwargs):
|
||||
"""Add a new memory item"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def update_memory(self, memory_id: str, content: Any, session_id: str, **kwargs):
|
||||
"""Update an existing memory item"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_memory(self, memory_id: str):
|
||||
"""Delete a memory item"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_context_string(self) -> str:
|
||||
"""Get memory as a formatted string for LLM context"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def search_memories(self, query: str) -> List[Any]:
|
||||
"""Search memories by query"""
|
||||
pass
|
||||
|
||||
|
||||
class NotesMemoryManager(BaseMemoryManager):
|
||||
"""Memory manager using notes list approach"""
|
||||
|
||||
def __init__(self, user_id: str, verbose: bool = False):
|
||||
self.notes: List[MemoryNote] = []
|
||||
super().__init__(user_id, verbose)
|
||||
|
||||
def load_memory(self):
|
||||
"""Load notes from storage"""
|
||||
if os.path.exists(self.memory_file):
|
||||
try:
|
||||
with open(self.memory_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
self.notes = [MemoryNote.from_dict(note) for note in data.get('notes', [])]
|
||||
logger.info(f"Loaded {len(self.notes)} notes for user {self.user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading notes: {e}")
|
||||
self.notes = []
|
||||
else:
|
||||
self.notes = []
|
||||
logger.info(f"No existing memory file for user {self.user_id}")
|
||||
|
||||
def save_memory(self):
|
||||
"""Save notes to storage"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.memory_file) or ".", exist_ok=True)
|
||||
# Write to a temp file then atomically replace: a crash mid-dump
|
||||
# must not truncate the only copy of the persisted data.
|
||||
tmp_file = self.memory_file + '.tmp'
|
||||
with open(tmp_file, 'w', encoding='utf-8') as f:
|
||||
data = {
|
||||
'user_id': self.user_id,
|
||||
'type': 'notes',
|
||||
'updated_at': datetime.now().isoformat(),
|
||||
'notes': [note.to_dict() for note in self.notes]
|
||||
}
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp_file, self.memory_file)
|
||||
logger.info(f"Saved {len(self.notes)} notes for user {self.user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving notes: {e}")
|
||||
|
||||
def add_memory(self, content: str, session_id: str, tags: List[str] = None):
|
||||
"""Add a new note"""
|
||||
note = MemoryNote(
|
||||
note_id=str(uuid.uuid4()),
|
||||
content=content,
|
||||
session_id=session_id,
|
||||
created_at=datetime.now().isoformat(),
|
||||
updated_at=datetime.now().isoformat(),
|
||||
tags=tags or []
|
||||
)
|
||||
self.notes.append(note)
|
||||
|
||||
if self.verbose:
|
||||
print(f" ➕ Added memory note (ID: {note.note_id[:8]}...):")
|
||||
print(f" Content: {content[:100]}..." if len(content) > 100 else f" Content: {content}")
|
||||
if tags:
|
||||
print(f" Tags: {', '.join(tags)}")
|
||||
|
||||
# Keep only the most recent notes if limit exceeded
|
||||
if len(self.notes) > Config.MAX_MEMORY_ITEMS:
|
||||
# Sort by updated_at and keep the most recent
|
||||
old_count = len(self.notes)
|
||||
self.notes.sort(key=lambda n: n.updated_at, reverse=True)
|
||||
self.notes = self.notes[:Config.MAX_MEMORY_ITEMS]
|
||||
if self.verbose:
|
||||
removed_count = old_count - len(self.notes)
|
||||
print(f" 🗑️ Removed {removed_count} oldest memory notes (limit: {Config.MAX_MEMORY_ITEMS})")
|
||||
|
||||
self.save_memory()
|
||||
return note.note_id
|
||||
|
||||
def update_memory(self, memory_id: str, content: str, session_id: str, tags: List[str] = None):
|
||||
"""Update an existing note"""
|
||||
for note in self.notes:
|
||||
if note.note_id == memory_id:
|
||||
old_content = note.content
|
||||
note.content = content
|
||||
note.session_id = session_id
|
||||
note.updated_at = datetime.now().isoformat()
|
||||
if tags is not None:
|
||||
note.tags = tags
|
||||
|
||||
if self.verbose:
|
||||
print(f" 📝 Updated memory note (ID: {memory_id[:8]}...):")
|
||||
print(f" Old: {old_content[:100]}..." if len(old_content) > 100 else f" Old: {old_content}")
|
||||
print(f" New: {content[:100]}..." if len(content) > 100 else f" New: {content}")
|
||||
if tags:
|
||||
print(f" Tags: {', '.join(tags)}")
|
||||
|
||||
self.save_memory()
|
||||
return True
|
||||
|
||||
if self.verbose:
|
||||
print(f" ⚠️ Memory note not found for update (ID: {memory_id[:8]}...)")
|
||||
return False
|
||||
|
||||
def delete_memory(self, memory_id: str):
|
||||
"""Delete a note"""
|
||||
original_count = len(self.notes)
|
||||
deleted_note = None
|
||||
for note in self.notes:
|
||||
if note.note_id == memory_id:
|
||||
deleted_note = note
|
||||
break
|
||||
|
||||
self.notes = [note for note in self.notes if note.note_id != memory_id]
|
||||
|
||||
if self.verbose:
|
||||
if deleted_note:
|
||||
print(f" 🗑️ Deleted memory note (ID: {memory_id[:8]}...):")
|
||||
print(f" Content: {deleted_note.content[:100]}..." if len(deleted_note.content) > 100 else f" Content: {deleted_note.content}")
|
||||
elif original_count == len(self.notes):
|
||||
print(f" ⚠️ Memory note not found for deletion (ID: {memory_id[:8]}...)")
|
||||
|
||||
self.save_memory()
|
||||
|
||||
def clear_all_memories(self):
|
||||
"""Clear all memories for this user - useful for testing"""
|
||||
self.notes = []
|
||||
self.save_memory()
|
||||
logger.info(f"Cleared all memories for user {self.user_id}")
|
||||
print(f" 🧹 Cleared all memories for user {self.user_id}")
|
||||
|
||||
def get_context_string(self) -> str:
|
||||
"""Get notes as formatted string for LLM context"""
|
||||
if not self.notes:
|
||||
return "No previous memory notes available."
|
||||
|
||||
context = "User Memory Notes:\n\n"
|
||||
for i, note in enumerate(self.notes, 1):
|
||||
context += f"Note {i} (ID: {note.note_id}, Session: {note.session_id}):\n"
|
||||
context += f" Content: {note.content}\n"
|
||||
if note.tags:
|
||||
context += f" Tags: {', '.join(note.tags)}\n"
|
||||
context += f" Updated: {note.updated_at}\n\n"
|
||||
|
||||
return context
|
||||
|
||||
def search_memories(self, query: str) -> List[MemoryNote]:
|
||||
"""Search notes by query (simple text search)"""
|
||||
query_lower = query.lower()
|
||||
results = []
|
||||
for note in self.notes:
|
||||
if query_lower in note.content.lower() or any(query_lower in tag.lower() for tag in note.tags):
|
||||
results.append(note)
|
||||
return results
|
||||
|
||||
def consolidate_memories(self, resolve_conflicts: bool = True) -> Dict[str, Any]:
|
||||
"""Deterministically deduplicate and (optionally) conflict-resolve notes.
|
||||
|
||||
This is the offline counterpart to the LLM-driven memory maintenance in
|
||||
the background processor. It runs without any API call so the storage /
|
||||
dedup / versioned-conflict logic can be exercised and inspected directly.
|
||||
|
||||
Two passes:
|
||||
1. Dedup - notes whose normalized content is identical are merged into
|
||||
one (the most recently updated is kept, tags are unioned).
|
||||
2. Conflict resolution - remaining notes are grouped by their
|
||||
"attribute key" (the first tag, e.g. "home_address"). If notes in a
|
||||
group carry different content they describe conflicting versions of
|
||||
the same attribute; the most recently updated one wins and the older
|
||||
versions are superseded. This is version-based conflict detection.
|
||||
|
||||
Args:
|
||||
resolve_conflicts: When False only the dedup pass runs.
|
||||
|
||||
Returns:
|
||||
A report dict describing what was merged / superseded and the final
|
||||
note count. Nothing is written unless something actually changed.
|
||||
"""
|
||||
report: Dict[str, Any] = {
|
||||
"duplicates_removed": 0,
|
||||
"merged_notes": [],
|
||||
"conflicts_resolved": [],
|
||||
"initial_count": len(self.notes),
|
||||
"final_count": len(self.notes),
|
||||
}
|
||||
|
||||
# --- Pass 1: deduplicate identical content ---------------------------
|
||||
by_content: Dict[str, MemoryNote] = {}
|
||||
deduped: List[MemoryNote] = []
|
||||
for note in self.notes:
|
||||
norm = _normalize_text(note.content)
|
||||
existing = by_content.get(norm)
|
||||
if existing is None:
|
||||
by_content[norm] = note
|
||||
deduped.append(note)
|
||||
continue
|
||||
# Duplicate found - keep whichever is newer, union the tags.
|
||||
keeper, dropped = (existing, note) if existing.updated_at >= note.updated_at else (note, existing)
|
||||
keeper.tags = sorted(set(keeper.tags) | set(dropped.tags))
|
||||
keeper.created_at = min(existing.created_at, note.created_at)
|
||||
keeper.updated_at = max(existing.updated_at, note.updated_at)
|
||||
if keeper is note: # replace the reference we already stored
|
||||
idx = deduped.index(existing)
|
||||
deduped[idx] = note
|
||||
by_content[norm] = note
|
||||
report["duplicates_removed"] += 1
|
||||
report["merged_notes"].append(keeper.content)
|
||||
|
||||
# --- Pass 2: resolve conflicting versions of the same attribute ------
|
||||
if resolve_conflicts:
|
||||
groups: Dict[str, List[MemoryNote]] = {}
|
||||
singletons: List[MemoryNote] = []
|
||||
for note in deduped:
|
||||
attr = note.tags[0] if note.tags else None
|
||||
if attr is None:
|
||||
singletons.append(note)
|
||||
else:
|
||||
groups.setdefault(attr, []).append(note)
|
||||
|
||||
kept: List[MemoryNote] = list(singletons)
|
||||
for attr, members in groups.items():
|
||||
distinct = {_normalize_text(m.content) for m in members}
|
||||
if len(members) == 1 or len(distinct) == 1:
|
||||
# No conflict: single note, or identical content under one attr.
|
||||
kept.extend(members)
|
||||
continue
|
||||
winner = max(members, key=lambda m: m.updated_at)
|
||||
superseded = [m for m in members if m is not winner]
|
||||
kept.append(winner)
|
||||
report["conflicts_resolved"].append({
|
||||
"attribute": attr,
|
||||
"kept": winner.content,
|
||||
"superseded": [m.content for m in superseded],
|
||||
})
|
||||
deduped = kept
|
||||
|
||||
changed = len(deduped) != len(self.notes)
|
||||
self.notes = deduped
|
||||
report["final_count"] = len(self.notes)
|
||||
|
||||
if self.verbose and (report["duplicates_removed"] or report["conflicts_resolved"]):
|
||||
print(f" 🧹 Consolidated memories: {report['initial_count']} → {report['final_count']} notes")
|
||||
for c in report["conflicts_resolved"]:
|
||||
print(f" ⚔️ Conflict on '{c['attribute']}': kept \"{c['kept']}\", "
|
||||
f"superseded {c['superseded']}")
|
||||
|
||||
if changed:
|
||||
self.save_memory()
|
||||
return report
|
||||
|
||||
|
||||
class JSONMemoryManager(BaseMemoryManager):
|
||||
"""Memory manager using hierarchical JSON cards approach"""
|
||||
|
||||
def __init__(self, user_id: str, verbose: bool = False):
|
||||
self.memory_cards: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
||||
super().__init__(user_id, verbose)
|
||||
|
||||
def load_memory(self):
|
||||
"""Load JSON memory cards from storage"""
|
||||
if os.path.exists(self.memory_file):
|
||||
try:
|
||||
with open(self.memory_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
self.memory_cards = data.get('memory_cards', {})
|
||||
logger.info(f"Loaded memory cards for user {self.user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading memory cards: {e}")
|
||||
self.memory_cards = {}
|
||||
else:
|
||||
self.memory_cards = {}
|
||||
logger.info(f"No existing memory file for user {self.user_id}")
|
||||
|
||||
def save_memory(self):
|
||||
"""Save JSON memory cards to storage"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.memory_file) or ".", exist_ok=True)
|
||||
# Write to a temp file then atomically replace: a crash mid-dump
|
||||
# must not truncate the only copy of the persisted data.
|
||||
tmp_file = self.memory_file + '.tmp'
|
||||
with open(tmp_file, 'w', encoding='utf-8') as f:
|
||||
data = {
|
||||
'user_id': self.user_id,
|
||||
'type': 'json_cards',
|
||||
'updated_at': datetime.now().isoformat(),
|
||||
'memory_cards': self.memory_cards
|
||||
}
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp_file, self.memory_file)
|
||||
logger.info(f"Saved memory cards for user {self.user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving memory cards: {e}")
|
||||
|
||||
def add_memory(self, content: Dict[str, Any], session_id: str, **kwargs):
|
||||
"""
|
||||
Add a new memory card
|
||||
|
||||
Args:
|
||||
content: Dictionary with 'category', 'subcategory', 'key', and 'value'
|
||||
session_id: Session identifier
|
||||
"""
|
||||
category = content.get('category', 'general')
|
||||
subcategory = content.get('subcategory', 'info')
|
||||
key = content.get('key', str(uuid.uuid4()))
|
||||
value = content.get('value')
|
||||
|
||||
if category not in self.memory_cards:
|
||||
self.memory_cards[category] = {}
|
||||
|
||||
if subcategory not in self.memory_cards[category]:
|
||||
self.memory_cards[category][subcategory] = {}
|
||||
|
||||
self.memory_cards[category][subcategory][key] = {
|
||||
'value': value,
|
||||
'source': session_id,
|
||||
'updated_at': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
if self.verbose:
|
||||
print(f" ➕ Added JSON memory card: {category}.{subcategory}.{key}")
|
||||
value_str = str(value)[:100] + "..." if len(str(value)) > 100 else str(value)
|
||||
print(f" Value: {value_str}")
|
||||
|
||||
self.save_memory()
|
||||
return f"{category}.{subcategory}.{key}"
|
||||
|
||||
def update_memory(self, memory_id: str, content: Dict[str, Any], session_id: str, **kwargs):
|
||||
"""Update an existing memory card"""
|
||||
parts = memory_id.split('.')
|
||||
if len(parts) != 3:
|
||||
return False
|
||||
|
||||
category, subcategory, key = parts
|
||||
|
||||
if (category in self.memory_cards and
|
||||
subcategory in self.memory_cards[category] and
|
||||
key in self.memory_cards[category][subcategory]):
|
||||
|
||||
old_value = self.memory_cards[category][subcategory][key]['value']
|
||||
value = content.get('value')
|
||||
self.memory_cards[category][subcategory][key] = {
|
||||
'value': value,
|
||||
'source': session_id,
|
||||
'updated_at': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
if self.verbose:
|
||||
print(f" 📝 Updated JSON memory card: {category}.{subcategory}.{key}")
|
||||
old_str = str(old_value)[:100] + "..." if len(str(old_value)) > 100 else str(old_value)
|
||||
new_str = str(value)[:100] + "..." if len(str(value)) > 100 else str(value)
|
||||
print(f" Old: {old_str}")
|
||||
print(f" New: {new_str}")
|
||||
|
||||
self.save_memory()
|
||||
return True
|
||||
|
||||
if self.verbose:
|
||||
print(f" ⚠️ JSON memory card not found for update: {memory_id}")
|
||||
return False
|
||||
|
||||
def delete_memory(self, memory_id: str):
|
||||
"""Delete a memory card"""
|
||||
parts = memory_id.split('.')
|
||||
if len(parts) != 3:
|
||||
if self.verbose:
|
||||
print(f" ⚠️ Invalid memory ID format for deletion: {memory_id}")
|
||||
return
|
||||
|
||||
category, subcategory, key = parts
|
||||
|
||||
if (category in self.memory_cards and
|
||||
subcategory in self.memory_cards[category] and
|
||||
key in self.memory_cards[category][subcategory]):
|
||||
|
||||
deleted_value = self.memory_cards[category][subcategory][key]['value']
|
||||
del self.memory_cards[category][subcategory][key]
|
||||
|
||||
if self.verbose:
|
||||
print(f" 🗑️ Deleted JSON memory card: {category}.{subcategory}.{key}")
|
||||
value_str = str(deleted_value)[:100] + "..." if len(str(deleted_value)) > 100 else str(deleted_value)
|
||||
print(f" Value: {value_str}")
|
||||
|
||||
# Clean up empty subcategories and categories
|
||||
if not self.memory_cards[category][subcategory]:
|
||||
del self.memory_cards[category][subcategory]
|
||||
if not self.memory_cards[category]:
|
||||
del self.memory_cards[category]
|
||||
|
||||
self.save_memory()
|
||||
else:
|
||||
if self.verbose:
|
||||
print(f" ⚠️ JSON memory card not found for deletion: {memory_id}")
|
||||
|
||||
def clear_all_memories(self):
|
||||
"""Clear all memories for this user - useful for testing"""
|
||||
self.memory_cards = {}
|
||||
self.save_memory()
|
||||
logger.info(f"Cleared all memories for user {self.user_id}")
|
||||
print(f" 🧹 Cleared all memories for user {self.user_id}")
|
||||
|
||||
def get_context_string(self) -> str:
|
||||
"""Get memory cards as formatted string for LLM context"""
|
||||
if not self.memory_cards:
|
||||
return "No previous memory cards available."
|
||||
|
||||
context = "User Memory Cards (Hierarchical JSON):\n\n"
|
||||
context += json.dumps(self.memory_cards, indent=2, ensure_ascii=False)
|
||||
return context
|
||||
|
||||
def search_memories(self, query: str) -> List[Tuple[str, Any]]:
|
||||
"""Search memory cards by query"""
|
||||
query_lower = query.lower()
|
||||
results = []
|
||||
|
||||
for category, subcategories in self.memory_cards.items():
|
||||
for subcategory, items in subcategories.items():
|
||||
for key, data in items.items():
|
||||
memory_path = f"{category}.{subcategory}.{key}"
|
||||
value_str = str(data.get('value', '')).lower()
|
||||
|
||||
if (query_lower in category.lower() or
|
||||
query_lower in subcategory.lower() or
|
||||
query_lower in key.lower() or
|
||||
query_lower in value_str):
|
||||
|
||||
results.append((memory_path, data))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
class AdvancedJSONMemoryManager(BaseMemoryManager):
|
||||
"""
|
||||
Advanced JSON memory manager with complete memory card objects
|
||||
Structure: categories -> memory_card_key -> memory card (arbitrary JSON)
|
||||
"""
|
||||
|
||||
def __init__(self, user_id: str, verbose: bool = False):
|
||||
self.categories: Dict[str, Dict[str, Dict[str, Any]]] = {}
|
||||
super().__init__(user_id, verbose)
|
||||
|
||||
def load_memory(self):
|
||||
"""Load advanced JSON memory cards from storage"""
|
||||
if os.path.exists(self.memory_file):
|
||||
try:
|
||||
with open(self.memory_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
self.categories = data.get('categories', {})
|
||||
logger.info(f"Loaded advanced memory cards for user {self.user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading advanced memory cards: {e}")
|
||||
self.categories = {}
|
||||
else:
|
||||
self.categories = {}
|
||||
logger.info(f"No existing memory file for user {self.user_id}")
|
||||
|
||||
def save_memory(self):
|
||||
"""Save advanced JSON memory cards to storage"""
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.memory_file) or ".", exist_ok=True)
|
||||
# Write to a temp file then atomically replace: a crash mid-dump
|
||||
# must not truncate the only copy of the persisted data.
|
||||
tmp_file = self.memory_file + '.tmp'
|
||||
with open(tmp_file, 'w', encoding='utf-8') as f:
|
||||
data = {
|
||||
'user_id': self.user_id,
|
||||
'type': 'advanced_json_cards',
|
||||
'updated_at': datetime.now().isoformat(),
|
||||
'categories': self.categories
|
||||
}
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp_file, self.memory_file)
|
||||
logger.info(f"Saved advanced memory cards for user {self.user_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving advanced memory cards: {e}")
|
||||
|
||||
def add_memory(self, content: Dict[str, Any], session_id: str, **kwargs):
|
||||
"""
|
||||
Add a new memory card
|
||||
|
||||
Args:
|
||||
content: Dictionary with 'category', 'card_key', and 'card' (complete memory card object)
|
||||
session_id: Session identifier
|
||||
|
||||
Returns:
|
||||
Memory ID in format: category.card_key
|
||||
"""
|
||||
category = content.get('category', 'general')
|
||||
card_key = content.get('card_key')
|
||||
card = content.get('card', {})
|
||||
|
||||
if not card_key:
|
||||
card_key = str(uuid.uuid4())
|
||||
|
||||
if category not in self.categories:
|
||||
self.categories[category] = {}
|
||||
|
||||
# Add metadata to the card
|
||||
card['_metadata'] = {
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'updated_at': datetime.now().isoformat(),
|
||||
'source': session_id
|
||||
}
|
||||
|
||||
# Ensure required fields
|
||||
if 'backstory' not in card:
|
||||
card['backstory'] = kwargs.get('backstory', '')
|
||||
if 'date_created' not in card:
|
||||
card['date_created'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
if 'person' not in card:
|
||||
card['person'] = kwargs.get('person', 'Unknown')
|
||||
if 'relationship' not in card:
|
||||
card['relationship'] = kwargs.get('relationship', 'primary account holder')
|
||||
|
||||
self.categories[category][card_key] = card
|
||||
self.save_memory()
|
||||
|
||||
return f"{category}.{card_key}"
|
||||
|
||||
def update_memory(self, memory_id: str, content: Dict[str, Any], session_id: str, **kwargs):
|
||||
"""
|
||||
Update an existing memory card
|
||||
|
||||
Args:
|
||||
memory_id: Memory ID in format category.card_key
|
||||
content: Complete new memory card or partial updates
|
||||
session_id: Session identifier
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
parts = memory_id.split('.', 1)
|
||||
if len(parts) != 2:
|
||||
return False
|
||||
|
||||
category, card_key = parts
|
||||
|
||||
if category not in self.categories or card_key not in self.categories[category]:
|
||||
return False
|
||||
|
||||
card = content.get('card', content)
|
||||
|
||||
# Preserve existing metadata
|
||||
if '_metadata' in self.categories[category][card_key]:
|
||||
old_metadata = self.categories[category][card_key]['_metadata']
|
||||
card['_metadata'] = {
|
||||
'created_at': old_metadata.get('created_at', datetime.now().isoformat()),
|
||||
'updated_at': datetime.now().isoformat(),
|
||||
'source': session_id
|
||||
}
|
||||
else:
|
||||
card['_metadata'] = {
|
||||
'created_at': datetime.now().isoformat(),
|
||||
'updated_at': datetime.now().isoformat(),
|
||||
'source': session_id
|
||||
}
|
||||
|
||||
# Update the card
|
||||
self.categories[category][card_key] = card
|
||||
self.save_memory()
|
||||
|
||||
return True
|
||||
|
||||
def delete_memory(self, memory_id: str):
|
||||
"""Delete a memory card"""
|
||||
parts = memory_id.split('.', 1)
|
||||
if len(parts) != 2:
|
||||
return
|
||||
|
||||
category, card_key = parts
|
||||
|
||||
if category in self.categories and card_key in self.categories[category]:
|
||||
del self.categories[category][card_key]
|
||||
|
||||
# Clean up empty categories
|
||||
if not self.categories[category]:
|
||||
del self.categories[category]
|
||||
|
||||
self.save_memory()
|
||||
|
||||
def clear_all_memories(self):
|
||||
"""Clear all memories for this user"""
|
||||
self.categories = {}
|
||||
self.save_memory()
|
||||
logger.info(f"Cleared all memories for user {self.user_id}")
|
||||
print(f" 🧹 Cleared all memories for user {self.user_id}")
|
||||
|
||||
def get_context_string(self) -> str:
|
||||
"""Get memory cards as formatted string for LLM context"""
|
||||
if not self.categories:
|
||||
return "No previous memory cards available."
|
||||
|
||||
context = "User Memory Cards (Advanced JSON Structure):\n\n"
|
||||
for category, cards in self.categories.items():
|
||||
context += f"Category: {category}\n"
|
||||
for card_key, card in cards.items():
|
||||
# Remove internal metadata from display
|
||||
display_card = {k: v for k, v in card.items() if k != '_metadata'}
|
||||
context += f" Card '{card_key}':\n"
|
||||
context += f" {json.dumps(display_card, indent=4, ensure_ascii=False)}\n"
|
||||
|
||||
return context
|
||||
|
||||
def search_memories(self, query: str) -> List[Tuple[str, Any]]:
|
||||
"""Search memory cards by query"""
|
||||
query_lower = query.lower()
|
||||
results = []
|
||||
|
||||
for category, cards in self.categories.items():
|
||||
for card_key, card in cards.items():
|
||||
memory_id = f"{category}.{card_key}"
|
||||
|
||||
# Search in category, card_key, and all card fields
|
||||
card_str = json.dumps(card, ensure_ascii=False).lower()
|
||||
|
||||
if (query_lower in category.lower() or
|
||||
query_lower in card_key.lower() or
|
||||
query_lower in card_str):
|
||||
|
||||
results.append((memory_id, card))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def create_memory_manager(user_id: str, mode: MemoryMode = None) -> BaseMemoryManager:
|
||||
"""
|
||||
Factory function to create appropriate memory manager
|
||||
|
||||
Args:
|
||||
user_id: User identifier
|
||||
mode: Memory mode (defaults to config setting)
|
||||
|
||||
Returns:
|
||||
Memory manager instance
|
||||
"""
|
||||
mode = mode or Config.MEMORY_MODE
|
||||
|
||||
if mode == MemoryMode.NOTES or mode == MemoryMode.ENHANCED_NOTES:
|
||||
# Both basic and enhanced notes use the same manager
|
||||
# The difference is in the prompts used by the agent
|
||||
return NotesMemoryManager(user_id)
|
||||
elif mode == MemoryMode.JSON_CARDS:
|
||||
return JSONMemoryManager(user_id)
|
||||
elif mode == MemoryMode.ADVANCED_JSON_CARDS:
|
||||
return AdvancedJSONMemoryManager(user_id)
|
||||
else:
|
||||
raise ValueError(f"Unknown memory mode: {mode}")
|
||||
|
||||
|
||||
def ensure_memory_cleared(memory_manager: BaseMemoryManager, description: str = "memory") -> bool:
|
||||
"""
|
||||
Ensures that all memory is cleared for a given memory manager.
|
||||
Used primarily for testing and evaluation to ensure clean state before each test case.
|
||||
|
||||
Args:
|
||||
memory_manager: The memory manager to clear
|
||||
description: Description for logging (e.g., "agent memory", "processor memory")
|
||||
|
||||
Returns:
|
||||
True if memory was successfully cleared, False otherwise
|
||||
"""
|
||||
if not memory_manager:
|
||||
logger.warning(f"No memory manager provided for {description}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Clear all memories
|
||||
if hasattr(memory_manager, 'clear_all_memories'):
|
||||
memory_manager.clear_all_memories()
|
||||
else:
|
||||
logger.warning(f"Memory manager for {description} doesn't support clear_all_memories()")
|
||||
return False
|
||||
|
||||
# Verify memory is cleared by checking the context string
|
||||
context = memory_manager.get_context_string()
|
||||
is_cleared = "No previous memory" in context
|
||||
|
||||
if is_cleared:
|
||||
logger.info(f"✅ {description} cleared successfully")
|
||||
else:
|
||||
logger.warning(f"⚠️ {description} may not be fully cleared. Context: {context[:100]}...")
|
||||
|
||||
return is_cleared
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error clearing {description}: {e}")
|
||||
return False
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Formatter for memory operations output
|
||||
Provides consistent formatting for memory operation lists
|
||||
"""
|
||||
|
||||
from typing import List, Dict, Any
|
||||
import json
|
||||
|
||||
|
||||
def format_memory_operations(operations: List[Dict[str, Any]], verbose: bool = False) -> str:
|
||||
"""
|
||||
Format memory operations for display
|
||||
|
||||
Args:
|
||||
operations: List of memory operations
|
||||
verbose: Whether to show detailed output
|
||||
|
||||
Returns:
|
||||
Formatted string representation of operations
|
||||
"""
|
||||
if not operations:
|
||||
return "📝 Memory Operations: None (no updates needed)"
|
||||
|
||||
lines = []
|
||||
lines.append(f"📝 Memory Operations ({len(operations)} total):")
|
||||
lines.append("-" * 50)
|
||||
|
||||
for i, op in enumerate(operations, 1):
|
||||
# Choose icon based on action
|
||||
icon_map = {
|
||||
'add': '➕',
|
||||
'update': '📝',
|
||||
'delete': '🗑️'
|
||||
}
|
||||
action = str(op.get('action') or 'unknown').lower()
|
||||
icon = icon_map.get(action, '❓')
|
||||
|
||||
# Main operation line
|
||||
lines.append(f"{i}. {icon} {action.upper()}")
|
||||
|
||||
if op.get('memory_id'):
|
||||
lines.append(f" Memory ID: {op['memory_id']}")
|
||||
if op.get('content'):
|
||||
content = op['content']
|
||||
# Truncate if too long and not verbose
|
||||
if not verbose and len(content) > 100:
|
||||
content = content[:97] + "..."
|
||||
lines.append(f" Content: {content}")
|
||||
|
||||
# Reason
|
||||
if op.get('reason'):
|
||||
lines.append(f" Reason: {op['reason']}")
|
||||
|
||||
# Tags
|
||||
if op.get('tags'):
|
||||
lines.append(f" Tags: {', '.join(op['tags'])}")
|
||||
|
||||
lines.append("") # Empty line between operations
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def format_operation_summary(summary: Dict[str, int]) -> str:
|
||||
"""
|
||||
Format operation summary statistics
|
||||
|
||||
Args:
|
||||
summary: Dictionary with counts of operations
|
||||
|
||||
Returns:
|
||||
Formatted summary string
|
||||
"""
|
||||
added = summary.get('added', 0)
|
||||
updated = summary.get('updated', 0)
|
||||
deleted = summary.get('deleted', 0)
|
||||
failed = summary.get('failed', 0)
|
||||
|
||||
parts = []
|
||||
if added > 0:
|
||||
parts.append(f"{added} added")
|
||||
if updated > 0:
|
||||
parts.append(f"{updated} updated")
|
||||
if deleted > 0:
|
||||
parts.append(f"{deleted} deleted")
|
||||
if failed > 0:
|
||||
parts.append(f"{failed} failed")
|
||||
|
||||
if not parts:
|
||||
return "No operations performed"
|
||||
|
||||
return "Summary: " + ", ".join(parts)
|
||||
|
||||
|
||||
def display_memory_operations(results: Dict[str, Any], verbose: bool = False):
|
||||
"""
|
||||
Display memory operations from processing results
|
||||
|
||||
Args:
|
||||
results: Processing results containing operations
|
||||
verbose: Whether to show detailed output
|
||||
"""
|
||||
operations = results.get('operations', [])
|
||||
summary = results.get('summary', {})
|
||||
|
||||
# Display operations
|
||||
print(format_memory_operations(operations, verbose))
|
||||
|
||||
# Display summary
|
||||
print(format_operation_summary(summary))
|
||||
print("-" * 50)
|
||||
|
||||
|
||||
def operations_to_json(operations: List[Dict[str, Any]], pretty: bool = True) -> str:
|
||||
"""
|
||||
Convert operations to JSON string
|
||||
|
||||
Args:
|
||||
operations: List of memory operations
|
||||
pretty: Whether to pretty-print the JSON
|
||||
|
||||
Returns:
|
||||
JSON string representation
|
||||
"""
|
||||
if pretty:
|
||||
return json.dumps(operations, indent=2, ensure_ascii=False)
|
||||
else:
|
||||
return json.dumps(operations, ensure_ascii=False)
|
||||
|
||||
|
||||
def filter_operations_by_action(operations: List[Dict[str, Any]], action: str) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Filter operations by action type
|
||||
|
||||
Args:
|
||||
operations: List of memory operations
|
||||
action: Action type to filter ('add', 'update', 'delete')
|
||||
|
||||
Returns:
|
||||
Filtered list of operations
|
||||
"""
|
||||
return [op for op in operations if op.get('action') == action]
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,199 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Quick start script for User Memory System with Separated Architecture
|
||||
Demonstrates conversation-based memory processing
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from dotenv import load_dotenv
|
||||
from conversational_agent import ConversationalAgent, ConversationConfig
|
||||
from background_memory_processor import BackgroundMemoryProcessor, MemoryProcessorConfig
|
||||
from config import Config, MemoryMode
|
||||
from memory_manager import create_memory_manager
|
||||
from memory_operation_formatter import display_memory_operations
|
||||
|
||||
# Load environment variables
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def quickstart():
|
||||
"""Run a quick demonstration of the memory system with separated architecture"""
|
||||
print("\n" + "="*60)
|
||||
print("🚀 USER MEMORY SYSTEM - QUICK START")
|
||||
print(" (Conversation-Based Memory Processing)")
|
||||
print("="*60)
|
||||
|
||||
# Check configuration
|
||||
if not Config.MOONSHOT_API_KEY:
|
||||
print("\n❌ ERROR: MOONSHOT_API_KEY not found!")
|
||||
print("\nPlease set up your .env file with:")
|
||||
print(" MOONSHOT_API_KEY=your_api_key_here")
|
||||
print("\nYou can get an API key from: https://platform.moonshot.cn/")
|
||||
sys.exit(1)
|
||||
|
||||
# Create directories
|
||||
Config.create_directories()
|
||||
|
||||
# Setup demo user
|
||||
user_id = "quickstart_user"
|
||||
memory_mode = MemoryMode.NOTES
|
||||
|
||||
print(f"\n📌 Setting up separated architecture:")
|
||||
print(f" • User: {user_id}")
|
||||
print(f" • Memory Mode: {memory_mode.value}")
|
||||
print(f" • Processing: After each conversation round")
|
||||
|
||||
# Initialize conversational agent
|
||||
print("\n🤖 Initializing conversational agent...")
|
||||
agent = ConversationalAgent(
|
||||
user_id=user_id,
|
||||
memory_mode=memory_mode,
|
||||
config=ConversationConfig(
|
||||
enable_memory_context=True,
|
||||
enable_conversation_history=True
|
||||
),
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Initialize background memory processor
|
||||
print("🧠 Initializing memory processor...")
|
||||
processor = BackgroundMemoryProcessor(
|
||||
user_id=user_id,
|
||||
memory_mode=memory_mode,
|
||||
config=MemoryProcessorConfig(
|
||||
conversation_interval=1, # Process after each conversation
|
||||
min_conversation_turns=1,
|
||||
output_operations=True
|
||||
),
|
||||
verbose=False
|
||||
)
|
||||
|
||||
print("✅ System initialized\n")
|
||||
|
||||
# Session 1: Introduction
|
||||
print("="*60)
|
||||
print("SESSION 1: INTRODUCTION & LEARNING")
|
||||
print("="*60)
|
||||
|
||||
intro_messages = [
|
||||
"Hi! I'm Alex, a software developer who loves Python and machine learning.",
|
||||
"I'm currently working on a recommendation system project using PyTorch.",
|
||||
"I prefer dark themes in my IDE and always use type hints in my Python code."
|
||||
]
|
||||
|
||||
for i, msg in enumerate(intro_messages, 1):
|
||||
print(f"\n[Conversation Round {i}]")
|
||||
print(f"👤 User: {msg}")
|
||||
|
||||
# Have conversation
|
||||
response = agent.chat(msg)
|
||||
print(f"🤖 Assistant: {response[:150]}..." if len(response) > 150 else f"🤖 Assistant: {response}")
|
||||
|
||||
# Trigger memory processing after each conversation
|
||||
processor.increment_conversation_count()
|
||||
|
||||
print(f"\n📝 Processing memory after conversation {i}...")
|
||||
results = processor.process_recent_conversations()
|
||||
|
||||
# Display memory operations
|
||||
operations = results.get('operations', [])
|
||||
if operations:
|
||||
print("\nMemory Operations:")
|
||||
for j, op in enumerate(operations, 1):
|
||||
icon = {'add': '➕', 'update': '📝', 'delete': '🗑️'}.get(op['action'], '❓')
|
||||
print(f" {j}. {icon} {op['action'].upper()}: {op.get('content', '')[:80]}...")
|
||||
else:
|
||||
print(" ℹ️ No memory updates needed")
|
||||
|
||||
summary = results.get('summary', {})
|
||||
if any(summary.values()):
|
||||
print(f" Summary: {summary.get('added', 0)} added, {summary.get('updated', 0)} updated")
|
||||
|
||||
# Show current memory state
|
||||
print("\n" + "="*40)
|
||||
print("💾 MEMORY STATE AFTER SESSION 1")
|
||||
print("="*40)
|
||||
memory_manager = create_memory_manager(user_id, memory_mode)
|
||||
print(memory_manager.get_context_string())
|
||||
|
||||
# Session 2: Testing memory recall and updates
|
||||
print("\n" + "="*60)
|
||||
print("SESSION 2: MEMORY RECALL & UPDATES")
|
||||
print("="*60)
|
||||
|
||||
# Start new conversation session
|
||||
agent.reset_session()
|
||||
print("🔄 Started new conversation session\n")
|
||||
|
||||
recall_messages = [
|
||||
"What do you remember about my work and preferences?",
|
||||
"Actually, I recently switched from PyTorch to JAX for better performance.",
|
||||
"Can you recommend tools for my recommendation system based on what you know about me?"
|
||||
]
|
||||
|
||||
for i, msg in enumerate(recall_messages, 1):
|
||||
print(f"\n[Conversation Round {i}]")
|
||||
print(f"👤 User: {msg}")
|
||||
|
||||
# Have conversation
|
||||
response = agent.chat(msg)
|
||||
|
||||
# Show full response for memory recall questions
|
||||
if "remember" in msg.lower() or "recommend" in msg.lower():
|
||||
print(f"🤖 Assistant: {response}")
|
||||
else:
|
||||
print(f"🤖 Assistant: {response[:150]}..." if len(response) > 150 else f"🤖 Assistant: {response}")
|
||||
|
||||
# Trigger memory processing
|
||||
processor.increment_conversation_count()
|
||||
|
||||
print(f"\n📝 Processing memory after conversation {i}...")
|
||||
results = processor.process_recent_conversations()
|
||||
|
||||
# Display memory operations
|
||||
operations = results.get('operations', [])
|
||||
if operations:
|
||||
print("\nMemory Operations:")
|
||||
for j, op in enumerate(operations, 1):
|
||||
icon = {'add': '➕', 'update': '📝', 'delete': '🗑️'}.get(op['action'], '❓')
|
||||
content = op.get('content', op.get('memory_id', 'N/A'))
|
||||
print(f" {j}. {icon} {op['action'].upper()}: {content[:80]}...")
|
||||
if op.get('reason'):
|
||||
print(f" Reason: {op['reason'][:80]}...")
|
||||
else:
|
||||
print(" ℹ️ No memory updates needed")
|
||||
|
||||
summary = results.get('summary', {})
|
||||
if any(summary.values()):
|
||||
print(f" Summary: {summary.get('added', 0)} added, {summary.get('updated', 0)} updated")
|
||||
|
||||
# Final memory state
|
||||
print("\n" + "="*40)
|
||||
print("💾 FINAL MEMORY STATE")
|
||||
print("="*40)
|
||||
memory_manager = create_memory_manager(user_id, memory_mode)
|
||||
final_memory = memory_manager.get_context_string()
|
||||
print(final_memory if final_memory else "No memories stored")
|
||||
|
||||
# Summary
|
||||
print("\n" + "="*60)
|
||||
print("✨ QUICK START COMPLETED!")
|
||||
print("="*60)
|
||||
print("\n🎯 Key Features Demonstrated:")
|
||||
print(" • Separated conversation and memory processing")
|
||||
print(" • Memory operations after each conversation round")
|
||||
print(" • Clear list of add/update/delete operations")
|
||||
print(" • Memory persistence across sessions")
|
||||
|
||||
print("\n📚 Next Steps:")
|
||||
print(" 1. Interactive mode: python main.py --mode interactive --user your_name")
|
||||
print(" 2. Adjust processing: --conversation-interval 2 (process every 2 conversations)")
|
||||
print(" 3. Manual processing: --background-processing False")
|
||||
print(" 4. Try JSON cards: --memory-mode json_cards")
|
||||
print(" 5. Run full demo: python main.py --mode demo")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
quickstart()
|
||||
@@ -0,0 +1,23 @@
|
||||
# User Memory System Requirements
|
||||
|
||||
# Core dependencies
|
||||
openai>=1.35.0
|
||||
python-dotenv>=1.0.0
|
||||
|
||||
# Logging and utilities
|
||||
colorama>=0.4.6
|
||||
rich>=13.0.0
|
||||
pyyaml>=6.0.0
|
||||
pydantic>=2.0.0
|
||||
|
||||
# Optional: For Dify integration (vector search)
|
||||
requests>=2.31.0
|
||||
|
||||
# Optional: For advanced embedding search (if not using Dify)
|
||||
# sentence-transformers>=2.2.2
|
||||
# numpy>=1.24.0
|
||||
# faiss-cpu>=1.7.4
|
||||
|
||||
# Development dependencies
|
||||
pytest>=7.4.0
|
||||
pytest-asyncio>=0.21.0
|
||||
@@ -0,0 +1,557 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Live sequential-memory campaign for Experiments 3-1 and 3-2.
|
||||
|
||||
Unlike the offline keyword fixture, this runner sends every historical session
|
||||
to a real memory writer one at a time. From session two onward the writer is
|
||||
given only the previous *memory state* and the new session; prior raw sessions
|
||||
are deliberately absent. A fresh answer is then generated from memory alone
|
||||
and graded by a different provider/model.
|
||||
|
||||
The default is a six-case smoke campaign (two per layer). Use ``--all`` for the
|
||||
authoritative 60-case × four-mode comparison required by the manuscript.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import json
|
||||
import os
|
||||
import statistics
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List
|
||||
|
||||
import yaml
|
||||
from openai import OpenAI
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
CHAPTER = HERE.parent
|
||||
sys.path.insert(0, str(CHAPTER))
|
||||
from experiment_utils import ChatRecorder, jsonable, sha256_file, write_campaign_evidence
|
||||
|
||||
|
||||
ARK_ENDPOINT = "https://ark.cn-beijing.volces.com/api/v3"
|
||||
MOONSHOT_ENDPOINT = "https://api.moonshot.cn/v1"
|
||||
MODES = ("notes", "enhanced_notes", "json_cards", "advanced_json_cards")
|
||||
|
||||
MODE_INSTRUCTIONS = {
|
||||
"notes": (
|
||||
"Store memory as an array of minimal standalone factual notes. Split a "
|
||||
"complex statement into atomic facts; keep exact names, identifiers and dates."
|
||||
),
|
||||
"enhanced_notes": (
|
||||
"Store memory as an array of contextual paragraphs. Each paragraph must retain "
|
||||
"the entity, event, time, status, and relationships needed to interpret it."
|
||||
),
|
||||
"json_cards": (
|
||||
"Store memory as a hierarchical JSON object using category/subcategory/key/value "
|
||||
"organization. Preserve multi-entity distinctions and historical status."
|
||||
),
|
||||
"advanced_json_cards": (
|
||||
"Store memory as an array of cards. Every card must include category, card_key, "
|
||||
"backstory, person, relationship, timestamp, status, and a facts object. Keep "
|
||||
"conflicting instructions as ordered versions rather than silently merging them."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def parse_json(text: str) -> Dict[str, Any]:
|
||||
text = (text or "").strip()
|
||||
if "```" in text:
|
||||
parts = text.split("```")
|
||||
text = parts[1]
|
||||
if text.startswith("json"):
|
||||
text = text[4:]
|
||||
return json.loads(text.strip())
|
||||
|
||||
|
||||
def load_cases(root: Path, args: argparse.Namespace) -> List[Dict[str, Any]]:
|
||||
paths = sorted(root.glob("layer*/*.yaml"))
|
||||
cases = []
|
||||
wanted = set(args.case or [])
|
||||
by_layer: Dict[str, int] = defaultdict(int)
|
||||
for path in paths:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
if wanted and data.get("test_id") not in wanted:
|
||||
continue
|
||||
layer = data.get("category")
|
||||
if not args.all and not wanted and by_layer[layer] >= args.per_layer:
|
||||
continue
|
||||
data["_path"] = str(path.resolve())
|
||||
cases.append(data)
|
||||
by_layer[layer] += 1
|
||||
if wanted:
|
||||
missing = wanted - {c["test_id"] for c in cases}
|
||||
if missing:
|
||||
raise ValueError(f"Unknown test ids: {sorted(missing)}")
|
||||
return cases
|
||||
|
||||
|
||||
def format_history(history: Dict[str, Any]) -> str:
|
||||
metadata = json.dumps(history.get("metadata") or {}, ensure_ascii=False)
|
||||
lines = [
|
||||
f"conversation_id={history.get('conversation_id')}",
|
||||
f"timestamp={history.get('timestamp')}",
|
||||
f"metadata={metadata}",
|
||||
]
|
||||
for message in history.get("messages", []):
|
||||
lines.append(f"{str(message.get('role', '')).upper()}: {message.get('content', '')}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def initial_memory(mode: str) -> Any:
|
||||
return [] if mode != "json_cards" else {}
|
||||
|
||||
|
||||
def memory_prompt(mode: str, memory: Any, history: Dict[str, Any], session_index: int) -> List[Dict[str, str]]:
|
||||
system = (
|
||||
"You are a long-term memory writer. Select only facts that may help a future "
|
||||
"assistant, but retain exact values, ownership, event status, dates, provenance, "
|
||||
"and relationships. Apply updates without losing still-valid facts. Never answer "
|
||||
"the conversation. Return JSON only as {\"memory\": ...}. " + MODE_INSTRUCTIONS[mode]
|
||||
)
|
||||
user = (
|
||||
f"MEMORY MODE: {mode}\nSESSION INDEX: {session_index}\n\n"
|
||||
"CURRENT MEMORY STATE (the only retained information from older sessions):\n"
|
||||
f"{json.dumps(memory, ensure_ascii=False)}\n\n"
|
||||
"NEW SESSION (analyze this session, then replace the memory state):\n"
|
||||
f"{format_history(history)}"
|
||||
)
|
||||
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
||||
|
||||
|
||||
def answer_prompt(mode: str, memory: Any, question: str) -> List[Dict[str, str]]:
|
||||
return [
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You are an assistant in a brand-new session. The supplied long-term memory "
|
||||
"is your only source about this user: you cannot access earlier raw dialogue. "
|
||||
"Answer accurately, resolve ambiguity, connect sessions, and proactively warn "
|
||||
"about material risks. Do not invent facts."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"MEMORY MODE: {mode}\nLONG-TERM MEMORY:\n"
|
||||
f"{json.dumps(memory, ensure_ascii=False)}\n\nUSER QUESTION:\n{question}"
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def judge_prompt(case: Dict[str, Any], answer: str) -> List[Dict[str, str]]:
|
||||
source = "\n\n".join(format_history(h) for h in case["conversation_histories"])
|
||||
system = (
|
||||
"You are a strict independent judge of a memory assistant. Use only the authoritative "
|
||||
"conversation source. Score precision, recall, reasoning, and proactivity from 1 to 4. "
|
||||
"A material unsupported or contradicted factual claim is a hallucination veto. Return "
|
||||
"JSON only."
|
||||
)
|
||||
user = f"""AUTHORITATIVE SOURCE:
|
||||
{source}
|
||||
|
||||
QUESTION: {case['user_question']}
|
||||
ANSWER: {answer}
|
||||
EVALUATION CRITERIA: {case['evaluation_criteria']}
|
||||
EXPECTED BEHAVIOR: {case.get('expected_behavior', '')}
|
||||
|
||||
Return exactly:
|
||||
{{"dimensions": {{"precision": {{"score": 1, "reasoning": "...", "evidence": []}},
|
||||
"recall": {{"score": 1, "reasoning": "...", "evidence": []}},
|
||||
"reasoning": {{"score": 1, "reasoning": "...", "evidence": []}},
|
||||
"proactivity": {{"score": 1, "reasoning": "...", "evidence": []}}}},
|
||||
"hallucination": {{"detected": false, "claims": [], "reasoning": "..."}},
|
||||
"overall_reasoning": "..."}}
|
||||
|
||||
Scale: 4 fully meets the concrete criterion; 3 meets the core with only a minor
|
||||
defect; 2 has a material omission; 1 misses/contradicts the core. Asking a
|
||||
targeted clarification is correct when several entities plausibly match.
|
||||
"""
|
||||
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
||||
|
||||
|
||||
def judge_summary(raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
dims = raw.get("dimensions") or {}
|
||||
scores = {}
|
||||
for name in ("precision", "recall", "reasoning", "proactivity"):
|
||||
score = int((dims.get(name) or {}).get("score", 1))
|
||||
scores[name] = min(4, max(1, score))
|
||||
hallucination = bool((raw.get("hallucination") or {}).get("detected"))
|
||||
passed = not hallucination and all(scores[x] >= 3 for x in ("precision", "recall", "reasoning"))
|
||||
reward = 0.0 if hallucination else statistics.mean(scores.values()) / 4.0
|
||||
return {"scores": scores, "hallucination_veto": hallucination, "passed": passed, "reward": reward}
|
||||
|
||||
|
||||
class Campaign:
|
||||
def __init__(self, args: argparse.Namespace):
|
||||
ark_key = os.getenv("ARK_API_KEY") or os.getenv("DOUBAO_API_KEY")
|
||||
moonshot_key = os.getenv("MOONSHOT_API_KEY")
|
||||
if not ark_key or not moonshot_key:
|
||||
raise RuntimeError("ARK_API_KEY and MOONSHOT_API_KEY are both required")
|
||||
self.args = args
|
||||
self.writer_client = OpenAI(
|
||||
api_key=ark_key, base_url=args.writer_endpoint, timeout=args.timeout, max_retries=3
|
||||
)
|
||||
self.judge_client = OpenAI(
|
||||
api_key=moonshot_key, base_url=args.judge_endpoint, timeout=args.timeout, max_retries=3
|
||||
)
|
||||
self.checkpoint_dir = args.checkpoint_dir.resolve()
|
||||
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.checkpoint_signature = {
|
||||
"writer_endpoint": args.writer_endpoint,
|
||||
"writer_model": args.writer_model,
|
||||
"judge_endpoint": args.judge_endpoint,
|
||||
"judge_model": args.judge_model,
|
||||
"seed": args.seed,
|
||||
}
|
||||
|
||||
def _checkpoint_path(self, test_id: str, mode: str) -> Path:
|
||||
safe_id = "".join(c if c.isalnum() or c in "-_" else "_" for c in test_id)
|
||||
return self.checkpoint_dir / f"{safe_id}--{mode}.json"
|
||||
|
||||
@staticmethod
|
||||
def _write_checkpoint(path: Path, payload: Dict[str, Any]) -> None:
|
||||
temporary = path.with_suffix(f".{threading.get_ident()}.tmp")
|
||||
temporary.write_text(
|
||||
json.dumps(jsonable(payload), ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
temporary.replace(path)
|
||||
|
||||
@staticmethod
|
||||
def _successful_call(calls: List[Dict[str, Any]], purpose: str) -> Dict[str, Any] | None:
|
||||
for call in reversed(calls):
|
||||
choices = (call.get("response") or {}).get("choices") or []
|
||||
finish_reason = choices[0].get("finish_reason") if choices else None
|
||||
if (
|
||||
call.get("purpose") == purpose
|
||||
and "response" in call
|
||||
and "error" not in call
|
||||
and finish_reason != "length"
|
||||
):
|
||||
return call
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _content_from_call(call: Dict[str, Any]) -> str:
|
||||
return call["response"]["choices"][0]["message"]["content"]
|
||||
|
||||
def run_one(self, case: Dict[str, Any], mode: str) -> Dict[str, Any]:
|
||||
checkpoint_path = self._checkpoint_path(case["test_id"], mode)
|
||||
if checkpoint_path.exists():
|
||||
checkpoint = json.loads(checkpoint_path.read_text(encoding="utf-8"))
|
||||
if checkpoint.get("signature") != self.checkpoint_signature:
|
||||
raise RuntimeError(
|
||||
f"checkpoint signature mismatch for {case['test_id']} {mode}; "
|
||||
"use a different --checkpoint-dir"
|
||||
)
|
||||
else:
|
||||
checkpoint = {
|
||||
"schema_version": "chapter3-memory-checkpoint-v1",
|
||||
"signature": self.checkpoint_signature,
|
||||
"test_id": case["test_id"],
|
||||
"mode": mode,
|
||||
"status": "running",
|
||||
"memory_states": [],
|
||||
"writer_calls": [],
|
||||
"judge_calls": [],
|
||||
}
|
||||
if checkpoint.get("status") == "completed" and checkpoint.get("result"):
|
||||
result = dict(checkpoint["result"])
|
||||
result["_receipts"] = checkpoint.get("writer_calls", []) + checkpoint.get("judge_calls", [])
|
||||
result["_resumed"] = True
|
||||
return result
|
||||
|
||||
writer: ChatRecorder
|
||||
judge: ChatRecorder
|
||||
|
||||
def persist_calls() -> None:
|
||||
checkpoint["writer_calls"] = writer.calls
|
||||
checkpoint["judge_calls"] = judge.calls
|
||||
checkpoint["updated_at_epoch"] = time.time()
|
||||
self._write_checkpoint(checkpoint_path, checkpoint)
|
||||
|
||||
class JobRecorder(ChatRecorder):
|
||||
def create(inner_self, *, purpose: str, **request: Any) -> Any:
|
||||
try:
|
||||
return super(JobRecorder, inner_self).create(purpose=purpose, **request)
|
||||
finally:
|
||||
persist_calls()
|
||||
|
||||
writer = JobRecorder(self.writer_client, "ark", self.args.writer_endpoint)
|
||||
judge = JobRecorder(self.judge_client, "moonshot", self.args.judge_endpoint)
|
||||
writer.calls = list(checkpoint.get("writer_calls", []))
|
||||
judge.calls = list(checkpoint.get("judge_calls", []))
|
||||
|
||||
states = list(checkpoint.get("memory_states", []))
|
||||
memory: Any = states[-1]["memory"] if states else initial_memory(mode)
|
||||
for index, history in enumerate(case["conversation_histories"], start=1):
|
||||
if index <= len(states):
|
||||
continue
|
||||
messages = memory_prompt(mode, memory, history, index)
|
||||
purpose = f"3-1/3-2 memory update {case['test_id']} {mode} session {index}"
|
||||
prior_call = self._successful_call(writer.calls, purpose)
|
||||
if prior_call:
|
||||
content = self._content_from_call(prior_call)
|
||||
else:
|
||||
response = writer.create(
|
||||
purpose=purpose,
|
||||
model=self.args.writer_model,
|
||||
messages=messages,
|
||||
temperature=0,
|
||||
seed=self.args.seed,
|
||||
max_tokens=self.args.memory_max_tokens,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
content = response.choices[0].message.content
|
||||
parsed = parse_json(content)
|
||||
memory = parsed.get("memory", parsed)
|
||||
states.append(
|
||||
{
|
||||
"session_index": index,
|
||||
"conversation_id": history.get("conversation_id"),
|
||||
"memory": memory,
|
||||
"isolation": {
|
||||
"prior_raw_histories_supplied": 0,
|
||||
"current_memory_supplied": True,
|
||||
"new_history_supplied": history.get("conversation_id"),
|
||||
},
|
||||
}
|
||||
)
|
||||
checkpoint["memory_states"] = states
|
||||
persist_calls()
|
||||
|
||||
answer_purpose = f"3-1/3-2 answer {case['test_id']} {mode}"
|
||||
answer_call = self._successful_call(writer.calls, answer_purpose)
|
||||
if answer_call:
|
||||
answer = self._content_from_call(answer_call) or ""
|
||||
else:
|
||||
answer_response = writer.create(
|
||||
purpose=answer_purpose,
|
||||
model=self.args.writer_model,
|
||||
messages=answer_prompt(mode, memory, case["user_question"]),
|
||||
temperature=0,
|
||||
seed=self.args.seed,
|
||||
max_tokens=self.args.answer_max_tokens,
|
||||
)
|
||||
answer = answer_response.choices[0].message.content or ""
|
||||
checkpoint["answer"] = answer
|
||||
persist_calls()
|
||||
|
||||
judge_purpose = f"3-1/3-2 independent judge {case['test_id']} {mode}"
|
||||
prior_judge = self._successful_call(judge.calls, judge_purpose)
|
||||
if prior_judge:
|
||||
judge_content = self._content_from_call(prior_judge)
|
||||
else:
|
||||
judge_response = judge.create(
|
||||
purpose=judge_purpose,
|
||||
model=self.args.judge_model,
|
||||
messages=judge_prompt(case, answer),
|
||||
temperature=0,
|
||||
seed=self.args.seed,
|
||||
max_tokens=self.args.judge_max_tokens,
|
||||
response_format={"type": "json_object"},
|
||||
)
|
||||
judge_content = judge_response.choices[0].message.content
|
||||
judge_raw = parse_json(judge_content)
|
||||
result = {
|
||||
"test_id": case["test_id"],
|
||||
"layer": case["category"],
|
||||
"title": case["title"],
|
||||
"mode": mode,
|
||||
"session_count": len(case["conversation_histories"]),
|
||||
"memory_states": states,
|
||||
"answer": answer,
|
||||
"judge": judge_summary(judge_raw),
|
||||
"judge_raw": judge_raw,
|
||||
}
|
||||
checkpoint["status"] = "completed"
|
||||
checkpoint["result"] = result
|
||||
persist_calls()
|
||||
result["_receipts"] = writer.calls + judge.calls
|
||||
result["_resumed"] = False
|
||||
return result
|
||||
|
||||
|
||||
def aggregate(results: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
groups: Dict[str, Dict[str, List[Dict[str, Any]]]] = defaultdict(lambda: defaultdict(list))
|
||||
for result in results:
|
||||
groups[result["mode"]][result["layer"]].append(result)
|
||||
output: Dict[str, Any] = {}
|
||||
for mode, layers in groups.items():
|
||||
output[mode] = {}
|
||||
all_rows = []
|
||||
for layer, rows in sorted(layers.items()):
|
||||
all_rows.extend(rows)
|
||||
output[mode][layer] = {
|
||||
"n": len(rows),
|
||||
"pass_rate": sum(r["judge"]["passed"] for r in rows) / len(rows),
|
||||
"mean_reward": statistics.mean(r["judge"]["reward"] for r in rows),
|
||||
"hallucination_rate": sum(r["judge"]["hallucination_veto"] for r in rows) / len(rows),
|
||||
}
|
||||
output[mode]["overall"] = {
|
||||
"n": len(all_rows),
|
||||
"pass_rate": sum(r["judge"]["passed"] for r in all_rows) / len(all_rows),
|
||||
"mean_reward": statistics.mean(r["judge"]["reward"] for r in all_rows),
|
||||
"hallucination_rate": sum(r["judge"]["hallucination_veto"] for r in all_rows) / len(all_rows),
|
||||
}
|
||||
return output
|
||||
|
||||
|
||||
def token_totals(calls: Iterable[Dict[str, Any]]) -> Dict[str, int]:
|
||||
totals = {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
for call in calls:
|
||||
usage = call.get("usage") or {}
|
||||
for key in totals:
|
||||
totals[key] += int(usage.get(key) or 0)
|
||||
return totals
|
||||
|
||||
|
||||
def mode_call_stats(calls: List[Dict[str, Any]]) -> Dict[str, Any]:
|
||||
output = {}
|
||||
for mode in MODES:
|
||||
selected = [call for call in calls if f" {mode}" in str(call.get("purpose", ""))]
|
||||
latencies = [float(call.get("latency_ms") or 0) for call in selected]
|
||||
output[mode] = {
|
||||
"api_calls": len(selected),
|
||||
"token_usage": token_totals(selected),
|
||||
"latency_ms": {
|
||||
"total": sum(latencies),
|
||||
"mean_per_call": statistics.mean(latencies) if latencies else 0,
|
||||
},
|
||||
}
|
||||
return output
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Live sequential memory comparison for Experiments 3-1/3-2")
|
||||
parser.add_argument("--all", action="store_true", help="run all 60 cases (authoritative campaign)")
|
||||
parser.add_argument("--case", action="append", help="run a specific test id (repeatable)")
|
||||
parser.add_argument("--per-layer", type=int, default=2, help="default smoke cases per layer")
|
||||
parser.add_argument("--mode", action="append", choices=MODES, help="memory mode (default: all four)")
|
||||
parser.add_argument("--workers", type=int, default=4)
|
||||
parser.add_argument("--seed", type=int, default=37)
|
||||
parser.add_argument("--writer-model", default=os.getenv("ARK_MODEL", "doubao-seed-1-6-250615"))
|
||||
parser.add_argument("--judge-model", default=os.getenv("MEMORY_JUDGE_MODEL", "moonshot-v1-32k"))
|
||||
parser.add_argument("--writer-endpoint", default=ARK_ENDPOINT)
|
||||
parser.add_argument("--judge-endpoint", default=MOONSHOT_ENDPOINT)
|
||||
parser.add_argument("--timeout", type=float, default=180)
|
||||
parser.add_argument("--memory-max-tokens", type=int, default=6000)
|
||||
parser.add_argument("--answer-max-tokens", type=int, default=1200)
|
||||
parser.add_argument("--judge-max-tokens", type=int, default=1800)
|
||||
parser.add_argument(
|
||||
"--checkpoint-dir",
|
||||
type=Path,
|
||||
default=HERE / "validation" / "checkpoints" / "full-60x4",
|
||||
help="per-case/mode resumable raw-call checkpoints",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test-cases-dir",
|
||||
type=Path,
|
||||
default=CHAPTER / "user-memory-evaluation" / "test_cases",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
cases = load_cases(args.test_cases_dir.resolve(), args)
|
||||
modes = tuple(args.mode or MODES)
|
||||
expected_total = len(cases) * len(modes)
|
||||
print(f"Running {len(cases)} cases × {len(modes)} modes = {expected_total} evaluations")
|
||||
campaign = Campaign(args)
|
||||
results = []
|
||||
calls: List[Dict[str, Any]] = []
|
||||
errors = []
|
||||
jobs = [(case, mode) for case in cases for mode in modes]
|
||||
with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool:
|
||||
future_map = {pool.submit(campaign.run_one, case, mode): (case["test_id"], mode) for case, mode in jobs}
|
||||
for future in concurrent.futures.as_completed(future_map):
|
||||
test_id, mode = future_map[future]
|
||||
try:
|
||||
result = future.result()
|
||||
calls.extend(result.pop("_receipts", []))
|
||||
resumed = result.pop("_resumed", False)
|
||||
results.append(result)
|
||||
marker = "resumed" if resumed else "live"
|
||||
print(f"[{len(results)}/{expected_total}] {test_id} {mode}: reward={result['judge']['reward']:.3f} ({marker})")
|
||||
except Exception as exc:
|
||||
errors.append({"test_id": test_id, "mode": mode, "type": type(exc).__name__, "error": str(exc)})
|
||||
print(f"[ERROR] {test_id} {mode}: {exc}", file=sys.stderr)
|
||||
|
||||
results.sort(key=lambda r: (r["test_id"], r["mode"]))
|
||||
full_suite = (
|
||||
len(cases) == 60
|
||||
and set(modes) == set(MODES)
|
||||
and len(results) == 240
|
||||
and not errors
|
||||
)
|
||||
status = "passed" if full_suite else ("partial" if results else "blocked")
|
||||
isolation_ok = all(
|
||||
state["isolation"]["prior_raw_histories_supplied"] == 0
|
||||
for result in results
|
||||
for state in result["memory_states"]
|
||||
)
|
||||
evidence = {
|
||||
"status": status,
|
||||
"scope": {
|
||||
"dataset_cases_available": len(list(args.test_cases_dir.glob("layer*/*.yaml"))),
|
||||
"cases_run": len(cases),
|
||||
"modes": list(modes),
|
||||
"evaluations_completed": len(results),
|
||||
"evaluations_expected": expected_total,
|
||||
"layers": sorted({c["category"] for c in cases}),
|
||||
},
|
||||
"configuration": {
|
||||
"writer_provider": "ark",
|
||||
"writer_endpoint": args.writer_endpoint,
|
||||
"writer_model": args.writer_model,
|
||||
"writer_seed": args.seed,
|
||||
"judge_provider": "moonshot",
|
||||
"judge_endpoint": args.judge_endpoint,
|
||||
"judge_model": args.judge_model,
|
||||
"judge_is_external_to_writer": True,
|
||||
"workers": args.workers,
|
||||
"memory_max_tokens": args.memory_max_tokens,
|
||||
"answer_max_tokens": args.answer_max_tokens,
|
||||
"judge_max_tokens": args.judge_max_tokens,
|
||||
},
|
||||
"acceptance": {
|
||||
"all_60_cases": len(cases) == 60,
|
||||
"twenty_per_layer": all(sum(c["category"] == layer for c in cases) == 20 for layer in ("layer1", "layer2", "layer3")),
|
||||
"all_four_modes": set(modes) == set(MODES),
|
||||
"sequential_memory_only": isolation_ok,
|
||||
"independent_llm_judge": True,
|
||||
"all_calls_succeeded": not errors,
|
||||
"passed": full_suite and isolation_ok,
|
||||
},
|
||||
"summary": {
|
||||
"aggregate": aggregate(results) if results else {},
|
||||
"token_usage": token_totals(calls),
|
||||
"by_mode": mode_call_stats(calls),
|
||||
"api_calls": len(calls),
|
||||
"errors": len(errors),
|
||||
},
|
||||
"errors": errors,
|
||||
"results": results,
|
||||
}
|
||||
manifest = write_campaign_evidence(
|
||||
HERE,
|
||||
"3-1-and-3-2",
|
||||
evidence,
|
||||
calls,
|
||||
input_paths=[HERE / "run_evaluation.py", *[c["_path"] for c in cases]],
|
||||
)
|
||||
print(json.dumps(manifest["summary"], ensure_ascii=False, indent=2))
|
||||
print(f"Canonical evidence: {HERE / 'validation' / 'latest.json'}")
|
||||
return 0 if not errors else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+66
@@ -0,0 +1,66 @@
|
||||
#!/bin/bash
|
||||
|
||||
# User Memory System Setup Script
|
||||
|
||||
echo "=========================================="
|
||||
echo "User Memory System - Setup"
|
||||
echo "=========================================="
|
||||
|
||||
# Check Python version
|
||||
python_version=$(python3 --version 2>&1 | awk '{print $2}')
|
||||
echo "✓ Python version: $python_version"
|
||||
|
||||
# Create virtual environment if it doesn't exist
|
||||
if [ ! -d "venv" ]; then
|
||||
echo "Creating virtual environment..."
|
||||
python3 -m venv venv
|
||||
echo "✓ Virtual environment created"
|
||||
else
|
||||
echo "✓ Virtual environment already exists"
|
||||
fi
|
||||
|
||||
# Activate virtual environment
|
||||
source venv/bin/activate
|
||||
echo "✓ Virtual environment activated"
|
||||
|
||||
# Install requirements
|
||||
echo "Installing dependencies..."
|
||||
pip install -q --upgrade pip
|
||||
pip install -q -r requirements.txt
|
||||
echo "✓ Dependencies installed"
|
||||
|
||||
# Create .env file if it doesn't exist
|
||||
if [ ! -f ".env" ]; then
|
||||
cp env.example .env
|
||||
echo "✓ Created .env file from template"
|
||||
echo ""
|
||||
echo "⚠️ IMPORTANT: Please edit .env and add your MOONSHOT_API_KEY"
|
||||
echo " Get your API key from: https://platform.moonshot.cn/"
|
||||
else
|
||||
echo "✓ .env file already exists"
|
||||
fi
|
||||
|
||||
# Create necessary directories
|
||||
mkdir -p data/memories
|
||||
mkdir -p data/conversations
|
||||
mkdir -p data/locomo
|
||||
mkdir -p results/locomo
|
||||
mkdir -p logs
|
||||
echo "✓ Created necessary directories"
|
||||
|
||||
# Run tests
|
||||
echo ""
|
||||
echo "Running system tests..."
|
||||
python test_memory_system.py
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Setup Complete!"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo "1. Edit .env and add your MOONSHOT_API_KEY"
|
||||
echo "2. Run: python quickstart.py"
|
||||
echo "3. Run: python main.py interactive <your_name>"
|
||||
echo ""
|
||||
echo "For more information, see README.md"
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Regression test for https://github.com/bojieli/ai-agent-book/issues/181
|
||||
|
||||
BackgroundMemoryProcessor entered an infinite processing loop because:
|
||||
1. Its ConversationHistory instance loaded the history file once at startup
|
||||
and never reloaded, so turns saved by the main agent's separate instance
|
||||
were invisible -> process_recent_conversations() always returned early.
|
||||
2. On that early return, last_processed_count was never updated, so
|
||||
should_process() stayed True and the background thread re-triggered
|
||||
every second forever.
|
||||
|
||||
This test simulates the interactive-mode flow without any API calls.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
# Use an isolated data dir and a dummy API key before importing project modules
|
||||
_tmpdir = tempfile.mkdtemp(prefix="user_memory_test_")
|
||||
os.environ["CONVERSATION_HISTORY_DIR"] = os.path.join(_tmpdir, "conversations")
|
||||
os.environ.setdefault("MOONSHOT_API_KEY", "test-dummy-key")
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from config import Config
|
||||
Config.create_directories()
|
||||
|
||||
from conversation_history import ConversationHistory
|
||||
from background_memory_processor import BackgroundMemoryProcessor
|
||||
|
||||
|
||||
class TestBackgroundProcessorLoop(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.user_id = "test_user_loop"
|
||||
history_file = os.path.join(
|
||||
os.environ["CONVERSATION_HISTORY_DIR"], f"{self.user_id}_history.json"
|
||||
)
|
||||
if os.path.exists(history_file):
|
||||
os.remove(history_file)
|
||||
|
||||
def _make_processor(self):
|
||||
processor = BackgroundMemoryProcessor(
|
||||
user_id=self.user_id, provider="kimi", verbose=False
|
||||
)
|
||||
# Avoid real LLM calls: analysis is a no-op
|
||||
processor.analyze_conversation = lambda ctx: []
|
||||
return processor
|
||||
|
||||
def test_new_turns_from_other_instance_are_seen(self):
|
||||
"""Processor must see turns saved by a separate ConversationHistory."""
|
||||
processor = self._make_processor()
|
||||
|
||||
# Simulate the main agent saving a turn through its own instance
|
||||
agent_history = ConversationHistory(self.user_id)
|
||||
agent_history.add_turn("session-1", "你好,我是小明", "你好小明!")
|
||||
|
||||
processor.increment_conversation_count()
|
||||
self.assertTrue(processor.should_process())
|
||||
|
||||
results = processor.process_recent_conversations()
|
||||
|
||||
self.assertEqual(results.get("analyzed_turns"), 1)
|
||||
self.assertEqual(processor.last_processed_count, 1)
|
||||
self.assertFalse(processor.should_process())
|
||||
|
||||
def test_no_infinite_loop_when_nothing_new(self):
|
||||
"""should_process() must go False after a no-op processing run."""
|
||||
processor = self._make_processor()
|
||||
|
||||
processor.increment_conversation_count()
|
||||
results = processor.process_recent_conversations()
|
||||
self.assertIn("message", results) # early return: nothing to process
|
||||
self.assertFalse(
|
||||
processor.should_process(),
|
||||
"should_process() stayed True after no-op run -> infinite loop",
|
||||
)
|
||||
|
||||
# And it must trigger again once a new conversation arrives
|
||||
processor.increment_conversation_count()
|
||||
self.assertTrue(processor.should_process())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,31 @@
|
||||
import pytest
|
||||
from memory_manager import NotesMemoryManager, MemoryNote
|
||||
|
||||
def test_consolidate_memories_preserves_earliest_created_at():
|
||||
"""Verify deduplicating identical memory notes retains the earliest created_at timestamp."""
|
||||
mgr = NotesMemoryManager(user_id="test_created_at_user")
|
||||
mgr.notes = [
|
||||
MemoryNote(
|
||||
note_id="note_old",
|
||||
content="User prefers Python for AI development",
|
||||
session_id="s1",
|
||||
created_at="2026-01-01T10:00:00",
|
||||
updated_at="2026-01-01T10:00:00",
|
||||
tags=["pref"]
|
||||
),
|
||||
MemoryNote(
|
||||
note_id="note_new",
|
||||
content="User prefers Python for AI development",
|
||||
session_id="s2",
|
||||
created_at="2026-01-10T15:00:00",
|
||||
updated_at="2026-01-10T15:00:00",
|
||||
tags=["language"]
|
||||
)
|
||||
]
|
||||
|
||||
report = mgr.consolidate_memories(resolve_conflicts=False)
|
||||
|
||||
assert len(mgr.notes) == 1
|
||||
assert mgr.notes[0].created_at == "2026-01-01T10:00:00"
|
||||
assert mgr.notes[0].updated_at == "2026-01-10T15:00:00"
|
||||
assert mgr.notes[0].tags == ["language", "pref"]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Regression coverage for evaluation menu memory display."""
|
||||
import builtins
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_evaluation_option_two_prints_memory_manager_context(monkeypatch, capsys):
|
||||
monkeypatch.syspath_prepend(str(Path(__file__).parent))
|
||||
|
||||
import main
|
||||
from config import MemoryMode
|
||||
|
||||
class FakeTestSuite:
|
||||
test_cases = [object()]
|
||||
|
||||
class FakeFramework:
|
||||
def __init__(self):
|
||||
self.test_suite = FakeTestSuite()
|
||||
|
||||
fake_evaluation_modules = {
|
||||
"config": types.SimpleNamespace(),
|
||||
"models": types.SimpleNamespace(),
|
||||
"evaluator": types.SimpleNamespace(),
|
||||
"framework": types.SimpleNamespace(UserMemoryEvaluationFramework=FakeFramework),
|
||||
}
|
||||
real_import = builtins.__import__
|
||||
|
||||
def import_fake_evaluation_module(name, globals=None, locals=None, fromlist=(), level=0):
|
||||
if level == 0 and name in fake_evaluation_modules:
|
||||
return fake_evaluation_modules[name]
|
||||
return real_import(name, globals, locals, fromlist, level)
|
||||
|
||||
monkeypatch.setattr(builtins, "__import__", import_fake_evaluation_module)
|
||||
|
||||
class FakeMemoryManager:
|
||||
def get_context_string(self):
|
||||
return "User Memory Notes:\n\nNote 1: Prefers Python"
|
||||
|
||||
class FakeConversationHistory:
|
||||
def __init__(self):
|
||||
self.conversations = []
|
||||
|
||||
class FakeAgent:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.memory_manager = FakeMemoryManager()
|
||||
self.conversation_history = FakeConversationHistory()
|
||||
self.conversation = []
|
||||
|
||||
class FakeProcessor:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.memory_manager = FakeMemoryManager()
|
||||
|
||||
inputs = iter(["2", "4"])
|
||||
monkeypatch.setattr(main.Config, "get_api_key", lambda provider: "test-key")
|
||||
monkeypatch.setattr(main, "ConversationalAgent", FakeAgent)
|
||||
monkeypatch.setattr(main, "BackgroundMemoryProcessor", FakeProcessor)
|
||||
monkeypatch.setattr("builtins.input", lambda prompt="": next(inputs))
|
||||
|
||||
main.run_evaluation_mode("default_user", MemoryMode.NOTES, provider="moonshot", model="test-model")
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Current Memory State" in output
|
||||
assert "Note 1: Prefers Python" in output
|
||||
@@ -0,0 +1,16 @@
|
||||
import pytest
|
||||
from memory_operation_formatter import format_memory_operations
|
||||
|
||||
|
||||
def test_format_memory_operations_includes_memory_id_when_content_present():
|
||||
operations = [
|
||||
{
|
||||
"action": "update",
|
||||
"memory_id": "mem_101",
|
||||
"content": "User prefers dark mode.",
|
||||
"reason": "User updated preference",
|
||||
}
|
||||
]
|
||||
result = format_memory_operations(operations)
|
||||
assert "Memory ID: mem_101" in result
|
||||
assert "Content: User prefers dark mode." in result
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Regression test for JSON cards tool payload parsing."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from agent import UserMemoryAgent
|
||||
from config import MemoryMode
|
||||
|
||||
|
||||
def test_json_cards_add_memory_parses_stringified_json():
|
||||
captured = {}
|
||||
|
||||
class FakeMemoryManager:
|
||||
def add_memory(self, content, session_id, **kwargs):
|
||||
captured["content"] = content
|
||||
captured["session_id"] = session_id
|
||||
return "banking.first_national.checking_account_number"
|
||||
|
||||
agent = UserMemoryAgent.__new__(UserMemoryAgent)
|
||||
agent.config = type("Cfg", (), {"memory_mode": MemoryMode.JSON_CARDS})()
|
||||
agent.memory_manager = FakeMemoryManager()
|
||||
agent.session_id = "session-test"
|
||||
|
||||
result = agent._tool_add_memory(
|
||||
content='{"category": "banking", "subcategory": "first_national", "key": "checking_account_number", "value": "4429853327"}'
|
||||
)
|
||||
|
||||
assert captured["session_id"] == "session-test"
|
||||
assert captured["content"] == {
|
||||
"category": "banking",
|
||||
"subcategory": "first_national",
|
||||
"key": "checking_account_number",
|
||||
"value": "4429853327",
|
||||
}
|
||||
assert result["success"] is True
|
||||
assert result["memory_id"] == "banking.first_national.checking_account_number"
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Regression tests: os.makedirs(os.path.dirname(p)) must not raise
|
||||
FileNotFoundError when p is a bare filename with no directory component
|
||||
(e.g. LOG_FILE=debug.log, or an empty storage dir env var)."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from config import Config
|
||||
from conversation_history import ConversationHistory
|
||||
from memory_manager import NotesMemoryManager
|
||||
|
||||
|
||||
def test_create_directories_with_bare_log_file(tmp_path, monkeypatch):
|
||||
"""LOG_FILE='debug.log' (no dir) used to crash create_directories()."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(Config, "LOG_FILE", "debug.log")
|
||||
monkeypatch.setattr(Config, "MEMORY_STORAGE_DIR", str(tmp_path / "mem"))
|
||||
monkeypatch.setattr(Config, "CONVERSATION_HISTORY_DIR", str(tmp_path / "conv"))
|
||||
monkeypatch.setattr(Config, "LOCOMO_OUTPUT_DIR", str(tmp_path / "locomo"))
|
||||
Config.create_directories() # must not raise
|
||||
|
||||
|
||||
def test_save_memory_with_empty_storage_dir(tmp_path, monkeypatch):
|
||||
"""MEMORY_STORAGE_DIR='' makes memory_file a bare filename; save must persist."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(Config, "MEMORY_STORAGE_DIR", "")
|
||||
mgr = NotesMemoryManager("u1")
|
||||
assert mgr.memory_file == "u1_memory.json"
|
||||
mgr.add_memory("favorite color is blue", session_id="s1")
|
||||
assert (tmp_path / "u1_memory.json").exists()
|
||||
|
||||
|
||||
def test_save_history_with_empty_history_dir(tmp_path, monkeypatch):
|
||||
"""CONVERSATION_HISTORY_DIR='' makes history_file a bare filename; save must persist."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.setattr(Config, "CONVERSATION_HISTORY_DIR", "")
|
||||
hist = ConversationHistory("u1")
|
||||
assert hist.history_file == "u1_history.json"
|
||||
hist.add_turn("s1", "hi", "hello")
|
||||
assert (tmp_path / "u1_history.json").exists()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""get_recent_turns(limit=0) must return [], not the full history."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from config import Config
|
||||
from conversation_history import ConversationHistory, ConversationTurn
|
||||
|
||||
|
||||
def test_limit_zero_returns_empty(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Config, "CONVERSATION_HISTORY_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(Config, "ENABLE_HISTORY_SEARCH", False)
|
||||
hist = ConversationHistory("u1")
|
||||
hist.conversations = [
|
||||
ConversationTurn("s", "u0", "a0", "t0", 1),
|
||||
ConversationTurn("s", "u1", "a1", "t1", 2),
|
||||
ConversationTurn("s", "u2", "a2", "t2", 3),
|
||||
]
|
||||
assert hist.get_recent_turns(0) == []
|
||||
|
||||
|
||||
def test_positive_limit_still_returns_recent(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Config, "CONVERSATION_HISTORY_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(Config, "ENABLE_HISTORY_SEARCH", False)
|
||||
hist = ConversationHistory("u1")
|
||||
t1 = ConversationTurn("s", "u1", "a1", "t1", 1)
|
||||
t2 = ConversationTurn("s", "u2", "a2", "t2", 2)
|
||||
t3 = ConversationTurn("s", "u3", "a3", "t3", 3)
|
||||
hist.conversations = [t1, t2, t3]
|
||||
assert hist.get_recent_turns(2) == [t2, t3]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Regression test for ConversationHistory.search_history limit=0 handling.
|
||||
|
||||
Proves contract: Requesting limit<=0 from search_history returns an empty list
|
||||
without executing text or vector search.
|
||||
Locks out bug where limit=0 appended the first match before breaking, returning 1 result instead of 0.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SRC = Path(__file__).resolve().parent
|
||||
sys.path.insert(0, str(SRC))
|
||||
|
||||
from conversation_history import ConversationHistory # noqa: E402
|
||||
from config import Config # noqa: E402
|
||||
|
||||
|
||||
def make_history(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(Config, "CONVERSATION_HISTORY_DIR", str(tmp_path))
|
||||
monkeypatch.setattr(Config, "ENABLE_HISTORY_SEARCH", False)
|
||||
return ConversationHistory("user1")
|
||||
|
||||
|
||||
def test_search_history_limit_zero_returns_empty_list(tmp_path, monkeypatch):
|
||||
history = make_history(tmp_path, monkeypatch)
|
||||
history.add_turn("session-1", "Hello assistant", "Hello user")
|
||||
history.add_turn("session-1", "Search for something", "Here is your result")
|
||||
|
||||
results = history.search_history("hello", limit=0)
|
||||
assert results == []
|
||||
|
||||
|
||||
def test_search_history_limit_negative_returns_empty_list(tmp_path, monkeypatch):
|
||||
history = make_history(tmp_path, monkeypatch)
|
||||
history.add_turn("session-1", "Hello assistant", "Hello user")
|
||||
|
||||
results = history.search_history("hello", limit=-1)
|
||||
assert results == []
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Regression tests for issue #493's cross-session history leak."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from conversational_agent import ConversationConfig, ConversationalAgent
|
||||
from conversation_history import ConversationHistory, ConversationTurn
|
||||
|
||||
|
||||
class StubMemoryManager:
|
||||
def __init__(self, context: str):
|
||||
self.context = context
|
||||
self.load_count = 0
|
||||
|
||||
def load_memory(self):
|
||||
self.load_count += 1
|
||||
|
||||
def get_context_string(self) -> str:
|
||||
return self.context
|
||||
|
||||
|
||||
def make_agent(history: ConversationHistory, session_id: str):
|
||||
"""Build the prompt-only portion of an agent without an API client."""
|
||||
agent = ConversationalAgent.__new__(ConversationalAgent)
|
||||
agent.config = ConversationConfig()
|
||||
agent.memory_manager = StubMemoryManager("Preferred editor: VS Code")
|
||||
agent.conversation_history = history
|
||||
agent.session_id = session_id
|
||||
return agent
|
||||
|
||||
|
||||
def test_memory_context_excludes_raw_turns_from_previous_sessions():
|
||||
history = ConversationHistory.__new__(ConversationHistory)
|
||||
history.conversations = [
|
||||
ConversationTurn(
|
||||
"session-old",
|
||||
"My private detail from the old session",
|
||||
"I will remember that detail",
|
||||
"2026-07-28T10:00:00",
|
||||
1,
|
||||
),
|
||||
ConversationTurn(
|
||||
"session-current",
|
||||
"What did we discuss in this session?",
|
||||
"We discussed the current task",
|
||||
"2026-07-29T10:00:00",
|
||||
2,
|
||||
),
|
||||
]
|
||||
agent = make_agent(history, "session-current")
|
||||
|
||||
context = agent._get_memory_context()
|
||||
|
||||
assert "Preferred editor: VS Code" in context
|
||||
assert "=== CURRENT SESSION HISTORY ===" in context
|
||||
assert "What did we discuss in this session?" in context
|
||||
assert "My private detail from the old session" not in context
|
||||
assert "session-old" not in context
|
||||
|
||||
|
||||
def test_new_session_uses_structured_memory_without_old_raw_history():
|
||||
history = ConversationHistory.__new__(ConversationHistory)
|
||||
history.conversations = [
|
||||
ConversationTurn(
|
||||
"session-old",
|
||||
"I work at TechCorp",
|
||||
"Thanks for sharing",
|
||||
"2026-07-28T10:00:00",
|
||||
1,
|
||||
)
|
||||
]
|
||||
agent = make_agent(history, "session-new")
|
||||
|
||||
context = agent._get_memory_context()
|
||||
|
||||
assert "Preferred editor: VS Code" in context
|
||||
assert "I work at TechCorp" not in context
|
||||
assert "CURRENT SESSION HISTORY" not in context
|
||||
assert agent.memory_manager.load_count == 1
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Regression tests for providers that end streams with ``choices=[]``."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from agent import UserMemoryAgent
|
||||
from conversational_agent import ConversationConfig, ConversationalAgent
|
||||
|
||||
|
||||
class StubCompletions:
|
||||
def create(self, **kwargs):
|
||||
return [
|
||||
SimpleNamespace(
|
||||
choices=[
|
||||
SimpleNamespace(delta=SimpleNamespace(content="Hello"))
|
||||
]
|
||||
),
|
||||
SimpleNamespace(choices=[]),
|
||||
]
|
||||
|
||||
|
||||
def stub_client():
|
||||
return SimpleNamespace(
|
||||
chat=SimpleNamespace(completions=StubCompletions())
|
||||
)
|
||||
|
||||
|
||||
def test_conversational_agent_ignores_empty_choices_chunk():
|
||||
agent = ConversationalAgent.__new__(ConversationalAgent)
|
||||
agent.config = ConversationConfig(
|
||||
enable_memory_context=False,
|
||||
enable_conversation_history=False,
|
||||
)
|
||||
agent.verbose = False
|
||||
agent.model = "test-model"
|
||||
agent.client = stub_client()
|
||||
agent.conversation = []
|
||||
agent.conversation_history = None
|
||||
agent.session_id = "session-test"
|
||||
|
||||
assert agent.chat("Hi") == "Hello"
|
||||
assert agent.conversation[-1] == {
|
||||
"role": "assistant",
|
||||
"content": "Hello",
|
||||
}
|
||||
|
||||
|
||||
def test_user_memory_agent_ignores_empty_choices_chunk():
|
||||
agent = UserMemoryAgent.__new__(UserMemoryAgent)
|
||||
agent._get_memory_context = lambda: ""
|
||||
agent.model = "test-model"
|
||||
agent.client = stub_client()
|
||||
agent.conversation = []
|
||||
agent.conversation_history = None
|
||||
agent.session_id = "session-test"
|
||||
|
||||
assert agent._chat_stream("Hi") == "Hello"
|
||||
assert agent.conversation[-1] == {
|
||||
"role": "assistant",
|
||||
"content": "Hello",
|
||||
}
|
||||
+629
File diff suppressed because one or more lines are too long
+345
File diff suppressed because one or more lines are too long
+561
File diff suppressed because one or more lines are too long
+597
File diff suppressed because one or more lines are too long
+737
File diff suppressed because one or more lines are too long
+345
File diff suppressed because one or more lines are too long
+624
File diff suppressed because one or more lines are too long
+713
File diff suppressed because one or more lines are too long
+917
File diff suppressed because one or more lines are too long
+347
File diff suppressed because one or more lines are too long
+633
File diff suppressed because one or more lines are too long
+733
File diff suppressed because one or more lines are too long
+645
File diff suppressed because one or more lines are too long
+353
File diff suppressed because one or more lines are too long
+529
File diff suppressed because one or more lines are too long
+603
File diff suppressed because one or more lines are too long
+589
File diff suppressed because one or more lines are too long
+341
File diff suppressed because one or more lines are too long
+517
File diff suppressed because one or more lines are too long
+617
File diff suppressed because one or more lines are too long
+763
File diff suppressed because one or more lines are too long
+347
File diff suppressed because one or more lines are too long
+623
File diff suppressed because one or more lines are too long
+805
File diff suppressed because one or more lines are too long
+667
File diff suppressed because one or more lines are too long
+346
File diff suppressed because one or more lines are too long
+505
File diff suppressed because one or more lines are too long
+515
File diff suppressed because one or more lines are too long
+527
File diff suppressed because one or more lines are too long
+348
File diff suppressed because one or more lines are too long
+507
File diff suppressed because one or more lines are too long
+425
File diff suppressed because one or more lines are too long
+605
File diff suppressed because one or more lines are too long
+345
File diff suppressed because one or more lines are too long
+633
File diff suppressed because one or more lines are too long
+685
File diff suppressed because one or more lines are too long
+619
File diff suppressed because one or more lines are too long
+343
File diff suppressed because one or more lines are too long
+519
File diff suppressed because one or more lines are too long
+459
File diff suppressed because one or more lines are too long
+667
File diff suppressed because one or more lines are too long
+345
File diff suppressed because one or more lines are too long
+671
File diff suppressed because one or more lines are too long
+716
File diff suppressed because one or more lines are too long
+747
File diff suppressed because one or more lines are too long
+351
File diff suppressed because one or more lines are too long
+646
File diff suppressed because one or more lines are too long
+683
File diff suppressed because one or more lines are too long
+1115
File diff suppressed because one or more lines are too long
+359
File diff suppressed because one or more lines are too long
+721
File diff suppressed because one or more lines are too long
+953
File diff suppressed because one or more lines are too long
+689
File diff suppressed because one or more lines are too long
+344
File diff suppressed because one or more lines are too long
+610
File diff suppressed because one or more lines are too long
+553
File diff suppressed because one or more lines are too long
+783
File diff suppressed because one or more lines are too long
+347
File diff suppressed because one or more lines are too long
+717
File diff suppressed because one or more lines are too long
+733
File diff suppressed because one or more lines are too long
+833
File diff suppressed because one or more lines are too long
+354
File diff suppressed because one or more lines are too long
+749
File diff suppressed because one or more lines are too long
+835
File diff suppressed because one or more lines are too long
+763
File diff suppressed because one or more lines are too long
+348
File diff suppressed because one or more lines are too long
+680
File diff suppressed because one or more lines are too long
+717
File diff suppressed because one or more lines are too long
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user