Initial commit

This commit is contained in:
belviskhoremk
2026-02-22 21:59:37 +00:00
commit 5bd496d355
27 changed files with 4172 additions and 0 deletions

0
app/services/__init__.py Normal file
View File

713
app/services/code_export.py Normal file
View 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()
'''

View 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

View 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
View 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
View 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()

View 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()