alaselababatunde commited on
Commit
e8d69f7
·
1 Parent(s): ab2de89
.dockerignore CHANGED
@@ -1,6 +1,9 @@
1
- frontend/
2
  .git/
3
  .gitignore
4
- data/chroma/
5
- *.pdf
6
- node_modules/
 
 
 
 
 
 
1
  .git/
2
  .gitignore
3
+ chroma_db/
4
+ memory.db/
5
+ frontend/node_modules/
6
+ frontend/dist/
7
+ __pycache__/
8
+ *.pyc
9
+ .env
Dockerfile CHANGED
@@ -1,11 +1,36 @@
 
 
 
 
 
 
 
 
 
 
1
  FROM python:3.11-slim
2
 
3
  WORKDIR /app
4
 
5
- COPY requirements.txt .
 
 
 
 
6
 
 
 
7
  RUN pip install --no-cache-dir -r requirements.txt
8
 
 
9
  COPY . .
10
 
11
- CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
 
 
 
 
 
 
 
 
 
1
+ # Build stage for React frontend
2
+ FROM node:20-slim as build-frontend
3
+
4
+ WORKDIR /app/frontend
5
+ COPY frontend/package*.json ./
6
+ RUN npm install
7
+ COPY frontend/ ./
8
+ RUN npm run build
9
+
10
+ # Final stage
11
  FROM python:3.11-slim
12
 
13
  WORKDIR /app
14
 
