Spaces:
Sleeping
Sleeping
Vineetiitg commited on
Commit ·
b9537ed
1
Parent(s): aaa6ad5
feat: add auth guardrails and request observability
Browse files- .env.example +4 -0
- app/auth/__init__.py +1 -0
- app/auth/models.py +15 -0
- app/auth/security.py +19 -0
- app/core/config.py +6 -0
- app/engine/ingestion.py +6 -0
- app/guardrails/document.py +19 -0
- app/guardrails/input.py +51 -0
- app/guardrails/output.py +12 -0
- app/main.py +58 -16
- app/observability/__init__.py +1 -0
- app/observability/metrics.py +36 -0
.env.example
CHANGED
|
@@ -15,3 +15,7 @@ MAX_CONTEXT_CHARS=12000
|
|
| 15 |
ENABLE_GUARDRAILS=true
|
| 16 |
ENABLE_RAG_EVAL=false
|
| 17 |
MAX_QUERY_LENGTH=2000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
ENABLE_GUARDRAILS=true
|
| 16 |
ENABLE_RAG_EVAL=false
|
| 17 |
MAX_QUERY_LENGTH=2000
|
| 18 |
+
RATE_LIMIT_PER_MINUTE=30
|
| 19 |
+
AUTH_ENABLED=false
|
| 20 |
+
ADMIN_API_KEY=change-me-admin
|
| 21 |
+
USER_API_KEY=change-me-user
|
app/auth/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/auth/models.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class UserContext(BaseModel):
|
| 5 |
+
role: str
|
| 6 |
+
user_id: str
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class LoginRequest(BaseModel):
|
| 10 |
+
api_key: str
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class LoginResponse(BaseModel):
|
| 14 |
+
role: str
|
| 15 |
+
token_type: str = "api_key"
|
app/auth/security.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import Header, HTTPException
|
| 2 |
+
|
| 3 |
+
from app.auth.models import UserContext
|
| 4 |
+
from app.core.config import settings
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def resolve_user(x_api_key: str | None = Header(default=None)) -> UserContext:
|
| 8 |
+
if not settings.AUTH_ENABLED:
|
| 9 |
+
return UserContext(role="admin", user_id="local-dev")
|
| 10 |
+
if x_api_key == settings.ADMIN_API_KEY:
|
| 11 |
+
return UserContext(role="admin", user_id="admin")
|
| 12 |
+
if x_api_key == settings.USER_API_KEY:
|
| 13 |
+
return UserContext(role="user", user_id="user")
|
| 14 |
+
raise HTTPException(status_code=401, detail="Invalid or missing API key.")
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def require_admin(user: UserContext) -> None:
|
| 18 |
+
if user.role != "admin":
|
| 19 |
+
raise HTTPException(status_code=403, detail="Admin role required.")
|
app/core/config.py
CHANGED
|
@@ -33,6 +33,12 @@ class Settings(BaseSettings):
|
|
| 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"
|
|
|
|
| 33 |
ENABLE_GUARDRAILS: bool = True
|
| 34 |
ENABLE_RAG_EVAL: bool = False
|
| 35 |
MAX_QUERY_LENGTH: int = 2000
|
| 36 |
+
RATE_LIMIT_PER_MINUTE: int = 30
|
| 37 |
+
|
| 38 |
+
# Auth Config
|
| 39 |
+
AUTH_ENABLED: bool = False
|
| 40 |
+
ADMIN_API_KEY: str = "local-admin-key"
|
| 41 |
+
USER_API_KEY: str = "local-user-key"
|
| 42 |
|
| 43 |
class Config:
|
| 44 |
env_file = ".env"
|
app/engine/ingestion.py
CHANGED
|
@@ -15,6 +15,7 @@ from app.engine.document_registry import (
|
|
| 15 |
)
|
| 16 |
from app.engine.indexer import delete_document, index_documents, reset_collection
|
| 17 |
from app.engine.loaders import SUPPORTED_EXTENSIONS, load_document
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
def ingest_documents(data_dir: str = "data/docs", force: bool = False) -> None:
|
|
@@ -40,6 +41,11 @@ def ingest_documents(data_dir: str = "data/docs", force: bool = False) -> None:
|
|
| 40 |
|
| 41 |
doc_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{path.name}:{content_hash}"))
|
| 42 |
loaded = load_document(path)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
for document in loaded:
|
| 44 |
document.metadata["doc_id"] = doc_id
|
| 45 |
document.metadata["content_hash"] = content_hash
|
|
|
|
| 15 |
)
|
| 16 |
from app.engine.indexer import delete_document, index_documents, reset_collection
|
| 17 |
from app.engine.loaders import SUPPORTED_EXTENSIONS, load_document
|
| 18 |
+
from app.guardrails.document import filter_malicious_documents
|
| 19 |
|
| 20 |
|
| 21 |
def ingest_documents(data_dir: str = "data/docs", force: bool = False) -> None:
|
|
|
|
| 41 |
|
| 42 |
doc_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{path.name}:{content_hash}"))
|
| 43 |
loaded = load_document(path)
|
| 44 |
+
loaded, flagged = filter_malicious_documents(loaded)
|
| 45 |
+
for flagged_source in flagged:
|
| 46 |
+
print(f"Skipping document with suspicious instructions: {flagged_source}")
|
| 47 |
+
if not loaded:
|
| 48 |
+
continue
|
| 49 |
for document in loaded:
|
| 50 |
document.metadata["doc_id"] = doc_id
|
| 51 |
document.metadata["content_hash"] = content_hash
|
app/guardrails/document.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from langchain_core.documents import Document
|
| 2 |
+
|
| 3 |
+
from app.guardrails.input import PROMPT_INJECTION_PATTERNS
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def document_has_malicious_instruction(document: Document) -> bool:
|
| 7 |
+
normalized = document.page_content.lower()
|
| 8 |
+
return any(pattern in normalized for pattern in PROMPT_INJECTION_PATTERNS)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def filter_malicious_documents(documents: list[Document]) -> tuple[list[Document], list[str]]:
|
| 12 |
+
safe: list[Document] = []
|
| 13 |
+
flagged: list[str] = []
|
| 14 |
+
for document in documents:
|
| 15 |
+
if document_has_malicious_instruction(document):
|
| 16 |
+
flagged.append(document.metadata.get("source", "unknown"))
|
| 17 |
+
continue
|
| 18 |
+
safe.append(document)
|
| 19 |
+
return safe, flagged
|
app/guardrails/input.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import time
|
| 3 |
+
from collections import defaultdict, deque
|
| 4 |
+
|
| 5 |
+
from fastapi import HTTPException
|
| 6 |
+
|
| 7 |
+
from app.core.config import settings
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
_requests_by_client: dict[str, deque[float]] = defaultdict(deque)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
PROMPT_INJECTION_PATTERNS = [
|
| 14 |
+
"ignore previous instructions",
|
| 15 |
+
"ignore the instructions above",
|
| 16 |
+
"forget all previous",
|
| 17 |
+
"system prompt",
|
| 18 |
+
"developer message",
|
| 19 |
+
"bypass system",
|
| 20 |
+
"disregard instructions",
|
| 21 |
+
"reveal hidden",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def validate_query(query: str) -> None:
|
| 26 |
+
if not query.strip():
|
| 27 |
+
raise HTTPException(status_code=400, detail="Query cannot be empty.")
|
| 28 |
+
if len(query) > settings.MAX_QUERY_LENGTH:
|
| 29 |
+
raise HTTPException(status_code=400, detail="Query is too long.")
|
| 30 |
+
normalized = query.lower()
|
| 31 |
+
for pattern in PROMPT_INJECTION_PATTERNS:
|
| 32 |
+
if pattern in normalized:
|
| 33 |
+
raise HTTPException(status_code=400, detail="Prompt injection attempt detected.")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def enforce_rate_limit(client_id: str) -> None:
|
| 37 |
+
if settings.RATE_LIMIT_PER_MINUTE <= 0:
|
| 38 |
+
return
|
| 39 |
+
now = time.time()
|
| 40 |
+
bucket = _requests_by_client[client_id]
|
| 41 |
+
while bucket and now - bucket[0] > 60:
|
| 42 |
+
bucket.popleft()
|
| 43 |
+
if len(bucket) >= settings.RATE_LIMIT_PER_MINUTE:
|
| 44 |
+
raise HTTPException(status_code=429, detail="Rate limit exceeded.")
|
| 45 |
+
bucket.append(now)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def contains_pii(text: str) -> bool:
|
| 49 |
+
email = r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}"
|
| 50 |
+
phone = r"\b(?:\+?\d{1,3}[-.\s]?)?(?:\d{10}|\d{3}[-.\s]\d{3}[-.\s]\d{4})\b"
|
| 51 |
+
return bool(re.search(email, text) or re.search(phone, text))
|
app/guardrails/output.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def redact_sensitive_data(text: str) -> str:
|
| 5 |
+
text = re.sub(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "[REDACTED_EMAIL]", text)
|
| 6 |
+
text = re.sub(r"\b(?:\+?\d{1,3}[-.\s]?)?(?:\d{10}|\d{3}[-.\s]\d{3}[-.\s]\d{4})\b", "[REDACTED_PHONE]", text)
|
| 7 |
+
return text
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def refuses_system_prompt_leak(text: str) -> bool:
|
| 11 |
+
normalized = text.lower()
|
| 12 |
+
return "system prompt" in normalized or "developer message" in normalized
|
app/main.py
CHANGED
|
@@ -1,18 +1,24 @@
|
|
| 1 |
import asyncio
|
| 2 |
-
import
|
| 3 |
-
from fastapi import FastAPI, HTTPException
|
| 4 |
from fastapi.responses import StreamingResponse
|
| 5 |
from pydantic import BaseModel
|
| 6 |
from guardrails import Guard
|
| 7 |
from langchain_core.prompts import PromptTemplate
|
| 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.engine.context_builder import build_context, format_sources
|
| 14 |
from app.graph.workflow import compile_workflow
|
|
|
|
|
|
|
| 15 |
from app.guardrails.validators import DetectPromptInjection
|
|
|
|
| 16 |
|
| 17 |
configure_logging()
|
| 18 |
app = FastAPI(title=settings.PROJECT_NAME)
|
|
@@ -34,6 +40,10 @@ class ChatResponse(BaseModel):
|
|
| 34 |
answer: str
|
| 35 |
sources: list[SourceCitation] = []
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
@app.get("/health")
|
| 38 |
async def health_endpoint():
|
| 39 |
return {"status": "ok", "project": settings.PROJECT_NAME}
|
|
@@ -48,11 +58,41 @@ async def ready_endpoint():
|
|
| 48 |
"qdrant": qdrant,
|
| 49 |
}
|
| 50 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
@app.post("/chat", response_model=ChatResponse)
|
| 52 |
-
async def chat_endpoint(request: ChatRequest):
|
| 53 |
-
|
| 54 |
-
if
|
| 55 |
-
|
| 56 |
if settings.ENABLE_GUARDRAILS:
|
| 57 |
try:
|
| 58 |
input_guard.validate(request.query)
|
|
@@ -61,19 +101,20 @@ async def chat_endpoint(request: ChatRequest):
|
|
| 61 |
|
| 62 |
initial_state = {"question": request.query, "run_count": 0}
|
| 63 |
try:
|
| 64 |
-
|
| 65 |
-
|
|
|
|
| 66 |
sources = final_state.get("sources", [])
|
| 67 |
except Exception as e:
|
| 68 |
raise HTTPException(status_code=500, detail=str(e))
|
| 69 |
|
| 70 |
-
|
| 71 |
return ChatResponse(query=request.query, answer=answer, sources=sources)
|
| 72 |
|
| 73 |
@app.post("/chat/stream")
|
| 74 |
-
async def chat_stream_endpoint(request: ChatRequest):
|
| 75 |
-
if
|
| 76 |
-
|
| 77 |
if settings.ENABLE_GUARDRAILS:
|
| 78 |
try:
|
| 79 |
input_guard.validate(request.query)
|
|
@@ -81,9 +122,10 @@ async def chat_stream_endpoint(request: ChatRequest):
|
|
| 81 |
raise HTTPException(status_code=400, detail=str(getattr(e, "message", e)))
|
| 82 |
|
| 83 |
async def token_generator():
|
| 84 |
-
|
| 85 |
initial_state = {"question": request.query, "run_count": 0}
|
| 86 |
-
|
|
|
|
| 87 |
documents = final_state.get("documents", [])
|
| 88 |
|
| 89 |
if not documents:
|
|
@@ -102,9 +144,9 @@ async def chat_stream_endpoint(request: ChatRequest):
|
|
| 102 |
|
| 103 |
async for chunk in rag_chain.astream({"context": context, "question": request.query}):
|
| 104 |
if chunk.content:
|
| 105 |
-
yield chunk.content
|
| 106 |
await asyncio.sleep(0.01)
|
| 107 |
yield format_sources(documents)
|
| 108 |
-
|
| 109 |
|
| 110 |
return StreamingResponse(token_generator(), media_type="text/event-stream")
|
|
|
|
| 1 |
import asyncio
|
| 2 |
+
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
|
|
| 3 |
from fastapi.responses import StreamingResponse
|
| 4 |
from pydantic import BaseModel
|
| 5 |
from guardrails import Guard
|
| 6 |
from langchain_core.prompts import PromptTemplate
|
| 7 |
from langchain_ollama import ChatOllama
|
| 8 |
|
| 9 |
+
from app.auth.models import LoginRequest, LoginResponse, UserContext
|
| 10 |
+
from app.auth.security import require_admin, resolve_user
|
| 11 |
from app.core.config import settings
|
| 12 |
from app.core.dependencies import check_ollama, check_qdrant
|
| 13 |
from app.core.logging import configure_logging, logger
|
| 14 |
+
from app.engine.document_registry import load_registry
|
| 15 |
+
from app.engine.ingestion import delete_indexed_document, ingest_documents, reset_index
|
| 16 |
from app.engine.context_builder import build_context, format_sources
|
| 17 |
from app.graph.workflow import compile_workflow
|
| 18 |
+
from app.guardrails.input import enforce_rate_limit, validate_query
|
| 19 |
+
from app.guardrails.output import redact_sensitive_data
|
| 20 |
from app.guardrails.validators import DetectPromptInjection
|
| 21 |
+
from app.observability.metrics import RequestMetrics, log_request_metrics, timed_stage
|
| 22 |
|
| 23 |
configure_logging()
|
| 24 |
app = FastAPI(title=settings.PROJECT_NAME)
|
|
|
|
| 40 |
answer: str
|
| 41 |
sources: list[SourceCitation] = []
|
| 42 |
|
| 43 |
+
class IngestionRequest(BaseModel):
|
| 44 |
+
data_dir: str = "data/docs"
|
| 45 |
+
force: bool = False
|
| 46 |
+
|
| 47 |
@app.get("/health")
|
| 48 |
async def health_endpoint():
|
| 49 |
return {"status": "ok", "project": settings.PROJECT_NAME}
|
|
|
|
| 58 |
"qdrant": qdrant,
|
| 59 |
}
|
| 60 |
|
| 61 |
+
@app.post("/auth/login", response_model=LoginResponse)
|
| 62 |
+
async def login_endpoint(request: LoginRequest):
|
| 63 |
+
if request.api_key == settings.ADMIN_API_KEY:
|
| 64 |
+
return LoginResponse(role="admin")
|
| 65 |
+
if request.api_key == settings.USER_API_KEY:
|
| 66 |
+
return LoginResponse(role="user")
|
| 67 |
+
raise HTTPException(status_code=401, detail="Invalid API key.")
|
| 68 |
+
|
| 69 |
+
@app.get("/documents")
|
| 70 |
+
async def documents_endpoint(user: UserContext = Depends(resolve_user)):
|
| 71 |
+
return {"documents": list(load_registry().values()), "role": user.role}
|
| 72 |
+
|
| 73 |
+
@app.post("/admin/ingest")
|
| 74 |
+
async def admin_ingest_endpoint(request: IngestionRequest, user: UserContext = Depends(resolve_user)):
|
| 75 |
+
require_admin(user)
|
| 76 |
+
ingest_documents(data_dir=request.data_dir, force=request.force)
|
| 77 |
+
return {"status": "ok", "message": "Ingestion completed."}
|
| 78 |
+
|
| 79 |
+
@app.delete("/admin/documents/{doc_id}")
|
| 80 |
+
async def admin_delete_document_endpoint(doc_id: str, user: UserContext = Depends(resolve_user)):
|
| 81 |
+
require_admin(user)
|
| 82 |
+
delete_indexed_document(doc_id)
|
| 83 |
+
return {"status": "ok", "doc_id": doc_id}
|
| 84 |
+
|
| 85 |
+
@app.post("/admin/reset")
|
| 86 |
+
async def admin_reset_endpoint(user: UserContext = Depends(resolve_user)):
|
| 87 |
+
require_admin(user)
|
| 88 |
+
reset_index()
|
| 89 |
+
return {"status": "ok", "message": "Index reset."}
|
| 90 |
+
|
| 91 |
@app.post("/chat", response_model=ChatResponse)
|
| 92 |
+
async def chat_endpoint(request: ChatRequest, http_request: Request, user: UserContext = Depends(resolve_user)):
|
| 93 |
+
metrics = RequestMetrics()
|
| 94 |
+
enforce_rate_limit(http_request.client.host if http_request.client else user.user_id)
|
| 95 |
+
validate_query(request.query)
|
| 96 |
if settings.ENABLE_GUARDRAILS:
|
| 97 |
try:
|
| 98 |
input_guard.validate(request.query)
|
|
|
|
| 101 |
|
| 102 |
initial_state = {"question": request.query, "run_count": 0}
|
| 103 |
try:
|
| 104 |
+
with timed_stage(metrics, "rag_workflow"):
|
| 105 |
+
final_state = rag_agent.invoke(initial_state)
|
| 106 |
+
answer = redact_sensitive_data(final_state.get("generation", "Unable to compile answer."))
|
| 107 |
sources = final_state.get("sources", [])
|
| 108 |
except Exception as e:
|
| 109 |
raise HTTPException(status_code=500, detail=str(e))
|
| 110 |
|
| 111 |
+
log_request_metrics(metrics, route="/chat", sources=len(sources), model=settings.OLLAMA_MODEL)
|
| 112 |
return ChatResponse(query=request.query, answer=answer, sources=sources)
|
| 113 |
|
| 114 |
@app.post("/chat/stream")
|
| 115 |
+
async def chat_stream_endpoint(request: ChatRequest, http_request: Request, user: UserContext = Depends(resolve_user)):
|
| 116 |
+
enforce_rate_limit(http_request.client.host if http_request.client else user.user_id)
|
| 117 |
+
validate_query(request.query)
|
| 118 |
if settings.ENABLE_GUARDRAILS:
|
| 119 |
try:
|
| 120 |
input_guard.validate(request.query)
|
|
|
|
| 122 |
raise HTTPException(status_code=400, detail=str(getattr(e, "message", e)))
|
| 123 |
|
| 124 |
async def token_generator():
|
| 125 |
+
metrics = RequestMetrics()
|
| 126 |
initial_state = {"question": request.query, "run_count": 0}
|
| 127 |
+
with timed_stage(metrics, "rag_workflow"):
|
| 128 |
+
final_state = rag_agent.invoke(initial_state)
|
| 129 |
documents = final_state.get("documents", [])
|
| 130 |
|
| 131 |
if not documents:
|
|
|
|
| 144 |
|
| 145 |
async for chunk in rag_chain.astream({"context": context, "question": request.query}):
|
| 146 |
if chunk.content:
|
| 147 |
+
yield redact_sensitive_data(chunk.content)
|
| 148 |
await asyncio.sleep(0.01)
|
| 149 |
yield format_sources(documents)
|
| 150 |
+
log_request_metrics(metrics, route="/chat/stream", sources=len(documents), model=settings.OLLAMA_MODEL)
|
| 151 |
|
| 152 |
return StreamingResponse(token_generator(), media_type="text/event-stream")
|
app/observability/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
app/observability/metrics.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import time
|
| 3 |
+
import uuid
|
| 4 |
+
from contextlib import contextmanager
|
| 5 |
+
from dataclasses import dataclass, field
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger("support_docs_copilot.metrics")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass
|
| 11 |
+
class RequestMetrics:
|
| 12 |
+
request_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
| 13 |
+
started_at: float = field(default_factory=time.perf_counter)
|
| 14 |
+
stages: dict[str, float] = field(default_factory=dict)
|
| 15 |
+
|
| 16 |
+
def total_ms(self) -> float:
|
| 17 |
+
return round((time.perf_counter() - self.started_at) * 1000, 2)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@contextmanager
|
| 21 |
+
def timed_stage(metrics: RequestMetrics, stage: str):
|
| 22 |
+
started = time.perf_counter()
|
| 23 |
+
try:
|
| 24 |
+
yield
|
| 25 |
+
finally:
|
| 26 |
+
metrics.stages[stage] = round((time.perf_counter() - started) * 1000, 2)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def log_request_metrics(metrics: RequestMetrics, **extra) -> None:
|
| 30 |
+
logger.info(
|
| 31 |
+
"request_id=%s total_ms=%s stages=%s extra=%s",
|
| 32 |
+
metrics.request_id,
|
| 33 |
+
metrics.total_ms(),
|
| 34 |
+
metrics.stages,
|
| 35 |
+
extra,
|
| 36 |
+
)
|