Spaces:
Sleeping
Sleeping
Commit ·
e8d69f7
1
Parent(s): ab2de89
Updated
Browse files- .dockerignore +7 -4
- Dockerfile +27 -2
- README.md +0 -11
- app/api/endpoints/chat.py +0 -51
- app/core/config.py +0 -11
- app/main.py +0 -31
- app/services/llm.py +0 -45
- app/services/rag.py +0 -112
- {frontend/src/assets → assets}/logo.jpeg +0 -0
- dstv-explora-quick-guide.pdf → data/dstv-explora-quick-guide.pdf +0 -0
- dstvhd6s_quickguide_v22_e_dec2020.pdf → data/dstvhd6s_quickguide_v22_e_dec2020.pdf +0 -0
- docker-compose.yml +8 -28
- frontend/.dockerignore +0 -33
- frontend/Dockerfile +0 -18
- frontend/package-lock.json +0 -0
- frontend/package.json +1 -4
- frontend/postcss.config.js +0 -6
- logo.jpeg → frontend/public/logo.jpeg +0 -0
- frontend/src/App.css +42 -0
- frontend/src/App.tsx +134 -5
- frontend/src/components/ChatInterface.tsx +0 -153
- frontend/src/index.css +179 -14
- frontend/tailwind.config.js +0 -18
- grok_client.py +48 -0
- main.py +104 -0
- memory_manager.py +47 -0
- rag_engine.py +50 -0
- requirements.txt +16 -17
- search_tool.py +42 -0
.dockerignore
CHANGED
|
@@ -1,6 +1,9 @@
|
|
| 1 |
-
frontend/
|
| 2 |
.git/
|
| 3 |
.gitignore
|
| 4 |
-
|
| 5 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
|
|
|
|
|
|
|
| 7 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 8 |
|
|
|
|
| 9 |
COPY . .
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 5 |
build: .
|
| 6 |
-
container_name: dstv_backend
|
| 7 |
ports:
|
| 8 |
-
- "
|
|
|
|
|
|
|
| 9 |
volumes:
|
| 10 |
-
-
|
| 11 |
-
|
| 12 |
-
-
|
| 13 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
function App() {
|
| 4 |
return (
|
| 5 |
-
<div className="
|
| 6 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
@
|
| 2 |
-
@tailwind components;
|
| 3 |
-
@tailwind utilities;
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 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-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 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()
|