mirror of
http://88.130.71.182:3000/BlitTech/contexta_be.git
synced 2026-06-12 23:23:21 +00:00
Initial commit
This commit is contained in:
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
713
app/services/code_export.py
Normal file
713
app/services/code_export.py
Normal file
@@ -0,0 +1,713 @@
|
||||
import zipfile
|
||||
import io
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
def generate_export_package(
|
||||
chatbot: Dict[str, Any],
|
||||
company: Dict[str, Any],
|
||||
qdrant_url: str,
|
||||
qdrant_key: str,
|
||||
) -> bytes:
|
||||
"""
|
||||
Generate a complete export ZIP with FastAPI backend + React widget
|
||||
"""
|
||||
buffer = io.BytesIO()
|
||||
|
||||
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
# ── Backend files ──────────────────────────────────────────
|
||||
zf.writestr("backend/requirements.txt", _requirements())
|
||||
zf.writestr("backend/.env.example", _env_example(chatbot, qdrant_url, qdrant_key))
|
||||
zf.writestr("backend/main.py", _main_py(chatbot))
|
||||
zf.writestr("backend/rag_engine.py", _rag_engine_py())
|
||||
zf.writestr("backend/Dockerfile", _dockerfile())
|
||||
zf.writestr("backend/docker-compose.yml", _docker_compose(chatbot))
|
||||
zf.writestr("backend/README.md", _backend_readme(chatbot))
|
||||
|
||||
# ── Frontend files ─────────────────────────────────────────
|
||||
zf.writestr("frontend/src/ChatWidget.tsx", _chat_widget_tsx(chatbot))
|
||||
zf.writestr("frontend/src/useChat.ts", _use_chat_ts())
|
||||
zf.writestr("frontend/src/api.ts", _api_ts())
|
||||
zf.writestr("frontend/src/types.ts", _types_ts())
|
||||
zf.writestr("frontend/package.json", _package_json(chatbot))
|
||||
zf.writestr("frontend/tsconfig.json", _tsconfig())
|
||||
zf.writestr("frontend/vite.config.ts", _vite_config())
|
||||
zf.writestr("frontend/README.md", _frontend_readme(chatbot))
|
||||
|
||||
# ── Root ───────────────────────────────────────────────────
|
||||
zf.writestr("QUICK_START.md", _quick_start(chatbot))
|
||||
zf.writestr("setup.py", _setup_wizard(chatbot))
|
||||
|
||||
buffer.seek(0)
|
||||
return buffer.read()
|
||||
|
||||
|
||||
def _requirements():
|
||||
return """fastapi==0.115.0
|
||||
uvicorn[standard]==0.30.6
|
||||
python-dotenv==1.0.1
|
||||
pydantic==2.8.2
|
||||
qdrant-client==1.11.1
|
||||
openai==1.51.0
|
||||
anthropic==0.34.2
|
||||
google-generativeai==0.8.1
|
||||
httpx==0.27.2
|
||||
langdetect==1.0.9
|
||||
"""
|
||||
|
||||
|
||||
def _env_example(chatbot: Dict, qdrant_url: str, qdrant_key: str):
|
||||
name = chatbot.get("name", "My Chatbot").upper().replace(" ", "_")
|
||||
return f"""# {name} - Environment Configuration
|
||||
# Copy to .env and fill in your values
|
||||
|
||||
# LLM Provider (choose one)
|
||||
LLM_PROVIDER=openai
|
||||
LLM_MODEL=gpt-4o
|
||||
LLM_API_KEY=sk-your-openai-key
|
||||
|
||||
# For Anthropic: sk-ant-your-key
|
||||
# For Google: your-google-api-key
|
||||
# For Fireworks: your-fireworks-key
|
||||
|
||||
# Embeddings (required - OpenAI)
|
||||
EMBEDDING_API_KEY=sk-your-openai-key
|
||||
EMBEDDING_MODEL=text-embedding-3-small
|
||||
|
||||
# Qdrant Vector Database
|
||||
QDRANT_URL={qdrant_url}
|
||||
QDRANT_API_KEY={qdrant_key}
|
||||
QDRANT_COLLECTION={chatbot.get("qdrant_collection_name", "chatbot_collection")}
|
||||
|
||||
# Server
|
||||
PORT=8000
|
||||
HOST=0.0.0.0
|
||||
ALLOWED_ORIGINS=http://localhost:3000,https://yourdomain.com
|
||||
"""
|
||||
|
||||
|
||||
def _main_py(chatbot: Dict):
|
||||
return f'''"""
|
||||
Auto-generated FastAPI backend for: {chatbot.get("name", "Chatbot")}
|
||||
Generated by Contexta Platform
|
||||
"""
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
from rag_engine import RAGEngine
|
||||
|
||||
load_dotenv()
|
||||
|
||||
app = FastAPI(
|
||||
title="{chatbot.get("name", "Chatbot")} API",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=os.getenv("ALLOWED_ORIGINS", "*").split(","),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
rag = RAGEngine(
|
||||
qdrant_url=os.getenv("QDRANT_URL"),
|
||||
qdrant_api_key=os.getenv("QDRANT_API_KEY"),
|
||||
collection_name=os.getenv("QDRANT_COLLECTION"),
|
||||
llm_provider=os.getenv("LLM_PROVIDER", "openai"),
|
||||
llm_model=os.getenv("LLM_MODEL", "gpt-4o"),
|
||||
llm_api_key=os.getenv("LLM_API_KEY"),
|
||||
embedding_api_key=os.getenv("EMBEDDING_API_KEY"),
|
||||
embedding_model=os.getenv("EMBEDDING_MODEL", "text-embedding-3-small"),
|
||||
system_prompt="""{chatbot.get("system_prompt") or "You are a helpful assistant."}""",
|
||||
)
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
message: str
|
||||
session_id: Optional[str] = None
|
||||
language: str = "en"
|
||||
history: List[dict] = []
|
||||
|
||||
|
||||
class Source(BaseModel):
|
||||
document_name: str
|
||||
text: str
|
||||
score: float
|
||||
|
||||
|
||||
class ChatResponse(BaseModel):
|
||||
response: str
|
||||
session_id: str
|
||||
sources: List[Source]
|
||||
|
||||
|
||||
@app.post("/chat", response_model=ChatResponse)
|
||||
async def chat(request: ChatRequest):
|
||||
import uuid
|
||||
session_id = request.session_id or str(uuid.uuid4())
|
||||
result = await rag.query(
|
||||
query=request.message,
|
||||
history=request.history,
|
||||
language=request.language,
|
||||
)
|
||||
return ChatResponse(
|
||||
response=result["response"],
|
||||
session_id=session_id,
|
||||
sources=[Source(**s) for s in result.get("sources", [])],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
return {{"status": "healthy", "chatbot": "{chatbot.get("name", "Chatbot")}"}}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host=os.getenv("HOST", "0.0.0.0"), port=int(os.getenv("PORT", 8000)))
|
||||
'''
|
||||
|
||||
|
||||
def _rag_engine_py():
|
||||
return '''"""RAG Engine - Retrieval-Augmented Generation"""
|
||||
from qdrant_client import QdrantClient
|
||||
from qdrant_client.http.models import Distance, VectorParams
|
||||
from openai import AsyncOpenAI
|
||||
from typing import List, Dict, Any, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RAGEngine:
|
||||
def __init__(self, qdrant_url, qdrant_api_key, collection_name,
|
||||
llm_provider, llm_model, llm_api_key,
|
||||
embedding_api_key, embedding_model, system_prompt=""):
|
||||
self.collection_name = collection_name
|
||||
self.llm_provider = llm_provider
|
||||
self.llm_model = llm_model
|
||||
self.llm_api_key = llm_api_key
|
||||
self.embedding_model = embedding_model
|
||||
self.system_prompt = system_prompt
|
||||
|
||||
# Qdrant
|
||||
qdrant_kwargs = {"url": qdrant_url}
|
||||
if qdrant_api_key:
|
||||
qdrant_kwargs["api_key"] = qdrant_api_key
|
||||
self.qdrant = QdrantClient(**qdrant_kwargs)
|
||||
|
||||
# OpenAI for embeddings
|
||||
self.embed_client = AsyncOpenAI(api_key=embedding_api_key)
|
||||
|
||||
async def embed(self, text: str) -> List[float]:
|
||||
resp = await self.embed_client.embeddings.create(
|
||||
model=self.embedding_model, input=text
|
||||
)
|
||||
return resp.data[0].embedding
|
||||
|
||||
async def retrieve(self, query_vector: List[float], limit: int = 5) -> List[Dict]:
|
||||
results = self.qdrant.search(
|
||||
collection_name=self.collection_name,
|
||||
query_vector=query_vector,
|
||||
limit=limit,
|
||||
score_threshold=0.3,
|
||||
)
|
||||
return [{"text": r.payload.get("text", ""), "document_name": r.payload.get("file_name", ""), "score": r.score}
|
||||
for r in results]
|
||||
|
||||
async def generate(self, messages: List[Dict]) -> str:
|
||||
if self.llm_provider == "openai":
|
||||
from openai import AsyncOpenAI
|
||||
client = AsyncOpenAI(api_key=self.llm_api_key)
|
||||
resp = await client.chat.completions.create(
|
||||
model=self.llm_model, messages=messages, max_tokens=1000
|
||||
)
|
||||
return resp.choices[0].message.content
|
||||
elif self.llm_provider == "anthropic":
|
||||
import anthropic
|
||||
client = anthropic.AsyncAnthropic(api_key=self.llm_api_key)
|
||||
system = next((m["content"] for m in messages if m["role"] == "system"), "")
|
||||
conv = [m for m in messages if m["role"] != "system"]
|
||||
resp = await client.messages.create(
|
||||
model=self.llm_model, max_tokens=1000, system=system, messages=conv
|
||||
)
|
||||
return resp.content[0].text
|
||||
elif self.llm_provider == "fireworks":
|
||||
import httpx
|
||||
async with httpx.AsyncClient(timeout=60) as c:
|
||||
r = await c.post(
|
||||
"https://api.fireworks.ai/inference/v1/chat/completions",
|
||||
headers={"Authorization": f"Bearer {self.llm_api_key}"},
|
||||
json={"model": self.llm_model, "messages": messages, "max_tokens": 1000},
|
||||
)
|
||||
r.raise_for_status()
|
||||
return r.json()["choices"][0]["message"]["content"]
|
||||
return "Error: unknown provider"
|
||||
|
||||
async def query(self, query: str, history: List[Dict] = None, language: str = "en") -> Dict:
|
||||
if history is None:
|
||||
history = []
|
||||
|
||||
query_vec = await self.embed(query)
|
||||
docs = await self.retrieve(query_vec)
|
||||
context = "\\n\\n---\\n\\n".join(d["text"] for d in docs) or "No context found."
|
||||
|
||||
system = f"{self.system_prompt}\\n\\nContext:\\n{context}"
|
||||
messages = [{"role": "system", "content": system}]
|
||||
for h in history[-10:]:
|
||||
messages.append(h)
|
||||
messages.append({"role": "user", "content": query})
|
||||
|
||||
response = await self.generate(messages)
|
||||
return {"response": response, "sources": docs}
|
||||
'''
|
||||
|
||||
|
||||
def _dockerfile():
|
||||
return """FROM python:3.11-slim
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
"""
|
||||
|
||||
|
||||
def _docker_compose(chatbot: Dict):
|
||||
name = chatbot.get("name", "chatbot").lower().replace(" ", "-")
|
||||
return f"""version: '3.8'
|
||||
services:
|
||||
api:
|
||||
build: .
|
||||
ports:
|
||||
- "8000:8000"
|
||||
env_file: .env
|
||||
restart: unless-stopped
|
||||
container_name: {name}-api
|
||||
"""
|
||||
|
||||
|
||||
def _chat_widget_tsx(chatbot: Dict):
|
||||
color = chatbot.get("primary_color", "#6366f1")
|
||||
welcome = chatbot.get("welcome_message", "Hello! How can I help you today?")
|
||||
name = chatbot.get("name", "Assistant")
|
||||
return f'''import React, {{ useState, useRef, useEffect }} from "react";
|
||||
import {{ useChat }} from "./useChat";
|
||||
|
||||
const PRIMARY_COLOR = "{color}";
|
||||
const BOT_NAME = "{name}";
|
||||
const WELCOME_MESSAGE = "{welcome}";
|
||||
|
||||
export const ChatWidget: React.FC = () => {{
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const {{ messages, isLoading, sendMessage }} = useChat(WELCOME_MESSAGE);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {{
|
||||
bottomRef.current?.scrollIntoView({{ behavior: "smooth" }});
|
||||
}}, [messages]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{{isOpen && (
|
||||
<div style={{{{
|
||||
position: "fixed", bottom: 90, right: 20, width: 360, height: 520,
|
||||
borderRadius: 16, boxShadow: "0 20px 60px rgba(0,0,0,0.2)",
|
||||
display: "flex", flexDirection: "column", background: "#fff",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif", zIndex: 9999
|
||||
}}}}>
|
||||
<div style={{{{ background: PRIMARY_COLOR, padding: "16px 20px",
|
||||
borderRadius: "16px 16px 0 0", display: "flex", justifyContent: "space-between", alignItems: "center" }}}}>
|
||||
<span style={{{{ color: "#fff", fontWeight: 600, fontSize: 16 }}}}>{{BOT_NAME}}</span>
|
||||
<button onClick={{() => setIsOpen(false)}}
|
||||
style={{{{ background: "none", border: "none", color: "#fff", cursor: "pointer", fontSize: 20 }}}}>×</button>
|
||||
</div>
|
||||
<div style={{{{ flex: 1, overflowY: "auto", padding: 16, display: "flex", flexDirection: "column", gap: 12 }}}}>
|
||||
{{messages.map((msg, i) => (
|
||||
<div key={{i}} style={{{{ display: "flex", justifyContent: msg.role === "user" ? "flex-end" : "flex-start" }}}}>
|
||||
<div style={{{{
|
||||
maxWidth: "80%", padding: "10px 14px", borderRadius: 12, fontSize: 14, lineHeight: 1.5,
|
||||
background: msg.role === "user" ? PRIMARY_COLOR : "#f3f4f6",
|
||||
color: msg.role === "user" ? "#fff" : "#111"
|
||||
}}}}>{{msg.content}}</div>
|
||||
</div>
|
||||
))}}
|
||||
{{isLoading && <div style={{{{ color: "#6b7280", fontSize: 13 }}}}>Thinking...</div>}}
|
||||
<div ref={{bottomRef}} />
|
||||
</div>
|
||||
<div style={{{{ padding: "12px 16px", borderTop: "1px solid #e5e7eb", display: "flex", gap: 8 }}}}>
|
||||
<input
|
||||
style={{{{ flex: 1, border: "1px solid #e5e7eb", borderRadius: 8, padding: "8px 12px", outline: "none", fontSize: 14 }}}}
|
||||
placeholder="Type a message..."
|
||||
onKeyDown={{(e) => {{
|
||||
if (e.key === "Enter" && !e.shiftKey) {{
|
||||
e.preventDefault();
|
||||
const val = (e.target as HTMLInputElement).value.trim();
|
||||
if (val) {{ sendMessage(val); (e.target as HTMLInputElement).value = ""; }}
|
||||
}}
|
||||
}}}}
|
||||
/>
|
||||
<button
|
||||
style={{{{ background: PRIMARY_COLOR, color: "#fff", border: "none", borderRadius: 8,
|
||||
padding: "8px 14px", cursor: "pointer", fontSize: 14 }}}}
|
||||
onClick={{(e) => {{
|
||||
const input = (e.currentTarget.previousSibling as HTMLInputElement);
|
||||
const val = input.value.trim();
|
||||
if (val) {{ sendMessage(val); input.value = ""; }}
|
||||
}}}}
|
||||
>Send</button>
|
||||
</div>
|
||||
</div>
|
||||
)}}
|
||||
<button
|
||||
onClick={{() => setIsOpen(!isOpen)}}
|
||||
style={{{{
|
||||
position: "fixed", bottom: 20, right: 20, width: 56, height: 56,
|
||||
borderRadius: "50%", background: PRIMARY_COLOR, border: "none",
|
||||
cursor: "pointer", display: "flex", alignItems: "center", justifyContent: "center",
|
||||
boxShadow: "0 4px 20px rgba(0,0,0,0.2)", zIndex: 9999, fontSize: 24
|
||||
}}}}
|
||||
>
|
||||
{{isOpen ? "×" : "💬"}}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
}};
|
||||
'''
|
||||
|
||||
|
||||
def _use_chat_ts():
|
||||
return '''import { useState, useCallback } from "react";
|
||||
import { sendChatMessage } from "./api";
|
||||
|
||||
interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function useChat(welcomeMessage: string) {
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ role: "assistant", content: welcomeMessage }
|
||||
]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [sessionId] = useState(() => crypto.randomUUID());
|
||||
|
||||
const sendMessage = useCallback(async (content: string) => {
|
||||
setMessages(prev => [...prev, { role: "user", content }]);
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const history = messages.map(m => ({ role: m.role, content: m.content }));
|
||||
const result = await sendChatMessage({ message: content, session_id: sessionId, history });
|
||||
setMessages(prev => [...prev, { role: "assistant", content: result.response }]);
|
||||
} catch {
|
||||
setMessages(prev => [...prev, { role: "assistant", content: "Sorry, I encountered an error. Please try again." }]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [messages, sessionId]);
|
||||
|
||||
return { messages, isLoading, sendMessage };
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def _api_ts():
|
||||
return '''const API_URL = import.meta.env.VITE_API_URL || "http://localhost:8000";
|
||||
|
||||
export async function sendChatMessage(payload: {
|
||||
message: string;
|
||||
session_id: string;
|
||||
history?: any[];
|
||||
}) {
|
||||
const response = await fetch(`${API_URL}/chat`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!response.ok) throw new Error("Chat request failed");
|
||||
return response.json();
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def _types_ts():
|
||||
return '''export interface Message {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface Source {
|
||||
document_name: string;
|
||||
text: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
response: string;
|
||||
session_id: string;
|
||||
sources: Source[];
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def _package_json(chatbot: Dict):
|
||||
name = chatbot.get("name", "chatbot").lower().replace(" ", "-")
|
||||
return f'''{{
|
||||
"name": "{name}-widget",
|
||||
"version": "1.0.0",
|
||||
"scripts": {{
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
}},
|
||||
"dependencies": {{
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}},
|
||||
"devDependencies": {{
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"@vitejs/plugin-react": "^4.0.0"
|
||||
}}
|
||||
}}
|
||||
'''
|
||||
|
||||
|
||||
def _tsconfig():
|
||||
return '''{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
def _vite_config():
|
||||
return '''import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: "src/main.tsx",
|
||||
name: "ChatWidget",
|
||||
fileName: "chatbot-widget"
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ["react", "react-dom"],
|
||||
}
|
||||
}
|
||||
});
|
||||
'''
|
||||
|
||||
|
||||
def _backend_readme(chatbot: Dict):
|
||||
return f"""# {chatbot.get("name", "Chatbot")} - Backend API
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env with your API keys
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload --port 8000
|
||||
```
|
||||
|
||||
## Deploy with Docker
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
- `POST /chat` - Send a message
|
||||
- `GET /health` - Health check
|
||||
|
||||
## Environment Variables
|
||||
|
||||
See `.env.example` for all required variables.
|
||||
"""
|
||||
|
||||
|
||||
def _frontend_readme(chatbot: Dict):
|
||||
return f"""# {chatbot.get("name", "Chatbot")} - Chat Widget
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Set VITE_API_URL to your backend URL
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Build for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Embed in Any Website
|
||||
|
||||
```html
|
||||
<script src="path/to/dist/chatbot-widget.umd.cjs"></script>
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `VITE_API_URL` - Backend API URL (default: http://localhost:8000)
|
||||
"""
|
||||
|
||||
|
||||
def _quick_start(chatbot: Dict):
|
||||
return f"""# Quick Start - {chatbot.get("name", "Chatbot")}
|
||||
|
||||
Get your chatbot running in 5 minutes!
|
||||
|
||||
## Prerequisites
|
||||
- Python 3.11+
|
||||
- Node.js 18+
|
||||
- API key from OpenAI, Anthropic, or Google
|
||||
|
||||
## 1. Configure Environment (2 min)
|
||||
|
||||
Run the setup wizard:
|
||||
```bash
|
||||
python setup.py
|
||||
```
|
||||
|
||||
Or manually:
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
# Edit .env with your keys
|
||||
```
|
||||
|
||||
## 2. Start Backend (1 min)
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
pip install -r requirements.txt
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
Backend runs at: http://localhost:8000
|
||||
|
||||
## 3. Start Frontend Widget (1 min)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Widget available at: http://localhost:3000
|
||||
|
||||
## 4. Embed in Your Website
|
||||
|
||||
After building (`npm run build`):
|
||||
```html
|
||||
<script src="dist/chatbot-widget.umd.cjs"></script>
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
### Railway (Recommended)
|
||||
```bash
|
||||
railway init
|
||||
railway up
|
||||
```
|
||||
|
||||
### Docker
|
||||
```bash
|
||||
cd backend && docker-compose up -d
|
||||
```
|
||||
"""
|
||||
|
||||
|
||||
def _setup_wizard(chatbot: Dict):
|
||||
return f'''#!/usr/bin/env python3
|
||||
"""
|
||||
Interactive setup wizard for {chatbot.get("name", "Chatbot")}
|
||||
"""
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main():
|
||||
print("""
|
||||
╔══════════════════════════════════════╗
|
||||
║ {chatbot.get("name", "Chatbot")} Setup Wizard ║
|
||||
╚══════════════════════════════════════╝
|
||||
""")
|
||||
|
||||
print("Choose your LLM provider:")
|
||||
print("1. OpenAI (GPT-4o)")
|
||||
print("2. Anthropic (Claude)")
|
||||
print("3. Google (Gemini)")
|
||||
print("4. Fireworks AI (Free, open-source models)")
|
||||
choice = input("\\nEnter choice (1-4): ").strip()
|
||||
|
||||
providers = {{"1": "openai", "2": "anthropic", "3": "google", "4": "fireworks"}}
|
||||
models = {{"1": "gpt-4o", "2": "claude-3-5-sonnet-20241022", "3": "gemini-1.5-pro",
|
||||
"4": "accounts/fireworks/models/llama-v3p1-70b-instruct"}}
|
||||
|
||||
provider = providers.get(choice, "openai")
|
||||
model = models.get(choice, "gpt-4o")
|
||||
|
||||
api_key = input(f"Enter your {{provider}} API key: ").strip()
|
||||
|
||||
env_content = f"""LLM_PROVIDER={{provider}}
|
||||
LLM_MODEL={{model}}
|
||||
LLM_API_KEY={{api_key}}
|
||||
EMBEDDING_API_KEY={{api_key if provider == "openai" else input("Enter OpenAI key for embeddings: ").strip()}}
|
||||
EMBEDDING_MODEL=text-embedding-3-small
|
||||
QDRANT_URL={os.getenv("QDRANT_URL", "your-qdrant-url")}
|
||||
QDRANT_API_KEY={os.getenv("QDRANT_API_KEY", "your-qdrant-key")}
|
||||
QDRANT_COLLECTION={chatbot.get("qdrant_collection_name", "chatbot_collection")}
|
||||
"""
|
||||
|
||||
env_file = Path("backend/.env")
|
||||
env_file.write_text(env_content)
|
||||
print("\\n✅ .env file created!")
|
||||
|
||||
frontend_url = input("\\nBackend URL for frontend (default: http://localhost:8000): ").strip()
|
||||
if not frontend_url:
|
||||
frontend_url = "http://localhost:8000"
|
||||
|
||||
Path("frontend/.env").write_text(f"VITE_API_URL={{frontend_url}}\\n")
|
||||
print("✅ Frontend .env created!")
|
||||
|
||||
print("""
|
||||
\\n╔══════════════════════════════════════╗
|
||||
║ Setup Complete! 🎉 ║
|
||||
╠══════════════════════════════════════╣
|
||||
║ Backend: cd backend && uvicorn ║
|
||||
║ main:app --reload ║
|
||||
║ Frontend: cd frontend && npm dev ║
|
||||
╚══════════════════════════════════════╝
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
221
app/services/document_processor.py
Normal file
221
app/services/document_processor.py
Normal file
@@ -0,0 +1,221 @@
|
||||
import io
|
||||
import logging
|
||||
from typing import List, Dict, Any, Tuple
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CHUNK_SIZE = 512 # tokens approximate (chars ÷ 4)
|
||||
CHUNK_OVERLAP = 50
|
||||
|
||||
|
||||
def parse_pdf(file_bytes: bytes) -> List[Dict[str, Any]]:
|
||||
"""Parse PDF and return list of {text, page_number}"""
|
||||
try:
|
||||
import pypdf
|
||||
|
||||
reader = pypdf.PdfReader(io.BytesIO(file_bytes))
|
||||
pages = []
|
||||
for i, page in enumerate(reader.pages):
|
||||
text = page.extract_text() or ""
|
||||
text = text.strip()
|
||||
if text:
|
||||
pages.append({"text": text, "page_number": i + 1})
|
||||
return pages
|
||||
except Exception as e:
|
||||
logger.error(f"PDF parse error: {e}")
|
||||
raise ValueError(f"Failed to parse PDF: {str(e)}")
|
||||
|
||||
|
||||
def parse_docx(file_bytes: bytes) -> List[Dict[str, Any]]:
|
||||
"""Parse DOCX and return list of {text, page_number}"""
|
||||
try:
|
||||
from docx import Document
|
||||
|
||||
doc = Document(io.BytesIO(file_bytes))
|
||||
sections = []
|
||||
current_text = []
|
||||
section_idx = 1
|
||||
|
||||
for para in doc.paragraphs:
|
||||
text = para.text.strip()
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# New section on headings
|
||||
if para.style.name.startswith("Heading"):
|
||||
if current_text:
|
||||
sections.append(
|
||||
{"text": "\n".join(current_text), "page_number": section_idx}
|
||||
)
|
||||
current_text = []
|
||||
section_idx += 1
|
||||
current_text.append(text)
|
||||
|
||||
if current_text:
|
||||
sections.append({"text": "\n".join(current_text), "page_number": section_idx})
|
||||
|
||||
return sections if sections else [{"text": "", "page_number": 1}]
|
||||
except Exception as e:
|
||||
logger.error(f"DOCX parse error: {e}")
|
||||
raise ValueError(f"Failed to parse DOCX: {str(e)}")
|
||||
|
||||
|
||||
def parse_csv(file_bytes: bytes) -> List[Dict[str, Any]]:
|
||||
"""Parse CSV - each row becomes a chunk"""
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
df = pd.read_csv(io.BytesIO(file_bytes))
|
||||
columns = list(df.columns)
|
||||
chunks = []
|
||||
|
||||
# Process in batches of rows
|
||||
batch_size = 10
|
||||
for start in range(0, len(df), batch_size):
|
||||
batch = df.iloc[start : start + batch_size]
|
||||
rows_text = []
|
||||
for _, row in batch.iterrows():
|
||||
row_parts = [f"{col}: {val}" for col, val in zip(columns, row) if str(val) != "nan"]
|
||||
rows_text.append(" | ".join(row_parts))
|
||||
text = "\n".join(rows_text)
|
||||
chunks.append({"text": text, "page_number": (start // batch_size) + 1})
|
||||
|
||||
return chunks
|
||||
except Exception as e:
|
||||
logger.error(f"CSV parse error: {e}")
|
||||
raise ValueError(f"Failed to parse CSV: {str(e)}")
|
||||
|
||||
|
||||
def parse_xlsx(file_bytes: bytes) -> List[Dict[str, Any]]:
|
||||
"""Parse XLSX - each sheet becomes sections"""
|
||||
try:
|
||||
import pandas as pd
|
||||
|
||||
xl = pd.ExcelFile(io.BytesIO(file_bytes))
|
||||
chunks = []
|
||||
page_num = 1
|
||||
|
||||
for sheet_name in xl.sheet_names:
|
||||
df = xl.parse(sheet_name)
|
||||
columns = list(df.columns)
|
||||
|
||||
batch_size = 10
|
||||
for start in range(0, len(df), batch_size):
|
||||
batch = df.iloc[start : start + batch_size]
|
||||
rows_text = [f"Sheet: {sheet_name}"]
|
||||
for _, row in batch.iterrows():
|
||||
row_parts = [
|
||||
f"{col}: {val}"
|
||||
for col, val in zip(columns, row)
|
||||
if str(val) not in ("nan", "NaT", "None")
|
||||
]
|
||||
if row_parts:
|
||||
rows_text.append(" | ".join(row_parts))
|
||||
text = "\n".join(rows_text)
|
||||
if text.strip():
|
||||
chunks.append({"text": text, "page_number": page_num})
|
||||
page_num += 1
|
||||
|
||||
return chunks
|
||||
except Exception as e:
|
||||
logger.error(f"XLSX parse error: {e}")
|
||||
raise ValueError(f"Failed to parse XLSX: {str(e)}")
|
||||
|
||||
|
||||
def parse_txt(file_bytes: bytes) -> List[Dict[str, Any]]:
|
||||
"""Parse plain text"""
|
||||
try:
|
||||
text = file_bytes.decode("utf-8", errors="ignore")
|
||||
# Split into sections by double newlines
|
||||
sections = [s.strip() for s in text.split("\n\n") if s.strip()]
|
||||
if not sections:
|
||||
sections = [text.strip()]
|
||||
return [{"text": s, "page_number": i + 1} for i, s in enumerate(sections)]
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to parse TXT: {str(e)}")
|
||||
|
||||
|
||||
def chunk_text(text: str, chunk_size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP) -> List[str]:
|
||||
"""Split text into overlapping chunks"""
|
||||
# Approximate token count: 1 token ≈ 4 chars
|
||||
char_size = chunk_size * 4
|
||||
char_overlap = overlap * 4
|
||||
|
||||
if len(text) <= char_size:
|
||||
return [text]
|
||||
|
||||
chunks = []
|
||||
start = 0
|
||||
while start < len(text):
|
||||
end = min(start + char_size, len(text))
|
||||
|
||||
# Try to break at sentence boundary
|
||||
if end < len(text):
|
||||
for sep in [". ", "! ", "? ", "\n", " "]:
|
||||
pos = text.rfind(sep, start, end)
|
||||
if pos > start + char_size // 2:
|
||||
end = pos + len(sep)
|
||||
break
|
||||
|
||||
chunk = text[start:end].strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
|
||||
start = end - char_overlap if end - char_overlap > start else end
|
||||
|
||||
return chunks
|
||||
|
||||
|
||||
def process_document(
|
||||
file_bytes: bytes,
|
||||
file_name: str,
|
||||
document_id: str,
|
||||
company_id: str,
|
||||
) -> Tuple[List[str], List[Dict[str, Any]]]:
|
||||
"""
|
||||
Main entry point: parse and chunk a document.
|
||||
Returns (chunks_text, chunk_payloads)
|
||||
"""
|
||||
ext = Path(file_name).suffix.lower()
|
||||
|
||||
# Parse
|
||||
if ext == ".pdf":
|
||||
pages = parse_pdf(file_bytes)
|
||||
elif ext == ".docx":
|
||||
pages = parse_docx(file_bytes)
|
||||
elif ext == ".csv":
|
||||
pages = parse_csv(file_bytes)
|
||||
elif ext in (".xlsx", ".xls"):
|
||||
pages = parse_xlsx(file_bytes)
|
||||
elif ext in (".txt", ".md"):
|
||||
pages = parse_txt(file_bytes)
|
||||
else:
|
||||
raise ValueError(f"Unsupported file type: {ext}")
|
||||
|
||||
# Chunk
|
||||
all_chunks = []
|
||||
all_payloads = []
|
||||
|
||||
for page in pages:
|
||||
text = page["text"]
|
||||
page_num = page.get("page_number", 1)
|
||||
|
||||
chunks = chunk_text(text)
|
||||
for idx, chunk in enumerate(chunks):
|
||||
all_chunks.append(chunk)
|
||||
all_payloads.append(
|
||||
{
|
||||
"document_id": document_id,
|
||||
"company_id": company_id,
|
||||
"file_name": file_name,
|
||||
"page_number": page_num,
|
||||
"chunk_index": idx,
|
||||
"text": chunk,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Processed {file_name}: {len(pages)} pages → {len(all_chunks)} chunks"
|
||||
)
|
||||
return all_chunks, all_payloads
|
||||
54
app/services/embeddings.py
Normal file
54
app/services/embeddings.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from openai import OpenAI
|
||||
from app.config import settings
|
||||
from typing import List
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_openai_client = None
|
||||
|
||||
|
||||
def get_openai_client() -> OpenAI:
|
||||
global _openai_client
|
||||
if _openai_client is None:
|
||||
_openai_client = OpenAI(api_key=settings.openai_api_key)
|
||||
return _openai_client
|
||||
|
||||
|
||||
class EmbeddingService:
|
||||
def __init__(self):
|
||||
self.model = settings.embedding_model
|
||||
|
||||
def embed_text(self, text: str) -> List[float]:
|
||||
"""Generate embedding for a single text"""
|
||||
client = get_openai_client()
|
||||
try:
|
||||
response = client.embeddings.create(
|
||||
model=self.model,
|
||||
input=text,
|
||||
)
|
||||
return response.data[0].embedding
|
||||
except Exception as e:
|
||||
logger.error(f"Embedding error: {e}")
|
||||
raise
|
||||
|
||||
def embed_batch(self, texts: List[str]) -> List[List[float]]:
|
||||
"""Generate embeddings for multiple texts"""
|
||||
client = get_openai_client()
|
||||
try:
|
||||
# Clean texts
|
||||
cleaned = [t.replace("\n", " ").strip() for t in texts if t.strip()]
|
||||
if not cleaned:
|
||||
return []
|
||||
|
||||
response = client.embeddings.create(
|
||||
model=self.model,
|
||||
input=cleaned,
|
||||
)
|
||||
return [item.embedding for item in response.data]
|
||||
except Exception as e:
|
||||
logger.error(f"Batch embedding error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
embedding_service = EmbeddingService()
|
||||
171
app/services/llm.py
Normal file
171
app/services/llm.py
Normal file
@@ -0,0 +1,171 @@
|
||||
from app.config import settings, MODEL_PROVIDERS, PLAN_LIMITS
|
||||
from typing import List, Dict, Any, Optional, AsyncGenerator
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMService:
|
||||
"""Routes requests to appropriate LLM provider"""
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
model: str,
|
||||
max_tokens: int = 1000,
|
||||
temperature: float = 0.7,
|
||||
) -> Dict[str, Any]:
|
||||
"""Generate a response from the LLM"""
|
||||
provider = MODEL_PROVIDERS.get(model, "openai")
|
||||
|
||||
try:
|
||||
if provider == "fireworks":
|
||||
return await self._call_fireworks(messages, model, max_tokens, temperature)
|
||||
elif provider == "openai":
|
||||
return await self._call_openai(messages, model, max_tokens, temperature)
|
||||
elif provider == "anthropic":
|
||||
return await self._call_anthropic(messages, model, max_tokens, temperature)
|
||||
elif provider == "google":
|
||||
return await self._call_google(messages, model, max_tokens, temperature)
|
||||
else:
|
||||
return await self._call_openai(messages, model, max_tokens, temperature)
|
||||
except Exception as e:
|
||||
logger.error(f"LLM error ({provider}/{model}): {e}")
|
||||
# Fallback to a basic model if available
|
||||
if model != "accounts/fireworks/models/llama-v3p1-70b-instruct" and settings.fireworks_api_key:
|
||||
logger.info("Falling back to Fireworks AI")
|
||||
return await self._call_fireworks(
|
||||
messages,
|
||||
"accounts/fireworks/models/llama-v3p1-70b-instruct",
|
||||
max_tokens,
|
||||
temperature,
|
||||
)
|
||||
raise
|
||||
|
||||
async def _call_fireworks(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
model: str,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
) -> Dict[str, Any]:
|
||||
import httpx
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {settings.fireworks_api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
resp = await client.post(
|
||||
"https://api.fireworks.ai/inference/v1/chat/completions",
|
||||
headers=headers,
|
||||
json=payload,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
return {
|
||||
"content": data["choices"][0]["message"]["content"],
|
||||
"tokens_used": data.get("usage", {}).get("total_tokens", 0),
|
||||
"model": model,
|
||||
}
|
||||
|
||||
async def _call_openai(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
model: str,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
) -> Dict[str, Any]:
|
||||
from openai import AsyncOpenAI
|
||||
|
||||
client = AsyncOpenAI(api_key=settings.openai_api_key)
|
||||
response = await client.chat.completions.create(
|
||||
model=model,
|
||||
messages=messages,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
)
|
||||
return {
|
||||
"content": response.choices[0].message.content,
|
||||
"tokens_used": response.usage.total_tokens if response.usage else 0,
|
||||
"model": model,
|
||||
}
|
||||
|
||||
async def _call_anthropic(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
model: str,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
) -> Dict[str, Any]:
|
||||
import anthropic
|
||||
|
||||
client = anthropic.AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
# Separate system message from conversation
|
||||
system_msg = ""
|
||||
conv_messages = []
|
||||
for msg in messages:
|
||||
if msg["role"] == "system":
|
||||
system_msg = msg["content"]
|
||||
else:
|
||||
conv_messages.append(msg)
|
||||
|
||||
response = await client.messages.create(
|
||||
model=model,
|
||||
max_tokens=max_tokens,
|
||||
system=system_msg if system_msg else "You are a helpful assistant.",
|
||||
messages=conv_messages,
|
||||
temperature=temperature,
|
||||
)
|
||||
return {
|
||||
"content": response.content[0].text,
|
||||
"tokens_used": response.usage.input_tokens + response.usage.output_tokens,
|
||||
"model": model,
|
||||
}
|
||||
|
||||
async def _call_google(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
model: str,
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
) -> Dict[str, Any]:
|
||||
import google.generativeai as genai
|
||||
|
||||
genai.configure(api_key=settings.google_api_key)
|
||||
gemini_model = genai.GenerativeModel(model)
|
||||
|
||||
# Convert messages
|
||||
parts = []
|
||||
for msg in messages:
|
||||
role = "user" if msg["role"] in ("user", "system") else "model"
|
||||
parts.append({"role": role, "parts": [msg["content"]]})
|
||||
|
||||
# Use last message as prompt if only one
|
||||
if len(parts) == 1:
|
||||
response = await gemini_model.generate_content_async(
|
||||
parts[0]["parts"][0],
|
||||
generation_config={"max_output_tokens": max_tokens, "temperature": temperature},
|
||||
)
|
||||
else:
|
||||
chat = gemini_model.start_chat(history=parts[:-1])
|
||||
response = await chat.send_message_async(
|
||||
parts[-1]["parts"][0],
|
||||
generation_config={"max_output_tokens": max_tokens, "temperature": temperature},
|
||||
)
|
||||
|
||||
return {
|
||||
"content": response.text,
|
||||
"tokens_used": 0,
|
||||
"model": model,
|
||||
}
|
||||
|
||||
|
||||
llm_service = LLMService()
|
||||
130
app/services/rag.py
Normal file
130
app/services/rag.py
Normal file
@@ -0,0 +1,130 @@
|
||||
from app.services.embeddings import embedding_service
|
||||
from app.services.vector_store import vector_store
|
||||
from app.services.llm import llm_service
|
||||
from app.models import SourceDocument
|
||||
from typing import List, Dict, Any, Optional, Tuple
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RAG_SYSTEM_PROMPT = """You are a helpful AI assistant for {company_name}.
|
||||
Your role is to answer questions based on the provided context from company documents.
|
||||
|
||||
IMPORTANT RULES:
|
||||
1. Only answer based on the provided context
|
||||
2. If information is not in the context, say "I don't have information about that in my knowledge base"
|
||||
3. Be concise and helpful
|
||||
4. Always maintain a professional, friendly tone
|
||||
5. If asked about topics outside the context, politely redirect to relevant topics
|
||||
|
||||
{custom_instructions}
|
||||
|
||||
Context from knowledge base:
|
||||
{context}
|
||||
"""
|
||||
|
||||
|
||||
class RAGEngine:
|
||||
def __init__(self):
|
||||
self.embedding_svc = embedding_service
|
||||
self.vector_svc = vector_store
|
||||
self.llm_svc = llm_service
|
||||
|
||||
async def process_query(
|
||||
self,
|
||||
query: str,
|
||||
collection_name: str,
|
||||
chatbot_config: Dict[str, Any],
|
||||
conversation_history: List[Dict[str, str]] = None,
|
||||
language: str = "en",
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Full RAG pipeline: embed → retrieve → generate
|
||||
"""
|
||||
if conversation_history is None:
|
||||
conversation_history = []
|
||||
|
||||
# Step 1: Embed the query
|
||||
try:
|
||||
query_embedding = self.embedding_svc.embed_text(query)
|
||||
except Exception as e:
|
||||
logger.error(f"Embedding error: {e}")
|
||||
return {
|
||||
"response": "I'm having trouble processing your request. Please try again.",
|
||||
"sources": [],
|
||||
"tokens_used": 0,
|
||||
"model": chatbot_config.get("model", "unknown"),
|
||||
}
|
||||
|
||||
# Step 2: Retrieve relevant chunks
|
||||
retrieved = self.vector_svc.search(
|
||||
collection_name=collection_name,
|
||||
query_vector=query_embedding,
|
||||
limit=5,
|
||||
score_threshold=0.3,
|
||||
)
|
||||
|
||||
# Step 3: Build sources
|
||||
sources = []
|
||||
context_parts = []
|
||||
seen_texts = set()
|
||||
|
||||
for item in retrieved:
|
||||
payload = item.get("payload", {})
|
||||
text = payload.get("text", "")
|
||||
if text and text not in seen_texts:
|
||||
seen_texts.add(text)
|
||||
context_parts.append(text)
|
||||
sources.append(
|
||||
SourceDocument(
|
||||
document_name=payload.get("file_name", "Document"),
|
||||
chunk_text=text[:200] + "..." if len(text) > 200 else text,
|
||||
score=item.get("score", 0.0),
|
||||
page_number=payload.get("page_number"),
|
||||
)
|
||||
)
|
||||
|
||||
context = "\n\n---\n\n".join(context_parts) if context_parts else "No relevant information found."
|
||||
|
||||
# Step 4: Build messages
|
||||
system_prompt = RAG_SYSTEM_PROMPT.format(
|
||||
company_name=chatbot_config.get("company_name", ""),
|
||||
custom_instructions=chatbot_config.get("system_prompt") or "",
|
||||
context=context,
|
||||
)
|
||||
|
||||
messages = [{"role": "system", "content": system_prompt}]
|
||||
|
||||
# Add conversation history (last 10 messages)
|
||||
for msg in conversation_history[-10:]:
|
||||
messages.append({"role": msg["role"], "content": msg["content"]})
|
||||
|
||||
# Add current query
|
||||
messages.append({"role": "user", "content": query})
|
||||
|
||||
# Step 5: Generate response
|
||||
model = chatbot_config.get("model", "accounts/fireworks/models/llama-v3p1-70b-instruct")
|
||||
try:
|
||||
result = await self.llm_svc.generate(
|
||||
messages=messages,
|
||||
model=model,
|
||||
max_tokens=chatbot_config.get("max_tokens", 1000),
|
||||
temperature=chatbot_config.get("temperature", 0.7),
|
||||
)
|
||||
return {
|
||||
"response": result["content"],
|
||||
"sources": sources,
|
||||
"tokens_used": result.get("tokens_used", 0),
|
||||
"model": result.get("model", model),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"LLM generation error: {e}")
|
||||
return {
|
||||
"response": "I'm having trouble generating a response. Please try again later.",
|
||||
"sources": sources,
|
||||
"tokens_used": 0,
|
||||
"model": model,
|
||||
}
|
||||
|
||||
|
||||
rag_engine = RAGEngine()
|
||||
153
app/services/vector_store.py
Normal file
153
app/services/vector_store.py
Normal file
@@ -0,0 +1,153 @@
|
||||
from qdrant_client import QdrantClient, models
|
||||
from qdrant_client.http.models import (
|
||||
Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue
|
||||
)
|
||||
from app.config import settings
|
||||
from typing import List, Dict, Any, Optional
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_qdrant_client: QdrantClient = None
|
||||
|
||||
|
||||
def get_qdrant_client() -> QdrantClient:
|
||||
global _qdrant_client
|
||||
if _qdrant_client is None:
|
||||
kwargs = {"url": settings.qdrant_url}
|
||||
if settings.qdrant_api_key:
|
||||
kwargs["api_key"] = settings.qdrant_api_key
|
||||
_qdrant_client = QdrantClient(**kwargs)
|
||||
return _qdrant_client
|
||||
|
||||
|
||||
class VectorStoreService:
|
||||
VECTOR_SIZE = 1536 # text-embedding-3-small
|
||||
|
||||
def __init__(self):
|
||||
self.client = get_qdrant_client()
|
||||
|
||||
def create_collection(self, collection_name: str) -> bool:
|
||||
"""Create a new collection for a chatbot"""
|
||||
try:
|
||||
self.client.create_collection(
|
||||
collection_name=collection_name,
|
||||
vectors_config=VectorParams(
|
||||
size=self.VECTOR_SIZE,
|
||||
distance=Distance.COSINE,
|
||||
),
|
||||
)
|
||||
logger.info(f"Created collection: {collection_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
if "already exists" in str(e).lower():
|
||||
return True
|
||||
logger.error(f"Error creating collection {collection_name}: {e}")
|
||||
raise
|
||||
|
||||
def delete_collection(self, collection_name: str) -> bool:
|
||||
"""Delete a chatbot's collection"""
|
||||
try:
|
||||
self.client.delete_collection(collection_name=collection_name)
|
||||
logger.info(f"Deleted collection: {collection_name}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting collection {collection_name}: {e}")
|
||||
return False
|
||||
|
||||
def collection_exists(self, collection_name: str) -> bool:
|
||||
try:
|
||||
self.client.get_collection(collection_name)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def upsert_vectors(
|
||||
self,
|
||||
collection_name: str,
|
||||
vectors: List[List[float]],
|
||||
payloads: List[Dict[str, Any]],
|
||||
ids: Optional[List[str]] = None,
|
||||
) -> bool:
|
||||
"""Upsert vectors into collection"""
|
||||
if ids is None:
|
||||
ids = [str(uuid.uuid4()) for _ in vectors]
|
||||
|
||||
points = [
|
||||
PointStruct(
|
||||
id=idx,
|
||||
vector=vector,
|
||||
payload=payload,
|
||||
)
|
||||
for idx, vector, payload in zip(ids, vectors, payloads)
|
||||
]
|
||||
|
||||
try:
|
||||
self.client.upsert(
|
||||
collection_name=collection_name,
|
||||
points=points,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error upserting vectors: {e}")
|
||||
raise
|
||||
|
||||
def search(
|
||||
self,
|
||||
collection_name: str,
|
||||
query_vector: List[float],
|
||||
limit: int = 5,
|
||||
score_threshold: float = 0.3,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search for similar vectors"""
|
||||
try:
|
||||
results = self.client.search(
|
||||
collection_name=collection_name,
|
||||
query_vector=query_vector,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
return [
|
||||
{
|
||||
"id": str(r.id),
|
||||
"score": r.score,
|
||||
"payload": r.payload,
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
except Exception as e:
|
||||
logger.error(f"Error searching vectors: {e}")
|
||||
return []
|
||||
|
||||
def delete_by_document_id(self, collection_name: str, document_id: str) -> bool:
|
||||
"""Delete all vectors for a document"""
|
||||
try:
|
||||
self.client.delete(
|
||||
collection_name=collection_name,
|
||||
points_selector=models.FilterSelector(
|
||||
filter=Filter(
|
||||
must=[
|
||||
FieldCondition(
|
||||
key="document_id",
|
||||
match=MatchValue(value=document_id),
|
||||
)
|
||||
]
|
||||
)
|
||||
),
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting document vectors: {e}")
|
||||
return False
|
||||
|
||||
def count_vectors(self, collection_name: str) -> int:
|
||||
"""Count vectors in a collection"""
|
||||
try:
|
||||
result = self.client.count(collection_name=collection_name)
|
||||
return result.count
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
|
||||
vector_store = VectorStoreService()
|
||||
Reference in New Issue
Block a user