mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 08:11:07 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de9d4bd3c6 | ||
|
|
aba2265e5e |
No files matched your search
@@ -2763,11 +2763,11 @@ agent.chat("Hello!") # Auto-persists messages, runs, traces
|
||||
| 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) |
|
||||
| Pinecone | Knowledge | `pip install pinecone` | [pinecone_wow.py](examples/vector/pinecone_wow.py) | [docs](https://docs.praison.ai/docs/databases/pinecone) |
|
||||
| Weaviate | Knowledge | `pip install weaviate-client` | [weaviate_wow.py](examples/vector/weaviate_wow.py) | [docs](https://docs.praison.ai/docs/databases/weaviate) |
|
||||
| LanceDB | Knowledge | `pip install lancedb` | [lancedb_real_wow.py](examples/vector/lancedb_real_wow.py) | [docs](https://docs.praison.ai/docs/databases/lancedb) |
|
||||
| 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) |
|
||||
| PGVector | Knowledge | `pip install psycopg2-binary` | [pgvector_real_wow.py](examples/vector/pgvector_real_wow.py) | [docs](https://docs.praison.ai/docs/databases/pgvector) |
|
||||
| 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) |
|
||||
|
||||
@@ -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.2" \
|
||||
"praisonai>=2.4.4" \
|
||||
"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.2" \
|
||||
"praisonai>=2.4.4" \
|
||||
"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.2" \
|
||||
"praisonai>=2.4.4" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Multi-Agent Persistence Example
|
||||
|
||||
Demonstrates how multiple agents can share or isolate conversation
|
||||
history using the persistence layer.
|
||||
"""
|
||||
|
||||
from praisonai.persistence.factory import create_conversation_store, create_state_store
|
||||
from praisonai.persistence.conversation.base import ConversationSession, ConversationMessage
|
||||
import uuid
|
||||
|
||||
|
||||
def main():
|
||||
# Create stores
|
||||
conv_store = create_conversation_store("sqlite", path=":memory:")
|
||||
state_store = create_state_store("memory")
|
||||
|
||||
print("=== Multi-Agent Persistence Demo ===\n")
|
||||
|
||||
# Scenario 1: Shared session (agents collaborate)
|
||||
print("--- Scenario 1: Shared Session ---")
|
||||
shared_session_id = f"shared-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
session = ConversationSession(
|
||||
session_id=shared_session_id,
|
||||
user_id="user-123",
|
||||
name="Shared Conversation"
|
||||
)
|
||||
conv_store.create_session(session)
|
||||
|
||||
# Agent 1 (Researcher) adds context
|
||||
conv_store.add_message(shared_session_id, ConversationMessage(
|
||||
role="assistant",
|
||||
content="[Researcher] I found relevant information about the topic.",
|
||||
metadata={"agent_id": "researcher", "agent_role": "research"}
|
||||
))
|
||||
|
||||
# Agent 2 (Writer) uses the context
|
||||
conv_store.add_message(shared_session_id, ConversationMessage(
|
||||
role="assistant",
|
||||
content="[Writer] Based on the research, here's a summary...",
|
||||
metadata={"agent_id": "writer", "agent_role": "writing"}
|
||||
))
|
||||
|
||||
# Agent 3 (Editor) reviews
|
||||
conv_store.add_message(shared_session_id, ConversationMessage(
|
||||
role="assistant",
|
||||
content="[Editor] I've reviewed and polished the content.",
|
||||
metadata={"agent_id": "editor", "agent_role": "editing"}
|
||||
))
|
||||
|
||||
messages = conv_store.get_messages(shared_session_id)
|
||||
print(f"Shared session has {len(messages)} messages from different agents")
|
||||
for msg in messages:
|
||||
agent = msg.metadata.get("agent_id", "unknown") if msg.metadata else "unknown"
|
||||
print(f" - {agent}: {msg.content[:50]}...")
|
||||
|
||||
# Scenario 2: Isolated sessions (agents work independently)
|
||||
print("\n--- Scenario 2: Isolated Sessions ---")
|
||||
|
||||
agent_sessions = {}
|
||||
for agent_name in ["planner", "executor", "validator"]:
|
||||
session_id = f"{agent_name}-{uuid.uuid4().hex[:8]}"
|
||||
agent_sessions[agent_name] = session_id
|
||||
|
||||
conv_store.create_session(ConversationSession(
|
||||
session_id=session_id,
|
||||
agent_id=agent_name,
|
||||
name=f"{agent_name.title()} Session"
|
||||
))
|
||||
|
||||
# Each agent has its own conversation
|
||||
conv_store.add_message(session_id, ConversationMessage(
|
||||
role="user",
|
||||
content=f"Task for {agent_name}"
|
||||
))
|
||||
conv_store.add_message(session_id, ConversationMessage(
|
||||
role="assistant",
|
||||
content=f"[{agent_name.title()}] Processing task..."
|
||||
))
|
||||
|
||||
print("Created isolated sessions for each agent:")
|
||||
for agent, sid in agent_sessions.items():
|
||||
msgs = conv_store.get_messages(sid)
|
||||
print(f" - {agent}: {len(msgs)} messages")
|
||||
|
||||
# Scenario 3: Shared state across agents
|
||||
print("\n--- Scenario 3: Shared State ---")
|
||||
|
||||
# Agents share state for coordination
|
||||
state_store.set("workflow:status", {"phase": "research", "progress": 0})
|
||||
state_store.set("workflow:context", {"topic": "AI Agents", "deadline": "2024-12-31"})
|
||||
|
||||
# Agent 1 updates progress
|
||||
state = state_store.get("workflow:status")
|
||||
state["progress"] = 33
|
||||
state["phase"] = "writing"
|
||||
state_store.set("workflow:status", state)
|
||||
print(f"Agent 1 updated state: {state}")
|
||||
|
||||
# Agent 2 reads and updates
|
||||
state = state_store.get("workflow:status")
|
||||
state["progress"] = 66
|
||||
state["phase"] = "editing"
|
||||
state_store.set("workflow:status", state)
|
||||
print(f"Agent 2 updated state: {state}")
|
||||
|
||||
# Agent 3 completes
|
||||
state = state_store.get("workflow:status")
|
||||
state["progress"] = 100
|
||||
state["phase"] = "complete"
|
||||
state_store.set("workflow:status", state)
|
||||
print(f"Agent 3 completed: {state}")
|
||||
|
||||
# Cleanup
|
||||
conv_store.close()
|
||||
state_store.close()
|
||||
print("\n✅ Multi-agent persistence demo complete!")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Multi-Agent Shared Session - DB Persistence Test"""
|
||||
import sys
|
||||
sys.path.insert(0, 'src/praisonai')
|
||||
from praisonai.persistence import create_conversation_store
|
||||
from praisonai.persistence.conversation.base import ConversationSession, ConversationMessage
|
||||
|
||||
# Create shared session for multiple agents
|
||||
store = create_conversation_store("sqlite", path="/tmp/multi_agent.db")
|
||||
session_id = "shared-session-123"
|
||||
|
||||
# Create session
|
||||
session = ConversationSession(session_id=session_id, agent_id="multi")
|
||||
try:
|
||||
store.create_session(session)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Agent 1 writes
|
||||
msg1 = ConversationMessage(session_id=session_id, role="user", content="Agent1: Hello")
|
||||
store.add_message(session_id, msg1)
|
||||
msg2 = ConversationMessage(session_id=session_id, role="assistant", content="Agent1: Hi there!")
|
||||
store.add_message(session_id, msg2)
|
||||
|
||||
# Agent 2 writes to same session
|
||||
msg3 = ConversationMessage(session_id=session_id, role="user", content="Agent2: What did Agent1 say?")
|
||||
store.add_message(session_id, msg3)
|
||||
msg4 = ConversationMessage(session_id=session_id, role="assistant", content="Agent2: Agent1 said Hello")
|
||||
store.add_message(session_id, msg4)
|
||||
|
||||
# Verify both agents' messages are in session
|
||||
messages = store.get_messages(session_id)
|
||||
print(f"Total messages in shared session: {len(messages)}")
|
||||
assert len(messages) >= 4, "Should have messages from both agents"
|
||||
|
||||
# Verify content
|
||||
contents = [m.content for m in messages]
|
||||
assert any("Agent1" in c for c in contents), "Should have Agent1 messages"
|
||||
assert any("Agent2" in c for c in contents), "Should have Agent2 messages"
|
||||
|
||||
print("PASSED: Multi-agent shared session persistence")
|
||||
@@ -0,0 +1,16 @@
|
||||
"""Multi-Agent Shared Session Demo"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonai.persistence import create_conversation_store
|
||||
|
||||
store = create_conversation_store("sqlite", path="/tmp/multi_agent.db")
|
||||
|
||||
agent1 = Agent(name="Researcher", llm="gpt-4o-mini", db=store, session_id="shared-session")
|
||||
agent2 = Agent(name="Writer", llm="gpt-4o-mini", db=store, session_id="shared-session")
|
||||
|
||||
r1 = agent1.chat("Research topic: AI in healthcare. Key finding: reduces diagnosis time by 50%")
|
||||
print(f"Researcher: {r1[:50]}...")
|
||||
|
||||
r2 = agent2.chat("Based on the research, what was the key finding?")
|
||||
print(f"Writer: {r2[:50]}...")
|
||||
assert "50" in r2 or "diagnosis" in r2.lower(), "Writer should see Researcher's context"
|
||||
print("PASSED: Multi-agent shared session works")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Basic Observability Usage Example
|
||||
|
||||
This example demonstrates how to use the observability module
|
||||
with PraisonAI agents.
|
||||
|
||||
Usage:
|
||||
# Set your provider's API key first
|
||||
export LANGFUSE_PUBLIC_KEY=pk-lf-xxx
|
||||
export LANGFUSE_SECRET_KEY=sk-lf-xxx
|
||||
|
||||
python basic_usage.py
|
||||
"""
|
||||
|
||||
from praisonai_tools.observability import obs
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Initialize observability (auto-detects provider from env vars)
|
||||
obs.init()
|
||||
|
||||
# Or specify a provider explicitly
|
||||
# obs.init(provider="langfuse", project_name="my-project")
|
||||
|
||||
# Create an agent
|
||||
agent = Agent(
|
||||
instructions="You are a helpful assistant.",
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
# Use trace context manager for complete workflow tracing
|
||||
with obs.trace("chat-session", session_id="user-123"):
|
||||
# Use span context manager for individual operations
|
||||
with obs.span("user-query", kind=obs.SpanKind.AGENT):
|
||||
response = agent.chat("What is the capital of France?")
|
||||
print(response)
|
||||
|
||||
# Log LLM calls manually if needed
|
||||
obs.log_llm_call(
|
||||
model="gpt-4o-mini",
|
||||
input_messages="What is 2+2?",
|
||||
output="4",
|
||||
input_tokens=10,
|
||||
output_tokens=1,
|
||||
)
|
||||
|
||||
# Check diagnostics
|
||||
print("\nDiagnostics:")
|
||||
print(obs.doctor())
|
||||
@@ -0,0 +1,48 @@
|
||||
"""
|
||||
Langfuse Integration Example
|
||||
|
||||
This example shows how to use Langfuse for LLM observability.
|
||||
|
||||
Setup:
|
||||
1. Sign up at https://langfuse.com/
|
||||
2. Get your API keys from the project settings
|
||||
3. Set environment variables:
|
||||
export LANGFUSE_PUBLIC_KEY=pk-lf-xxx
|
||||
export LANGFUSE_SECRET_KEY=sk-lf-xxx
|
||||
4. Install dependencies:
|
||||
pip install opentelemetry-sdk opentelemetry-exporter-otlp
|
||||
|
||||
Usage:
|
||||
python langfuse_example.py
|
||||
"""
|
||||
|
||||
import os
|
||||
from praisonai_tools.observability import obs
|
||||
from praisonaiagents import Agent
|
||||
|
||||
# Initialize Langfuse
|
||||
success = obs.init(
|
||||
provider="langfuse",
|
||||
project_name="praisonai-demo",
|
||||
)
|
||||
|
||||
if not success:
|
||||
print("Failed to initialize Langfuse. Check your API keys.")
|
||||
print("Required: LANGFUSE_PUBLIC_KEY, LANGFUSE_SECRET_KEY")
|
||||
exit(1)
|
||||
|
||||
print("Langfuse initialized successfully!")
|
||||
print(f"View traces at: https://cloud.langfuse.com/")
|
||||
|
||||
# Create agent
|
||||
agent = Agent(
|
||||
instructions="You are a helpful coding assistant.",
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
# Run with tracing
|
||||
with obs.trace("coding-session", user_id="developer-1"):
|
||||
response = agent.chat("Write a Python function to calculate fibonacci numbers")
|
||||
print(response)
|
||||
|
||||
print("\nCheck Langfuse dashboard for traces!")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""MLflow Local Observability Demo (no API key needed)"""
|
||||
import mlflow
|
||||
import time
|
||||
|
||||
mlflow.set_tracking_uri("file:///tmp/mlflow_demo")
|
||||
mlflow.set_experiment("praisonai-demo")
|
||||
|
||||
with mlflow.start_run(run_name="agent-trace"):
|
||||
mlflow.log_param("agent_name", "Assistant")
|
||||
mlflow.log_param("model", "gpt-4o-mini")
|
||||
|
||||
# Simulate agent work
|
||||
start = time.time()
|
||||
time.sleep(0.1)
|
||||
latency = time.time() - start
|
||||
|
||||
mlflow.log_metric("latency_ms", latency * 1000)
|
||||
mlflow.log_metric("tokens_used", 150)
|
||||
|
||||
print(f"Logged run to MLflow: latency={latency*1000:.1f}ms")
|
||||
|
||||
print("PASSED: MLflow local observability works")
|
||||
@@ -0,0 +1,53 @@
|
||||
"""
|
||||
Multi-Agent Tracing Example
|
||||
|
||||
This example shows how to trace multi-agent workflows with
|
||||
parent/child span relationships.
|
||||
|
||||
Usage:
|
||||
# Set your provider's API key
|
||||
export LANGFUSE_PUBLIC_KEY=pk-lf-xxx
|
||||
export LANGFUSE_SECRET_KEY=sk-lf-xxx
|
||||
|
||||
python multi_agent_tracing.py
|
||||
"""
|
||||
|
||||
from praisonai_tools.observability import obs
|
||||
from praisonai_tools.observability.base import SpanKind
|
||||
from praisonaiagents import Agent, PraisonAIAgents
|
||||
|
||||
# Initialize observability
|
||||
obs.init()
|
||||
|
||||
# Create agents
|
||||
researcher = Agent(
|
||||
name="Researcher",
|
||||
instructions="You research topics and provide detailed information.",
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
writer = Agent(
|
||||
name="Writer",
|
||||
instructions="You write clear, engaging content based on research.",
|
||||
model="gpt-4o-mini",
|
||||
)
|
||||
|
||||
# Trace the multi-agent workflow
|
||||
with obs.trace("content-creation", metadata={"workflow": "research-write"}):
|
||||
|
||||
# Research phase
|
||||
with obs.span("research-phase", kind=SpanKind.AGENT) as research_span:
|
||||
research_span.attributes["agent"] = "Researcher"
|
||||
research_result = researcher.chat("Research the benefits of AI in healthcare")
|
||||
|
||||
# Writing phase
|
||||
with obs.span("writing-phase", kind=SpanKind.AGENT) as write_span:
|
||||
write_span.attributes["agent"] = "Writer"
|
||||
write_span.attributes["input_length"] = len(str(research_result))
|
||||
final_content = writer.chat(f"Write a blog post based on: {research_result}")
|
||||
|
||||
print("Final Content:")
|
||||
print(final_content)
|
||||
|
||||
# Check trace info
|
||||
print("\nDiagnostics:", obs.doctor())
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run all examples and collect PASS/SKIP/FAIL results."""
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
EXAMPLES_DIR = Path(__file__).parent
|
||||
RESULTS = []
|
||||
|
||||
def run_example(path: Path) -> tuple:
|
||||
"""Run an example and return (name, status, reason)."""
|
||||
name = f"{path.parent.name}/{path.name}"
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
env={**os.environ, "PYTHONPATH": str(EXAMPLES_DIR.parent / "src")}
|
||||
)
|
||||
output = result.stdout + result.stderr
|
||||
if "PASSED" in output:
|
||||
return (name, "PASS", "")
|
||||
elif "SKIPPED" in output:
|
||||
return (name, "SKIP", output.split("SKIPPED:")[-1].strip()[:50])
|
||||
elif result.returncode != 0:
|
||||
error = output.strip().split("\n")[-1][:80]
|
||||
return (name, "FAIL", error)
|
||||
else:
|
||||
return (name, "PASS", "")
|
||||
except subprocess.TimeoutExpired:
|
||||
return (name, "FAIL", "Timeout")
|
||||
except Exception as e:
|
||||
return (name, "FAIL", str(e)[:50])
|
||||
|
||||
def main():
|
||||
print("=" * 70)
|
||||
print("EXAMPLE RUNNER - PASS/SKIP/FAIL SUMMARY")
|
||||
print("=" * 70)
|
||||
|
||||
# Find all example files
|
||||
examples = []
|
||||
for subdir in ["db", "vector", "observability", "multi_agent", "workflows"]:
|
||||
subpath = EXAMPLES_DIR / subdir
|
||||
if subpath.exists():
|
||||
examples.extend(sorted(subpath.glob("*_wow.py")))
|
||||
|
||||
print(f"\nFound {len(examples)} examples to run\n")
|
||||
|
||||
for ex in examples:
|
||||
name, status, reason = run_example(ex)
|
||||
RESULTS.append((name, status, reason))
|
||||
icon = {"PASS": "✅", "SKIP": "⏭️", "FAIL": "❌"}.get(status, "?")
|
||||
reason_str = f" ({reason})" if reason else ""
|
||||
print(f"{icon} {status:4} | {name:45} {reason_str}")
|
||||
|
||||
# Summary
|
||||
print("\n" + "=" * 70)
|
||||
passed = sum(1 for _, s, _ in RESULTS if s == "PASS")
|
||||
skipped = sum(1 for _, s, _ in RESULTS if s == "SKIP")
|
||||
failed = sum(1 for _, s, _ in RESULTS if s == "FAIL")
|
||||
print(f"SUMMARY: {passed} PASS | {skipped} SKIP | {failed} FAIL | {len(RESULTS)} TOTAL")
|
||||
print("=" * 70)
|
||||
|
||||
return 0 if failed == 0 else 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,21 @@
|
||||
"""ChromaDB Vector Store - Basic Test"""
|
||||
import sys
|
||||
sys.path.insert(0, 'src/praisonai')
|
||||
from praisonai.persistence import create_knowledge_store
|
||||
from praisonai.persistence.knowledge.base import KnowledgeDocument
|
||||
|
||||
store = create_knowledge_store("chroma", path="/tmp/chroma_demo")
|
||||
try:
|
||||
store.delete_collection("demo")
|
||||
except Exception:
|
||||
pass
|
||||
store.create_collection("demo", dimension=1536)
|
||||
docs = [
|
||||
KnowledgeDocument(id="1", content="Python is a programming language", embedding=[0.1]*1536),
|
||||
KnowledgeDocument(id="2", content="JavaScript runs in browsers", embedding=[0.2]*1536)
|
||||
]
|
||||
store.insert("demo", docs)
|
||||
results = store.search("demo", query_embedding=[0.1]*1536, limit=1)
|
||||
print(f"Found: {len(results)} results")
|
||||
assert len(results) >= 1
|
||||
print("PASSED: ChromaDB vector store")
|
||||
@@ -0,0 +1,31 @@
|
||||
"""LanceDB Vector Store - Local Test"""
|
||||
import sys
|
||||
import os
|
||||
import shutil
|
||||
|
||||
try:
|
||||
import lancedb
|
||||
except ImportError:
|
||||
print("SKIPPED: LanceDB - lancedb not installed")
|
||||
sys.exit(0)
|
||||
|
||||
# Clean up previous test
|
||||
db_path = "/tmp/lancedb_test"
|
||||
if os.path.exists(db_path):
|
||||
shutil.rmtree(db_path)
|
||||
|
||||
db = lancedb.connect(db_path)
|
||||
|
||||
# Create table with vectors
|
||||
data = [
|
||||
{"id": "1", "text": "Machine learning is AI", "vector": [0.1] * 128},
|
||||
{"id": "2", "text": "Deep learning uses neural nets", "vector": [0.2] * 128},
|
||||
]
|
||||
table = db.create_table("demo", data)
|
||||
|
||||
# Search
|
||||
results = table.search([0.1] * 128).limit(1).to_list()
|
||||
print(f"Found: {len(results)} results")
|
||||
print(f"Top result: {results[0]['text'][:30]}...")
|
||||
assert len(results) >= 1
|
||||
print("PASSED: LanceDB vector store")
|
||||
@@ -0,0 +1,20 @@
|
||||
"""LanceDB Vector Store - Basic Test"""
|
||||
import sys
|
||||
sys.path.insert(0, 'src/praisonai')
|
||||
try:
|
||||
from praisonai.persistence import create_knowledge_store
|
||||
from praisonai.persistence.knowledge.base import KnowledgeDocument
|
||||
|
||||
store = create_knowledge_store("lancedb", path="/tmp/lancedb_demo")
|
||||
store.create_collection("demo", dimension=1536)
|
||||
docs = [
|
||||
KnowledgeDocument(id="doc1", content="The Eiffel Tower is in Paris", embedding=[0.1]*1536),
|
||||
KnowledgeDocument(id="doc2", content="The Statue of Liberty is in New York", embedding=[0.2]*1536)
|
||||
]
|
||||
store.insert("demo", docs)
|
||||
results = store.search("demo", query_embedding=[0.1]*1536, limit=1)
|
||||
print(f"Found: {len(results)} results")
|
||||
assert len(results) >= 1
|
||||
print("PASSED: LanceDB vector store")
|
||||
except ImportError as e:
|
||||
print(f"SKIPPED: LanceDB - {e}")
|
||||
@@ -0,0 +1,59 @@
|
||||
"""PGVector Vector Store - Docker Test"""
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Docker: docker run -d --name pgvector -e POSTGRES_PASSWORD=postgres -p 5433:5432 pgvector/pgvector:pg16
|
||||
url = os.getenv("PGVECTOR_URL", "postgresql://postgres:postgres@localhost:5433/postgres")
|
||||
|
||||
try:
|
||||
import psycopg2
|
||||
except ImportError:
|
||||
print("SKIPPED: PGVector - psycopg2 not installed")
|
||||
sys.exit(0)
|
||||
|
||||
try:
|
||||
conn = psycopg2.connect(url)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Ensure extension
|
||||
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
conn.commit()
|
||||
|
||||
# Create test table
|
||||
cur.execute("DROP TABLE IF EXISTS test_vectors")
|
||||
cur.execute("""
|
||||
CREATE TABLE test_vectors (
|
||||
id SERIAL PRIMARY KEY,
|
||||
content TEXT,
|
||||
embedding vector(128)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# Insert vectors
|
||||
cur.execute("""
|
||||
INSERT INTO test_vectors (content, embedding) VALUES
|
||||
('Machine learning is AI', %s),
|
||||
('Deep learning uses neural nets', %s)
|
||||
""", ([0.1] * 128, [0.2] * 128))
|
||||
conn.commit()
|
||||
|
||||
# Search (cosine similarity)
|
||||
cur.execute("""
|
||||
SELECT content, embedding <=> %s::vector AS distance
|
||||
FROM test_vectors
|
||||
ORDER BY distance
|
||||
LIMIT 1
|
||||
""", ([0.1] * 128,))
|
||||
|
||||
row = cur.fetchone()
|
||||
print(f"Top result: {row[0][:30]}... (distance: {row[1]:.4f})")
|
||||
|
||||
# Cleanup
|
||||
cur.execute("DROP TABLE test_vectors")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print("PASSED: PGVector vector store")
|
||||
except Exception as e:
|
||||
print(f"SKIPPED: PGVector - {e}")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""PGVector Knowledge Store - Basic Test (requires Docker)"""
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, 'src/praisonai')
|
||||
from praisonai.persistence import create_knowledge_store
|
||||
from praisonai.persistence.knowledge.base import KnowledgeDocument
|
||||
|
||||
# Docker: docker run -d --name pgvector -e POSTGRES_PASSWORD=postgres -p 5433:5432 pgvector/pgvector:pg16
|
||||
url = os.getenv("PGVECTOR_URL", "postgresql://postgres:postgres@localhost:5433/postgres")
|
||||
try:
|
||||
store = create_knowledge_store("pgvector", url=url)
|
||||
store.create_collection("demo", dimension=1536)
|
||||
docs = [
|
||||
KnowledgeDocument(id="1", content="Kubernetes orchestrates containers", embedding=[0.1]*1536),
|
||||
KnowledgeDocument(id="2", content="Docker packages applications", embedding=[0.2]*1536)
|
||||
]
|
||||
store.insert("demo", docs)
|
||||
results = store.search("demo", query_embedding=[0.1]*1536, limit=1)
|
||||
print(f"Found: {len(results)} results")
|
||||
assert len(results) >= 1
|
||||
print("PASSED: PGVector vector store")
|
||||
except Exception as e:
|
||||
print(f"SKIPPED: PGVector - {e}")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Pinecone Vector Store - Real API Test"""
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, 'src/praisonai')
|
||||
|
||||
# Requires: export PINECONE_API_KEY=...
|
||||
api_key = os.getenv("PINECONE_API_KEY")
|
||||
if not api_key:
|
||||
print("SKIPPED: Pinecone - PINECONE_API_KEY not set")
|
||||
sys.exit(0)
|
||||
|
||||
from pinecone import Pinecone
|
||||
|
||||
pc = Pinecone(api_key=api_key)
|
||||
indexes = pc.list_indexes()
|
||||
print(f"Pinecone connected! Found {len(list(indexes))} indexes")
|
||||
|
||||
# Use existing 'test' index or create one
|
||||
index_name = "test"
|
||||
if index_name not in [idx.name for idx in pc.list_indexes()]:
|
||||
print(f"SKIPPED: Pinecone - index '{index_name}' not found")
|
||||
sys.exit(0)
|
||||
|
||||
index = pc.Index(index_name)
|
||||
stats = index.describe_index_stats()
|
||||
print(f"Index '{index_name}' has {stats.total_vector_count} vectors")
|
||||
print("PASSED: Pinecone vector store")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Qdrant Vector Store - Basic Test (requires Docker)"""
|
||||
import sys, os
|
||||
sys.path.insert(0, 'src/praisonai')
|
||||
from praisonai.persistence import create_knowledge_store
|
||||
from praisonai.persistence.knowledge.base import KnowledgeDocument
|
||||
|
||||
# Docker: docker run -d --name qdrant -p 6333:6333 qdrant/qdrant
|
||||
url = os.getenv("QDRANT_URL", "http://localhost:6333")
|
||||
store = create_knowledge_store("qdrant", url=url)
|
||||
try:
|
||||
store.delete_collection("demo")
|
||||
except Exception:
|
||||
pass
|
||||
store.create_collection("demo", dimension=1536)
|
||||
docs = [
|
||||
KnowledgeDocument(id="1", content="Machine learning is AI", embedding=[0.1]*1536),
|
||||
KnowledgeDocument(id="2", content="Deep learning uses neural nets", embedding=[0.2]*1536)
|
||||
]
|
||||
store.insert("demo", docs)
|
||||
results = store.search("demo", query_embedding=[0.1]*1536, limit=1)
|
||||
print(f"Found: {len(results)} results")
|
||||
assert len(results) >= 1
|
||||
print("PASSED: Qdrant vector store")
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Weaviate Cloud Vector Store - Real API Test"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Requires: export WEAVIATE_URL=... and WEAVIATE_API_KEY=...
|
||||
weaviate_url = os.getenv("WEAVIATE_URL", "https://thhoyczshsawyt16ot3uw.c0.europe-west3.gcp.weaviate.cloud")
|
||||
weaviate_key = os.getenv("WEAVIATE_API_KEY")
|
||||
if not weaviate_key:
|
||||
print("SKIPPED: Weaviate - WEAVIATE_API_KEY not set")
|
||||
sys.exit(0)
|
||||
|
||||
import weaviate
|
||||
from weaviate.classes.init import Auth
|
||||
|
||||
client = weaviate.connect_to_weaviate_cloud(
|
||||
cluster_url=weaviate_url,
|
||||
auth_credentials=Auth.api_key(weaviate_key)
|
||||
)
|
||||
|
||||
print(f"Weaviate connected: {client.is_ready()}")
|
||||
collections = client.collections.list_all()
|
||||
print(f"Found {len(collections)} collections")
|
||||
client.close()
|
||||
print("PASSED: Weaviate cloud vector store")
|
||||
@@ -55,6 +55,7 @@ except ImportError:
|
||||
from .session import Session
|
||||
from .memory.memory import Memory
|
||||
from .db import db
|
||||
from .obs import obs
|
||||
# Workflows - import from dedicated workflows module
|
||||
from .workflows import (
|
||||
Workflow, WorkflowStep, WorkflowContext, StepResult,
|
||||
@@ -259,6 +260,7 @@ __all__ = [
|
||||
'Session',
|
||||
'Memory',
|
||||
'db',
|
||||
'obs',
|
||||
'display_interaction',
|
||||
'display_self_reflection',
|
||||
'display_instruction',
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""
|
||||
Observability interface for PraisonAI Agents.
|
||||
|
||||
This module provides the protocol and types for observability/tracing.
|
||||
Implementations are provided by the wrapper layer (praisonai_tools.observability).
|
||||
|
||||
Usage (simplest - recommended):
|
||||
from praisonaiagents import Agent, obs
|
||||
|
||||
agent = Agent(
|
||||
name="Assistant",
|
||||
observability=obs.auto(), # Auto-detect from env vars
|
||||
)
|
||||
agent.chat("Hello!") # Auto-traces to configured provider
|
||||
|
||||
Alternative (explicit provider):
|
||||
observability=obs.langfuse() # Langfuse
|
||||
observability=obs.langsmith() # LangSmith
|
||||
observability=obs.agentops() # AgentOps
|
||||
observability=obs.arize() # Arize Phoenix
|
||||
observability=obs.datadog() # Datadog
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
from dataclasses import dataclass, field
|
||||
import time
|
||||
|
||||
|
||||
@dataclass
|
||||
class Span:
|
||||
"""Represents a span in a trace."""
|
||||
span_id: str
|
||||
trace_id: str
|
||||
name: str
|
||||
parent_span_id: Optional[str] = None
|
||||
start_time: float = field(default_factory=time.time)
|
||||
end_time: Optional[float] = None
|
||||
attributes: Dict[str, Any] = field(default_factory=dict)
|
||||
status: str = "ok"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Trace:
|
||||
"""Represents a complete trace."""
|
||||
trace_id: str
|
||||
session_id: Optional[str] = None
|
||||
agent_name: Optional[str] = None
|
||||
user_id: Optional[str] = None
|
||||
start_time: float = field(default_factory=time.time)
|
||||
end_time: Optional[float] = None
|
||||
spans: List[Span] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
status: str = "ok"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ObservabilityAdapter(Protocol):
|
||||
"""Protocol for observability adapters."""
|
||||
|
||||
def on_trace_start(
|
||||
self,
|
||||
trace_id: str,
|
||||
session_id: Optional[str] = None,
|
||||
agent_name: Optional[str] = None,
|
||||
user_id: Optional[str] = None,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Called when a trace starts."""
|
||||
...
|
||||
|
||||
def on_trace_end(
|
||||
self,
|
||||
trace_id: str,
|
||||
status: str = "ok",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Called when a trace ends."""
|
||||
...
|
||||
|
||||
def on_span_start(
|
||||
self,
|
||||
span_id: str,
|
||||
trace_id: str,
|
||||
name: str,
|
||||
parent_span_id: Optional[str] = None,
|
||||
attributes: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Called when a span starts."""
|
||||
...
|
||||
|
||||
def on_span_end(
|
||||
self,
|
||||
span_id: str,
|
||||
status: str = "ok",
|
||||
attributes: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Called when a span ends."""
|
||||
...
|
||||
|
||||
def on_llm_call(
|
||||
self,
|
||||
span_id: str,
|
||||
model: str,
|
||||
messages: List[Dict[str, Any]],
|
||||
response: Optional[str] = None,
|
||||
tokens: Optional[Dict[str, int]] = None,
|
||||
latency_ms: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Called when an LLM call is made."""
|
||||
...
|
||||
|
||||
def on_tool_call(
|
||||
self,
|
||||
span_id: str,
|
||||
tool_name: str,
|
||||
args: Dict[str, Any],
|
||||
result: Any,
|
||||
latency_ms: Optional[float] = None,
|
||||
) -> None:
|
||||
"""Called when a tool is invoked."""
|
||||
...
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Flush any pending data."""
|
||||
...
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the adapter and release resources."""
|
||||
...
|
||||
|
||||
|
||||
class _LazyObsModule:
|
||||
"""
|
||||
Lazy proxy for observability backends.
|
||||
|
||||
Allows `from praisonaiagents import obs` without importing heavy deps.
|
||||
Actual provider classes are loaded only when accessed.
|
||||
"""
|
||||
|
||||
_PROVIDERS = {
|
||||
"langfuse": "praisonai_tools.observability.providers.langfuse_provider",
|
||||
"langsmith": "praisonai_tools.observability.providers.langsmith_provider",
|
||||
"agentops": "praisonai_tools.observability.providers.agentops_provider",
|
||||
"arize": "praisonai_tools.observability.providers.arize_phoenix_provider",
|
||||
"phoenix": "praisonai_tools.observability.providers.arize_phoenix_provider",
|
||||
"datadog": "praisonai_tools.observability.providers.datadog_provider",
|
||||
"mlflow": "praisonai_tools.observability.providers.mlflow_provider",
|
||||
"openlit": "praisonai_tools.observability.providers.openlit_provider",
|
||||
"opik": "praisonai_tools.observability.providers.opik_provider",
|
||||
"portkey": "praisonai_tools.observability.providers.portkey_provider",
|
||||
"traceloop": "praisonai_tools.observability.providers.traceloop_provider",
|
||||
"weave": "praisonai_tools.observability.providers.weave_provider",
|
||||
"braintrust": "praisonai_tools.observability.providers.braintrust_provider",
|
||||
"langtrace": "praisonai_tools.observability.providers.langtrace_provider",
|
||||
"langwatch": "praisonai_tools.observability.providers.langwatch_provider",
|
||||
"maxim": "praisonai_tools.observability.providers.maxim_provider",
|
||||
}
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
if name in self._PROVIDERS:
|
||||
return self._create_provider_factory(name)
|
||||
raise AttributeError(f"module 'obs' has no attribute {name!r}")
|
||||
|
||||
def _create_provider_factory(self, provider_name: str):
|
||||
"""Create a factory function for a provider."""
|
||||
def factory(**kwargs):
|
||||
try:
|
||||
import importlib
|
||||
module = importlib.import_module(self._PROVIDERS[provider_name])
|
||||
# Get the provider class (naming convention: XxxProvider)
|
||||
class_name = f"{provider_name.title().replace('_', '')}Provider"
|
||||
provider_cls = getattr(module, class_name, None)
|
||||
if provider_cls:
|
||||
return provider_cls(**kwargs)
|
||||
# Fallback: try to find any class ending with Provider
|
||||
for attr_name in dir(module):
|
||||
if attr_name.endswith("Provider"):
|
||||
return getattr(module, attr_name)(**kwargs)
|
||||
raise AttributeError(f"No provider class found in {module}")
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
f"Observability provider '{provider_name}' requires praisonai-tools. "
|
||||
f"Install with: pip install praisonai-tools\n"
|
||||
f"Original error: {e}"
|
||||
) from e
|
||||
return factory
|
||||
|
||||
def auto(self, **kwargs):
|
||||
"""
|
||||
Auto-detect observability provider from environment variables.
|
||||
|
||||
Checks for common env vars like LANGFUSE_PUBLIC_KEY, LANGSMITH_API_KEY, etc.
|
||||
Returns None if no provider is detected.
|
||||
"""
|
||||
import os
|
||||
|
||||
# Check for common provider env vars
|
||||
provider_env_map = {
|
||||
"LANGFUSE_PUBLIC_KEY": "langfuse",
|
||||
"LANGSMITH_API_KEY": "langsmith",
|
||||
"AGENTOPS_API_KEY": "agentops",
|
||||
"ARIZE_API_KEY": "arize",
|
||||
"DATADOG_API_KEY": "datadog",
|
||||
"MLFLOW_TRACKING_URI": "mlflow",
|
||||
"OPENLIT_API_KEY": "openlit",
|
||||
"OPIK_API_KEY": "opik",
|
||||
"PORTKEY_API_KEY": "portkey",
|
||||
"TRACELOOP_API_KEY": "traceloop",
|
||||
"WANDB_API_KEY": "weave",
|
||||
"BRAINTRUST_API_KEY": "braintrust",
|
||||
}
|
||||
|
||||
for env_var, provider in provider_env_map.items():
|
||||
if os.getenv(env_var):
|
||||
return self._create_provider_factory(provider)(**kwargs)
|
||||
|
||||
return None
|
||||
|
||||
def __repr__(self):
|
||||
return "<module 'praisonaiagents.obs' (lazy observability backends)>"
|
||||
|
||||
def __call__(self, provider: str = "auto", **kwargs):
|
||||
"""
|
||||
Shortcut: obs("langfuse") or obs() for auto-detection.
|
||||
"""
|
||||
if provider == "auto":
|
||||
return self.auto(**kwargs)
|
||||
return self._create_provider_factory(provider)(**kwargs)
|
||||
|
||||
|
||||
# Singleton lazy module instance
|
||||
obs = _LazyObsModule()
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ObservabilityAdapter",
|
||||
"Span",
|
||||
"Trace",
|
||||
"obs",
|
||||
]
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "0.2.1"
|
||||
version = "0.2.2"
|
||||
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.1"
|
||||
version = "0.2.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -2763,11 +2763,11 @@ agent.chat("Hello!") # Auto-persists messages, runs, traces
|
||||
| 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) |
|
||||
| Pinecone | Knowledge | `pip install pinecone` | [pinecone_wow.py](examples/vector/pinecone_wow.py) | [docs](https://docs.praison.ai/docs/databases/pinecone) |
|
||||
| Weaviate | Knowledge | `pip install weaviate-client` | [weaviate_wow.py](examples/vector/weaviate_wow.py) | [docs](https://docs.praison.ai/docs/databases/weaviate) |
|
||||
| LanceDB | Knowledge | `pip install lancedb` | [lancedb_real_wow.py](examples/vector/lancedb_real_wow.py) | [docs](https://docs.praison.ai/docs/databases/lancedb) |
|
||||
| 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) |
|
||||
| PGVector | Knowledge | `pip install psycopg2-binary` | [pgvector_real_wow.py](examples/vector/pgvector_real_wow.py) | [docs](https://docs.praison.ai/docs/databases/pgvector) |
|
||||
| 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) |
|
||||
|
||||
@@ -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.2.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.4.2.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.4.4.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v2.4.4.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Observability CLI Handler
|
||||
|
||||
CLI commands for managing observability providers.
|
||||
Usage: praisonai obs <command>
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
from .base import FlagHandler
|
||||
|
||||
|
||||
class ObservabilityHandler(FlagHandler):
|
||||
"""
|
||||
Handler for observability CLI commands.
|
||||
|
||||
Commands:
|
||||
praisonai obs list - List available providers
|
||||
praisonai obs doctor - Check provider connectivity
|
||||
praisonai obs init <name> - Initialize a provider
|
||||
"""
|
||||
|
||||
@property
|
||||
def feature_name(self) -> str:
|
||||
return "observability"
|
||||
|
||||
@property
|
||||
def flag_name(self) -> str:
|
||||
return "obs"
|
||||
|
||||
@property
|
||||
def flag_help(self) -> str:
|
||||
return "Observability commands (list, doctor, init)"
|
||||
|
||||
def check_dependencies(self) -> Tuple[bool, str]:
|
||||
"""Check if observability module is available."""
|
||||
try:
|
||||
from praisonai_tools.observability import obs
|
||||
return True, ""
|
||||
except ImportError:
|
||||
return False, "Observability requires praisonai-tools. Install with: pip install praisonai-tools"
|
||||
|
||||
def list_providers(self) -> Dict[str, Any]:
|
||||
"""List all available observability providers."""
|
||||
result = {
|
||||
"registered": [],
|
||||
"available": [],
|
||||
"configured": [],
|
||||
}
|
||||
|
||||
try:
|
||||
from praisonai_tools.observability.manager import ObservabilityManager
|
||||
from praisonai_tools.observability.config import PROVIDER_ENV_KEYS
|
||||
|
||||
mgr = ObservabilityManager()
|
||||
|
||||
# Get all registered providers
|
||||
result["registered"] = mgr.list_providers()
|
||||
|
||||
# Check which have env vars configured
|
||||
for provider, keys in PROVIDER_ENV_KEYS.items():
|
||||
if all(os.getenv(key) for key in keys):
|
||||
result["configured"].append(provider)
|
||||
|
||||
# Check which SDKs are available
|
||||
for provider in result["registered"]:
|
||||
try:
|
||||
mgr._load_provider(provider)
|
||||
if provider in mgr._providers:
|
||||
provider_class = mgr._providers[provider]
|
||||
instance = provider_class()
|
||||
if instance.is_available():
|
||||
result["available"].append(provider)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
return result
|
||||
|
||||
def doctor(self, provider: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Run diagnostics on observability setup."""
|
||||
try:
|
||||
from praisonai_tools.observability import obs
|
||||
|
||||
if provider:
|
||||
obs.init(provider=provider)
|
||||
|
||||
return obs.doctor()
|
||||
|
||||
except ImportError:
|
||||
return {
|
||||
"error": "praisonai-tools not installed",
|
||||
"enabled": False,
|
||||
}
|
||||
|
||||
def init_provider(self, provider: str, **kwargs) -> bool:
|
||||
"""Initialize a specific provider."""
|
||||
try:
|
||||
from praisonai_tools.observability import obs
|
||||
return obs.init(provider=provider, **kwargs)
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
def execute(self, action: str = "list", **kwargs) -> Any:
|
||||
"""
|
||||
Execute observability command.
|
||||
|
||||
Args:
|
||||
action: Command to execute (list, doctor, init)
|
||||
**kwargs: Additional arguments
|
||||
|
||||
Returns:
|
||||
Command result
|
||||
"""
|
||||
if action == "list":
|
||||
return self._execute_list()
|
||||
elif action == "doctor":
|
||||
return self._execute_doctor(kwargs.get("provider"))
|
||||
elif action == "init":
|
||||
return self._execute_init(kwargs.get("provider"), **kwargs)
|
||||
else:
|
||||
self.print_status(f"Unknown action: {action}", "error")
|
||||
return None
|
||||
|
||||
def _execute_list(self) -> Dict[str, Any]:
|
||||
"""Execute list command."""
|
||||
result = self.list_providers()
|
||||
|
||||
self.print_status("\n📊 Observability Providers", "info")
|
||||
self.print_status("=" * 40, "info")
|
||||
|
||||
# Provider status table
|
||||
all_providers = [
|
||||
"agentops", "langfuse", "langsmith", "traceloop",
|
||||
"arize_phoenix", "openlit", "langtrace", "langwatch",
|
||||
"datadog", "mlflow", "opik", "portkey",
|
||||
"braintrust", "maxim", "weave", "neatlogs",
|
||||
"langdb", "atla", "patronus", "truefoundry",
|
||||
]
|
||||
|
||||
for provider in all_providers:
|
||||
status_parts = []
|
||||
if provider in result.get("available", []):
|
||||
status_parts.append("✅ SDK")
|
||||
else:
|
||||
status_parts.append("❌ SDK")
|
||||
|
||||
if provider in result.get("configured", []):
|
||||
status_parts.append("🔑 Keys")
|
||||
else:
|
||||
status_parts.append("⚪ Keys")
|
||||
|
||||
status = " | ".join(status_parts)
|
||||
self.print_status(f" {provider:20} {status}", "info")
|
||||
|
||||
self.print_status("\n✅ = Available ❌ = Not installed 🔑 = API key configured", "info")
|
||||
|
||||
return result
|
||||
|
||||
def _execute_doctor(self, provider: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""Execute doctor command."""
|
||||
result = self.doctor(provider)
|
||||
|
||||
self.print_status("\n🩺 Observability Diagnostics", "info")
|
||||
self.print_status("=" * 40, "info")
|
||||
|
||||
self.print_status(f" Enabled: {result.get('enabled', False)}", "info")
|
||||
self.print_status(f" Provider: {result.get('provider', 'None')}", "info")
|
||||
|
||||
if result.get("connection_status") is not None:
|
||||
status = "✅" if result["connection_status"] else "❌"
|
||||
self.print_status(f" Connection: {status} {result.get('connection_message', '')}", "info")
|
||||
|
||||
if result.get("available_providers"):
|
||||
self.print_status(f" Available: {', '.join(result['available_providers'])}", "info")
|
||||
|
||||
return result
|
||||
|
||||
def _execute_init(self, provider: str, **kwargs) -> bool:
|
||||
"""Execute init command."""
|
||||
if not provider:
|
||||
self.print_status("Provider name required. Usage: praisonai obs init <provider>", "error")
|
||||
return False
|
||||
|
||||
success = self.init_provider(provider, **kwargs)
|
||||
|
||||
if success:
|
||||
self.print_status(f"✅ {provider} initialized successfully", "success")
|
||||
else:
|
||||
self.print_status(f"❌ Failed to initialize {provider}", "error")
|
||||
self.print_status(f" Check that {provider} SDK is installed and API keys are set", "info")
|
||||
|
||||
return success
|
||||
|
||||
def apply_to_agent_config(self, config: Dict[str, Any], flag_value: Any) -> Dict[str, Any]:
|
||||
"""Apply observability to agent config."""
|
||||
if flag_value:
|
||||
try:
|
||||
from praisonai_tools.observability import obs
|
||||
|
||||
if isinstance(flag_value, str):
|
||||
obs.init(provider=flag_value)
|
||||
else:
|
||||
obs.init()
|
||||
|
||||
self.print_status("📊 Observability enabled", "success")
|
||||
except ImportError:
|
||||
self.print_status("Observability requires praisonai-tools", "warning")
|
||||
|
||||
return config
|
||||
|
||||
|
||||
# Provider environment variable reference
|
||||
PROVIDER_SETUP_GUIDE = {
|
||||
"agentops": {
|
||||
"env_vars": ["AGENTOPS_API_KEY"],
|
||||
"install": "pip install agentops",
|
||||
"docs": "https://agentops.ai/",
|
||||
},
|
||||
"langfuse": {
|
||||
"env_vars": ["LANGFUSE_PUBLIC_KEY", "LANGFUSE_SECRET_KEY"],
|
||||
"install": "pip install opentelemetry-sdk opentelemetry-exporter-otlp",
|
||||
"docs": "https://langfuse.com/",
|
||||
},
|
||||
"langsmith": {
|
||||
"env_vars": ["LANGSMITH_API_KEY"],
|
||||
"install": "pip install opentelemetry-sdk opentelemetry-exporter-otlp",
|
||||
"docs": "https://smith.langchain.com/",
|
||||
},
|
||||
"traceloop": {
|
||||
"env_vars": ["TRACELOOP_API_KEY"],
|
||||
"install": "pip install traceloop-sdk",
|
||||
"docs": "https://traceloop.com/",
|
||||
},
|
||||
"arize_phoenix": {
|
||||
"env_vars": ["PHOENIX_API_KEY"],
|
||||
"install": "pip install arize-phoenix openinference-instrumentation",
|
||||
"docs": "https://phoenix.arize.com/",
|
||||
},
|
||||
"openlit": {
|
||||
"env_vars": [],
|
||||
"install": "pip install openlit",
|
||||
"docs": "https://github.com/openlit/openlit",
|
||||
},
|
||||
"langtrace": {
|
||||
"env_vars": ["LANGTRACE_API_KEY"],
|
||||
"install": "pip install langtrace-python-sdk",
|
||||
"docs": "https://langtrace.ai/",
|
||||
},
|
||||
"langwatch": {
|
||||
"env_vars": ["LANGWATCH_API_KEY"],
|
||||
"install": "pip install langwatch",
|
||||
"docs": "https://langwatch.ai/",
|
||||
},
|
||||
"datadog": {
|
||||
"env_vars": ["DD_API_KEY"],
|
||||
"install": "pip install ddtrace",
|
||||
"docs": "https://www.datadoghq.com/product/llm-observability/",
|
||||
},
|
||||
"mlflow": {
|
||||
"env_vars": ["MLFLOW_TRACKING_URI"],
|
||||
"install": "pip install mlflow",
|
||||
"docs": "https://mlflow.org/",
|
||||
},
|
||||
"opik": {
|
||||
"env_vars": ["OPIK_API_KEY"],
|
||||
"install": "pip install opik",
|
||||
"docs": "https://www.comet.com/docs/opik/",
|
||||
},
|
||||
"portkey": {
|
||||
"env_vars": ["PORTKEY_API_KEY"],
|
||||
"install": "pip install portkey-ai",
|
||||
"docs": "https://portkey.ai/",
|
||||
},
|
||||
"braintrust": {
|
||||
"env_vars": ["BRAINTRUST_API_KEY"],
|
||||
"install": "pip install braintrust",
|
||||
"docs": "https://www.braintrust.dev/",
|
||||
},
|
||||
"maxim": {
|
||||
"env_vars": ["MAXIM_API_KEY"],
|
||||
"install": "pip install maxim-py",
|
||||
"docs": "https://getmaxim.ai/",
|
||||
},
|
||||
"weave": {
|
||||
"env_vars": ["WANDB_API_KEY"],
|
||||
"install": "pip install weave",
|
||||
"docs": "https://weave-docs.wandb.ai/",
|
||||
},
|
||||
}
|
||||
@@ -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.2 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==2.4.4 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
"""
|
||||
Async MySQL implementation of ConversationStore.
|
||||
|
||||
Requires: aiomysql
|
||||
Install: pip install aiomysql
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
from .base import ConversationStore, ConversationSession, ConversationMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncMySQLConversationStore(ConversationStore):
|
||||
"""
|
||||
Async MySQL conversation store using aiomysql.
|
||||
|
||||
Provides high-performance async database operations.
|
||||
|
||||
Example:
|
||||
store = AsyncMySQLConversationStore(
|
||||
host="localhost",
|
||||
user="root",
|
||||
password="password",
|
||||
database="praisonai"
|
||||
)
|
||||
await store.init()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
host: str = "localhost",
|
||||
port: int = 3306,
|
||||
database: str = "praisonai",
|
||||
user: str = "root",
|
||||
password: str = "",
|
||||
table_prefix: str = "praisonai_",
|
||||
pool_size: int = 10,
|
||||
):
|
||||
"""
|
||||
Initialize async MySQL store.
|
||||
|
||||
Args:
|
||||
url: MySQL connection URL (takes precedence)
|
||||
host: Database host
|
||||
port: Database port
|
||||
database: Database name
|
||||
user: Database user
|
||||
password: Database password
|
||||
table_prefix: Prefix for table names
|
||||
pool_size: Connection pool size
|
||||
"""
|
||||
self.url = url
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.database = database
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.table_prefix = table_prefix
|
||||
self.pool_size = pool_size
|
||||
self._pool = None
|
||||
self._initialized = False
|
||||
|
||||
if url:
|
||||
self._parse_url(url)
|
||||
|
||||
def _parse_url(self, url: str):
|
||||
"""Parse MySQL URL into components."""
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
self.host = parsed.hostname or "localhost"
|
||||
self.port = parsed.port or 3306
|
||||
self.database = parsed.path.lstrip('/') or "praisonai"
|
||||
self.user = parsed.username or "root"
|
||||
self.password = parsed.password or ""
|
||||
|
||||
async def init(self):
|
||||
"""Initialize connection pool and create tables."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
import aiomysql
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"aiomysql is required for async MySQL support. "
|
||||
"Install with: pip install aiomysql"
|
||||
)
|
||||
|
||||
self._pool = await aiomysql.create_pool(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
user=self.user,
|
||||
password=self.password,
|
||||
db=self.database,
|
||||
minsize=1,
|
||||
maxsize=self.pool_size,
|
||||
autocommit=True
|
||||
)
|
||||
|
||||
await self._create_tables()
|
||||
self._initialized = True
|
||||
|
||||
async def _create_tables(self):
|
||||
"""Create required tables if they don't exist."""
|
||||
sessions_table = f"{self.table_prefix}sessions"
|
||||
messages_table = f"{self.table_prefix}messages"
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {sessions_table} (
|
||||
session_id VARCHAR(255) PRIMARY KEY,
|
||||
user_id VARCHAR(255),
|
||||
agent_id VARCHAR(255),
|
||||
name VARCHAR(255),
|
||||
metadata JSON,
|
||||
created_at DOUBLE,
|
||||
updated_at DOUBLE,
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_agent_id (agent_id)
|
||||
)
|
||||
""")
|
||||
|
||||
await cur.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {messages_table} (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
session_id VARCHAR(255),
|
||||
role VARCHAR(50),
|
||||
content TEXT,
|
||||
metadata JSON,
|
||||
created_at DOUBLE,
|
||||
INDEX idx_session_id (session_id),
|
||||
FOREIGN KEY (session_id) REFERENCES {sessions_table}(session_id) ON DELETE CASCADE
|
||||
)
|
||||
""")
|
||||
|
||||
async def async_create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Create a new session asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}sessions"
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(f"""
|
||||
INSERT INTO {table} (session_id, user_id, agent_id, name, metadata, created_at, updated_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""", (session.session_id, session.user_id, session.agent_id, session.name,
|
||||
json.dumps(session.metadata) if session.metadata else None,
|
||||
session.created_at, session.updated_at))
|
||||
|
||||
return session
|
||||
|
||||
def create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Sync wrapper for create_session."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_create_session(session)
|
||||
)
|
||||
|
||||
async def async_get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
"""Get a session by ID asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}sessions"
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(f"""
|
||||
SELECT session_id, user_id, agent_id, name, metadata, created_at, updated_at
|
||||
FROM {table} WHERE session_id = %s
|
||||
""", (session_id,))
|
||||
row = await cur.fetchone()
|
||||
|
||||
if row:
|
||||
return ConversationSession(
|
||||
session_id=row[0],
|
||||
user_id=row[1],
|
||||
agent_id=row[2],
|
||||
name=row[3],
|
||||
metadata=json.loads(row[4]) if row[4] else None,
|
||||
created_at=row[5],
|
||||
updated_at=row[6]
|
||||
)
|
||||
return None
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
"""Sync wrapper for get_session."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_get_session(session_id)
|
||||
)
|
||||
|
||||
async def async_update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Update an existing session asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
session.updated_at = time.time()
|
||||
table = f"{self.table_prefix}sessions"
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(f"""
|
||||
UPDATE {table}
|
||||
SET name = %s, metadata = %s, updated_at = %s
|
||||
WHERE session_id = %s
|
||||
""", (session.name, json.dumps(session.metadata) if session.metadata else None,
|
||||
session.updated_at, session.session_id))
|
||||
|
||||
return session
|
||||
|
||||
def update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Sync wrapper for update_session."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_update_session(session)
|
||||
)
|
||||
|
||||
async def async_delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}sessions"
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(f"""
|
||||
DELETE FROM {table} WHERE session_id = %s
|
||||
""", (session_id,))
|
||||
return cur.rowcount > 0
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Sync wrapper for delete_session."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_delete_session(session_id)
|
||||
)
|
||||
|
||||
async def async_list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[ConversationSession]:
|
||||
"""List sessions asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}sessions"
|
||||
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 = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
params.extend([limit, offset])
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(f"""
|
||||
SELECT session_id, user_id, agent_id, name, metadata, created_at, updated_at
|
||||
FROM {table} {where_clause}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""", params)
|
||||
rows = await cur.fetchall()
|
||||
|
||||
return [
|
||||
ConversationSession(
|
||||
session_id=row[0],
|
||||
user_id=row[1],
|
||||
agent_id=row[2],
|
||||
name=row[3],
|
||||
metadata=json.loads(row[4]) if row[4] else None,
|
||||
created_at=row[5],
|
||||
updated_at=row[6]
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[ConversationSession]:
|
||||
"""Sync wrapper for list_sessions."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_list_sessions(user_id, agent_id, limit, offset)
|
||||
)
|
||||
|
||||
async def async_add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
"""Add a message asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
message.session_id = session_id
|
||||
table = f"{self.table_prefix}messages"
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(f"""
|
||||
INSERT INTO {table} (id, session_id, role, content, metadata, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""", (message.id, session_id, message.role, message.content,
|
||||
json.dumps(message.metadata) if message.metadata else None,
|
||||
message.created_at))
|
||||
|
||||
return message
|
||||
|
||||
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
"""Sync wrapper for add_message."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_add_message(session_id, message)
|
||||
)
|
||||
|
||||
async def async_get_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
before: Optional[float] = None,
|
||||
after: Optional[float] = None
|
||||
) -> List[ConversationMessage]:
|
||||
"""Get messages asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}messages"
|
||||
conditions = ["session_id = %s"]
|
||||
params = [session_id]
|
||||
|
||||
if before:
|
||||
conditions.append("created_at < %s")
|
||||
params.append(before)
|
||||
|
||||
if after:
|
||||
conditions.append("created_at > %s")
|
||||
params.append(after)
|
||||
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}"
|
||||
limit_clause = "LIMIT %s" if limit else ""
|
||||
if limit:
|
||||
params.append(limit)
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
await cur.execute(f"""
|
||||
SELECT id, session_id, role, content, metadata, created_at
|
||||
FROM {table} {where_clause}
|
||||
ORDER BY created_at ASC {limit_clause}
|
||||
""", params)
|
||||
rows = await cur.fetchall()
|
||||
|
||||
return [
|
||||
ConversationMessage(
|
||||
id=row[0],
|
||||
session_id=row[1],
|
||||
role=row[2],
|
||||
content=row[3],
|
||||
metadata=json.loads(row[4]) if row[4] else None,
|
||||
created_at=row[5]
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
before: Optional[float] = None,
|
||||
after: Optional[float] = None
|
||||
) -> List[ConversationMessage]:
|
||||
"""Sync wrapper for get_messages."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_get_messages(session_id, limit, before, after)
|
||||
)
|
||||
|
||||
async def async_delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
"""Delete messages asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}messages"
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
async with conn.cursor() as cur:
|
||||
if message_ids is None:
|
||||
await cur.execute(f"""
|
||||
DELETE FROM {table} WHERE session_id = %s
|
||||
""", (session_id,))
|
||||
else:
|
||||
placeholders = ','.join(['%s'] * len(message_ids))
|
||||
await cur.execute(f"""
|
||||
DELETE FROM {table} WHERE session_id = %s AND id IN ({placeholders})
|
||||
""", [session_id] + message_ids)
|
||||
return cur.rowcount
|
||||
|
||||
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
"""Sync wrapper for delete_messages."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_delete_messages(session_id, message_ids)
|
||||
)
|
||||
|
||||
async def async_close(self) -> None:
|
||||
"""Close the connection pool."""
|
||||
if self._pool:
|
||||
self._pool.close()
|
||||
await self._pool.wait_closed()
|
||||
self._pool = None
|
||||
self._initialized = False
|
||||
|
||||
def close(self) -> None:
|
||||
"""Sync wrapper for close."""
|
||||
if self._pool:
|
||||
asyncio.get_event_loop().run_until_complete(self.async_close())
|
||||
@@ -0,0 +1,410 @@
|
||||
"""
|
||||
Async PostgreSQL implementation of ConversationStore.
|
||||
|
||||
Requires: asyncpg
|
||||
Install: pip install asyncpg
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
from .base import ConversationStore, ConversationSession, ConversationMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncPostgresConversationStore(ConversationStore):
|
||||
"""
|
||||
Async PostgreSQL conversation store using asyncpg.
|
||||
|
||||
Provides high-performance async database operations.
|
||||
|
||||
Example:
|
||||
store = AsyncPostgresConversationStore(
|
||||
url="postgresql://user:pass@localhost:5432/praisonai"
|
||||
)
|
||||
await store.init()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
host: str = "localhost",
|
||||
port: int = 5432,
|
||||
database: str = "praisonai",
|
||||
user: str = "postgres",
|
||||
password: str = "",
|
||||
table_prefix: str = "praisonai_",
|
||||
pool_size: int = 10,
|
||||
):
|
||||
"""
|
||||
Initialize async PostgreSQL store.
|
||||
|
||||
Args:
|
||||
url: PostgreSQL connection URL (takes precedence)
|
||||
host: Database host
|
||||
port: Database port
|
||||
database: Database name
|
||||
user: Database user
|
||||
password: Database password
|
||||
table_prefix: Prefix for table names
|
||||
pool_size: Connection pool size
|
||||
"""
|
||||
self.url = url
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.database = database
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.table_prefix = table_prefix
|
||||
self.pool_size = pool_size
|
||||
self._pool = None
|
||||
self._initialized = False
|
||||
|
||||
async def init(self):
|
||||
"""Initialize connection pool and create tables."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
import asyncpg
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"asyncpg is required for async PostgreSQL support. "
|
||||
"Install with: pip install asyncpg"
|
||||
)
|
||||
|
||||
if self.url:
|
||||
self._pool = await asyncpg.create_pool(self.url, min_size=1, max_size=self.pool_size)
|
||||
else:
|
||||
self._pool = await asyncpg.create_pool(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
database=self.database,
|
||||
user=self.user,
|
||||
password=self.password,
|
||||
min_size=1,
|
||||
max_size=self.pool_size
|
||||
)
|
||||
|
||||
await self._create_tables()
|
||||
self._initialized = True
|
||||
|
||||
async def _create_tables(self):
|
||||
"""Create required tables if they don't exist."""
|
||||
sessions_table = f"{self.table_prefix}sessions"
|
||||
messages_table = f"{self.table_prefix}messages"
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {sessions_table} (
|
||||
session_id VARCHAR(255) PRIMARY KEY,
|
||||
user_id VARCHAR(255),
|
||||
agent_id VARCHAR(255),
|
||||
name VARCHAR(255),
|
||||
metadata JSONB,
|
||||
created_at DOUBLE PRECISION,
|
||||
updated_at DOUBLE PRECISION
|
||||
)
|
||||
""")
|
||||
|
||||
await conn.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {messages_table} (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
session_id VARCHAR(255) REFERENCES {sessions_table}(session_id) ON DELETE CASCADE,
|
||||
role VARCHAR(50),
|
||||
content TEXT,
|
||||
metadata JSONB,
|
||||
created_at DOUBLE PRECISION
|
||||
)
|
||||
""")
|
||||
|
||||
await conn.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_{messages_table}_session
|
||||
ON {messages_table}(session_id)
|
||||
""")
|
||||
|
||||
async def async_create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Create a new session asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}sessions"
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.execute(f"""
|
||||
INSERT INTO {table} (session_id, user_id, agent_id, name, metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
""", session.session_id, session.user_id, session.agent_id, session.name,
|
||||
json.dumps(session.metadata) if session.metadata else None,
|
||||
session.created_at, session.updated_at)
|
||||
|
||||
return session
|
||||
|
||||
def create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Sync wrapper for create_session."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_create_session(session)
|
||||
)
|
||||
|
||||
async def async_get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
"""Get a session by ID asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}sessions"
|
||||
async with self._pool.acquire() as conn:
|
||||
row = await conn.fetchrow(f"""
|
||||
SELECT * FROM {table} WHERE session_id = $1
|
||||
""", session_id)
|
||||
|
||||
if row:
|
||||
return ConversationSession(
|
||||
session_id=row['session_id'],
|
||||
user_id=row['user_id'],
|
||||
agent_id=row['agent_id'],
|
||||
name=row['name'],
|
||||
metadata=json.loads(row['metadata']) if row['metadata'] else None,
|
||||
created_at=row['created_at'],
|
||||
updated_at=row['updated_at']
|
||||
)
|
||||
return None
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
"""Sync wrapper for get_session."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_get_session(session_id)
|
||||
)
|
||||
|
||||
async def async_update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Update an existing session asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
session.updated_at = time.time()
|
||||
table = f"{self.table_prefix}sessions"
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.execute(f"""
|
||||
UPDATE {table}
|
||||
SET name = $2, metadata = $3, updated_at = $4
|
||||
WHERE session_id = $1
|
||||
""", session.session_id, session.name,
|
||||
json.dumps(session.metadata) if session.metadata else None,
|
||||
session.updated_at)
|
||||
|
||||
return session
|
||||
|
||||
def update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Sync wrapper for update_session."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_update_session(session)
|
||||
)
|
||||
|
||||
async def async_delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}sessions"
|
||||
async with self._pool.acquire() as conn:
|
||||
result = await conn.execute(f"""
|
||||
DELETE FROM {table} WHERE session_id = $1
|
||||
""", session_id)
|
||||
|
||||
return "DELETE 1" in result
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Sync wrapper for delete_session."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_delete_session(session_id)
|
||||
)
|
||||
|
||||
async def async_list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[ConversationSession]:
|
||||
"""List sessions asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}sessions"
|
||||
conditions = []
|
||||
params = []
|
||||
param_idx = 1
|
||||
|
||||
if user_id:
|
||||
conditions.append(f"user_id = ${param_idx}")
|
||||
params.append(user_id)
|
||||
param_idx += 1
|
||||
|
||||
if agent_id:
|
||||
conditions.append(f"agent_id = ${param_idx}")
|
||||
params.append(agent_id)
|
||||
param_idx += 1
|
||||
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
params.extend([limit, offset])
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
rows = await conn.fetch(f"""
|
||||
SELECT * FROM {table} {where_clause}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ${param_idx} OFFSET ${param_idx + 1}
|
||||
""", *params)
|
||||
|
||||
return [
|
||||
ConversationSession(
|
||||
session_id=row['session_id'],
|
||||
user_id=row['user_id'],
|
||||
agent_id=row['agent_id'],
|
||||
name=row['name'],
|
||||
metadata=json.loads(row['metadata']) if row['metadata'] else None,
|
||||
created_at=row['created_at'],
|
||||
updated_at=row['updated_at']
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[ConversationSession]:
|
||||
"""Sync wrapper for list_sessions."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_list_sessions(user_id, agent_id, limit, offset)
|
||||
)
|
||||
|
||||
async def async_add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
"""Add a message asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
message.session_id = session_id
|
||||
table = f"{self.table_prefix}messages"
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
await conn.execute(f"""
|
||||
INSERT INTO {table} (id, session_id, role, content, metadata, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
""", message.id, session_id, message.role, message.content,
|
||||
json.dumps(message.metadata) if message.metadata else None,
|
||||
message.created_at)
|
||||
|
||||
return message
|
||||
|
||||
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
"""Sync wrapper for add_message."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_add_message(session_id, message)
|
||||
)
|
||||
|
||||
async def async_get_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
before: Optional[float] = None,
|
||||
after: Optional[float] = None
|
||||
) -> List[ConversationMessage]:
|
||||
"""Get messages asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}messages"
|
||||
conditions = ["session_id = $1"]
|
||||
params = [session_id]
|
||||
param_idx = 2
|
||||
|
||||
if before:
|
||||
conditions.append(f"created_at < ${param_idx}")
|
||||
params.append(before)
|
||||
param_idx += 1
|
||||
|
||||
if after:
|
||||
conditions.append(f"created_at > ${param_idx}")
|
||||
params.append(after)
|
||||
param_idx += 1
|
||||
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}"
|
||||
limit_clause = f"LIMIT ${param_idx}" if limit else ""
|
||||
if limit:
|
||||
params.append(limit)
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
rows = await conn.fetch(f"""
|
||||
SELECT * FROM {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'],
|
||||
metadata=json.loads(row['metadata']) if row['metadata'] else None,
|
||||
created_at=row['created_at']
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
before: Optional[float] = None,
|
||||
after: Optional[float] = None
|
||||
) -> List[ConversationMessage]:
|
||||
"""Sync wrapper for get_messages."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_get_messages(session_id, limit, before, after)
|
||||
)
|
||||
|
||||
async def async_delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
"""Delete messages asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}messages"
|
||||
|
||||
async with self._pool.acquire() as conn:
|
||||
if message_ids is None:
|
||||
result = await conn.execute(f"""
|
||||
DELETE FROM {table} WHERE session_id = $1
|
||||
""", session_id)
|
||||
else:
|
||||
result = await conn.execute(f"""
|
||||
DELETE FROM {table} WHERE session_id = $1 AND id = ANY($2)
|
||||
""", session_id, message_ids)
|
||||
|
||||
# Parse count from result like "DELETE 5"
|
||||
try:
|
||||
return int(result.split()[-1])
|
||||
except (ValueError, IndexError):
|
||||
return 0
|
||||
|
||||
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
"""Sync wrapper for delete_messages."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_delete_messages(session_id, message_ids)
|
||||
)
|
||||
|
||||
async def async_close(self) -> None:
|
||||
"""Close the connection pool."""
|
||||
if self._pool:
|
||||
await self._pool.close()
|
||||
self._pool = None
|
||||
self._initialized = False
|
||||
|
||||
def close(self) -> None:
|
||||
"""Sync wrapper for close."""
|
||||
if self._pool:
|
||||
asyncio.get_event_loop().run_until_complete(self.async_close())
|
||||
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
Async SQLite implementation of ConversationStore.
|
||||
|
||||
Requires: aiosqlite
|
||||
Install: pip install aiosqlite
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import List, Optional
|
||||
|
||||
from .base import ConversationStore, ConversationSession, ConversationMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncSQLiteConversationStore(ConversationStore):
|
||||
"""
|
||||
Async SQLite conversation store using aiosqlite.
|
||||
|
||||
Provides async database operations for SQLite.
|
||||
|
||||
Example:
|
||||
store = AsyncSQLiteConversationStore(path="./conversations.db")
|
||||
await store.init()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str = "praisonai_conversations.db",
|
||||
table_prefix: str = "praisonai_",
|
||||
):
|
||||
"""
|
||||
Initialize async SQLite store.
|
||||
|
||||
Args:
|
||||
path: Path to SQLite database file
|
||||
table_prefix: Prefix for table names
|
||||
"""
|
||||
self.path = path
|
||||
self.table_prefix = table_prefix
|
||||
self._conn = None
|
||||
self._initialized = False
|
||||
|
||||
async def init(self):
|
||||
"""Initialize connection and create tables."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
import aiosqlite
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"aiosqlite is required for async SQLite support. "
|
||||
"Install with: pip install aiosqlite"
|
||||
)
|
||||
|
||||
self._aiosqlite = aiosqlite
|
||||
self._conn = await aiosqlite.connect(self.path)
|
||||
self._conn.row_factory = aiosqlite.Row
|
||||
await self._create_tables()
|
||||
self._initialized = True
|
||||
|
||||
async def _create_tables(self):
|
||||
"""Create required tables if they don't exist."""
|
||||
sessions_table = f"{self.table_prefix}sessions"
|
||||
messages_table = f"{self.table_prefix}messages"
|
||||
|
||||
await self._conn.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {sessions_table} (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
agent_id TEXT,
|
||||
name TEXT,
|
||||
metadata TEXT,
|
||||
created_at REAL,
|
||||
updated_at REAL
|
||||
)
|
||||
""")
|
||||
|
||||
await self._conn.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {messages_table} (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT REFERENCES {sessions_table}(session_id) ON DELETE CASCADE,
|
||||
role TEXT,
|
||||
content TEXT,
|
||||
metadata TEXT,
|
||||
created_at REAL
|
||||
)
|
||||
""")
|
||||
|
||||
await self._conn.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_{messages_table}_session
|
||||
ON {messages_table}(session_id)
|
||||
""")
|
||||
|
||||
await self._conn.commit()
|
||||
|
||||
async def async_create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Create a new session asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}sessions"
|
||||
await self._conn.execute(f"""
|
||||
INSERT INTO {table} (session_id, user_id, agent_id, name, metadata, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", (session.session_id, session.user_id, session.agent_id, session.name,
|
||||
json.dumps(session.metadata) if session.metadata else None,
|
||||
session.created_at, session.updated_at))
|
||||
await self._conn.commit()
|
||||
|
||||
return session
|
||||
|
||||
def create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Sync wrapper for create_session."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_create_session(session)
|
||||
)
|
||||
|
||||
async def async_get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
"""Get a session by ID asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}sessions"
|
||||
async with self._conn.execute(f"""
|
||||
SELECT * FROM {table} WHERE session_id = ?
|
||||
""", (session_id,)) as cursor:
|
||||
row = await cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return ConversationSession(
|
||||
session_id=row['session_id'],
|
||||
user_id=row['user_id'],
|
||||
agent_id=row['agent_id'],
|
||||
name=row['name'],
|
||||
metadata=json.loads(row['metadata']) if row['metadata'] else None,
|
||||
created_at=row['created_at'],
|
||||
updated_at=row['updated_at']
|
||||
)
|
||||
return None
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
"""Sync wrapper for get_session."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_get_session(session_id)
|
||||
)
|
||||
|
||||
async def async_update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Update an existing session asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
session.updated_at = time.time()
|
||||
table = f"{self.table_prefix}sessions"
|
||||
|
||||
await self._conn.execute(f"""
|
||||
UPDATE {table}
|
||||
SET name = ?, metadata = ?, updated_at = ?
|
||||
WHERE session_id = ?
|
||||
""", (session.name, json.dumps(session.metadata) if session.metadata else None,
|
||||
session.updated_at, session.session_id))
|
||||
await self._conn.commit()
|
||||
|
||||
return session
|
||||
|
||||
def update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Sync wrapper for update_session."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_update_session(session)
|
||||
)
|
||||
|
||||
async def async_delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}sessions"
|
||||
cursor = await self._conn.execute(f"""
|
||||
DELETE FROM {table} WHERE session_id = ?
|
||||
""", (session_id,))
|
||||
await self._conn.commit()
|
||||
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Sync wrapper for delete_session."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_delete_session(session_id)
|
||||
)
|
||||
|
||||
async def async_list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[ConversationSession]:
|
||||
"""List sessions asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}sessions"
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if user_id:
|
||||
conditions.append("user_id = ?")
|
||||
params.append(user_id)
|
||||
|
||||
if agent_id:
|
||||
conditions.append("agent_id = ?")
|
||||
params.append(agent_id)
|
||||
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}" if conditions else ""
|
||||
params.extend([limit, offset])
|
||||
|
||||
async with self._conn.execute(f"""
|
||||
SELECT * FROM {table} {where_clause}
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
""", params) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return [
|
||||
ConversationSession(
|
||||
session_id=row['session_id'],
|
||||
user_id=row['user_id'],
|
||||
agent_id=row['agent_id'],
|
||||
name=row['name'],
|
||||
metadata=json.loads(row['metadata']) if row['metadata'] else None,
|
||||
created_at=row['created_at'],
|
||||
updated_at=row['updated_at']
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def list_sessions(
|
||||
self,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0
|
||||
) -> List[ConversationSession]:
|
||||
"""Sync wrapper for list_sessions."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_list_sessions(user_id, agent_id, limit, offset)
|
||||
)
|
||||
|
||||
async def async_add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
"""Add a message asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
message.session_id = session_id
|
||||
table = f"{self.table_prefix}messages"
|
||||
|
||||
await self._conn.execute(f"""
|
||||
INSERT INTO {table} (id, session_id, role, content, metadata, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (message.id, session_id, message.role, message.content,
|
||||
json.dumps(message.metadata) if message.metadata else None,
|
||||
message.created_at))
|
||||
await self._conn.commit()
|
||||
|
||||
return message
|
||||
|
||||
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
"""Sync wrapper for add_message."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_add_message(session_id, message)
|
||||
)
|
||||
|
||||
async def async_get_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
before: Optional[float] = None,
|
||||
after: Optional[float] = None
|
||||
) -> List[ConversationMessage]:
|
||||
"""Get messages asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}messages"
|
||||
conditions = ["session_id = ?"]
|
||||
params = [session_id]
|
||||
|
||||
if before:
|
||||
conditions.append("created_at < ?")
|
||||
params.append(before)
|
||||
|
||||
if after:
|
||||
conditions.append("created_at > ?")
|
||||
params.append(after)
|
||||
|
||||
where_clause = f"WHERE {' AND '.join(conditions)}"
|
||||
limit_clause = f"LIMIT ?" if limit else ""
|
||||
if limit:
|
||||
params.append(limit)
|
||||
|
||||
async with self._conn.execute(f"""
|
||||
SELECT * FROM {table} {where_clause}
|
||||
ORDER BY created_at ASC {limit_clause}
|
||||
""", params) as cursor:
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return [
|
||||
ConversationMessage(
|
||||
id=row['id'],
|
||||
session_id=row['session_id'],
|
||||
role=row['role'],
|
||||
content=row['content'],
|
||||
metadata=json.loads(row['metadata']) if row['metadata'] else None,
|
||||
created_at=row['created_at']
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def get_messages(
|
||||
self,
|
||||
session_id: str,
|
||||
limit: Optional[int] = None,
|
||||
before: Optional[float] = None,
|
||||
after: Optional[float] = None
|
||||
) -> List[ConversationMessage]:
|
||||
"""Sync wrapper for get_messages."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_get_messages(session_id, limit, before, after)
|
||||
)
|
||||
|
||||
async def async_delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
"""Delete messages asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
table = f"{self.table_prefix}messages"
|
||||
|
||||
if message_ids is None:
|
||||
cursor = await self._conn.execute(f"""
|
||||
DELETE FROM {table} WHERE session_id = ?
|
||||
""", (session_id,))
|
||||
else:
|
||||
placeholders = ','.join(['?' for _ in message_ids])
|
||||
cursor = await self._conn.execute(f"""
|
||||
DELETE FROM {table} WHERE session_id = ? AND id IN ({placeholders})
|
||||
""", [session_id] + message_ids)
|
||||
|
||||
await self._conn.commit()
|
||||
return cursor.rowcount
|
||||
|
||||
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
|
||||
"""Sync wrapper for delete_messages."""
|
||||
return asyncio.get_event_loop().run_until_complete(
|
||||
self.async_delete_messages(session_id, message_ids)
|
||||
)
|
||||
|
||||
async def async_close(self) -> None:
|
||||
"""Close the connection."""
|
||||
if self._conn:
|
||||
await self._conn.close()
|
||||
self._conn = None
|
||||
self._initialized = False
|
||||
|
||||
def close(self) -> None:
|
||||
"""Sync wrapper for close."""
|
||||
if self._conn:
|
||||
asyncio.get_event_loop().run_until_complete(self.async_close())
|
||||
@@ -0,0 +1,250 @@
|
||||
"""
|
||||
JSON file-based implementation of ConversationStore.
|
||||
|
||||
Simple file-based storage for development and testing.
|
||||
No external dependencies required.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
import threading
|
||||
|
||||
from .base import ConversationStore, ConversationSession, ConversationMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JSONConversationStore(ConversationStore):
|
||||
"""
|
||||
JSON file-based conversation store.
|
||||
|
||||
Stores sessions and messages in JSON files.
|
||||
Suitable for development, testing, and small-scale deployments.
|
||||
|
||||
Example:
|
||||
store = JSONConversationStore(path="./data/conversations")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str = "./praisonai_conversations",
|
||||
pretty: bool = True,
|
||||
):
|
||||
"""
|
||||
Initialize JSON conversation store.
|
||||
|
||||
Args:
|
||||
path: Directory path for JSON files
|
||||
pretty: Use pretty-printed JSON (default: True)
|
||||
"""
|
||||
self.path = Path(path)
|
||||
self.pretty = pretty
|
||||
self._lock = threading.Lock()
|
||||
|
||||
# Create directory if it doesn't exist
|
||||
self.path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Sessions index file
|
||||
self._index_file = self.path / "_sessions_index.json"
|
||||
self._load_index()
|
||||
|
||||
def _load_index(self):
|
||||
"""Load or create sessions index."""
|
||||
if self._index_file.exists():
|
||||
with open(self._index_file, 'r') as f:
|
||||
self._index = json.load(f)
|
||||
else:
|
||||
self._index = {"sessions": {}}
|
||||
self._save_index()
|
||||
|
||||
def _save_index(self):
|
||||
"""Save sessions index."""
|
||||
with open(self._index_file, 'w') as f:
|
||||
if self.pretty:
|
||||
json.dump(self._index, f, indent=2, default=str)
|
||||
else:
|
||||
json.dump(self._index, f, default=str)
|
||||
|
||||
def _session_file(self, session_id: str) -> Path:
|
||||
"""Get path to session file."""
|
||||
return self.path / f"{session_id}.json"
|
||||
|
||||
def _load_session_data(self, session_id: str) -> Optional[Dict]:
|
||||
"""Load session data from file."""
|
||||
file_path = self._session_file(session_id)
|
||||
if file_path.exists():
|
||||
with open(file_path, 'r') as f:
|
||||
return json.load(f)
|
||||
return None
|
||||
|
||||
def _save_session_data(self, session_id: str, data: Dict):
|
||||
"""Save session data to file."""
|
||||
file_path = self._session_file(session_id)
|
||||
with open(file_path, 'w') as f:
|
||||
if self.pretty:
|
||||
json.dump(data, f, indent=2, default=str)
|
||||
else:
|
||||
json.dump(data, f, default=str)
|
||||
|
||||
def create_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Create a new session."""
|
||||
with self._lock:
|
||||
data = {
|
||||
"session": session.to_dict(),
|
||||
"messages": []
|
||||
}
|
||||
self._save_session_data(session.session_id, data)
|
||||
|
||||
# Update index
|
||||
self._index["sessions"][session.session_id] = {
|
||||
"user_id": session.user_id,
|
||||
"agent_id": session.agent_id,
|
||||
"name": session.name,
|
||||
"created_at": session.created_at,
|
||||
"updated_at": session.updated_at,
|
||||
}
|
||||
self._save_index()
|
||||
|
||||
return session
|
||||
|
||||
def get_session(self, session_id: str) -> Optional[ConversationSession]:
|
||||
"""Get a session by ID."""
|
||||
data = self._load_session_data(session_id)
|
||||
if data and "session" in data:
|
||||
return ConversationSession.from_dict(data["session"])
|
||||
return None
|
||||
|
||||
def update_session(self, session: ConversationSession) -> ConversationSession:
|
||||
"""Update an existing session."""
|
||||
with self._lock:
|
||||
data = self._load_session_data(session.session_id)
|
||||
if not data:
|
||||
raise ValueError(f"Session {session.session_id} not found")
|
||||
|
||||
session.updated_at = time.time()
|
||||
data["session"] = session.to_dict()
|
||||
self._save_session_data(session.session_id, data)
|
||||
|
||||
# Update index
|
||||
self._index["sessions"][session.session_id].update({
|
||||
"name": session.name,
|
||||
"updated_at": session.updated_at,
|
||||
})
|
||||
self._save_index()
|
||||
|
||||
return session
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session and all its messages."""
|
||||
with self._lock:
|
||||
file_path = self._session_file(session_id)
|
||||
if file_path.exists():
|
||||
file_path.unlink()
|
||||
|
||||
# Update index
|
||||
if session_id in self._index["sessions"]:
|
||||
del self._index["sessions"][session_id]
|
||||
self._save_index()
|
||||
|
||||
return True
|
||||
return False
|
||||
|
||||
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."""
|
||||
sessions = []
|
||||
|
||||
for sid, info in self._index["sessions"].items():
|
||||
if user_id and info.get("user_id") != user_id:
|
||||
continue
|
||||
if agent_id and info.get("agent_id") != agent_id:
|
||||
continue
|
||||
|
||||
session = self.get_session(sid)
|
||||
if session:
|
||||
sessions.append(session)
|
||||
|
||||
# Sort by updated_at descending
|
||||
sessions.sort(key=lambda s: s.updated_at, reverse=True)
|
||||
|
||||
return sessions[offset:offset + limit]
|
||||
|
||||
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
|
||||
"""Add a message to a session."""
|
||||
with self._lock:
|
||||
data = self._load_session_data(session_id)
|
||||
if not data:
|
||||
raise ValueError(f"Session {session_id} not found")
|
||||
|
||||
message.session_id = session_id
|
||||
data["messages"].append(message.to_dict())
|
||||
|
||||
# Update session timestamp
|
||||
data["session"]["updated_at"] = time.time()
|
||||
|
||||
self._save_session_data(session_id, data)
|
||||
|
||||
# Update index
|
||||
if session_id in self._index["sessions"]:
|
||||
self._index["sessions"][session_id]["updated_at"] = time.time()
|
||||
self._save_index()
|
||||
|
||||
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."""
|
||||
data = self._load_session_data(session_id)
|
||||
if not data:
|
||||
return []
|
||||
|
||||
messages = [ConversationMessage.from_dict(m) for m in data.get("messages", [])]
|
||||
|
||||
# Apply filters
|
||||
if before:
|
||||
messages = [m for m in messages if m.created_at < before]
|
||||
if after:
|
||||
messages = [m for m in messages if m.created_at > after]
|
||||
|
||||
# Sort by created_at
|
||||
messages.sort(key=lambda m: m.created_at)
|
||||
|
||||
if limit:
|
||||
messages = messages[-limit:]
|
||||
|
||||
return messages
|
||||
|
||||
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."""
|
||||
with self._lock:
|
||||
data = self._load_session_data(session_id)
|
||||
if not data:
|
||||
return 0
|
||||
|
||||
if message_ids is None:
|
||||
count = len(data["messages"])
|
||||
data["messages"] = []
|
||||
else:
|
||||
original_count = len(data["messages"])
|
||||
data["messages"] = [m for m in data["messages"] if m.get("id") not in message_ids]
|
||||
count = original_count - len(data["messages"])
|
||||
|
||||
self._save_session_data(session_id, data)
|
||||
return count
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
pass # No resources to release
|
||||
@@ -62,10 +62,29 @@ def create_conversation_store(
|
||||
from .conversation.surrealdb import SurrealDBConversationStore
|
||||
return SurrealDBConversationStore(url=url, **options)
|
||||
|
||||
elif backend == "json":
|
||||
from .conversation.json_store import JSONConversationStore
|
||||
path = url or options.pop("path", None) or "./praisonai_conversations"
|
||||
return JSONConversationStore(path=path, **options)
|
||||
|
||||
elif backend in ("async_postgres", "asyncpg", "postgres_async"):
|
||||
from .conversation.async_postgres import AsyncPostgresConversationStore
|
||||
return AsyncPostgresConversationStore(url=url, **options)
|
||||
|
||||
elif backend in ("async_sqlite", "aiosqlite", "sqlite_async"):
|
||||
from .conversation.async_sqlite import AsyncSQLiteConversationStore
|
||||
path = url or options.pop("path", None)
|
||||
return AsyncSQLiteConversationStore(path=path, **options)
|
||||
|
||||
elif backend in ("async_mysql", "aiomysql", "mysql_async"):
|
||||
from .conversation.async_mysql import AsyncMySQLConversationStore
|
||||
return AsyncMySQLConversationStore(url=url, **options)
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown conversation store backend: {backend}. "
|
||||
f"Supported: postgres, mysql, sqlite, singlestore, supabase, surrealdb"
|
||||
f"Supported: postgres, mysql, sqlite, json, singlestore, supabase, surrealdb, "
|
||||
f"async_postgres, async_sqlite, async_mysql"
|
||||
)
|
||||
|
||||
|
||||
@@ -135,10 +154,47 @@ def create_knowledge_store(
|
||||
from .knowledge.clickhouse import ClickHouseKnowledgeStore
|
||||
return ClickHouseKnowledgeStore(**options)
|
||||
|
||||
elif backend == "couchbase":
|
||||
from .knowledge.couchbase import CouchbaseKnowledgeStore
|
||||
return CouchbaseKnowledgeStore(**options)
|
||||
|
||||
elif backend in ("mongodb_vector", "mongodb_atlas", "mongo_vector"):
|
||||
from .knowledge.mongodb_vector import MongoDBVectorKnowledgeStore
|
||||
return MongoDBVectorKnowledgeStore(url=url, **options)
|
||||
|
||||
elif backend in ("singlestore_vector", "singlestore_v"):
|
||||
from .knowledge.singlestore_vector import SingleStoreVectorKnowledgeStore
|
||||
return SingleStoreVectorKnowledgeStore(url=url, **options)
|
||||
|
||||
elif backend in ("surrealdb_vector", "surrealdb_v"):
|
||||
from .knowledge.surrealdb_vector import SurrealDBVectorKnowledgeStore
|
||||
return SurrealDBVectorKnowledgeStore(url=url, **options)
|
||||
|
||||
elif backend in ("upstash_vector", "upstash_v"):
|
||||
from .knowledge.upstash_vector import UpstashVectorKnowledgeStore
|
||||
return UpstashVectorKnowledgeStore(url=url, **options)
|
||||
|
||||
elif backend == "lightrag":
|
||||
from .knowledge.lightrag_adapter import LightRAGKnowledgeStore
|
||||
return LightRAGKnowledgeStore(**options)
|
||||
|
||||
elif backend in ("langchain", "langchain_adapter"):
|
||||
from .knowledge.langchain_adapter import LangChainKnowledgeStore
|
||||
return LangChainKnowledgeStore(**options)
|
||||
|
||||
elif backend in ("llamaindex", "llama_index", "llamaindex_adapter"):
|
||||
from .knowledge.llamaindex_adapter import LlamaIndexKnowledgeStore
|
||||
return LlamaIndexKnowledgeStore(**options)
|
||||
|
||||
elif backend in ("cosmosdb", "cosmos", "azure_cosmos", "cosmosdb_vector"):
|
||||
from .knowledge.cosmosdb_vector import CosmosDBVectorKnowledgeStore
|
||||
return CosmosDBVectorKnowledgeStore(**options)
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown knowledge store backend: {backend}. "
|
||||
f"Supported: qdrant, pinecone, chroma, weaviate, lancedb, milvus, pgvector, redis, cassandra, clickhouse"
|
||||
f"Supported: qdrant, pinecone, chroma, weaviate, lancedb, milvus, pgvector, redis, cassandra, clickhouse, "
|
||||
f"couchbase, mongodb_vector, singlestore_vector, surrealdb_vector, upstash_vector, lightrag, langchain, llamaindex, cosmosdb"
|
||||
)
|
||||
|
||||
|
||||
@@ -190,10 +246,21 @@ def create_state_store(
|
||||
from .state.memory import MemoryStateStore
|
||||
return MemoryStateStore(**options)
|
||||
|
||||
elif backend == "gcs":
|
||||
from .state.gcs import GCSStateStore
|
||||
bucket = options.pop("bucket_name", None) or options.pop("bucket", None)
|
||||
if not bucket:
|
||||
raise ValueError("GCS state store requires 'bucket_name' option")
|
||||
return GCSStateStore(bucket_name=bucket, **options)
|
||||
|
||||
elif backend in ("async_mongodb", "motor", "mongodb_async"):
|
||||
from .state.async_mongodb import AsyncMongoDBStateStore
|
||||
return AsyncMongoDBStateStore(url=url, **options)
|
||||
|
||||
else:
|
||||
raise ValueError(
|
||||
f"Unknown state store backend: {backend}. "
|
||||
f"Supported: redis, dynamodb, firestore, mongodb, upstash, memory"
|
||||
f"Supported: redis, dynamodb, firestore, mongodb, upstash, memory, gcs, async_mongodb"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
"""
|
||||
Azure Cosmos DB Vector implementation of KnowledgeStore.
|
||||
|
||||
Supports both MongoDB API and vCore modes.
|
||||
|
||||
Requires: pymongo (for MongoDB API) or azure-cosmos (for SQL API)
|
||||
Install: pip install pymongo # or pip install azure-cosmos
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CosmosDBVectorKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
Azure Cosmos DB vector store for knowledge/RAG.
|
||||
|
||||
Supports MongoDB API with vector search capabilities.
|
||||
|
||||
Example:
|
||||
store = CosmosDBVectorKnowledgeStore(
|
||||
connection_string="mongodb+srv://...",
|
||||
database="praisonai",
|
||||
collection="vectors"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connection_string: Optional[str] = None,
|
||||
database: str = "praisonai",
|
||||
collection: str = "vectors",
|
||||
index_name: str = "vector_index",
|
||||
embedding_field: str = "embedding",
|
||||
text_field: str = "content",
|
||||
embedding_dim: int = 1536,
|
||||
api_mode: str = "mongodb", # "mongodb" or "sql"
|
||||
):
|
||||
"""
|
||||
Initialize Cosmos DB Vector store.
|
||||
|
||||
Args:
|
||||
connection_string: Azure Cosmos DB connection string
|
||||
database: Database name
|
||||
collection: Collection name
|
||||
index_name: Vector search index name
|
||||
embedding_field: Field name for embeddings
|
||||
text_field: Field name for text content
|
||||
embedding_dim: Embedding dimension
|
||||
api_mode: API mode ("mongodb" or "sql")
|
||||
"""
|
||||
self.connection_string = connection_string
|
||||
self.database_name = database
|
||||
self.collection_name = collection
|
||||
self.index_name = index_name
|
||||
self.embedding_field = embedding_field
|
||||
self.text_field = text_field
|
||||
self.embedding_dim = embedding_dim
|
||||
self.api_mode = api_mode
|
||||
|
||||
self._client = None
|
||||
self._db = None
|
||||
self._collection = None
|
||||
self._initialized = False
|
||||
|
||||
def _init_client(self):
|
||||
"""Initialize Cosmos DB client lazily."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
if not self.connection_string:
|
||||
import os
|
||||
self.connection_string = os.getenv("COSMOS_CONNECTION_STRING")
|
||||
if not self.connection_string:
|
||||
raise ValueError(
|
||||
"connection_string required. Set COSMOS_CONNECTION_STRING env var."
|
||||
)
|
||||
|
||||
if self.api_mode == "mongodb":
|
||||
self._init_mongodb_client()
|
||||
else:
|
||||
self._init_sql_client()
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def _init_mongodb_client(self):
|
||||
"""Initialize MongoDB API client."""
|
||||
try:
|
||||
from pymongo import MongoClient
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"pymongo is required for Cosmos DB MongoDB API. "
|
||||
"Install with: pip install pymongo"
|
||||
)
|
||||
|
||||
self._client = MongoClient(self.connection_string)
|
||||
self._db = self._client[self.database_name]
|
||||
self._collection = self._db[self.collection_name]
|
||||
|
||||
def _init_sql_client(self):
|
||||
"""Initialize SQL API client."""
|
||||
try:
|
||||
from azure.cosmos import CosmosClient
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"azure-cosmos is required for Cosmos DB SQL API. "
|
||||
"Install with: pip install azure-cosmos"
|
||||
)
|
||||
|
||||
self._client = CosmosClient.from_connection_string(self.connection_string)
|
||||
self._db = self._client.get_database_client(self.database_name)
|
||||
self._collection = self._db.get_container_client(self.collection_name)
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int = 1536,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create collection with vector index."""
|
||||
self._init_client()
|
||||
|
||||
if self.api_mode == "mongodb":
|
||||
# Create vector search index via MongoDB command
|
||||
try:
|
||||
self._db.command({
|
||||
"createIndexes": name,
|
||||
"indexes": [{
|
||||
"name": self.index_name,
|
||||
"key": {self.embedding_field: "cosmosSearch"},
|
||||
"cosmosSearchOptions": {
|
||||
"kind": "vector-ivf",
|
||||
"numLists": 100,
|
||||
"similarity": distance,
|
||||
"dimensions": dimension
|
||||
}
|
||||
}]
|
||||
})
|
||||
except Exception as e:
|
||||
logger.warning(f"Index creation may require manual setup: {e}")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete collection."""
|
||||
self._init_client()
|
||||
try:
|
||||
if self.api_mode == "mongodb":
|
||||
self._db.drop_collection(name)
|
||||
else:
|
||||
self._db.delete_container(name)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete collection: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if collection exists."""
|
||||
self._init_client()
|
||||
if self.api_mode == "mongodb":
|
||||
return name in self._db.list_collection_names()
|
||||
return True # SQL API containers are pre-created
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List collections."""
|
||||
self._init_client()
|
||||
if self.api_mode == "mongodb":
|
||||
return self._db.list_collection_names()
|
||||
return [c["id"] for c in self._db.list_containers()]
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents."""
|
||||
self._init_client()
|
||||
|
||||
coll = self._db[collection] if self.api_mode == "mongodb" else self._db.get_container_client(collection)
|
||||
ids = []
|
||||
|
||||
for doc in documents:
|
||||
item = {
|
||||
"_id" if self.api_mode == "mongodb" else "id": doc.id,
|
||||
self.text_field: doc.content,
|
||||
self.embedding_field: doc.embedding,
|
||||
"metadata": doc.metadata or {},
|
||||
"content_hash": doc.content_hash,
|
||||
"created_at": doc.created_at,
|
||||
}
|
||||
|
||||
try:
|
||||
if self.api_mode == "mongodb":
|
||||
coll.insert_one(item)
|
||||
else:
|
||||
coll.create_item(item)
|
||||
ids.append(doc.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to insert {doc.id}: {e}")
|
||||
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Upsert documents."""
|
||||
self._init_client()
|
||||
|
||||
coll = self._db[collection] if self.api_mode == "mongodb" else self._db.get_container_client(collection)
|
||||
ids = []
|
||||
|
||||
for doc in documents:
|
||||
item = {
|
||||
"_id" if self.api_mode == "mongodb" else "id": doc.id,
|
||||
self.text_field: doc.content,
|
||||
self.embedding_field: doc.embedding,
|
||||
"metadata": doc.metadata or {},
|
||||
"content_hash": doc.content_hash,
|
||||
"created_at": doc.created_at,
|
||||
}
|
||||
|
||||
try:
|
||||
if self.api_mode == "mongodb":
|
||||
coll.replace_one({"_id": doc.id}, item, upsert=True)
|
||||
else:
|
||||
coll.upsert_item(item)
|
||||
ids.append(doc.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to upsert {doc.id}: {e}")
|
||||
|
||||
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 using vector search."""
|
||||
self._init_client()
|
||||
|
||||
if self.api_mode == "mongodb":
|
||||
return self._search_mongodb(collection, query_embedding, limit, filters, score_threshold)
|
||||
else:
|
||||
return self._search_sql(collection, query_embedding, limit, filters, score_threshold)
|
||||
|
||||
def _search_mongodb(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: List[float],
|
||||
limit: int,
|
||||
filters: Optional[Dict[str, Any]],
|
||||
score_threshold: Optional[float]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""MongoDB API vector search."""
|
||||
coll = self._db[collection]
|
||||
|
||||
pipeline = [
|
||||
{
|
||||
"$search": {
|
||||
"cosmosSearch": {
|
||||
"vector": query_embedding,
|
||||
"path": self.embedding_field,
|
||||
"k": limit
|
||||
},
|
||||
"returnStoredSource": True
|
||||
}
|
||||
},
|
||||
{
|
||||
"$project": {
|
||||
"similarityScore": {"$meta": "searchScore"},
|
||||
self.text_field: 1,
|
||||
"metadata": 1,
|
||||
"content_hash": 1,
|
||||
"created_at": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
if filters:
|
||||
pipeline.insert(1, {"$match": filters})
|
||||
|
||||
try:
|
||||
results = list(coll.aggregate(pipeline))
|
||||
|
||||
documents = []
|
||||
for doc in results:
|
||||
score = doc.get("similarityScore", 0)
|
||||
if score_threshold and score < score_threshold:
|
||||
continue
|
||||
|
||||
documents.append(KnowledgeDocument(
|
||||
id=str(doc.get("_id", "")),
|
||||
content=doc.get(self.text_field, ""),
|
||||
metadata={**(doc.get("metadata") or {}), "score": score},
|
||||
content_hash=doc.get("content_hash"),
|
||||
created_at=doc.get("created_at", 0)
|
||||
))
|
||||
|
||||
return documents
|
||||
except Exception as e:
|
||||
logger.error(f"Vector search failed: {e}")
|
||||
return []
|
||||
|
||||
def _search_sql(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: List[float],
|
||||
limit: int,
|
||||
filters: Optional[Dict[str, Any]],
|
||||
score_threshold: Optional[float]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""SQL API vector search (requires DiskANN index)."""
|
||||
coll = self._db.get_container_client(collection)
|
||||
|
||||
# SQL API uses VectorDistance function
|
||||
query = f"""
|
||||
SELECT TOP {limit} c.id, c.{self.text_field}, c.metadata, c.content_hash, c.created_at,
|
||||
VectorDistance(c.{self.embedding_field}, @embedding) AS score
|
||||
FROM c
|
||||
ORDER BY VectorDistance(c.{self.embedding_field}, @embedding)
|
||||
"""
|
||||
|
||||
try:
|
||||
results = list(coll.query_items(
|
||||
query=query,
|
||||
parameters=[{"name": "@embedding", "value": query_embedding}],
|
||||
enable_cross_partition_query=True
|
||||
))
|
||||
|
||||
documents = []
|
||||
for doc in results:
|
||||
score = doc.get("score", 0)
|
||||
if score_threshold and score < score_threshold:
|
||||
continue
|
||||
|
||||
documents.append(KnowledgeDocument(
|
||||
id=doc.get("id", ""),
|
||||
content=doc.get(self.text_field, ""),
|
||||
metadata={**(doc.get("metadata") or {}), "score": score},
|
||||
content_hash=doc.get("content_hash"),
|
||||
created_at=doc.get("created_at", 0)
|
||||
))
|
||||
|
||||
return documents
|
||||
except Exception as e:
|
||||
logger.error(f"Vector search failed: {e}")
|
||||
return []
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
self._init_client()
|
||||
|
||||
coll = self._db[collection] if self.api_mode == "mongodb" else self._db.get_container_client(collection)
|
||||
documents = []
|
||||
|
||||
for doc_id in ids:
|
||||
try:
|
||||
if self.api_mode == "mongodb":
|
||||
doc = coll.find_one({"_id": doc_id})
|
||||
else:
|
||||
doc = coll.read_item(item=doc_id, partition_key=doc_id)
|
||||
|
||||
if doc:
|
||||
documents.append(KnowledgeDocument(
|
||||
id=str(doc.get("_id" if self.api_mode == "mongodb" else "id", "")),
|
||||
content=doc.get(self.text_field, ""),
|
||||
embedding=doc.get(self.embedding_field),
|
||||
metadata=doc.get("metadata"),
|
||||
content_hash=doc.get("content_hash"),
|
||||
created_at=doc.get("created_at", 0)
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get {doc_id}: {e}")
|
||||
|
||||
return documents
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents."""
|
||||
self._init_client()
|
||||
|
||||
coll = self._db[collection] if self.api_mode == "mongodb" else self._db.get_container_client(collection)
|
||||
|
||||
if ids:
|
||||
count = 0
|
||||
for doc_id in ids:
|
||||
try:
|
||||
if self.api_mode == "mongodb":
|
||||
coll.delete_one({"_id": doc_id})
|
||||
else:
|
||||
coll.delete_item(item=doc_id, partition_key=doc_id)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete {doc_id}: {e}")
|
||||
return count
|
||||
|
||||
return 0
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents."""
|
||||
self._init_client()
|
||||
|
||||
if self.api_mode == "mongodb":
|
||||
return self._db[collection].count_documents({})
|
||||
else:
|
||||
# SQL API count
|
||||
coll = self._db.get_container_client(collection)
|
||||
result = list(coll.query_items(
|
||||
query="SELECT VALUE COUNT(1) FROM c",
|
||||
enable_cross_partition_query=True
|
||||
))
|
||||
return result[0] if result else 0
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the connection."""
|
||||
if self._client:
|
||||
if self.api_mode == "mongodb":
|
||||
self._client.close()
|
||||
self._client = None
|
||||
self._db = None
|
||||
self._collection = None
|
||||
self._initialized = False
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Couchbase implementation of KnowledgeStore.
|
||||
|
||||
Requires: couchbase
|
||||
Install: pip install couchbase
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CouchbaseKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
Couchbase vector store for knowledge/RAG.
|
||||
|
||||
Uses Couchbase's vector search capabilities.
|
||||
|
||||
Example:
|
||||
store = CouchbaseKnowledgeStore(
|
||||
connection_string="couchbase://localhost",
|
||||
username="admin",
|
||||
password="password",
|
||||
bucket_name="praisonai"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
connection_string: str = "couchbase://localhost",
|
||||
username: str = "Administrator",
|
||||
password: str = "",
|
||||
bucket_name: str = "praisonai",
|
||||
scope_name: str = "_default",
|
||||
collection_name: str = "vectors",
|
||||
index_name: str = "vector_index",
|
||||
vector_field: str = "embedding",
|
||||
text_field: str = "content",
|
||||
embedding_dim: int = 1536,
|
||||
):
|
||||
"""
|
||||
Initialize Couchbase knowledge store.
|
||||
|
||||
Args:
|
||||
connection_string: Couchbase connection string
|
||||
username: Couchbase username
|
||||
password: Couchbase password
|
||||
bucket_name: Bucket name
|
||||
scope_name: Scope name
|
||||
collection_name: Collection name
|
||||
index_name: Vector search index name
|
||||
vector_field: Field name for embeddings
|
||||
text_field: Field name for text content
|
||||
embedding_dim: Embedding dimension
|
||||
"""
|
||||
self.connection_string = connection_string
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.bucket_name = bucket_name
|
||||
self.scope_name = scope_name
|
||||
self.collection_name = collection_name
|
||||
self.index_name = index_name
|
||||
self.vector_field = vector_field
|
||||
self.text_field = text_field
|
||||
self.embedding_dim = embedding_dim
|
||||
|
||||
self._cluster = None
|
||||
self._bucket = None
|
||||
self._collection = None
|
||||
self._initialized = False
|
||||
|
||||
def _init_client(self):
|
||||
"""Initialize Couchbase client lazily."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
from couchbase.cluster import Cluster
|
||||
from couchbase.options import ClusterOptions
|
||||
from couchbase.auth import PasswordAuthenticator
|
||||
from datetime import timedelta
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"couchbase is required for Couchbase support. "
|
||||
"Install with: pip install couchbase"
|
||||
)
|
||||
|
||||
auth = PasswordAuthenticator(self.username, self.password)
|
||||
options = ClusterOptions(auth)
|
||||
|
||||
self._cluster = Cluster(self.connection_string, options)
|
||||
self._cluster.wait_until_ready(timedelta(seconds=10))
|
||||
|
||||
self._bucket = self._cluster.bucket(self.bucket_name)
|
||||
scope = self._bucket.scope(self.scope_name)
|
||||
self._collection = scope.collection(self.collection_name)
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection_name: str,
|
||||
documents: List[KnowledgeDocument],
|
||||
embeddings: Optional[List[List[float]]] = None,
|
||||
) -> List[str]:
|
||||
"""Insert documents into the store."""
|
||||
self._init_client()
|
||||
|
||||
ids = []
|
||||
for i, doc in enumerate(documents):
|
||||
doc_id = doc.id or f"doc_{i}_{hash(doc.content)}"
|
||||
|
||||
data = {
|
||||
self.text_field: doc.content,
|
||||
"metadata": doc.metadata or {},
|
||||
}
|
||||
|
||||
if embeddings and i < len(embeddings):
|
||||
data[self.vector_field] = embeddings[i]
|
||||
|
||||
self._collection.upsert(doc_id, data)
|
||||
ids.append(doc_id)
|
||||
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection_name: str,
|
||||
documents: List[KnowledgeDocument],
|
||||
embeddings: Optional[List[List[float]]] = None,
|
||||
) -> List[str]:
|
||||
"""Upsert documents (same as insert for Couchbase)."""
|
||||
return self.insert(collection_name, documents, embeddings)
|
||||
|
||||
def search(
|
||||
self,
|
||||
collection_name: str,
|
||||
query_embedding: List[float],
|
||||
limit: int = 10,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Search for similar documents using vector search."""
|
||||
self._init_client()
|
||||
|
||||
try:
|
||||
from couchbase.vector_search import VectorQuery, VectorSearch
|
||||
except ImportError:
|
||||
logger.warning("Vector search not available in this Couchbase version")
|
||||
return []
|
||||
|
||||
vector_query = VectorQuery(
|
||||
self.vector_field,
|
||||
query_embedding,
|
||||
num_candidates=limit * 2
|
||||
)
|
||||
|
||||
search_req = VectorSearch.from_vector_query(vector_query)
|
||||
|
||||
try:
|
||||
scope = self._bucket.scope(self.scope_name)
|
||||
result = scope.search(
|
||||
self.index_name,
|
||||
search_req,
|
||||
limit=limit
|
||||
)
|
||||
|
||||
documents = []
|
||||
for row in result.rows():
|
||||
doc_id = row.id
|
||||
try:
|
||||
doc_data = self._collection.get(doc_id).content_as[dict]
|
||||
documents.append(KnowledgeDocument(
|
||||
id=doc_id,
|
||||
content=doc_data.get(self.text_field, ""),
|
||||
metadata=doc_data.get("metadata", {}),
|
||||
score=row.score if hasattr(row, 'score') else None
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch document {doc_id}: {e}")
|
||||
|
||||
return documents
|
||||
except Exception as e:
|
||||
logger.error(f"Vector search failed: {e}")
|
||||
return []
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection_name: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> int:
|
||||
"""Delete documents by ID."""
|
||||
self._init_client()
|
||||
|
||||
if not ids:
|
||||
return 0
|
||||
|
||||
count = 0
|
||||
for doc_id in ids:
|
||||
try:
|
||||
self._collection.remove(doc_id)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete {doc_id}: {e}")
|
||||
|
||||
return count
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection_name: str,
|
||||
ids: List[str],
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by ID."""
|
||||
self._init_client()
|
||||
|
||||
documents = []
|
||||
for doc_id in ids:
|
||||
try:
|
||||
doc_data = self._collection.get(doc_id).content_as[dict]
|
||||
documents.append(KnowledgeDocument(
|
||||
id=doc_id,
|
||||
content=doc_data.get(self.text_field, ""),
|
||||
metadata=doc_data.get("metadata", {})
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get {doc_id}: {e}")
|
||||
|
||||
return documents
|
||||
|
||||
def count(self, collection_name: str) -> int:
|
||||
"""Count documents in collection."""
|
||||
self._init_client()
|
||||
|
||||
try:
|
||||
query = f"SELECT COUNT(*) as count FROM `{self.bucket_name}`.`{self.scope_name}`.`{self.collection_name}`"
|
||||
result = self._cluster.query(query)
|
||||
for row in result:
|
||||
return row.get("count", 0)
|
||||
except Exception as e:
|
||||
logger.error(f"Count query failed: {e}")
|
||||
|
||||
return 0
|
||||
|
||||
def create_collection(self, collection_name: str, **kwargs) -> bool:
|
||||
"""Create collection (Couchbase collections are pre-created)."""
|
||||
self._init_client()
|
||||
return True
|
||||
|
||||
def delete_collection(self, collection_name: str) -> bool:
|
||||
"""Delete all documents in collection."""
|
||||
self._init_client()
|
||||
|
||||
try:
|
||||
query = f"DELETE FROM `{self.bucket_name}`.`{self.scope_name}`.`{self.collection_name}`"
|
||||
self._cluster.query(query)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Delete collection failed: {e}")
|
||||
return False
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List available collections."""
|
||||
self._init_client()
|
||||
|
||||
try:
|
||||
collections = self._bucket.collections().get_all_scopes()
|
||||
result = []
|
||||
for scope in collections:
|
||||
for coll in scope.collections:
|
||||
result.append(f"{scope.name}.{coll.name}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"List collections failed: {e}")
|
||||
return []
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the connection."""
|
||||
if self._cluster:
|
||||
self._cluster.close()
|
||||
self._cluster = None
|
||||
self._bucket = None
|
||||
self._collection = None
|
||||
self._initialized = False
|
||||
@@ -0,0 +1,291 @@
|
||||
"""
|
||||
LangChain VectorStore adapter implementation of KnowledgeStore.
|
||||
|
||||
Wraps any LangChain VectorStore for use with PraisonAI.
|
||||
|
||||
Requires: langchain-core
|
||||
Install: pip install langchain-core
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LangChainKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
LangChain VectorStore adapter for knowledge/RAG.
|
||||
|
||||
Wraps any LangChain VectorStore for use with PraisonAI's interface.
|
||||
|
||||
Example:
|
||||
from langchain_chroma import Chroma
|
||||
|
||||
lc_store = Chroma(persist_directory="./chroma_db")
|
||||
store = LangChainKnowledgeStore(vectorstore=lc_store)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vectorstore: Any = None,
|
||||
vectorstore_cls: Optional[str] = None,
|
||||
vectorstore_kwargs: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize LangChain adapter.
|
||||
|
||||
Args:
|
||||
vectorstore: Pre-initialized LangChain VectorStore instance
|
||||
vectorstore_cls: Class name to import (e.g., "langchain_chroma.Chroma")
|
||||
vectorstore_kwargs: Kwargs for vectorstore initialization
|
||||
"""
|
||||
self._vectorstore = vectorstore
|
||||
self._vectorstore_cls = vectorstore_cls
|
||||
self._vectorstore_kwargs = vectorstore_kwargs or {}
|
||||
self._initialized = vectorstore is not None
|
||||
|
||||
def _init_client(self):
|
||||
"""Initialize vectorstore lazily."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
if not self._vectorstore_cls:
|
||||
raise ValueError(
|
||||
"Either vectorstore or vectorstore_cls must be provided"
|
||||
)
|
||||
|
||||
# Dynamic import of vectorstore class
|
||||
parts = self._vectorstore_cls.rsplit(".", 1)
|
||||
if len(parts) == 2:
|
||||
module_name, class_name = parts
|
||||
import importlib
|
||||
module = importlib.import_module(module_name)
|
||||
cls = getattr(module, class_name)
|
||||
self._vectorstore = cls(**self._vectorstore_kwargs)
|
||||
else:
|
||||
raise ValueError(f"Invalid vectorstore_cls: {self._vectorstore_cls}")
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int = 1536,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create collection (handled by underlying vectorstore)."""
|
||||
self._init_client()
|
||||
logger.info(f"Collection creation handled by LangChain vectorstore")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete collection."""
|
||||
self._init_client()
|
||||
try:
|
||||
if hasattr(self._vectorstore, 'delete_collection'):
|
||||
self._vectorstore.delete_collection()
|
||||
return True
|
||||
logger.warning("Underlying vectorstore doesn't support delete_collection")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete collection: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if collection exists."""
|
||||
self._init_client()
|
||||
return self._vectorstore is not None
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List collections."""
|
||||
self._init_client()
|
||||
if hasattr(self._vectorstore, 'list_collections'):
|
||||
return self._vectorstore.list_collections()
|
||||
return ["default"]
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents."""
|
||||
self._init_client()
|
||||
|
||||
try:
|
||||
from langchain_core.documents import Document as LCDocument
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"langchain-core is required. Install with: pip install langchain-core"
|
||||
)
|
||||
|
||||
lc_docs = []
|
||||
ids = []
|
||||
for doc in documents:
|
||||
lc_doc = LCDocument(
|
||||
page_content=doc.content,
|
||||
metadata={
|
||||
**(doc.metadata or {}),
|
||||
"id": doc.id,
|
||||
"content_hash": doc.content_hash,
|
||||
"created_at": doc.created_at,
|
||||
}
|
||||
)
|
||||
lc_docs.append(lc_doc)
|
||||
ids.append(doc.id)
|
||||
|
||||
if hasattr(self._vectorstore, 'add_documents'):
|
||||
self._vectorstore.add_documents(lc_docs, ids=ids)
|
||||
else:
|
||||
logger.warning("Vectorstore doesn't support add_documents")
|
||||
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Upsert documents."""
|
||||
# Most LangChain vectorstores handle upsert via add_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."""
|
||||
self._init_client()
|
||||
|
||||
try:
|
||||
if hasattr(self._vectorstore, 'similarity_search_by_vector'):
|
||||
results = self._vectorstore.similarity_search_by_vector(
|
||||
query_embedding,
|
||||
k=limit,
|
||||
filter=filters
|
||||
)
|
||||
else:
|
||||
logger.warning("Vectorstore doesn't support vector search")
|
||||
return []
|
||||
|
||||
documents = []
|
||||
for lc_doc in results:
|
||||
metadata = lc_doc.metadata or {}
|
||||
documents.append(KnowledgeDocument(
|
||||
id=metadata.get("id", ""),
|
||||
content=lc_doc.page_content,
|
||||
metadata=metadata,
|
||||
content_hash=metadata.get("content_hash"),
|
||||
created_at=metadata.get("created_at", 0)
|
||||
))
|
||||
|
||||
return documents
|
||||
except Exception as e:
|
||||
logger.error(f"Search failed: {e}")
|
||||
return []
|
||||
|
||||
def similarity_search(
|
||||
self,
|
||||
query_text: str,
|
||||
limit: int = 5,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""
|
||||
Text-based similarity search (native LangChain method).
|
||||
|
||||
Args:
|
||||
query_text: Query string
|
||||
limit: Maximum results
|
||||
filters: Metadata filters
|
||||
|
||||
Returns:
|
||||
List of matching documents
|
||||
"""
|
||||
self._init_client()
|
||||
|
||||
try:
|
||||
results = self._vectorstore.similarity_search(
|
||||
query_text,
|
||||
k=limit,
|
||||
filter=filters
|
||||
)
|
||||
|
||||
documents = []
|
||||
for lc_doc in results:
|
||||
metadata = lc_doc.metadata or {}
|
||||
documents.append(KnowledgeDocument(
|
||||
id=metadata.get("id", ""),
|
||||
content=lc_doc.page_content,
|
||||
metadata=metadata,
|
||||
content_hash=metadata.get("content_hash"),
|
||||
created_at=metadata.get("created_at", 0)
|
||||
))
|
||||
|
||||
return documents
|
||||
except Exception as e:
|
||||
logger.error(f"Similarity search failed: {e}")
|
||||
return []
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
self._init_client()
|
||||
|
||||
if hasattr(self._vectorstore, 'get'):
|
||||
try:
|
||||
results = self._vectorstore.get(ids)
|
||||
documents = []
|
||||
for lc_doc in results:
|
||||
metadata = lc_doc.metadata or {}
|
||||
documents.append(KnowledgeDocument(
|
||||
id=metadata.get("id", ""),
|
||||
content=lc_doc.page_content,
|
||||
metadata=metadata
|
||||
))
|
||||
return documents
|
||||
except Exception as e:
|
||||
logger.warning(f"Get by ID failed: {e}")
|
||||
|
||||
return []
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents."""
|
||||
self._init_client()
|
||||
|
||||
if ids and hasattr(self._vectorstore, 'delete'):
|
||||
try:
|
||||
self._vectorstore.delete(ids)
|
||||
return len(ids)
|
||||
except Exception as e:
|
||||
logger.error(f"Delete failed: {e}")
|
||||
|
||||
return 0
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents."""
|
||||
self._init_client()
|
||||
|
||||
if hasattr(self._vectorstore, '__len__'):
|
||||
return len(self._vectorstore)
|
||||
|
||||
logger.warning("Vectorstore doesn't support counting")
|
||||
return -1
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the client."""
|
||||
self._vectorstore = None
|
||||
self._initialized = False
|
||||
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
LightRAG adapter implementation of KnowledgeStore.
|
||||
|
||||
Requires: lightrag
|
||||
Install: pip install lightrag
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LightRAGKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
LightRAG adapter for knowledge/RAG.
|
||||
|
||||
Wraps LightRAG for use with PraisonAI's KnowledgeStore interface.
|
||||
|
||||
Example:
|
||||
store = LightRAGKnowledgeStore(
|
||||
working_dir="./lightrag_data"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
working_dir: str = "./lightrag_data",
|
||||
llm_model: str = "gpt-4o-mini",
|
||||
embedding_model: str = "text-embedding-3-small",
|
||||
**kwargs
|
||||
):
|
||||
"""
|
||||
Initialize LightRAG adapter.
|
||||
|
||||
Args:
|
||||
working_dir: Directory for LightRAG data
|
||||
llm_model: LLM model for graph extraction
|
||||
embedding_model: Embedding model name
|
||||
**kwargs: Additional LightRAG options
|
||||
"""
|
||||
self.working_dir = working_dir
|
||||
self.llm_model = llm_model
|
||||
self.embedding_model = embedding_model
|
||||
self.kwargs = kwargs
|
||||
|
||||
self._rag = None
|
||||
self._initialized = False
|
||||
|
||||
def _init_client(self):
|
||||
"""Initialize LightRAG client lazily."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
from lightrag import LightRAG, QueryParam
|
||||
from lightrag.llm import gpt_4o_mini_complete, openai_embedding
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"lightrag is required for LightRAG support. "
|
||||
"Install with: pip install lightrag"
|
||||
)
|
||||
|
||||
self._QueryParam = QueryParam
|
||||
self._rag = LightRAG(
|
||||
working_dir=self.working_dir,
|
||||
llm_model_func=gpt_4o_mini_complete,
|
||||
embedding_func=openai_embedding,
|
||||
**self.kwargs
|
||||
)
|
||||
self._initialized = True
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int = 1536,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create collection (LightRAG uses working directory)."""
|
||||
self._init_client()
|
||||
logger.info(f"LightRAG uses working_dir: {self.working_dir}")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete collection data."""
|
||||
import shutil
|
||||
import os
|
||||
|
||||
try:
|
||||
if os.path.exists(self.working_dir):
|
||||
shutil.rmtree(self.working_dir)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete collection: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if working directory exists."""
|
||||
import os
|
||||
return os.path.exists(self.working_dir)
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List collections (single collection per working_dir)."""
|
||||
import os
|
||||
if os.path.exists(self.working_dir):
|
||||
return ["default"]
|
||||
return []
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents into LightRAG."""
|
||||
self._init_client()
|
||||
|
||||
ids = []
|
||||
for doc in documents:
|
||||
try:
|
||||
self._rag.insert(doc.content)
|
||||
ids.append(doc.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to insert document {doc.id}: {e}")
|
||||
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Upsert documents (same as insert for LightRAG)."""
|
||||
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 using LightRAG's query method."""
|
||||
self._init_client()
|
||||
|
||||
# LightRAG uses text query, not embeddings directly
|
||||
# This is a limitation - we need the original query text
|
||||
logger.warning(
|
||||
"LightRAG search requires text query, not embeddings. "
|
||||
"Use query() method directly for best results."
|
||||
)
|
||||
return []
|
||||
|
||||
def query(
|
||||
self,
|
||||
query_text: str,
|
||||
mode: str = "hybrid",
|
||||
limit: int = 5
|
||||
) -> str:
|
||||
"""
|
||||
Query LightRAG with text (native method).
|
||||
|
||||
Args:
|
||||
query_text: The query string
|
||||
mode: Query mode (naive, local, global, hybrid)
|
||||
limit: Maximum results
|
||||
|
||||
Returns:
|
||||
Query response as string
|
||||
"""
|
||||
self._init_client()
|
||||
|
||||
try:
|
||||
result = self._rag.query(
|
||||
query_text,
|
||||
param=self._QueryParam(mode=mode, top_k=limit)
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Query failed: {e}")
|
||||
return ""
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs (not supported by LightRAG)."""
|
||||
logger.warning("LightRAG does not support get by ID")
|
||||
return []
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents (not directly supported)."""
|
||||
logger.warning("LightRAG does not support individual document deletion")
|
||||
return 0
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents (not directly supported)."""
|
||||
logger.warning("LightRAG does not support document counting")
|
||||
return -1
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the client."""
|
||||
self._rag = None
|
||||
self._initialized = False
|
||||
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
LlamaIndex VectorStore adapter implementation of KnowledgeStore.
|
||||
|
||||
Wraps any LlamaIndex VectorStore for use with PraisonAI.
|
||||
|
||||
Requires: llama-index-core
|
||||
Install: pip install llama-index-core
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LlamaIndexKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
LlamaIndex VectorStore adapter for knowledge/RAG.
|
||||
|
||||
Wraps any LlamaIndex VectorStore for use with PraisonAI's interface.
|
||||
|
||||
Example:
|
||||
from llama_index.vector_stores.chroma import ChromaVectorStore
|
||||
|
||||
li_store = ChromaVectorStore(chroma_collection=collection)
|
||||
store = LlamaIndexKnowledgeStore(vector_store=li_store)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
vector_store: Any = None,
|
||||
vector_store_cls: Optional[str] = None,
|
||||
vector_store_kwargs: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize LlamaIndex adapter.
|
||||
|
||||
Args:
|
||||
vector_store: Pre-initialized LlamaIndex VectorStore instance
|
||||
vector_store_cls: Class name to import (e.g., "llama_index.vector_stores.chroma.ChromaVectorStore")
|
||||
vector_store_kwargs: Kwargs for vector_store initialization
|
||||
"""
|
||||
self._vector_store = vector_store
|
||||
self._vector_store_cls = vector_store_cls
|
||||
self._vector_store_kwargs = vector_store_kwargs or {}
|
||||
self._initialized = vector_store is not None
|
||||
|
||||
def _init_client(self):
|
||||
"""Initialize vector store lazily."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
if not self._vector_store_cls:
|
||||
raise ValueError(
|
||||
"Either vector_store or vector_store_cls must be provided"
|
||||
)
|
||||
|
||||
# Dynamic import of vector store class
|
||||
parts = self._vector_store_cls.rsplit(".", 1)
|
||||
if len(parts) == 2:
|
||||
module_name, class_name = parts
|
||||
import importlib
|
||||
module = importlib.import_module(module_name)
|
||||
cls = getattr(module, class_name)
|
||||
self._vector_store = cls(**self._vector_store_kwargs)
|
||||
else:
|
||||
raise ValueError(f"Invalid vector_store_cls: {self._vector_store_cls}")
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int = 1536,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create collection (handled by underlying vector store)."""
|
||||
self._init_client()
|
||||
logger.info("Collection creation handled by LlamaIndex vector store")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete collection."""
|
||||
self._init_client()
|
||||
try:
|
||||
if hasattr(self._vector_store, 'delete'):
|
||||
self._vector_store.delete(delete_all=True)
|
||||
return True
|
||||
logger.warning("Underlying vector store doesn't support delete")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete collection: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if collection exists."""
|
||||
self._init_client()
|
||||
return self._vector_store is not None
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List collections."""
|
||||
self._init_client()
|
||||
return ["default"]
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents."""
|
||||
self._init_client()
|
||||
|
||||
try:
|
||||
from llama_index.core.schema import TextNode
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"llama-index-core is required. Install with: pip install llama-index-core"
|
||||
)
|
||||
|
||||
nodes = []
|
||||
ids = []
|
||||
for doc in documents:
|
||||
node = TextNode(
|
||||
text=doc.content,
|
||||
id_=doc.id,
|
||||
metadata={
|
||||
**(doc.metadata or {}),
|
||||
"content_hash": doc.content_hash,
|
||||
"created_at": doc.created_at,
|
||||
},
|
||||
embedding=doc.embedding
|
||||
)
|
||||
nodes.append(node)
|
||||
ids.append(doc.id)
|
||||
|
||||
if hasattr(self._vector_store, 'add'):
|
||||
self._vector_store.add(nodes)
|
||||
else:
|
||||
logger.warning("Vector store doesn't support add")
|
||||
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Upsert 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."""
|
||||
self._init_client()
|
||||
|
||||
try:
|
||||
from llama_index.core.vector_stores import VectorStoreQuery
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"llama-index-core is required. Install with: pip install llama-index-core"
|
||||
)
|
||||
|
||||
try:
|
||||
query = VectorStoreQuery(
|
||||
query_embedding=query_embedding,
|
||||
similarity_top_k=limit,
|
||||
)
|
||||
|
||||
result = self._vector_store.query(query)
|
||||
|
||||
documents = []
|
||||
for i, node in enumerate(result.nodes or []):
|
||||
score = result.similarities[i] if result.similarities else None
|
||||
if score_threshold and score and score < score_threshold:
|
||||
continue
|
||||
|
||||
metadata = node.metadata or {}
|
||||
if score:
|
||||
metadata["score"] = score
|
||||
|
||||
documents.append(KnowledgeDocument(
|
||||
id=node.node_id or node.id_,
|
||||
content=node.text or node.get_content(),
|
||||
embedding=node.embedding,
|
||||
metadata=metadata,
|
||||
content_hash=metadata.get("content_hash"),
|
||||
created_at=metadata.get("created_at", 0)
|
||||
))
|
||||
|
||||
return documents
|
||||
except Exception as e:
|
||||
logger.error(f"Search failed: {e}")
|
||||
return []
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
self._init_client()
|
||||
|
||||
documents = []
|
||||
for doc_id in ids:
|
||||
try:
|
||||
if hasattr(self._vector_store, 'get'):
|
||||
node = self._vector_store.get(doc_id)
|
||||
if node:
|
||||
metadata = node.metadata or {}
|
||||
documents.append(KnowledgeDocument(
|
||||
id=node.node_id or node.id_,
|
||||
content=node.text or node.get_content(),
|
||||
embedding=node.embedding,
|
||||
metadata=metadata
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning(f"Get by ID failed for {doc_id}: {e}")
|
||||
|
||||
return documents
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents."""
|
||||
self._init_client()
|
||||
|
||||
if ids and hasattr(self._vector_store, 'delete'):
|
||||
try:
|
||||
self._vector_store.delete(ref_doc_id=ids[0] if len(ids) == 1 else None)
|
||||
return len(ids)
|
||||
except Exception as e:
|
||||
logger.error(f"Delete failed: {e}")
|
||||
|
||||
return 0
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents."""
|
||||
self._init_client()
|
||||
logger.warning("LlamaIndex vector stores don't typically support counting")
|
||||
return -1
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the client."""
|
||||
self._vector_store = None
|
||||
self._initialized = False
|
||||
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
MongoDB Vector Search implementation of KnowledgeStore.
|
||||
|
||||
Requires: pymongo
|
||||
Install: pip install pymongo
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MongoDBVectorKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
MongoDB Atlas Vector Search store for knowledge/RAG.
|
||||
|
||||
Uses MongoDB Atlas Vector Search capabilities.
|
||||
|
||||
Example:
|
||||
store = MongoDBVectorKnowledgeStore(
|
||||
url="mongodb+srv://...",
|
||||
database="praisonai",
|
||||
collection="vectors"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "mongodb://localhost:27017",
|
||||
database: str = "praisonai",
|
||||
collection: str = "vectors",
|
||||
index_name: str = "vector_index",
|
||||
embedding_field: str = "embedding",
|
||||
text_field: str = "content",
|
||||
embedding_dim: int = 1536,
|
||||
):
|
||||
"""
|
||||
Initialize MongoDB Vector store.
|
||||
|
||||
Args:
|
||||
url: MongoDB connection URL
|
||||
database: Database name
|
||||
collection: Collection name
|
||||
index_name: Vector search index name
|
||||
embedding_field: Field name for embeddings
|
||||
text_field: Field name for text content
|
||||
embedding_dim: Embedding dimension
|
||||
"""
|
||||
self.url = url
|
||||
self.database_name = database
|
||||
self.collection_name = collection
|
||||
self.index_name = index_name
|
||||
self.embedding_field = embedding_field
|
||||
self.text_field = text_field
|
||||
self.embedding_dim = embedding_dim
|
||||
|
||||
self._client = None
|
||||
self._db = None
|
||||
self._collection = None
|
||||
self._initialized = False
|
||||
|
||||
def _init_client(self):
|
||||
"""Initialize MongoDB client lazily."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
from pymongo import MongoClient
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"pymongo is required for MongoDB Vector support. "
|
||||
"Install with: pip install pymongo"
|
||||
)
|
||||
|
||||
self._client = MongoClient(self.url)
|
||||
self._db = self._client[self.database_name]
|
||||
self._collection = self._db[self.collection_name]
|
||||
self._initialized = True
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int = 1536,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create collection (MongoDB creates on first insert)."""
|
||||
self._init_client()
|
||||
# MongoDB creates collections automatically on first insert
|
||||
# Vector search index must be created via Atlas UI or API
|
||||
logger.info(
|
||||
f"Collection '{name}' will be created on first insert. "
|
||||
f"Note: Vector search index must be created via MongoDB Atlas."
|
||||
)
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete a collection."""
|
||||
self._init_client()
|
||||
try:
|
||||
self._db.drop_collection(name)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete collection {name}: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if collection exists."""
|
||||
self._init_client()
|
||||
return name in self._db.list_collection_names()
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all collections."""
|
||||
self._init_client()
|
||||
return self._db.list_collection_names()
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents into collection."""
|
||||
self._init_client()
|
||||
|
||||
coll = self._db[collection]
|
||||
ids = []
|
||||
|
||||
for doc in documents:
|
||||
mongo_doc = {
|
||||
"_id": doc.id,
|
||||
self.text_field: doc.content,
|
||||
"metadata": doc.metadata or {},
|
||||
"content_hash": doc.content_hash,
|
||||
"created_at": doc.created_at,
|
||||
}
|
||||
|
||||
if doc.embedding:
|
||||
mongo_doc[self.embedding_field] = doc.embedding
|
||||
|
||||
try:
|
||||
coll.insert_one(mongo_doc)
|
||||
ids.append(doc.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to insert document {doc.id}: {e}")
|
||||
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Upsert documents into collection."""
|
||||
self._init_client()
|
||||
|
||||
coll = self._db[collection]
|
||||
ids = []
|
||||
|
||||
for doc in documents:
|
||||
mongo_doc = {
|
||||
self.text_field: doc.content,
|
||||
"metadata": doc.metadata or {},
|
||||
"content_hash": doc.content_hash,
|
||||
"created_at": doc.created_at,
|
||||
}
|
||||
|
||||
if doc.embedding:
|
||||
mongo_doc[self.embedding_field] = doc.embedding
|
||||
|
||||
try:
|
||||
coll.replace_one(
|
||||
{"_id": doc.id},
|
||||
{"_id": doc.id, **mongo_doc},
|
||||
upsert=True
|
||||
)
|
||||
ids.append(doc.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to upsert document {doc.id}: {e}")
|
||||
|
||||
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 using vector search."""
|
||||
self._init_client()
|
||||
|
||||
coll = self._db[collection]
|
||||
|
||||
# Build aggregation pipeline for vector search
|
||||
pipeline = [
|
||||
{
|
||||
"$vectorSearch": {
|
||||
"index": self.index_name,
|
||||
"path": self.embedding_field,
|
||||
"queryVector": query_embedding,
|
||||
"numCandidates": limit * 10,
|
||||
"limit": limit
|
||||
}
|
||||
},
|
||||
{
|
||||
"$project": {
|
||||
"_id": 1,
|
||||
self.text_field: 1,
|
||||
"metadata": 1,
|
||||
"content_hash": 1,
|
||||
"created_at": 1,
|
||||
"score": {"$meta": "vectorSearchScore"}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
# Add filter if provided
|
||||
if filters:
|
||||
pipeline[0]["$vectorSearch"]["filter"] = filters
|
||||
|
||||
try:
|
||||
results = list(coll.aggregate(pipeline))
|
||||
|
||||
documents = []
|
||||
for doc in results:
|
||||
score = doc.get("score", 0)
|
||||
if score_threshold and score < score_threshold:
|
||||
continue
|
||||
|
||||
documents.append(KnowledgeDocument(
|
||||
id=str(doc["_id"]),
|
||||
content=doc.get(self.text_field, ""),
|
||||
metadata={**(doc.get("metadata") or {}), "score": score},
|
||||
content_hash=doc.get("content_hash"),
|
||||
created_at=doc.get("created_at", 0)
|
||||
))
|
||||
|
||||
return documents
|
||||
except Exception as e:
|
||||
logger.error(f"Vector search failed: {e}")
|
||||
return []
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
self._init_client()
|
||||
|
||||
coll = self._db[collection]
|
||||
documents = []
|
||||
|
||||
for doc_id in ids:
|
||||
try:
|
||||
doc = coll.find_one({"_id": doc_id})
|
||||
if doc:
|
||||
documents.append(KnowledgeDocument(
|
||||
id=str(doc["_id"]),
|
||||
content=doc.get(self.text_field, ""),
|
||||
embedding=doc.get(self.embedding_field),
|
||||
metadata=doc.get("metadata"),
|
||||
content_hash=doc.get("content_hash"),
|
||||
created_at=doc.get("created_at", 0)
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get document {doc_id}: {e}")
|
||||
|
||||
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."""
|
||||
self._init_client()
|
||||
|
||||
coll = self._db[collection]
|
||||
|
||||
if ids:
|
||||
result = coll.delete_many({"_id": {"$in": ids}})
|
||||
return result.deleted_count
|
||||
elif filters:
|
||||
result = coll.delete_many(filters)
|
||||
return result.deleted_count
|
||||
else:
|
||||
return 0
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents in collection."""
|
||||
self._init_client()
|
||||
return self._db[collection].count_documents({})
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the connection."""
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
self._db = None
|
||||
self._collection = None
|
||||
self._initialized = False
|
||||
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
SingleStore Vector implementation of KnowledgeStore.
|
||||
|
||||
Requires: singlestoredb
|
||||
Install: pip install singlestoredb
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SingleStoreVectorKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
SingleStore vector store for knowledge/RAG.
|
||||
|
||||
Uses SingleStore's native vector capabilities.
|
||||
|
||||
Example:
|
||||
store = SingleStoreVectorKnowledgeStore(
|
||||
url="singlestoredb://user:pass@host:3306/db"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
host: str = "localhost",
|
||||
port: int = 3306,
|
||||
database: str = "praisonai",
|
||||
user: str = "root",
|
||||
password: str = "",
|
||||
table_prefix: str = "praisonai_",
|
||||
embedding_dim: int = 1536,
|
||||
):
|
||||
"""
|
||||
Initialize SingleStore Vector store.
|
||||
|
||||
Args:
|
||||
url: SingleStore connection URL
|
||||
host: Database host
|
||||
port: Database port
|
||||
database: Database name
|
||||
user: Database user
|
||||
password: Database password
|
||||
table_prefix: Prefix for table names
|
||||
embedding_dim: Embedding dimension
|
||||
"""
|
||||
self.url = url
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.database = database
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.table_prefix = table_prefix
|
||||
self.embedding_dim = embedding_dim
|
||||
|
||||
self._conn = None
|
||||
self._initialized = False
|
||||
|
||||
def _init_client(self):
|
||||
"""Initialize SingleStore client lazily."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
import singlestoredb as s2
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"singlestoredb is required for SingleStore Vector support. "
|
||||
"Install with: pip install singlestoredb"
|
||||
)
|
||||
|
||||
if self.url:
|
||||
self._conn = s2.connect(self.url)
|
||||
else:
|
||||
self._conn = s2.connect(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
database=self.database,
|
||||
user=self.user,
|
||||
password=self.password
|
||||
)
|
||||
|
||||
self._initialized = True
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int = 1536,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create a vector table."""
|
||||
self._init_client()
|
||||
|
||||
table_name = f"{self.table_prefix}{name}"
|
||||
|
||||
with self._conn.cursor() as cur:
|
||||
cur.execute(f"""
|
||||
CREATE TABLE IF NOT EXISTS {table_name} (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
content TEXT,
|
||||
embedding VECTOR({dimension}),
|
||||
metadata JSON,
|
||||
content_hash VARCHAR(64),
|
||||
created_at DOUBLE
|
||||
)
|
||||
""")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete a vector table."""
|
||||
self._init_client()
|
||||
|
||||
table_name = f"{self.table_prefix}{name}"
|
||||
try:
|
||||
with self._conn.cursor() as cur:
|
||||
cur.execute(f"DROP TABLE IF EXISTS {table_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete collection {name}: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if table exists."""
|
||||
self._init_client()
|
||||
|
||||
table_name = f"{self.table_prefix}{name}"
|
||||
with self._conn.cursor() as cur:
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*) FROM information_schema.tables
|
||||
WHERE table_schema = %s AND table_name = %s
|
||||
""", (self.database, table_name))
|
||||
return cur.fetchone()[0] > 0
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all vector tables."""
|
||||
self._init_client()
|
||||
|
||||
with self._conn.cursor() as cur:
|
||||
cur.execute(f"""
|
||||
SELECT table_name FROM information_schema.tables
|
||||
WHERE table_schema = %s AND table_name LIKE %s
|
||||
""", (self.database, f"{self.table_prefix}%"))
|
||||
return [row[0][len(self.table_prefix):] for row in cur.fetchall()]
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents."""
|
||||
self._init_client()
|
||||
|
||||
table_name = f"{self.table_prefix}{collection}"
|
||||
ids = []
|
||||
|
||||
with self._conn.cursor() as cur:
|
||||
for doc in documents:
|
||||
embedding_str = str(doc.embedding) if doc.embedding else None
|
||||
cur.execute(f"""
|
||||
INSERT INTO {table_name} (id, content, embedding, metadata, content_hash, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""", (doc.id, doc.content, embedding_str,
|
||||
str(doc.metadata) if doc.metadata else None,
|
||||
doc.content_hash, doc.created_at))
|
||||
ids.append(doc.id)
|
||||
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Upsert documents."""
|
||||
self._init_client()
|
||||
|
||||
table_name = f"{self.table_prefix}{collection}"
|
||||
ids = []
|
||||
|
||||
with self._conn.cursor() as cur:
|
||||
for doc in documents:
|
||||
embedding_str = str(doc.embedding) if doc.embedding else None
|
||||
cur.execute(f"""
|
||||
REPLACE INTO {table_name} (id, content, embedding, metadata, content_hash, created_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""", (doc.id, doc.content, embedding_str,
|
||||
str(doc.metadata) if doc.metadata else None,
|
||||
doc.content_hash, doc.created_at))
|
||||
ids.append(doc.id)
|
||||
|
||||
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 using vector search."""
|
||||
self._init_client()
|
||||
|
||||
table_name = f"{self.table_prefix}{collection}"
|
||||
embedding_str = str(query_embedding)
|
||||
|
||||
with self._conn.cursor() as cur:
|
||||
cur.execute(f"""
|
||||
SELECT id, content, metadata, content_hash, created_at,
|
||||
DOT_PRODUCT(embedding, %s) as score
|
||||
FROM {table_name}
|
||||
ORDER BY score DESC
|
||||
LIMIT %s
|
||||
""", (embedding_str, limit))
|
||||
|
||||
documents = []
|
||||
for row in cur.fetchall():
|
||||
score = row[5] if len(row) > 5 else 0
|
||||
if score_threshold and score < score_threshold:
|
||||
continue
|
||||
|
||||
documents.append(KnowledgeDocument(
|
||||
id=row[0],
|
||||
content=row[1],
|
||||
metadata=eval(row[2]) if row[2] else None,
|
||||
content_hash=row[3],
|
||||
created_at=row[4]
|
||||
))
|
||||
|
||||
return documents
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
self._init_client()
|
||||
|
||||
table_name = f"{self.table_prefix}{collection}"
|
||||
placeholders = ','.join(['%s'] * len(ids))
|
||||
|
||||
with self._conn.cursor() as cur:
|
||||
cur.execute(f"""
|
||||
SELECT id, content, metadata, content_hash, created_at
|
||||
FROM {table_name}
|
||||
WHERE id IN ({placeholders})
|
||||
""", ids)
|
||||
|
||||
return [
|
||||
KnowledgeDocument(
|
||||
id=row[0],
|
||||
content=row[1],
|
||||
metadata=eval(row[2]) if row[2] else None,
|
||||
content_hash=row[3],
|
||||
created_at=row[4]
|
||||
)
|
||||
for row in cur.fetchall()
|
||||
]
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents."""
|
||||
self._init_client()
|
||||
|
||||
table_name = f"{self.table_prefix}{collection}"
|
||||
|
||||
with self._conn.cursor() as cur:
|
||||
if ids:
|
||||
placeholders = ','.join(['%s'] * len(ids))
|
||||
cur.execute(f"DELETE FROM {table_name} WHERE id IN ({placeholders})", ids)
|
||||
else:
|
||||
cur.execute(f"DELETE FROM {table_name}")
|
||||
return cur.rowcount
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents."""
|
||||
self._init_client()
|
||||
|
||||
table_name = f"{self.table_prefix}{collection}"
|
||||
with self._conn.cursor() as cur:
|
||||
cur.execute(f"SELECT COUNT(*) FROM {table_name}")
|
||||
return cur.fetchone()[0]
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the connection."""
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
self._initialized = False
|
||||
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
SurrealDB Vector implementation of KnowledgeStore.
|
||||
|
||||
Requires: surrealdb
|
||||
Install: pip install surrealdb
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SurrealDBVectorKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
SurrealDB vector store for knowledge/RAG.
|
||||
|
||||
Uses SurrealDB's vector capabilities.
|
||||
|
||||
Example:
|
||||
store = SurrealDBVectorKnowledgeStore(
|
||||
url="ws://localhost:8000/rpc",
|
||||
namespace="praisonai",
|
||||
database="vectors"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "ws://localhost:8000/rpc",
|
||||
namespace: str = "praisonai",
|
||||
database: str = "vectors",
|
||||
username: str = "root",
|
||||
password: str = "root",
|
||||
embedding_dim: int = 1536,
|
||||
):
|
||||
"""
|
||||
Initialize SurrealDB Vector store.
|
||||
|
||||
Args:
|
||||
url: SurrealDB WebSocket URL
|
||||
namespace: SurrealDB namespace
|
||||
database: SurrealDB database
|
||||
username: Authentication username
|
||||
password: Authentication password
|
||||
embedding_dim: Embedding dimension
|
||||
"""
|
||||
self.url = url
|
||||
self.namespace = namespace
|
||||
self.database = database
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.embedding_dim = embedding_dim
|
||||
|
||||
self._client = None
|
||||
self._initialized = False
|
||||
|
||||
def _init_client(self):
|
||||
"""Initialize SurrealDB client lazily."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
from surrealdb import Surreal
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"surrealdb is required for SurrealDB Vector support. "
|
||||
"Install with: pip install surrealdb"
|
||||
)
|
||||
|
||||
import asyncio
|
||||
|
||||
async def connect():
|
||||
client = Surreal(self.url)
|
||||
await client.connect()
|
||||
await client.signin({"user": self.username, "pass": self.password})
|
||||
await client.use(self.namespace, self.database)
|
||||
return client
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
self._client = loop.run_until_complete(connect())
|
||||
self._initialized = True
|
||||
|
||||
def _run_async(self, coro):
|
||||
"""Run async coroutine synchronously."""
|
||||
import asyncio
|
||||
return asyncio.get_event_loop().run_until_complete(coro)
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int = 1536,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create a collection (SurrealDB creates tables on insert)."""
|
||||
self._init_client()
|
||||
# SurrealDB creates tables automatically
|
||||
logger.info(f"Collection '{name}' will be created on first insert")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete a collection."""
|
||||
self._init_client()
|
||||
try:
|
||||
self._run_async(self._client.query(f"REMOVE TABLE {name}"))
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete collection {name}: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if collection exists."""
|
||||
self._init_client()
|
||||
try:
|
||||
result = self._run_async(self._client.query(f"INFO FOR TABLE {name}"))
|
||||
return bool(result)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all collections."""
|
||||
self._init_client()
|
||||
try:
|
||||
result = self._run_async(self._client.query("INFO FOR DB"))
|
||||
if result and len(result) > 0:
|
||||
tables = result[0].get("result", {}).get("tables", {})
|
||||
return list(tables.keys())
|
||||
return []
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list collections: {e}")
|
||||
return []
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents."""
|
||||
self._init_client()
|
||||
|
||||
ids = []
|
||||
for doc in documents:
|
||||
record = {
|
||||
"content": doc.content,
|
||||
"embedding": doc.embedding,
|
||||
"metadata": doc.metadata or {},
|
||||
"content_hash": doc.content_hash,
|
||||
"created_at": doc.created_at,
|
||||
}
|
||||
|
||||
try:
|
||||
result = self._run_async(
|
||||
self._client.create(f"{collection}:{doc.id}", record)
|
||||
)
|
||||
ids.append(doc.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to insert document {doc.id}: {e}")
|
||||
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Upsert documents."""
|
||||
self._init_client()
|
||||
|
||||
ids = []
|
||||
for doc in documents:
|
||||
record = {
|
||||
"content": doc.content,
|
||||
"embedding": doc.embedding,
|
||||
"metadata": doc.metadata or {},
|
||||
"content_hash": doc.content_hash,
|
||||
"created_at": doc.created_at,
|
||||
}
|
||||
|
||||
try:
|
||||
self._run_async(
|
||||
self._client.update(f"{collection}:{doc.id}", record)
|
||||
)
|
||||
ids.append(doc.id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to upsert document {doc.id}: {e}")
|
||||
|
||||
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 using vector search."""
|
||||
self._init_client()
|
||||
|
||||
# SurrealDB vector search query
|
||||
embedding_str = str(query_embedding)
|
||||
query = f"""
|
||||
SELECT *, vector::similarity::cosine(embedding, {embedding_str}) AS score
|
||||
FROM {collection}
|
||||
ORDER BY score DESC
|
||||
LIMIT {limit}
|
||||
"""
|
||||
|
||||
try:
|
||||
result = self._run_async(self._client.query(query))
|
||||
|
||||
documents = []
|
||||
if result and len(result) > 0:
|
||||
for row in result[0].get("result", []):
|
||||
score = row.get("score", 0)
|
||||
if score_threshold and score < score_threshold:
|
||||
continue
|
||||
|
||||
doc_id = str(row.get("id", "")).split(":")[-1]
|
||||
documents.append(KnowledgeDocument(
|
||||
id=doc_id,
|
||||
content=row.get("content", ""),
|
||||
embedding=row.get("embedding"),
|
||||
metadata={**(row.get("metadata") or {}), "score": score},
|
||||
content_hash=row.get("content_hash"),
|
||||
created_at=row.get("created_at", 0)
|
||||
))
|
||||
|
||||
return documents
|
||||
except Exception as e:
|
||||
logger.error(f"Vector search failed: {e}")
|
||||
return []
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
self._init_client()
|
||||
|
||||
documents = []
|
||||
for doc_id in ids:
|
||||
try:
|
||||
result = self._run_async(self._client.select(f"{collection}:{doc_id}"))
|
||||
if result:
|
||||
row = result if isinstance(result, dict) else result[0]
|
||||
documents.append(KnowledgeDocument(
|
||||
id=doc_id,
|
||||
content=row.get("content", ""),
|
||||
embedding=row.get("embedding"),
|
||||
metadata=row.get("metadata"),
|
||||
content_hash=row.get("content_hash"),
|
||||
created_at=row.get("created_at", 0)
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get document {doc_id}: {e}")
|
||||
|
||||
return documents
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents."""
|
||||
self._init_client()
|
||||
|
||||
count = 0
|
||||
if ids:
|
||||
for doc_id in ids:
|
||||
try:
|
||||
self._run_async(self._client.delete(f"{collection}:{doc_id}"))
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete {doc_id}: {e}")
|
||||
else:
|
||||
try:
|
||||
self._run_async(self._client.query(f"DELETE FROM {collection}"))
|
||||
count = -1 # Unknown count
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete all: {e}")
|
||||
|
||||
return count
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents."""
|
||||
self._init_client()
|
||||
|
||||
try:
|
||||
result = self._run_async(
|
||||
self._client.query(f"SELECT count() FROM {collection} GROUP ALL")
|
||||
)
|
||||
if result and len(result) > 0:
|
||||
return result[0].get("result", [{}])[0].get("count", 0)
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Count failed: {e}")
|
||||
return 0
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the connection."""
|
||||
if self._client:
|
||||
self._run_async(self._client.close())
|
||||
self._client = None
|
||||
self._initialized = False
|
||||
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Upstash Vector implementation of KnowledgeStore.
|
||||
|
||||
Requires: upstash-vector
|
||||
Install: pip install upstash-vector
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpstashVectorKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
Upstash Vector store for knowledge/RAG.
|
||||
|
||||
Uses Upstash's serverless vector database.
|
||||
|
||||
Example:
|
||||
store = UpstashVectorKnowledgeStore(
|
||||
url="https://xxx.upstash.io",
|
||||
token="your-token"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: Optional[str] = None,
|
||||
token: Optional[str] = None,
|
||||
embedding_dim: int = 1536,
|
||||
):
|
||||
"""
|
||||
Initialize Upstash Vector store.
|
||||
|
||||
Args:
|
||||
url: Upstash Vector REST URL (or UPSTASH_VECTOR_REST_URL env)
|
||||
token: Upstash Vector REST token (or UPSTASH_VECTOR_REST_TOKEN env)
|
||||
embedding_dim: Embedding dimension
|
||||
"""
|
||||
self.url = url or os.getenv("UPSTASH_VECTOR_REST_URL")
|
||||
self.token = token or os.getenv("UPSTASH_VECTOR_REST_TOKEN")
|
||||
self.embedding_dim = embedding_dim
|
||||
|
||||
self._index = None
|
||||
self._initialized = False
|
||||
|
||||
def _init_client(self):
|
||||
"""Initialize Upstash client lazily."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
if not self.url or not self.token:
|
||||
raise ValueError(
|
||||
"Upstash Vector requires url and token. "
|
||||
"Set UPSTASH_VECTOR_REST_URL and UPSTASH_VECTOR_REST_TOKEN env vars."
|
||||
)
|
||||
|
||||
try:
|
||||
from upstash_vector import Index
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"upstash-vector is required for Upstash Vector support. "
|
||||
"Install with: pip install upstash-vector"
|
||||
)
|
||||
|
||||
self._index = Index(url=self.url, token=self.token)
|
||||
self._initialized = True
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int = 1536,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Create collection (Upstash uses namespaces)."""
|
||||
self._init_client()
|
||||
# Upstash Vector uses a single index with namespaces
|
||||
logger.info(f"Using namespace '{name}' in Upstash Vector index")
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete all vectors in namespace."""
|
||||
self._init_client()
|
||||
try:
|
||||
self._index.delete_namespace(name)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to delete namespace {name}: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if namespace has vectors."""
|
||||
self._init_client()
|
||||
try:
|
||||
info = self._index.info()
|
||||
namespaces = info.namespaces if hasattr(info, 'namespaces') else {}
|
||||
return name in namespaces
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all namespaces."""
|
||||
self._init_client()
|
||||
try:
|
||||
info = self._index.info()
|
||||
namespaces = info.namespaces if hasattr(info, 'namespaces') else {}
|
||||
return list(namespaces.keys())
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list namespaces: {e}")
|
||||
return []
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Insert documents."""
|
||||
self._init_client()
|
||||
|
||||
vectors = []
|
||||
for doc in documents:
|
||||
if not doc.embedding:
|
||||
logger.warning(f"Document {doc.id} has no embedding, skipping")
|
||||
continue
|
||||
|
||||
vectors.append({
|
||||
"id": doc.id,
|
||||
"vector": doc.embedding,
|
||||
"metadata": {
|
||||
"content": doc.content,
|
||||
**(doc.metadata or {}),
|
||||
"content_hash": doc.content_hash,
|
||||
"created_at": doc.created_at,
|
||||
}
|
||||
})
|
||||
|
||||
if vectors:
|
||||
self._index.upsert(vectors, namespace=collection)
|
||||
|
||||
return [v["id"] for v in vectors]
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument]
|
||||
) -> List[str]:
|
||||
"""Upsert documents (same as insert for Upstash)."""
|
||||
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."""
|
||||
self._init_client()
|
||||
|
||||
try:
|
||||
results = self._index.query(
|
||||
vector=query_embedding,
|
||||
top_k=limit,
|
||||
include_metadata=True,
|
||||
namespace=collection,
|
||||
filter=filters
|
||||
)
|
||||
|
||||
documents = []
|
||||
for result in results:
|
||||
score = result.score if hasattr(result, 'score') else 0
|
||||
if score_threshold and score < score_threshold:
|
||||
continue
|
||||
|
||||
metadata = result.metadata if hasattr(result, 'metadata') else {}
|
||||
content = metadata.pop("content", "") if metadata else ""
|
||||
|
||||
documents.append(KnowledgeDocument(
|
||||
id=result.id,
|
||||
content=content,
|
||||
metadata={**metadata, "score": score},
|
||||
content_hash=metadata.get("content_hash") if metadata else None,
|
||||
created_at=metadata.get("created_at", 0) if metadata else 0
|
||||
))
|
||||
|
||||
return documents
|
||||
except Exception as e:
|
||||
logger.error(f"Search failed: {e}")
|
||||
return []
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str]
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs."""
|
||||
self._init_client()
|
||||
|
||||
documents = []
|
||||
try:
|
||||
results = self._index.fetch(ids, include_metadata=True, namespace=collection)
|
||||
|
||||
for result in results:
|
||||
if result:
|
||||
metadata = result.metadata if hasattr(result, 'metadata') else {}
|
||||
content = metadata.pop("content", "") if metadata else ""
|
||||
|
||||
documents.append(KnowledgeDocument(
|
||||
id=result.id,
|
||||
content=content,
|
||||
embedding=result.vector if hasattr(result, 'vector') else None,
|
||||
metadata=metadata,
|
||||
content_hash=metadata.get("content_hash") if metadata else None,
|
||||
created_at=metadata.get("created_at", 0) if metadata else 0
|
||||
))
|
||||
except Exception as e:
|
||||
logger.error(f"Fetch failed: {e}")
|
||||
|
||||
return documents
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None
|
||||
) -> int:
|
||||
"""Delete documents."""
|
||||
self._init_client()
|
||||
|
||||
try:
|
||||
if ids:
|
||||
self._index.delete(ids, namespace=collection)
|
||||
return len(ids)
|
||||
elif filters:
|
||||
# Upstash doesn't support filter-based delete directly
|
||||
logger.warning("Filter-based delete not supported, use IDs")
|
||||
return 0
|
||||
else:
|
||||
self._index.delete_namespace(collection)
|
||||
return -1 # Unknown count
|
||||
except Exception as e:
|
||||
logger.error(f"Delete failed: {e}")
|
||||
return 0
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents in namespace."""
|
||||
self._init_client()
|
||||
|
||||
try:
|
||||
info = self._index.info()
|
||||
namespaces = info.namespaces if hasattr(info, 'namespaces') else {}
|
||||
ns_info = namespaces.get(collection, {})
|
||||
return ns_info.get("vector_count", 0) if isinstance(ns_info, dict) else 0
|
||||
except Exception as e:
|
||||
logger.error(f"Count failed: {e}")
|
||||
return 0
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the client."""
|
||||
self._index = None
|
||||
self._initialized = False
|
||||
@@ -0,0 +1,200 @@
|
||||
"""
|
||||
Async MongoDB implementation of StateStore.
|
||||
|
||||
Requires: motor
|
||||
Install: pip install motor
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import StateStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncMongoDBStateStore(StateStore):
|
||||
"""
|
||||
Async MongoDB state store using motor.
|
||||
|
||||
Provides high-performance async database operations.
|
||||
|
||||
Example:
|
||||
store = AsyncMongoDBStateStore(
|
||||
url="mongodb://localhost:27017",
|
||||
database="praisonai"
|
||||
)
|
||||
await store.init()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
url: str = "mongodb://localhost:27017",
|
||||
database: str = "praisonai",
|
||||
collection: str = "state",
|
||||
):
|
||||
"""
|
||||
Initialize async MongoDB store.
|
||||
|
||||
Args:
|
||||
url: MongoDB connection URL
|
||||
database: Database name
|
||||
collection: Collection name
|
||||
"""
|
||||
self.url = url
|
||||
self.database_name = database
|
||||
self.collection_name = collection
|
||||
self._client = None
|
||||
self._db = None
|
||||
self._collection = None
|
||||
self._initialized = False
|
||||
|
||||
async def init(self):
|
||||
"""Initialize connection."""
|
||||
if self._initialized:
|
||||
return
|
||||
|
||||
try:
|
||||
from motor.motor_asyncio import AsyncIOMotorClient
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"motor is required for async MongoDB support. "
|
||||
"Install with: pip install motor"
|
||||
)
|
||||
|
||||
self._client = AsyncIOMotorClient(self.url)
|
||||
self._db = self._client[self.database_name]
|
||||
self._collection = self._db[self.collection_name]
|
||||
|
||||
# Create TTL index for expiration
|
||||
await self._collection.create_index("expires_at", expireAfterSeconds=0)
|
||||
|
||||
self._initialized = True
|
||||
|
||||
async def async_get(self, key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get state by key asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
doc = await self._collection.find_one({"_id": key})
|
||||
if doc:
|
||||
# Check TTL manually (in case index hasn't cleaned up yet)
|
||||
if "expires_at" in doc and doc["expires_at"]:
|
||||
if time.time() > doc["expires_at"]:
|
||||
await self.async_delete(key)
|
||||
return None
|
||||
|
||||
# Remove internal fields
|
||||
result = dict(doc)
|
||||
result.pop("_id", None)
|
||||
result.pop("expires_at", None)
|
||||
return result.get("value", result)
|
||||
return None
|
||||
|
||||
def get(self, key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Sync wrapper for get."""
|
||||
return asyncio.get_event_loop().run_until_complete(self.async_get(key))
|
||||
|
||||
async def async_set(self, key: str, value: Dict[str, Any], ttl: Optional[int] = None) -> bool:
|
||||
"""Set state by key with optional TTL asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
doc = {
|
||||
"_id": key,
|
||||
"value": value,
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
|
||||
if ttl:
|
||||
doc["expires_at"] = time.time() + ttl
|
||||
|
||||
try:
|
||||
await self._collection.replace_one(
|
||||
{"_id": key},
|
||||
doc,
|
||||
upsert=True
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting state {key}: {e}")
|
||||
return False
|
||||
|
||||
def set(self, key: str, value: Dict[str, Any], ttl: Optional[int] = None) -> bool:
|
||||
"""Sync wrapper for set."""
|
||||
return asyncio.get_event_loop().run_until_complete(self.async_set(key, value, ttl))
|
||||
|
||||
async def async_delete(self, key: str) -> bool:
|
||||
"""Delete state by key asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
result = await self._collection.delete_one({"_id": key})
|
||||
return result.deleted_count > 0
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Sync wrapper for delete."""
|
||||
return asyncio.get_event_loop().run_until_complete(self.async_delete(key))
|
||||
|
||||
async def async_exists(self, key: str) -> bool:
|
||||
"""Check if key exists asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
count = await self._collection.count_documents({"_id": key}, limit=1)
|
||||
return count > 0
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Sync wrapper for exists."""
|
||||
return asyncio.get_event_loop().run_until_complete(self.async_exists(key))
|
||||
|
||||
async def async_list_keys(self, prefix: Optional[str] = None) -> List[str]:
|
||||
"""List all keys with optional prefix filter asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
query = {}
|
||||
if prefix:
|
||||
query["_id"] = {"$regex": f"^{prefix}"}
|
||||
|
||||
cursor = self._collection.find(query, {"_id": 1})
|
||||
keys = []
|
||||
async for doc in cursor:
|
||||
keys.append(doc["_id"])
|
||||
return keys
|
||||
|
||||
def list_keys(self, prefix: Optional[str] = None) -> List[str]:
|
||||
"""Sync wrapper for list_keys."""
|
||||
return asyncio.get_event_loop().run_until_complete(self.async_list_keys(prefix))
|
||||
|
||||
async def async_clear(self, prefix: Optional[str] = None) -> int:
|
||||
"""Clear all keys with optional prefix filter asynchronously."""
|
||||
if not self._initialized:
|
||||
await self.init()
|
||||
|
||||
query = {}
|
||||
if prefix:
|
||||
query["_id"] = {"$regex": f"^{prefix}"}
|
||||
|
||||
result = await self._collection.delete_many(query)
|
||||
return result.deleted_count
|
||||
|
||||
def clear(self, prefix: Optional[str] = None) -> int:
|
||||
"""Sync wrapper for clear."""
|
||||
return asyncio.get_event_loop().run_until_complete(self.async_clear(prefix))
|
||||
|
||||
async def async_close(self) -> None:
|
||||
"""Close the connection."""
|
||||
if self._client:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
self._db = None
|
||||
self._collection = None
|
||||
self._initialized = False
|
||||
|
||||
def close(self) -> None:
|
||||
"""Sync wrapper for close."""
|
||||
if self._client:
|
||||
asyncio.get_event_loop().run_until_complete(self.async_close())
|
||||
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Google Cloud Storage implementation of StateStore.
|
||||
|
||||
Requires: google-cloud-storage
|
||||
Install: pip install google-cloud-storage
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import StateStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GCSStateStore(StateStore):
|
||||
"""
|
||||
Google Cloud Storage-based state store.
|
||||
|
||||
Stores state as JSON objects in GCS buckets.
|
||||
|
||||
Example:
|
||||
store = GCSStateStore(
|
||||
bucket_name="my-praisonai-state",
|
||||
prefix="state/"
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bucket_name: str,
|
||||
prefix: str = "praisonai_state/",
|
||||
project: Optional[str] = None,
|
||||
credentials_path: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize GCS state store.
|
||||
|
||||
Args:
|
||||
bucket_name: GCS bucket name
|
||||
prefix: Object prefix for state keys
|
||||
project: GCP project ID (optional)
|
||||
credentials_path: Path to service account JSON (optional)
|
||||
"""
|
||||
try:
|
||||
from google.cloud import storage
|
||||
if credentials_path:
|
||||
self._client = storage.Client.from_service_account_json(
|
||||
credentials_path, project=project
|
||||
)
|
||||
else:
|
||||
self._client = storage.Client(project=project)
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"google-cloud-storage is required for GCS support. "
|
||||
"Install with: pip install google-cloud-storage"
|
||||
)
|
||||
|
||||
self.bucket_name = bucket_name
|
||||
self.prefix = prefix
|
||||
self._bucket = self._client.bucket(bucket_name)
|
||||
|
||||
def _key_to_path(self, key: str) -> str:
|
||||
"""Convert key to GCS object path."""
|
||||
return f"{self.prefix}{key}.json"
|
||||
|
||||
def get(self, key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get state by key."""
|
||||
try:
|
||||
blob = self._bucket.blob(self._key_to_path(key))
|
||||
if blob.exists():
|
||||
data = json.loads(blob.download_as_text())
|
||||
# Check TTL
|
||||
if "_ttl_expires" in data:
|
||||
if time.time() > data["_ttl_expires"]:
|
||||
self.delete(key)
|
||||
return None
|
||||
del data["_ttl_expires"]
|
||||
return data
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting state {key}: {e}")
|
||||
return None
|
||||
|
||||
def set(self, key: str, value: Dict[str, Any], ttl: Optional[int] = None) -> bool:
|
||||
"""Set state by key with optional TTL in seconds."""
|
||||
try:
|
||||
data = value.copy()
|
||||
if ttl:
|
||||
data["_ttl_expires"] = time.time() + ttl
|
||||
|
||||
blob = self._bucket.blob(self._key_to_path(key))
|
||||
blob.upload_from_string(
|
||||
json.dumps(data, default=str),
|
||||
content_type="application/json"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting state {key}: {e}")
|
||||
return False
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete state by key."""
|
||||
try:
|
||||
blob = self._bucket.blob(self._key_to_path(key))
|
||||
if blob.exists():
|
||||
blob.delete()
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting state {key}: {e}")
|
||||
return False
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if key exists."""
|
||||
try:
|
||||
blob = self._bucket.blob(self._key_to_path(key))
|
||||
return blob.exists()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def list_keys(self, prefix: Optional[str] = None) -> List[str]:
|
||||
"""List all keys with optional prefix filter."""
|
||||
try:
|
||||
search_prefix = self.prefix
|
||||
if prefix:
|
||||
search_prefix = f"{self.prefix}{prefix}"
|
||||
|
||||
blobs = self._client.list_blobs(self.bucket_name, prefix=search_prefix)
|
||||
keys = []
|
||||
for blob in blobs:
|
||||
# Remove prefix and .json suffix
|
||||
key = blob.name[len(self.prefix):]
|
||||
if key.endswith(".json"):
|
||||
key = key[:-5]
|
||||
keys.append(key)
|
||||
return keys
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing keys: {e}")
|
||||
return []
|
||||
|
||||
def clear(self, prefix: Optional[str] = None) -> int:
|
||||
"""Clear all keys with optional prefix filter."""
|
||||
keys = self.list_keys(prefix)
|
||||
count = 0
|
||||
for key in keys:
|
||||
if self.delete(key):
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
self._client.close()
|
||||
@@ -1 +1 @@
|
||||
__version__ = "2.4.2"
|
||||
__version__ = "2.4.4"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=0.2.1",
|
||||
"praisonaiagents>=0.2.2",
|
||||
"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.1" },
|
||||
{ name = "praisonaiagents", specifier = ">=0.2.2" },
|
||||
{ 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.1"
|
||||
version = "0.2.2"
|
||||
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/24/58/16b6bd1bb1c9b3932a5e106f72ba1ca2d23b2dad87b45fb0e912b61ab9f7/praisonaiagents-0.2.1.tar.gz", hash = "sha256:7d392c635359696d22ecd92acf90bd2dd1ac24282211ef651612f3d194ea06e5", size = 730640, upload-time = "2025-12-24T14:57:46.426Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/0d/860b0fa64c0074c7e82d6fdacdf567b2b69da58602aaf749c8612d20b61d/praisonaiagents-0.2.2.tar.gz", hash = "sha256:4174a92b5a38a5597bc748ce4a0aa692d66575331dcb1839acdae4d491bc7333", size = 733706, upload-time = "2025-12-26T00:59:53.844Z" }
|
||||
wheels = [
|
||||
{ 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" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/4d/e862f14fb05fbd9e470a1d7a8c0c9920688758ecc5dbffa9ec597ec690f1/praisonaiagents-0.2.2-py3-none-any.whl", hash = "sha256:0fdfd131cb0ce7859b8a2cd033da9d3cb84c9265a77cca3509cdbf70152612c8", size = 681243, upload-time = "2025-12-26T00:59:51.892Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user