Spaces:
Sleeping
Sleeping
Vineetiitg commited on
Commit ·
73ee76f
1
Parent(s): a0010e2
feat: add production config and readiness checks
Browse files- .env.example +17 -0
- .gitignore +3 -3
- app/__init__.py +1 -0
- app/core/__init__.py +1 -0
- app/core/config.py +25 -5
- app/core/dependencies.py +34 -0
- app/core/errors.py +7 -0
- app/core/logging.py +14 -0
- app/engine/__init__.py +1 -0
- app/graph/__init__.py +1 -0
- app/guardrails/__init__.py +1 -0
- app/main.py +32 -8
- app/tests/__init__.py +1 -0
- ui/app.py +12 -10
.env.example
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
PROJECT_NAME="Support Docs Copilot"
|
| 2 |
+
OLLAMA_BASE_URL=http://127.0.0.1:11434
|
| 3 |
+
OLLAMA_MODEL=llama3
|
| 4 |
+
QDRANT_URL=
|
| 5 |
+
QDRANT_LOCATION=./qdrant_data
|
| 6 |
+
COLLECTION_NAME=support_docs
|
| 7 |
+
RETRIEVAL_MODE=hybrid
|
| 8 |
+
RETRIEVAL_TOP_K=15
|
| 9 |
+
RERANKER_TOP_N=3
|
| 10 |
+
RERANKER_ENABLED=true
|
| 11 |
+
CHUNK_SIZE=500
|
| 12 |
+
CHUNK_OVERLAP=50
|
| 13 |
+
MIN_RELEVANCE_SCORE=0.0
|
| 14 |
+
MAX_CONTEXT_CHARS=12000
|
| 15 |
+
ENABLE_GUARDRAILS=true
|
| 16 |
+
ENABLE_RAG_EVAL=false
|
| 17 |
+
MAX_QUERY_LENGTH=2000
|
.gitignore
CHANGED
|
@@ -15,7 +15,7 @@ frontend.log
|
|
| 15 |
backend.log
|
| 16 |
fastembed_cache/
|
| 17 |
.cache/
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
|
|
|
|
| 15 |
backend.log
|
| 16 |
fastembed_cache/
|
| 17 |
.cache/
|
| 18 |
+
reports/*.html
|
| 19 |
+
reports/*.json
|
| 20 |
+
data/document_registry.json
|
| 21 |
|
app/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/core/config.py
CHANGED
|
@@ -1,16 +1,15 @@
|
|
| 1 |
-
import os
|
| 2 |
from pydantic_settings import BaseSettings
|
| 3 |
|
| 4 |
class Settings(BaseSettings):
|
| 5 |
PROJECT_NAME: str = "Support Docs Copilot"
|
| 6 |
|
| 7 |
# Ollama LLM Config
|
| 8 |
-
OLLAMA_BASE_URL: str =
|
| 9 |
-
OLLAMA_MODEL: str =
|
| 10 |
|
| 11 |
# Qdrant Vector DB Config
|
| 12 |
-
QDRANT_URL: str =
|
| 13 |
-
QDRANT_LOCATION: str =
|
| 14 |
COLLECTION_NAME: str = "support_docs"
|
| 15 |
|
| 16 |
# Embeddings Config
|
|
@@ -18,4 +17,25 @@ class Settings(BaseSettings):
|
|
| 18 |
SPARSE_EMBEDDING_MODEL: str = "Qdrant/bm25"
|
| 19 |
RERANKER_MODEL: str = "BAAI/bge-reranker-base"
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
settings = Settings()
|
|
|
|
|
|
|
| 1 |
from pydantic_settings import BaseSettings
|
| 2 |
|
| 3 |
class Settings(BaseSettings):
|
| 4 |
PROJECT_NAME: str = "Support Docs Copilot"
|
| 5 |
|
| 6 |
# Ollama LLM Config
|
| 7 |
+
OLLAMA_BASE_URL: str = "http://127.0.0.1:11434"
|
| 8 |
+
OLLAMA_MODEL: str = "llama3"
|
| 9 |
|
| 10 |
# Qdrant Vector DB Config
|
| 11 |
+
QDRANT_URL: str = ""
|
| 12 |
+
QDRANT_LOCATION: str = "./qdrant_data"
|
| 13 |
COLLECTION_NAME: str = "support_docs"
|
| 14 |
|
| 15 |
# Embeddings Config
|
|
|
|
| 17 |
SPARSE_EMBEDDING_MODEL: str = "Qdrant/bm25"
|
| 18 |
RERANKER_MODEL: str = "BAAI/bge-reranker-base"
|
| 19 |
|
| 20 |
+
# Retrieval Config
|
| 21 |
+
RETRIEVAL_MODE: str = "hybrid"
|
| 22 |
+
RETRIEVAL_TOP_K: int = 15
|
| 23 |
+
RERANKER_TOP_N: int = 3
|
| 24 |
+
RERANKER_ENABLED: bool = True
|
| 25 |
+
MIN_RELEVANCE_SCORE: float = 0.0
|
| 26 |
+
MAX_CONTEXT_CHARS: int = 12000
|
| 27 |
+
|
| 28 |
+
# Chunking Config
|
| 29 |
+
CHUNK_SIZE: int = 500
|
| 30 |
+
CHUNK_OVERLAP: int = 50
|
| 31 |
+
|
| 32 |
+
# Runtime Safety
|
| 33 |
+
ENABLE_GUARDRAILS: bool = True
|
| 34 |
+
ENABLE_RAG_EVAL: bool = False
|
| 35 |
+
MAX_QUERY_LENGTH: int = 2000
|
| 36 |
+
|
| 37 |
+
class Config:
|
| 38 |
+
env_file = ".env"
|
| 39 |
+
extra = "ignore"
|
| 40 |
+
|
| 41 |
settings = Settings()
|
app/core/dependencies.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any
|
| 2 |
+
|
| 3 |
+
import requests
|
| 4 |
+
from qdrant_client import QdrantClient
|
| 5 |
+
|
| 6 |
+
from app.core.config import settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def get_qdrant_client() -> QdrantClient:
|
| 10 |
+
if settings.QDRANT_URL:
|
| 11 |
+
return QdrantClient(url=settings.QDRANT_URL)
|
| 12 |
+
return QdrantClient(path=settings.QDRANT_LOCATION)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def check_ollama() -> dict[str, Any]:
|
| 16 |
+
try:
|
| 17 |
+
response = requests.get(f"{settings.OLLAMA_BASE_URL}/api/tags", timeout=3)
|
| 18 |
+
return {"ok": response.ok, "status_code": response.status_code}
|
| 19 |
+
except requests.RequestException as exc:
|
| 20 |
+
return {"ok": False, "error": str(exc)}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def check_qdrant() -> dict[str, Any]:
|
| 24 |
+
try:
|
| 25 |
+
client = get_qdrant_client()
|
| 26 |
+
collections = client.get_collections()
|
| 27 |
+
names = [collection.name for collection in collections.collections]
|
| 28 |
+
return {
|
| 29 |
+
"ok": settings.COLLECTION_NAME in names,
|
| 30 |
+
"collection": settings.COLLECTION_NAME,
|
| 31 |
+
"available_collections": names,
|
| 32 |
+
}
|
| 33 |
+
except Exception as exc:
|
| 34 |
+
return {"ok": False, "error": str(exc)}
|
app/core/errors.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
class CopilotError(Exception):
|
| 2 |
+
"""Base application error with a user-safe message."""
|
| 3 |
+
|
| 4 |
+
def __init__(self, message: str, status_code: int = 500):
|
| 5 |
+
self.message = message
|
| 6 |
+
self.status_code = status_code
|
| 7 |
+
super().__init__(message)
|
app/core/logging.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import sys
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def configure_logging() -> None:
|
| 6 |
+
logging.basicConfig(
|
| 7 |
+
level=logging.INFO,
|
| 8 |
+
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
| 9 |
+
handlers=[logging.StreamHandler(sys.stdout)],
|
| 10 |
+
force=True,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger("support_docs_copilot")
|
app/engine/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/graph/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/guardrails/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/main.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
| 1 |
import asyncio
|
|
|
|
| 2 |
from fastapi import FastAPI, HTTPException
|
| 3 |
from fastapi.responses import StreamingResponse
|
| 4 |
from pydantic import BaseModel
|
|
@@ -7,9 +8,12 @@ from langchain_core.prompts import PromptTemplate
|
|
| 7 |
from langchain_ollama import ChatOllama
|
| 8 |
|
| 9 |
from app.core.config import settings
|
|
|
|
|
|
|
| 10 |
from app.graph.workflow import compile_workflow
|
| 11 |
from app.guardrails.validators import DetectPromptInjection
|
| 12 |
|
|
|
|
| 13 |
app = FastAPI(title=settings.PROJECT_NAME)
|
| 14 |
rag_agent = compile_workflow()
|
| 15 |
input_guard = Guard().use(DetectPromptInjection, on_fail="exception")
|
|
@@ -21,12 +25,28 @@ class ChatResponse(BaseModel):
|
|
| 21 |
query: str
|
| 22 |
answer: str
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
@app.post("/chat", response_model=ChatResponse)
|
| 25 |
async def chat_endpoint(request: ChatRequest):
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
|
|
|
|
|
|
| 30 |
|
| 31 |
initial_state = {"question": request.query, "run_count": 0}
|
| 32 |
try:
|
|
@@ -35,16 +55,19 @@ async def chat_endpoint(request: ChatRequest):
|
|
| 35 |
except Exception as e:
|
| 36 |
raise HTTPException(status_code=500, detail=str(e))
|
| 37 |
|
|
|
|
| 38 |
return ChatResponse(query=request.query, answer=answer)
|
| 39 |
|
| 40 |
@app.post("/chat/stream")
|
| 41 |
async def chat_stream_endpoint(request: ChatRequest):
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
| 46 |
|
| 47 |
async def token_generator():
|
|
|
|
| 48 |
initial_state = {"question": request.query, "run_count": 0}
|
| 49 |
final_state = rag_agent.invoke(initial_state)
|
| 50 |
documents = final_state.get("documents", [])
|
|
@@ -67,5 +90,6 @@ async def chat_stream_endpoint(request: ChatRequest):
|
|
| 67 |
if chunk.content:
|
| 68 |
yield chunk.content
|
| 69 |
await asyncio.sleep(0.01)
|
|
|
|
| 70 |
|
| 71 |
return StreamingResponse(token_generator(), media_type="text/event-stream")
|
|
|
|
| 1 |
import asyncio
|
| 2 |
+
import time
|
| 3 |
from fastapi import FastAPI, HTTPException
|
| 4 |
from fastapi.responses import StreamingResponse
|
| 5 |
from pydantic import BaseModel
|
|
|
|
| 8 |
from langchain_ollama import ChatOllama
|
| 9 |
|
| 10 |
from app.core.config import settings
|
| 11 |
+
from app.core.dependencies import check_ollama, check_qdrant
|
| 12 |
+
from app.core.logging import configure_logging, logger
|
| 13 |
from app.graph.workflow import compile_workflow
|
| 14 |
from app.guardrails.validators import DetectPromptInjection
|
| 15 |
|
| 16 |
+
configure_logging()
|
| 17 |
app = FastAPI(title=settings.PROJECT_NAME)
|
| 18 |
rag_agent = compile_workflow()
|
| 19 |
input_guard = Guard().use(DetectPromptInjection, on_fail="exception")
|
|
|
|
| 25 |
query: str
|
| 26 |
answer: str
|
| 27 |
|
| 28 |
+
@app.get("/health")
|
| 29 |
+
async def health_endpoint():
|
| 30 |
+
return {"status": "ok", "project": settings.PROJECT_NAME}
|
| 31 |
+
|
| 32 |
+
@app.get("/ready")
|
| 33 |
+
async def ready_endpoint():
|
| 34 |
+
ollama = check_ollama()
|
| 35 |
+
qdrant = check_qdrant()
|
| 36 |
+
return {
|
| 37 |
+
"ready": bool(ollama.get("ok") and qdrant.get("ok")),
|
| 38 |
+
"ollama": ollama,
|
| 39 |
+
"qdrant": qdrant,
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
@app.post("/chat", response_model=ChatResponse)
|
| 43 |
async def chat_endpoint(request: ChatRequest):
|
| 44 |
+
started_at = time.perf_counter()
|
| 45 |
+
if settings.ENABLE_GUARDRAILS:
|
| 46 |
+
try:
|
| 47 |
+
input_guard.validate(request.query)
|
| 48 |
+
except Exception as e:
|
| 49 |
+
raise HTTPException(status_code=400, detail=str(getattr(e, "message", e)))
|
| 50 |
|
| 51 |
initial_state = {"question": request.query, "run_count": 0}
|
| 52 |
try:
|
|
|
|
| 55 |
except Exception as e:
|
| 56 |
raise HTTPException(status_code=500, detail=str(e))
|
| 57 |
|
| 58 |
+
logger.info("chat completed latency_ms=%s", round((time.perf_counter() - started_at) * 1000, 2))
|
| 59 |
return ChatResponse(query=request.query, answer=answer)
|
| 60 |
|
| 61 |
@app.post("/chat/stream")
|
| 62 |
async def chat_stream_endpoint(request: ChatRequest):
|
| 63 |
+
if settings.ENABLE_GUARDRAILS:
|
| 64 |
+
try:
|
| 65 |
+
input_guard.validate(request.query)
|
| 66 |
+
except Exception as e:
|
| 67 |
+
raise HTTPException(status_code=400, detail=str(getattr(e, "message", e)))
|
| 68 |
|
| 69 |
async def token_generator():
|
| 70 |
+
started_at = time.perf_counter()
|
| 71 |
initial_state = {"question": request.query, "run_count": 0}
|
| 72 |
final_state = rag_agent.invoke(initial_state)
|
| 73 |
documents = final_state.get("documents", [])
|
|
|
|
| 90 |
if chunk.content:
|
| 91 |
yield chunk.content
|
| 92 |
await asyncio.sleep(0.01)
|
| 93 |
+
logger.info("stream completed latency_ms=%s", round((time.perf_counter() - started_at) * 1000, 2))
|
| 94 |
|
| 95 |
return StreamingResponse(token_generator(), media_type="text/event-stream")
|
app/tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
ui/app.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 1 |
-
import streamlit as st
|
| 2 |
-
import requests
|
| 3 |
import os
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
BACKEND_URL = os.getenv("BACKEND_URL", "http://127.0.0.1:8000/chat/stream")
|
| 9 |
|
|
@@ -23,21 +25,21 @@ if user_query := st.chat_input("Ask a support question..."):
|
|
| 23 |
response_placeholder = st.empty()
|
| 24 |
full_response = ""
|
| 25 |
try:
|
| 26 |
-
with requests.post(BACKEND_URL, json={"query": user_query}, stream=True) as response:
|
| 27 |
if response.status_code == 200:
|
| 28 |
for chunk in response.iter_content(chunk_size=None, decode_unicode=True):
|
| 29 |
if chunk:
|
| 30 |
full_response += chunk
|
| 31 |
-
response_placeholder.markdown(full_response + "
|
| 32 |
response_placeholder.markdown(full_response)
|
| 33 |
elif response.status_code == 400:
|
| 34 |
-
full_response = f"
|
| 35 |
response_placeholder.error(full_response)
|
| 36 |
else:
|
| 37 |
-
full_response = "
|
| 38 |
response_placeholder.error(full_response)
|
| 39 |
-
except:
|
| 40 |
-
full_response = "
|
| 41 |
response_placeholder.error(full_response)
|
| 42 |
|
| 43 |
st.session_state.messages.append({"role": "assistant", "content": full_response})
|
|
|
|
|
|
|
|
|
|
| 1 |
import os
|
| 2 |
|
| 3 |
+
import requests
|
| 4 |
+
import streamlit as st
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
st.set_page_config(page_title="Support Docs Copilot", page_icon="SD")
|
| 8 |
+
st.title("Support Docs Copilot")
|
| 9 |
|
| 10 |
BACKEND_URL = os.getenv("BACKEND_URL", "http://127.0.0.1:8000/chat/stream")
|
| 11 |
|
|
|
|
| 25 |
response_placeholder = st.empty()
|
| 26 |
full_response = ""
|
| 27 |
try:
|
| 28 |
+
with requests.post(BACKEND_URL, json={"query": user_query}, stream=True, timeout=120) as response:
|
| 29 |
if response.status_code == 200:
|
| 30 |
for chunk in response.iter_content(chunk_size=None, decode_unicode=True):
|
| 31 |
if chunk:
|
| 32 |
full_response += chunk
|
| 33 |
+
response_placeholder.markdown(full_response + "...")
|
| 34 |
response_placeholder.markdown(full_response)
|
| 35 |
elif response.status_code == 400:
|
| 36 |
+
full_response = f"Warning: {response.json().get('detail', 'Violation.')}"
|
| 37 |
response_placeholder.error(full_response)
|
| 38 |
else:
|
| 39 |
+
full_response = "Warning: server communication error."
|
| 40 |
response_placeholder.error(full_response)
|
| 41 |
+
except requests.RequestException:
|
| 42 |
+
full_response = "Backend connection error."
|
| 43 |
response_placeholder.error(full_response)
|
| 44 |
|
| 45 |
st.session_state.messages.append({"role": "assistant", "content": full_response})
|