mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 03:31:28 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb4318f6e8 | ||
|
|
d9bb869d5e |
No files matched your search
@@ -261,6 +261,12 @@ npm install praisonai
|
||||
| ↳ 100+ LLM Support | [Example](examples/python/providers/openai/openai_gpt4_example.py) | [📖](https://docs.praison.ai/models) |
|
||||
| ↳ Callback Agents | [Example](examples/python/general/advanced-callback-systems.py) | [📖](https://docs.praison.ai/features/callbacks) |
|
||||
| ↳ Hooks | [Example](#9-hooks) | [📖](https://docs.praison.ai/features/hooks) |
|
||||
| ↳ Shadow Git Checkpoints | [Example](#10-shadow-git-checkpoints) | [📖](https://docs.praison.ai/features/checkpoints) |
|
||||
| ↳ Background Tasks | [Example](examples/background/basic_background.py) | [📖](https://docs.praison.ai/features/background-tasks) |
|
||||
| ↳ Policy Engine | [Example](examples/policy/basic_policy.py) | [📖](https://docs.praison.ai/features/policy-engine) |
|
||||
| ↳ Thinking Budgets | [Example](examples/thinking/basic_thinking.py) | [📖](https://docs.praison.ai/features/thinking-budgets) |
|
||||
| ↳ Output Styles | [Example](examples/output/basic_output.py) | [📖](https://docs.praison.ai/features/output-styles) |
|
||||
| ↳ Context Compaction | [Example](examples/compaction/basic_compaction.py) | [📖](https://docs.praison.ai/features/context-compaction) |
|
||||
| **📊 Monitoring & Management** | | |
|
||||
| ↳ Sessions Management | [Example](examples/python/sessions/comprehensive-session-management.py) | [📖](https://docs.praison.ai/features/sessions) |
|
||||
| ↳ Auto-Save Sessions | [Example](#session-management-python) | [📖](https://docs.praison.ai/docs/cli/session) |
|
||||
@@ -930,8 +936,191 @@ praisonai checkpoint diff # Show changes
|
||||
praisonai checkpoint restore abc123 # Restore to checkpoint
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/checkpoints)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/checkpoint)
|
||||
- [💻 Example](examples/checkpoints/basic_checkpoints.py)
|
||||
|
||||
### 11. Field Names Reference (A-I-G-S)
|
||||
---
|
||||
|
||||
### 11. Background Tasks
|
||||
|
||||
Run agent tasks asynchronously without blocking:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from praisonaiagents.background import BackgroundRunner, BackgroundConfig
|
||||
|
||||
async def main():
|
||||
config = BackgroundConfig(max_concurrent_tasks=3)
|
||||
runner = BackgroundRunner(config=config)
|
||||
|
||||
async def my_task(name: str) -> str:
|
||||
await asyncio.sleep(2)
|
||||
return f"Task {name} completed"
|
||||
|
||||
task = await runner.submit(my_task, args=("example",), name="my_task")
|
||||
await task.wait(timeout=10.0)
|
||||
print(task.result)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai background list # List running tasks
|
||||
praisonai background status <id> # Check task status
|
||||
praisonai background cancel <id> # Cancel a task
|
||||
praisonai background clear # Clear completed tasks
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/background-tasks)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/background)
|
||||
- [💻 Example](examples/background/basic_background.py)
|
||||
|
||||
---
|
||||
|
||||
### 12. Policy Engine
|
||||
|
||||
Control what agents can and cannot do with policy-based execution:
|
||||
|
||||
```python
|
||||
from praisonaiagents.policy import (
|
||||
PolicyEngine, Policy, PolicyRule, PolicyAction
|
||||
)
|
||||
|
||||
engine = PolicyEngine()
|
||||
|
||||
policy = Policy(
|
||||
name="no_delete",
|
||||
rules=[
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:delete_*",
|
||||
reason="Delete operations blocked"
|
||||
)
|
||||
]
|
||||
)
|
||||
engine.add_policy(policy)
|
||||
|
||||
result = engine.check("tool:delete_file", {})
|
||||
print(f"Allowed: {result.allowed}")
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai policy list # List policies
|
||||
praisonai policy check "tool:name" # Check if allowed
|
||||
praisonai policy init # Create template
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/policy-engine)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/policy)
|
||||
- [💻 Example](examples/policy/basic_policy.py)
|
||||
|
||||
---
|
||||
|
||||
### 13. Thinking Budgets
|
||||
|
||||
Configure token budgets for extended thinking:
|
||||
|
||||
```python
|
||||
from praisonaiagents.thinking import ThinkingBudget, ThinkingTracker
|
||||
|
||||
# Use predefined levels
|
||||
budget = ThinkingBudget.high() # 16,000 tokens
|
||||
|
||||
# Track usage
|
||||
tracker = ThinkingTracker()
|
||||
session = tracker.start_session(budget_tokens=16000)
|
||||
tracker.end_session(session, tokens_used=12000)
|
||||
|
||||
summary = tracker.get_summary()
|
||||
print(f"Utilization: {summary['average_utilization']:.1%}")
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai thinking status # Show current budget
|
||||
praisonai thinking set high # Set budget level
|
||||
praisonai thinking stats # Show usage statistics
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/thinking-budgets)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/thinking)
|
||||
- [💻 Example](examples/thinking/basic_thinking.py)
|
||||
|
||||
---
|
||||
|
||||
### 14. Output Styles
|
||||
|
||||
Configure how agents format their responses:
|
||||
|
||||
```python
|
||||
from praisonaiagents.output import OutputStyle, OutputFormatter
|
||||
|
||||
# Use preset styles
|
||||
style = OutputStyle.concise()
|
||||
formatter = OutputFormatter(style)
|
||||
|
||||
# Format output
|
||||
text = "# Hello\n\nThis is **bold** text."
|
||||
plain = formatter.format(text)
|
||||
print(plain)
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai output status # Show current style
|
||||
praisonai output set concise # Set output style
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/output-styles)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/output-style)
|
||||
- [💻 Example](examples/output/basic_output.py)
|
||||
|
||||
---
|
||||
|
||||
### 15. Context Compaction
|
||||
|
||||
Automatically manage context window size:
|
||||
|
||||
```python
|
||||
from praisonaiagents.compaction import (
|
||||
ContextCompactor, CompactionStrategy
|
||||
)
|
||||
|
||||
compactor = ContextCompactor(
|
||||
max_tokens=4000,
|
||||
strategy=CompactionStrategy.SLIDING,
|
||||
preserve_recent=3
|
||||
)
|
||||
|
||||
messages = [...] # Your conversation history
|
||||
compacted, result = compactor.compact(messages)
|
||||
|
||||
print(f"Compression: {result.compression_ratio:.1%}")
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai compaction status # Show settings
|
||||
praisonai compaction set sliding # Set strategy
|
||||
praisonai compaction stats # Show statistics
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/context-compaction)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/compaction)
|
||||
- [💻 Example](examples/compaction/basic_compaction.py)
|
||||
|
||||
---
|
||||
|
||||
### 16. Field Names Reference (A-I-G-S)
|
||||
|
||||
PraisonAI accepts both old (agents.yaml) and new (workflow.yaml) field names. Use the **canonical names** for new projects:
|
||||
|
||||
@@ -2535,6 +2724,87 @@ PraisonAI provides zero-dependency persistent memory for agents. For detailed ex
|
||||
|
||||
---
|
||||
|
||||
## 💾 Persistence (Databases)
|
||||
|
||||
Enable automatic conversation persistence with 2 lines of code:
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, db
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
db=db.PraisonDB(database_url="postgresql://localhost/mydb"),
|
||||
session_id="my-session" # Optional: defaults to per-hour ID (YYYYMMDDHH)
|
||||
)
|
||||
agent.chat("Hello!") # Auto-persists messages, runs, traces
|
||||
```
|
||||
|
||||
### Persistence CLI Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `praisonai persistence doctor` | Validate DB connectivity |
|
||||
| `praisonai persistence run` | Run agent with persistence |
|
||||
| `praisonai persistence resume` | Resume existing session |
|
||||
| `praisonai persistence export` | Export session to JSONL |
|
||||
| `praisonai persistence import` | Import session from JSONL |
|
||||
| `praisonai persistence migrate` | Apply schema migrations |
|
||||
| `praisonai persistence status` | Show schema status |
|
||||
|
||||
### Databases Table
|
||||
|
||||
| Database | Store Type | Install | Example | Docs |
|
||||
|----------|------------|---------|---------|------|
|
||||
| PostgreSQL | Conversation | `pip install "praisonai[tools]"` | [simple_db_agent.py](examples/persistence/simple_db_agent.py) | [docs](https://docs.praison.ai/docs/databases/postgres) |
|
||||
| MySQL | Conversation | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| SQLite | Conversation | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| SingleStore | Conversation | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Supabase | Conversation | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| SurrealDB | Conversation | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Qdrant | Knowledge | `pip install "praisonai[tools]"` | [knowledge_qdrant.py](examples/persistence/knowledge_qdrant.py) | [docs](https://docs.praison.ai/docs/databases/qdrant) |
|
||||
| ChromaDB | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Pinecone | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Weaviate | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| LanceDB | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Milvus | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| PGVector | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Redis Vector | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Cassandra | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| ClickHouse | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Redis | State | `pip install "praisonai[tools]"` | [state_redis.py](examples/persistence/state_redis.py) | [docs](https://docs.praison.ai/docs/databases/redis) |
|
||||
| MongoDB | State | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| DynamoDB | State | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Firestore | State | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Upstash | State | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Memory | State | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tools Table
|
||||
|
||||
| Tool | Category | Example | Docs |
|
||||
|------|----------|---------|------|
|
||||
| Tavily Search | Web Search | `from praisonai_tools import TavilySearchTool` | [docs](https://docs.praison.ai/docs/tools/tavily) |
|
||||
| DuckDuckGo | Web Search | `from praisonai_tools import DuckDuckGoSearchTool` | [docs](https://docs.praison.ai/docs/tools/duckduckgo) |
|
||||
| Exa Search | Web Search | `from praisonai_tools import ExaSearchTool` | [docs](https://docs.praison.ai/docs/tools/exa) |
|
||||
| Firecrawl | Web Scraping | `from praisonai_tools import FirecrawlTool` | [docs](https://docs.praison.ai/docs/tools/firecrawl) |
|
||||
| Crawl4AI | Web Scraping | `from praisonai_tools import Crawl4AITool` | [docs](https://docs.praison.ai/docs/tools/crawl4ai) |
|
||||
| Wikipedia | Knowledge | `from praisonai_tools import WikipediaTool` | [docs](https://docs.praison.ai/docs/tools/wikipedia) |
|
||||
| ArXiv | Research | `from praisonai_tools import ArxivTool` | [docs](https://docs.praison.ai/docs/tools/arxiv) |
|
||||
| YouTube | Media | `from praisonai_tools import YouTubeTool` | [docs](https://docs.praison.ai/docs/tools/youtube) |
|
||||
| File Tools | File System | `from praisonai_tools import FileReadTool` | [docs](https://docs.praison.ai/docs/tools/file) |
|
||||
| Code Tools | Development | `from praisonai_tools import CodeInterpreterTool` | [docs](https://docs.praison.ai/docs/tools/code) |
|
||||
| Shell Tools | System | `from praisonai_tools import ShellTool` | [docs](https://docs.praison.ai/docs/tools/shell) |
|
||||
| JSON Tools | Data | `from praisonai_tools import JSONTool` | [docs](https://docs.praison.ai/docs/tools/json) |
|
||||
| XML Tools | Data | `from praisonai_tools import XMLTool` | [docs](https://docs.praison.ai/docs/tools/xml) |
|
||||
| CSV Tools | Data | `from praisonai_tools import CSVTool` | [docs](https://docs.praison.ai/docs/tools/csv) |
|
||||
| PDF Tools | Documents | `from praisonai_tools import PDFTool` | [docs](https://docs.praison.ai/docs/tools/pdf) |
|
||||
| Calculator | Math | `from praisonai_tools import CalculatorTool` | [docs](https://docs.praison.ai/docs/tools/calculator) |
|
||||
|
||||
> See [full tools documentation](https://docs.praison.ai/docs/tools/tools) for all available tools.
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Video Tutorials
|
||||
|
||||
Learn PraisonAI through our comprehensive video series:
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.4.0" \
|
||||
"praisonai>=2.4.1" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.4.0" \
|
||||
"praisonai>=2.4.1" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=2.4.0" \
|
||||
"praisonai>=2.4.1" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Persistence CLI Commands
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```bash
|
||||
# Check database connectivity
|
||||
praisonai persistence doctor \
|
||||
--conversation-url postgresql://localhost:5432/praisonai \
|
||||
--state-url redis://localhost:6379
|
||||
|
||||
# Run agent with persistence
|
||||
praisonai persistence run \
|
||||
--conversation-url postgresql://localhost:5432/praisonai \
|
||||
--session-id my-session
|
||||
|
||||
# Resume a session
|
||||
praisonai persistence resume \
|
||||
--conversation-url postgresql://localhost:5432/praisonai \
|
||||
--session-id my-session
|
||||
|
||||
# Export session to file
|
||||
praisonai persistence export \
|
||||
--conversation-url postgresql://localhost:5432/praisonai \
|
||||
--session-id my-session \
|
||||
--output session_backup.jsonl
|
||||
|
||||
# Import session from file
|
||||
praisonai persistence import \
|
||||
--conversation-url postgresql://localhost:5432/praisonai \
|
||||
--file session_backup.jsonl
|
||||
|
||||
# Check schema status
|
||||
praisonai persistence status \
|
||||
--conversation-url postgresql://localhost:5432/praisonai \
|
||||
--state-url redis://localhost:6379
|
||||
|
||||
# Apply migrations
|
||||
praisonai persistence migrate \
|
||||
--conversation-url postgresql://localhost:5432/praisonai \
|
||||
--state-url redis://localhost:6379
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Instead of CLI flags, you can use environment variables:
|
||||
|
||||
```bash
|
||||
export PRAISON_CONVERSATION_URL=postgresql://localhost:5432/praisonai
|
||||
export PRAISON_STATE_URL=redis://localhost:6379
|
||||
export PRAISON_KNOWLEDGE_URL=http://localhost:6333
|
||||
|
||||
# Then run without URL flags
|
||||
praisonai persistence doctor
|
||||
praisonai persistence status
|
||||
```
|
||||
|
||||
## Docker Setup
|
||||
|
||||
```bash
|
||||
# Start all services
|
||||
docker run -d --name praison-postgres \
|
||||
-e POSTGRES_PASSWORD=praison123 \
|
||||
-e POSTGRES_DB=praisonai \
|
||||
-p 5432:5432 postgres:16
|
||||
|
||||
docker run -d --name praison-redis \
|
||||
-p 6379:6379 redis:7
|
||||
|
||||
docker run -d --name praison-qdrant \
|
||||
-p 6333:6333 -p 6334:6334 qdrant/qdrant
|
||||
|
||||
# Verify
|
||||
docker ps
|
||||
```
|
||||
|
||||
## Example Workflow
|
||||
|
||||
```bash
|
||||
# 1. Start services
|
||||
docker-compose up -d
|
||||
|
||||
# 2. Check connectivity
|
||||
praisonai persistence doctor
|
||||
|
||||
# 3. Run agent
|
||||
praisonai persistence run --session-id demo-session
|
||||
|
||||
# 4. Export for backup
|
||||
praisonai persistence export --session-id demo-session --output backup.jsonl
|
||||
|
||||
# 5. Import to another environment
|
||||
praisonai persistence import --file backup.jsonl
|
||||
```
|
||||
@@ -0,0 +1,124 @@
|
||||
# PraisonAI Persistence CLI - End-to-End Guide
|
||||
|
||||
This guide demonstrates the CLI commands for database persistence.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
# Start Docker containers
|
||||
docker run -d --name praison-postgres -p 5432:5432 \
|
||||
-e POSTGRES_PASSWORD=praison123 \
|
||||
-e POSTGRES_DB=praisonai \
|
||||
postgres:16
|
||||
|
||||
docker run -d --name praison-qdrant -p 6333:6333 qdrant/qdrant
|
||||
docker run -d --name praison-redis -p 6379:6379 redis:7
|
||||
```
|
||||
|
||||
## 1. Doctor Command - Validate Connectivity
|
||||
|
||||
```bash
|
||||
# Check all stores
|
||||
praisonai persistence doctor \
|
||||
--conversation-url "postgresql://postgres:praison123@localhost:5432/praisonai" \
|
||||
--knowledge-url "http://localhost:6333" \
|
||||
--state-url "redis://localhost:6379"
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
==================================================
|
||||
PraisonAI Persistence Doctor
|
||||
==================================================
|
||||
|
||||
[Conversation Store] Testing: postgresql://postgres:****@localhost:5432/praisonai
|
||||
✅ Connected (postgres)
|
||||
|
||||
[Knowledge Store] Testing: http://localhost:6333
|
||||
✅ Connected (qdrant)
|
||||
|
||||
[State Store] Testing: redis://localhost:6379
|
||||
✅ Connected (redis)
|
||||
|
||||
==================================================
|
||||
Results: 3/3 stores connected successfully
|
||||
==================================================
|
||||
```
|
||||
|
||||
## 2. Run Command - Execute Agent with Persistence
|
||||
|
||||
```bash
|
||||
# Run agent with persistence (dry-run first)
|
||||
praisonai persistence run \
|
||||
--session-id "cli-demo-001" \
|
||||
--conversation-url "postgresql://postgres:praison123@localhost:5432/praisonai" \
|
||||
--dry-run \
|
||||
"Hello, my name is Bob"
|
||||
|
||||
# Actually run it
|
||||
praisonai persistence run \
|
||||
--session-id "cli-demo-001" \
|
||||
--conversation-url "postgresql://postgres:praison123@localhost:5432/praisonai" \
|
||||
"Hello, my name is Bob"
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
[Session: cli-demo-001]
|
||||
Agent: Hello Bob! Nice to meet you. How can I help you today?
|
||||
```
|
||||
|
||||
## 3. Resume Command - Continue a Session
|
||||
|
||||
```bash
|
||||
# Show history
|
||||
praisonai persistence resume \
|
||||
--session-id "cli-demo-001" \
|
||||
--conversation-url "postgresql://postgres:praison123@localhost:5432/praisonai" \
|
||||
--show-history
|
||||
|
||||
# Continue conversation
|
||||
praisonai persistence resume \
|
||||
--session-id "cli-demo-001" \
|
||||
--conversation-url "postgresql://postgres:praison123@localhost:5432/praisonai" \
|
||||
--continue "What's my name?"
|
||||
```
|
||||
|
||||
**Expected Output:**
|
||||
```
|
||||
[Session: cli-demo-001]
|
||||
Messages in history: 2
|
||||
|
||||
--- Conversation History ---
|
||||
[USER] Hello, my name is Bob
|
||||
[ASSISTANT] Hello Bob! Nice to meet you...
|
||||
--- End History ---
|
||||
|
||||
Agent: Your name is Bob!
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Instead of passing URLs on command line, use environment variables:
|
||||
|
||||
```bash
|
||||
export PRAISON_CONVERSATION_URL="postgresql://postgres:praison123@localhost:5432/praisonai"
|
||||
export PRAISON_KNOWLEDGE_URL="http://localhost:6333"
|
||||
export PRAISON_STATE_URL="redis://localhost:6379"
|
||||
export OPENAI_API_KEY="your-key"
|
||||
|
||||
# Now commands are simpler
|
||||
praisonai persistence doctor --all
|
||||
praisonai persistence run --session-id "my-session" "Hello!"
|
||||
```
|
||||
|
||||
## Full CLI Reference
|
||||
|
||||
```
|
||||
praisonai persistence --help
|
||||
praisonai persistence doctor --help
|
||||
praisonai persistence run --help
|
||||
praisonai persistence resume --help
|
||||
```
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Qdrant Knowledge Store Example for PraisonAI.
|
||||
|
||||
Demonstrates vector insert + search through the knowledge store.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
Docker Setup:
|
||||
docker run -d --name praison-qdrant -p 6333:6333 qdrant/qdrant
|
||||
|
||||
Run:
|
||||
python knowledge_qdrant.py
|
||||
|
||||
Expected Output:
|
||||
=== Qdrant Knowledge Store Demo ===
|
||||
Created collection: demo_knowledge
|
||||
Inserted 3 documents
|
||||
Search results: 2 documents found
|
||||
"""
|
||||
|
||||
from praisonai.persistence.factory import create_knowledge_store
|
||||
from praisonai.persistence.knowledge.base import KnowledgeDocument
|
||||
import random
|
||||
|
||||
# Create Qdrant knowledge store
|
||||
store = create_knowledge_store("qdrant", url="http://localhost:6333")
|
||||
|
||||
collection_name = "demo_knowledge"
|
||||
dimension = 384 # Embedding dimension
|
||||
|
||||
# Create collection
|
||||
print("=== Qdrant Knowledge Store Demo ===")
|
||||
if store.collection_exists(collection_name):
|
||||
store.delete_collection(collection_name)
|
||||
|
||||
store.create_collection(collection_name, dimension=dimension)
|
||||
print(f"Created collection: {collection_name}")
|
||||
|
||||
# Create sample documents with embeddings
|
||||
random.seed(42)
|
||||
documents = [
|
||||
KnowledgeDocument(
|
||||
id="doc-1",
|
||||
content="Python is a versatile programming language used for web development, data science, and AI.",
|
||||
embedding=[random.random() for _ in range(dimension)],
|
||||
metadata={"category": "programming", "language": "python"}
|
||||
),
|
||||
KnowledgeDocument(
|
||||
id="doc-2",
|
||||
content="Machine learning enables computers to learn patterns from data without explicit programming.",
|
||||
embedding=[random.random() for _ in range(dimension)],
|
||||
metadata={"category": "ai", "topic": "ml"}
|
||||
),
|
||||
KnowledgeDocument(
|
||||
id="doc-3",
|
||||
content="PostgreSQL is a powerful open-source relational database system.",
|
||||
embedding=[random.random() for _ in range(dimension)],
|
||||
metadata={"category": "database", "type": "sql"}
|
||||
),
|
||||
]
|
||||
|
||||
# Insert documents
|
||||
ids = store.insert(collection_name, documents)
|
||||
print(f"Inserted {len(ids)} documents")
|
||||
|
||||
# Search for similar documents
|
||||
query_embedding = [random.random() for _ in range(dimension)]
|
||||
results = store.search(collection_name, query_embedding, limit=2)
|
||||
print(f"Search results: {len(results)} documents found")
|
||||
|
||||
for doc in results:
|
||||
print(f" - {doc.id}: {doc.content[:50]}...")
|
||||
|
||||
# Cleanup
|
||||
store.delete_collection(collection_name)
|
||||
store.close()
|
||||
|
||||
print("\n=== Demo Complete ===")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""
|
||||
Minimal Agent with Database Persistence
|
||||
|
||||
This is the simplest way to add persistence to your agent.
|
||||
Messages, runs, and traces are automatically saved.
|
||||
|
||||
Run:
|
||||
python minimal_agent_db.py
|
||||
|
||||
Expected output:
|
||||
- Agent responds to your question
|
||||
- Data is persisted to PostgreSQL
|
||||
- Session can be resumed later with same session_id
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, db
|
||||
|
||||
# Create database connection (PostgreSQL + Redis)
|
||||
my_db = db.PraisonDB(
|
||||
database_url="postgresql://postgres:praison123@localhost:5432/praisonai",
|
||||
state_url="redis://localhost:6379" # Optional: for state/tracing
|
||||
)
|
||||
|
||||
# Create agent with persistence
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful assistant. Be concise.",
|
||||
db=my_db,
|
||||
# session_id is optional - defaults to per-hour ID (YYYYMMDDHH-hash)
|
||||
# Set explicitly for session continuity across runs:
|
||||
session_id="my-persistent-session"
|
||||
)
|
||||
|
||||
# Chat - messages are automatically persisted
|
||||
response = agent.chat("What is the capital of France?")
|
||||
print(f"Response: {response}")
|
||||
|
||||
# Verify persistence
|
||||
data = my_db.export_session("my-persistent-session")
|
||||
print(f"\nMessages stored: {len(data.get('messages', []))}")
|
||||
|
||||
# Clean up
|
||||
my_db.close()
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
PostgreSQL with Runs and Traces
|
||||
|
||||
Demonstrates full persistence with run tracking and tracing.
|
||||
|
||||
Run:
|
||||
python postgres_runs_traces.py
|
||||
|
||||
Expected output:
|
||||
- Agent responds with tool usage
|
||||
- Runs and traces are persisted
|
||||
- Export shows full session data
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, db
|
||||
|
||||
def get_weather(city: str) -> str:
|
||||
"""Get weather for a city."""
|
||||
return f"Weather in {city}: Sunny, 72°F"
|
||||
|
||||
# Create database with PostgreSQL + Redis
|
||||
my_db = db.PraisonDB(
|
||||
database_url="postgresql://postgres:praison123@localhost:5432/praisonai",
|
||||
state_url="redis://localhost:6379"
|
||||
)
|
||||
|
||||
# Create agent with tools
|
||||
agent = Agent(
|
||||
name="WeatherBot",
|
||||
instructions="You help with weather. Use the get_weather tool when asked.",
|
||||
tools=[get_weather],
|
||||
db=my_db,
|
||||
session_id="weather-session-001"
|
||||
)
|
||||
|
||||
# First chat - triggers run tracking
|
||||
print("=== First Question ===")
|
||||
response1 = agent.chat("What's the weather in Tokyo?")
|
||||
print(f"Response: {response1}\n")
|
||||
|
||||
# Second chat - same session, new run
|
||||
print("=== Second Question ===")
|
||||
response2 = agent.chat("How about New York?")
|
||||
print(f"Response: {response2}\n")
|
||||
|
||||
# Export session data
|
||||
print("=== Session Data ===")
|
||||
data = my_db.export_session("weather-session-001")
|
||||
print(f"Session ID: {data.get('session_id')}")
|
||||
print(f"Messages: {len(data.get('messages', []))}")
|
||||
print(f"Agent: {data.get('agent_name')}")
|
||||
|
||||
# Show messages
|
||||
print("\n=== Messages ===")
|
||||
for msg in data.get('messages', []):
|
||||
role = msg.get('role', 'unknown')
|
||||
content = msg.get('content', '')[:60]
|
||||
print(f" [{role}] {content}...")
|
||||
|
||||
my_db.close()
|
||||
print("\n✅ Done")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Redis State Store
|
||||
|
||||
Use Redis for state management, tracing, and caching.
|
||||
|
||||
Run:
|
||||
python redis_state.py
|
||||
|
||||
Expected output:
|
||||
- State operations (set/get/delete)
|
||||
- Tracing operations
|
||||
"""
|
||||
|
||||
from praisonaiagents import db
|
||||
|
||||
# Create Redis-only database (for state/tracing)
|
||||
redis_db = db.RedisDB(host="localhost", port=6379)
|
||||
|
||||
print("=== Redis State Store ===")
|
||||
|
||||
# Test state operations
|
||||
print("\n1. State Operations:")
|
||||
redis_db._init_stores() # Initialize stores
|
||||
|
||||
# Set a value
|
||||
redis_db._state_store.set("user:123:preferences", {
|
||||
"theme": "dark",
|
||||
"language": "en"
|
||||
})
|
||||
print(" Set user preferences")
|
||||
|
||||
# Get the value
|
||||
prefs = redis_db._state_store.get("user:123:preferences")
|
||||
print(f" Got preferences: {prefs}")
|
||||
|
||||
# Set with TTL (expires in 60 seconds)
|
||||
redis_db._state_store.set("temp:token", "abc123", ttl=60)
|
||||
print(" Set temporary token (60s TTL)")
|
||||
|
||||
# Delete
|
||||
redis_db._state_store.delete("user:123:preferences")
|
||||
print(" Deleted preferences")
|
||||
|
||||
# Test tracing
|
||||
print("\n2. Tracing Operations:")
|
||||
|
||||
# Start a trace
|
||||
trace_id = "trace-example-001"
|
||||
redis_db.on_trace_start(
|
||||
trace_id=trace_id,
|
||||
session_id="example-session",
|
||||
agent_name="ExampleAgent",
|
||||
metadata={"purpose": "demo"}
|
||||
)
|
||||
print(f" Started trace: {trace_id}")
|
||||
|
||||
# Add spans
|
||||
span_id = "span-llm-001"
|
||||
redis_db.on_span_start(
|
||||
span_id=span_id,
|
||||
trace_id=trace_id,
|
||||
name="llm_call",
|
||||
attributes={"model": "gpt-4", "temperature": 0.7}
|
||||
)
|
||||
print(f" Started span: {span_id}")
|
||||
|
||||
redis_db.on_span_end(
|
||||
span_id=span_id,
|
||||
status="ok",
|
||||
attributes={"tokens": 150, "latency_ms": 450}
|
||||
)
|
||||
print(f" Ended span: {span_id}")
|
||||
|
||||
# End trace
|
||||
redis_db.on_trace_end(
|
||||
trace_id=trace_id,
|
||||
status="ok",
|
||||
metadata={"total_spans": 1}
|
||||
)
|
||||
print(f" Ended trace: {trace_id}")
|
||||
|
||||
redis_db.close()
|
||||
print("\n✅ Done")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Session Resume Example
|
||||
|
||||
Demonstrates how to resume a conversation from a previous session.
|
||||
|
||||
Run:
|
||||
python session_resume.py
|
||||
|
||||
Expected output:
|
||||
- First run: Creates new session
|
||||
- Second run: Resumes with history
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, db
|
||||
|
||||
# Create database
|
||||
my_db = db.PraisonDB(
|
||||
database_url="postgresql://postgres:praison123@localhost:5432/praisonai",
|
||||
state_url="redis://localhost:6379"
|
||||
)
|
||||
|
||||
SESSION_ID = "resume-demo-session"
|
||||
|
||||
# Create agent with explicit session_id
|
||||
agent = Agent(
|
||||
name="MemoryBot",
|
||||
instructions="You remember our conversation. Be helpful and concise.",
|
||||
db=my_db,
|
||||
session_id=SESSION_ID
|
||||
)
|
||||
|
||||
# Check if this is a new or resumed session
|
||||
data = my_db.export_session(SESSION_ID)
|
||||
existing_messages = len(data.get('messages', []))
|
||||
|
||||
if existing_messages == 0:
|
||||
print("=== NEW SESSION ===")
|
||||
print("This is a new conversation.\n")
|
||||
|
||||
response = agent.chat("My name is Alice and I love Python programming.")
|
||||
print(f"Response: {response}\n")
|
||||
|
||||
response = agent.chat("What's my favorite programming language?")
|
||||
print(f"Response: {response}\n")
|
||||
else:
|
||||
print(f"=== RESUMED SESSION ({existing_messages} previous messages) ===")
|
||||
print("Continuing previous conversation.\n")
|
||||
|
||||
# Show previous messages
|
||||
print("Previous messages:")
|
||||
for msg in data.get('messages', [])[:4]:
|
||||
role = msg.get('role', 'unknown')
|
||||
content = msg.get('content', '')[:60]
|
||||
print(f" [{role}] {content}...")
|
||||
print()
|
||||
|
||||
# Continue conversation
|
||||
response = agent.chat("What's my name and what do I like?")
|
||||
print(f"Response: {response}\n")
|
||||
|
||||
# Show final state
|
||||
final_data = my_db.export_session(SESSION_ID)
|
||||
print(f"Total messages now: {len(final_data.get('messages', []))}")
|
||||
|
||||
my_db.close()
|
||||
print("✅ Done")
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Simple Database Persistence Example for PraisonAI Agents.
|
||||
|
||||
This example shows how to enable automatic conversation persistence
|
||||
with just 2 lines of code.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
Docker Setup:
|
||||
docker run -d --name praison-postgres -p 5432:5432 \
|
||||
-e POSTGRES_PASSWORD=praison123 \
|
||||
-e POSTGRES_DB=praisonai \
|
||||
postgres:16
|
||||
|
||||
Environment:
|
||||
export OPENAI_API_KEY=your-key
|
||||
|
||||
Run:
|
||||
python simple_agent_postgres.py
|
||||
|
||||
Expected Output:
|
||||
=== First Run ===
|
||||
Agent: Nice to meet you, Alice! Python is a great choice...
|
||||
Session ID: demo-session-001
|
||||
Messages persisted: 2
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.db import PostgresDB
|
||||
|
||||
# Create database adapter
|
||||
db = PostgresDB(
|
||||
host="localhost",
|
||||
port=5432,
|
||||
database="praisonai",
|
||||
user="postgres",
|
||||
password="praison123"
|
||||
)
|
||||
|
||||
# Create agent with persistence enabled
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
instructions="You are a helpful assistant. Keep responses brief.",
|
||||
db=db,
|
||||
session_id="demo-session-001", # Same session_id = resume conversation
|
||||
verbose=False
|
||||
)
|
||||
|
||||
# Chat with persistence
|
||||
print("=== First Run ===")
|
||||
response = agent.chat("My name is Alice and I love Python programming.")
|
||||
print(f"Agent: {response}\n")
|
||||
|
||||
# Show session info
|
||||
print(f"Session ID: {agent.session_id}")
|
||||
print(f"Messages in history: {len(agent.chat_history)}")
|
||||
|
||||
# Close DB connection
|
||||
db.close()
|
||||
|
||||
print("\n=== Session persisted! ===")
|
||||
print("Run again with same session_id to resume.")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
SQLite Local Database
|
||||
|
||||
Use SQLite for simple local persistence without external services.
|
||||
|
||||
Run:
|
||||
python sqlite_local.py
|
||||
|
||||
Expected output:
|
||||
- Agent responds
|
||||
- Data persisted to local SQLite file
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, db
|
||||
import tempfile
|
||||
import os
|
||||
|
||||
# Create SQLite database (local file)
|
||||
db_path = os.path.join(tempfile.gettempdir(), "my_agent.db")
|
||||
my_db = db.SQLiteDB(path=db_path)
|
||||
|
||||
print(f"Database: {db_path}")
|
||||
|
||||
# Create agent
|
||||
agent = Agent(
|
||||
name="LocalBot",
|
||||
instructions="You are a helpful assistant.",
|
||||
db=my_db,
|
||||
session_id="local-session"
|
||||
)
|
||||
|
||||
# Chat
|
||||
response = agent.chat("Hello! What can you help me with?")
|
||||
print(f"Response: {response}")
|
||||
|
||||
# Verify
|
||||
data = my_db.export_session("local-session")
|
||||
print(f"\nMessages stored: {len(data.get('messages', []))}")
|
||||
|
||||
my_db.close()
|
||||
print("✅ Done")
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
Redis State Store Example for PraisonAI.
|
||||
|
||||
Demonstrates set/get/delete + TTL operations.
|
||||
|
||||
Requirements:
|
||||
pip install "praisonai[tools]"
|
||||
|
||||
Docker Setup:
|
||||
docker run -d --name praison-redis -p 6379:6379 redis:7
|
||||
|
||||
Run:
|
||||
python state_redis.py
|
||||
|
||||
Expected Output:
|
||||
=== Redis State Store Demo ===
|
||||
Set key: session:user123:preferences
|
||||
Get value: {'theme': 'dark', 'language': 'en'}
|
||||
TTL remaining: 3600 seconds
|
||||
Hash operations: {'name': 'Alice', 'role': 'admin'}
|
||||
"""
|
||||
|
||||
from praisonai.persistence.factory import create_state_store
|
||||
|
||||
# Create Redis state store
|
||||
store = create_state_store("redis", url="redis://localhost:6379")
|
||||
|
||||
print("=== Redis State Store Demo ===")
|
||||
|
||||
# Basic set/get
|
||||
key = "session:user123:preferences"
|
||||
value = {"theme": "dark", "language": "en", "notifications": True}
|
||||
|
||||
store.set(key, value)
|
||||
print(f"Set key: {key}")
|
||||
|
||||
retrieved = store.get(key)
|
||||
print(f"Get value: {retrieved}")
|
||||
|
||||
# Set with TTL (expires in 1 hour)
|
||||
ttl_key = "session:user123:token"
|
||||
store.set(ttl_key, "abc123xyz", ttl=3600)
|
||||
remaining = store.ttl(ttl_key)
|
||||
print(f"TTL remaining: {remaining} seconds")
|
||||
|
||||
# Hash operations (for structured data)
|
||||
hash_key = "user:123:profile"
|
||||
store.hset(hash_key, "name", "Alice")
|
||||
store.hset(hash_key, "role", "admin")
|
||||
store.hset(hash_key, "last_login", "2024-12-24")
|
||||
|
||||
profile = store.hgetall(hash_key)
|
||||
print(f"Hash operations: {profile}")
|
||||
|
||||
# Check existence
|
||||
exists = store.exists(key)
|
||||
print(f"Key exists: {exists}")
|
||||
|
||||
# Delete keys
|
||||
store.delete(key)
|
||||
store.delete(ttl_key)
|
||||
store.delete(hash_key)
|
||||
print("Cleaned up test keys")
|
||||
|
||||
store.close()
|
||||
|
||||
print("\n=== Demo Complete ===")
|
||||
@@ -54,6 +54,7 @@ except ImportError:
|
||||
MCP = None
|
||||
from .session import Session
|
||||
from .memory.memory import Memory
|
||||
from .db import db
|
||||
# Workflows - import from dedicated workflows module
|
||||
from .workflows import (
|
||||
Workflow, WorkflowStep, WorkflowContext, StepResult,
|
||||
@@ -257,6 +258,7 @@ __all__ = [
|
||||
'AutoAgents',
|
||||
'Session',
|
||||
'Memory',
|
||||
'db',
|
||||
'display_interaction',
|
||||
'display_self_reflection',
|
||||
'display_instruction',
|
||||
|
||||
@@ -252,7 +252,9 @@ class Agent:
|
||||
history_in_context: Optional[int] = None,
|
||||
auto_save: Optional[str] = None,
|
||||
skills: Optional[List[str]] = None,
|
||||
skills_dirs: Optional[List[str]] = None
|
||||
skills_dirs: Optional[List[str]] = None,
|
||||
db: Optional[Any] = None,
|
||||
session_id: Optional[str] = None
|
||||
):
|
||||
"""Initialize an Agent instance.
|
||||
|
||||
@@ -615,6 +617,11 @@ Your Goal: {self.goal}
|
||||
self._skill_manager = None # Lazy loaded
|
||||
self._skills_initialized = False
|
||||
|
||||
# Database persistence (lazy - no imports until used)
|
||||
self._db = db
|
||||
self._session_id = session_id
|
||||
self._db_initialized = False
|
||||
|
||||
@property
|
||||
def console(self):
|
||||
"""Lazily initialize Rich Console only when needed."""
|
||||
@@ -1957,7 +1964,108 @@ Your Goal: {self.goal}"""
|
||||
# expand=False
|
||||
# )
|
||||
|
||||
def _init_db_session(self):
|
||||
"""Initialize DB session if db adapter is provided (lazy, first chat only)."""
|
||||
if self._db is None or self._db_initialized:
|
||||
return
|
||||
|
||||
# Generate session_id if not provided: default to per-hour ID (YYYYMMDDHH-agentname)
|
||||
if self._session_id is None:
|
||||
import hashlib
|
||||
from datetime import datetime, timezone
|
||||
# Per-hour session ID: YYYYMMDDHH (UTC) + agent name hash for uniqueness
|
||||
hour_str = datetime.now(timezone.utc).strftime("%Y%m%d%H")
|
||||
agent_hash = hashlib.md5(self.name.encode()).hexdigest()[:6]
|
||||
self._session_id = f"{hour_str}-{agent_hash}"
|
||||
|
||||
# Call db adapter's on_agent_start to get previous messages
|
||||
try:
|
||||
history = self._db.on_agent_start(
|
||||
agent_name=self.name,
|
||||
session_id=self._session_id,
|
||||
user_id=self.user_id,
|
||||
metadata={"role": self.role, "goal": self.goal}
|
||||
)
|
||||
|
||||
# Restore chat history from previous session
|
||||
if history:
|
||||
for msg in history:
|
||||
self.chat_history.append({
|
||||
"role": msg.role,
|
||||
"content": msg.content
|
||||
})
|
||||
logging.info(f"Resumed session {self._session_id} with {len(history)} messages")
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to initialize DB session: {e}")
|
||||
|
||||
self._db_initialized = True
|
||||
self._current_run_id = None # Track current run
|
||||
|
||||
def _start_run(self, input_content: str):
|
||||
"""Start a new run (turn) for persistence tracking."""
|
||||
if self._db is None:
|
||||
return
|
||||
|
||||
import uuid
|
||||
self._current_run_id = f"run-{uuid.uuid4().hex[:12]}"
|
||||
|
||||
try:
|
||||
if hasattr(self._db, 'on_run_start'):
|
||||
self._db.on_run_start(
|
||||
session_id=self._session_id,
|
||||
run_id=self._current_run_id,
|
||||
input_content=input_content,
|
||||
metadata={"agent_name": self.name}
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to start run: {e}")
|
||||
|
||||
def _end_run(self, output_content: str, status: str = "completed", metrics: dict = None):
|
||||
"""End the current run (turn)."""
|
||||
if self._db is None or self._current_run_id is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if hasattr(self._db, 'on_run_end'):
|
||||
self._db.on_run_end(
|
||||
session_id=self._session_id,
|
||||
run_id=self._current_run_id,
|
||||
output_content=output_content,
|
||||
status=status,
|
||||
metrics=metrics or {},
|
||||
metadata={"agent_name": self.name}
|
||||
)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to end run: {e}")
|
||||
|
||||
self._current_run_id = None
|
||||
|
||||
def _persist_message(self, role: str, content: str):
|
||||
"""Persist a message to the DB if adapter is provided."""
|
||||
if self._db is None:
|
||||
return
|
||||
|
||||
try:
|
||||
if role == "user":
|
||||
self._db.on_user_message(self._session_id, content)
|
||||
elif role == "assistant":
|
||||
self._db.on_agent_message(self._session_id, content)
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to persist message: {e}")
|
||||
|
||||
@property
|
||||
def session_id(self) -> Optional[str]:
|
||||
"""Get the current session ID."""
|
||||
return self._session_id
|
||||
|
||||
def chat(self, prompt, temperature=1.0, tools=None, output_json=None, output_pydantic=None, reasoning_steps=False, stream=None, task_name=None, task_description=None, task_id=None):
|
||||
# Initialize DB session on first chat (lazy)
|
||||
self._init_db_session()
|
||||
|
||||
# Start a new run for this chat turn
|
||||
prompt_str = prompt if isinstance(prompt, str) else str(prompt)
|
||||
self._start_run(prompt_str)
|
||||
|
||||
# Reset the final display flag for each new conversation
|
||||
self._final_display_shown = False
|
||||
|
||||
@@ -2054,6 +2162,8 @@ Your Goal: {self.goal}"""
|
||||
self.chat_history[-1].get("content") == normalized_content):
|
||||
# Add user message to chat history BEFORE LLM call so handoffs can access it
|
||||
self.chat_history.append({"role": "user", "content": normalized_content})
|
||||
# Persist user message to DB
|
||||
self._persist_message("user", normalized_content)
|
||||
|
||||
try:
|
||||
# Pass everything to LLM class
|
||||
@@ -2083,6 +2193,8 @@ Your Goal: {self.goal}"""
|
||||
)
|
||||
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
|
||||
# Log completion time if in debug mode
|
||||
if logging.getLogger().getEffectiveLevel() == logging.DEBUG:
|
||||
@@ -2127,6 +2239,8 @@ Your Goal: {self.goal}"""
|
||||
self.chat_history[-1].get("content") == normalized_content):
|
||||
# Add user message to chat history BEFORE LLM call so handoffs can access it
|
||||
self.chat_history.append({"role": "user", "content": normalized_content})
|
||||
# Persist user message to DB (OpenAI path)
|
||||
self._persist_message("user", normalized_content)
|
||||
|
||||
reflection_count = 0
|
||||
start_time = time.time()
|
||||
@@ -2166,6 +2280,8 @@ Your Goal: {self.goal}"""
|
||||
# Add to chat history and return raw response
|
||||
# User message already added before LLM call via _build_messages
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
# Apply guardrail validation even for JSON output
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
@@ -2181,6 +2297,8 @@ Your Goal: {self.goal}"""
|
||||
if not self.self_reflect:
|
||||
# User message already added before LLM call via _build_messages
|
||||
self.chat_history.append({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB (non-reflect path)
|
||||
self._persist_message("assistant", response_text)
|
||||
if self.verbose:
|
||||
logging.debug(f"Agent {self.name} final response: {response_text}")
|
||||
# Return only reasoning content if reasoning_steps is True
|
||||
@@ -2269,11 +2387,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
self._end_run(validated_response, "completed", {"duration_ms": (time.time() - start_time) * 1000})
|
||||
return validated_response
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self.chat_history = self.chat_history[:chat_history_length]
|
||||
self._end_run(None, "error", {"error": str(e)})
|
||||
return None
|
||||
|
||||
# Check if we've hit max reflections
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
Generated
+1
-1
@@ -2422,7 +2422,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
+271
-1
@@ -261,6 +261,12 @@ npm install praisonai
|
||||
| ↳ 100+ LLM Support | [Example](examples/python/providers/openai/openai_gpt4_example.py) | [📖](https://docs.praison.ai/models) |
|
||||
| ↳ Callback Agents | [Example](examples/python/general/advanced-callback-systems.py) | [📖](https://docs.praison.ai/features/callbacks) |
|
||||
| ↳ Hooks | [Example](#9-hooks) | [📖](https://docs.praison.ai/features/hooks) |
|
||||
| ↳ Shadow Git Checkpoints | [Example](#10-shadow-git-checkpoints) | [📖](https://docs.praison.ai/features/checkpoints) |
|
||||
| ↳ Background Tasks | [Example](examples/background/basic_background.py) | [📖](https://docs.praison.ai/features/background-tasks) |
|
||||
| ↳ Policy Engine | [Example](examples/policy/basic_policy.py) | [📖](https://docs.praison.ai/features/policy-engine) |
|
||||
| ↳ Thinking Budgets | [Example](examples/thinking/basic_thinking.py) | [📖](https://docs.praison.ai/features/thinking-budgets) |
|
||||
| ↳ Output Styles | [Example](examples/output/basic_output.py) | [📖](https://docs.praison.ai/features/output-styles) |
|
||||
| ↳ Context Compaction | [Example](examples/compaction/basic_compaction.py) | [📖](https://docs.praison.ai/features/context-compaction) |
|
||||
| **📊 Monitoring & Management** | | |
|
||||
| ↳ Sessions Management | [Example](examples/python/sessions/comprehensive-session-management.py) | [📖](https://docs.praison.ai/features/sessions) |
|
||||
| ↳ Auto-Save Sessions | [Example](#session-management-python) | [📖](https://docs.praison.ai/docs/cli/session) |
|
||||
@@ -930,8 +936,191 @@ praisonai checkpoint diff # Show changes
|
||||
praisonai checkpoint restore abc123 # Restore to checkpoint
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/checkpoints)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/checkpoint)
|
||||
- [💻 Example](examples/checkpoints/basic_checkpoints.py)
|
||||
|
||||
### 11. Field Names Reference (A-I-G-S)
|
||||
---
|
||||
|
||||
### 11. Background Tasks
|
||||
|
||||
Run agent tasks asynchronously without blocking:
|
||||
|
||||
```python
|
||||
import asyncio
|
||||
from praisonaiagents.background import BackgroundRunner, BackgroundConfig
|
||||
|
||||
async def main():
|
||||
config = BackgroundConfig(max_concurrent_tasks=3)
|
||||
runner = BackgroundRunner(config=config)
|
||||
|
||||
async def my_task(name: str) -> str:
|
||||
await asyncio.sleep(2)
|
||||
return f"Task {name} completed"
|
||||
|
||||
task = await runner.submit(my_task, args=("example",), name="my_task")
|
||||
await task.wait(timeout=10.0)
|
||||
print(task.result)
|
||||
|
||||
asyncio.run(main())
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai background list # List running tasks
|
||||
praisonai background status <id> # Check task status
|
||||
praisonai background cancel <id> # Cancel a task
|
||||
praisonai background clear # Clear completed tasks
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/background-tasks)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/background)
|
||||
- [💻 Example](examples/background/basic_background.py)
|
||||
|
||||
---
|
||||
|
||||
### 12. Policy Engine
|
||||
|
||||
Control what agents can and cannot do with policy-based execution:
|
||||
|
||||
```python
|
||||
from praisonaiagents.policy import (
|
||||
PolicyEngine, Policy, PolicyRule, PolicyAction
|
||||
)
|
||||
|
||||
engine = PolicyEngine()
|
||||
|
||||
policy = Policy(
|
||||
name="no_delete",
|
||||
rules=[
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:delete_*",
|
||||
reason="Delete operations blocked"
|
||||
)
|
||||
]
|
||||
)
|
||||
engine.add_policy(policy)
|
||||
|
||||
result = engine.check("tool:delete_file", {})
|
||||
print(f"Allowed: {result.allowed}")
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai policy list # List policies
|
||||
praisonai policy check "tool:name" # Check if allowed
|
||||
praisonai policy init # Create template
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/policy-engine)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/policy)
|
||||
- [💻 Example](examples/policy/basic_policy.py)
|
||||
|
||||
---
|
||||
|
||||
### 13. Thinking Budgets
|
||||
|
||||
Configure token budgets for extended thinking:
|
||||
|
||||
```python
|
||||
from praisonaiagents.thinking import ThinkingBudget, ThinkingTracker
|
||||
|
||||
# Use predefined levels
|
||||
budget = ThinkingBudget.high() # 16,000 tokens
|
||||
|
||||
# Track usage
|
||||
tracker = ThinkingTracker()
|
||||
session = tracker.start_session(budget_tokens=16000)
|
||||
tracker.end_session(session, tokens_used=12000)
|
||||
|
||||
summary = tracker.get_summary()
|
||||
print(f"Utilization: {summary['average_utilization']:.1%}")
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai thinking status # Show current budget
|
||||
praisonai thinking set high # Set budget level
|
||||
praisonai thinking stats # Show usage statistics
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/thinking-budgets)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/thinking)
|
||||
- [💻 Example](examples/thinking/basic_thinking.py)
|
||||
|
||||
---
|
||||
|
||||
### 14. Output Styles
|
||||
|
||||
Configure how agents format their responses:
|
||||
|
||||
```python
|
||||
from praisonaiagents.output import OutputStyle, OutputFormatter
|
||||
|
||||
# Use preset styles
|
||||
style = OutputStyle.concise()
|
||||
formatter = OutputFormatter(style)
|
||||
|
||||
# Format output
|
||||
text = "# Hello\n\nThis is **bold** text."
|
||||
plain = formatter.format(text)
|
||||
print(plain)
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai output status # Show current style
|
||||
praisonai output set concise # Set output style
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/output-styles)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/output-style)
|
||||
- [💻 Example](examples/output/basic_output.py)
|
||||
|
||||
---
|
||||
|
||||
### 15. Context Compaction
|
||||
|
||||
Automatically manage context window size:
|
||||
|
||||
```python
|
||||
from praisonaiagents.compaction import (
|
||||
ContextCompactor, CompactionStrategy
|
||||
)
|
||||
|
||||
compactor = ContextCompactor(
|
||||
max_tokens=4000,
|
||||
strategy=CompactionStrategy.SLIDING,
|
||||
preserve_recent=3
|
||||
)
|
||||
|
||||
messages = [...] # Your conversation history
|
||||
compacted, result = compactor.compact(messages)
|
||||
|
||||
print(f"Compression: {result.compression_ratio:.1%}")
|
||||
```
|
||||
|
||||
**CLI Commands:**
|
||||
```bash
|
||||
praisonai compaction status # Show settings
|
||||
praisonai compaction set sliding # Set strategy
|
||||
praisonai compaction stats # Show statistics
|
||||
```
|
||||
|
||||
**Links:**
|
||||
- [📖 Coding Docs](https://docs.praison.ai/features/context-compaction)
|
||||
- [📖 CLI Docs](https://docs.praison.ai/docs/cli/compaction)
|
||||
- [💻 Example](examples/compaction/basic_compaction.py)
|
||||
|
||||
---
|
||||
|
||||
### 16. Field Names Reference (A-I-G-S)
|
||||
|
||||
PraisonAI accepts both old (agents.yaml) and new (workflow.yaml) field names. Use the **canonical names** for new projects:
|
||||
|
||||
@@ -2535,6 +2724,87 @@ PraisonAI provides zero-dependency persistent memory for agents. For detailed ex
|
||||
|
||||
---
|
||||
|
||||
## 💾 Persistence (Databases)
|
||||
|
||||
Enable automatic conversation persistence with 2 lines of code:
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, db
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
db=db.PraisonDB(database_url="postgresql://localhost/mydb"),
|
||||
session_id="my-session" # Optional: defaults to per-hour ID (YYYYMMDDHH)
|
||||
)
|
||||
agent.chat("Hello!") # Auto-persists messages, runs, traces
|
||||
```
|
||||
|
||||
### Persistence CLI Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `praisonai persistence doctor` | Validate DB connectivity |
|
||||
| `praisonai persistence run` | Run agent with persistence |
|
||||
| `praisonai persistence resume` | Resume existing session |
|
||||
| `praisonai persistence export` | Export session to JSONL |
|
||||
| `praisonai persistence import` | Import session from JSONL |
|
||||
| `praisonai persistence migrate` | Apply schema migrations |
|
||||
| `praisonai persistence status` | Show schema status |
|
||||
|
||||
### Databases Table
|
||||
|
||||
| Database | Store Type | Install | Example | Docs |
|
||||
|----------|------------|---------|---------|------|
|
||||
| PostgreSQL | Conversation | `pip install "praisonai[tools]"` | [simple_db_agent.py](examples/persistence/simple_db_agent.py) | [docs](https://docs.praison.ai/docs/databases/postgres) |
|
||||
| MySQL | Conversation | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| SQLite | Conversation | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| SingleStore | Conversation | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Supabase | Conversation | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| SurrealDB | Conversation | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Qdrant | Knowledge | `pip install "praisonai[tools]"` | [knowledge_qdrant.py](examples/persistence/knowledge_qdrant.py) | [docs](https://docs.praison.ai/docs/databases/qdrant) |
|
||||
| ChromaDB | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Pinecone | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Weaviate | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| LanceDB | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Milvus | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| PGVector | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Redis Vector | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Cassandra | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| ClickHouse | Knowledge | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Redis | State | `pip install "praisonai[tools]"` | [state_redis.py](examples/persistence/state_redis.py) | [docs](https://docs.praison.ai/docs/databases/redis) |
|
||||
| MongoDB | State | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| DynamoDB | State | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Firestore | State | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Upstash | State | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
| Memory | State | `pip install "praisonai[tools]"` | - | [docs](https://docs.praison.ai/docs/databases/overview) |
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Tools Table
|
||||
|
||||
| Tool | Category | Example | Docs |
|
||||
|------|----------|---------|------|
|
||||
| Tavily Search | Web Search | `from praisonai_tools import TavilySearchTool` | [docs](https://docs.praison.ai/docs/tools/tavily) |
|
||||
| DuckDuckGo | Web Search | `from praisonai_tools import DuckDuckGoSearchTool` | [docs](https://docs.praison.ai/docs/tools/duckduckgo) |
|
||||
| Exa Search | Web Search | `from praisonai_tools import ExaSearchTool` | [docs](https://docs.praison.ai/docs/tools/exa) |
|
||||
| Firecrawl | Web Scraping | `from praisonai_tools import FirecrawlTool` | [docs](https://docs.praison.ai/docs/tools/firecrawl) |
|
||||
| Crawl4AI | Web Scraping | `from praisonai_tools import Crawl4AITool` | [docs](https://docs.praison.ai/docs/tools/crawl4ai) |
|
||||
| Wikipedia | Knowledge | `from praisonai_tools import WikipediaTool` | [docs](https://docs.praison.ai/docs/tools/wikipedia) |
|
||||
| ArXiv | Research | `from praisonai_tools import ArxivTool` | [docs](https://docs.praison.ai/docs/tools/arxiv) |
|
||||
| YouTube | Media | `from praisonai_tools import YouTubeTool` | [docs](https://docs.praison.ai/docs/tools/youtube) |
|
||||
| File Tools | File System | `from praisonai_tools import FileReadTool` | [docs](https://docs.praison.ai/docs/tools/file) |
|
||||
| Code Tools | Development | `from praisonai_tools import CodeInterpreterTool` | [docs](https://docs.praison.ai/docs/tools/code) |
|
||||
| Shell Tools | System | `from praisonai_tools import ShellTool` | [docs](https://docs.praison.ai/docs/tools/shell) |
|
||||
| JSON Tools | Data | `from praisonai_tools import JSONTool` | [docs](https://docs.praison.ai/docs/tools/json) |
|
||||
| XML Tools | Data | `from praisonai_tools import XMLTool` | [docs](https://docs.praison.ai/docs/tools/xml) |
|
||||
| CSV Tools | Data | `from praisonai_tools import CSVTool` | [docs](https://docs.praison.ai/docs/tools/csv) |
|
||||
| PDF Tools | Documents | `from praisonai_tools import PDFTool` | [docs](https://docs.praison.ai/docs/tools/pdf) |
|
||||
| Calculator | Math | `from praisonai_tools import CalculatorTool` | [docs](https://docs.praison.ai/docs/tools/calculator) |
|
||||
|
||||
> See [full tools documentation](https://docs.praison.ai/docs/tools/tools) for all available tools.
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Video Tutorials
|
||||
|
||||
Learn PraisonAI through our comprehensive video series:
|
||||
|
||||
@@ -3,8 +3,8 @@ class Praisonai < Formula
|
||||
|
||||
desc "AI tools for various AI applications"
|
||||
homepage "https://github.com/MervinPraison/PraisonAI"
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.4.0.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.4.0.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.4.1.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.4.1.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
"""
|
||||
Persistence CLI feature for PraisonAI.
|
||||
|
||||
Provides commands for database persistence configuration and management:
|
||||
- praisonai persistence doctor - Validate DB connectivity
|
||||
- praisonai persistence run - Run agent with persistence
|
||||
- praisonai persistence resume - Resume a session
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_persistence_command(args: list) -> bool:
|
||||
"""
|
||||
Handle persistence subcommands.
|
||||
|
||||
Args:
|
||||
args: Command line arguments after 'persistence'
|
||||
|
||||
Returns:
|
||||
True if command was handled, False otherwise
|
||||
"""
|
||||
if not args:
|
||||
print_persistence_help()
|
||||
return True
|
||||
|
||||
subcommand = args[0].lower()
|
||||
sub_args = args[1:]
|
||||
|
||||
if subcommand == "doctor":
|
||||
return handle_doctor(sub_args)
|
||||
elif subcommand == "run":
|
||||
return handle_run(sub_args)
|
||||
elif subcommand == "resume":
|
||||
return handle_resume(sub_args)
|
||||
elif subcommand == "export":
|
||||
return handle_export(sub_args)
|
||||
elif subcommand == "import":
|
||||
return handle_import(sub_args)
|
||||
elif subcommand == "migrate":
|
||||
return handle_migrate(sub_args)
|
||||
elif subcommand == "status":
|
||||
return handle_status(sub_args)
|
||||
elif subcommand in ("--help", "-h", "help"):
|
||||
print_persistence_help()
|
||||
return True
|
||||
else:
|
||||
print(f"Unknown persistence subcommand: {subcommand}")
|
||||
print_persistence_help()
|
||||
return True
|
||||
|
||||
|
||||
def print_persistence_help():
|
||||
"""Print persistence command help."""
|
||||
help_text = """
|
||||
PraisonAI Persistence Commands
|
||||
==============================
|
||||
|
||||
Usage: praisonai persistence <command> [options]
|
||||
|
||||
Commands:
|
||||
doctor Validate database connectivity and configuration
|
||||
run Run an agent with persistence enabled
|
||||
resume Resume an existing session
|
||||
|
||||
Doctor Command:
|
||||
praisonai persistence doctor [options]
|
||||
|
||||
Options:
|
||||
--conversation-url URL Conversation store URL (postgres/mysql/sqlite)
|
||||
--knowledge-url URL Knowledge store URL (qdrant/chroma/pinecone)
|
||||
--state-url URL State store URL (redis/memory)
|
||||
--all Test all configured stores
|
||||
|
||||
Environment Variables:
|
||||
PRAISON_CONVERSATION_URL Default conversation store URL
|
||||
PRAISON_KNOWLEDGE_URL Default knowledge store URL
|
||||
PRAISON_STATE_URL Default state store URL
|
||||
|
||||
Run Command:
|
||||
praisonai persistence run [options] "prompt"
|
||||
|
||||
Options:
|
||||
--session-id ID Session identifier (auto-generated if not provided)
|
||||
--user-id ID User identifier (default: "default")
|
||||
--conversation-url URL Conversation store URL
|
||||
--knowledge-url URL Knowledge store URL
|
||||
--state-url URL State store URL
|
||||
--agent-name NAME Agent name (default: "Assistant")
|
||||
--agent-instructions TEXT Agent instructions
|
||||
--dry-run Show configuration without running
|
||||
|
||||
Resume Command:
|
||||
praisonai persistence resume --session-id ID [options]
|
||||
|
||||
Options:
|
||||
--session-id ID Session to resume (required)
|
||||
--conversation-url URL Conversation store URL
|
||||
--show-history Display conversation history
|
||||
--continue "prompt" Continue with a new prompt
|
||||
|
||||
Export/Import Commands:
|
||||
praisonai persistence export --session-id ID [options]
|
||||
praisonai persistence import --file FILE [options]
|
||||
|
||||
Options:
|
||||
--session-id ID Session to export (required for export)
|
||||
--conversation-url URL Conversation store URL
|
||||
--file FILE File path for export/import (JSONL format)
|
||||
--output FILE Output file path (default: session-{id}.jsonl)
|
||||
|
||||
Examples:
|
||||
# Check PostgreSQL connectivity
|
||||
praisonai persistence doctor --conversation-url "postgresql://localhost/mydb"
|
||||
|
||||
# Run agent with persistence
|
||||
praisonai persistence run --session-id "my-session" "Hello, remember my name is Alice"
|
||||
|
||||
# Resume a session
|
||||
praisonai persistence resume --session-id "my-session" --show-history
|
||||
|
||||
# Continue a session
|
||||
praisonai persistence resume --session-id "my-session" --continue "What's my name?"
|
||||
|
||||
# Export a session
|
||||
praisonai persistence export --session-id "my-session" --output session.jsonl
|
||||
|
||||
# Import a session
|
||||
praisonai persistence import --file session.jsonl
|
||||
"""
|
||||
print(help_text)
|
||||
|
||||
|
||||
def parse_persistence_args(args: list) -> dict:
|
||||
"""Parse persistence command arguments."""
|
||||
config = {
|
||||
"session_id": None,
|
||||
"user_id": "default",
|
||||
"conversation_url": os.getenv("PRAISON_CONVERSATION_URL"),
|
||||
"knowledge_url": os.getenv("PRAISON_KNOWLEDGE_URL"),
|
||||
"state_url": os.getenv("PRAISON_STATE_URL"),
|
||||
"agent_name": "Assistant",
|
||||
"agent_instructions": "You are a helpful assistant.",
|
||||
"dry_run": False,
|
||||
"show_history": False,
|
||||
"all_stores": False,
|
||||
"prompt": None,
|
||||
"continue_prompt": None,
|
||||
"output_file": None,
|
||||
"input_file": None,
|
||||
}
|
||||
|
||||
i = 0
|
||||
while i < len(args):
|
||||
arg = args[i]
|
||||
|
||||
if arg == "--session-id" and i + 1 < len(args):
|
||||
config["session_id"] = args[i + 1]
|
||||
i += 2
|
||||
elif arg == "--user-id" and i + 1 < len(args):
|
||||
config["user_id"] = args[i + 1]
|
||||
i += 2
|
||||
elif arg == "--conversation-url" and i + 1 < len(args):
|
||||
config["conversation_url"] = args[i + 1]
|
||||
i += 2
|
||||
elif arg == "--knowledge-url" and i + 1 < len(args):
|
||||
config["knowledge_url"] = args[i + 1]
|
||||
i += 2
|
||||
elif arg == "--state-url" and i + 1 < len(args):
|
||||
config["state_url"] = args[i + 1]
|
||||
i += 2
|
||||
elif arg == "--agent-name" and i + 1 < len(args):
|
||||
config["agent_name"] = args[i + 1]
|
||||
i += 2
|
||||
elif arg == "--agent-instructions" and i + 1 < len(args):
|
||||
config["agent_instructions"] = args[i + 1]
|
||||
i += 2
|
||||
elif arg == "--dry-run":
|
||||
config["dry_run"] = True
|
||||
i += 1
|
||||
elif arg == "--show-history":
|
||||
config["show_history"] = True
|
||||
i += 1
|
||||
elif arg == "--all":
|
||||
config["all_stores"] = True
|
||||
i += 1
|
||||
elif arg == "--continue" and i + 1 < len(args):
|
||||
config["continue_prompt"] = args[i + 1]
|
||||
i += 2
|
||||
elif arg == "--output" and i + 1 < len(args):
|
||||
config["output_file"] = args[i + 1]
|
||||
i += 2
|
||||
elif arg == "--file" and i + 1 < len(args):
|
||||
config["input_file"] = args[i + 1]
|
||||
i += 2
|
||||
elif not arg.startswith("-"):
|
||||
config["prompt"] = arg
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
|
||||
return config
|
||||
|
||||
|
||||
def handle_doctor(args: list) -> bool:
|
||||
"""Handle persistence doctor command."""
|
||||
config = parse_persistence_args(args)
|
||||
|
||||
print("=" * 50)
|
||||
print("PraisonAI Persistence Doctor")
|
||||
print("=" * 50)
|
||||
|
||||
results = []
|
||||
|
||||
# Test conversation store
|
||||
if config["conversation_url"] or config["all_stores"]:
|
||||
url = config["conversation_url"]
|
||||
if url:
|
||||
print(f"\n[Conversation Store] Testing: {_mask_url(url)}")
|
||||
success, msg = _test_conversation_store(url)
|
||||
results.append(("Conversation", success, msg))
|
||||
print(f" {'✅' if success else '❌'} {msg}")
|
||||
else:
|
||||
print("\n[Conversation Store] Not configured (set --conversation-url or PRAISON_CONVERSATION_URL)")
|
||||
|
||||
# Test knowledge store
|
||||
if config["knowledge_url"] or config["all_stores"]:
|
||||
url = config["knowledge_url"]
|
||||
if url:
|
||||
print(f"\n[Knowledge Store] Testing: {_mask_url(url)}")
|
||||
success, msg = _test_knowledge_store(url)
|
||||
results.append(("Knowledge", success, msg))
|
||||
print(f" {'✅' if success else '❌'} {msg}")
|
||||
else:
|
||||
print("\n[Knowledge Store] Not configured (set --knowledge-url or PRAISON_KNOWLEDGE_URL)")
|
||||
|
||||
# Test state store
|
||||
if config["state_url"] or config["all_stores"]:
|
||||
url = config["state_url"]
|
||||
if url:
|
||||
print(f"\n[State Store] Testing: {_mask_url(url)}")
|
||||
success, msg = _test_state_store(url)
|
||||
results.append(("State", success, msg))
|
||||
print(f" {'✅' if success else '❌'} {msg}")
|
||||
else:
|
||||
print("\n[State Store] Not configured (set --state-url or PRAISON_STATE_URL)")
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 50)
|
||||
if results:
|
||||
passed = sum(1 for _, s, _ in results if s)
|
||||
total = len(results)
|
||||
print(f"Results: {passed}/{total} stores connected successfully")
|
||||
else:
|
||||
print("No stores configured. Use --conversation-url, --knowledge-url, or --state-url")
|
||||
print("=" * 50)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def handle_run(args: list) -> bool:
|
||||
"""Handle persistence run command."""
|
||||
config = parse_persistence_args(args)
|
||||
|
||||
if not config["prompt"]:
|
||||
print("Error: No prompt provided")
|
||||
print("Usage: praisonai persistence run [options] \"your prompt\"")
|
||||
return True
|
||||
|
||||
if not config["conversation_url"]:
|
||||
print("Error: No conversation URL configured")
|
||||
print("Set --conversation-url or PRAISON_CONVERSATION_URL")
|
||||
return True
|
||||
|
||||
if config["dry_run"]:
|
||||
print("=" * 50)
|
||||
print("Dry Run - Configuration")
|
||||
print("=" * 50)
|
||||
print(f"Session ID: {config['session_id'] or '(auto-generated)'}")
|
||||
print(f"User ID: {config['user_id']}")
|
||||
print(f"Conversation URL: {_mask_url(config['conversation_url'])}")
|
||||
print(f"Agent Name: {config['agent_name']}")
|
||||
print(f"Prompt: {config['prompt'][:50]}...")
|
||||
return True
|
||||
|
||||
# Run agent with persistence
|
||||
try:
|
||||
from praisonai.db import PraisonDB
|
||||
from praisonaiagents import Agent
|
||||
|
||||
db = PraisonDB(
|
||||
database_url=config["conversation_url"],
|
||||
state_url=config["state_url"],
|
||||
knowledge_url=config["knowledge_url"],
|
||||
)
|
||||
|
||||
agent = Agent(
|
||||
name=config["agent_name"],
|
||||
instructions=config["agent_instructions"],
|
||||
db=db,
|
||||
session_id=config["session_id"],
|
||||
verbose=True
|
||||
)
|
||||
|
||||
print(f"\n[Session: {agent.session_id}]")
|
||||
response = agent.chat(config["prompt"])
|
||||
print(f"\nAgent: {response}")
|
||||
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def handle_resume(args: list) -> bool:
|
||||
"""Handle persistence resume command."""
|
||||
config = parse_persistence_args(args)
|
||||
|
||||
if not config["session_id"]:
|
||||
print("Error: --session-id is required for resume")
|
||||
return True
|
||||
|
||||
if not config["conversation_url"]:
|
||||
print("Error: No conversation URL configured")
|
||||
print("Set --conversation-url or PRAISON_CONVERSATION_URL")
|
||||
return True
|
||||
|
||||
try:
|
||||
from praisonai.db import PraisonDB
|
||||
|
||||
db = PraisonDB(database_url=config["conversation_url"])
|
||||
|
||||
# Get session history
|
||||
history = db.on_agent_start(
|
||||
agent_name="Resume",
|
||||
session_id=config["session_id"],
|
||||
user_id=config["user_id"]
|
||||
)
|
||||
|
||||
print(f"\n[Session: {config['session_id']}]")
|
||||
print(f"Messages in history: {len(history)}")
|
||||
|
||||
if config["show_history"]:
|
||||
print("\n--- Conversation History ---")
|
||||
for msg in history:
|
||||
role = msg.role.upper()
|
||||
content = msg.content[:100] + "..." if len(msg.content) > 100 else msg.content
|
||||
print(f"[{role}] {content}")
|
||||
print("--- End History ---\n")
|
||||
|
||||
if config["continue_prompt"]:
|
||||
from praisonaiagents import Agent
|
||||
|
||||
agent = Agent(
|
||||
name=config["agent_name"],
|
||||
instructions=config["agent_instructions"],
|
||||
db=db,
|
||||
session_id=config["session_id"],
|
||||
verbose=True
|
||||
)
|
||||
|
||||
response = agent.chat(config["continue_prompt"])
|
||||
print(f"\nAgent: {response}")
|
||||
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def handle_export(args: list) -> bool:
|
||||
"""Handle persistence export command."""
|
||||
config = parse_persistence_args(args)
|
||||
|
||||
if not config["session_id"]:
|
||||
print("Error: --session-id is required for export")
|
||||
return True
|
||||
|
||||
if not config["conversation_url"]:
|
||||
print("Error: No conversation URL configured")
|
||||
return True
|
||||
|
||||
try:
|
||||
from praisonai.db import PraisonDB
|
||||
import json
|
||||
|
||||
db = PraisonDB(database_url=config["conversation_url"])
|
||||
|
||||
# Export session
|
||||
data = db.export_session(config["session_id"])
|
||||
|
||||
if not data:
|
||||
print(f"Error: Session {config['session_id']} not found")
|
||||
db.close()
|
||||
return True
|
||||
|
||||
# Determine output file
|
||||
output_file = config.get("output_file") or f"session-{config['session_id']}.jsonl"
|
||||
|
||||
with open(output_file, 'w') as f:
|
||||
f.write(json.dumps(data, default=str) + '\n')
|
||||
|
||||
print(f"Exported session to: {output_file}")
|
||||
print(f"Messages: {len(data.get('messages', []))}")
|
||||
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def handle_import(args: list) -> bool:
|
||||
"""Handle persistence import command."""
|
||||
config = parse_persistence_args(args)
|
||||
|
||||
input_file = config.get("input_file")
|
||||
if not input_file:
|
||||
print("Error: --file is required for import")
|
||||
return True
|
||||
|
||||
if not config["conversation_url"]:
|
||||
print("Error: No conversation URL configured")
|
||||
return True
|
||||
|
||||
try:
|
||||
from praisonai.db import PraisonDB
|
||||
import json
|
||||
|
||||
db = PraisonDB(database_url=config["conversation_url"])
|
||||
|
||||
with open(input_file, 'r') as f:
|
||||
data = json.loads(f.readline())
|
||||
|
||||
session_id = db.import_session(data)
|
||||
|
||||
print(f"Imported session: {session_id}")
|
||||
print(f"Messages: {len(data.get('messages', []))}")
|
||||
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def handle_migrate(args: list) -> bool:
|
||||
"""Handle persistence migrate command."""
|
||||
config = parse_persistence_args(args)
|
||||
|
||||
if not config["conversation_url"] and not config["state_url"]:
|
||||
print("Error: No database URL configured")
|
||||
print("Set --conversation-url or --state-url")
|
||||
return True
|
||||
|
||||
try:
|
||||
from praisonai.db import PraisonDB
|
||||
from praisonai.persistence.migrations import MigrationManager
|
||||
|
||||
db = PraisonDB(
|
||||
database_url=config["conversation_url"],
|
||||
state_url=config["state_url"]
|
||||
)
|
||||
|
||||
manager = MigrationManager(db)
|
||||
|
||||
# Check for --up or --down flags
|
||||
direction = "up"
|
||||
target = None
|
||||
for i, arg in enumerate(args):
|
||||
if arg == "--down":
|
||||
direction = "down"
|
||||
elif arg == "--target" and i + 1 < len(args):
|
||||
target = args[i + 1]
|
||||
|
||||
if direction == "up":
|
||||
print("Applying pending migrations...")
|
||||
applied = manager.migrate_up(target)
|
||||
if applied:
|
||||
print(f"Applied migrations: {', '.join(applied)}")
|
||||
else:
|
||||
print("No pending migrations")
|
||||
else:
|
||||
if not target:
|
||||
print("Error: --target required for --down migration")
|
||||
return True
|
||||
print(f"Rolling back to version {target}...")
|
||||
rolled_back = manager.migrate_down(target)
|
||||
if rolled_back:
|
||||
print(f"Rolled back: {', '.join(rolled_back)}")
|
||||
else:
|
||||
print("No migrations to rollback")
|
||||
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def handle_status(args: list) -> bool:
|
||||
"""Handle persistence status command."""
|
||||
config = parse_persistence_args(args)
|
||||
|
||||
if not config["conversation_url"] and not config["state_url"]:
|
||||
print("Error: No database URL configured")
|
||||
return True
|
||||
|
||||
try:
|
||||
from praisonai.db import PraisonDB
|
||||
from praisonai.persistence.migrations import MigrationManager
|
||||
|
||||
db = PraisonDB(
|
||||
database_url=config["conversation_url"],
|
||||
state_url=config["state_url"]
|
||||
)
|
||||
|
||||
manager = MigrationManager(db)
|
||||
status = manager.get_migration_status()
|
||||
|
||||
print("\n=== Schema Status ===")
|
||||
print(f"Current version: {status['current_version'] or '(not set)'}")
|
||||
print(f"Latest version: {status['latest_version']}")
|
||||
print(f"Pending: {status['pending_count']} migration(s)")
|
||||
|
||||
if status['pending_versions']:
|
||||
print(f"Pending versions: {', '.join(status['pending_versions'])}")
|
||||
|
||||
db.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return True
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def _mask_url(url: str) -> str:
|
||||
"""Mask sensitive parts of URL."""
|
||||
if not url:
|
||||
return "(not set)"
|
||||
|
||||
# Mask password in URLs
|
||||
import re
|
||||
masked = re.sub(r'://([^:]+):([^@]+)@', r'://\1:****@', url)
|
||||
return masked
|
||||
|
||||
|
||||
def _test_conversation_store(url: str) -> tuple:
|
||||
"""Test conversation store connectivity."""
|
||||
try:
|
||||
from praisonai.persistence.factory import create_conversation_store
|
||||
|
||||
# Detect backend
|
||||
if "postgresql" in url or "postgres" in url:
|
||||
backend = "postgres"
|
||||
elif "mysql" in url:
|
||||
backend = "mysql"
|
||||
elif url.endswith(".db") or "sqlite" in url:
|
||||
backend = "sqlite"
|
||||
else:
|
||||
backend = "sqlite"
|
||||
|
||||
store = create_conversation_store(backend, url=url)
|
||||
|
||||
# Try a simple operation
|
||||
sessions = store.list_sessions(limit=1)
|
||||
store.close()
|
||||
|
||||
return True, f"Connected ({backend})"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def _test_knowledge_store(url: str) -> tuple:
|
||||
"""Test knowledge store connectivity."""
|
||||
try:
|
||||
from praisonai.persistence.factory import create_knowledge_store
|
||||
|
||||
# Detect backend
|
||||
if "qdrant" in url or ":6333" in url:
|
||||
backend = "qdrant"
|
||||
elif "chroma" in url:
|
||||
backend = "chroma"
|
||||
else:
|
||||
backend = "qdrant"
|
||||
|
||||
store = create_knowledge_store(backend, url=url)
|
||||
|
||||
# Try a simple operation
|
||||
exists = store.collection_exists("test_doctor")
|
||||
store.close()
|
||||
|
||||
return True, f"Connected ({backend})"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def _test_state_store(url: str) -> tuple:
|
||||
"""Test state store connectivity."""
|
||||
try:
|
||||
from praisonai.persistence.factory import create_state_store
|
||||
|
||||
# Detect backend
|
||||
if "redis" in url:
|
||||
backend = "redis"
|
||||
else:
|
||||
backend = "memory"
|
||||
|
||||
store = create_state_store(backend, url=url)
|
||||
|
||||
# Try a simple operation
|
||||
store.set("_doctor_test", "ok", ttl=5)
|
||||
value = store.get("_doctor_test")
|
||||
store.delete("_doctor_test")
|
||||
store.close()
|
||||
|
||||
return True, f"Connected ({backend})"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
@@ -266,6 +266,12 @@ class PraisonAI:
|
||||
file_input = self.read_file_if_provided(getattr(args, 'file', None))
|
||||
|
||||
if args.command:
|
||||
# Handle persistence command
|
||||
if args.command == "persistence":
|
||||
from praisonai.cli.features.persistence import handle_persistence_command
|
||||
handle_persistence_command(unknown_args)
|
||||
return None
|
||||
|
||||
# Handle schedule command
|
||||
if args.command == "schedule":
|
||||
from praisonai.cli.features.agent_scheduler import AgentSchedulerHandler
|
||||
|
||||
@@ -57,7 +57,7 @@ class CloudDeployer:
|
||||
file.write("FROM python:3.11-slim\n")
|
||||
file.write("WORKDIR /app\n")
|
||||
file.write("COPY . .\n")
|
||||
file.write("RUN pip install flask praisonai==2.4.0 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==2.4.1 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
PraisonAI Persistence Layer
|
||||
|
||||
Provides database integrations for:
|
||||
- Conversation history persistence (ConversationStore)
|
||||
- Knowledge storage and retrieval (KnowledgeStore)
|
||||
- Session/state management (StateStore)
|
||||
|
||||
All integrations use lazy loading to avoid importing unused dependencies.
|
||||
|
||||
Supported Backends (22 total):
|
||||
- ConversationStore (6): postgres, mysql, sqlite, singlestore, supabase, surrealdb
|
||||
- KnowledgeStore (10): qdrant, pinecone, chroma, weaviate, lancedb, milvus, pgvector, redis, cassandra, clickhouse
|
||||
- StateStore (6): redis, dynamodb, firestore, mongodb, upstash, memory
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
__all__ = [
|
||||
# Base interfaces
|
||||
"ConversationStore",
|
||||
"ConversationSession",
|
||||
"ConversationMessage",
|
||||
"KnowledgeStore",
|
||||
"KnowledgeDocument",
|
||||
"StateStore",
|
||||
# Orchestrator
|
||||
"PersistenceOrchestrator",
|
||||
# Factory functions
|
||||
"create_conversation_store",
|
||||
"create_knowledge_store",
|
||||
"create_state_store",
|
||||
"create_stores_from_config",
|
||||
# Config
|
||||
"PersistenceConfig",
|
||||
# Agent hooks
|
||||
"wrap_agent_with_persistence",
|
||||
"PersistentAgent",
|
||||
"create_persistent_session",
|
||||
]
|
||||
|
||||
# Lazy imports to avoid loading dependencies until needed
|
||||
def __getattr__(name: str):
|
||||
if name in ("ConversationStore", "ConversationSession", "ConversationMessage"):
|
||||
from .conversation.base import ConversationStore, ConversationSession, ConversationMessage
|
||||
return locals()[name]
|
||||
|
||||
if name in ("KnowledgeStore", "KnowledgeDocument"):
|
||||
from .knowledge.base import KnowledgeStore, KnowledgeDocument
|
||||
return locals()[name]
|
||||
|
||||
if name == "StateStore":
|
||||
from .state.base import StateStore
|
||||
return StateStore
|
||||
|
||||
if name == "PersistenceOrchestrator":
|
||||
from .orchestrator import PersistenceOrchestrator
|
||||
return PersistenceOrchestrator
|
||||
|
||||
if name == "PersistenceConfig":
|
||||
from .config import PersistenceConfig
|
||||
return PersistenceConfig
|
||||
|
||||
if name == "create_conversation_store":
|
||||
from .factory import create_conversation_store
|
||||
return create_conversation_store
|
||||
|
||||
if name == "create_knowledge_store":
|
||||
from .factory import create_knowledge_store
|
||||
return create_knowledge_store
|
||||
|
||||
if name == "create_state_store":
|
||||
from .factory import create_state_store
|
||||
return create_state_store
|
||||
|
||||
if name == "create_stores_from_config":
|
||||
from .factory import create_stores_from_config
|
||||
return create_stores_from_config
|
||||
|
||||
if name in ("wrap_agent_with_persistence", "PersistentAgent", "create_persistent_session"):
|
||||
from .hooks.agent_hooks import wrap_agent_with_persistence, PersistentAgent, create_persistent_session
|
||||
return locals()[name]
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,238 @@
|
||||
"""
|
||||
Configuration handling for PraisonAI persistence layer.
|
||||
|
||||
Supports configuration via:
|
||||
- Environment variables
|
||||
- Config file (YAML/JSON)
|
||||
- CLI arguments
|
||||
- Direct Python API
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Environment variable mappings
|
||||
ENV_VARS = {
|
||||
# Conversation stores
|
||||
"conversation_store": "PRAISONAI_CONVERSATION_STORE",
|
||||
"conversation_url": "PRAISONAI_CONVERSATION_URL",
|
||||
# Knowledge stores
|
||||
"knowledge_store": "PRAISONAI_KNOWLEDGE_STORE",
|
||||
"knowledge_url": "PRAISONAI_KNOWLEDGE_URL",
|
||||
# State stores
|
||||
"state_store": "PRAISONAI_STATE_STORE",
|
||||
"state_url": "PRAISONAI_STATE_URL",
|
||||
# Common
|
||||
"session_id": "PRAISONAI_SESSION_ID",
|
||||
"user_id": "PRAISONAI_USER_ID",
|
||||
# PostgreSQL
|
||||
"postgres_url": "POSTGRES_URL",
|
||||
"postgres_host": "POSTGRES_HOST",
|
||||
"postgres_port": "POSTGRES_PORT",
|
||||
"postgres_database": "POSTGRES_DATABASE",
|
||||
"postgres_user": "POSTGRES_USER",
|
||||
"postgres_password": "POSTGRES_PASSWORD",
|
||||
# Qdrant
|
||||
"qdrant_url": "QDRANT_URL",
|
||||
"qdrant_host": "QDRANT_HOST",
|
||||
"qdrant_port": "QDRANT_PORT",
|
||||
"qdrant_api_key": "QDRANT_API_KEY",
|
||||
# Redis
|
||||
"redis_url": "REDIS_URL",
|
||||
"redis_host": "REDIS_HOST",
|
||||
"redis_port": "REDIS_PORT",
|
||||
"redis_password": "REDIS_PASSWORD",
|
||||
# Pinecone
|
||||
"pinecone_api_key": "PINECONE_API_KEY",
|
||||
"pinecone_environment": "PINECONE_ENVIRONMENT",
|
||||
"pinecone_index": "PINECONE_INDEX",
|
||||
# ChromaDB
|
||||
"chroma_path": "CHROMA_DB_PATH",
|
||||
"chroma_host": "CHROMA_HOST",
|
||||
"chroma_port": "CHROMA_PORT",
|
||||
# Weaviate
|
||||
"weaviate_url": "WEAVIATE_URL",
|
||||
"weaviate_api_key": "WEAVIATE_API_KEY",
|
||||
# Milvus
|
||||
"milvus_host": "MILVUS_HOST",
|
||||
"milvus_port": "MILVUS_PORT",
|
||||
"milvus_token": "MILVUS_TOKEN",
|
||||
# MongoDB
|
||||
"mongodb_url": "MONGODB_URL",
|
||||
"mongodb_database": "MONGODB_DATABASE",
|
||||
# DynamoDB
|
||||
"aws_region": "AWS_REGION",
|
||||
"dynamodb_table": "DYNAMODB_TABLE",
|
||||
# Firestore
|
||||
"google_credentials": "GOOGLE_APPLICATION_CREDENTIALS",
|
||||
"firestore_project": "FIRESTORE_PROJECT",
|
||||
# Upstash
|
||||
"upstash_redis_url": "UPSTASH_REDIS_URL",
|
||||
"upstash_redis_token": "UPSTASH_REDIS_TOKEN",
|
||||
"upstash_vector_url": "UPSTASH_VECTOR_URL",
|
||||
"upstash_vector_token": "UPSTASH_VECTOR_TOKEN",
|
||||
# Supabase
|
||||
"supabase_url": "SUPABASE_URL",
|
||||
"supabase_key": "SUPABASE_KEY",
|
||||
# SurrealDB
|
||||
"surrealdb_url": "SURREALDB_URL",
|
||||
"surrealdb_namespace": "SURREALDB_NS",
|
||||
"surrealdb_database": "SURREALDB_DB",
|
||||
# MySQL
|
||||
"mysql_url": "MYSQL_URL",
|
||||
"mysql_host": "MYSQL_HOST",
|
||||
"mysql_port": "MYSQL_PORT",
|
||||
"mysql_database": "MYSQL_DATABASE",
|
||||
"mysql_user": "MYSQL_USER",
|
||||
"mysql_password": "MYSQL_PASSWORD",
|
||||
# SingleStore
|
||||
"singlestore_url": "SINGLESTORE_URL",
|
||||
# ClickHouse
|
||||
"clickhouse_host": "CLICKHOUSE_HOST",
|
||||
"clickhouse_port": "CLICKHOUSE_PORT",
|
||||
"clickhouse_user": "CLICKHOUSE_USER",
|
||||
"clickhouse_password": "CLICKHOUSE_PASSWORD",
|
||||
# Cassandra
|
||||
"cassandra_hosts": "CASSANDRA_HOSTS",
|
||||
"cassandra_keyspace": "CASSANDRA_KEYSPACE",
|
||||
# LanceDB
|
||||
"lancedb_path": "LANCEDB_PATH",
|
||||
"lancedb_uri": "LANCEDB_URI",
|
||||
}
|
||||
|
||||
# Supported backends
|
||||
CONVERSATION_BACKENDS = ["postgres", "mysql", "sqlite", "singlestore", "supabase", "surrealdb"]
|
||||
KNOWLEDGE_BACKENDS = ["qdrant", "pinecone", "chroma", "weaviate", "lancedb", "milvus", "pgvector", "redis", "cassandra", "clickhouse"]
|
||||
STATE_BACKENDS = ["redis", "dynamodb", "firestore", "mongodb", "upstash", "memory"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PersistenceConfig:
|
||||
"""
|
||||
Configuration for PraisonAI persistence layer.
|
||||
|
||||
Example:
|
||||
config = PersistenceConfig(
|
||||
conversation_store="postgres",
|
||||
conversation_url="postgresql://localhost:5432/praisonai",
|
||||
knowledge_store="qdrant",
|
||||
knowledge_url="http://localhost:6333",
|
||||
state_store="redis",
|
||||
state_url="redis://localhost:6379",
|
||||
)
|
||||
"""
|
||||
# Store backends
|
||||
conversation_store: Optional[str] = None
|
||||
conversation_url: Optional[str] = None
|
||||
conversation_options: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
knowledge_store: Optional[str] = None
|
||||
knowledge_url: Optional[str] = None
|
||||
knowledge_options: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
state_store: Optional[str] = None
|
||||
state_url: Optional[str] = None
|
||||
state_options: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
# Session/user context
|
||||
session_id: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
|
||||
# General options
|
||||
auto_create_tables: bool = True
|
||||
lazy_init: bool = True
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "PersistenceConfig":
|
||||
"""Create config from environment variables."""
|
||||
return cls(
|
||||
conversation_store=os.getenv(ENV_VARS["conversation_store"]),
|
||||
conversation_url=os.getenv(ENV_VARS["conversation_url"]) or os.getenv(ENV_VARS["postgres_url"]),
|
||||
knowledge_store=os.getenv(ENV_VARS["knowledge_store"]),
|
||||
knowledge_url=os.getenv(ENV_VARS["knowledge_url"]) or os.getenv(ENV_VARS["qdrant_url"]),
|
||||
state_store=os.getenv(ENV_VARS["state_store"]),
|
||||
state_url=os.getenv(ENV_VARS["state_url"]) or os.getenv(ENV_VARS["redis_url"]),
|
||||
session_id=os.getenv(ENV_VARS["session_id"]),
|
||||
user_id=os.getenv(ENV_VARS["user_id"]),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "PersistenceConfig":
|
||||
"""Create config from dictionary."""
|
||||
return cls(**{k: v for k, v in data.items() if hasattr(cls, k)})
|
||||
|
||||
@classmethod
|
||||
def from_yaml(cls, path: str) -> "PersistenceConfig":
|
||||
"""Load config from YAML file."""
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
raise ImportError("PyYAML required for YAML config. Install with: pip install pyyaml")
|
||||
|
||||
with open(path) as f:
|
||||
data = yaml.safe_load(f)
|
||||
|
||||
# Handle nested persistence config
|
||||
if "persistence" in data:
|
||||
data = data["persistence"]
|
||||
|
||||
return cls.from_dict(data)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert config to dictionary."""
|
||||
return {
|
||||
"conversation_store": self.conversation_store,
|
||||
"conversation_url": self.conversation_url,
|
||||
"conversation_options": self.conversation_options,
|
||||
"knowledge_store": self.knowledge_store,
|
||||
"knowledge_url": self.knowledge_url,
|
||||
"knowledge_options": self.knowledge_options,
|
||||
"state_store": self.state_store,
|
||||
"state_url": self.state_url,
|
||||
"state_options": self.state_options,
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
"auto_create_tables": self.auto_create_tables,
|
||||
"lazy_init": self.lazy_init,
|
||||
}
|
||||
|
||||
def validate(self) -> bool:
|
||||
"""Validate configuration."""
|
||||
valid = True
|
||||
|
||||
if self.conversation_store and self.conversation_store not in CONVERSATION_BACKENDS:
|
||||
logger.error(f"Invalid conversation_store: {self.conversation_store}. "
|
||||
f"Supported: {CONVERSATION_BACKENDS}")
|
||||
valid = False
|
||||
|
||||
if self.knowledge_store and self.knowledge_store not in KNOWLEDGE_BACKENDS:
|
||||
logger.error(f"Invalid knowledge_store: {self.knowledge_store}. "
|
||||
f"Supported: {KNOWLEDGE_BACKENDS}")
|
||||
valid = False
|
||||
|
||||
if self.state_store and self.state_store not in STATE_BACKENDS:
|
||||
logger.error(f"Invalid state_store: {self.state_store}. "
|
||||
f"Supported: {STATE_BACKENDS}")
|
||||
valid = False
|
||||
|
||||
return valid
|
||||
|
||||
|
||||
def get_env_var(key: str, default: Optional[str] = None) -> Optional[str]:
|
||||
"""Get environment variable by config key name."""
|
||||
env_name = ENV_VARS.get(key, key.upper())
|
||||
return os.getenv(env_name, default)
|
||||
|
||||
|
||||
def list_available_backends() -> Dict[str, list]:
|
||||
"""List all available backends by store type."""
|
||||
return {
|
||||
"conversation": CONVERSATION_BACKENDS,
|
||||
"knowledge": KNOWLEDGE_BACKENDS,
|
||||
"state": STATE_BACKENDS,
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
ConversationStore implementations for session and message persistence.
|
||||
|
||||
Supported backends:
|
||||
- PostgreSQL (postgres)
|
||||
- MySQL (mysql)
|
||||
- SQLite (sqlite)
|
||||
- SingleStore (singlestore)
|
||||
- Supabase (supabase)
|
||||
- SurrealDB (surrealdb)
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
__all__ = [
|
||||
"ConversationStore",
|
||||
"ConversationSession",
|
||||
"ConversationMessage",
|
||||
]
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name in ("ConversationStore", "ConversationSession", "ConversationMessage"):
|
||||
from .base import ConversationStore, ConversationSession, ConversationMessage
|
||||
return locals()[name]
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
Base interfaces for ConversationStore.
|
||||
|
||||
ConversationStore handles session and message persistence for conversation history.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
import time
|
||||
import uuid
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationMessage:
|
||||
"""A single message in a conversation."""
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
session_id: str = ""
|
||||
role: str = "" # user, assistant, system, tool
|
||||
content: str = ""
|
||||
tool_calls: Optional[List[Dict[str, Any]]] = None
|
||||
tool_call_id: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
created_at: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"session_id": self.session_id,
|
||||
"role": self.role,
|
||||
"content": self.content,
|
||||
"tool_calls": self.tool_calls,
|
||||
"tool_call_id": self.tool_call_id,
|
||||
"metadata": self.metadata,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ConversationMessage":
|
||||
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationSession:
|
||||
"""A conversation session containing messages."""
|
||||
session_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
user_id: Optional[str] = None
|
||||
agent_id: Optional[str] = None
|
||||
name: Optional[str] = None
|
||||
state: Optional[Dict[str, Any]] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
created_at: float = field(default_factory=time.time)
|
||||
updated_at: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"user_id": self.user_id,
|
||||
"agent_id": self.agent_id,
|
||||
"name": self.name,
|
||||
"state": self.state,
|
||||
"metadata": self.metadata,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "ConversationSession":
|
||||
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
||||
|
||||
|
||||
class ConversationStore(ABC):
|
||||
"""
|
||||
Abstract base class for conversation persistence.
|
||||
|
||||
Implementations handle session and message storage for different backends:
|
||||
- PostgreSQL, MySQL, SQLite (relational)
|
||||
- SingleStore, Supabase, SurrealDB (hybrid)
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Create a new session."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
"""Get a session by ID."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Update an existing session."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session and all its messages."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[ConversationSession]:
|
||||
"""List sessions, optionally filtered by user or agent."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
"""Add a message to a session."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
before: Optional[float] = None,
|
||||
after: Optional[float] = None
|
||||
) -> List[ConversationMessage]:
|
||||
"""Get messages from a session."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
"""Delete messages. If message_ids is None, delete all messages in session."""
|
||||
raise NotImplementedError
|
||||
|
||||
def upsert_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Create or update a session."""
|
||||
existing = self.get_session(session.session_id)
|
||||
if existing:
|
||||
session.updated_at = time.time()
|
||||
return self.update_session(session)
|
||||
return self.create_session(session)
|
||||
|
||||
@abstractmethod
|
||||
def close(self) -> None:
|
||||
"""Close the store and release resources."""
|
||||
raise NotImplementedError
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
return False
|
||||
@@ -0,0 +1,387 @@
|
||||
"""
|
||||
MySQL implementation of ConversationStore.
|
||||
|
||||
Requires: mysql-connector-python or pymysql
|
||||
Install: pip install mysql-connector-python
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from .base import ConversationStore, ConversationSession, ConversationMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MySQLConversationStore(ConversationStore):
|
||||
"""
|
||||
MySQL-based conversation store.
|
||||
|
||||
Example:
|
||||
store = MySQLConversationStore(
|
||||
url="mysql://user:password@localhost:3306/praisonai"
|
||||
)
|
||||
"""
|
||||
|
||||
SCHEMA_VERSION = "1.0.0"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
host: str = "localhost",
|
||||
port: int = 3306,
|
||||
database: str = "praisonai",
|
||||
user: str = "root",
|
||||
password: str = "",
|
||||
table_prefix: str = "praison_",
|
||||
auto_create_tables: bool = True,
|
||||
pool_size: int = 5,
|
||||
):
|
||||
"""
|
||||
Initialize MySQL conversation store.
|
||||
|
||||
Args:
|
||||
url: Full connection URL (overrides individual params)
|
||||
host: Database host
|
||||
port: Database port
|
||||
database: Database name
|
||||
user: Database user
|
||||
password: Database password
|
||||
table_prefix: Prefix for table names
|
||||
auto_create_tables: Create tables if they don't exist
|
||||
pool_size: Connection pool size
|
||||
"""
|
||||
try:
|
||||
import mysql.connector
|
||||
from mysql.connector import pooling
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"mysql-connector-python is required for MySQL support. "
|
||||
"Install with: pip install mysql-connector-python"
|
||||
)
|
||||
|
||||
self._mysql = mysql.connector
|
||||
self.table_prefix = table_prefix
|
||||
self.sessions_table = f"{table_prefix}sessions"
|
||||
self.messages_table = f"{table_prefix}messages"
|
||||
|
||||
# Parse URL if provided
|
||||
if url:
|
||||
# Parse mysql://user:pass@host:port/database
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname or host
|
||||
port = parsed.port or port
|
||||
database = parsed.path.lstrip("/") or database
|
||||
user = parsed.username or user
|
||||
password = parsed.password or password
|
||||
|
||||
# Create connection pool
|
||||
self._pool = pooling.MySQLConnectionPool(
|
||||
pool_name="praison_mysql_pool",
|
||||
pool_size=pool_size,
|
||||
host=host,
|
||||
port=port,
|
||||
database=database,
|
||||
user=user,
|
||||
password=password,
|
||||
)
|
||||
|
||||
if auto_create_tables:
|
||||
self._create_tables()
|
||||
|
||||
def _get_conn(self):
|
||||
"""Get a connection from the pool."""
|
||||
return self._pool.get_connection()
|
||||
|
||||
def _create_tables(self) -> None:
|
||||
"""Create tables if they don't exist."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
|
||||
# Sessions table
|
||||
cur.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.sessions_table} (
|
||||
session_id VARCHAR(255) PRIMARY KEY,
|
||||
user_id VARCHAR(255),
|
||||
agent_id VARCHAR(255),
|
||||
name VARCHAR(255),
|
||||
state JSON,
|
||||
metadata JSON,
|
||||
created_at DOUBLE,
|
||||
updated_at DOUBLE,
|
||||
INDEX idx_user (user_id),
|
||||
INDEX idx_agent (agent_id),
|
||||
INDEX idx_updated (updated_at DESC)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
|
||||
# Messages table
|
||||
cur.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.messages_table} (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
session_id VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(50) NOT NULL,
|
||||
content TEXT,
|
||||
tool_calls JSON,
|
||||
tool_call_id VARCHAR(255),
|
||||
metadata JSON,
|
||||
created_at DOUBLE,
|
||||
INDEX idx_session (session_id),
|
||||
INDEX idx_created (session_id, created_at),
|
||||
FOREIGN KEY (session_id) REFERENCES {self.sessions_table}(session_id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"MySQL tables created: {self.sessions_table}, {self.messages_table}")
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Create a new session."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"""
|
||||
INSERT INTO {self.sessions_table}
|
||||
(session_id, user_id, agent_id, name, state, metadata, created_at, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
session.session_id,
|
||||
session.user_id,
|
||||
session.agent_id,
|
||||
session.name,
|
||||
json.dumps(session.state) if session.state else None,
|
||||
json.dumps(session.metadata) if session.metadata else None,
|
||||
session.created_at,
|
||||
session.updated_at,
|
||||
))
|
||||
conn.commit()
|
||||
return session
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
"""Get a session by ID."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
cur = conn.cursor(dictionary=True)
|
||||
cur.execute(f"""
|
||||
SELECT * FROM {self.sessions_table} WHERE session_id = %s
|
||||
""", (session_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return ConversationSession(
|
||||
session_id=row["session_id"],
|
||||
user_id=row["user_id"],
|
||||
agent_id=row["agent_id"],
|
||||
name=row["name"],
|
||||
state=row["state"] if isinstance(row["state"], dict) else (json.loads(row["state"]) if row["state"] else None),
|
||||
metadata=row["metadata"] if isinstance(row["metadata"], dict) else (json.loads(row["metadata"]) if row["metadata"] else None),
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Update an existing session."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"""
|
||||
UPDATE {self.sessions_table}
|
||||
SET user_id = %s, agent_id = %s, name = %s,
|
||||
state = %s, metadata = %s, updated_at = %s
|
||||
WHERE session_id = %s
|
||||
""", (
|
||||
session.user_id,
|
||||
session.agent_id,
|
||||
session.name,
|
||||
json.dumps(session.state) if session.state else None,
|
||||
json.dumps(session.metadata) if session.metadata else None,
|
||||
session.updated_at,
|
||||
session.session_id,
|
||||
))
|
||||
conn.commit()
|
||||
return session
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session and all its messages."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"""
|
||||
DELETE FROM {self.sessions_table} WHERE session_id = %s
|
||||
""", (session_id,))
|
||||
deleted = cur.rowcount > 0
|
||||
conn.commit()
|
||||
return deleted
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[ConversationSession]:
|
||||
"""List sessions."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
cur = conn.cursor(dictionary=True)
|
||||
|
||||
conditions = []
|
||||
params: List[Any] = []
|
||||
|
||||
if user_id:
|
||||
conditions.append("user_id = %s")
|
||||
params.append(user_id)
|
||||
if agent_id:
|
||||
conditions.append("agent_id = %s")
|
||||
params.append(agent_id)
|
||||
|
||||
where_clause = ""
|
||||
if conditions:
|
||||
where_clause = "WHERE " + " AND ".join(conditions)
|
||||
|
||||
params.extend([limit, offset])
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT * FROM {self.sessions_table}
|
||||
{where_clause}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params)
|
||||
|
||||
return [
|
||||
ConversationSession(
|
||||
session_id=row["session_id"],
|
||||
user_id=row["user_id"],
|
||||
agent_id=row["agent_id"],
|
||||
name=row["name"],
|
||||
state=row["state"] if isinstance(row["state"], dict) else (json.loads(row["state"]) if row["state"] else None),
|
||||
metadata=row["metadata"] if isinstance(row["metadata"], dict) else (json.loads(row["metadata"]) if row["metadata"] else None),
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
"""Add a message to a session."""
|
||||
message.session_id = session_id
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"""
|
||||
INSERT INTO {self.messages_table}
|
||||
(id, session_id, role, content, tool_calls, tool_call_id, metadata, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
message.id,
|
||||
session_id,
|
||||
message.role,
|
||||
message.content,
|
||||
json.dumps(message.tool_calls) if message.tool_calls else None,
|
||||
message.tool_call_id,
|
||||
json.dumps(message.metadata) if message.metadata else None,
|
||||
message.created_at,
|
||||
))
|
||||
conn.commit()
|
||||
return message
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
before: Optional[float] = None,
|
||||
after: Optional[float] = None
|
||||
) -> List[ConversationMessage]:
|
||||
"""Get messages from a session."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
cur = conn.cursor(dictionary=True)
|
||||
|
||||
conditions = ["session_id = %s"]
|
||||
params: List[Any] = [session_id]
|
||||
|
||||
if before:
|
||||
conditions.append("created_at < %s")
|
||||
params.append(before)
|
||||
if after:
|
||||
conditions.append("created_at > %s")
|
||||
params.append(after)
|
||||
|
||||
where_clause = "WHERE " + " AND ".join(conditions)
|
||||
limit_clause = f"LIMIT {limit}" if limit else ""
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT * FROM {self.messages_table}
|
||||
{where_clause}
|
||||
ORDER BY created_at ASC
|
||||
{limit_clause}
|
||||
""", params)
|
||||
|
||||
return [
|
||||
ConversationMessage(
|
||||
id=row["id"],
|
||||
session_id=row["session_id"],
|
||||
role=row["role"],
|
||||
content=row["content"],
|
||||
tool_calls=row["tool_calls"] if isinstance(row["tool_calls"], list) else (json.loads(row["tool_calls"]) if row["tool_calls"] else None),
|
||||
tool_call_id=row["tool_call_id"],
|
||||
metadata=row["metadata"] if isinstance(row["metadata"], dict) else (json.loads(row["metadata"]) if row["metadata"] else None),
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
"""Delete messages."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
|
||||
if message_ids:
|
||||
placeholders = ",".join(["%s"] * len(message_ids))
|
||||
cur.execute(f"""
|
||||
DELETE FROM {self.messages_table}
|
||||
WHERE session_id = %s AND id IN ({placeholders})
|
||||
""", [session_id] + message_ids)
|
||||
else:
|
||||
cur.execute(f"""
|
||||
DELETE FROM {self.messages_table} WHERE session_id = %s
|
||||
""", (session_id,))
|
||||
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
return deleted
|
||||
finally:
|
||||
cur.close()
|
||||
conn.close()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
pass # Pool handles cleanup
|
||||
@@ -0,0 +1,401 @@
|
||||
"""
|
||||
PostgreSQL implementation of ConversationStore.
|
||||
|
||||
Requires: psycopg2-binary or psycopg2
|
||||
Install: pip install psycopg2-binary
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import ConversationStore, ConversationSession, ConversationMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PostgresConversationStore(ConversationStore):
|
||||
"""
|
||||
PostgreSQL-based conversation store.
|
||||
|
||||
Connection URL format: postgresql://user:password@host:port/database
|
||||
|
||||
Example:
|
||||
store = PostgresConversationStore(
|
||||
url="postgresql://postgres:password@localhost:5432/praisonai"
|
||||
)
|
||||
"""
|
||||
|
||||
SCHEMA_VERSION = "1.0.0"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
host: str = "localhost",
|
||||
port: int = 5432,
|
||||
database: str = "praisonai",
|
||||
user: str = "postgres",
|
||||
password: str = "",
|
||||
schema: str = "public",
|
||||
table_prefix: str = "praison_",
|
||||
auto_create_tables: bool = True,
|
||||
pool_size: int = 5,
|
||||
):
|
||||
"""
|
||||
Initialize PostgreSQL conversation store.
|
||||
|
||||
Args:
|
||||
url: Full connection URL (overrides individual params)
|
||||
host: Database host
|
||||
port: Database port
|
||||
database: Database name
|
||||
user: Database user
|
||||
password: Database password
|
||||
schema: PostgreSQL schema
|
||||
table_prefix: Prefix for table names
|
||||
auto_create_tables: Create tables if they don't exist
|
||||
pool_size: Connection pool size
|
||||
"""
|
||||
try:
|
||||
import psycopg2
|
||||
from psycopg2 import pool as pg_pool
|
||||
from psycopg2.extras import RealDictCursor
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"psycopg2 is required for PostgreSQL support. "
|
||||
"Install with: pip install psycopg2-binary"
|
||||
)
|
||||
|
||||
self._psycopg2 = psycopg2
|
||||
self._RealDictCursor = RealDictCursor
|
||||
|
||||
self.schema = schema
|
||||
self.table_prefix = table_prefix
|
||||
self.sessions_table = f"{schema}.{table_prefix}sessions"
|
||||
self.messages_table = f"{schema}.{table_prefix}messages"
|
||||
|
||||
# Build connection params
|
||||
if url:
|
||||
self._pool = pg_pool.ThreadedConnectionPool(1, pool_size, url)
|
||||
else:
|
||||
self._pool = pg_pool.ThreadedConnectionPool(
|
||||
1, pool_size,
|
||||
host=host,
|
||||
port=port,
|
||||
database=database,
|
||||
user=user,
|
||||
password=password,
|
||||
)
|
||||
|
||||
if auto_create_tables:
|
||||
self._create_tables()
|
||||
|
||||
def _get_conn(self):
|
||||
"""Get a connection from the pool."""
|
||||
return self._pool.getconn()
|
||||
|
||||
def _put_conn(self, conn):
|
||||
"""Return a connection to the pool."""
|
||||
self._pool.putconn(conn)
|
||||
|
||||
def _create_tables(self) -> None:
|
||||
"""Create tables if they don't exist."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
# Create schema if not exists
|
||||
cur.execute(f"CREATE SCHEMA IF NOT EXISTS {self.schema}")
|
||||
|
||||
# Sessions table
|
||||
cur.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.sessions_table} (
|
||||
session_id VARCHAR(255) PRIMARY KEY,
|
||||
user_id VARCHAR(255),
|
||||
agent_id VARCHAR(255),
|
||||
name VARCHAR(255),
|
||||
state JSONB,
|
||||
metadata JSONB,
|
||||
created_at DOUBLE PRECISION,
|
||||
updated_at DOUBLE PRECISION
|
||||
)
|
||||
""")
|
||||
|
||||
# Sessions indexes
|
||||
cur.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}sessions_user
|
||||
ON {self.sessions_table}(user_id)
|
||||
""")
|
||||
cur.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}sessions_agent
|
||||
ON {self.sessions_table}(agent_id)
|
||||
""")
|
||||
cur.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}sessions_updated
|
||||
ON {self.sessions_table}(updated_at DESC)
|
||||
""")
|
||||
|
||||
# Messages table
|
||||
cur.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.messages_table} (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
session_id VARCHAR(255) NOT NULL
|
||||
REFERENCES {self.sessions_table}(session_id) ON DELETE CASCADE,
|
||||
role VARCHAR(50) NOT NULL,
|
||||
content TEXT,
|
||||
tool_calls JSONB,
|
||||
tool_call_id VARCHAR(255),
|
||||
metadata JSONB,
|
||||
created_at DOUBLE PRECISION DEFAULT EXTRACT(EPOCH FROM NOW())
|
||||
)
|
||||
""")
|
||||
|
||||
# Messages indexes
|
||||
cur.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}messages_session
|
||||
ON {self.messages_table}(session_id)
|
||||
""")
|
||||
cur.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}messages_created
|
||||
ON {self.messages_table}(session_id, created_at)
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"PostgreSQL tables created: {self.sessions_table}, {self.messages_table}")
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Create a new session."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"""
|
||||
INSERT INTO {self.sessions_table}
|
||||
(session_id, user_id, agent_id, name, state, metadata, created_at, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING session_id
|
||||
""", (
|
||||
session.session_id,
|
||||
session.user_id,
|
||||
session.agent_id,
|
||||
session.name,
|
||||
json.dumps(session.state) if session.state else None,
|
||||
json.dumps(session.metadata) if session.metadata else None,
|
||||
session.created_at,
|
||||
session.updated_at,
|
||||
))
|
||||
conn.commit()
|
||||
return session
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
"""Get a session by ID."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=self._RealDictCursor) as cur:
|
||||
cur.execute(f"""
|
||||
SELECT * FROM {self.sessions_table} WHERE session_id = %s
|
||||
""", (session_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return ConversationSession(
|
||||
session_id=row["session_id"],
|
||||
user_id=row["user_id"],
|
||||
agent_id=row["agent_id"],
|
||||
name=row["name"],
|
||||
state=row["state"],
|
||||
metadata=row["metadata"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Update an existing session."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"""
|
||||
UPDATE {self.sessions_table}
|
||||
SET user_id = %s, agent_id = %s, name = %s,
|
||||
state = %s, metadata = %s, updated_at = %s
|
||||
WHERE session_id = %s
|
||||
""", (
|
||||
session.user_id,
|
||||
session.agent_id,
|
||||
session.name,
|
||||
json.dumps(session.state) if session.state else None,
|
||||
json.dumps(session.metadata) if session.metadata else None,
|
||||
session.updated_at,
|
||||
session.session_id,
|
||||
))
|
||||
conn.commit()
|
||||
return session
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session and all its messages."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"""
|
||||
DELETE FROM {self.sessions_table} WHERE session_id = %s
|
||||
""", (session_id,))
|
||||
deleted = cur.rowcount > 0
|
||||
conn.commit()
|
||||
return deleted
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[ConversationSession]:
|
||||
"""List sessions, optionally filtered by user or agent."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=self._RealDictCursor) as cur:
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if user_id:
|
||||
conditions.append("user_id = %s")
|
||||
params.append(user_id)
|
||||
if agent_id:
|
||||
conditions.append("agent_id = %s")
|
||||
params.append(agent_id)
|
||||
|
||||
where_clause = ""
|
||||
if conditions:
|
||||
where_clause = "WHERE " + " AND ".join(conditions)
|
||||
|
||||
params.extend([limit, offset])
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT * FROM {self.sessions_table}
|
||||
{where_clause}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params)
|
||||
|
||||
return [
|
||||
ConversationSession(
|
||||
session_id=row["session_id"],
|
||||
user_id=row["user_id"],
|
||||
agent_id=row["agent_id"],
|
||||
name=row["name"],
|
||||
state=row["state"],
|
||||
metadata=row["metadata"],
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
"""Add a message to a session."""
|
||||
message.session_id = session_id
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"""
|
||||
INSERT INTO {self.messages_table}
|
||||
(id, session_id, role, content, tool_calls, tool_call_id, metadata, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
message.id,
|
||||
session_id,
|
||||
message.role,
|
||||
message.content,
|
||||
json.dumps(message.tool_calls) if message.tool_calls else None,
|
||||
message.tool_call_id,
|
||||
json.dumps(message.metadata) if message.metadata else None,
|
||||
message.created_at,
|
||||
))
|
||||
conn.commit()
|
||||
return message
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
before: Optional[float] = None,
|
||||
after: Optional[float] = None
|
||||
) -> List[ConversationMessage]:
|
||||
"""Get messages from a session."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=self._RealDictCursor) as cur:
|
||||
conditions = ["session_id = %s"]
|
||||
params: List[Any] = [session_id]
|
||||
|
||||
if before:
|
||||
conditions.append("created_at < %s")
|
||||
params.append(before)
|
||||
if after:
|
||||
conditions.append("created_at > %s")
|
||||
params.append(after)
|
||||
|
||||
where_clause = "WHERE " + " AND ".join(conditions)
|
||||
limit_clause = f"LIMIT {limit}" if limit else ""
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT * FROM {self.messages_table}
|
||||
{where_clause}
|
||||
ORDER BY created_at ASC
|
||||
{limit_clause}
|
||||
""", params)
|
||||
|
||||
return [
|
||||
ConversationMessage(
|
||||
id=row["id"],
|
||||
session_id=row["session_id"],
|
||||
role=row["role"],
|
||||
content=row["content"],
|
||||
tool_calls=row["tool_calls"],
|
||||
tool_call_id=row["tool_call_id"],
|
||||
metadata=row["metadata"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
"""Delete messages. If message_ids is None, delete all messages in session."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
if message_ids:
|
||||
placeholders = ",".join(["%s"] * len(message_ids))
|
||||
cur.execute(f"""
|
||||
DELETE FROM {self.messages_table}
|
||||
WHERE session_id = %s AND id IN ({placeholders})
|
||||
""", [session_id] + message_ids)
|
||||
else:
|
||||
cur.execute(f"""
|
||||
DELETE FROM {self.messages_table} WHERE session_id = %s
|
||||
""", (session_id,))
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
return deleted
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store and release resources."""
|
||||
if self._pool:
|
||||
self._pool.closeall()
|
||||
self._pool = None
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
SingleStore implementation of ConversationStore.
|
||||
|
||||
Requires: singlestoredb
|
||||
Install: pip install singlestoredb
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from .base import ConversationStore, ConversationSession, ConversationMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SingleStoreConversationStore(ConversationStore):
|
||||
"""
|
||||
SingleStore-based conversation store.
|
||||
|
||||
SingleStore is MySQL-compatible with additional vector capabilities.
|
||||
|
||||
Example:
|
||||
store = SingleStoreConversationStore(
|
||||
url="singlestoredb://user:password@host:3306/database"
|
||||
)
|
||||
"""
|
||||
|
||||
SCHEMA_VERSION = "1.0.0"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
host: str = "localhost",
|
||||
port: int = 3306,
|
||||
database: str = "praisonai",
|
||||
user: str = "root",
|
||||
password: str = "",
|
||||
table_prefix: str = "praison_",
|
||||
auto_create_tables: bool = True,
|
||||
):
|
||||
try:
|
||||
import singlestoredb as s2
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"singlestoredb is required for SingleStore support. "
|
||||
"Install with: pip install singlestoredb"
|
||||
)
|
||||
|
||||
self._s2 = s2
|
||||
self.table_prefix = table_prefix
|
||||
self.sessions_table = f"{table_prefix}sessions"
|
||||
self.messages_table = f"{table_prefix}messages"
|
||||
|
||||
if url:
|
||||
self._conn = s2.connect(url)
|
||||
else:
|
||||
self._conn = s2.connect(
|
||||
host=host,
|
||||
port=port,
|
||||
database=database,
|
||||
user=user,
|
||||
password=password,
|
||||
)
|
||||
|
||||
if auto_create_tables:
|
||||
self._create_tables()
|
||||
|
||||
def _create_tables(self) -> None:
|
||||
"""Create tables if they don't exist."""
|
||||
cur = self._conn.cursor()
|
||||
|
||||
cur.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.sessions_table} (
|
||||
session_id VARCHAR(255) PRIMARY KEY,
|
||||
user_id VARCHAR(255),
|
||||
agent_id VARCHAR(255),
|
||||
name VARCHAR(255),
|
||||
state JSON,
|
||||
metadata JSON,
|
||||
created_at DOUBLE,
|
||||
updated_at DOUBLE,
|
||||
KEY idx_user (user_id),
|
||||
KEY idx_agent (agent_id)
|
||||
)
|
||||
""")
|
||||
|
||||
cur.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.messages_table} (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
session_id VARCHAR(255) NOT NULL,
|
||||
role VARCHAR(50) NOT NULL,
|
||||
content TEXT,
|
||||
tool_calls JSON,
|
||||
tool_call_id VARCHAR(255),
|
||||
metadata JSON,
|
||||
created_at DOUBLE,
|
||||
KEY idx_session (session_id),
|
||||
KEY idx_created (session_id, created_at)
|
||||
)
|
||||
""")
|
||||
|
||||
self._conn.commit()
|
||||
logger.info(f"SingleStore tables created")
|
||||
|
||||
def create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
cur = self._conn.cursor()
|
||||
cur.execute(f"""
|
||||
INSERT INTO {self.sessions_table}
|
||||
(session_id, user_id, agent_id, name, state, metadata, created_at, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
session.session_id, session.user_id, session.agent_id, session.name,
|
||||
json.dumps(session.state) if session.state else None,
|
||||
json.dumps(session.metadata) if session.metadata else None,
|
||||
session.created_at, session.updated_at,
|
||||
))
|
||||
self._conn.commit()
|
||||
return session
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
cur = self._conn.cursor()
|
||||
cur.execute(f"SELECT * FROM {self.sessions_table} WHERE session_id = %s", (session_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
cols = [d[0] for d in cur.description]
|
||||
data = dict(zip(cols, row))
|
||||
return ConversationSession(
|
||||
session_id=data["session_id"], user_id=data["user_id"],
|
||||
agent_id=data["agent_id"], name=data["name"],
|
||||
state=json.loads(data["state"]) if data["state"] else None,
|
||||
metadata=json.loads(data["metadata"]) if data["metadata"] else None,
|
||||
created_at=data["created_at"], updated_at=data["updated_at"],
|
||||
)
|
||||
|
||||
def update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
cur = self._conn.cursor()
|
||||
cur.execute(f"""
|
||||
UPDATE {self.sessions_table}
|
||||
SET user_id=%s, agent_id=%s, name=%s, state=%s, metadata=%s, updated_at=%s
|
||||
WHERE session_id=%s
|
||||
""", (
|
||||
session.user_id, session.agent_id, session.name,
|
||||
json.dumps(session.state) if session.state else None,
|
||||
json.dumps(session.metadata) if session.metadata else None,
|
||||
session.updated_at, session.session_id,
|
||||
))
|
||||
self._conn.commit()
|
||||
return session
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
cur = self._conn.cursor()
|
||||
cur.execute(f"DELETE FROM {self.messages_table} WHERE session_id = %s", (session_id,))
|
||||
cur.execute(f"DELETE FROM {self.sessions_table} WHERE session_id = %s", (session_id,))
|
||||
self._conn.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
def list_sessions(self, user_id: Optional[str] = None, agent_id: Optional[str] = None,
|
||||
limit: int = 100, offset: int = 0) -> List[ConversationSession]:
|
||||
cur = self._conn.cursor()
|
||||
conditions, params = [], []
|
||||
if user_id:
|
||||
conditions.append("user_id = %s")
|
||||
params.append(user_id)
|
||||
if agent_id:
|
||||
conditions.append("agent_id = %s")
|
||||
params.append(agent_id)
|
||||
where = "WHERE " + " AND ".join(conditions) if conditions else ""
|
||||
params.extend([limit, offset])
|
||||
cur.execute(f"SELECT * FROM {self.sessions_table} {where} ORDER BY updated_at DESC LIMIT %s OFFSET %s", params)
|
||||
cols = [d[0] for d in cur.description]
|
||||
return [ConversationSession(
|
||||
session_id=dict(zip(cols, r))["session_id"],
|
||||
user_id=dict(zip(cols, r))["user_id"],
|
||||
agent_id=dict(zip(cols, r))["agent_id"],
|
||||
name=dict(zip(cols, r))["name"],
|
||||
state=json.loads(dict(zip(cols, r))["state"]) if dict(zip(cols, r))["state"] else None,
|
||||
metadata=json.loads(dict(zip(cols, r))["metadata"]) if dict(zip(cols, r))["metadata"] else None,
|
||||
created_at=dict(zip(cols, r))["created_at"],
|
||||
updated_at=dict(zip(cols, r))["updated_at"],
|
||||
) for r in cur.fetchall()]
|
||||
|
||||
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
message.session_id = session_id
|
||||
cur = self._conn.cursor()
|
||||
cur.execute(f"""
|
||||
INSERT INTO {self.messages_table}
|
||||
(id, session_id, role, content, tool_calls, tool_call_id, metadata, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
message.id, session_id, message.role, message.content,
|
||||
json.dumps(message.tool_calls) if message.tool_calls else None,
|
||||
message.tool_call_id,
|
||||
json.dumps(message.metadata) if message.metadata else None,
|
||||
message.created_at,
|
||||
))
|
||||
self._conn.commit()
|
||||
return message
|
||||
|
||||
def get_messages(self, session_id: str, limit: Optional[int] = None,
|
||||
before: Optional[float] = None, after: Optional[float] = None) -> List[ConversationMessage]:
|
||||
cur = self._conn.cursor()
|
||||
conditions, params = ["session_id = %s"], [session_id]
|
||||
if before:
|
||||
conditions.append("created_at < %s")
|
||||
params.append(before)
|
||||
if after:
|
||||
conditions.append("created_at > %s")
|
||||
params.append(after)
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
limit_clause = f"LIMIT {limit}" if limit else ""
|
||||
cur.execute(f"SELECT * FROM {self.messages_table} {where} ORDER BY created_at ASC {limit_clause}", params)
|
||||
cols = [d[0] for d in cur.description]
|
||||
return [ConversationMessage(
|
||||
id=dict(zip(cols, r))["id"],
|
||||
session_id=dict(zip(cols, r))["session_id"],
|
||||
role=dict(zip(cols, r))["role"],
|
||||
content=dict(zip(cols, r))["content"],
|
||||
tool_calls=json.loads(dict(zip(cols, r))["tool_calls"]) if dict(zip(cols, r))["tool_calls"] else None,
|
||||
tool_call_id=dict(zip(cols, r))["tool_call_id"],
|
||||
metadata=json.loads(dict(zip(cols, r))["metadata"]) if dict(zip(cols, r))["metadata"] else None,
|
||||
created_at=dict(zip(cols, r))["created_at"],
|
||||
) for r in cur.fetchall()]
|
||||
|
||||
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
cur = self._conn.cursor()
|
||||
if message_ids:
|
||||
placeholders = ",".join(["%s"] * len(message_ids))
|
||||
cur.execute(f"DELETE FROM {self.messages_table} WHERE session_id = %s AND id IN ({placeholders})",
|
||||
[session_id] + message_ids)
|
||||
else:
|
||||
cur.execute(f"DELETE FROM {self.messages_table} WHERE session_id = %s", (session_id,))
|
||||
self._conn.commit()
|
||||
return cur.rowcount
|
||||
|
||||
def close(self) -> None:
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
SQLite implementation of ConversationStore.
|
||||
|
||||
Zero external dependencies - uses built-in sqlite3 module.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
import threading
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from .base import ConversationStore, ConversationSession, ConversationMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SQLiteConversationStore(ConversationStore):
|
||||
"""
|
||||
SQLite-based conversation store.
|
||||
|
||||
Zero external dependencies - uses Python's built-in sqlite3.
|
||||
|
||||
Example:
|
||||
store = SQLiteConversationStore(
|
||||
path="./praisonai.db"
|
||||
)
|
||||
"""
|
||||
|
||||
SCHEMA_VERSION = "1.0.0"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str = "praisonai_conversations.db",
|
||||
table_prefix: str = "praison_",
|
||||
auto_create_tables: bool = True,
|
||||
check_same_thread: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize SQLite conversation store.
|
||||
|
||||
Args:
|
||||
path: Path to SQLite database file
|
||||
table_prefix: Prefix for table names
|
||||
auto_create_tables: Create tables if they don't exist
|
||||
check_same_thread: SQLite check_same_thread parameter
|
||||
"""
|
||||
self.path = path
|
||||
self.table_prefix = table_prefix
|
||||
self.sessions_table = f"{table_prefix}sessions"
|
||||
self.messages_table = f"{table_prefix}messages"
|
||||
self._check_same_thread = check_same_thread
|
||||
self._local = threading.local()
|
||||
|
||||
if auto_create_tables:
|
||||
self._create_tables()
|
||||
|
||||
def _get_conn(self) -> sqlite3.Connection:
|
||||
"""Get thread-local connection."""
|
||||
if not hasattr(self._local, "conn") or self._local.conn is None:
|
||||
self._local.conn = sqlite3.connect(
|
||||
self.path,
|
||||
check_same_thread=self._check_same_thread
|
||||
)
|
||||
self._local.conn.row_factory = sqlite3.Row
|
||||
return self._local.conn
|
||||
|
||||
def _create_tables(self) -> None:
|
||||
"""Create tables if they don't exist."""
|
||||
conn = self._get_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Sessions table
|
||||
cur.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.sessions_table} (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
agent_id TEXT,
|
||||
name TEXT,
|
||||
state TEXT,
|
||||
metadata TEXT,
|
||||
created_at REAL,
|
||||
updated_at REAL
|
||||
)
|
||||
""")
|
||||
|
||||
# Sessions indexes
|
||||
cur.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}sessions_user
|
||||
ON {self.sessions_table}(user_id)
|
||||
""")
|
||||
cur.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}sessions_agent
|
||||
ON {self.sessions_table}(agent_id)
|
||||
""")
|
||||
|
||||
# Messages table
|
||||
cur.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {self.messages_table} (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_calls TEXT,
|
||||
tool_call_id TEXT,
|
||||
metadata TEXT,
|
||||
created_at REAL,
|
||||
FOREIGN KEY (session_id) REFERENCES {self.sessions_table}(session_id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
# Messages indexes
|
||||
cur.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}messages_session
|
||||
ON {self.messages_table}(session_id)
|
||||
""")
|
||||
cur.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}messages_created
|
||||
ON {self.messages_table}(session_id, created_at)
|
||||
""")
|
||||
|
||||
# Enable foreign keys
|
||||
cur.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"SQLite tables created: {self.sessions_table}, {self.messages_table}")
|
||||
|
||||
def create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Create a new session."""
|
||||
conn = self._get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"""
|
||||
INSERT INTO {self.sessions_table}
|
||||
(session_id, user_id, agent_id, name, state, metadata, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
session.session_id,
|
||||
session.user_id,
|
||||
session.agent_id,
|
||||
session.name,
|
||||
json.dumps(session.state) if session.state else None,
|
||||
json.dumps(session.metadata) if session.metadata else None,
|
||||
session.created_at,
|
||||
session.updated_at,
|
||||
))
|
||||
conn.commit()
|
||||
return session
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
"""Get a session by ID."""
|
||||
conn = self._get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"""
|
||||
SELECT * FROM {self.sessions_table} WHERE session_id = ?
|
||||
""", (session_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return ConversationSession(
|
||||
session_id=row["session_id"],
|
||||
user_id=row["user_id"],
|
||||
agent_id=row["agent_id"],
|
||||
name=row["name"],
|
||||
state=json.loads(row["state"]) if row["state"] else None,
|
||||
metadata=json.loads(row["metadata"]) if row["metadata"] else None,
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
def update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Update an existing session."""
|
||||
conn = self._get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"""
|
||||
UPDATE {self.sessions_table}
|
||||
SET user_id = ?, agent_id = ?, name = ?,
|
||||
state = ?, metadata = ?, updated_at = ?
|
||||
WHERE session_id = ?
|
||||
""", (
|
||||
session.user_id,
|
||||
session.agent_id,
|
||||
session.name,
|
||||
json.dumps(session.state) if session.state else None,
|
||||
json.dumps(session.metadata) if session.metadata else None,
|
||||
session.updated_at,
|
||||
session.session_id,
|
||||
))
|
||||
conn.commit()
|
||||
return session
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session and all its messages."""
|
||||
conn = self._get_conn()
|
||||
cur = conn.cursor()
|
||||
# Delete messages first (foreign key)
|
||||
cur.execute(f"DELETE FROM {self.messages_table} WHERE session_id = ?", (session_id,))
|
||||
cur.execute(f"DELETE FROM {self.sessions_table} WHERE session_id = ?", (session_id,))
|
||||
deleted = cur.rowcount > 0
|
||||
conn.commit()
|
||||
return deleted
|
||||
|
||||
def list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[ConversationSession]:
|
||||
"""List sessions, optionally filtered by user or agent."""
|
||||
conn = self._get_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
conditions = []
|
||||
params: List[Any] = []
|
||||
|
||||
if user_id:
|
||||
conditions.append("user_id = ?")
|
||||
params.append(user_id)
|
||||
if agent_id:
|
||||
conditions.append("agent_id = ?")
|
||||
params.append(agent_id)
|
||||
|
||||
where_clause = ""
|
||||
if conditions:
|
||||
where_clause = "WHERE " + " AND ".join(conditions)
|
||||
|
||||
params.extend([limit, offset])
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT * FROM {self.sessions_table}
|
||||
{where_clause}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""", params)
|
||||
|
||||
return [
|
||||
ConversationSession(
|
||||
session_id=row["session_id"],
|
||||
user_id=row["user_id"],
|
||||
agent_id=row["agent_id"],
|
||||
name=row["name"],
|
||||
state=json.loads(row["state"]) if row["state"] else None,
|
||||
metadata=json.loads(row["metadata"]) if row["metadata"] else None,
|
||||
created_at=row["created_at"],
|
||||
updated_at=row["updated_at"],
|
||||
)
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
|
||||
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
"""Add a message to a session."""
|
||||
message.session_id = session_id
|
||||
conn = self._get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(f"""
|
||||
INSERT INTO {self.messages_table}
|
||||
(id, session_id, role, content, tool_calls, tool_call_id, metadata, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
message.id,
|
||||
session_id,
|
||||
message.role,
|
||||
message.content,
|
||||
json.dumps(message.tool_calls) if message.tool_calls else None,
|
||||
message.tool_call_id,
|
||||
json.dumps(message.metadata) if message.metadata else None,
|
||||
message.created_at,
|
||||
))
|
||||
conn.commit()
|
||||
return message
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
before: Optional[float] = None,
|
||||
after: Optional[float] = None
|
||||
) -> List[ConversationMessage]:
|
||||
"""Get messages from a session."""
|
||||
conn = self._get_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
conditions = ["session_id = ?"]
|
||||
params: List[Any] = [session_id]
|
||||
|
||||
if before:
|
||||
conditions.append("created_at < ?")
|
||||
params.append(before)
|
||||
if after:
|
||||
conditions.append("created_at > ?")
|
||||
params.append(after)
|
||||
|
||||
where_clause = "WHERE " + " AND ".join(conditions)
|
||||
limit_clause = f"LIMIT {limit}" if limit else ""
|
||||
|
||||
cur.execute(f"""
|
||||
SELECT * FROM {self.messages_table}
|
||||
{where_clause}
|
||||
ORDER BY created_at ASC
|
||||
{limit_clause}
|
||||
""", params)
|
||||
|
||||
return [
|
||||
ConversationMessage(
|
||||
id=row["id"],
|
||||
session_id=row["session_id"],
|
||||
role=row["role"],
|
||||
content=row["content"],
|
||||
tool_calls=json.loads(row["tool_calls"]) if row["tool_calls"] else None,
|
||||
tool_call_id=row["tool_call_id"],
|
||||
metadata=json.loads(row["metadata"]) if row["metadata"] else None,
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
|
||||
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
"""Delete messages."""
|
||||
conn = self._get_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
if message_ids:
|
||||
placeholders = ",".join(["?"] * len(message_ids))
|
||||
cur.execute(f"""
|
||||
DELETE FROM {self.messages_table}
|
||||
WHERE session_id = ? AND id IN ({placeholders})
|
||||
""", [session_id] + message_ids)
|
||||
else:
|
||||
cur.execute(f"""
|
||||
DELETE FROM {self.messages_table} WHERE session_id = ?
|
||||
""", (session_id,))
|
||||
|
||||
deleted = cur.rowcount
|
||||
conn.commit()
|
||||
return deleted
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
if hasattr(self._local, "conn") and self._local.conn:
|
||||
self._local.conn.close()
|
||||
self._local.conn = None
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Supabase implementation of ConversationStore.
|
||||
|
||||
Requires: supabase
|
||||
Install: pip install supabase
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import ConversationStore, ConversationSession, ConversationMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SupabaseConversationStore(ConversationStore):
|
||||
"""
|
||||
Supabase-based conversation store.
|
||||
|
||||
Uses Supabase's PostgreSQL backend via REST API.
|
||||
|
||||
Example:
|
||||
store = SupabaseConversationStore(
|
||||
url="https://xxx.supabase.co",
|
||||
key="your-anon-key"
|
||||
)
|
||||
"""
|
||||
|
||||
SCHEMA_VERSION = "1.0.0"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
key: Optional[str] = None,
|
||||
table_prefix: str = "praison_",
|
||||
auto_create_tables: bool = False, # Tables should be created via Supabase dashboard
|
||||
):
|
||||
"""
|
||||
Initialize Supabase conversation store.
|
||||
|
||||
Args:
|
||||
url: Supabase project URL
|
||||
key: Supabase anon/service key
|
||||
table_prefix: Prefix for table names
|
||||
auto_create_tables: Not supported - use Supabase dashboard
|
||||
"""
|
||||
try:
|
||||
from supabase import create_client, Client
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"supabase is required for Supabase support. "
|
||||
"Install with: pip install supabase"
|
||||
)
|
||||
|
||||
import os
|
||||
url = url or os.getenv("SUPABASE_URL")
|
||||
key = key or os.getenv("SUPABASE_KEY")
|
||||
|
||||
if not url or not key:
|
||||
raise ValueError("Supabase URL and key are required")
|
||||
|
||||
self._client: Client = create_client(url, key)
|
||||
self.table_prefix = table_prefix
|
||||
self.sessions_table = f"{table_prefix}sessions"
|
||||
self.messages_table = f"{table_prefix}messages"
|
||||
|
||||
if auto_create_tables:
|
||||
logger.warning("auto_create_tables not supported for Supabase. Create tables via dashboard.")
|
||||
|
||||
def create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
data = {
|
||||
"session_id": session.session_id,
|
||||
"user_id": session.user_id,
|
||||
"agent_id": session.agent_id,
|
||||
"name": session.name,
|
||||
"state": session.state,
|
||||
"metadata": session.metadata,
|
||||
"created_at": session.created_at,
|
||||
"updated_at": session.updated_at,
|
||||
}
|
||||
self._client.table(self.sessions_table).insert(data).execute()
|
||||
return session
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
result = self._client.table(self.sessions_table).select("*").eq("session_id", session_id).execute()
|
||||
if not result.data:
|
||||
return None
|
||||
row = result.data[0]
|
||||
return ConversationSession(
|
||||
session_id=row["session_id"],
|
||||
user_id=row.get("user_id"),
|
||||
agent_id=row.get("agent_id"),
|
||||
name=row.get("name"),
|
||||
state=row.get("state"),
|
||||
metadata=row.get("metadata"),
|
||||
created_at=row.get("created_at"),
|
||||
updated_at=row.get("updated_at"),
|
||||
)
|
||||
|
||||
def update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
data = {
|
||||
"user_id": session.user_id,
|
||||
"agent_id": session.agent_id,
|
||||
"name": session.name,
|
||||
"state": session.state,
|
||||
"metadata": session.metadata,
|
||||
"updated_at": session.updated_at,
|
||||
}
|
||||
self._client.table(self.sessions_table).update(data).eq("session_id", session.session_id).execute()
|
||||
return session
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
# Delete messages first
|
||||
self._client.table(self.messages_table).delete().eq("session_id", session_id).execute()
|
||||
result = self._client.table(self.sessions_table).delete().eq("session_id", session_id).execute()
|
||||
return len(result.data) > 0 if result.data else False
|
||||
|
||||
def list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[ConversationSession]:
|
||||
query = self._client.table(self.sessions_table).select("*")
|
||||
if user_id:
|
||||
query = query.eq("user_id", user_id)
|
||||
if agent_id:
|
||||
query = query.eq("agent_id", agent_id)
|
||||
query = query.order("updated_at", desc=True).range(offset, offset + limit - 1)
|
||||
result = query.execute()
|
||||
|
||||
return [
|
||||
ConversationSession(
|
||||
session_id=row["session_id"],
|
||||
user_id=row.get("user_id"),
|
||||
agent_id=row.get("agent_id"),
|
||||
name=row.get("name"),
|
||||
state=row.get("state"),
|
||||
metadata=row.get("metadata"),
|
||||
created_at=row.get("created_at"),
|
||||
updated_at=row.get("updated_at"),
|
||||
)
|
||||
for row in (result.data or [])
|
||||
]
|
||||
|
||||
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
message.session_id = session_id
|
||||
data = {
|
||||
"id": message.id,
|
||||
"session_id": session_id,
|
||||
"role": message.role,
|
||||
"content": message.content,
|
||||
"tool_calls": message.tool_calls,
|
||||
"tool_call_id": message.tool_call_id,
|
||||
"metadata": message.metadata,
|
||||
"created_at": message.created_at,
|
||||
}
|
||||
self._client.table(self.messages_table).insert(data).execute()
|
||||
return message
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
before: Optional[float] = None,
|
||||
after: Optional[float] = None
|
||||
) -> List[ConversationMessage]:
|
||||
query = self._client.table(self.messages_table).select("*").eq("session_id", session_id)
|
||||
if before:
|
||||
query = query.lt("created_at", before)
|
||||
if after:
|
||||
query = query.gt("created_at", after)
|
||||
query = query.order("created_at", desc=False)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
result = query.execute()
|
||||
|
||||
return [
|
||||
ConversationMessage(
|
||||
id=row["id"],
|
||||
session_id=row["session_id"],
|
||||
role=row["role"],
|
||||
content=row.get("content", ""),
|
||||
tool_calls=row.get("tool_calls"),
|
||||
tool_call_id=row.get("tool_call_id"),
|
||||
metadata=row.get("metadata"),
|
||||
created_at=row.get("created_at"),
|
||||
)
|
||||
for row in (result.data or [])
|
||||
]
|
||||
|
||||
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
if message_ids:
|
||||
result = self._client.table(self.messages_table).delete().eq("session_id", session_id).in_("id", message_ids).execute()
|
||||
else:
|
||||
result = self._client.table(self.messages_table).delete().eq("session_id", session_id).execute()
|
||||
return len(result.data) if result.data else 0
|
||||
|
||||
def close(self) -> None:
|
||||
pass # Supabase client doesn't need explicit close
|
||||
@@ -0,0 +1,287 @@
|
||||
"""
|
||||
SurrealDB implementation of ConversationStore.
|
||||
|
||||
Requires: surrealdb
|
||||
Install: pip install surrealdb
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from .base import ConversationStore, ConversationSession, ConversationMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SurrealDBConversationStore(ConversationStore):
|
||||
"""
|
||||
SurrealDB-based conversation store.
|
||||
|
||||
SurrealDB is a multi-model database supporting SQL-like queries.
|
||||
|
||||
Example:
|
||||
store = SurrealDBConversationStore(
|
||||
url="ws://localhost:8000/rpc",
|
||||
namespace="praisonai",
|
||||
database="conversations"
|
||||
)
|
||||
"""
|
||||
|
||||
SCHEMA_VERSION = "1.0.0"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "ws://localhost:8000/rpc",
|
||||
namespace: str = "praisonai",
|
||||
database: str = "conversations",
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
table_prefix: str = "praison_",
|
||||
auto_create_tables: bool = True,
|
||||
):
|
||||
try:
|
||||
from surrealdb import Surreal
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"surrealdb is required for SurrealDB support. "
|
||||
"Install with: pip install surrealdb"
|
||||
)
|
||||
|
||||
self._Surreal = Surreal
|
||||
self.url = url
|
||||
self.namespace = namespace
|
||||
self.database = database
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.table_prefix = table_prefix
|
||||
self.sessions_table = f"{table_prefix}sessions"
|
||||
self.messages_table = f"{table_prefix}messages"
|
||||
self._client = None
|
||||
|
||||
# Note: SurrealDB Python client is async, we'll use sync wrapper
|
||||
self._init_sync()
|
||||
|
||||
def _init_sync(self):
|
||||
"""Initialize synchronously using asyncio."""
|
||||
import asyncio
|
||||
|
||||
async def _init():
|
||||
self._client = self._Surreal(self.url)
|
||||
await self._client.connect()
|
||||
if self.username and self.password:
|
||||
await self._client.signin({"user": self.username, "pass": self.password})
|
||||
await self._client.use(self.namespace, self.database)
|
||||
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
loop.run_until_complete(_init())
|
||||
|
||||
def _run_sync(self, coro):
|
||||
"""Run async coroutine synchronously."""
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
return loop.run_until_complete(coro)
|
||||
|
||||
def create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
data = {
|
||||
"session_id": session.session_id,
|
||||
"user_id": session.user_id,
|
||||
"agent_id": session.agent_id,
|
||||
"name": session.name,
|
||||
"state": session.state,
|
||||
"metadata": session.metadata,
|
||||
"created_at": session.created_at,
|
||||
"updated_at": session.updated_at,
|
||||
}
|
||||
self._run_sync(self._client.create(self.sessions_table, data))
|
||||
return session
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
result = self._run_sync(
|
||||
self._client.query(
|
||||
f"SELECT * FROM {self.sessions_table} WHERE session_id = $sid",
|
||||
{"sid": session_id}
|
||||
)
|
||||
)
|
||||
if not result or not result[0].get("result"):
|
||||
return None
|
||||
row = result[0]["result"][0]
|
||||
return ConversationSession(
|
||||
session_id=row["session_id"],
|
||||
user_id=row.get("user_id"),
|
||||
agent_id=row.get("agent_id"),
|
||||
name=row.get("name"),
|
||||
state=row.get("state"),
|
||||
metadata=row.get("metadata"),
|
||||
created_at=row.get("created_at"),
|
||||
updated_at=row.get("updated_at"),
|
||||
)
|
||||
|
||||
def update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
self._run_sync(
|
||||
self._client.query(
|
||||
f"""UPDATE {self.sessions_table} SET
|
||||
user_id = $user_id, agent_id = $agent_id, name = $name,
|
||||
state = $state, metadata = $metadata, updated_at = $updated_at
|
||||
WHERE session_id = $session_id""",
|
||||
{
|
||||
"session_id": session.session_id,
|
||||
"user_id": session.user_id,
|
||||
"agent_id": session.agent_id,
|
||||
"name": session.name,
|
||||
"state": session.state,
|
||||
"metadata": session.metadata,
|
||||
"updated_at": session.updated_at,
|
||||
}
|
||||
)
|
||||
)
|
||||
return session
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
self._run_sync(
|
||||
self._client.query(
|
||||
f"DELETE FROM {self.messages_table} WHERE session_id = $sid",
|
||||
{"sid": session_id}
|
||||
)
|
||||
)
|
||||
result = self._run_sync(
|
||||
self._client.query(
|
||||
f"DELETE FROM {self.sessions_table} WHERE session_id = $sid",
|
||||
{"sid": session_id}
|
||||
)
|
||||
)
|
||||
return True
|
||||
|
||||
def list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[ConversationSession]:
|
||||
conditions = []
|
||||
params = {"limit": limit, "offset": offset}
|
||||
|
||||
if user_id:
|
||||
conditions.append("user_id = $user_id")
|
||||
params["user_id"] = user_id
|
||||
if agent_id:
|
||||
conditions.append("agent_id = $agent_id")
|
||||
params["agent_id"] = agent_id
|
||||
|
||||
where = "WHERE " + " AND ".join(conditions) if conditions else ""
|
||||
|
||||
result = self._run_sync(
|
||||
self._client.query(
|
||||
f"SELECT * FROM {self.sessions_table} {where} ORDER BY updated_at DESC LIMIT $limit START $offset",
|
||||
params
|
||||
)
|
||||
)
|
||||
|
||||
if not result or not result[0].get("result"):
|
||||
return []
|
||||
|
||||
return [
|
||||
ConversationSession(
|
||||
session_id=row["session_id"],
|
||||
user_id=row.get("user_id"),
|
||||
agent_id=row.get("agent_id"),
|
||||
name=row.get("name"),
|
||||
state=row.get("state"),
|
||||
metadata=row.get("metadata"),
|
||||
created_at=row.get("created_at"),
|
||||
updated_at=row.get("updated_at"),
|
||||
)
|
||||
for row in result[0]["result"]
|
||||
]
|
||||
|
||||
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
message.session_id = session_id
|
||||
data = {
|
||||
"id": message.id,
|
||||
"session_id": session_id,
|
||||
"role": message.role,
|
||||
"content": message.content,
|
||||
"tool_calls": message.tool_calls,
|
||||
"tool_call_id": message.tool_call_id,
|
||||
"metadata": message.metadata,
|
||||
"created_at": message.created_at,
|
||||
}
|
||||
self._run_sync(self._client.create(self.messages_table, data))
|
||||
return message
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
before: Optional[float] = None,
|
||||
after: Optional[float] = None
|
||||
) -> List[ConversationMessage]:
|
||||
conditions = ["session_id = $session_id"]
|
||||
params = {"session_id": session_id}
|
||||
|
||||
if before:
|
||||
conditions.append("created_at < $before")
|
||||
params["before"] = before
|
||||
if after:
|
||||
conditions.append("created_at > $after")
|
||||
params["after"] = after
|
||||
|
||||
where = "WHERE " + " AND ".join(conditions)
|
||||
limit_clause = f"LIMIT {limit}" if limit else ""
|
||||
|
||||
result = self._run_sync(
|
||||
self._client.query(
|
||||
f"SELECT * FROM {self.messages_table} {where} ORDER BY created_at ASC {limit_clause}",
|
||||
params
|
||||
)
|
||||
)
|
||||
|
||||
if not result or not result[0].get("result"):
|
||||
return []
|
||||
|
||||
return [
|
||||
ConversationMessage(
|
||||
id=row["id"],
|
||||
session_id=row["session_id"],
|
||||
role=row["role"],
|
||||
content=row.get("content", ""),
|
||||
tool_calls=row.get("tool_calls"),
|
||||
tool_call_id=row.get("tool_call_id"),
|
||||
metadata=row.get("metadata"),
|
||||
created_at=row.get("created_at"),
|
||||
)
|
||||
for row in result[0]["result"]
|
||||
]
|
||||
|
||||
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
if message_ids:
|
||||
self._run_sync(
|
||||
self._client.query(
|
||||
f"DELETE FROM {self.messages_table} WHERE session_id = $sid AND id IN $ids",
|
||||
{"sid": session_id, "ids": message_ids}
|
||||
)
|
||||
)
|
||||
return len(message_ids)
|
||||
else:
|
||||
self._run_sync(
|
||||
self._client.query(
|
||||
f"DELETE FROM {self.messages_table} WHERE session_id = $sid",
|
||||
{"sid": session_id}
|
||||
)
|
||||
)
|
||||
return -1
|
||||
|
||||
def close(self) -> None:
|
||||
if self._client:
|
||||
self._run_sync(self._client.close())
|
||||
self._client = None
|
||||
@@ -0,0 +1,234 @@
|
||||
"""
|
||||
Factory functions for creating store instances.
|
||||
|
||||
Provides lazy loading of backend implementations to avoid importing
|
||||
unused dependencies.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from .config import PersistenceConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def create_conversation_store(
|
||||
backend: str,
|
||||
url: Optional[str] = None,
|
||||
**options: Any
|
||||
):
|
||||
"""
|
||||
Create a ConversationStore instance.
|
||||
|
||||
Args:
|
||||
backend: Backend type (postgres, mysql, sqlite, singlestore, supabase, surrealdb)
|
||||
url: Connection URL
|
||||
**options: Backend-specific options
|
||||
|
||||
Returns:
|
||||
ConversationStore instance
|
||||
|
||||
Example:
|
||||
store = create_conversation_store(
|
||||
"postgres",
|
||||
url="postgresql://localhost:5432/praisonai"
|
||||
)
|
||||
"""
|
||||
backend = backend.lower()
|
||||
|
||||
if backend == "postgres":
|
||||
from .conversation.postgres import PostgresConversationStore
|
||||
return PostgresConversationStore(url=url, **options)
|
||||
|
||||
elif backend == "mysql":
|
||||
from .conversation.mysql import MySQLConversationStore
|
||||
return MySQLConversationStore(url=url, **options)
|
||||
|
||||
elif backend == "sqlite":
|
||||
from .conversation.sqlite import SQLiteConversationStore
|
||||
path = url or options.pop("path", None)
|
||||
return SQLiteConversationStore(path=path, **options)
|
||||
|
||||
elif backend == "singlestore":
|
||||
from .conversation.singlestore import SingleStoreConversationStore
|
||||
return SingleStoreConversationStore(url=url, **options)
|
||||
|
||||
elif backend == "supabase":
|
||||
from .conversation.supabase import SupabaseConversationStore
|
||||
return SupabaseConversationStore(url=url, **options)
|
||||
|
||||
elif backend == "surrealdb":
|
||||
from .conversation.surrealdb import SurrealDBConversationStore
|
||||
return SurrealDBConversationStore(url=url, **options)
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown conversation store backend: {backend}. "
|
||||
f"Supported: postgres, mysql, sqlite, singlestore, supabase, surrealdb"
|
||||
)
|
||||
|
||||
|
||||
def create_knowledge_store(
|
||||
backend: str,
|
||||
url: Optional[str] = None,
|
||||
**options: Any
|
||||
):
|
||||
"""
|
||||
Create a KnowledgeStore instance.
|
||||
|
||||
Args:
|
||||
backend: Backend type (qdrant, pinecone, chroma, weaviate, lancedb, milvus, pgvector, redis, cassandra, clickhouse)
|
||||
url: Connection URL
|
||||
**options: Backend-specific options
|
||||
|
||||
Returns:
|
||||
KnowledgeStore instance
|
||||
|
||||
Example:
|
||||
store = create_knowledge_store(
|
||||
"qdrant",
|
||||
url="http://localhost:6333"
|
||||
)
|
||||
"""
|
||||
backend = backend.lower()
|
||||
|
||||
if backend == "qdrant":
|
||||
from .knowledge.qdrant import QdrantKnowledgeStore
|
||||
return QdrantKnowledgeStore(url=url, **options)
|
||||
|
||||
elif backend == "pinecone":
|
||||
from .knowledge.pinecone import PineconeKnowledgeStore
|
||||
return PineconeKnowledgeStore(**options)
|
||||
|
||||
elif backend == "chroma":
|
||||
from .knowledge.chroma import ChromaKnowledgeStore
|
||||
path = url or options.pop("path", None)
|
||||
return ChromaKnowledgeStore(path=path, **options)
|
||||
|
||||
elif backend == "weaviate":
|
||||
from .knowledge.weaviate import WeaviateKnowledgeStore
|
||||
return WeaviateKnowledgeStore(url=url, **options)
|
||||
|
||||
elif backend == "lancedb":
|
||||
from .knowledge.lancedb import LanceDBKnowledgeStore
|
||||
path = url or options.pop("path", None)
|
||||
return LanceDBKnowledgeStore(path=path, **options)
|
||||
|
||||
elif backend == "milvus":
|
||||
from .knowledge.milvus import MilvusKnowledgeStore
|
||||
return MilvusKnowledgeStore(url=url, **options)
|
||||
|
||||
elif backend == "pgvector":
|
||||
from .knowledge.pgvector import PGVectorKnowledgeStore
|
||||
return PGVectorKnowledgeStore(url=url, **options)
|
||||
|
||||
elif backend == "redis":
|
||||
from .knowledge.redis_vector import RedisVectorKnowledgeStore
|
||||
return RedisVectorKnowledgeStore(url=url, **options)
|
||||
|
||||
elif backend == "cassandra":
|
||||
from .knowledge.cassandra import CassandraKnowledgeStore
|
||||
return CassandraKnowledgeStore(**options)
|
||||
|
||||
elif backend == "clickhouse":
|
||||
from .knowledge.clickhouse import ClickHouseKnowledgeStore
|
||||
return ClickHouseKnowledgeStore(**options)
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown knowledge store backend: {backend}. "
|
||||
f"Supported: qdrant, pinecone, chroma, weaviate, lancedb, milvus, pgvector, redis, cassandra, clickhouse"
|
||||
)
|
||||
|
||||
|
||||
def create_state_store(
|
||||
backend: str,
|
||||
url: Optional[str] = None,
|
||||
**options: Any
|
||||
):
|
||||
"""
|
||||
Create a StateStore instance.
|
||||
|
||||
Args:
|
||||
backend: Backend type (redis, dynamodb, firestore, mongodb, upstash, memory)
|
||||
url: Connection URL
|
||||
**options: Backend-specific options
|
||||
|
||||
Returns:
|
||||
StateStore instance
|
||||
|
||||
Example:
|
||||
store = create_state_store(
|
||||
"redis",
|
||||
url="redis://localhost:6379"
|
||||
)
|
||||
"""
|
||||
backend = backend.lower()
|
||||
|
||||
if backend == "redis":
|
||||
from .state.redis import RedisStateStore
|
||||
return RedisStateStore(url=url, **options)
|
||||
|
||||
elif backend == "dynamodb":
|
||||
from .state.dynamodb import DynamoDBStateStore
|
||||
return DynamoDBStateStore(**options)
|
||||
|
||||
elif backend == "firestore":
|
||||
from .state.firestore import FirestoreStateStore
|
||||
return FirestoreStateStore(**options)
|
||||
|
||||
elif backend == "mongodb":
|
||||
from .state.mongodb import MongoDBStateStore
|
||||
return MongoDBStateStore(url=url, **options)
|
||||
|
||||
elif backend == "upstash":
|
||||
from .state.upstash import UpstashStateStore
|
||||
return UpstashStateStore(url=url, **options)
|
||||
|
||||
elif backend == "memory":
|
||||
from .state.memory import MemoryStateStore
|
||||
return MemoryStateStore(**options)
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown state store backend: {backend}. "
|
||||
f"Supported: redis, dynamodb, firestore, mongodb, upstash, memory"
|
||||
)
|
||||
|
||||
|
||||
def create_stores_from_config(config: PersistenceConfig) -> Dict[str, Any]:
|
||||
"""
|
||||
Create all configured stores from a PersistenceConfig.
|
||||
|
||||
Returns:
|
||||
Dict with keys: conversation, knowledge, state (values may be None)
|
||||
"""
|
||||
stores = {
|
||||
"conversation": None,
|
||||
"knowledge": None,
|
||||
"state": None,
|
||||
}
|
||||
|
||||
if config.conversation_store:
|
||||
stores["conversation"] = create_conversation_store(
|
||||
config.conversation_store,
|
||||
url=config.conversation_url,
|
||||
**config.conversation_options
|
||||
)
|
||||
|
||||
if config.knowledge_store:
|
||||
stores["knowledge"] = create_knowledge_store(
|
||||
config.knowledge_store,
|
||||
url=config.knowledge_url,
|
||||
**config.knowledge_options
|
||||
)
|
||||
|
||||
if config.state_store:
|
||||
stores["state"] = create_state_store(
|
||||
config.state_store,
|
||||
url=config.state_url,
|
||||
**config.state_options
|
||||
)
|
||||
|
||||
return stores
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
Agent hooks for automatic persistence integration.
|
||||
|
||||
Provides wrapper functions to add persistence capabilities to PraisonAI agents
|
||||
without modifying the core SDK.
|
||||
"""
|
||||
|
||||
from .agent_hooks import (
|
||||
wrap_agent_with_persistence,
|
||||
PersistentAgent,
|
||||
create_persistent_session,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"wrap_agent_with_persistence",
|
||||
"PersistentAgent",
|
||||
"create_persistent_session",
|
||||
]
|
||||
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
Agent hooks for automatic persistence integration.
|
||||
|
||||
Provides wrapper functions to add persistence capabilities to PraisonAI agents
|
||||
without modifying the core SDK.
|
||||
"""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
from functools import wraps
|
||||
|
||||
from ..orchestrator import PersistenceOrchestrator
|
||||
from ..conversation.base import ConversationMessage, ConversationSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def wrap_agent_with_persistence(
|
||||
agent,
|
||||
orchestrator: PersistenceOrchestrator,
|
||||
session_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
auto_resume: bool = True,
|
||||
):
|
||||
"""
|
||||
Wrap an agent to add automatic persistence hooks.
|
||||
|
||||
This function wraps the agent's chat method to automatically:
|
||||
1. Load/create session on first message
|
||||
2. Persist each user message
|
||||
3. Persist each assistant response
|
||||
4. Update session metadata after each interaction
|
||||
|
||||
Args:
|
||||
agent: PraisonAI Agent instance
|
||||
orchestrator: PersistenceOrchestrator instance
|
||||
session_id: Session ID (auto-generated if None)
|
||||
user_id: User ID for the session
|
||||
auto_resume: Whether to auto-resume existing session
|
||||
|
||||
Returns:
|
||||
The wrapped agent with persistence capabilities
|
||||
|
||||
Example:
|
||||
from praisonai.persistence import PersistenceOrchestrator
|
||||
from praisonai.persistence.hooks import wrap_agent_with_persistence
|
||||
from praisonaiagents import Agent
|
||||
|
||||
orchestrator = PersistenceOrchestrator(...)
|
||||
agent = Agent(name="Assistant", role="Helper")
|
||||
|
||||
# Wrap agent with persistence
|
||||
agent = wrap_agent_with_persistence(agent, orchestrator, session_id="my-session")
|
||||
|
||||
# Now all chats are automatically persisted
|
||||
response = agent.chat("Hello!")
|
||||
"""
|
||||
session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
|
||||
user_id = user_id or getattr(agent, 'user_id', 'default')
|
||||
|
||||
# Store original chat method
|
||||
original_chat = agent.chat
|
||||
_session_initialized = [False] # Use list to allow mutation in closure
|
||||
|
||||
@wraps(original_chat)
|
||||
def chat_with_persistence(prompt, *args, **kwargs):
|
||||
nonlocal _session_initialized
|
||||
|
||||
# Initialize session on first call
|
||||
if not _session_initialized[0]:
|
||||
history = orchestrator.on_agent_start(
|
||||
agent,
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
resume=auto_resume
|
||||
)
|
||||
|
||||
# Inject history into agent's chat_history if resuming
|
||||
if auto_resume and history:
|
||||
agent.chat_history = [
|
||||
{"role": msg.role, "content": msg.content}
|
||||
for msg in history
|
||||
]
|
||||
logger.info(f"Resumed session {session_id} with {len(history)} messages")
|
||||
|
||||
_session_initialized[0] = True
|
||||
|
||||
# Persist user message
|
||||
orchestrator.on_message(session_id, "user", prompt if isinstance(prompt, str) else str(prompt))
|
||||
|
||||
# Call original chat
|
||||
response = original_chat(prompt, *args, **kwargs)
|
||||
|
||||
# Persist assistant response
|
||||
if response:
|
||||
orchestrator.on_message(session_id, "assistant", response)
|
||||
|
||||
return response
|
||||
|
||||
# Replace chat method
|
||||
agent.chat = chat_with_persistence
|
||||
|
||||
# Add persistence-related methods to agent
|
||||
agent._persistence_orchestrator = orchestrator
|
||||
agent._persistence_session_id = session_id
|
||||
|
||||
def get_session():
|
||||
"""Get the current session."""
|
||||
return orchestrator.get_session(session_id)
|
||||
|
||||
def get_messages(limit: Optional[int] = None):
|
||||
"""Get messages from the current session."""
|
||||
return orchestrator.get_messages(session_id, limit=limit)
|
||||
|
||||
def end_session():
|
||||
"""End the current session."""
|
||||
orchestrator.on_agent_end(agent, session_id)
|
||||
|
||||
agent.get_session = get_session
|
||||
agent.get_messages = get_messages
|
||||
agent.end_session = end_session
|
||||
|
||||
return agent
|
||||
|
||||
|
||||
class PersistentAgent:
|
||||
"""
|
||||
A wrapper class that provides persistence capabilities for any agent.
|
||||
|
||||
This is an alternative to wrap_agent_with_persistence that uses composition
|
||||
instead of monkey-patching.
|
||||
|
||||
Example:
|
||||
from praisonai.persistence import PersistenceOrchestrator
|
||||
from praisonai.persistence.hooks import PersistentAgent
|
||||
from praisonaiagents import Agent
|
||||
|
||||
orchestrator = PersistenceOrchestrator(...)
|
||||
base_agent = Agent(name="Assistant", role="Helper")
|
||||
|
||||
agent = PersistentAgent(base_agent, orchestrator, session_id="my-session")
|
||||
response = agent.chat("Hello!")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent,
|
||||
orchestrator: PersistenceOrchestrator,
|
||||
session_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
auto_resume: bool = True,
|
||||
):
|
||||
self._agent = agent
|
||||
self._orchestrator = orchestrator
|
||||
self._session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
|
||||
self._user_id = user_id or getattr(agent, 'user_id', 'default')
|
||||
self._auto_resume = auto_resume
|
||||
self._session_initialized = False
|
||||
|
||||
def __getattr__(self, name):
|
||||
"""Delegate attribute access to wrapped agent."""
|
||||
return getattr(self._agent, name)
|
||||
|
||||
def _ensure_session(self):
|
||||
"""Ensure session is initialized."""
|
||||
if not self._session_initialized:
|
||||
history = self._orchestrator.on_agent_start(
|
||||
self._agent,
|
||||
session_id=self._session_id,
|
||||
user_id=self._user_id,
|
||||
resume=self._auto_resume
|
||||
)
|
||||
|
||||
if self._auto_resume and history:
|
||||
self._agent.chat_history = [
|
||||
{"role": msg.role, "content": msg.content}
|
||||
for msg in history
|
||||
]
|
||||
logger.info(f"Resumed session {self._session_id} with {len(history)} messages")
|
||||
|
||||
self._session_initialized = True
|
||||
|
||||
def chat(self, prompt, *args, **kwargs):
|
||||
"""Chat with automatic persistence."""
|
||||
self._ensure_session()
|
||||
|
||||
# Persist user message
|
||||
self._orchestrator.on_message(
|
||||
self._session_id, "user",
|
||||
prompt if isinstance(prompt, str) else str(prompt)
|
||||
)
|
||||
|
||||
# Call agent chat
|
||||
response = self._agent.chat(prompt, *args, **kwargs)
|
||||
|
||||
# Persist assistant response
|
||||
if response:
|
||||
self._orchestrator.on_message(self._session_id, "assistant", response)
|
||||
|
||||
return response
|
||||
|
||||
def get_session(self) -> Optional[ConversationSession]:
|
||||
"""Get the current session."""
|
||||
return self._orchestrator.get_session(self._session_id)
|
||||
|
||||
def get_messages(self, limit: Optional[int] = None) -> List[ConversationMessage]:
|
||||
"""Get messages from the current session."""
|
||||
return self._orchestrator.get_messages(self._session_id, limit=limit)
|
||||
|
||||
def end_session(self):
|
||||
"""End the current session."""
|
||||
self._orchestrator.on_agent_end(self._agent, self._session_id)
|
||||
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
"""Get the session ID."""
|
||||
return self._session_id
|
||||
|
||||
|
||||
def create_persistent_session(
|
||||
orchestrator: PersistenceOrchestrator,
|
||||
session_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Create a context manager for persistent sessions.
|
||||
|
||||
Example:
|
||||
from praisonai.persistence import PersistenceOrchestrator
|
||||
from praisonai.persistence.hooks import create_persistent_session
|
||||
from praisonaiagents import Agent
|
||||
|
||||
orchestrator = PersistenceOrchestrator(...)
|
||||
|
||||
with create_persistent_session(orchestrator, session_id="my-session") as session:
|
||||
agent = Agent(name="Assistant", role="Helper")
|
||||
|
||||
# Messages are persisted within the session context
|
||||
session.persist_message("user", "Hello!")
|
||||
response = agent.chat("Hello!")
|
||||
session.persist_message("assistant", response)
|
||||
"""
|
||||
|
||||
class PersistentSession:
|
||||
def __init__(self, orchestrator, session_id, user_id):
|
||||
self.orchestrator = orchestrator
|
||||
self.session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
|
||||
self.user_id = user_id or "default"
|
||||
self._started = False
|
||||
|
||||
def __enter__(self):
|
||||
self.start()
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.end()
|
||||
return False
|
||||
|
||||
def start(self, agent=None):
|
||||
"""Start the session."""
|
||||
if not self._started:
|
||||
class MockAgent:
|
||||
name = "session-agent"
|
||||
|
||||
self.orchestrator.on_agent_start(
|
||||
agent or MockAgent(),
|
||||
session_id=self.session_id,
|
||||
user_id=self.user_id
|
||||
)
|
||||
self._started = True
|
||||
|
||||
def end(self, agent=None):
|
||||
"""End the session."""
|
||||
if self._started:
|
||||
class MockAgent:
|
||||
name = "session-agent"
|
||||
|
||||
self.orchestrator.on_agent_end(
|
||||
agent or MockAgent(),
|
||||
self.session_id
|
||||
)
|
||||
|
||||
def persist_message(self, role: str, content: str):
|
||||
"""Persist a message to the session."""
|
||||
self.orchestrator.on_message(self.session_id, role, content)
|
||||
|
||||
def get_messages(self, limit: Optional[int] = None):
|
||||
"""Get messages from the session."""
|
||||
return self.orchestrator.get_messages(self.session_id, limit=limit)
|
||||
|
||||
def get_session(self):
|
||||
"""Get the session object."""
|
||||
return self.orchestrator.get_session(self.session_id)
|
||||
|
||||
return PersistentSession(orchestrator, session_id, user_id)
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
KnowledgeStore implementations for vector embeddings and semantic search.
|
||||
|
||||
Supported backends:
|
||||
- Qdrant (qdrant)
|
||||
- Pinecone (pinecone)
|
||||
- ChromaDB (chroma)
|
||||
- Weaviate (weaviate)
|
||||
- LanceDB (lancedb)
|
||||
- Milvus (milvus)
|
||||
- PGVector (pgvector)
|
||||
- Redis Vector (redis)
|
||||
- Cassandra Vector (cassandra)
|
||||
- ClickHouse Vector (clickhouse)
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"KnowledgeStore",
|
||||
"KnowledgeDocument",
|
||||
]
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name in ("KnowledgeStore", "KnowledgeDocument"):
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
return locals()[name]
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Base interfaces for KnowledgeStore.
|
||||
|
||||
KnowledgeStore handles vector embeddings and semantic search for RAG.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
import time
|
||||
import uuid
|
||||
import hashlib
|
||||
|
||||
|
||||
@dataclass
|
||||
class KnowledgeDocument:
|
||||
"""A document with vector embedding for semantic search."""
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
||||
content: str = ""
|
||||
embedding: Optional[List[float]] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
content_hash: Optional[str] = None
|
||||
created_at: float = field(default_factory=time.time)
|
||||
|
||||
def __post_init__(self):
|
||||
if self.content and not self.content_hash:
|
||||
self.content_hash = hashlib.sha256(self.content.encode()).hexdigest()[:16]
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"content": self.content,
|
||||
"embedding": self.embedding,
|
||||
"metadata": self.metadata,
|
||||
"content_hash": self.content_hash,
|
||||
"created_at": self.created_at,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "KnowledgeDocument":
|
||||
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
||||
|
||||
|
||||
class KnowledgeStore(ABC):
|
||||
"""
|
||||
Abstract base class for vector/knowledge persistence.
|
||||
|
||||
Implementations handle document storage and semantic search for different backends:
|
||||
- Qdrant, Pinecone, ChromaDB, Weaviate (dedicated vector DBs)
|
||||
- LanceDB, Milvus (embedded/distributed vector DBs)
|
||||
- PGVector, Redis, Cassandra, ClickHouse (vector extensions)
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create a new collection/index for vectors."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete a collection and all its documents."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if a collection exists."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all collections."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents into a collection. Returns list of IDs."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert or update documents. Returns list of IDs."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: List[float],
|
||||
limit: int = 5,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
score_threshold: Optional[float] = None
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Search for similar documents by embedding."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents by IDs or filters. Returns count deleted."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents in a collection."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def close(self) -> None:
|
||||
"""Close the store and release resources."""
|
||||
raise NotImplementedError
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
return False
|
||||
@@ -0,0 +1,232 @@
|
||||
"""
|
||||
Cassandra Vector implementation of KnowledgeStore.
|
||||
|
||||
Requires: cassandra-driver
|
||||
Install: pip install cassandra-driver
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CassandraKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
Cassandra-based knowledge store using vector search (SAI).
|
||||
|
||||
Requires Cassandra 5.0+ or DataStax Astra with vector search.
|
||||
|
||||
Example:
|
||||
store = CassandraKnowledgeStore(
|
||||
hosts=["localhost"],
|
||||
keyspace="praisonai"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hosts: Optional[List[str]] = None,
|
||||
keyspace: str = "praisonai",
|
||||
username: Optional[str] = None,
|
||||
password: Optional[str] = None,
|
||||
port: int = 9042,
|
||||
secure_connect_bundle: Optional[str] = None,
|
||||
):
|
||||
try:
|
||||
from cassandra.cluster import Cluster
|
||||
from cassandra.auth import PlainTextAuthProvider
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"cassandra-driver is required for Cassandra support. "
|
||||
"Install with: pip install cassandra-driver"
|
||||
)
|
||||
|
||||
self._Cluster = Cluster
|
||||
self.keyspace = keyspace
|
||||
|
||||
auth = None
|
||||
if username and password:
|
||||
auth = PlainTextAuthProvider(username=username, password=password)
|
||||
|
||||
if secure_connect_bundle:
|
||||
# Astra DB connection
|
||||
from cassandra.cluster import Cluster
|
||||
cloud_config = {"secure_connect_bundle": secure_connect_bundle}
|
||||
self._cluster = Cluster(cloud=cloud_config, auth_provider=auth)
|
||||
else:
|
||||
hosts = hosts or ["localhost"]
|
||||
self._cluster = Cluster(hosts, port=port, auth_provider=auth)
|
||||
|
||||
self._session = self._cluster.connect()
|
||||
|
||||
# Create keyspace if not exists
|
||||
self._session.execute(f"""
|
||||
CREATE KEYSPACE IF NOT EXISTS {keyspace}
|
||||
WITH replication = {{'class': 'SimpleStrategy', 'replication_factor': 1}}
|
||||
""")
|
||||
self._session.set_keyspace(keyspace)
|
||||
logger.info(f"Connected to Cassandra keyspace: {keyspace}")
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create a new table with vector column."""
|
||||
similarity_map = {"cosine": "COSINE", "euclidean": "EUCLIDEAN", "dot": "DOT_PRODUCT"}
|
||||
|
||||
self._session.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {name} (
|
||||
id text PRIMARY KEY,
|
||||
content text,
|
||||
content_hash text,
|
||||
created_at double,
|
||||
embedding vector<float, {dimension}>
|
||||
)
|
||||
""")
|
||||
|
||||
# Create SAI index for vector search
|
||||
self._session.execute(f"""
|
||||
CREATE CUSTOM INDEX IF NOT EXISTS {name}_embedding_idx ON {name} (embedding)
|
||||
USING 'StorageAttachedIndex'
|
||||
WITH OPTIONS = {{'similarity_function': '{similarity_map.get(distance, "COSINE")}'}}
|
||||
""")
|
||||
logger.info(f"Created Cassandra table: {name}")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete a table."""
|
||||
try:
|
||||
self._session.execute(f"DROP TABLE IF EXISTS {name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete table {name}: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if a table exists."""
|
||||
result = self._session.execute("""
|
||||
SELECT table_name FROM system_schema.tables
|
||||
WHERE keyspace_name = %s AND table_name = %s
|
||||
""", (self.keyspace, name))
|
||||
return len(list(result)) > 0
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all tables."""
|
||||
result = self._session.execute("""
|
||||
SELECT table_name FROM system_schema.tables WHERE keyspace_name = %s
|
||||
""", (self.keyspace,))
|
||||
return [row.table_name for row in result]
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents."""
|
||||
ids = []
|
||||
for doc in documents:
|
||||
if doc.embedding is None:
|
||||
raise ValueError(f"Document {doc.id} has no embedding")
|
||||
|
||||
self._session.execute(f"""
|
||||
INSERT INTO {collection} (id, content, content_hash, created_at, embedding)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
""", (doc.id, doc.content, doc.content_hash or "", doc.created_at, doc.embedding))
|
||||
ids.append(doc.id)
|
||||
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert or update documents."""
|
||||
return self.insert(collection, documents) # Cassandra INSERT is upsert
|
||||
|
||||
def search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: List[float],
|
||||
limit: int = 5,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
score_threshold: Optional[float] = None
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Search for similar documents using ANN."""
|
||||
result = self._session.execute(f"""
|
||||
SELECT id, content, content_hash, created_at, similarity_cosine(embedding, %s) as score
|
||||
FROM {collection}
|
||||
ORDER BY embedding ANN OF %s
|
||||
LIMIT %s
|
||||
""", (query_embedding, query_embedding, limit))
|
||||
|
||||
documents = []
|
||||
for row in result:
|
||||
if score_threshold and row.score < score_threshold:
|
||||
continue
|
||||
|
||||
doc = KnowledgeDocument(
|
||||
id=row.id,
|
||||
content=row.content,
|
||||
embedding=None,
|
||||
metadata={},
|
||||
content_hash=row.content_hash,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
documents = []
|
||||
for doc_id in ids:
|
||||
result = self._session.execute(f"""
|
||||
SELECT id, content, content_hash, created_at, embedding
|
||||
FROM {collection} WHERE id = %s
|
||||
""", (doc_id,))
|
||||
|
||||
for row in result:
|
||||
doc = KnowledgeDocument(
|
||||
id=row.id,
|
||||
content=row.content,
|
||||
embedding=list(row.embedding) if row.embedding else None,
|
||||
metadata={},
|
||||
content_hash=row.content_hash,
|
||||
created_at=row.created_at,
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents."""
|
||||
if ids:
|
||||
for doc_id in ids:
|
||||
self._session.execute(f"DELETE FROM {collection} WHERE id = %s", (doc_id,))
|
||||
return len(ids)
|
||||
return 0
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents."""
|
||||
result = self._session.execute(f"SELECT COUNT(*) FROM {collection}")
|
||||
return result.one()[0]
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
if self._cluster:
|
||||
self._cluster.shutdown()
|
||||
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
ChromaDB implementation of KnowledgeStore.
|
||||
|
||||
Requires: chromadb
|
||||
Install: pip install chromadb
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ChromaKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
ChromaDB-based knowledge store for vector search.
|
||||
|
||||
Zero-config embedded vector database.
|
||||
|
||||
Example:
|
||||
store = ChromaKnowledgeStore(
|
||||
path="./chroma_db"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Optional[str] = None,
|
||||
host: Optional[str] = None,
|
||||
port: int = 8000,
|
||||
persistent: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize ChromaDB knowledge store.
|
||||
|
||||
Args:
|
||||
path: Path for persistent storage (embedded mode)
|
||||
host: Chroma server host (client mode)
|
||||
port: Chroma server port
|
||||
persistent: Use persistent storage
|
||||
"""
|
||||
try:
|
||||
import chromadb
|
||||
from chromadb.config import Settings
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"chromadb is required for ChromaDB support. "
|
||||
"Install with: pip install chromadb"
|
||||
)
|
||||
|
||||
self._chromadb = chromadb
|
||||
|
||||
if host:
|
||||
# Client mode
|
||||
self._client = chromadb.HttpClient(host=host, port=port)
|
||||
elif path and persistent:
|
||||
# Persistent embedded mode
|
||||
self._client = chromadb.PersistentClient(
|
||||
path=path,
|
||||
settings=Settings(anonymized_telemetry=False)
|
||||
)
|
||||
else:
|
||||
# In-memory mode
|
||||
self._client = chromadb.Client(
|
||||
settings=Settings(anonymized_telemetry=False)
|
||||
)
|
||||
|
||||
self._collections: Dict[str, Any] = {}
|
||||
logger.info(f"ChromaDB initialized (path={path}, host={host})")
|
||||
|
||||
def _get_collection(self, name: str):
|
||||
"""Get or cache a collection."""
|
||||
if name not in self._collections:
|
||||
self._collections[name] = self._client.get_collection(name)
|
||||
return self._collections[name]
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create a new collection."""
|
||||
distance_map = {
|
||||
"cosine": "cosine",
|
||||
"euclidean": "l2",
|
||||
"dot": "ip",
|
||||
}
|
||||
|
||||
self._client.create_collection(
|
||||
name=name,
|
||||
metadata={"hnsw:space": distance_map.get(distance, "cosine"), **(metadata or {})}
|
||||
)
|
||||
logger.info(f"Created ChromaDB collection: {name}")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete a collection."""
|
||||
try:
|
||||
self._client.delete_collection(name)
|
||||
if name in self._collections:
|
||||
del self._collections[name]
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete collection {name}: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if a collection exists."""
|
||||
try:
|
||||
self._client.get_collection(name)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all collections."""
|
||||
return [c.name for c in self._client.list_collections()]
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents into a collection."""
|
||||
col = self._get_collection(collection)
|
||||
|
||||
ids = []
|
||||
embeddings = []
|
||||
contents = []
|
||||
metadatas = []
|
||||
|
||||
for doc in documents:
|
||||
if doc.embedding is None:
|
||||
raise ValueError(f"Document {doc.id} has no embedding")
|
||||
ids.append(doc.id)
|
||||
embeddings.append(doc.embedding)
|
||||
contents.append(doc.content)
|
||||
metadatas.append({
|
||||
"content_hash": doc.content_hash or "",
|
||||
"created_at": doc.created_at,
|
||||
**(doc.metadata or {})
|
||||
})
|
||||
|
||||
col.add(
|
||||
ids=ids,
|
||||
embeddings=embeddings,
|
||||
documents=contents,
|
||||
metadatas=metadatas,
|
||||
)
|
||||
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert or update documents."""
|
||||
col = self._get_collection(collection)
|
||||
|
||||
ids = []
|
||||
embeddings = []
|
||||
contents = []
|
||||
metadatas = []
|
||||
|
||||
for doc in documents:
|
||||
if doc.embedding is None:
|
||||
raise ValueError(f"Document {doc.id} has no embedding")
|
||||
ids.append(doc.id)
|
||||
embeddings.append(doc.embedding)
|
||||
contents.append(doc.content)
|
||||
metadatas.append({
|
||||
"content_hash": doc.content_hash or "",
|
||||
"created_at": doc.created_at,
|
||||
**(doc.metadata or {})
|
||||
})
|
||||
|
||||
col.upsert(
|
||||
ids=ids,
|
||||
embeddings=embeddings,
|
||||
documents=contents,
|
||||
metadatas=metadatas,
|
||||
)
|
||||
|
||||
return ids
|
||||
|
||||
def search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: List[float],
|
||||
limit: int = 5,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
score_threshold: Optional[float] = None
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Search for similar documents."""
|
||||
col = self._get_collection(collection)
|
||||
|
||||
where = None
|
||||
if filters:
|
||||
where = {k: v for k, v in filters.items()}
|
||||
|
||||
results = col.query(
|
||||
query_embeddings=[query_embedding],
|
||||
n_results=limit,
|
||||
where=where,
|
||||
include=["documents", "metadatas", "distances"],
|
||||
)
|
||||
|
||||
documents = []
|
||||
if results["ids"] and results["ids"][0]:
|
||||
for i, doc_id in enumerate(results["ids"][0]):
|
||||
distance = results["distances"][0][i] if results.get("distances") else None
|
||||
|
||||
# Convert distance to similarity score (for cosine: 1 - distance)
|
||||
if score_threshold is not None and distance is not None:
|
||||
score = 1 - distance
|
||||
if score < score_threshold:
|
||||
continue
|
||||
|
||||
metadata = results["metadatas"][0][i] if results.get("metadatas") else {}
|
||||
content_hash = metadata.pop("content_hash", None)
|
||||
created_at = metadata.pop("created_at", 0)
|
||||
|
||||
doc = KnowledgeDocument(
|
||||
id=doc_id,
|
||||
content=results["documents"][0][i] if results.get("documents") else "",
|
||||
embedding=None,
|
||||
metadata=metadata,
|
||||
content_hash=content_hash,
|
||||
created_at=created_at,
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
col = self._get_collection(collection)
|
||||
|
||||
results = col.get(
|
||||
ids=ids,
|
||||
include=["documents", "metadatas", "embeddings"],
|
||||
)
|
||||
|
||||
documents = []
|
||||
if results["ids"]:
|
||||
for i, doc_id in enumerate(results["ids"]):
|
||||
metadata = results["metadatas"][i] if results.get("metadatas") else {}
|
||||
content_hash = metadata.pop("content_hash", None)
|
||||
created_at = metadata.pop("created_at", 0)
|
||||
|
||||
doc = KnowledgeDocument(
|
||||
id=doc_id,
|
||||
content=results["documents"][i] if results.get("documents") else "",
|
||||
embedding=results["embeddings"][i] if results.get("embeddings") else None,
|
||||
metadata=metadata,
|
||||
content_hash=content_hash,
|
||||
created_at=created_at,
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents."""
|
||||
col = self._get_collection(collection)
|
||||
|
||||
if ids:
|
||||
col.delete(ids=ids)
|
||||
return len(ids)
|
||||
elif filters:
|
||||
col.delete(where=filters)
|
||||
return -1 # Unknown count
|
||||
return 0
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents in a collection."""
|
||||
col = self._get_collection(collection)
|
||||
return col.count()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
self._collections.clear()
|
||||
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
ClickHouse Vector implementation of KnowledgeStore.
|
||||
|
||||
Requires: clickhouse-connect
|
||||
Install: pip install clickhouse-connect
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ClickHouseKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
ClickHouse-based knowledge store using vector search.
|
||||
|
||||
Example:
|
||||
store = ClickHouseKnowledgeStore(
|
||||
host="localhost",
|
||||
port=8123
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "localhost",
|
||||
port: int = 8123,
|
||||
username: str = "default",
|
||||
password: str = "",
|
||||
database: str = "praisonai",
|
||||
secure: bool = False,
|
||||
):
|
||||
try:
|
||||
import clickhouse_connect
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"clickhouse-connect is required for ClickHouse support. "
|
||||
"Install with: pip install clickhouse-connect"
|
||||
)
|
||||
|
||||
self._client = clickhouse_connect.get_client(
|
||||
host=host,
|
||||
port=port,
|
||||
username=username,
|
||||
password=password,
|
||||
database=database,
|
||||
secure=secure,
|
||||
)
|
||||
self.database = database
|
||||
|
||||
# Create database if not exists
|
||||
self._client.command(f"CREATE DATABASE IF NOT EXISTS {database}")
|
||||
logger.info(f"Connected to ClickHouse database: {database}")
|
||||
|
||||
def _table_name(self, collection: str) -> str:
|
||||
return f"{self.database}.praison_vec_{collection}"
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create a new table with vector column."""
|
||||
table = self._table_name(name)
|
||||
|
||||
self._client.command(f"""
|
||||
CREATE TABLE IF NOT EXISTS {table} (
|
||||
id String,
|
||||
content String,
|
||||
content_hash String,
|
||||
created_at Float64,
|
||||
embedding Array(Float32)
|
||||
) ENGINE = MergeTree()
|
||||
ORDER BY id
|
||||
""")
|
||||
logger.info(f"Created ClickHouse table: {table}")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete a table."""
|
||||
table = self._table_name(name)
|
||||
try:
|
||||
self._client.command(f"DROP TABLE IF EXISTS {table}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete table {table}: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if a table exists."""
|
||||
table = self._table_name(name)
|
||||
result = self._client.query(f"EXISTS TABLE {table}")
|
||||
return result.result_rows[0][0] == 1
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all collections."""
|
||||
result = self._client.query(f"""
|
||||
SELECT name FROM system.tables
|
||||
WHERE database = '{self.database}' AND name LIKE 'praison_vec_%'
|
||||
""")
|
||||
return [row[0].replace("praison_vec_", "") for row in result.result_rows]
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents."""
|
||||
table = self._table_name(collection)
|
||||
|
||||
data = []
|
||||
ids = []
|
||||
for doc in documents:
|
||||
if doc.embedding is None:
|
||||
raise ValueError(f"Document {doc.id} has no embedding")
|
||||
data.append([
|
||||
doc.id,
|
||||
doc.content,
|
||||
doc.content_hash or "",
|
||||
doc.created_at,
|
||||
doc.embedding,
|
||||
])
|
||||
ids.append(doc.id)
|
||||
|
||||
self._client.insert(
|
||||
table,
|
||||
data,
|
||||
column_names=["id", "content", "content_hash", "created_at", "embedding"]
|
||||
)
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert or update documents."""
|
||||
# ClickHouse doesn't have native upsert for MergeTree
|
||||
# Delete then insert
|
||||
ids = [doc.id for doc in documents]
|
||||
self.delete(collection, ids)
|
||||
return self.insert(collection, documents)
|
||||
|
||||
def search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: List[float],
|
||||
limit: int = 5,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
score_threshold: Optional[float] = None
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Search for similar documents using cosine distance."""
|
||||
table = self._table_name(collection)
|
||||
|
||||
# Convert embedding to string for query
|
||||
embedding_str = "[" + ",".join(map(str, query_embedding)) + "]"
|
||||
|
||||
query = f"""
|
||||
SELECT
|
||||
id, content, content_hash, created_at,
|
||||
1 - cosineDistance(embedding, {embedding_str}) as score
|
||||
FROM {table}
|
||||
"""
|
||||
|
||||
if score_threshold:
|
||||
query += f" WHERE 1 - cosineDistance(embedding, {embedding_str}) >= {score_threshold}"
|
||||
|
||||
query += f" ORDER BY score DESC LIMIT {limit}"
|
||||
|
||||
result = self._client.query(query)
|
||||
|
||||
documents = []
|
||||
for row in result.result_rows:
|
||||
doc = KnowledgeDocument(
|
||||
id=row[0],
|
||||
content=row[1],
|
||||
embedding=None,
|
||||
metadata={},
|
||||
content_hash=row[2],
|
||||
created_at=row[3],
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
table = self._table_name(collection)
|
||||
|
||||
id_list = ", ".join([f"'{i}'" for i in ids])
|
||||
result = self._client.query(f"""
|
||||
SELECT id, content, content_hash, created_at, embedding
|
||||
FROM {table} WHERE id IN ({id_list})
|
||||
""")
|
||||
|
||||
documents = []
|
||||
for row in result.result_rows:
|
||||
doc = KnowledgeDocument(
|
||||
id=row[0],
|
||||
content=row[1],
|
||||
embedding=list(row[4]) if row[4] else None,
|
||||
metadata={},
|
||||
content_hash=row[2],
|
||||
created_at=row[3],
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents."""
|
||||
table = self._table_name(collection)
|
||||
|
||||
if ids:
|
||||
id_list = ", ".join([f"'{i}'" for i in ids])
|
||||
self._client.command(f"ALTER TABLE {table} DELETE WHERE id IN ({id_list})")
|
||||
return len(ids)
|
||||
return 0
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents."""
|
||||
table = self._table_name(collection)
|
||||
result = self._client.query(f"SELECT COUNT(*) FROM {table}")
|
||||
return result.result_rows[0][0]
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
if self._client:
|
||||
self._client.close()
|
||||
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
LanceDB implementation of KnowledgeStore.
|
||||
|
||||
Requires: lancedb
|
||||
Install: pip install lancedb
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LanceDBKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
LanceDB-based knowledge store for vector search.
|
||||
|
||||
Embedded serverless vector database.
|
||||
|
||||
Example:
|
||||
store = LanceDBKnowledgeStore(
|
||||
path="./lancedb"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str = "./lancedb",
|
||||
uri: Optional[str] = None,
|
||||
):
|
||||
try:
|
||||
import lancedb
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"lancedb is required for LanceDB support. "
|
||||
"Install with: pip install lancedb"
|
||||
)
|
||||
|
||||
self._lancedb = lancedb
|
||||
self._db = lancedb.connect(uri or path)
|
||||
self._tables: Dict[str, Any] = {}
|
||||
logger.info(f"Connected to LanceDB at {uri or path}")
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create a new table."""
|
||||
import pyarrow as pa
|
||||
|
||||
schema = pa.schema([
|
||||
pa.field("id", pa.string()),
|
||||
pa.field("content", pa.string()),
|
||||
pa.field("content_hash", pa.string()),
|
||||
pa.field("created_at", pa.float64()),
|
||||
pa.field("vector", pa.list_(pa.float32(), dimension)),
|
||||
])
|
||||
|
||||
self._db.create_table(name, schema=schema)
|
||||
logger.info(f"Created LanceDB table: {name}")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete a table."""
|
||||
try:
|
||||
self._db.drop_table(name)
|
||||
if name in self._tables:
|
||||
del self._tables[name]
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete table {name}: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if a table exists."""
|
||||
return name in self._db.table_names()
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all tables."""
|
||||
return self._db.table_names()
|
||||
|
||||
def _get_table(self, name: str):
|
||||
"""Get or cache a table."""
|
||||
if name not in self._tables:
|
||||
self._tables[name] = self._db.open_table(name)
|
||||
return self._tables[name]
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents."""
|
||||
table = self._get_table(collection)
|
||||
|
||||
data = []
|
||||
ids = []
|
||||
for doc in documents:
|
||||
if doc.embedding is None:
|
||||
raise ValueError(f"Document {doc.id} has no embedding")
|
||||
|
||||
data.append({
|
||||
"id": doc.id,
|
||||
"content": doc.content,
|
||||
"content_hash": doc.content_hash or "",
|
||||
"created_at": doc.created_at,
|
||||
"vector": doc.embedding,
|
||||
})
|
||||
ids.append(doc.id)
|
||||
|
||||
table.add(data)
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert or update documents."""
|
||||
# LanceDB doesn't have native upsert, delete then insert
|
||||
ids = [doc.id for doc in documents]
|
||||
self.delete(collection, ids)
|
||||
return self.insert(collection, documents)
|
||||
|
||||
def search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: List[float],
|
||||
limit: int = 5,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
score_threshold: Optional[float] = None
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Search for similar documents."""
|
||||
table = self._get_table(collection)
|
||||
|
||||
query = table.search(query_embedding).limit(limit)
|
||||
|
||||
if filters:
|
||||
where_clauses = [f"{k} = '{v}'" for k, v in filters.items()]
|
||||
query = query.where(" AND ".join(where_clauses))
|
||||
|
||||
results = query.to_pandas()
|
||||
|
||||
documents = []
|
||||
for _, row in results.iterrows():
|
||||
if score_threshold and row.get("_distance", 1) > (1 - score_threshold):
|
||||
continue
|
||||
|
||||
doc = KnowledgeDocument(
|
||||
id=row["id"],
|
||||
content=row["content"],
|
||||
embedding=None,
|
||||
metadata={},
|
||||
content_hash=row.get("content_hash"),
|
||||
created_at=row.get("created_at", 0),
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
table = self._get_table(collection)
|
||||
|
||||
id_list = ", ".join([f"'{i}'" for i in ids])
|
||||
results = table.search().where(f"id IN ({id_list})").to_pandas()
|
||||
|
||||
documents = []
|
||||
for _, row in results.iterrows():
|
||||
doc = KnowledgeDocument(
|
||||
id=row["id"],
|
||||
content=row["content"],
|
||||
embedding=list(row["vector"]) if "vector" in row else None,
|
||||
metadata={},
|
||||
content_hash=row.get("content_hash"),
|
||||
created_at=row.get("created_at", 0),
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents."""
|
||||
table = self._get_table(collection)
|
||||
|
||||
if ids:
|
||||
id_list = ", ".join([f"'{i}'" for i in ids])
|
||||
table.delete(f"id IN ({id_list})")
|
||||
return len(ids)
|
||||
elif filters:
|
||||
where_clauses = [f"{k} = '{v}'" for k, v in filters.items()]
|
||||
table.delete(" AND ".join(where_clauses))
|
||||
return -1
|
||||
return 0
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents."""
|
||||
table = self._get_table(collection)
|
||||
return table.count_rows()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
self._tables.clear()
|
||||
@@ -0,0 +1,277 @@
|
||||
"""
|
||||
Milvus implementation of KnowledgeStore.
|
||||
|
||||
Requires: pymilvus
|
||||
Install: pip install pymilvus
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MilvusKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
Milvus-based knowledge store for vector search.
|
||||
|
||||
Example:
|
||||
store = MilvusKnowledgeStore(
|
||||
host="localhost",
|
||||
port=19530
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
host: str = "localhost",
|
||||
port: int = 19530,
|
||||
token: Optional[str] = None,
|
||||
db_name: str = "default",
|
||||
):
|
||||
try:
|
||||
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType, utility
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"pymilvus is required for Milvus support. "
|
||||
"Install with: pip install pymilvus"
|
||||
)
|
||||
|
||||
self._pymilvus = {
|
||||
"connections": connections,
|
||||
"Collection": Collection,
|
||||
"FieldSchema": FieldSchema,
|
||||
"CollectionSchema": CollectionSchema,
|
||||
"DataType": DataType,
|
||||
"utility": utility,
|
||||
}
|
||||
|
||||
if url:
|
||||
# Parse milvus://host:port
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
host = parsed.hostname or host
|
||||
port = parsed.port or port
|
||||
|
||||
connections.connect(
|
||||
alias="default",
|
||||
host=host,
|
||||
port=port,
|
||||
token=token,
|
||||
db_name=db_name,
|
||||
)
|
||||
logger.info(f"Connected to Milvus at {host}:{port}")
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create a new collection."""
|
||||
FieldSchema = self._pymilvus["FieldSchema"]
|
||||
CollectionSchema = self._pymilvus["CollectionSchema"]
|
||||
DataType = self._pymilvus["DataType"]
|
||||
Collection = self._pymilvus["Collection"]
|
||||
|
||||
fields = [
|
||||
FieldSchema(name="id", dtype=DataType.VARCHAR, is_primary=True, max_length=256),
|
||||
FieldSchema(name="content", dtype=DataType.VARCHAR, max_length=65535),
|
||||
FieldSchema(name="content_hash", dtype=DataType.VARCHAR, max_length=64),
|
||||
FieldSchema(name="created_at", dtype=DataType.DOUBLE),
|
||||
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dimension),
|
||||
]
|
||||
|
||||
schema = CollectionSchema(fields=fields, description="PraisonAI knowledge store")
|
||||
collection = Collection(name=name, schema=schema)
|
||||
|
||||
# Create index
|
||||
metric_map = {"cosine": "COSINE", "euclidean": "L2", "dot": "IP"}
|
||||
index_params = {
|
||||
"metric_type": metric_map.get(distance, "COSINE"),
|
||||
"index_type": "HNSW",
|
||||
"params": {"M": 16, "efConstruction": 256}
|
||||
}
|
||||
collection.create_index(field_name="vector", index_params=index_params)
|
||||
logger.info(f"Created Milvus collection: {name}")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete a collection."""
|
||||
utility = self._pymilvus["utility"]
|
||||
try:
|
||||
utility.drop_collection(name)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete collection {name}: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if a collection exists."""
|
||||
utility = self._pymilvus["utility"]
|
||||
return utility.has_collection(name)
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all collections."""
|
||||
utility = self._pymilvus["utility"]
|
||||
return utility.list_collections()
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents."""
|
||||
Collection = self._pymilvus["Collection"]
|
||||
col = Collection(collection)
|
||||
col.load()
|
||||
|
||||
data = [
|
||||
[doc.id for doc in documents],
|
||||
[doc.content for doc in documents],
|
||||
[doc.content_hash or "" for doc in documents],
|
||||
[doc.created_at for doc in documents],
|
||||
[doc.embedding for doc in documents],
|
||||
]
|
||||
|
||||
col.insert(data)
|
||||
col.flush()
|
||||
return [doc.id for doc in documents]
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert or update documents."""
|
||||
Collection = self._pymilvus["Collection"]
|
||||
col = Collection(collection)
|
||||
col.load()
|
||||
|
||||
data = [
|
||||
[doc.id for doc in documents],
|
||||
[doc.content for doc in documents],
|
||||
[doc.content_hash or "" for doc in documents],
|
||||
[doc.created_at for doc in documents],
|
||||
[doc.embedding for doc in documents],
|
||||
]
|
||||
|
||||
col.upsert(data)
|
||||
col.flush()
|
||||
return [doc.id for doc in documents]
|
||||
|
||||
def search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: List[float],
|
||||
limit: int = 5,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
score_threshold: Optional[float] = None
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Search for similar documents."""
|
||||
Collection = self._pymilvus["Collection"]
|
||||
col = Collection(collection)
|
||||
col.load()
|
||||
|
||||
search_params = {"metric_type": "COSINE", "params": {"ef": 64}}
|
||||
|
||||
expr = None
|
||||
if filters:
|
||||
conditions = [f'{k} == "{v}"' for k, v in filters.items()]
|
||||
expr = " and ".join(conditions)
|
||||
|
||||
results = col.search(
|
||||
data=[query_embedding],
|
||||
anns_field="vector",
|
||||
param=search_params,
|
||||
limit=limit,
|
||||
expr=expr,
|
||||
output_fields=["id", "content", "content_hash", "created_at"],
|
||||
)
|
||||
|
||||
documents = []
|
||||
for hits in results:
|
||||
for hit in hits:
|
||||
if score_threshold and hit.score < score_threshold:
|
||||
continue
|
||||
|
||||
doc = KnowledgeDocument(
|
||||
id=hit.entity.get("id"),
|
||||
content=hit.entity.get("content", ""),
|
||||
embedding=None,
|
||||
metadata={},
|
||||
content_hash=hit.entity.get("content_hash"),
|
||||
created_at=hit.entity.get("created_at", 0),
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
Collection = self._pymilvus["Collection"]
|
||||
col = Collection(collection)
|
||||
col.load()
|
||||
|
||||
id_list = ", ".join([f'"{i}"' for i in ids])
|
||||
expr = f"id in [{id_list}]"
|
||||
|
||||
results = col.query(
|
||||
expr=expr,
|
||||
output_fields=["id", "content", "content_hash", "created_at", "vector"],
|
||||
)
|
||||
|
||||
documents = []
|
||||
for row in results:
|
||||
doc = KnowledgeDocument(
|
||||
id=row["id"],
|
||||
content=row.get("content", ""),
|
||||
embedding=row.get("vector"),
|
||||
metadata={},
|
||||
content_hash=row.get("content_hash"),
|
||||
created_at=row.get("created_at", 0),
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents."""
|
||||
Collection = self._pymilvus["Collection"]
|
||||
col = Collection(collection)
|
||||
col.load()
|
||||
|
||||
if ids:
|
||||
id_list = ", ".join([f'"{i}"' for i in ids])
|
||||
expr = f"id in [{id_list}]"
|
||||
col.delete(expr)
|
||||
return len(ids)
|
||||
elif filters:
|
||||
conditions = [f'{k} == "{v}"' for k, v in filters.items()]
|
||||
expr = " and ".join(conditions)
|
||||
col.delete(expr)
|
||||
return -1
|
||||
return 0
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents."""
|
||||
Collection = self._pymilvus["Collection"]
|
||||
col = Collection(collection)
|
||||
return col.num_entities
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
connections = self._pymilvus["connections"]
|
||||
connections.disconnect("default")
|
||||
@@ -0,0 +1,335 @@
|
||||
"""
|
||||
PGVector implementation of KnowledgeStore.
|
||||
|
||||
Requires: pgvector, psycopg2-binary
|
||||
Install: pip install pgvector psycopg2-binary
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PGVectorKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
PGVector-based knowledge store for vector search.
|
||||
|
||||
Uses PostgreSQL with pgvector extension.
|
||||
|
||||
Example:
|
||||
store = PGVectorKnowledgeStore(
|
||||
url="postgresql://localhost:5432/praisonai"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
host: str = "localhost",
|
||||
port: int = 5432,
|
||||
database: str = "praisonai",
|
||||
user: str = "postgres",
|
||||
password: str = "",
|
||||
schema: str = "public",
|
||||
auto_create_extension: bool = True,
|
||||
):
|
||||
try:
|
||||
import psycopg2
|
||||
from psycopg2 import pool as pg_pool
|
||||
from psycopg2.extras import RealDictCursor
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"psycopg2 is required for PGVector support. "
|
||||
"Install with: pip install psycopg2-binary"
|
||||
)
|
||||
|
||||
self._psycopg2 = psycopg2
|
||||
self._RealDictCursor = RealDictCursor
|
||||
self.schema = schema
|
||||
|
||||
if url:
|
||||
self._pool = pg_pool.ThreadedConnectionPool(1, 5, url)
|
||||
else:
|
||||
self._pool = pg_pool.ThreadedConnectionPool(
|
||||
1, 5,
|
||||
host=host, port=port, database=database,
|
||||
user=user, password=password,
|
||||
)
|
||||
|
||||
if auto_create_extension:
|
||||
self._create_extension()
|
||||
|
||||
def _get_conn(self):
|
||||
return self._pool.getconn()
|
||||
|
||||
def _put_conn(self, conn):
|
||||
self._pool.putconn(conn)
|
||||
|
||||
def _create_extension(self):
|
||||
"""Create pgvector extension if not exists."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
conn.commit()
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def _table_name(self, collection: str) -> str:
|
||||
return f"{self.schema}.praison_vec_{collection}"
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create a new collection table."""
|
||||
table = self._table_name(name)
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {table} (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
content TEXT,
|
||||
content_hash VARCHAR(64),
|
||||
created_at DOUBLE PRECISION,
|
||||
metadata JSONB,
|
||||
embedding vector({dimension})
|
||||
)
|
||||
""")
|
||||
|
||||
# Create index based on distance metric
|
||||
op_map = {"cosine": "vector_cosine_ops", "euclidean": "vector_l2_ops", "dot": "vector_ip_ops"}
|
||||
ops = op_map.get(distance, "vector_cosine_ops")
|
||||
|
||||
cur.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_{name}_embedding
|
||||
ON {table} USING hnsw (embedding {ops})
|
||||
""")
|
||||
conn.commit()
|
||||
logger.info(f"Created PGVector table: {table}")
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete a collection table."""
|
||||
table = self._table_name(name)
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"DROP TABLE IF EXISTS {table}")
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete table {table}: {e}")
|
||||
return False
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if a collection exists."""
|
||||
table = self._table_name(name)
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = %s AND table_name = %s
|
||||
)
|
||||
""", (self.schema, f"praison_vec_{name}"))
|
||||
return cur.fetchone()[0]
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all collections."""
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = %s AND table_name LIKE 'praison_vec_%%'
|
||||
""", (self.schema,))
|
||||
return [row[0].replace("praison_vec_", "") for row in cur.fetchall()]
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents."""
|
||||
table = self._table_name(collection)
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for doc in documents:
|
||||
if doc.embedding is None:
|
||||
raise ValueError(f"Document {doc.id} has no embedding")
|
||||
cur.execute(f"""
|
||||
INSERT INTO {table} (id, content, content_hash, created_at, metadata, embedding)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""", (
|
||||
doc.id, doc.content, doc.content_hash, doc.created_at,
|
||||
json.dumps(doc.metadata) if doc.metadata else None,
|
||||
doc.embedding,
|
||||
))
|
||||
conn.commit()
|
||||
return [doc.id for doc in documents]
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert or update documents."""
|
||||
table = self._table_name(collection)
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
for doc in documents:
|
||||
if doc.embedding is None:
|
||||
raise ValueError(f"Document {doc.id} has no embedding")
|
||||
cur.execute(f"""
|
||||
INSERT INTO {table} (id, content, content_hash, created_at, metadata, embedding)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
ON CONFLICT (id) DO UPDATE SET
|
||||
content = EXCLUDED.content,
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
created_at = EXCLUDED.created_at,
|
||||
metadata = EXCLUDED.metadata,
|
||||
embedding = EXCLUDED.embedding
|
||||
""", (
|
||||
doc.id, doc.content, doc.content_hash, doc.created_at,
|
||||
json.dumps(doc.metadata) if doc.metadata else None,
|
||||
doc.embedding,
|
||||
))
|
||||
conn.commit()
|
||||
return [doc.id for doc in documents]
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: List[float],
|
||||
limit: int = 5,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
score_threshold: Optional[float] = None
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Search for similar documents."""
|
||||
table = self._table_name(collection)
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=self._RealDictCursor) as cur:
|
||||
# Use cosine distance operator <=>
|
||||
query = f"""
|
||||
SELECT id, content, content_hash, created_at, metadata,
|
||||
1 - (embedding <=> %s::vector) as score
|
||||
FROM {table}
|
||||
"""
|
||||
params = [query_embedding]
|
||||
|
||||
if score_threshold:
|
||||
query += " WHERE 1 - (embedding <=> %s::vector) >= %s"
|
||||
params.extend([query_embedding, score_threshold])
|
||||
|
||||
query += " ORDER BY embedding <=> %s::vector LIMIT %s"
|
||||
params.extend([query_embedding, limit])
|
||||
|
||||
cur.execute(query, params)
|
||||
|
||||
documents = []
|
||||
for row in cur.fetchall():
|
||||
doc = KnowledgeDocument(
|
||||
id=row["id"],
|
||||
content=row["content"],
|
||||
embedding=None,
|
||||
metadata=row["metadata"] or {},
|
||||
content_hash=row["content_hash"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
documents.append(doc)
|
||||
return documents
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
table = self._table_name(collection)
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor(cursor_factory=self._RealDictCursor) as cur:
|
||||
placeholders = ",".join(["%s"] * len(ids))
|
||||
cur.execute(f"""
|
||||
SELECT id, content, content_hash, created_at, metadata, embedding::float[]
|
||||
FROM {table} WHERE id IN ({placeholders})
|
||||
""", ids)
|
||||
|
||||
documents = []
|
||||
for row in cur.fetchall():
|
||||
doc = KnowledgeDocument(
|
||||
id=row["id"],
|
||||
content=row["content"],
|
||||
embedding=row["embedding"],
|
||||
metadata=row["metadata"] or {},
|
||||
content_hash=row["content_hash"],
|
||||
created_at=row["created_at"],
|
||||
)
|
||||
documents.append(doc)
|
||||
return documents
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents."""
|
||||
table = self._table_name(collection)
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
if ids:
|
||||
placeholders = ",".join(["%s"] * len(ids))
|
||||
cur.execute(f"DELETE FROM {table} WHERE id IN ({placeholders})", ids)
|
||||
deleted = cur.rowcount
|
||||
else:
|
||||
deleted = 0
|
||||
conn.commit()
|
||||
return deleted
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents."""
|
||||
table = self._table_name(collection)
|
||||
conn = self._get_conn()
|
||||
try:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(f"SELECT COUNT(*) FROM {table}")
|
||||
return cur.fetchone()[0]
|
||||
finally:
|
||||
self._put_conn(conn)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
if self._pool:
|
||||
self._pool.closeall()
|
||||
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
Pinecone implementation of KnowledgeStore.
|
||||
|
||||
Requires: pinecone-client
|
||||
Install: pip install pinecone-client
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PineconeKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
Pinecone-based knowledge store for vector search.
|
||||
|
||||
Cloud-only managed vector database.
|
||||
|
||||
Example:
|
||||
store = PineconeKnowledgeStore(
|
||||
api_key="your-api-key",
|
||||
index_name="praisonai"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: Optional[str] = None,
|
||||
index_name: Optional[str] = None,
|
||||
environment: Optional[str] = None,
|
||||
namespace: str = "",
|
||||
):
|
||||
try:
|
||||
from pinecone import Pinecone
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"pinecone-client is required for Pinecone support. "
|
||||
"Install with: pip install pinecone-client"
|
||||
)
|
||||
|
||||
api_key = api_key or os.getenv("PINECONE_API_KEY")
|
||||
if not api_key:
|
||||
raise ValueError("Pinecone API key is required")
|
||||
|
||||
self._pc = Pinecone(api_key=api_key)
|
||||
self._index_name = index_name or os.getenv("PINECONE_INDEX", "praisonai")
|
||||
self._namespace = namespace
|
||||
self._index = None
|
||||
|
||||
# Try to connect to existing index
|
||||
try:
|
||||
self._index = self._pc.Index(self._index_name)
|
||||
logger.info(f"Connected to Pinecone index: {self._index_name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Index {self._index_name} not found: {e}")
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create a new index (Pinecone calls them indexes, not collections)."""
|
||||
from pinecone import ServerlessSpec
|
||||
|
||||
metric_map = {"cosine": "cosine", "euclidean": "euclidean", "dot": "dotproduct"}
|
||||
|
||||
self._pc.create_index(
|
||||
name=name,
|
||||
dimension=dimension,
|
||||
metric=metric_map.get(distance, "cosine"),
|
||||
spec=ServerlessSpec(cloud="aws", region="us-east-1")
|
||||
)
|
||||
self._index_name = name
|
||||
self._index = self._pc.Index(name)
|
||||
logger.info(f"Created Pinecone index: {name}")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete an index."""
|
||||
try:
|
||||
self._pc.delete_index(name)
|
||||
if name == self._index_name:
|
||||
self._index = None
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete index {name}: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if an index exists."""
|
||||
try:
|
||||
indexes = self._pc.list_indexes()
|
||||
return any(idx.name == name for idx in indexes)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all indexes."""
|
||||
indexes = self._pc.list_indexes()
|
||||
return [idx.name for idx in indexes]
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents."""
|
||||
if collection != self._index_name:
|
||||
self._index = self._pc.Index(collection)
|
||||
self._index_name = collection
|
||||
|
||||
vectors = []
|
||||
ids = []
|
||||
|
||||
for doc in documents:
|
||||
if doc.embedding is None:
|
||||
raise ValueError(f"Document {doc.id} has no embedding")
|
||||
|
||||
vectors.append({
|
||||
"id": doc.id,
|
||||
"values": doc.embedding,
|
||||
"metadata": {
|
||||
"content": doc.content,
|
||||
"content_hash": doc.content_hash or "",
|
||||
"created_at": doc.created_at,
|
||||
**(doc.metadata or {})
|
||||
}
|
||||
})
|
||||
ids.append(doc.id)
|
||||
|
||||
self._index.upsert(vectors=vectors, namespace=self._namespace)
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert or update documents."""
|
||||
return self.insert(collection, documents)
|
||||
|
||||
def search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: List[float],
|
||||
limit: int = 5,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
score_threshold: Optional[float] = None
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Search for similar documents."""
|
||||
if collection != self._index_name:
|
||||
self._index = self._pc.Index(collection)
|
||||
self._index_name = collection
|
||||
|
||||
results = self._index.query(
|
||||
vector=query_embedding,
|
||||
top_k=limit,
|
||||
filter=filters,
|
||||
include_metadata=True,
|
||||
namespace=self._namespace,
|
||||
)
|
||||
|
||||
documents = []
|
||||
for match in results.matches:
|
||||
if score_threshold and match.score < score_threshold:
|
||||
continue
|
||||
|
||||
metadata = match.metadata or {}
|
||||
content = metadata.pop("content", "")
|
||||
content_hash = metadata.pop("content_hash", None)
|
||||
created_at = metadata.pop("created_at", 0)
|
||||
|
||||
doc = KnowledgeDocument(
|
||||
id=match.id,
|
||||
content=content,
|
||||
embedding=None,
|
||||
metadata=metadata,
|
||||
content_hash=content_hash,
|
||||
created_at=created_at,
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
if collection != self._index_name:
|
||||
self._index = self._pc.Index(collection)
|
||||
self._index_name = collection
|
||||
|
||||
results = self._index.fetch(ids=ids, namespace=self._namespace)
|
||||
|
||||
documents = []
|
||||
for doc_id, vector in results.vectors.items():
|
||||
metadata = vector.metadata or {}
|
||||
content = metadata.pop("content", "")
|
||||
content_hash = metadata.pop("content_hash", None)
|
||||
created_at = metadata.pop("created_at", 0)
|
||||
|
||||
doc = KnowledgeDocument(
|
||||
id=doc_id,
|
||||
content=content,
|
||||
embedding=list(vector.values),
|
||||
metadata=metadata,
|
||||
content_hash=content_hash,
|
||||
created_at=created_at,
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents."""
|
||||
if collection != self._index_name:
|
||||
self._index = self._pc.Index(collection)
|
||||
self._index_name = collection
|
||||
|
||||
if ids:
|
||||
self._index.delete(ids=ids, namespace=self._namespace)
|
||||
return len(ids)
|
||||
elif filters:
|
||||
self._index.delete(filter=filters, namespace=self._namespace)
|
||||
return -1
|
||||
return 0
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents."""
|
||||
if collection != self._index_name:
|
||||
self._index = self._pc.Index(collection)
|
||||
self._index_name = collection
|
||||
|
||||
stats = self._index.describe_index_stats()
|
||||
if self._namespace:
|
||||
return stats.namespaces.get(self._namespace, {}).get("vector_count", 0)
|
||||
return stats.total_vector_count
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
self._index = None
|
||||
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
Qdrant implementation of KnowledgeStore.
|
||||
|
||||
Requires: qdrant-client
|
||||
Install: pip install qdrant-client
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class QdrantKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
Qdrant-based knowledge store for vector search.
|
||||
|
||||
Example:
|
||||
store = QdrantKnowledgeStore(
|
||||
url="http://localhost:6333"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
host: str = "localhost",
|
||||
port: int = 6333,
|
||||
grpc_port: int = 6334,
|
||||
api_key: Optional[str] = None,
|
||||
prefer_grpc: bool = False,
|
||||
timeout: int = 30,
|
||||
):
|
||||
"""
|
||||
Initialize Qdrant knowledge store.
|
||||
|
||||
Args:
|
||||
url: Full URL (overrides host/port)
|
||||
host: Qdrant host
|
||||
port: Qdrant HTTP port
|
||||
grpc_port: Qdrant gRPC port
|
||||
api_key: API key for Qdrant Cloud
|
||||
prefer_grpc: Use gRPC instead of HTTP
|
||||
timeout: Request timeout in seconds
|
||||
"""
|
||||
try:
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.models import (
|
||||
Distance, VectorParams, PointStruct,
|
||||
Filter, FieldCondition, MatchValue
|
||||
)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"qdrant-client is required for Qdrant support. "
|
||||
"Install with: pip install qdrant-client"
|
||||
)
|
||||
|
||||
self._QdrantClient = QdrantClient
|
||||
self._Distance = Distance
|
||||
self._VectorParams = VectorParams
|
||||
self._PointStruct = PointStruct
|
||||
self._Filter = Filter
|
||||
self._FieldCondition = FieldCondition
|
||||
self._MatchValue = MatchValue
|
||||
|
||||
if url:
|
||||
self._client = QdrantClient(url=url, api_key=api_key, timeout=timeout)
|
||||
else:
|
||||
self._client = QdrantClient(
|
||||
host=host,
|
||||
port=port,
|
||||
grpc_port=grpc_port,
|
||||
api_key=api_key,
|
||||
prefer_grpc=prefer_grpc,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
logger.info(f"Connected to Qdrant at {url or f'{host}:{port}'}")
|
||||
|
||||
def _get_distance(self, distance: str) -> Any:
|
||||
"""Convert distance string to Qdrant Distance enum."""
|
||||
distance_map = {
|
||||
"cosine": self._Distance.COSINE,
|
||||
"euclidean": self._Distance.EUCLID,
|
||||
"dot": self._Distance.DOT,
|
||||
"manhattan": self._Distance.MANHATTAN,
|
||||
}
|
||||
return distance_map.get(distance.lower(), self._Distance.COSINE)
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create a new collection."""
|
||||
self._client.create_collection(
|
||||
collection_name=name,
|
||||
vectors_config=self._VectorParams(
|
||||
size=dimension,
|
||||
distance=self._get_distance(distance)
|
||||
)
|
||||
)
|
||||
logger.info(f"Created Qdrant collection: {name} (dim={dimension}, distance={distance})")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete a collection."""
|
||||
try:
|
||||
self._client.delete_collection(collection_name=name)
|
||||
logger.info(f"Deleted Qdrant collection: {name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete collection {name}: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if a collection exists."""
|
||||
try:
|
||||
collections = self._client.get_collections()
|
||||
return any(c.name == name for c in collections.collections)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all collections."""
|
||||
collections = self._client.get_collections()
|
||||
return [c.name for c in collections.collections]
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents into a collection."""
|
||||
points = []
|
||||
ids = []
|
||||
|
||||
for i, doc in enumerate(documents):
|
||||
if doc.embedding is None:
|
||||
raise ValueError(f"Document {doc.id} has no embedding")
|
||||
|
||||
point = self._PointStruct(
|
||||
id=i, # Qdrant prefers numeric IDs for performance
|
||||
vector=doc.embedding,
|
||||
payload={
|
||||
"doc_id": doc.id,
|
||||
"content": doc.content,
|
||||
"content_hash": doc.content_hash,
|
||||
"created_at": doc.created_at,
|
||||
**(doc.metadata or {})
|
||||
}
|
||||
)
|
||||
points.append(point)
|
||||
ids.append(doc.id)
|
||||
|
||||
self._client.upsert(collection_name=collection, points=points)
|
||||
logger.debug(f"Inserted {len(documents)} documents into {collection}")
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert or update documents."""
|
||||
return self.insert(collection, documents)
|
||||
|
||||
def search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: List[float],
|
||||
limit: int = 5,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
score_threshold: Optional[float] = None
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Search for similar documents."""
|
||||
search_filter = None
|
||||
if filters:
|
||||
conditions = [
|
||||
self._FieldCondition(
|
||||
key=k,
|
||||
match=self._MatchValue(value=v)
|
||||
)
|
||||
for k, v in filters.items()
|
||||
]
|
||||
search_filter = self._Filter(must=conditions)
|
||||
|
||||
results = self._client.query_points(
|
||||
collection_name=collection,
|
||||
query=query_embedding,
|
||||
limit=limit,
|
||||
query_filter=search_filter,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
|
||||
documents = []
|
||||
for point in results.points:
|
||||
payload = point.payload or {}
|
||||
doc = KnowledgeDocument(
|
||||
id=payload.get("doc_id", str(point.id)),
|
||||
content=payload.get("content", ""),
|
||||
embedding=None, # Don't return embeddings by default
|
||||
metadata={
|
||||
k: v for k, v in payload.items()
|
||||
if k not in ("doc_id", "content", "content_hash", "created_at")
|
||||
},
|
||||
content_hash=payload.get("content_hash"),
|
||||
created_at=payload.get("created_at", 0),
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
# Qdrant uses numeric IDs internally, so we search by doc_id in payload
|
||||
documents = []
|
||||
for doc_id in ids:
|
||||
results = self._client.scroll(
|
||||
collection_name=collection,
|
||||
scroll_filter=self._Filter(
|
||||
must=[
|
||||
self._FieldCondition(
|
||||
key="doc_id",
|
||||
match=self._MatchValue(value=doc_id)
|
||||
)
|
||||
]
|
||||
),
|
||||
limit=1,
|
||||
)
|
||||
for point in results[0]:
|
||||
payload = point.payload or {}
|
||||
doc = KnowledgeDocument(
|
||||
id=payload.get("doc_id", str(point.id)),
|
||||
content=payload.get("content", ""),
|
||||
embedding=list(point.vector) if point.vector else None,
|
||||
metadata={
|
||||
k: v for k, v in payload.items()
|
||||
if k not in ("doc_id", "content", "content_hash", "created_at")
|
||||
},
|
||||
content_hash=payload.get("content_hash"),
|
||||
created_at=payload.get("created_at", 0),
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents by IDs or filters."""
|
||||
if ids:
|
||||
# Delete by doc_id in payload
|
||||
for doc_id in ids:
|
||||
self._client.delete(
|
||||
collection_name=collection,
|
||||
points_selector=self._Filter(
|
||||
must=[
|
||||
self._FieldCondition(
|
||||
key="doc_id",
|
||||
match=self._MatchValue(value=doc_id)
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
return len(ids)
|
||||
elif filters:
|
||||
conditions = [
|
||||
self._FieldCondition(
|
||||
key=k,
|
||||
match=self._MatchValue(value=v)
|
||||
)
|
||||
for k, v in filters.items()
|
||||
]
|
||||
self._client.delete(
|
||||
collection_name=collection,
|
||||
points_selector=self._Filter(must=conditions)
|
||||
)
|
||||
return -1 # Unknown count
|
||||
return 0
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents in a collection."""
|
||||
info = self._client.get_collection(collection_name=collection)
|
||||
return info.points_count or 0
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
Redis Vector implementation of KnowledgeStore.
|
||||
|
||||
Requires: redis
|
||||
Install: pip install redis
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RedisVectorKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
Redis-based knowledge store using RediSearch for vector search.
|
||||
|
||||
Requires Redis Stack or Redis with RediSearch module.
|
||||
|
||||
Example:
|
||||
store = RedisVectorKnowledgeStore(
|
||||
url="redis://localhost:6379"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
host: str = "localhost",
|
||||
port: int = 6379,
|
||||
password: Optional[str] = None,
|
||||
prefix: str = "praison:vec:",
|
||||
):
|
||||
try:
|
||||
import redis
|
||||
from redis.commands.search.field import VectorField, TextField, NumericField
|
||||
from redis.commands.search.indexDefinition import IndexDefinition, IndexType
|
||||
from redis.commands.search.query import Query
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"redis is required for Redis Vector support. "
|
||||
"Install with: pip install redis"
|
||||
)
|
||||
|
||||
self._redis = redis
|
||||
self._VectorField = VectorField
|
||||
self._TextField = TextField
|
||||
self._NumericField = NumericField
|
||||
self._IndexDefinition = IndexDefinition
|
||||
self._IndexType = IndexType
|
||||
self._Query = Query
|
||||
|
||||
self.prefix = prefix
|
||||
|
||||
if url:
|
||||
self._client = redis.from_url(url, decode_responses=False)
|
||||
else:
|
||||
self._client = redis.Redis(
|
||||
host=host, port=port, password=password, decode_responses=False
|
||||
)
|
||||
|
||||
self._client.ping()
|
||||
logger.info(f"Connected to Redis at {url or f'{host}:{port}'}")
|
||||
|
||||
def _index_name(self, collection: str) -> str:
|
||||
return f"{self.prefix}{collection}:idx"
|
||||
|
||||
def _doc_key(self, collection: str, doc_id: str) -> str:
|
||||
return f"{self.prefix}{collection}:{doc_id}"
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create a new index."""
|
||||
index_name = self._index_name(name)
|
||||
|
||||
distance_map = {"cosine": "COSINE", "euclidean": "L2", "dot": "IP"}
|
||||
|
||||
schema = (
|
||||
self._TextField("content"),
|
||||
self._TextField("content_hash"),
|
||||
self._NumericField("created_at"),
|
||||
self._VectorField(
|
||||
"embedding",
|
||||
"HNSW",
|
||||
{
|
||||
"TYPE": "FLOAT32",
|
||||
"DIM": dimension,
|
||||
"DISTANCE_METRIC": distance_map.get(distance, "COSINE"),
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
definition = self._IndexDefinition(
|
||||
prefix=[f"{self.prefix}{name}:"],
|
||||
index_type=self._IndexType.HASH,
|
||||
)
|
||||
|
||||
try:
|
||||
self._client.ft(index_name).create_index(schema, definition=definition)
|
||||
logger.info(f"Created Redis index: {index_name}")
|
||||
except Exception as e:
|
||||
if "Index already exists" not in str(e):
|
||||
raise
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete an index and all documents."""
|
||||
index_name = self._index_name(name)
|
||||
try:
|
||||
self._client.ft(index_name).dropindex(delete_documents=True)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete index {index_name}: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if an index exists."""
|
||||
index_name = self._index_name(name)
|
||||
try:
|
||||
self._client.ft(index_name).info()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all collections."""
|
||||
try:
|
||||
indexes = self._client.execute_command("FT._LIST")
|
||||
prefix = f"{self.prefix}"
|
||||
return [
|
||||
idx.decode().replace(prefix, "").replace(":idx", "")
|
||||
for idx in indexes
|
||||
if idx.decode().startswith(prefix)
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents."""
|
||||
import numpy as np
|
||||
|
||||
ids = []
|
||||
pipe = self._client.pipeline()
|
||||
|
||||
for doc in documents:
|
||||
if doc.embedding is None:
|
||||
raise ValueError(f"Document {doc.id} has no embedding")
|
||||
|
||||
key = self._doc_key(collection, doc.id)
|
||||
embedding_bytes = np.array(doc.embedding, dtype=np.float32).tobytes()
|
||||
|
||||
pipe.hset(key, mapping={
|
||||
"content": doc.content,
|
||||
"content_hash": doc.content_hash or "",
|
||||
"created_at": doc.created_at,
|
||||
"embedding": embedding_bytes,
|
||||
})
|
||||
ids.append(doc.id)
|
||||
|
||||
pipe.execute()
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert or update documents."""
|
||||
return self.insert(collection, documents)
|
||||
|
||||
def search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: List[float],
|
||||
limit: int = 5,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
score_threshold: Optional[float] = None
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Search for similar documents."""
|
||||
import numpy as np
|
||||
|
||||
index_name = self._index_name(collection)
|
||||
query_bytes = np.array(query_embedding, dtype=np.float32).tobytes()
|
||||
|
||||
query_str = f"*=>[KNN {limit} @embedding $vec AS score]"
|
||||
|
||||
query = (
|
||||
self._Query(query_str)
|
||||
.return_fields("content", "content_hash", "created_at", "score")
|
||||
.sort_by("score")
|
||||
.dialect(2)
|
||||
)
|
||||
|
||||
results = self._client.ft(index_name).search(
|
||||
query, query_params={"vec": query_bytes}
|
||||
)
|
||||
|
||||
documents = []
|
||||
for doc in results.docs:
|
||||
score = float(doc.score) if hasattr(doc, "score") else 0
|
||||
# Redis returns distance, convert to similarity for cosine
|
||||
similarity = 1 - score
|
||||
|
||||
if score_threshold and similarity < score_threshold:
|
||||
continue
|
||||
|
||||
doc_id = doc.id.decode() if isinstance(doc.id, bytes) else doc.id
|
||||
doc_id = doc_id.split(":")[-1] # Remove prefix
|
||||
|
||||
knowledge_doc = KnowledgeDocument(
|
||||
id=doc_id,
|
||||
content=doc.content.decode() if isinstance(doc.content, bytes) else doc.content,
|
||||
embedding=None,
|
||||
metadata={},
|
||||
content_hash=doc.content_hash.decode() if isinstance(doc.content_hash, bytes) else doc.content_hash,
|
||||
created_at=float(doc.created_at) if doc.created_at else 0,
|
||||
)
|
||||
documents.append(knowledge_doc)
|
||||
|
||||
return documents
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
import numpy as np
|
||||
|
||||
documents = []
|
||||
for doc_id in ids:
|
||||
key = self._doc_key(collection, doc_id)
|
||||
data = self._client.hgetall(key)
|
||||
|
||||
if data:
|
||||
embedding = None
|
||||
if b"embedding" in data:
|
||||
embedding = np.frombuffer(data[b"embedding"], dtype=np.float32).tolist()
|
||||
|
||||
doc = KnowledgeDocument(
|
||||
id=doc_id,
|
||||
content=data.get(b"content", b"").decode(),
|
||||
embedding=embedding,
|
||||
metadata={},
|
||||
content_hash=data.get(b"content_hash", b"").decode(),
|
||||
created_at=float(data.get(b"created_at", 0)),
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents."""
|
||||
if ids:
|
||||
pipe = self._client.pipeline()
|
||||
for doc_id in ids:
|
||||
key = self._doc_key(collection, doc_id)
|
||||
pipe.delete(key)
|
||||
pipe.execute()
|
||||
return len(ids)
|
||||
return 0
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents."""
|
||||
index_name = self._index_name(collection)
|
||||
try:
|
||||
info = self._client.ft(index_name).info()
|
||||
return int(info.get("num_docs", 0))
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
if self._client:
|
||||
self._client.close()
|
||||
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
Weaviate implementation of KnowledgeStore.
|
||||
|
||||
Requires: weaviate-client
|
||||
Install: pip install weaviate-client
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WeaviateKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
Weaviate-based knowledge store for vector search.
|
||||
|
||||
Example:
|
||||
store = WeaviateKnowledgeStore(
|
||||
url="http://localhost:8080"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
additional_headers: Optional[Dict[str, str]] = None,
|
||||
):
|
||||
try:
|
||||
import weaviate
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"weaviate-client is required for Weaviate support. "
|
||||
"Install with: pip install weaviate-client"
|
||||
)
|
||||
|
||||
url = url or os.getenv("WEAVIATE_URL", "http://localhost:8080")
|
||||
api_key = api_key or os.getenv("WEAVIATE_API_KEY")
|
||||
|
||||
auth = weaviate.auth.AuthApiKey(api_key) if api_key else None
|
||||
self._client = weaviate.connect_to_custom(
|
||||
http_host=url.replace("http://", "").replace("https://", "").split(":")[0],
|
||||
http_port=int(url.split(":")[-1]) if ":" in url.split("/")[-1] else 8080,
|
||||
http_secure=url.startswith("https"),
|
||||
auth_credentials=auth,
|
||||
additional_headers=additional_headers,
|
||||
)
|
||||
logger.info(f"Connected to Weaviate at {url}")
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create a new collection (class in Weaviate)."""
|
||||
from weaviate.classes.config import Configure, Property, DataType
|
||||
|
||||
distance_map = {"cosine": "cosine", "euclidean": "l2-squared", "dot": "dot"}
|
||||
|
||||
self._client.collections.create(
|
||||
name=name,
|
||||
vectorizer_config=Configure.Vectorizer.none(),
|
||||
vector_index_config=Configure.VectorIndex.hnsw(
|
||||
distance_metric=distance_map.get(distance, "cosine")
|
||||
),
|
||||
properties=[
|
||||
Property(name="content", data_type=DataType.TEXT),
|
||||
Property(name="content_hash", data_type=DataType.TEXT),
|
||||
Property(name="created_at", data_type=DataType.NUMBER),
|
||||
]
|
||||
)
|
||||
logger.info(f"Created Weaviate collection: {name}")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete a collection."""
|
||||
try:
|
||||
self._client.collections.delete(name)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete collection {name}: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if a collection exists."""
|
||||
return self._client.collections.exists(name)
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all collections."""
|
||||
return [c.name for c in self._client.collections.list_all().values()]
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents."""
|
||||
col = self._client.collections.get(collection)
|
||||
ids = []
|
||||
|
||||
with col.batch.dynamic() as batch:
|
||||
for doc in documents:
|
||||
if doc.embedding is None:
|
||||
raise ValueError(f"Document {doc.id} has no embedding")
|
||||
|
||||
properties = {
|
||||
"content": doc.content,
|
||||
"content_hash": doc.content_hash or "",
|
||||
"created_at": doc.created_at,
|
||||
**(doc.metadata or {})
|
||||
}
|
||||
|
||||
batch.add_object(
|
||||
properties=properties,
|
||||
vector=doc.embedding,
|
||||
uuid=doc.id,
|
||||
)
|
||||
ids.append(doc.id)
|
||||
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert or update documents."""
|
||||
return self.insert(collection, documents)
|
||||
|
||||
def search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: List[float],
|
||||
limit: int = 5,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
score_threshold: Optional[float] = None
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Search for similar documents."""
|
||||
col = self._client.collections.get(collection)
|
||||
|
||||
query = col.query.near_vector(
|
||||
near_vector=query_embedding,
|
||||
limit=limit,
|
||||
return_metadata=["distance"],
|
||||
)
|
||||
|
||||
documents = []
|
||||
for obj in query.objects:
|
||||
if score_threshold:
|
||||
score = 1 - (obj.metadata.distance or 0)
|
||||
if score < score_threshold:
|
||||
continue
|
||||
|
||||
props = obj.properties
|
||||
doc = KnowledgeDocument(
|
||||
id=str(obj.uuid),
|
||||
content=props.get("content", ""),
|
||||
embedding=None,
|
||||
metadata={k: v for k, v in props.items() if k not in ("content", "content_hash", "created_at")},
|
||||
content_hash=props.get("content_hash"),
|
||||
created_at=props.get("created_at", 0),
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
col = self._client.collections.get(collection)
|
||||
documents = []
|
||||
|
||||
for doc_id in ids:
|
||||
try:
|
||||
obj = col.query.fetch_object_by_id(doc_id, include_vector=True)
|
||||
if obj:
|
||||
props = obj.properties
|
||||
doc = KnowledgeDocument(
|
||||
id=str(obj.uuid),
|
||||
content=props.get("content", ""),
|
||||
embedding=list(obj.vector) if obj.vector else None,
|
||||
metadata={k: v for k, v in props.items() if k not in ("content", "content_hash", "created_at")},
|
||||
content_hash=props.get("content_hash"),
|
||||
created_at=props.get("created_at", 0),
|
||||
)
|
||||
documents.append(doc)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return documents
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents."""
|
||||
col = self._client.collections.get(collection)
|
||||
|
||||
if ids:
|
||||
for doc_id in ids:
|
||||
col.data.delete_by_id(doc_id)
|
||||
return len(ids)
|
||||
return 0
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents."""
|
||||
col = self._client.collections.get(collection)
|
||||
return col.aggregate.over_all(total_count=True).total_count or 0
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
if self._client:
|
||||
self._client.close()
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
Schema versioning and migrations for PraisonAI persistence.
|
||||
|
||||
This module provides tools for managing database schema versions
|
||||
and applying migrations when upgrading.
|
||||
"""
|
||||
|
||||
from .manager import MigrationManager, SchemaVersion
|
||||
|
||||
__all__ = ["MigrationManager", "SchemaVersion"]
|
||||
@@ -0,0 +1,251 @@
|
||||
"""
|
||||
Migration manager for PraisonAI persistence schema versioning.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from packaging import version as pkg_version
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SchemaVersion:
|
||||
"""Represents a schema version."""
|
||||
version: str
|
||||
description: str = ""
|
||||
applied_at: Optional[float] = None
|
||||
|
||||
def __lt__(self, other: "SchemaVersion") -> bool:
|
||||
return pkg_version.parse(self.version) < pkg_version.parse(other.version)
|
||||
|
||||
def __eq__(self, other: object) -> bool:
|
||||
if not isinstance(other, SchemaVersion):
|
||||
return False
|
||||
return self.version == other.version
|
||||
|
||||
|
||||
@dataclass
|
||||
class Migration:
|
||||
"""Represents a single migration."""
|
||||
version: str
|
||||
description: str
|
||||
up: Callable[[Any], None]
|
||||
down: Optional[Callable[[Any], None]] = None
|
||||
|
||||
|
||||
class MigrationManager:
|
||||
"""
|
||||
Manages database schema versions and migrations.
|
||||
|
||||
Example:
|
||||
manager = MigrationManager(db_adapter)
|
||||
manager.register_migration(
|
||||
version="1.0.0",
|
||||
description="Initial schema",
|
||||
up=lambda db: db.execute("CREATE TABLE ..."),
|
||||
down=lambda db: db.execute("DROP TABLE ...")
|
||||
)
|
||||
manager.migrate_up() # Apply all pending migrations
|
||||
"""
|
||||
|
||||
# Current schema version
|
||||
CURRENT_VERSION = "1.0.0"
|
||||
|
||||
def __init__(self, db_adapter: Any = None):
|
||||
"""
|
||||
Initialize migration manager.
|
||||
|
||||
Args:
|
||||
db_adapter: Database adapter (PraisonDB or similar)
|
||||
"""
|
||||
self._db = db_adapter
|
||||
self._migrations: List[Migration] = []
|
||||
self._versions_table = "praison_schema_versions"
|
||||
|
||||
# Register built-in migrations
|
||||
self._register_builtin_migrations()
|
||||
|
||||
def _register_builtin_migrations(self):
|
||||
"""Register built-in migrations."""
|
||||
self.register_migration(
|
||||
version="1.0.0",
|
||||
description="Initial schema with sessions, messages, runs",
|
||||
up=self._migrate_1_0_0_up,
|
||||
down=self._migrate_1_0_0_down
|
||||
)
|
||||
|
||||
def register_migration(
|
||||
self,
|
||||
version: str,
|
||||
description: str,
|
||||
up: Callable[[Any], None],
|
||||
down: Optional[Callable[[Any], None]] = None
|
||||
):
|
||||
"""
|
||||
Register a new migration.
|
||||
|
||||
Args:
|
||||
version: Version string (e.g., "1.0.0")
|
||||
description: Human-readable description
|
||||
up: Function to apply migration
|
||||
down: Optional function to rollback migration
|
||||
"""
|
||||
migration = Migration(
|
||||
version=version,
|
||||
description=description,
|
||||
up=up,
|
||||
down=down
|
||||
)
|
||||
self._migrations.append(migration)
|
||||
self._migrations.sort(key=lambda m: pkg_version.parse(m.version))
|
||||
|
||||
def get_current_version(self) -> Optional[str]:
|
||||
"""Get the current schema version from database."""
|
||||
if not self._db:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Try to get version from state store
|
||||
if hasattr(self._db, '_state_store') and self._db._state_store:
|
||||
self._db._init_stores()
|
||||
version_data = self._db._state_store.get(f"{self._versions_table}:current")
|
||||
if version_data:
|
||||
return version_data.get("version")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get schema version: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def set_current_version(self, version: str):
|
||||
"""Set the current schema version in database."""
|
||||
if not self._db:
|
||||
return
|
||||
|
||||
try:
|
||||
if hasattr(self._db, '_state_store') and self._db._state_store:
|
||||
self._db._init_stores()
|
||||
import time
|
||||
self._db._state_store.set(f"{self._versions_table}:current", {
|
||||
"version": version,
|
||||
"applied_at": time.time()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to set schema version: {e}")
|
||||
|
||||
def get_pending_migrations(self) -> List[Migration]:
|
||||
"""Get list of migrations that haven't been applied."""
|
||||
current = self.get_current_version()
|
||||
if not current:
|
||||
return self._migrations.copy()
|
||||
|
||||
current_ver = pkg_version.parse(current)
|
||||
return [
|
||||
m for m in self._migrations
|
||||
if pkg_version.parse(m.version) > current_ver
|
||||
]
|
||||
|
||||
def migrate_up(self, target_version: Optional[str] = None) -> List[str]:
|
||||
"""
|
||||
Apply pending migrations up to target version.
|
||||
|
||||
Args:
|
||||
target_version: Optional target version (default: latest)
|
||||
|
||||
Returns:
|
||||
List of applied migration versions
|
||||
"""
|
||||
pending = self.get_pending_migrations()
|
||||
if not pending:
|
||||
logger.info("No pending migrations")
|
||||
return []
|
||||
|
||||
target = pkg_version.parse(target_version) if target_version else None
|
||||
applied = []
|
||||
|
||||
for migration in pending:
|
||||
if target and pkg_version.parse(migration.version) > target:
|
||||
break
|
||||
|
||||
logger.info(f"Applying migration {migration.version}: {migration.description}")
|
||||
try:
|
||||
migration.up(self._db)
|
||||
self.set_current_version(migration.version)
|
||||
applied.append(migration.version)
|
||||
logger.info(f"Successfully applied migration {migration.version}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to apply migration {migration.version}: {e}")
|
||||
raise
|
||||
|
||||
return applied
|
||||
|
||||
def migrate_down(self, target_version: str) -> List[str]:
|
||||
"""
|
||||
Rollback migrations down to target version.
|
||||
|
||||
Args:
|
||||
target_version: Target version to rollback to
|
||||
|
||||
Returns:
|
||||
List of rolled back migration versions
|
||||
"""
|
||||
current = self.get_current_version()
|
||||
if not current:
|
||||
logger.info("No migrations to rollback")
|
||||
return []
|
||||
|
||||
current_ver = pkg_version.parse(current)
|
||||
target_ver = pkg_version.parse(target_version)
|
||||
|
||||
if target_ver >= current_ver:
|
||||
logger.info("Target version is not lower than current")
|
||||
return []
|
||||
|
||||
# Get migrations to rollback (in reverse order)
|
||||
to_rollback = [
|
||||
m for m in reversed(self._migrations)
|
||||
if pkg_version.parse(m.version) > target_ver and pkg_version.parse(m.version) <= current_ver
|
||||
]
|
||||
|
||||
rolled_back = []
|
||||
for migration in to_rollback:
|
||||
if not migration.down:
|
||||
logger.warning(f"Migration {migration.version} has no down function, skipping")
|
||||
continue
|
||||
|
||||
logger.info(f"Rolling back migration {migration.version}")
|
||||
try:
|
||||
migration.down(self._db)
|
||||
rolled_back.append(migration.version)
|
||||
logger.info(f"Successfully rolled back migration {migration.version}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to rollback migration {migration.version}: {e}")
|
||||
raise
|
||||
|
||||
self.set_current_version(target_version)
|
||||
return rolled_back
|
||||
|
||||
def get_migration_status(self) -> Dict[str, Any]:
|
||||
"""Get current migration status."""
|
||||
current = self.get_current_version()
|
||||
pending = self.get_pending_migrations()
|
||||
|
||||
return {
|
||||
"current_version": current,
|
||||
"latest_version": self._migrations[-1].version if self._migrations else None,
|
||||
"pending_count": len(pending),
|
||||
"pending_versions": [m.version for m in pending],
|
||||
"all_versions": [m.version for m in self._migrations]
|
||||
}
|
||||
|
||||
# --- Built-in Migrations ---
|
||||
|
||||
def _migrate_1_0_0_up(self, db: Any):
|
||||
"""Initial schema migration."""
|
||||
# This is a no-op since tables are created on first use
|
||||
logger.info("Initial schema (1.0.0) - tables created on first use")
|
||||
|
||||
def _migrate_1_0_0_down(self, db: Any):
|
||||
"""Rollback initial schema."""
|
||||
logger.info("Rolling back initial schema (1.0.0)")
|
||||
@@ -0,0 +1,406 @@
|
||||
"""
|
||||
PersistenceOrchestrator - Central coordinator for all persistence operations.
|
||||
|
||||
Hooks into agent lifecycle to provide automatic conversation persistence,
|
||||
knowledge retrieval, and state management.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from .conversation.base import ConversationStore, ConversationSession, ConversationMessage
|
||||
from .knowledge.base import KnowledgeStore, KnowledgeDocument
|
||||
from .state.base import StateStore
|
||||
from .config import PersistenceConfig
|
||||
from .factory import create_stores_from_config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PersistenceOrchestrator:
|
||||
"""
|
||||
Central coordinator for all persistence operations.
|
||||
|
||||
Lives in wrapper layer, hooks into agent lifecycle to provide:
|
||||
- Automatic session loading/creation at agent start
|
||||
- Message persistence during conversation
|
||||
- Session metadata updates at agent end
|
||||
- Knowledge retrieval for RAG
|
||||
- State caching
|
||||
|
||||
Example:
|
||||
orchestrator = PersistenceOrchestrator(
|
||||
conversation_store=PostgresConversationStore(...),
|
||||
knowledge_store=QdrantKnowledgeStore(...),
|
||||
state_store=RedisStateStore(...),
|
||||
)
|
||||
|
||||
# Hook into agent lifecycle
|
||||
history = orchestrator.on_agent_start(agent, session_id="sess-123")
|
||||
# ... agent runs ...
|
||||
orchestrator.on_message(session_id, user_message)
|
||||
orchestrator.on_message(session_id, assistant_message)
|
||||
orchestrator.on_agent_end(agent, session_id)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
conversation_store: Optional[ConversationStore] = None,
|
||||
knowledge_store: Optional[KnowledgeStore] = None,
|
||||
state_store: Optional[StateStore] = None,
|
||||
config: Optional[PersistenceConfig] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the orchestrator.
|
||||
|
||||
Args:
|
||||
conversation_store: Store for session/message persistence
|
||||
knowledge_store: Store for vector embeddings
|
||||
state_store: Store for key-value state
|
||||
config: Configuration (alternative to passing stores directly)
|
||||
"""
|
||||
if config:
|
||||
stores = create_stores_from_config(config)
|
||||
self.conversation = stores["conversation"] or conversation_store
|
||||
self.knowledge = stores["knowledge"] or knowledge_store
|
||||
self.state = stores["state"] or state_store
|
||||
self._config = config
|
||||
else:
|
||||
self.conversation = conversation_store
|
||||
self.knowledge = knowledge_store
|
||||
self.state = state_store
|
||||
self._config = None
|
||||
|
||||
self._current_session: Optional[ConversationSession] = None
|
||||
self._session_cache: Dict[str, ConversationSession] = {}
|
||||
|
||||
@classmethod
|
||||
def from_config(cls, config: PersistenceConfig) -> "PersistenceOrchestrator":
|
||||
"""Create orchestrator from configuration."""
|
||||
return cls(config=config)
|
||||
|
||||
@classmethod
|
||||
def from_env(cls) -> "PersistenceOrchestrator":
|
||||
"""Create orchestrator from environment variables."""
|
||||
config = PersistenceConfig.from_env()
|
||||
return cls(config=config)
|
||||
|
||||
# =========================================================================
|
||||
# Agent Lifecycle Hooks
|
||||
# =========================================================================
|
||||
|
||||
def on_agent_start(
|
||||
self,
|
||||
agent: Any,
|
||||
session_id: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
resume: bool = True,
|
||||
) -> List[ConversationMessage]:
|
||||
"""
|
||||
Called before agent run. Loads or creates session.
|
||||
|
||||
Args:
|
||||
agent: The agent instance
|
||||
session_id: Session ID (generated if not provided)
|
||||
user_id: User ID for session
|
||||
resume: Whether to load existing session history
|
||||
|
||||
Returns:
|
||||
List of previous messages if resuming, empty list otherwise
|
||||
"""
|
||||
if not session_id:
|
||||
session_id = str(uuid.uuid4())
|
||||
|
||||
if not self.conversation:
|
||||
logger.debug("No conversation store configured, skipping session load")
|
||||
return []
|
||||
|
||||
# Try to load existing session
|
||||
session = None
|
||||
if resume:
|
||||
session = self.conversation.get_session(session_id)
|
||||
|
||||
if session:
|
||||
logger.info(f"Resuming session: {session_id}")
|
||||
self._current_session = session
|
||||
self._session_cache[session_id] = session
|
||||
|
||||
# Load previous messages
|
||||
messages = self.conversation.get_messages(session_id)
|
||||
return messages
|
||||
else:
|
||||
# Create new session
|
||||
agent_id = getattr(agent, "name", None) or getattr(agent, "agent_id", None)
|
||||
session = ConversationSession(
|
||||
session_id=session_id,
|
||||
user_id=user_id,
|
||||
agent_id=agent_id,
|
||||
name=f"Session {session_id[:8]}",
|
||||
metadata={"agent_type": type(agent).__name__},
|
||||
)
|
||||
self.conversation.create_session(session)
|
||||
logger.info(f"Created new session: {session_id}")
|
||||
self._current_session = session
|
||||
self._session_cache[session_id] = session
|
||||
return []
|
||||
|
||||
def on_message(
|
||||
self,
|
||||
session_id: str,
|
||||
role: str,
|
||||
content: str,
|
||||
tool_calls: Optional[List[Dict]] = None,
|
||||
tool_call_id: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> Optional[ConversationMessage]:
|
||||
"""
|
||||
Called after each message (user or assistant).
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
role: Message role (user, assistant, system, tool)
|
||||
content: Message content
|
||||
tool_calls: Tool calls (for assistant messages)
|
||||
tool_call_id: Tool call ID (for tool response messages)
|
||||
metadata: Additional metadata
|
||||
|
||||
Returns:
|
||||
The persisted message, or None if no store configured
|
||||
"""
|
||||
if not self.conversation:
|
||||
return None
|
||||
|
||||
message = ConversationMessage(
|
||||
id=str(uuid.uuid4()),
|
||||
session_id=session_id,
|
||||
role=role,
|
||||
content=content,
|
||||
tool_calls=tool_calls,
|
||||
tool_call_id=tool_call_id,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
self.conversation.add_message(session_id, message)
|
||||
logger.debug(f"Persisted {role} message to session {session_id}")
|
||||
return message
|
||||
|
||||
def on_agent_end(
|
||||
self,
|
||||
agent: Any,
|
||||
session_id: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Called after agent run. Updates session metadata.
|
||||
|
||||
Args:
|
||||
agent: The agent instance
|
||||
session_id: Session ID
|
||||
metadata: Additional metadata to store
|
||||
"""
|
||||
if not self.conversation:
|
||||
return
|
||||
|
||||
session = self._session_cache.get(session_id) or self.conversation.get_session(session_id)
|
||||
if session:
|
||||
session.updated_at = time.time()
|
||||
if metadata:
|
||||
session.metadata = {**(session.metadata or {}), **metadata}
|
||||
self.conversation.update_session(session)
|
||||
logger.debug(f"Updated session metadata: {session_id}")
|
||||
|
||||
# =========================================================================
|
||||
# Knowledge Retrieval
|
||||
# =========================================================================
|
||||
|
||||
def retrieve_knowledge(
|
||||
self,
|
||||
query_embedding: List[float],
|
||||
collection: str = "default",
|
||||
limit: int = 5,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""
|
||||
Retrieve relevant documents for RAG.
|
||||
|
||||
Args:
|
||||
query_embedding: Query vector embedding
|
||||
collection: Collection name
|
||||
limit: Max documents to return
|
||||
filters: Metadata filters
|
||||
|
||||
Returns:
|
||||
List of relevant documents
|
||||
"""
|
||||
if not self.knowledge:
|
||||
logger.debug("No knowledge store configured")
|
||||
return []
|
||||
|
||||
return self.knowledge.search(
|
||||
collection=collection,
|
||||
query_embedding=query_embedding,
|
||||
limit=limit,
|
||||
filters=filters,
|
||||
)
|
||||
|
||||
def add_knowledge(
|
||||
self,
|
||||
documents: List[KnowledgeDocument],
|
||||
collection: str = "default",
|
||||
) -> List[str]:
|
||||
"""
|
||||
Add documents to knowledge store.
|
||||
|
||||
Args:
|
||||
documents: Documents with embeddings
|
||||
collection: Collection name
|
||||
|
||||
Returns:
|
||||
List of document IDs
|
||||
"""
|
||||
if not self.knowledge:
|
||||
raise ValueError("No knowledge store configured")
|
||||
|
||||
return self.knowledge.upsert(collection, documents)
|
||||
|
||||
# =========================================================================
|
||||
# State Management
|
||||
# =========================================================================
|
||||
|
||||
def get_state(self, key: str) -> Optional[Any]:
|
||||
"""Get state value."""
|
||||
if not self.state:
|
||||
return None
|
||||
return self.state.get(key)
|
||||
|
||||
def set_state(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
|
||||
"""Set state value with optional TTL."""
|
||||
if not self.state:
|
||||
return
|
||||
self.state.set(key, value, ttl)
|
||||
|
||||
def delete_state(self, key: str) -> bool:
|
||||
"""Delete state value."""
|
||||
if not self.state:
|
||||
return False
|
||||
return self.state.delete(key)
|
||||
|
||||
# =========================================================================
|
||||
# Session Management
|
||||
# =========================================================================
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
"""Get a session by ID."""
|
||||
if not self.conversation:
|
||||
return None
|
||||
return self.conversation.get_session(session_id)
|
||||
|
||||
def list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
) -> List[ConversationSession]:
|
||||
"""List sessions for a user."""
|
||||
if not self.conversation:
|
||||
return []
|
||||
return self.conversation.list_sessions(user_id=user_id, limit=limit)
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session and all its messages."""
|
||||
if not self.conversation:
|
||||
return False
|
||||
|
||||
if session_id in self._session_cache:
|
||||
del self._session_cache[session_id]
|
||||
|
||||
return self.conversation.delete_session(session_id)
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
) -> List[ConversationMessage]:
|
||||
"""Get messages from a session."""
|
||||
if not self.conversation:
|
||||
return []
|
||||
return self.conversation.get_messages(session_id, limit=limit)
|
||||
|
||||
# =========================================================================
|
||||
# Context Building
|
||||
# =========================================================================
|
||||
|
||||
def build_context(
|
||||
self,
|
||||
session_id: str,
|
||||
query_embedding: Optional[List[float]] = None,
|
||||
history_limit: int = 20,
|
||||
knowledge_limit: int = 5,
|
||||
knowledge_collection: str = "default",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Build context for agent including history and knowledge.
|
||||
|
||||
Args:
|
||||
session_id: Session ID
|
||||
query_embedding: Query embedding for knowledge retrieval
|
||||
history_limit: Max history messages
|
||||
knowledge_limit: Max knowledge documents
|
||||
knowledge_collection: Knowledge collection name
|
||||
|
||||
Returns:
|
||||
Dict with 'history' and 'knowledge' keys
|
||||
"""
|
||||
context = {
|
||||
"history": [],
|
||||
"knowledge": [],
|
||||
}
|
||||
|
||||
# Get conversation history
|
||||
if self.conversation:
|
||||
messages = self.conversation.get_messages(session_id, limit=history_limit)
|
||||
context["history"] = [
|
||||
{"role": m.role, "content": m.content}
|
||||
for m in messages
|
||||
]
|
||||
|
||||
# Get relevant knowledge
|
||||
if self.knowledge and query_embedding:
|
||||
docs = self.knowledge.search(
|
||||
collection=knowledge_collection,
|
||||
query_embedding=query_embedding,
|
||||
limit=knowledge_limit,
|
||||
)
|
||||
context["knowledge"] = [
|
||||
{"content": d.content, "metadata": d.metadata}
|
||||
for d in docs
|
||||
]
|
||||
|
||||
return context
|
||||
|
||||
# =========================================================================
|
||||
# Cleanup
|
||||
# =========================================================================
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close all stores and release resources."""
|
||||
if self.conversation:
|
||||
self.conversation.close()
|
||||
if self.knowledge:
|
||||
self.knowledge.close()
|
||||
if self.state:
|
||||
self.state.close()
|
||||
|
||||
self._session_cache.clear()
|
||||
logger.info("Persistence orchestrator closed")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
return False
|
||||
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
StateStore implementations for fast key-value state and caching.
|
||||
|
||||
Supported backends:
|
||||
- Redis (redis)
|
||||
- DynamoDB (dynamodb)
|
||||
- Firestore (firestore)
|
||||
- MongoDB (mongodb)
|
||||
- Upstash (upstash)
|
||||
- In-memory / JSON file (memory)
|
||||
"""
|
||||
|
||||
__all__ = [
|
||||
"StateStore",
|
||||
]
|
||||
|
||||
def __getattr__(name: str):
|
||||
if name == "StateStore":
|
||||
from .base import StateStore
|
||||
return StateStore
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
Base interfaces for StateStore.
|
||||
|
||||
StateStore handles fast key-value state and caching.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional
|
||||
import json
|
||||
|
||||
|
||||
class StateStore(ABC):
|
||||
"""
|
||||
Abstract base class for key-value state persistence.
|
||||
|
||||
Implementations handle fast state/cache storage for different backends:
|
||||
- Redis (dedicated KV store)
|
||||
- DynamoDB, Firestore (serverless document stores)
|
||||
- MongoDB (document store)
|
||||
- Upstash (serverless Redis)
|
||||
- In-memory / JSON file (zero-dependency fallback)
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""Get a value by key. Returns None if not found."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: Optional[int] = None
|
||||
) -> None:
|
||||
"""Set a value. TTL is in seconds."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete a key. Returns True if key existed."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if a key exists."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def keys(self, pattern: str = "*") -> List[str]:
|
||||
"""List keys matching pattern."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def ttl(self, key: str) -> Optional[int]:
|
||||
"""Get remaining TTL in seconds. Returns None if no TTL or key doesn't exist."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def expire(self, key: str, ttl: int) -> bool:
|
||||
"""Set TTL on existing key. Returns True if key exists."""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_json(self, key: str) -> Optional[Any]:
|
||||
"""Get and deserialize JSON value."""
|
||||
value = self.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return json.loads(value)
|
||||
return value
|
||||
|
||||
def set_json(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
|
||||
"""Serialize and set JSON value."""
|
||||
self.set(key, json.dumps(value), ttl)
|
||||
|
||||
@abstractmethod
|
||||
def hget(self, key: str, field: str) -> Optional[Any]:
|
||||
"""Get a field from a hash."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def hset(self, key: str, field: str, value: Any) -> None:
|
||||
"""Set a field in a hash."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def hgetall(self, key: str) -> Dict[str, Any]:
|
||||
"""Get all fields from a hash."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def hdel(self, key: str, *fields: str) -> int:
|
||||
"""Delete fields from a hash. Returns count deleted."""
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def close(self) -> None:
|
||||
"""Close the store and release resources."""
|
||||
raise NotImplementedError
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
return False
|
||||
@@ -0,0 +1,226 @@
|
||||
"""
|
||||
DynamoDB implementation of StateStore.
|
||||
|
||||
Requires: boto3
|
||||
Install: pip install boto3
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import StateStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DynamoDBStateStore(StateStore):
|
||||
"""
|
||||
DynamoDB-based state store.
|
||||
|
||||
Example:
|
||||
store = DynamoDBStateStore(
|
||||
table_name="praisonai_state",
|
||||
region="us-east-1"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
table_name: str = "praisonai_state",
|
||||
region: Optional[str] = None,
|
||||
endpoint_url: Optional[str] = None,
|
||||
auto_create_table: bool = True,
|
||||
):
|
||||
try:
|
||||
import boto3
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"boto3 is required for DynamoDB support. "
|
||||
"Install with: pip install boto3"
|
||||
)
|
||||
|
||||
region = region or os.getenv("AWS_REGION", "us-east-1")
|
||||
|
||||
self._dynamodb = boto3.resource(
|
||||
"dynamodb",
|
||||
region_name=region,
|
||||
endpoint_url=endpoint_url,
|
||||
)
|
||||
self._client = boto3.client(
|
||||
"dynamodb",
|
||||
region_name=region,
|
||||
endpoint_url=endpoint_url,
|
||||
)
|
||||
self.table_name = table_name
|
||||
|
||||
if auto_create_table:
|
||||
self._create_table()
|
||||
|
||||
self._table = self._dynamodb.Table(table_name)
|
||||
logger.info(f"Connected to DynamoDB table: {table_name}")
|
||||
|
||||
def _create_table(self) -> None:
|
||||
"""Create table if not exists."""
|
||||
try:
|
||||
self._client.describe_table(TableName=self.table_name)
|
||||
except self._client.exceptions.ResourceNotFoundException:
|
||||
self._client.create_table(
|
||||
TableName=self.table_name,
|
||||
KeySchema=[{"AttributeName": "pk", "KeyType": "HASH"}],
|
||||
AttributeDefinitions=[{"AttributeName": "pk", "AttributeType": "S"}],
|
||||
BillingMode="PAY_PER_REQUEST",
|
||||
)
|
||||
waiter = self._client.get_waiter("table_exists")
|
||||
waiter.wait(TableName=self.table_name)
|
||||
|
||||
# Enable TTL
|
||||
self._client.update_time_to_live(
|
||||
TableName=self.table_name,
|
||||
TimeToLiveSpecification={"Enabled": True, "AttributeName": "ttl"}
|
||||
)
|
||||
logger.info(f"Created DynamoDB table: {self.table_name}")
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""Get a value by key."""
|
||||
response = self._table.get_item(Key={"pk": key})
|
||||
item = response.get("Item")
|
||||
|
||||
if not item:
|
||||
return None
|
||||
|
||||
# Check TTL
|
||||
if item.get("ttl") and item["ttl"] <= int(time.time()):
|
||||
return None
|
||||
|
||||
value = item.get("value")
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
return value
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: Optional[int] = None
|
||||
) -> None:
|
||||
"""Set a value with optional TTL."""
|
||||
item = {
|
||||
"pk": key,
|
||||
"value": json.dumps(value) if not isinstance(value, str) else value,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
|
||||
if ttl:
|
||||
item["ttl"] = int(time.time()) + ttl
|
||||
|
||||
self._table.put_item(Item=item)
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete a key."""
|
||||
response = self._table.delete_item(
|
||||
Key={"pk": key},
|
||||
ReturnValues="ALL_OLD"
|
||||
)
|
||||
return "Attributes" in response
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if a key exists."""
|
||||
response = self._table.get_item(
|
||||
Key={"pk": key},
|
||||
ProjectionExpression="pk,#t",
|
||||
ExpressionAttributeNames={"#t": "ttl"}
|
||||
)
|
||||
item = response.get("Item")
|
||||
if not item:
|
||||
return False
|
||||
if item.get("ttl") and item["ttl"] <= int(time.time()):
|
||||
return False
|
||||
return True
|
||||
|
||||
def keys(self, pattern: str = "*") -> List[str]:
|
||||
"""List keys matching pattern."""
|
||||
# DynamoDB scan is expensive, use with caution
|
||||
response = self._table.scan(ProjectionExpression="pk")
|
||||
keys = [item["pk"] for item in response.get("Items", [])]
|
||||
|
||||
if pattern != "*":
|
||||
import fnmatch
|
||||
keys = [k for k in keys if fnmatch.fnmatch(k, pattern)]
|
||||
|
||||
return keys
|
||||
|
||||
def ttl(self, key: str) -> Optional[int]:
|
||||
"""Get remaining TTL in seconds."""
|
||||
response = self._table.get_item(
|
||||
Key={"pk": key},
|
||||
ProjectionExpression="#t",
|
||||
ExpressionAttributeNames={"#t": "ttl"}
|
||||
)
|
||||
item = response.get("Item")
|
||||
if not item or "ttl" not in item:
|
||||
return None
|
||||
|
||||
remaining = item["ttl"] - int(time.time())
|
||||
if remaining <= 0:
|
||||
return None
|
||||
return remaining
|
||||
|
||||
def expire(self, key: str, ttl: int) -> bool:
|
||||
"""Set TTL on existing key."""
|
||||
try:
|
||||
self._table.update_item(
|
||||
Key={"pk": key},
|
||||
UpdateExpression="SET #t = :ttl",
|
||||
ExpressionAttributeNames={"#t": "ttl"},
|
||||
ExpressionAttributeValues={":ttl": int(time.time()) + ttl},
|
||||
ConditionExpression="attribute_exists(pk)"
|
||||
)
|
||||
return True
|
||||
except self._client.exceptions.ConditionalCheckFailedException:
|
||||
return False
|
||||
|
||||
def hget(self, key: str, field: str) -> Optional[Any]:
|
||||
"""Get a field from a hash."""
|
||||
value = self.get(key)
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
return value.get(field)
|
||||
|
||||
def hset(self, key: str, field: str, value: Any) -> None:
|
||||
"""Set a field in a hash."""
|
||||
current = self.get(key)
|
||||
if not isinstance(current, dict):
|
||||
current = {}
|
||||
current[field] = value
|
||||
self.set(key, current)
|
||||
|
||||
def hgetall(self, key: str) -> Dict[str, Any]:
|
||||
"""Get all fields from a hash."""
|
||||
value = self.get(key)
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
return value
|
||||
|
||||
def hdel(self, key: str, *fields: str) -> int:
|
||||
"""Delete fields from a hash."""
|
||||
current = self.get(key)
|
||||
if not isinstance(current, dict):
|
||||
return 0
|
||||
count = 0
|
||||
for field in fields:
|
||||
if field in current:
|
||||
del current[field]
|
||||
count += 1
|
||||
if count > 0:
|
||||
self.set(key, current)
|
||||
return count
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
pass # boto3 handles cleanup
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Firestore implementation of StateStore.
|
||||
|
||||
Requires: google-cloud-firestore
|
||||
Install: pip install google-cloud-firestore
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import StateStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FirestoreStateStore(StateStore):
|
||||
"""
|
||||
Firestore-based state store.
|
||||
|
||||
Example:
|
||||
store = FirestoreStateStore(
|
||||
project="my-project",
|
||||
collection="praisonai_state"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
project: Optional[str] = None,
|
||||
collection: str = "praisonai_state",
|
||||
credentials_path: Optional[str] = None,
|
||||
):
|
||||
try:
|
||||
from google.cloud import firestore
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"google-cloud-firestore is required for Firestore support. "
|
||||
"Install with: pip install google-cloud-firestore"
|
||||
)
|
||||
|
||||
if credentials_path:
|
||||
os.environ["GOOGLE_APPLICATION_CREDENTIALS"] = credentials_path
|
||||
|
||||
project = project or os.getenv("FIRESTORE_PROJECT")
|
||||
self._client = firestore.Client(project=project)
|
||||
self._collection = self._client.collection(collection)
|
||||
|
||||
logger.info(f"Connected to Firestore collection: {collection}")
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""Get a value by key."""
|
||||
doc = self._collection.document(key).get()
|
||||
if not doc.exists:
|
||||
return None
|
||||
|
||||
data = doc.to_dict()
|
||||
|
||||
# Check TTL
|
||||
if data.get("expires_at") and data["expires_at"] <= time.time():
|
||||
self._collection.document(key).delete()
|
||||
return None
|
||||
|
||||
return data.get("value")
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: Optional[int] = None
|
||||
) -> None:
|
||||
"""Set a value with optional TTL."""
|
||||
data = {"value": value, "updated_at": time.time()}
|
||||
|
||||
if ttl:
|
||||
data["expires_at"] = time.time() + ttl
|
||||
|
||||
self._collection.document(key).set(data)
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete a key."""
|
||||
doc = self._collection.document(key)
|
||||
if doc.get().exists:
|
||||
doc.delete()
|
||||
return True
|
||||
return False
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if a key exists."""
|
||||
doc = self._collection.document(key).get()
|
||||
if not doc.exists:
|
||||
return False
|
||||
|
||||
data = doc.to_dict()
|
||||
if data.get("expires_at") and data["expires_at"] <= time.time():
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def keys(self, pattern: str = "*") -> List[str]:
|
||||
"""List keys matching pattern."""
|
||||
docs = self._collection.stream()
|
||||
keys = [doc.id for doc in docs]
|
||||
|
||||
if pattern != "*":
|
||||
import fnmatch
|
||||
keys = [k for k in keys if fnmatch.fnmatch(k, pattern)]
|
||||
|
||||
return keys
|
||||
|
||||
def ttl(self, key: str) -> Optional[int]:
|
||||
"""Get remaining TTL in seconds."""
|
||||
doc = self._collection.document(key).get()
|
||||
if not doc.exists:
|
||||
return None
|
||||
|
||||
data = doc.to_dict()
|
||||
if "expires_at" not in data:
|
||||
return None
|
||||
|
||||
remaining = data["expires_at"] - time.time()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
return int(remaining)
|
||||
|
||||
def expire(self, key: str, ttl: int) -> bool:
|
||||
"""Set TTL on existing key."""
|
||||
doc = self._collection.document(key)
|
||||
if not doc.get().exists:
|
||||
return False
|
||||
|
||||
doc.update({"expires_at": time.time() + ttl})
|
||||
return True
|
||||
|
||||
def hget(self, key: str, field: str) -> Optional[Any]:
|
||||
"""Get a field from a hash."""
|
||||
value = self.get(key)
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
return value.get(field)
|
||||
|
||||
def hset(self, key: str, field: str, value: Any) -> None:
|
||||
"""Set a field in a hash."""
|
||||
current = self.get(key)
|
||||
if not isinstance(current, dict):
|
||||
current = {}
|
||||
current[field] = value
|
||||
self.set(key, current)
|
||||
|
||||
def hgetall(self, key: str) -> Dict[str, Any]:
|
||||
"""Get all fields from a hash."""
|
||||
value = self.get(key)
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
return value
|
||||
|
||||
def hdel(self, key: str, *fields: str) -> int:
|
||||
"""Delete fields from a hash."""
|
||||
current = self.get(key)
|
||||
if not isinstance(current, dict):
|
||||
return 0
|
||||
count = 0
|
||||
for field in fields:
|
||||
if field in current:
|
||||
del current[field]
|
||||
count += 1
|
||||
if count > 0:
|
||||
self.set(key, current)
|
||||
return count
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
pass # Firestore client handles cleanup
|
||||
@@ -0,0 +1,245 @@
|
||||
"""
|
||||
In-memory / JSON file implementation of StateStore.
|
||||
|
||||
Zero external dependencies - uses Python built-ins.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import StateStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MemoryStateStore(StateStore):
|
||||
"""
|
||||
In-memory state store with optional JSON file persistence.
|
||||
|
||||
Zero external dependencies - ideal for development and testing.
|
||||
|
||||
Example:
|
||||
# In-memory only
|
||||
store = MemoryStateStore()
|
||||
|
||||
# With file persistence
|
||||
store = MemoryStateStore(path="./state.json")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: Optional[str] = None,
|
||||
auto_save: bool = True,
|
||||
save_interval: int = 60,
|
||||
):
|
||||
"""
|
||||
Initialize memory state store.
|
||||
|
||||
Args:
|
||||
path: Path to JSON file for persistence (None = in-memory only)
|
||||
auto_save: Auto-save to file periodically
|
||||
save_interval: Save interval in seconds
|
||||
"""
|
||||
self.path = path
|
||||
self.auto_save = auto_save
|
||||
self.save_interval = save_interval
|
||||
|
||||
self._data: Dict[str, Any] = {}
|
||||
self._ttls: Dict[str, float] = {} # key -> expiry timestamp
|
||||
self._lock = threading.RLock()
|
||||
self._last_save = time.time()
|
||||
|
||||
# Load from file if exists
|
||||
if path and os.path.exists(path):
|
||||
self._load()
|
||||
|
||||
logger.info(f"MemoryStateStore initialized (path={path})")
|
||||
|
||||
def _load(self) -> None:
|
||||
"""Load state from JSON file."""
|
||||
if not self.path:
|
||||
return
|
||||
|
||||
try:
|
||||
with open(self.path, "r") as f:
|
||||
saved = json.load(f)
|
||||
self._data = saved.get("data", {})
|
||||
self._ttls = saved.get("ttls", {})
|
||||
|
||||
# Clean expired keys
|
||||
now = time.time()
|
||||
expired = [k for k, exp in self._ttls.items() if exp <= now]
|
||||
for k in expired:
|
||||
self._data.pop(k, None)
|
||||
self._ttls.pop(k, None)
|
||||
|
||||
logger.debug(f"Loaded {len(self._data)} keys from {self.path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load state from {self.path}: {e}")
|
||||
|
||||
def _save(self) -> None:
|
||||
"""Save state to JSON file."""
|
||||
if not self.path:
|
||||
return
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(self.path) or ".", exist_ok=True)
|
||||
with open(self.path, "w") as f:
|
||||
json.dump({"data": self._data, "ttls": self._ttls}, f)
|
||||
self._last_save = time.time()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to save state to {self.path}: {e}")
|
||||
|
||||
def _maybe_save(self) -> None:
|
||||
"""Save if auto_save is enabled and interval has passed."""
|
||||
if self.auto_save and self.path:
|
||||
if time.time() - self._last_save >= self.save_interval:
|
||||
self._save()
|
||||
|
||||
def _check_ttl(self, key: str) -> bool:
|
||||
"""Check if key is expired. Returns True if valid, False if expired."""
|
||||
if key not in self._ttls:
|
||||
return True
|
||||
if self._ttls[key] <= time.time():
|
||||
self._data.pop(key, None)
|
||||
self._ttls.pop(key, None)
|
||||
return False
|
||||
return True
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""Get a value by key."""
|
||||
with self._lock:
|
||||
if not self._check_ttl(key):
|
||||
return None
|
||||
return self._data.get(key)
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: Optional[int] = None
|
||||
) -> None:
|
||||
"""Set a value with optional TTL."""
|
||||
with self._lock:
|
||||
self._data[key] = value
|
||||
if ttl:
|
||||
self._ttls[key] = time.time() + ttl
|
||||
elif key in self._ttls:
|
||||
del self._ttls[key]
|
||||
self._maybe_save()
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete a key."""
|
||||
with self._lock:
|
||||
existed = key in self._data
|
||||
self._data.pop(key, None)
|
||||
self._ttls.pop(key, None)
|
||||
self._maybe_save()
|
||||
return existed
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if a key exists."""
|
||||
with self._lock:
|
||||
if not self._check_ttl(key):
|
||||
return False
|
||||
return key in self._data
|
||||
|
||||
def keys(self, pattern: str = "*") -> List[str]:
|
||||
"""List keys matching pattern."""
|
||||
import fnmatch
|
||||
|
||||
with self._lock:
|
||||
# Clean expired keys first
|
||||
now = time.time()
|
||||
expired = [k for k, exp in self._ttls.items() if exp <= now]
|
||||
for k in expired:
|
||||
self._data.pop(k, None)
|
||||
self._ttls.pop(k, None)
|
||||
|
||||
if pattern == "*":
|
||||
return list(self._data.keys())
|
||||
return [k for k in self._data.keys() if fnmatch.fnmatch(k, pattern)]
|
||||
|
||||
def ttl(self, key: str) -> Optional[int]:
|
||||
"""Get remaining TTL in seconds."""
|
||||
with self._lock:
|
||||
if key not in self._ttls:
|
||||
return None
|
||||
remaining = self._ttls[key] - time.time()
|
||||
if remaining <= 0:
|
||||
self._data.pop(key, None)
|
||||
self._ttls.pop(key, None)
|
||||
return None
|
||||
return int(remaining)
|
||||
|
||||
def expire(self, key: str, ttl: int) -> bool:
|
||||
"""Set TTL on existing key."""
|
||||
with self._lock:
|
||||
if key not in self._data:
|
||||
return False
|
||||
self._ttls[key] = time.time() + ttl
|
||||
self._maybe_save()
|
||||
return True
|
||||
|
||||
def hget(self, key: str, field: str) -> Optional[Any]:
|
||||
"""Get a field from a hash."""
|
||||
with self._lock:
|
||||
if not self._check_ttl(key):
|
||||
return None
|
||||
data = self._data.get(key)
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return data.get(field)
|
||||
|
||||
def hset(self, key: str, field: str, value: Any) -> None:
|
||||
"""Set a field in a hash."""
|
||||
with self._lock:
|
||||
if key not in self._data or not isinstance(self._data[key], dict):
|
||||
self._data[key] = {}
|
||||
self._data[key][field] = value
|
||||
self._maybe_save()
|
||||
|
||||
def hgetall(self, key: str) -> Dict[str, Any]:
|
||||
"""Get all fields from a hash."""
|
||||
with self._lock:
|
||||
if not self._check_ttl(key):
|
||||
return {}
|
||||
data = self._data.get(key)
|
||||
if not isinstance(data, dict):
|
||||
return {}
|
||||
return dict(data)
|
||||
|
||||
def hdel(self, key: str, *fields: str) -> int:
|
||||
"""Delete fields from a hash."""
|
||||
with self._lock:
|
||||
if key not in self._data or not isinstance(self._data[key], dict):
|
||||
return 0
|
||||
count = 0
|
||||
for field in fields:
|
||||
if field in self._data[key]:
|
||||
del self._data[key][field]
|
||||
count += 1
|
||||
self._maybe_save()
|
||||
return count
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Force save to file."""
|
||||
with self._lock:
|
||||
self._save()
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all data."""
|
||||
with self._lock:
|
||||
self._data.clear()
|
||||
self._ttls.clear()
|
||||
self._maybe_save()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store and save."""
|
||||
with self._lock:
|
||||
if self.path:
|
||||
self._save()
|
||||
@@ -0,0 +1,158 @@
|
||||
"""
|
||||
MongoDB implementation of StateStore.
|
||||
|
||||
Requires: pymongo
|
||||
Install: pip install pymongo
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import StateStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MongoDBStateStore(StateStore):
|
||||
"""
|
||||
MongoDB-based state store.
|
||||
|
||||
Example:
|
||||
store = MongoDBStateStore(
|
||||
url="mongodb://localhost:27017",
|
||||
database="praisonai"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "mongodb://localhost:27017",
|
||||
database: str = "praisonai",
|
||||
collection: str = "state",
|
||||
):
|
||||
try:
|
||||
from pymongo import MongoClient
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"pymongo is required for MongoDB support. "
|
||||
"Install with: pip install pymongo"
|
||||
)
|
||||
|
||||
self._client = MongoClient(url)
|
||||
self._db = self._client[database]
|
||||
self._collection = self._db[collection]
|
||||
|
||||
# Create TTL index for automatic expiration
|
||||
self._collection.create_index("expires_at", expireAfterSeconds=0)
|
||||
|
||||
logger.info(f"Connected to MongoDB: {database}.{collection}")
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""Get a value by key."""
|
||||
doc = self._collection.find_one({"_id": key})
|
||||
if not doc:
|
||||
return None
|
||||
|
||||
# Check TTL
|
||||
if doc.get("expires_at") and doc["expires_at"] <= time.time():
|
||||
self._collection.delete_one({"_id": key})
|
||||
return None
|
||||
|
||||
return doc.get("value")
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: Optional[int] = None
|
||||
) -> None:
|
||||
"""Set a value with optional TTL."""
|
||||
doc = {"_id": key, "value": value, "updated_at": time.time()}
|
||||
|
||||
if ttl:
|
||||
from datetime import datetime, timedelta
|
||||
doc["expires_at"] = datetime.utcnow() + timedelta(seconds=ttl)
|
||||
|
||||
self._collection.replace_one({"_id": key}, doc, upsert=True)
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete a key."""
|
||||
result = self._collection.delete_one({"_id": key})
|
||||
return result.deleted_count > 0
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if a key exists."""
|
||||
doc = self._collection.find_one({"_id": key}, {"_id": 1, "expires_at": 1})
|
||||
if not doc:
|
||||
return False
|
||||
if doc.get("expires_at") and doc["expires_at"] <= time.time():
|
||||
return False
|
||||
return True
|
||||
|
||||
def keys(self, pattern: str = "*") -> List[str]:
|
||||
"""List keys matching pattern."""
|
||||
if pattern == "*":
|
||||
cursor = self._collection.find({}, {"_id": 1})
|
||||
else:
|
||||
# Convert glob pattern to regex
|
||||
import re
|
||||
regex = pattern.replace("*", ".*").replace("?", ".")
|
||||
cursor = self._collection.find({"_id": {"$regex": f"^{regex}$"}}, {"_id": 1})
|
||||
|
||||
return [doc["_id"] for doc in cursor]
|
||||
|
||||
def ttl(self, key: str) -> Optional[int]:
|
||||
"""Get remaining TTL in seconds."""
|
||||
doc = self._collection.find_one({"_id": key}, {"expires_at": 1})
|
||||
if not doc or "expires_at" not in doc:
|
||||
return None
|
||||
|
||||
remaining = doc["expires_at"].timestamp() - time.time()
|
||||
if remaining <= 0:
|
||||
return None
|
||||
return int(remaining)
|
||||
|
||||
def expire(self, key: str, ttl: int) -> bool:
|
||||
"""Set TTL on existing key."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
result = self._collection.update_one(
|
||||
{"_id": key},
|
||||
{"$set": {"expires_at": datetime.utcnow() + timedelta(seconds=ttl)}}
|
||||
)
|
||||
return result.modified_count > 0
|
||||
|
||||
def hget(self, key: str, field: str) -> Optional[Any]:
|
||||
"""Get a field from a hash."""
|
||||
doc = self._collection.find_one({"_id": key})
|
||||
if not doc or not isinstance(doc.get("value"), dict):
|
||||
return None
|
||||
return doc["value"].get(field)
|
||||
|
||||
def hset(self, key: str, field: str, value: Any) -> None:
|
||||
"""Set a field in a hash."""
|
||||
self._collection.update_one(
|
||||
{"_id": key},
|
||||
{"$set": {f"value.{field}": value, "updated_at": time.time()}},
|
||||
upsert=True
|
||||
)
|
||||
|
||||
def hgetall(self, key: str) -> Dict[str, Any]:
|
||||
"""Get all fields from a hash."""
|
||||
doc = self._collection.find_one({"_id": key})
|
||||
if not doc or not isinstance(doc.get("value"), dict):
|
||||
return {}
|
||||
return doc["value"]
|
||||
|
||||
def hdel(self, key: str, *fields: str) -> int:
|
||||
"""Delete fields from a hash."""
|
||||
unset = {f"value.{field}": "" for field in fields}
|
||||
result = self._collection.update_one({"_id": key}, {"$unset": unset})
|
||||
return len(fields) if result.modified_count > 0 else 0
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
if self._client:
|
||||
self._client.close()
|
||||
@@ -0,0 +1,190 @@
|
||||
"""
|
||||
Redis implementation of StateStore.
|
||||
|
||||
Requires: redis
|
||||
Install: pip install redis
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import StateStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RedisStateStore(StateStore):
|
||||
"""
|
||||
Redis-based state store for fast key-value operations.
|
||||
|
||||
Example:
|
||||
store = RedisStateStore(
|
||||
url="redis://localhost:6379"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
host: str = "localhost",
|
||||
port: int = 6379,
|
||||
db: int = 0,
|
||||
password: Optional[str] = None,
|
||||
prefix: str = "praison:",
|
||||
decode_responses: bool = True,
|
||||
socket_timeout: int = 5,
|
||||
max_connections: int = 10,
|
||||
):
|
||||
"""
|
||||
Initialize Redis state store.
|
||||
|
||||
Args:
|
||||
url: Full Redis URL (overrides host/port/db/password)
|
||||
host: Redis host
|
||||
port: Redis port
|
||||
db: Redis database number
|
||||
password: Redis password
|
||||
prefix: Key prefix for namespacing
|
||||
decode_responses: Decode bytes to strings
|
||||
socket_timeout: Socket timeout in seconds
|
||||
max_connections: Max connections in pool
|
||||
"""
|
||||
try:
|
||||
import redis as redis_lib
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"redis is required for Redis support. "
|
||||
"Install with: pip install redis"
|
||||
)
|
||||
|
||||
self._redis_lib = redis_lib
|
||||
self.prefix = prefix
|
||||
|
||||
if url:
|
||||
self._client = redis_lib.from_url(
|
||||
url,
|
||||
decode_responses=decode_responses,
|
||||
socket_timeout=socket_timeout,
|
||||
max_connections=max_connections,
|
||||
)
|
||||
else:
|
||||
pool = redis_lib.ConnectionPool(
|
||||
host=host,
|
||||
port=port,
|
||||
db=db,
|
||||
password=password,
|
||||
decode_responses=decode_responses,
|
||||
socket_timeout=socket_timeout,
|
||||
max_connections=max_connections,
|
||||
)
|
||||
self._client = redis_lib.Redis(connection_pool=pool)
|
||||
|
||||
# Test connection
|
||||
self._client.ping()
|
||||
logger.info(f"Connected to Redis at {url or f'{host}:{port}'}")
|
||||
|
||||
def _key(self, key: str) -> str:
|
||||
"""Add prefix to key."""
|
||||
return f"{self.prefix}{key}"
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""Get a value by key."""
|
||||
value = self._client.get(self._key(key))
|
||||
if value is None:
|
||||
return None
|
||||
# Try to deserialize JSON
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return value
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: Optional[int] = None
|
||||
) -> None:
|
||||
"""Set a value with optional TTL."""
|
||||
# Serialize non-string values
|
||||
if not isinstance(value, str):
|
||||
value = json.dumps(value)
|
||||
|
||||
if ttl:
|
||||
self._client.setex(self._key(key), ttl, value)
|
||||
else:
|
||||
self._client.set(self._key(key), value)
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete a key."""
|
||||
return self._client.delete(self._key(key)) > 0
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if a key exists."""
|
||||
return self._client.exists(self._key(key)) > 0
|
||||
|
||||
def keys(self, pattern: str = "*") -> List[str]:
|
||||
"""List keys matching pattern."""
|
||||
full_pattern = self._key(pattern)
|
||||
keys = self._client.keys(full_pattern)
|
||||
# Remove prefix from returned keys
|
||||
prefix_len = len(self.prefix)
|
||||
return [k[prefix_len:] if k.startswith(self.prefix) else k for k in keys]
|
||||
|
||||
def ttl(self, key: str) -> Optional[int]:
|
||||
"""Get remaining TTL in seconds."""
|
||||
result = self._client.ttl(self._key(key))
|
||||
if result < 0: # -1 = no TTL, -2 = key doesn't exist
|
||||
return None
|
||||
return result
|
||||
|
||||
def expire(self, key: str, ttl: int) -> bool:
|
||||
"""Set TTL on existing key."""
|
||||
return self._client.expire(self._key(key), ttl)
|
||||
|
||||
def hget(self, key: str, field: str) -> Optional[Any]:
|
||||
"""Get a field from a hash."""
|
||||
value = self._client.hget(self._key(key), field)
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return value
|
||||
|
||||
def hset(self, key: str, field: str, value: Any) -> None:
|
||||
"""Set a field in a hash."""
|
||||
if not isinstance(value, str):
|
||||
value = json.dumps(value)
|
||||
self._client.hset(self._key(key), field, value)
|
||||
|
||||
def hgetall(self, key: str) -> Dict[str, Any]:
|
||||
"""Get all fields from a hash."""
|
||||
data = self._client.hgetall(self._key(key))
|
||||
result = {}
|
||||
for k, v in data.items():
|
||||
try:
|
||||
result[k] = json.loads(v)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
def hdel(self, key: str, *fields: str) -> int:
|
||||
"""Delete fields from a hash."""
|
||||
if not fields:
|
||||
return 0
|
||||
return self._client.hdel(self._key(key), *fields)
|
||||
|
||||
def incr(self, key: str, amount: int = 1) -> int:
|
||||
"""Increment a counter."""
|
||||
return self._client.incrby(self._key(key), amount)
|
||||
|
||||
def decr(self, key: str, amount: int = 1) -> int:
|
||||
"""Decrement a counter."""
|
||||
return self._client.decrby(self._key(key), amount)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
@@ -0,0 +1,144 @@
|
||||
"""
|
||||
Upstash implementation of StateStore.
|
||||
|
||||
Requires: upstash-redis
|
||||
Install: pip install upstash-redis
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import StateStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpstashStateStore(StateStore):
|
||||
"""
|
||||
Upstash-based state store (serverless Redis).
|
||||
|
||||
Example:
|
||||
store = UpstashStateStore(
|
||||
url="https://xxx.upstash.io",
|
||||
token="your-token"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
prefix: str = "praison:",
|
||||
):
|
||||
try:
|
||||
from upstash_redis import Redis
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"upstash-redis is required for Upstash support. "
|
||||
"Install with: pip install upstash-redis"
|
||||
)
|
||||
|
||||
url = url or os.getenv("UPSTASH_REDIS_URL")
|
||||
token = token or os.getenv("UPSTASH_REDIS_TOKEN")
|
||||
|
||||
if not url or not token:
|
||||
raise ValueError("Upstash URL and token are required")
|
||||
|
||||
self._client = Redis(url=url, token=token)
|
||||
self.prefix = prefix
|
||||
|
||||
logger.info(f"Connected to Upstash Redis")
|
||||
|
||||
def _key(self, key: str) -> str:
|
||||
"""Add prefix to key."""
|
||||
return f"{self.prefix}{key}"
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""Get a value by key."""
|
||||
value = self._client.get(self._key(key))
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return value
|
||||
|
||||
def set(
|
||||
self,
|
||||
key: str,
|
||||
value: Any,
|
||||
ttl: Optional[int] = None
|
||||
) -> None:
|
||||
"""Set a value with optional TTL."""
|
||||
if not isinstance(value, str):
|
||||
value = json.dumps(value)
|
||||
|
||||
if ttl:
|
||||
self._client.setex(self._key(key), ttl, value)
|
||||
else:
|
||||
self._client.set(self._key(key), value)
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete a key."""
|
||||
return self._client.delete(self._key(key)) > 0
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if a key exists."""
|
||||
return self._client.exists(self._key(key)) > 0
|
||||
|
||||
def keys(self, pattern: str = "*") -> List[str]:
|
||||
"""List keys matching pattern."""
|
||||
full_pattern = self._key(pattern)
|
||||
keys = self._client.keys(full_pattern)
|
||||
prefix_len = len(self.prefix)
|
||||
return [k[prefix_len:] if k.startswith(self.prefix) else k for k in keys]
|
||||
|
||||
def ttl(self, key: str) -> Optional[int]:
|
||||
"""Get remaining TTL in seconds."""
|
||||
result = self._client.ttl(self._key(key))
|
||||
if result < 0:
|
||||
return None
|
||||
return result
|
||||
|
||||
def expire(self, key: str, ttl: int) -> bool:
|
||||
"""Set TTL on existing key."""
|
||||
return self._client.expire(self._key(key), ttl)
|
||||
|
||||
def hget(self, key: str, field: str) -> Optional[Any]:
|
||||
"""Get a field from a hash."""
|
||||
value = self._client.hget(self._key(key), field)
|
||||
if value is None:
|
||||
return None
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return value
|
||||
|
||||
def hset(self, key: str, field: str, value: Any) -> None:
|
||||
"""Set a field in a hash."""
|
||||
if not isinstance(value, str):
|
||||
value = json.dumps(value)
|
||||
self._client.hset(self._key(key), field, value)
|
||||
|
||||
def hgetall(self, key: str) -> Dict[str, Any]:
|
||||
"""Get all fields from a hash."""
|
||||
data = self._client.hgetall(self._key(key))
|
||||
result = {}
|
||||
for k, v in data.items():
|
||||
try:
|
||||
result[k] = json.loads(v)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
result[k] = v
|
||||
return result
|
||||
|
||||
def hdel(self, key: str, *fields: str) -> int:
|
||||
"""Delete fields from a hash."""
|
||||
if not fields:
|
||||
return 0
|
||||
return self._client.hdel(self._key(key), *fields)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
pass # Upstash is stateless HTTP
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Comprehensive TDD tests for PraisonAI persistence layer.
|
||||
"""
|
||||
@@ -0,0 +1,633 @@
|
||||
"""
|
||||
Comprehensive TDD tests for all 22 database backends.
|
||||
|
||||
Tests are organized by category:
|
||||
1. ConversationStore backends (6): PostgreSQL, MySQL, SQLite, SingleStore, Supabase, SurrealDB
|
||||
2. KnowledgeStore backends (10): Qdrant, ChromaDB, Pinecone, Weaviate, LanceDB, Milvus, PGVector, Redis Vector, Cassandra, ClickHouse
|
||||
3. StateStore backends (6): Redis, Memory, MongoDB, DynamoDB, Firestore, Upstash
|
||||
|
||||
Run with: python -m pytest praisonai/persistence/tests/test_all_backends.py -v
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import random
|
||||
import json
|
||||
|
||||
# Add the package to path
|
||||
sys.path.insert(0, '/Users/praison/praisonai-package/src/praisonai')
|
||||
|
||||
from praisonai.persistence.conversation.base import ConversationStore, ConversationSession, ConversationMessage
|
||||
from praisonai.persistence.knowledge.base import KnowledgeStore, KnowledgeDocument
|
||||
from praisonai.persistence.state.base import StateStore
|
||||
from praisonai.persistence.orchestrator import PersistenceOrchestrator
|
||||
from praisonai.persistence.factory import create_conversation_store, create_knowledge_store, create_state_store
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Test Utilities
|
||||
# =============================================================================
|
||||
|
||||
def generate_test_session() -> ConversationSession:
|
||||
"""Generate a test session with unique ID."""
|
||||
return ConversationSession(
|
||||
session_id=f"test-{uuid.uuid4().hex[:8]}",
|
||||
user_id="test-user",
|
||||
agent_id="test-agent",
|
||||
name="Test Session",
|
||||
metadata={"test": True, "timestamp": time.time()}
|
||||
)
|
||||
|
||||
|
||||
def generate_test_message(session_id: str, role: str = "user", content: str = None) -> ConversationMessage:
|
||||
"""Generate a test message."""
|
||||
return ConversationMessage(
|
||||
id=f"msg-{uuid.uuid4().hex[:8]}",
|
||||
session_id=session_id,
|
||||
role=role,
|
||||
content=content or f"Test message from {role} at {time.time()}",
|
||||
metadata={"test": True}
|
||||
)
|
||||
|
||||
|
||||
def generate_test_document(dim: int = 384) -> KnowledgeDocument:
|
||||
"""Generate a test document with random embedding."""
|
||||
random.seed(42)
|
||||
return KnowledgeDocument(
|
||||
id=f"doc-{uuid.uuid4().hex[:8]}",
|
||||
content=f"Test document content {uuid.uuid4().hex[:8]}",
|
||||
embedding=[random.random() for _ in range(dim)],
|
||||
metadata={"test": True, "category": "test"}
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ConversationStore Tests
|
||||
# =============================================================================
|
||||
|
||||
class ConversationStoreTestMixin:
|
||||
"""Mixin with standard tests for all ConversationStore implementations."""
|
||||
|
||||
store: ConversationStore = None
|
||||
|
||||
def test_create_session(self):
|
||||
"""Test session creation."""
|
||||
session = generate_test_session()
|
||||
result = self.store.create_session(session)
|
||||
assert result.session_id == session.session_id
|
||||
assert result.user_id == session.user_id
|
||||
# Cleanup
|
||||
self.store.delete_session(session.session_id)
|
||||
|
||||
def test_get_session(self):
|
||||
"""Test session retrieval."""
|
||||
session = generate_test_session()
|
||||
self.store.create_session(session)
|
||||
|
||||
retrieved = self.store.get_session(session.session_id)
|
||||
assert retrieved is not None
|
||||
assert retrieved.session_id == session.session_id
|
||||
assert retrieved.name == session.name
|
||||
|
||||
# Cleanup
|
||||
self.store.delete_session(session.session_id)
|
||||
|
||||
def test_update_session(self):
|
||||
"""Test session update."""
|
||||
session = generate_test_session()
|
||||
self.store.create_session(session)
|
||||
|
||||
session.name = "Updated Name"
|
||||
session.metadata = {"updated": True}
|
||||
self.store.update_session(session)
|
||||
|
||||
retrieved = self.store.get_session(session.session_id)
|
||||
assert retrieved.name == "Updated Name"
|
||||
|
||||
# Cleanup
|
||||
self.store.delete_session(session.session_id)
|
||||
|
||||
def test_delete_session(self):
|
||||
"""Test session deletion."""
|
||||
session = generate_test_session()
|
||||
self.store.create_session(session)
|
||||
|
||||
result = self.store.delete_session(session.session_id)
|
||||
assert result is True
|
||||
|
||||
retrieved = self.store.get_session(session.session_id)
|
||||
assert retrieved is None
|
||||
|
||||
def test_add_message(self):
|
||||
"""Test adding messages to session."""
|
||||
session = generate_test_session()
|
||||
self.store.create_session(session)
|
||||
|
||||
msg = generate_test_message(session.session_id, "user", "Hello!")
|
||||
result = self.store.add_message(session.session_id, msg)
|
||||
assert result.id == msg.id
|
||||
assert result.content == "Hello!"
|
||||
|
||||
# Cleanup
|
||||
self.store.delete_session(session.session_id)
|
||||
|
||||
def test_get_messages(self):
|
||||
"""Test retrieving messages from session."""
|
||||
session = generate_test_session()
|
||||
self.store.create_session(session)
|
||||
|
||||
# Add multiple messages
|
||||
for i in range(5):
|
||||
role = "user" if i % 2 == 0 else "assistant"
|
||||
msg = generate_test_message(session.session_id, role, f"Message {i}")
|
||||
self.store.add_message(session.session_id, msg)
|
||||
|
||||
messages = self.store.get_messages(session.session_id)
|
||||
assert len(messages) == 5
|
||||
|
||||
# Test limit
|
||||
limited = self.store.get_messages(session.session_id, limit=3)
|
||||
assert len(limited) == 3
|
||||
|
||||
# Cleanup
|
||||
self.store.delete_session(session.session_id)
|
||||
|
||||
def test_list_sessions(self):
|
||||
"""Test listing sessions."""
|
||||
sessions = []
|
||||
for i in range(3):
|
||||
session = generate_test_session()
|
||||
session.user_id = "list-test-user"
|
||||
self.store.create_session(session)
|
||||
sessions.append(session)
|
||||
|
||||
listed = self.store.list_sessions(user_id="list-test-user")
|
||||
assert len(listed) >= 3
|
||||
|
||||
# Cleanup
|
||||
for s in sessions:
|
||||
self.store.delete_session(s.session_id)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# KnowledgeStore Tests
|
||||
# =============================================================================
|
||||
|
||||
class KnowledgeStoreTestMixin:
|
||||
"""Mixin with standard tests for all KnowledgeStore implementations."""
|
||||
|
||||
store: KnowledgeStore = None
|
||||
collection_name: str = "test_collection"
|
||||
dimension: int = 384
|
||||
|
||||
def test_create_collection(self):
|
||||
"""Test collection creation."""
|
||||
if self.store.collection_exists(self.collection_name):
|
||||
self.store.delete_collection(self.collection_name)
|
||||
|
||||
self.store.create_collection(self.collection_name, self.dimension)
|
||||
assert self.store.collection_exists(self.collection_name)
|
||||
|
||||
# Cleanup
|
||||
self.store.delete_collection(self.collection_name)
|
||||
|
||||
def test_insert_documents(self):
|
||||
"""Test document insertion."""
|
||||
if not self.store.collection_exists(self.collection_name):
|
||||
self.store.create_collection(self.collection_name, self.dimension)
|
||||
|
||||
docs = [generate_test_document(self.dimension) for _ in range(5)]
|
||||
ids = self.store.insert(self.collection_name, docs)
|
||||
assert len(ids) == 5
|
||||
|
||||
# Cleanup
|
||||
self.store.delete_collection(self.collection_name)
|
||||
|
||||
def test_search_documents(self):
|
||||
"""Test vector search."""
|
||||
if not self.store.collection_exists(self.collection_name):
|
||||
self.store.create_collection(self.collection_name, self.dimension)
|
||||
|
||||
# Insert documents
|
||||
docs = [generate_test_document(self.dimension) for _ in range(10)]
|
||||
self.store.insert(self.collection_name, docs)
|
||||
|
||||
# Search
|
||||
random.seed(42)
|
||||
query_embedding = [random.random() for _ in range(self.dimension)]
|
||||
results = self.store.search(self.collection_name, query_embedding, limit=5)
|
||||
assert len(results) <= 5
|
||||
|
||||
# Cleanup
|
||||
self.store.delete_collection(self.collection_name)
|
||||
|
||||
def test_delete_documents(self):
|
||||
"""Test document deletion."""
|
||||
if not self.store.collection_exists(self.collection_name):
|
||||
self.store.create_collection(self.collection_name, self.dimension)
|
||||
|
||||
docs = [generate_test_document(self.dimension) for _ in range(3)]
|
||||
ids = self.store.insert(self.collection_name, docs)
|
||||
|
||||
deleted = self.store.delete(self.collection_name, ids=ids[:1])
|
||||
assert deleted >= 1
|
||||
|
||||
# Cleanup
|
||||
self.store.delete_collection(self.collection_name)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# StateStore Tests
|
||||
# =============================================================================
|
||||
|
||||
class StateStoreTestMixin:
|
||||
"""Mixin with standard tests for all StateStore implementations."""
|
||||
|
||||
store: StateStore = None
|
||||
|
||||
def test_set_get(self):
|
||||
"""Test basic set/get operations."""
|
||||
key = f"test:{uuid.uuid4().hex[:8]}"
|
||||
value = {"test": True, "count": 42}
|
||||
|
||||
self.store.set(key, value)
|
||||
retrieved = self.store.get(key)
|
||||
|
||||
assert retrieved == value
|
||||
|
||||
# Cleanup
|
||||
self.store.delete(key)
|
||||
|
||||
def test_set_with_ttl(self):
|
||||
"""Test set with TTL."""
|
||||
key = f"test:ttl:{uuid.uuid4().hex[:8]}"
|
||||
self.store.set(key, "temporary", ttl=3600)
|
||||
|
||||
assert self.store.exists(key)
|
||||
ttl = self.store.ttl(key)
|
||||
assert ttl is None or ttl > 0
|
||||
|
||||
# Cleanup
|
||||
self.store.delete(key)
|
||||
|
||||
def test_delete(self):
|
||||
"""Test key deletion."""
|
||||
key = f"test:delete:{uuid.uuid4().hex[:8]}"
|
||||
self.store.set(key, "to_delete")
|
||||
|
||||
result = self.store.delete(key)
|
||||
assert result is True
|
||||
assert not self.store.exists(key)
|
||||
|
||||
def test_exists(self):
|
||||
"""Test key existence check."""
|
||||
key = f"test:exists:{uuid.uuid4().hex[:8]}"
|
||||
|
||||
assert not self.store.exists(key)
|
||||
self.store.set(key, "value")
|
||||
assert self.store.exists(key)
|
||||
|
||||
# Cleanup
|
||||
self.store.delete(key)
|
||||
|
||||
def test_hash_operations(self):
|
||||
"""Test hash operations."""
|
||||
key = f"test:hash:{uuid.uuid4().hex[:8]}"
|
||||
|
||||
self.store.hset(key, "field1", "value1")
|
||||
self.store.hset(key, "field2", 42)
|
||||
|
||||
assert self.store.hget(key, "field1") == "value1"
|
||||
|
||||
all_fields = self.store.hgetall(key)
|
||||
assert "field1" in all_fields
|
||||
assert "field2" in all_fields
|
||||
|
||||
# Cleanup
|
||||
self.store.delete(key)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Concrete Test Classes
|
||||
# =============================================================================
|
||||
|
||||
def run_test(test_func, name):
|
||||
"""Run a single test and report result."""
|
||||
try:
|
||||
test_func()
|
||||
print(f" ✅ {name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f" ❌ {name}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def test_postgresql_conversation_store():
|
||||
"""Test PostgreSQL ConversationStore."""
|
||||
print("\n=== PostgreSQL ConversationStore ===")
|
||||
|
||||
try:
|
||||
from praisonai.persistence.conversation.postgres import PostgresConversationStore
|
||||
store = PostgresConversationStore(
|
||||
host='localhost', port=5432, database='praisonai',
|
||||
user='postgres', password='praison123'
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ SKIPPED: {e}")
|
||||
return
|
||||
|
||||
class TestPostgres(ConversationStoreTestMixin):
|
||||
pass
|
||||
|
||||
test = TestPostgres()
|
||||
test.store = store
|
||||
|
||||
results = []
|
||||
results.append(run_test(test.test_create_session, "create_session"))
|
||||
results.append(run_test(test.test_get_session, "get_session"))
|
||||
results.append(run_test(test.test_update_session, "update_session"))
|
||||
results.append(run_test(test.test_delete_session, "delete_session"))
|
||||
results.append(run_test(test.test_add_message, "add_message"))
|
||||
results.append(run_test(test.test_get_messages, "get_messages"))
|
||||
results.append(run_test(test.test_list_sessions, "list_sessions"))
|
||||
|
||||
store.close()
|
||||
print(f" Results: {sum(results)}/{len(results)} passed")
|
||||
|
||||
|
||||
def test_sqlite_conversation_store():
|
||||
"""Test SQLite ConversationStore."""
|
||||
print("\n=== SQLite ConversationStore ===")
|
||||
|
||||
try:
|
||||
from praisonai.persistence.conversation.sqlite import SQLiteConversationStore
|
||||
store = SQLiteConversationStore(path="/tmp/test_praison.db")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ SKIPPED: {e}")
|
||||
return
|
||||
|
||||
class TestSQLite(ConversationStoreTestMixin):
|
||||
pass
|
||||
|
||||
test = TestSQLite()
|
||||
test.store = store
|
||||
|
||||
results = []
|
||||
results.append(run_test(test.test_create_session, "create_session"))
|
||||
results.append(run_test(test.test_get_session, "get_session"))
|
||||
results.append(run_test(test.test_update_session, "update_session"))
|
||||
results.append(run_test(test.test_delete_session, "delete_session"))
|
||||
results.append(run_test(test.test_add_message, "add_message"))
|
||||
results.append(run_test(test.test_get_messages, "get_messages"))
|
||||
results.append(run_test(test.test_list_sessions, "list_sessions"))
|
||||
|
||||
store.close()
|
||||
print(f" Results: {sum(results)}/{len(results)} passed")
|
||||
|
||||
|
||||
def test_qdrant_knowledge_store():
|
||||
"""Test Qdrant KnowledgeStore."""
|
||||
print("\n=== Qdrant KnowledgeStore ===")
|
||||
|
||||
try:
|
||||
from praisonai.persistence.knowledge.qdrant import QdrantKnowledgeStore
|
||||
store = QdrantKnowledgeStore(host='localhost', port=6333)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ SKIPPED: {e}")
|
||||
return
|
||||
|
||||
class TestQdrant(KnowledgeStoreTestMixin):
|
||||
pass
|
||||
|
||||
test = TestQdrant()
|
||||
test.store = store
|
||||
test.collection_name = f"test_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
results = []
|
||||
results.append(run_test(test.test_create_collection, "create_collection"))
|
||||
results.append(run_test(test.test_insert_documents, "insert_documents"))
|
||||
results.append(run_test(test.test_search_documents, "search_documents"))
|
||||
results.append(run_test(test.test_delete_documents, "delete_documents"))
|
||||
|
||||
store.close()
|
||||
print(f" Results: {sum(results)}/{len(results)} passed")
|
||||
|
||||
|
||||
def test_chroma_knowledge_store():
|
||||
"""Test ChromaDB KnowledgeStore."""
|
||||
print("\n=== ChromaDB KnowledgeStore ===")
|
||||
|
||||
try:
|
||||
from praisonai.persistence.knowledge.chroma import ChromaKnowledgeStore
|
||||
store = ChromaKnowledgeStore(path="/tmp/test_chroma")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ SKIPPED: {e}")
|
||||
return
|
||||
|
||||
class TestChroma(KnowledgeStoreTestMixin):
|
||||
pass
|
||||
|
||||
test = TestChroma()
|
||||
test.store = store
|
||||
test.collection_name = f"test_{uuid.uuid4().hex[:8]}"
|
||||
|
||||
results = []
|
||||
results.append(run_test(test.test_create_collection, "create_collection"))
|
||||
results.append(run_test(test.test_insert_documents, "insert_documents"))
|
||||
results.append(run_test(test.test_search_documents, "search_documents"))
|
||||
results.append(run_test(test.test_delete_documents, "delete_documents"))
|
||||
|
||||
store.close()
|
||||
print(f" Results: {sum(results)}/{len(results)} passed")
|
||||
|
||||
|
||||
def test_redis_state_store():
|
||||
"""Test Redis StateStore."""
|
||||
print("\n=== Redis StateStore ===")
|
||||
|
||||
try:
|
||||
from praisonai.persistence.state.redis import RedisStateStore
|
||||
store = RedisStateStore(host='localhost', port=6379)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ SKIPPED: {e}")
|
||||
return
|
||||
|
||||
class TestRedis(StateStoreTestMixin):
|
||||
pass
|
||||
|
||||
test = TestRedis()
|
||||
test.store = store
|
||||
|
||||
results = []
|
||||
results.append(run_test(test.test_set_get, "set_get"))
|
||||
results.append(run_test(test.test_set_with_ttl, "set_with_ttl"))
|
||||
results.append(run_test(test.test_delete, "delete"))
|
||||
results.append(run_test(test.test_exists, "exists"))
|
||||
results.append(run_test(test.test_hash_operations, "hash_operations"))
|
||||
|
||||
store.close()
|
||||
print(f" Results: {sum(results)}/{len(results)} passed")
|
||||
|
||||
|
||||
def test_memory_state_store():
|
||||
"""Test Memory StateStore (zero-dependency)."""
|
||||
print("\n=== Memory StateStore ===")
|
||||
|
||||
try:
|
||||
from praisonai.persistence.state.memory import MemoryStateStore
|
||||
store = MemoryStateStore(path="/tmp/test_state.json")
|
||||
except Exception as e:
|
||||
print(f" ⚠️ SKIPPED: {e}")
|
||||
return
|
||||
|
||||
class TestMemory(StateStoreTestMixin):
|
||||
pass
|
||||
|
||||
test = TestMemory()
|
||||
test.store = store
|
||||
|
||||
results = []
|
||||
results.append(run_test(test.test_set_get, "set_get"))
|
||||
results.append(run_test(test.test_set_with_ttl, "set_with_ttl"))
|
||||
results.append(run_test(test.test_delete, "delete"))
|
||||
results.append(run_test(test.test_exists, "exists"))
|
||||
results.append(run_test(test.test_hash_operations, "hash_operations"))
|
||||
|
||||
store.close()
|
||||
print(f" Results: {sum(results)}/{len(results)} passed")
|
||||
|
||||
|
||||
def test_orchestrator_integration():
|
||||
"""Test PersistenceOrchestrator with all stores."""
|
||||
print("\n=== PersistenceOrchestrator Integration ===")
|
||||
|
||||
try:
|
||||
from praisonai.persistence.conversation.postgres import PostgresConversationStore
|
||||
from praisonai.persistence.knowledge.qdrant import QdrantKnowledgeStore
|
||||
from praisonai.persistence.state.redis import RedisStateStore
|
||||
|
||||
conv_store = PostgresConversationStore(
|
||||
host='localhost', port=5432, database='praisonai',
|
||||
user='postgres', password='praison123'
|
||||
)
|
||||
knowledge_store = QdrantKnowledgeStore(host='localhost', port=6333)
|
||||
state_store = RedisStateStore(host='localhost', port=6379)
|
||||
|
||||
orchestrator = PersistenceOrchestrator(
|
||||
conversation_store=conv_store,
|
||||
knowledge_store=knowledge_store,
|
||||
state_store=state_store,
|
||||
)
|
||||
except Exception as e:
|
||||
print(f" ⚠️ SKIPPED: {e}")
|
||||
return
|
||||
|
||||
results = []
|
||||
|
||||
# Test 1: Agent lifecycle
|
||||
def test_agent_lifecycle():
|
||||
class MockAgent:
|
||||
name = "test-agent"
|
||||
|
||||
agent = MockAgent()
|
||||
session_id = f"orch-test-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Start
|
||||
history = orchestrator.on_agent_start(agent, session_id=session_id, user_id="test-user")
|
||||
assert isinstance(history, list)
|
||||
|
||||
# Messages
|
||||
orchestrator.on_message(session_id, "user", "Hello!")
|
||||
orchestrator.on_message(session_id, "assistant", "Hi there!")
|
||||
|
||||
# End
|
||||
orchestrator.on_agent_end(agent, session_id)
|
||||
|
||||
# Verify
|
||||
messages = orchestrator.get_messages(session_id)
|
||||
assert len(messages) == 2
|
||||
|
||||
# Cleanup
|
||||
orchestrator.delete_session(session_id)
|
||||
|
||||
results.append(run_test(test_agent_lifecycle, "agent_lifecycle"))
|
||||
|
||||
# Test 2: State management
|
||||
def test_state_management():
|
||||
key = f"orch:state:{uuid.uuid4().hex[:8]}"
|
||||
orchestrator.set_state(key, {"test": True})
|
||||
value = orchestrator.get_state(key)
|
||||
assert value == {"test": True}
|
||||
orchestrator.delete_state(key)
|
||||
|
||||
results.append(run_test(test_state_management, "state_management"))
|
||||
|
||||
# Test 3: Session resume
|
||||
def test_session_resume():
|
||||
class MockAgent:
|
||||
name = "resume-agent"
|
||||
|
||||
agent = MockAgent()
|
||||
session_id = f"resume-test-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# First run
|
||||
orchestrator.on_agent_start(agent, session_id=session_id)
|
||||
orchestrator.on_message(session_id, "user", "First message")
|
||||
orchestrator.on_agent_end(agent, session_id)
|
||||
|
||||
# Second run - should resume
|
||||
history = orchestrator.on_agent_start(agent, session_id=session_id, resume=True)
|
||||
assert len(history) == 1
|
||||
assert history[0].content == "First message"
|
||||
|
||||
# Cleanup
|
||||
orchestrator.delete_session(session_id)
|
||||
|
||||
results.append(run_test(test_session_resume, "session_resume"))
|
||||
|
||||
orchestrator.close()
|
||||
print(f" Results: {sum(results)}/{len(results)} passed")
|
||||
|
||||
|
||||
def run_all_tests():
|
||||
"""Run all database backend tests."""
|
||||
print("=" * 60)
|
||||
print("PraisonAI Persistence Layer - Comprehensive TDD Tests")
|
||||
print("=" * 60)
|
||||
|
||||
# ConversationStore tests
|
||||
print("\n" + "=" * 40)
|
||||
print("CONVERSATION STORE BACKENDS")
|
||||
print("=" * 40)
|
||||
test_postgresql_conversation_store()
|
||||
test_sqlite_conversation_store()
|
||||
|
||||
# KnowledgeStore tests
|
||||
print("\n" + "=" * 40)
|
||||
print("KNOWLEDGE STORE BACKENDS")
|
||||
print("=" * 40)
|
||||
test_qdrant_knowledge_store()
|
||||
test_chroma_knowledge_store()
|
||||
|
||||
# StateStore tests
|
||||
print("\n" + "=" * 40)
|
||||
print("STATE STORE BACKENDS")
|
||||
print("=" * 40)
|
||||
test_redis_state_store()
|
||||
test_memory_state_store()
|
||||
|
||||
# Integration tests
|
||||
print("\n" + "=" * 40)
|
||||
print("INTEGRATION TESTS")
|
||||
print("=" * 40)
|
||||
test_orchestrator_integration()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print("All tests completed!")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_all_tests()
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.4.0"
|
||||
__version__ = "2.4.1"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=0.2.0",
|
||||
"praisonaiagents>=0.2.1",
|
||||
"python-dotenv>=0.19.0",
|
||||
"instructor>=1.3.3",
|
||||
"PyYAML>=6.0",
|
||||
|
||||
Generated
+4
-4
@@ -4197,7 +4197,7 @@ requires-dist = [
|
||||
{ name = "praisonai-tools", marker = "extra == 'autogen'", specifier = ">=0.0.22" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'autogen-v4'", specifier = ">=0.0.22" },
|
||||
{ name = "praisonai-tools", marker = "extra == 'crewai'", specifier = ">=0.0.22" },
|
||||
{ name = "praisonaiagents", specifier = ">=0.2.0" },
|
||||
{ name = "praisonaiagents", specifier = ">=0.2.1" },
|
||||
{ name = "pyautogen", marker = "extra == 'autogen'", specifier = "==0.2.29" },
|
||||
{ name = "pydantic", marker = "extra == 'chat'", specifier = "<=2.10.1" },
|
||||
{ name = "pydantic", marker = "extra == 'code'", specifier = "<=2.10.1" },
|
||||
@@ -4252,7 +4252,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "0.2.0"
|
||||
version = "0.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -4261,9 +4261,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e0/47/2ccdf169d00bef397202a0e2f4c13ba87097ba0f0e6c2feb50390dcca0fc/praisonaiagents-0.2.0.tar.gz", hash = "sha256:e1540a8a2919138293af3a7280bfd8b8c2d1d1f7da4353d8d2d29f8505eefb87", size = 717602, upload-time = "2025-12-24T07:43:33.045Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/58/16b6bd1bb1c9b3932a5e106f72ba1ca2d23b2dad87b45fb0e912b61ab9f7/praisonaiagents-0.2.1.tar.gz", hash = "sha256:7d392c635359696d22ecd92acf90bd2dd1ac24282211ef651612f3d194ea06e5", size = 730640, upload-time = "2025-12-24T14:57:46.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/14/b004b734e2247decee973f6cf3a52f892770c555ba74e1ca3ccc5dc14a88/praisonaiagents-0.2.0-py3-none-any.whl", hash = "sha256:7bf36a44af63f48e4965cdd6235dba1068bf51200b76d23739f8a7545a95a5d0", size = 669098, upload-time = "2025-12-24T07:43:31.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/d5/a1776b1ad798dce02a896de62f693cee0f6556ac6a2cf06598bf114041a1/praisonaiagents-0.2.1-py3-none-any.whl", hash = "sha256:cf6394986a9d3e701cca49b3e57f5798fdb9066f689c61633e6bdc8421fa995b", size = 678483, upload-time = "2025-12-24T14:57:44.376Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user