15
+ # Install system dependencies for ChromaDB and other libraries
16
+ RUN apt-get update && apt-get install -y \
17
+ build-essential \
18
+ curl \
19
+ && rm -rf /var/lib/apt/lists/*
20
 
21
+ # Copy requirements and install
22
+ COPY requirements.txt .
23
  RUN pip install --no-cache-dir -r requirements.txt
24
 
25
+ # Copy backend code
26
  COPY . .
27
 
28
+ # Copy built frontend from stage 1
29
+ COPY --from=build-frontend /app/frontend/dist ./frontend/dist
30
+
31
+ # Expose port (user requested 7860)
32
+ EXPOSE 7860
33
+
34
+ # Command to run the application
35
+ # We use uvicorn to serve the FastAPI app which also serves the frontend
36
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md DELETED
@@ -1,11 +0,0 @@
1
- ---
2
- title: DSTV AI Support
3
- emoji: 😻
4
- colorFrom: pink
5
- colorTo: red
6
- sdk: docker
7
- pinned: false
8
- license: apache-2.0
9
- ---
10
-
11
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
app/api/endpoints/chat.py DELETED
@@ -1,51 +0,0 @@
1
- from fastapi import APIRouter, HTTPException
2
- from fastapi.responses import StreamingResponse
3
- from pydantic import BaseModel
4
- from typing import List
5
- from app.services.llm import llm_service
6
- from app.services.rag import rag_service
7
-
8
- router = APIRouter()
9
-
10
- class Message(BaseModel):
11
- role: str
12
- content: str
13
-
14
- class ChatRequest(BaseModel):
15
- messages: List[Message]
16
-
17
- @router.post("/chat")
18
- async def chat_endpoint(request: ChatRequest):
19
- try:
20
- # 1. Extract latest query
21
- if not request.messages:
22
- raise HTTPException(status_code=400, detail="No messages provided")
23
-
24
- last_message = request.messages[-1]
25
- user_query = last_message.content
26
-
27
- # 2. Retrieve Context (RAG)
28
- # Note: In a real system, we'd only RAG on user queries, not history,
29
- # but here we keep it simple.
30
- context_docs = rag_service.query(user_query)
31
- context_text = "\n\n".join(context_docs) # context_docs is list of strings
32
-
33
- # 3. Stream Response
34
- # Convert pydantic messages to dict
35
- messages_dict = [{"role": m.role, "content": m.content} for m in request.messages]
36
-
37
- return StreamingResponse(
38
- llm_service.chat_stream(messages_dict, context=context_text),
39
- media_type="text/event-stream"
40
- )
41
-
42
- except Exception as e:
43
- print(f"Error in chat endpoint: {e}")
44
- raise HTTPException(status_code=500, detail=str(e))
45
-
46
- @router.post("/ingest")
47
- async def ingest_endpoint(url: str):
48
- success = rag_service.scrape_and_ingest(url)
49
- if success:
50
- return {"status": "success", "message": f"Ingested {url}"}
51
- raise HTTPException(status_code=400, detail="Failed to ingest URL")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/core/config.py DELETED
@@ -1,11 +0,0 @@
1
- from pydantic_settings import BaseSettings
2
-
3
- class Settings(BaseSettings):
4
- XAI_API_KEY: str
5
- REDIS_HOST: str = "redis"
6
- REDIS_PORT: int = 6379
7
-
8
- class Config:
9
- env_file = ".env"
10
-
11
- settings = Settings()
 
 
 
 
 
 
 
 
 
 
 
 
app/main.py DELETED
@@ -1,31 +0,0 @@
1
- from fastapi import FastAPI
2
- from fastapi.middleware.cors import CORSMiddleware
3
- from app.core.config import settings
4
- from app.api.endpoints import chat
5
-
6
- app = FastAPI(title="DStv AI Support")
7
-
8
- # CORS
9
- origins = [
10
- "http://localhost:5173",
11
- "http://localhost:80",
12
- ]
13
-
14
- app.add_middleware(
15
- CORSMiddleware,
16
- allow_origins=origins,
17
- allow_credentials=True,
18
- allow_methods=["*"],
19
- allow_headers=["*"],
20
- )
21
-
22
- @app.get("/health")
23
- def health_check():
24
- return {"status": "ok", "service": "DStv AI Support Backend"}
25
-
26
- @app.get("/")
27
- def root():
28
- return {"message": "DStv AI Support API is running"}
29
-
30
- app.include_router(chat.router, prefix="/api")
31
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/llm.py DELETED
@@ -1,45 +0,0 @@
1
- from openai import OpenAI
2
- from app.core.config import settings
3
- from typing import List, Dict, Generator
4
-
5
- class LLMService:
6
- def __init__(self):
7
- self.client = OpenAI(
8
- api_key=settings.XAI_API_KEY,
9
- base_url="https://api.x.ai/v1"
10
- )
11
- self.model = "grok-4-1-fast-reasoning" # Actually check if this is the exact string. Assuming yes based on prompt.
12
-
13
- def get_system_prompt(self, context: str = "") -> str:
14
- base_prompt = """You are DStv AI Support, a friendly and helpful customer service agent for DStv.
15
- Your goal is to assist customers with subscriptions, packages, payments, decoder issues, signal problems, installations, and account management.
16
-
17
- Brand Voice:
18
- - Friendly, practical, and clear.
19
- - Do NOT say "I am an AI", "Based on the context", or "Internal system details".
20
- - Speak like a real human agent.
21
-
22
- Context from Knowledge Base:
23
- {context}
24
-
25
- If the context helps, use it. If not, use your general knowledge but be careful not to hallucinate DStv specifics.
26
- If you don't know, suggest they check self-service on dstv.com or contact our call center.
27
- """
28
- return base_prompt.replace("{context}", context)
29
-
30
- def chat_stream(self, messages: List[Dict[str, str]], context: str = "") -> Generator[str, None, None]:
31
- system_msg = {"role": "system", "content": self.get_system_prompt(context)}
32
- full_messages = [system_msg] + messages
33
-
34
- stream = self.client.chat.completions.create(
35
- model=self.model,
36
- messages=full_messages,
37
- stream=True,
38
- temperature=0.4 # Lower temperature for support accuracy
39
- )
40
-
41
- for chunk in stream:
42
- if chunk.choices[0].delta.content:
43
- yield chunk.choices[0].delta.content
44
-
45
- llm_service = LLMService()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
app/services/rag.py DELETED
@@ -1,112 +0,0 @@
1
- import chromadb
2
- from chromadb.utils import embedding_functions
3
- import requests
4
- from bs4 import BeautifulSoup
5
- import uuid
6
- import os
7
- from pydantic_settings import BaseSettings
8
- from pypdf import PdfReader
9
-
10
- class RAGService:
11
- def __init__(self):
12
- # Persistent storage in ./data/chroma (relative to root now)
13
- self.client = chromadb.PersistentClient(path="./data/chroma")
14
-
15
- self.embedding_fn = embedding_functions.DefaultEmbeddingFunction()
16
-
17
- self.collection = self.client.get_or_create_collection(
18
- name="dstv_knowledge",
19
- embedding_function=self.embedding_fn
20
- )
21
- # Auto ingest PDFs on startup
22
- self.ingest_all_pdfs()
23
-
24
- def ingest_pdf(self, file_path: str):
25
- try:
26
- print(f"Ingesting PDF: {file_path}")
27
- reader = PdfReader(file_path)
28
- text_chunks = []
29
-
30
- for i, page in enumerate(reader.pages):
31
- text = page.extract_text()
32
- if text and len(text) > 50:
33
- # In a real app, we'd split large pages.
34
- # Assuming quick guides have less dense text per page or acceptable length.
35
- text_chunks.append(text)
36
-
37
- if not text_chunks:
38
- return False
39
-
40
- ids = [f"{os.path.basename(file_path)}_page_{i}" for i in range(len(text_chunks))]
41
- metadatas = [{"source": file_path, "page": i} for i in range(len(text_chunks))]
42
-
43
- self.collection.add(
44
- documents=text_chunks,
45
- ids=ids,
46
- metadatas=metadatas
47
- )
48
- print(f"Ingested {len(text_chunks)} pages from {file_path}")
49
- return True
50
- except Exception as e:
51
- print(f"Error ingesting PDF {file_path}: {e}")
52
- return False
53
-
54
- def ingest_all_pdfs(self, root_dir: str = "."):
55
- for file in os.listdir(root_dir):
56
- if file.lower().endswith(".pdf"):
57
- # Check if already ingested (naive check by ID of page 0)
58
- try:
59
- existing = self.collection.get(ids=[f"{file}_page_0"])
60
- if existing and existing['ids']:
61
- print(f"PDF {file} already found in DB.")
62
- continue
63
- except:
64
- pass
65
-
66
- self.ingest_pdf(os.path.join(root_dir, file))
67
-
68
- def scrape_and_ingest(self, url: str):
69
- try:
70
- print(f"Scraping {url}...")
71
- headers = {'User-Agent': 'Mozilla/5.0 (compatible; DStvBot/1.0)'}
72
- response = requests.get(url, headers=headers, timeout=10)
73
- if response.status_code != 200:
74
- print(f"Failed to fetch {url}: {response.status_code}")
75
- return False
76
-
77
- soup = BeautifulSoup(response.text, 'html.parser')
78
-
79
- text_blocks = []
80
- for tag in soup.find_all(['p', 'h1', 'h2', 'h3', 'li']):
81
- text = tag.get_text(strip=True)
82
- if len(text) > 30:
83
- text_blocks.append(text)
84
-
85
- if not text_blocks:
86
- return False
87
-
88
- ids = [str(uuid.uuid4()) for _ in text_blocks]
89
- metadatas = [{"source": url} for _ in text_blocks]
90
-
91
- self.collection.add(
92
- documents=text_blocks,
93
- ids=ids,
94
- metadatas=metadatas
95
- )
96
- print(f"Ingested {len(text_blocks)} chunks from {url}")
97
- return True
98
-
99
- except Exception as e:
100
- print(f"Error ingesting {url}: {e}")
101
- return False
102
-
103
- def query(self, query_text: str, n_results: int = 3):
104
- results = self.collection.query(
105
- query_texts=[query_text],
106
- n_results=n_results
107
- )
108
- if results["documents"]:
109
- return results["documents"][0]
110
- return []
111
-
112
- rag_service = RAGService()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
{frontend/src/assets → assets}/logo.jpeg RENAMED
File without changes
dstv-explora-quick-guide.pdf → data/dstv-explora-quick-guide.pdf RENAMED
File without changes
dstvhd6s_quickguide_v22_e_dec2020.pdf → data/dstvhd6s_quickguide_v22_e_dec2020.pdf RENAMED
File without changes
docker-compose.yml CHANGED
@@ -1,34 +1,14 @@
1
  version: '3.8'
2
 
3
  services:
4
- backend:
5
  build: .
6
- container_name: dstv_backend
7
  ports:
8
- - "8000:8000"
 
 
9
  volumes:
10
- - .:/app
11
- environment:
12
- - XAI_API_KEY=${XAI_API_KEY}
13
- - REDIS_HOST=redis
14
- depends_on:
15
- - redis
16
-
17
- frontend:
18
- build:
19
- context: ./frontend
20
- dockerfile: Dockerfile
21
- container_name: dstv_frontend
22
- ports:
23
- - "5173:5173"
24
- volumes:
25
- - ./frontend:/app
26
- - /app/node_modules
27
- environment:
28
- - VITE_API_URL=http://localhost:8000
29
-
30
- redis:
31
- image: redis:alpine
32
- container_name: dstv_redis
33
- ports:
34
- - "6379:6379"
 
1
  version: '3.8'
2
 
3
  services:
4
+ dstv-ai-support:
5
  build: .
 
6
  ports:
7
+ - "7860:7860"
8
+ env_file:
9
+ - .env
10
  volumes:
11
+ - ./data:/app/data
12
+ - ./chroma_db:/app/chroma_db
13
+ - ./memory.db:/app/memory.db
14
+ restart: always
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/.dockerignore DELETED
@@ -1,33 +0,0 @@
1
- # Dependencies
2
- node_modules
3
- npm-debug.log*
4
- yarn-debug.log*
5
- yarn-error.log*
6
- pnpm-debug.log*
7
-
8
- # Build output
9
- dist
10
- build
11
- .vite
12
-
13
- # Environment files
14
- .env
15
- .env.local
16
- .env.*.local
17
-
18
- # Git
19
- .git
20
- .gitignore
21
-
22
- # IDE
23
- .vscode
24
- .idea
25
- *.swp
26
- *.swo
27
-
28
- # OS
29
- .DS_Store
30
- Thumbs.db
31
-
32
- # Misc
33
- README.md
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/Dockerfile DELETED
@@ -1,18 +0,0 @@
1
- FROM node:18-alpine
2
-
3
- WORKDIR /app
4
-
5
- # Copy package files first for better layer caching
6
- COPY package*.json ./
7
-
8
- # Install dependencies using npm install (faster and more reliable in this env than npm ci)
9
- RUN npm install
10
-
11
- # Copy source code
12
- COPY . .
13
-
14
- # Expose Vite dev server port
15
- EXPOSE 5173
16
-
17
- # Start dev server with host binding for Docker
18
- CMD ["npm", "run", "dev", "--", "--host"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/package-lock.json DELETED
The diff for this file is too large to render. See raw diff
 
frontend/package.json CHANGED
@@ -5,7 +5,7 @@
5
  "type": "module",
6
  "scripts": {
7
  "dev": "vite",
8
- "build": "vite build",
9
  "lint": "eslint .",
10
  "preview": "vite preview"
11
  },
@@ -19,13 +19,10 @@
19
  "@types/react": "^19.2.5",
20
  "@types/react-dom": "^19.2.3",
21
  "@vitejs/plugin-react": "^5.1.1",
22
- "autoprefixer": "^10.4.23",
23
  "eslint": "^9.39.1",
24
  "eslint-plugin-react-hooks": "^7.0.1",
25
  "eslint-plugin-react-refresh": "^0.4.24",
26
  "globals": "^16.5.0",
27
- "postcss": "^8.5.6",
28
- "tailwindcss": "^4.1.18",
29
  "typescript": "~5.9.3",
30
  "typescript-eslint": "^8.46.4",
31
  "vite": "^7.2.4"
 
5
  "type": "module",
6
  "scripts": {
7
  "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
  "lint": "eslint .",
10
  "preview": "vite preview"
11
  },
 
19
  "@types/react": "^19.2.5",
20
  "@types/react-dom": "^19.2.3",
21
  "@vitejs/plugin-react": "^5.1.1",
 
22
  "eslint": "^9.39.1",
23
  "eslint-plugin-react-hooks": "^7.0.1",
24
  "eslint-plugin-react-refresh": "^0.4.24",
25
  "globals": "^16.5.0",
 
 
26
  "typescript": "~5.9.3",
27
  "typescript-eslint": "^8.46.4",
28
  "vite": "^7.2.4"
frontend/postcss.config.js DELETED
@@ -1,6 +0,0 @@
1
- export default {
2
- plugins: {
3
- tailwindcss: {},
4
- autoprefixer: {},
5
- },
6
- }
 
 
 
 
 
 
 
logo.jpeg → frontend/public/logo.jpeg RENAMED
File without changes
frontend/src/App.css ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #root {
2
+ max-width: 1280px;
3
+ margin: 0 auto;
4
+ padding: 2rem;
5
+ text-align: center;
6
+ }
7
+
8
+ .logo {
9
+ height: 6em;
10
+ padding: 1.5em;
11
+ will-change: filter;
12
+ transition: filter 300ms;
13
+ }
14
+ .logo:hover {
15
+ filter: drop-shadow(0 0 2em #646cffaa);
16
+ }
17
+ .logo.react:hover {
18
+ filter: drop-shadow(0 0 2em #61dafbaa);
19
+ }
20
+
21
+ @keyframes logo-spin {
22
+ from {
23
+ transform: rotate(0deg);
24
+ }
25
+ to {
26
+ transform: rotate(360deg);
27
+ }
28
+ }
29
+
30
+ @media (prefers-reduced-motion: no-preference) {
31
+ a:nth-of-type(2) .logo {
32
+ animation: logo-spin infinite 20s linear;
33
+ }
34
+ }
35
+
36
+ .card {
37
+ padding: 2em;
38
+ }
39
+
40
+ .read-the-docs {
41
+ color: #888;
42
+ }
frontend/src/App.tsx CHANGED
@@ -1,11 +1,140 @@
1
- import ChatInterface from './components/ChatInterface';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
- function App() {
4
  return (
5
- <div className="App w-full h-full bg-dstv-black">
6
- <ChatInterface />
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  </div>
8
  );
9
- }
10
 
11
  export default App;
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Send, User, Bot, Loader2 } from 'lucide-react';
3
+ import ReactMarkdown from 'react-markdown';
4
+ import axios from 'axios';
5
+
6
+ interface Message {
7
+ role: 'user' | 'assistant';
8
+ content: string;
9
+ }
10
+
11
+ const App: React.FC = () => {
12
+ const [messages, setMessages] = useState<Message[]>([]);
13
+ const [input, setInput] = useState('');
14
+ const [isLoading, setIsLoading] = useState(false);
15
+ const [sessionId, setSessionId] = useState<string | null>(null);
16
+ const chatEndRef = useRef<HTMLDivElement>(null);
17
+
18
+ useEffect(() => {
19
+ // Initialize session
20
+ const initSession = async () => {
21
+ try {
22
+ const response = await axios.get('http://localhost:8000/session');
23
+ setSessionId(response.data.session_id);
24
+ } catch (error) {
25
+ console.error('Failed to initialize session:', error);
26
+ }
27
+ };
28
+ initSession();
29
+ }, []);
30
+
31
+ useEffect(() => {
32
+ chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
33
+ }, [messages]);
34
+
35
+ const handleSend = async () => {
36
+ if (!input.trim() || isLoading) return;
37
+
38
+ const userMessage = input.trim();
39
+ setInput('');
40
+ setMessages(prev => [...prev, { role: 'user', content: userMessage }]);
41
+ setIsLoading(true);
42
+
43
+ try {
44
+ const response = await fetch('http://localhost:8000/chat', {
45
+ method: 'POST',
46
+ headers: {
47
+ 'Content-Type': 'application/json',
48
+ },
49
+ body: JSON.stringify({
50
+ message: userMessage,
51
+ session_id: sessionId,
52
+ }),
53
+ });
54
+
55
+ if (!response.ok) throw new Error('Failed to send message');
56
+ if (!response.body) throw new Error('No response body');
57
+
58
+ const reader = response.body.getReader();
59
+ const decoder = new TextDecoder();
60
+ let assistantContent = '';
61
+
62
+ setMessages(prev => [...prev, { role: 'assistant', content: '' }]);
63
+
64
+ while (true) {
65
+ const { done, value } = await reader.read();
66
+ if (done) break;
67
+
68
+ const chunk = decoder.decode(value, { stream: true });
69
+ assistantContent += chunk;
70
+
71
+ setMessages(prev => {
72
+ const newMessages = [...prev];
73
+ newMessages[newMessages.length - 1].content = assistantContent;
74
+ return newMessages;
75
+ });
76
+ }
77
+ } catch (error) {
78
+ console.error('Chat error:', error);
79
+ setMessages(prev => [...prev, { role: 'assistant', content: 'I apologize, but I encountered an error. Please try again or contact support if the issue persists.' }]);
80
+ } finally {
81
+ setIsLoading(false);
82
+ }
83
+ };
84
 
 
85
  return (
86
+ <div className="app-container">
87
+ <header className="header">
88
+ <img src="/logo.jpeg" alt="DStv Logo" className="logo" />
89
+ <h1>DStv AI Support</h1>
90
+ </header>
91
+
92
+ <div className="chat-window">
93
+ {messages.length === 0 && (
94
+ <div className="message assistant">
95
+ <p>Hello! I'm your DStv AI Support assistant. How can I help you today?</p>
96
+ </div>
97
+ )}
98
+ {messages.map((msg, index) => (
99
+ <div key={index} className={`message ${msg.role}`}>
100
+ <div className="flex items-center gap-2 mb-2 font-bold text-xs opacity-70">
101
+ {msg.role === 'user' ? <User size={14} /> : <Bot size={14} />}
102
+ {msg.role === 'user' ? 'You' : 'DStv Support'}
103
+ </div>
104
+ <div className="markdown-content">
105
+ <ReactMarkdown>{msg.content}</ReactMarkdown>
106
+ </div>
107
+ </div>
108
+ ))}
109
+ {isLoading && messages[messages.length - 1]?.role !== 'assistant' && (
110
+ <div className="message assistant">
111
+ <div className="typing-animation">
112
+ <div className="dot"></div>
113
+ <div className="dot"></div>
114
+ <div className="dot"></div>
115
+ </div>
116
+ </div>
117
+ )}
118
+ <div ref={chatEndRef} />
119
+ </div>
120
+
121
+ <div className="input-area">
122
+ <div className="input-wrapper">
123
+ <input
124
+ type="text"
125
+ value={input}
126
+ onChange={(e) => setInput(e.target.value)}
127
+ onKeyPress={(e) => e.key === 'Enter' && handleSend()}
128
+ placeholder="Type your message here..."
129
+ disabled={isLoading}
130
+ />
131
+ </div>
132
+ <button onClick={handleSend} disabled={isLoading || !input.trim()}>
133
+ {isLoading ? <Loader2 className="animate-spin" /> : <Send size={20} />}
134
+ </button>
135
+ </div>
136
  </div>
137
  );
138
+ };
139
 
140
  export default App;
frontend/src/components/ChatInterface.tsx DELETED
@@ -1,153 +0,0 @@
1
- import React, { useState, useRef, useEffect } from 'react';
2
- import logo from '../assets/logo.jpeg';
3
-
4
- interface Message {
5
- id: string;
6
- role: 'user' | 'assistant';
7
- content: string;
8
- }
9
-
10
- export default function ChatInterface() {
11
- const [messages, setMessages] = useState<Message[]>([
12
- { id: '1', role: 'assistant', content: 'Hello! I\'m your DStv guide. I can help with subscriptions, payments, technical issues, and more. How can I assist you today?' }
13
- ]);
14
- const [input, setInput] = useState('');
15
- const [isLoading, setIsLoading] = useState(false);
16
- const messagesEndRef = useRef<HTMLDivElement>(null);
17
-
18
- const scrollToBottom = () => {
19
- messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
20
- };
21
-
22
- useEffect(() => {
23
- scrollToBottom();
24
- }, [messages]);
25
-
26
- const sendMessage = async () => {
27
- if (!input.trim()) return;
28
-
29
- const userMsg: Message = { id: Date.now().toString(), role: 'user', content: input };
30
- setMessages(prev => [...prev, userMsg]);
31
- setInput('');
32
- setIsLoading(true);
33
-
34
- try {
35
- const response = await fetch(`${import.meta.env.VITE_API_URL || 'http://localhost:8000'}/api/chat`, {
36
- method: 'POST',
37
- headers: {
38
- 'Content-Type': 'application/json',
39
- },
40
- body: JSON.stringify({
41
- messages: [...messages, userMsg].map(m => ({ role: m.role, content: m.content }))
42
- }),
43
- });
44
-
45
- if (!response.ok) throw new Error('Network response was not ok');
46
- if (!response.body) return;
47
-
48
- const reader = response.body.getReader();
49
- const decoder = new TextDecoder();
50
- const aiMsgId = (Date.now() + 1).toString();
51
-
52
- // Add empty AI message first
53
- setMessages(prev => [...prev, { id: aiMsgId, role: 'assistant', content: '' }]);
54
-
55
- let accumulatedContent = '';
56
-
57
- while (true) {
58
- const { done, value } = await reader.read();
59
- if (done) break;
60
-
61
- const chunk = decoder.decode(value, { stream: true });
62
- accumulatedContent += chunk;
63
-
64
- setMessages(prev => prev.map(msg =>
65
- msg.id === aiMsgId ? { ...msg, content: accumulatedContent } : msg
66
- ));
67
- }
68
- } catch (error) {
69
- console.error('Error sending message:', error);
70
- setMessages(prev => [...prev, { id: Date.now().toString(), role: 'assistant', content: "Sorry, I'm having trouble connecting to the server. Please try again later." }]);
71
- } finally {
72
- setIsLoading(false);
73
- }
74
- };
75
-
76
- return (
77
- <div className="flex flex-col h-screen bg-dstv-black text-white font-sans max-w-md mx-auto shadow-2xl overflow-hidden border-x border-dstv-dark-gray sm:max-w-2xl md:max-w-4xl lg:max-w-6xl xl:max-w-full">
78
- {/* Header */}
79
- <header className="bg-dstv-blue p-4 flex items-center shadow-lg z-10">
80
- <img src={logo} alt="DStv Logo" className="h-8 md:h-10 mr-3 rounded-sm" />
81
- <div>
82
- <h1 className="font-bold text-lg md:text-xl tracking-wide">DStv Support</h1>
83
- <span className="text-xs text-blue-100 opacity-90 block">Always here for you</span>
84
- </div>
85
- </header>
86
-
87
- {/* Chat Area */}
88
- <div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gradient-to-b from-dstv-black to-dstv-dark-gray backdrop-blur-sm">
89
- {messages.map((msg) => (
90
- <div
91
- key={msg.id}
92
- className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} animate-fadeIn`}
93
- >
94
- <div
95
- className={`max-w-[80%] md:max-w-[70%] p-3 md:p-4 rounded-2xl text-sm md:text-base leading-relaxed shadow-sm ${msg.role === 'user'
96
- ? 'bg-dstv-blue text-white rounded-br-none'
97
- : 'bg-zinc-800 text-gray-100 rounded-bl-none border border-zinc-700'
98
- }`}
99
- >
100
- {msg.content.split('\n').map((paragraph, idx) => (
101
- <p key={idx} className={idx > 0 ? 'mt-3' : ''}>{paragraph}</p>
102
- ))}
103
- </div>
104
- </div>
105
- ))}
106
- {isLoading && (
107
- <div className="flex justify-start">
108
- <div className="bg-zinc-800 p-3 rounded-2xl rounded-bl-none border border-zinc-700 flex space-x-2 items-center">
109
- <div className="w-2 h-2 bg-dstv-blue rounded-full animate-bounce" style={{ animationDelay: '0ms' }}></div>
110
- <div className="w-2 h-2 bg-dstv-blue rounded-full animate-bounce" style={{ animationDelay: '150ms' }}></div>
111
- <div className="w-2 h-2 bg-dstv-blue rounded-full animate-bounce" style={{ animationDelay: '300ms' }}></div>
112
- </div>
113
- </div>
114
- )}
115
- <div ref={messagesEndRef} />
116
- </div>
117
-
118
- {/* Input Area */}
119
- <div className="p-4 bg-dstv-dark-gray border-t border-zinc-800">
120
- <div className="flex items-end space-x-2 bg-zinc-900 p-2 rounded-xl border border-zinc-700 focus-within:border-dstv-blue transition-colors">
121
- <textarea
122
- value={input}
123
- onChange={(e) => setInput(e.target.value)}
124
- onKeyDown={(e) => {
125
- if (e.key === 'Enter' && !e.shiftKey) {
126
- e.preventDefault();
127
- sendMessage();
128
- }
129
- }}
130
- placeholder="Type your message..."
131
- className="flex-1 bg-transparent border-none text-white focus:ring-0 resize-none max-h-32 min-h-[44px] py-1 px-2 placeholder-zinc-500"
132
- rows={1}
133
- />
134
- <button
135
- onClick={sendMessage}
136
- disabled={!input.trim() || isLoading}
137
- className={`p-2 rounded-lg transition-all duration-200 ${input.trim() && !isLoading
138
- ? 'bg-dstv-blue text-white hover:bg-blue-600 shadow-lg shadow-blue-500/20'
139
- : 'bg-zinc-700 text-zinc-500 cursor-not-allowed'
140
- }`}
141
- >
142
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" className="w-6 h-6 transform rotate-[-45deg] relative left-0.5 top-[-2px]">
143
- <path d="M3.478 2.404a.75.75 0 00-.926.941l2.432 7.905H13.5a.75.75 0 010 1.5H4.984l-2.432 7.905a.75.75 0 00.926.94 60.519 60.519 0 0018.445-8.986.75.75 0 000-1.218A60.517 60.517 0 003.478 2.404z" />
144
- </svg>
145
- </button>
146
- </div>
147
- <div className="text-center mt-2">
148
- <p className="text-[10px] text-zinc-500">AI responses may vary. Check DStv.com for official info.</p>
149
- </div>
150
- </div>
151
- </div>
152
- );
153
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/index.css CHANGED
@@ -1,19 +1,184 @@
1
- @tailwind base;
2
- @tailwind components;
3
- @tailwind utilities;
4
 
5
- @layer base {
6
- body {
7
- @apply bg-dstv-black text-white font-sans;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  }
10
 
11
- @layer utilities {
12
- .scrollbar-hide::-webkit-scrollbar {
13
- display: none;
14
- }
15
- .scrollbar-hide {
16
- -ms-overflow-style: none;
17
- scrollbar-width: none;
18
- }
19
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
 
 
2
 
3
+ :root {
4
+ --primary: #1B8DC6;
5
+ --primary-dark: #1573a0;
6
+ --secondary: #000000;
7
+ --bg-color: #f8fafc;
8
+ --text-main: #1e293b;
9
+ --text-muted: #64748b;
10
+ --glass-bg: rgba(255, 255, 255, 0.8);
11
+ --glass-border: rgba(255, 255, 255, 0.3);
12
+ --shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
13
+ }
14
+
15
+ * {
16
+ margin: 0;
17
+ padding: 0;
18
+ box-sizing: border-box;
19
+ }
20
+
21
+ body {
22
+ font-family: 'Inter', sans-serif;
23
+ background-color: var(--bg-color);
24
+ color: var(--text-main);
25
+ line-height: 1.6;
26
+ }
27
+
28
+ .app-container {
29
+ display: flex;
30
+ flex-direction: column;
31
+ height: 100vh;
32
+ max-width: 1000px;
33
+ margin: 0 auto;
34
+ background: white;
35
+ box-shadow: var(--shadow);
36
+ }
37
+
38
+ .header {
39
+ display: flex;
40
+ align-items: center;
41
+ padding: 1rem 2rem;
42
+ background: var(--secondary);
43
+ color: white;
44
+ border-bottom: 2px solid var(--primary);
45
+ }
46
+
47
+ .logo {
48
+ height: 40px;
49
+ margin-right: 1rem;
50
+ }
51
+
52
+ .chat-window {
53
+ flex: 1;
54
+ overflow-y: auto;
55
+ padding: 1.5rem;
56
+ display: flex;
57
+ flex-direction: column;
58
+ gap: 1.5rem;
59
+ background: #f1f5f9;
60
+ }
61
+
62
+ .message {
63
+ max-width: 80%;
64
+ padding: 1rem 1.25rem;
65
+ border-radius: 1rem;
66
+ position: relative;
67
+ animation: fadeIn 0.3s ease-out;
68
+ }
69
+
70
+ @keyframes fadeIn {
71
+ from {
72
+ opacity: 0;
73
+ transform: translateY(10px);
74
  }
75
+
76
+ to {
77
+ opacity: 1;
78
+ transform: translateY(0);
79
+ }
80
+ }
81
+
82
+ .message.user {
83
+ align-self: flex-end;
84
+ background: var(--primary);
85
+ color: white;
86
+ border-bottom-right-radius: 0.25rem;
87
+ }
88
+
89
+ .message.assistant {
90
+ align-self: flex-start;
91
+ background: white;
92
+ color: var(--text-main);
93
+ border-bottom-left-radius: 0.25rem;
94
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
95
+ }
96
+
97
+ .input-area {
98
+ padding: 1.5rem;
99
+ background: white;
100
+ border-top: 1px solid #e2e8f0;
101
+ display: flex;
102
+ gap: 1rem;
103
+ }
104
+
105
+ .input-wrapper {
106
+ flex: 1;
107
+ position: relative;
108
  }
109
 
110
+ input {
111
+ width: 100%;
112
+ padding: 0.75rem 1rem;
113
+ border: 1.5px solid #cbd5e1;
114
+ border-radius: 0.75rem;
115
+ font-size: 1rem;
116
+ outline: none;
117
+ transition: border-color 0.2s;
118
  }
119
+
120
+ input:focus {
121
+ border-color: var(--primary);
122
+ }
123
+
124
+ button {
125
+ background: var(--primary);
126
+ color: white;
127
+ border: none;
128
+ padding: 0.75rem 1.5rem;
129
+ border-radius: 0.75rem;
130
+ font-weight: 600;
131
+ cursor: pointer;
132
+ transition: background 0.2s;
133
+ }
134
+
135
+ button:hover {
136
+ background: var(--primary-dark);
137
+ }
138
+
139
+ button:disabled {
140
+ background: #94a3b8;
141
+ cursor: not-allowed;
142
+ }
143
+
144
+ .typing-animation {
145
+ display: flex;
146
+ gap: 4px;
147
+ }
148
+
149
+ .dot {
150
+ width: 8px;
151
+ height: 8px;
152
+ background: var(--text-muted);
153
+ border-radius: 50%;
154
+ animation: bounce 1.4s infinite ease-in-out both;
155
+ }
156
+
157
+ .dot:nth-child(1) {
158
+ animation-delay: -0.32s;
159
+ }
160
+
161
+ .dot:nth-child(2) {
162
+ animation-delay: -0.16s;
163
+ }
164
+
165
+ @keyframes bounce {
166
+
167
+ 0%,
168
+ 80%,
169
+ 100% {
170
+ transform: scale(0);
171
+ }
172
+
173
+ 40% {
174
+ transform: scale(1);
175
+ }
176
+ }
177
+
178
+ .markdown-content p {
179
+ margin-bottom: 1rem;
180
+ }
181
+
182
+ .markdown-content p:last-child {
183
+ margin-bottom: 0;
184
+ }
frontend/tailwind.config.js DELETED
@@ -1,18 +0,0 @@
1
- /** @type {import('tailwindcss').Config} */
2
- export default {
3
- content: [
4
- "./index.html",
5
- "./src/**/*.{js,ts,jsx,tsx}",
6
- ],
7
- theme: {
8
- extend: {
9
- colors: {
10
- 'dstv-blue': '#1B8DC6',
11
- 'dstv-black': '#000000',
12
- 'dstv-dark-gray': '#121212',
13
- 'dstv-light-gray': '#E5E5E5',
14
- },
15
- },
16
- },
17
- plugins: [],
18
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
grok_client.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import httpx
4
+ from typing import AsyncGenerator, List, Dict
5
+ from dotenv import load_dotenv
6
+
7
+ load_dotenv()
8
+
9
+ class GrokClient:
10
+ def __init__(self):
11
+ self.api_key = os.getenv("XAI_API_KEY")
12
+ self.base_url = "https://api.x.ai/v1/chat/completions"
13
+ self.model = "grok-4-1-fast-reasoning" # Model specified by user
14
+
15
+ async def stream_chat(self, messages: List[Dict[str, str]]) -> AsyncGenerator[str, None]:
16
+ headers = {
17
+ "Authorization": f"Bearer {self.api_key}",
18
+ "Content-Type": "application/json"
19
+ }
20
+
21
+ payload = {
22
+ "model": self.model,
23
+ "messages": messages,
24
+ "stream": True,
25
+ "temperature": 0.7
26
+ }
27
+
28
+ async with httpx.AsyncClient(timeout=60.0) as client:
29
+ async with client.stream("POST", self.base_url, headers=headers, json=payload) as response:
30
+ if response.status_code != 200:
31
+ error_detail = await response.aread()
32
+ yield f"Error: {response.status_code} - {error_detail.decode()}"
33
+ return
34
+
35
+ async for line in response.aiter_lines():
36
+ if line.startswith("data: "):
37
+ data_str = line[6:].strip()
38
+ if data_str == "[DONE]":
39
+ break
40
+ try:
41
+ data = json.loads(data_str)
42
+ content = data["choices"][0].get("delta", {}).get("content", "")
43
+ if content:
44
+ yield content
45
+ except json.JSONDecodeError:
46
+ continue
47
+
48
+ grok_client = GrokClient()
main.py ADDED
@@ -0,0 +1,104 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import uuid
3
+ from fastapi import FastAPI, HTTPException, Request
4
+ from fastapi.responses import StreamingResponse
5
+ from fastapi.middleware.cors import CORSMiddleware
6
+ from fastapi.staticfiles import StaticFiles
7
+ from pydantic import BaseModel
8
+ from typing import Optional, List
9
+
10
+ from rag_engine import rag_engine
11
+ from grok_client import grok_client
12
+ from memory_manager import memory_manager
13
+ from search_tool import search_tool
14
+
15
+ app = FastAPI(title="DStv AI Support API")
16
+
17
+ # Enable CORS for frontend development
18
+ app.add_middleware(
19
+ CORSMiddleware,
20
+ allow_origins=["*"],
21
+ allow_credentials=True,
22
+ allow_methods=["*"],
23
+ allow_headers=["*"],
24
+ )
25
+
26
+ # Serve static files from frontend/dist
27
+ # This will be used in production (Docker)
28
+ if os.path.exists("frontend/dist"):
29
+ app.mount("/", StaticFiles(directory="frontend/dist", html=True), name="static")
30
+
31
+ class ChatRequest(BaseModel):
32
+ message: str
33
+ session_id: Optional[str] = None
34
+
35
+ SYSTEM_PROMPT = """You are DStv AI Support, a helpful and professional customer support representative for DStv.
36
+ Your tone is friendly, clear, practical, and human.
37
+ Rules:
38
+ 1. Never say "as an AI".
39
+ 2. Never mention internal systems.
40
+ 3. Speak like real DStv support (e.g., "I can help you with that", "Let me check our guides for you").
41
+ 4. Use the provided context to answer accurately.
42
+ 5. If you cannot find the answer in the context or search results, politely inform the user and suggest they contact DStv support via official channels like the DStv app or WhatsApp.
43
+
44
+ When answering, focus on providing direct, actionable advice to the user.
45
+ """
46
+
47
+ @app.post("/chat")
48
+ async def chat_endpoint(request: ChatRequest):
49
+ session_id = request.session_id or str(uuid.uuid4())
50
+ user_message = request.message
51
+
52
+ # 1. Search RAG
53
+ rag_results = rag_engine.query(user_message)
54
+ context = ""
55
+
56
+ if rag_results:
57
+ context = "\n".join([doc.page_content for doc in rag_results])
58
+
59
+ # 2. If RAG results are weak or not found, try web search fallback
60
+ if not context or len(context) < 100:
61
+ web_results = search_tool.search_web(user_message)
62
+ if web_results:
63
+ context += "\n\nWeb Search Results:\n"
64
+ for res in web_results:
65
+ context += f"- {res['title']}: {res['snippet']}\n"
66
+
67
+ # 3. Retrieve Memory
68
+ history = memory_manager.get_history(session_id)
69
+
70
+ # 4. Prepare messages for Grok
71
+ current_context_prompt = f"Context Information:\n{context}\n\nUser Question: {user_message}"
72
+
73
+ messages = [{"role": "system", "content": SYSTEM_PROMPT}]
74
+ # Add relevant history (last 5 exchanges for context)
75
+ for msg in history[-10:]:
76
+ messages.append(msg)
77
+
78
+ messages.append({"role": "user", "content": current_context_prompt})
79
+
80
+ # Save user message to memory
81
+ memory_manager.save_message(session_id, "user", user_message)
82
+
83
+ async def response_generator():
84
+ full_response = ""
85
+ async for chunk in grok_client.stream_chat(messages):
86
+ full_response += chunk
87
+ yield chunk
88
+
89
+ # Save assistant response to memory after stream finishes
90
+ memory_manager.save_message(session_id, "assistant", full_response)
91
+
92
+ return StreamingResponse(response_generator(), media_type="text/event-stream")
93
+
94
+ @app.get("/session")
95
+ async def create_session():
96
+ return {"session_id": str(uuid.uuid4())}
97
+
98
+ @app.get("/health")
99
+ async def health_check():
100
+ return {"status": "healthy"}
101
+
102
+ if __name__ == "__main__":
103
+ import uvicorn
104
+ uvicorn.run(app, host="0.0.0.0", port=8000)
memory_manager.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import json
3
+ from typing import List, Dict
4
+
5
+ class MemoryManager:
6
+ def __init__(self, db_path: str = "memory.db"):
7
+ self.db_path = db_path
8
+ self._init_db()
9
+
10
+ def _init_db(self):
11
+ with sqlite3.connect(self.db_path) as conn:
12
+ conn.execute("""
13
+ CREATE TABLE IF NOT EXISTS sessions (
14
+ session_id TEXT PRIMARY KEY,
15
+ history TEXT
16
+ )
17
+ """)
18
+ conn.commit()
19
+
20
+ def get_history(self, session_id: str) -> List[Dict[str, str]]:
21
+ with sqlite3.connect(self.db_path) as conn:
22
+ cursor = conn.execute("SELECT history FROM sessions WHERE session_id = ?", (session_id,))
23
+ row = cursor.fetchone()
24
+ if row:
25
+ return json.loads(row[0])
26
+ return []
27
+
28
+ def save_message(self, session_id: str, role: str, content: str):
29
+ history = self.get_history(session_id)
30
+ history.append({"role": role, "content": content})
31
+
32
+ # Keep only last 10 messages for context window management if needed,
33
+ # but the prompt says persistent history.
34
+
35
+ with sqlite3.connect(self.db_path) as conn:
36
+ conn.execute("""
37
+ INSERT OR REPLACE INTO sessions (session_id, history)
38
+ VALUES (?, ?)
39
+ """, (session_id, json.dumps(history)))
40
+ conn.commit()
41
+
42
+ def clear_session(self, session_id: str):
43
+ with sqlite3.connect(self.db_path) as conn:
44
+ conn.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,))
45
+ conn.commit()
46
+
47
+ memory_manager = MemoryManager()
rag_engine.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import List
3
+ from langchain_community.document_loaders import PyPDFLoader
4
+ from langchain.text_splitter import RecursiveCharacterTextSplitter
5
+ from langchain_community.embeddings import HuggingFaceEmbeddings
6
+ from langchain_community.vectorstores import Chroma
7
+ from langchain.schema import Document
8
+
9
+ class RAGEngine:
10
+ def __init__(self, data_dir: str = "data", db_dir: str = "chroma_db"):
11
+ self.data_dir = data_dir
12
+ self.db_dir = db_dir
13
+ self.embeddings = HuggingFaceEmbeddings(model_name="all-MiniLM-L6-v2")
14
+ self.vector_store = None
15
+ self._initialize_vector_store()
16
+
17
+ def _initialize_vector_store(self):
18
+ if not os.path.exists(self.db_dir):
19
+ os.makedirs(self.db_dir)
20
+ self._process_documents()
21
+ else:
22
+ self.vector_store = Chroma(
23
+ persist_directory=self.db_dir,
24
+ embedding_function=self.embeddings
25
+ )
26
+
27
+ def _process_documents(self):
28
+ documents = []
29
+ for file in os.listdir(self.data_dir):
30
+ if file.endswith(".pdf"):
31
+ file_path = os.path.join(self.data_dir, file)
32
+ loader = PyPDFLoader(file_path)
33
+ documents.extend(loader.load())
34
+
35
+ text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
36
+ chunks = text_splitter.split_documents(documents)
37
+
38
+ self.vector_store = Chroma.from_documents(
39
+ documents=chunks,
40
+ embedding=self.embeddings,
41
+ persist_directory=self.db_dir
42
+ )
43
+ self.vector_store.persist()
44
+
45
+ def query(self, text: str, k: int = 3) -> List[Document]:
46
+ if not self.vector_store:
47
+ return []
48
+ return self.vector_store.similarity_search(text, k=k)
49
+
50
+ rag_engine = RAGEngine()
requirements.txt CHANGED
@@ -1,17 +1,16 @@
1
- fastapi
2
- uvicorn
3
- python-multipart
4
- # Networking
5
- requests
6
- httpx
7
- # AI / LLM
8
- openai
9
- # Database / Vector Store
10
- chromadb
11
- redis
12
- # Scraper
13
- beautifulsoup4
14
- # Utils
15
- pydantic-settings
16
- python-dotenv
17
- pypdf
 
1
+ fastapi==0.109.0
2
+ uvicorn==0.27.0
3
+ python-dotenv==1.0.1
4
+ chromadb==0.4.22
5
+ langchain==0.1.4
6
+ langchain-community==0.0.16
7
+ langchain-openai==0.0.5
8
+ pypdf==4.0.1
9
+ requests==2.31.0
10
+ beautifulsoup4==4.12.3
11
+ duckduckgo-search==4.4.2
12
+ httpx==0.26.0
13
+ pydantic==2.6.0
14
+ pydantic-settings==2.1.0
15
+ python-multipart==0.0.6
16
+ sentence-transformers==2.3.1
 
search_tool.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ from bs4 import BeautifulSoup
3
+ from duckduckgo_search import DDGS
4
+ from typing import List, Dict
5
+
6
+ class SearchTool:
7
+ def __init__(self):
8
+ self.ddgs = DDGS()
9
+
10
+ def search_web(self, query: str, max_results: int = 5) -> List[Dict[str, str]]:
11
+ results = []
12
+ try:
13
+ # specifically prioritize dstv.com
14
+ search_query = f"{query} site:dstv.com"
15
+ with DDGS() as ddgs:
16
+ for r in ddgs.text(search_query, max_results=max_results):
17
+ results.append({
18
+ "title": r['title'],
19
+ "link": r['href'],
20
+ "snippet": r['body']
21
+ })
22
+ except Exception as e:
23
+ print(f"Search error: {e}")
24
+ return results
25
+
26
+ def scrape_dstv_page(self, url: str) -> str:
27
+ try:
28
+ response = requests.get(url, timeout=10)
29
+ response.raise_for_status()
30
+ soup = BeautifulSoup(response.text, 'html.parser')
31
+
32
+ # Remove script and style elements
33
+ for script in soup(["script", "style"]):
34
+ script.decompose()
35
+
36
+ text = soup.get_text(separator=' ', strip=True)
37
+ return text[:2000] # Return first 2000 chars for context
38
+ except Exception as e:
39
+ print(f"Scrape error: {e}")
40
+ return ""
41
+
42
+ search_tool = SearchTool()