Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions backend/app/controllers/knowledge_base_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ async def get_files_controller():
raise HTTPException(status_code=500, detail=f"Error getting files: {str(e)}")


async def upload_pdf_controller(file: UploadFile = File(...)):
async def upload_pdf_controller(
file: UploadFile = File(...), source_url: Optional[str] = None
):
"""
Upload a PDF file to the knowledge base.
"""
Expand All @@ -63,7 +65,7 @@ async def upload_pdf_controller(file: UploadFile = File(...)):
file_name = file.filename

# Upload to knowledge base
result = upload_pdf_file(temp_file_path, file_name)
result = upload_pdf_file(temp_file_path, file_name, source_url)

if result["status"] == "success":
return JSONResponse(content=result, status_code=200)
Expand All @@ -83,7 +85,9 @@ async def upload_pdf_controller(file: UploadFile = File(...)):
pass # Ignore cleanup errors


async def upload_text_controller(file: UploadFile = File(...)):
async def upload_text_controller(
file: UploadFile = File(...), source_url: Optional[str] = None
):
"""
Upload a text file to the knowledge base.
"""
Expand All @@ -102,7 +106,7 @@ async def upload_text_controller(file: UploadFile = File(...)):
file_name = file.filename

# Upload to knowledge base
result = upload_text_file(temp_file_path, file_name)
result = upload_text_file(temp_file_path, file_name, source_url)

if result["status"] == "success":
return JSONResponse(content=result, status_code=200)
Expand Down
18 changes: 17 additions & 1 deletion backend/app/controllers/users_controller.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from fastapi import HTTPException

from app.services.litellm_service import get_user_budget_info
from app.services.users_service import sync_current_user
from app.services.users_service import get_user_role, sync_current_user


async def sync_current_user_controller(
Expand Down Expand Up @@ -36,3 +36,19 @@ async def get_current_user_budget_controller(payload: dict) -> dict:
)

return await get_user_budget_info(auth0_sub)


async def get_current_user_role_controller(payload: dict) -> dict:
"""
Fetch the authenticated user's role.
"""

auth0_sub = payload.get("sub")

if not auth0_sub:
raise HTTPException(
status_code=400,
detail="Missing Auth0 subject claim",
)

return {"role": get_user_role(auth0_sub)}
16 changes: 16 additions & 0 deletions backend/app/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from jwt.exceptions import PyJWKClientError

from app.core.config import settings
from app.services.users_service import get_user_role

bearer_scheme = HTTPBearer(auto_error=False)
_jwks_client: PyJWKClient | None = None
Expand Down Expand Up @@ -84,6 +85,21 @@ async def verify_auth0_token(
return _decode_and_verify(access_token)


async def require_admin(
payload: dict = Depends(verify_auth0_token),
) -> dict[str, Any]:
"""
Require the authenticated user to have the "admin" role in Supabase.
"""
if get_user_role(payload["sub"]) != "admin":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Admin privileges required",
)

return payload


async def get_optional_auth0_token(
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
) -> str | None:
Expand Down
18 changes: 12 additions & 6 deletions backend/app/routes/knowledge_base_router.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from fastapi import APIRouter, UploadFile, File
from fastapi import APIRouter, Depends, Form, UploadFile, File
from app.controllers.knowledge_base_controller import (
create_collection_controller,
get_files_controller,
Expand All @@ -8,8 +8,10 @@
get_similar_controller,
get_file_chunks_controller,
)
from app.core.auth import require_admin

router = APIRouter()
# All knowledge base endpoints are admin-only.
router = APIRouter(dependencies=[Depends(require_admin)])


@router.post("/create-collection")
Expand All @@ -35,23 +37,27 @@ async def get_files_endpoint():


@router.post("/upload-pdf")
async def upload_pdf_endpoint(file: UploadFile = File(...)):
async def upload_pdf_endpoint(
file: UploadFile = File(...), source_url: str = Form(None)
):
"""
Upload a PDF file to the knowledge base.
"""
try:
return await upload_pdf_controller(file)
return await upload_pdf_controller(file, source_url)
except Exception as e:
raise e


@router.post("/upload-text")
async def upload_text_endpoint(file: UploadFile = File(...)):
async def upload_text_endpoint(
file: UploadFile = File(...), source_url: str = Form(None)
):
"""
Upload a text file to the knowledge base.
"""
try:
return await upload_text_controller(file)
return await upload_text_controller(file, source_url)
except Exception as e:
raise e

Expand Down
20 changes: 18 additions & 2 deletions backend/app/routes/users_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,15 @@

from app.controllers.users_controller import (
get_current_user_budget_controller,
get_current_user_role_controller,
sync_current_user_controller,
)
from app.core.auth import verify_auth0_token
from app.schemas.users_schema import SyncUserResponse, UserBudgetResponse
from app.schemas.users_schema import (
SyncUserResponse,
UserBudgetResponse,
UserRoleResponse,
)

router = APIRouter()

Expand All @@ -29,4 +34,15 @@ async def get_current_user_budget(
Endpoint to retrieve authenticated user's LiteLLM spend and budget.
"""

return await get_current_user_budget_controller(payload)
return await get_current_user_budget_controller(payload)


@router.get("/users/me/role", response_model=UserRoleResponse)
async def get_current_user_role(
payload: dict = Depends(verify_auth0_token),
):
"""
Endpoint to retrieve the authenticated user's role.
"""

return await get_current_user_role_controller(payload)
4 changes: 4 additions & 0 deletions backend/app/schemas/users_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ class UserBudgetResponse(BaseModel):
spend: float
max_budget: Optional[float] = None
remaining_budget: Optional[float] = None


class UserRoleResponse(BaseModel):
role: Optional[str] = None
21 changes: 17 additions & 4 deletions backend/app/services/knowledge_base_service.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import os
import uuid
from datetime import datetime, timezone
from markitdown import MarkItDown
from typing import List, Dict, Any, Optional
from app.core.config import settings
Expand Down Expand Up @@ -62,12 +63,12 @@ def embed_text(text: str):
def chunk_text(text: str):
"""
Chunk a text string into smaller chunks using LangChain RecursiveCharacterTextSplitter.
The chunk size is 1000 characters and the overlap is 200 characters.
The chunk size is 2000 characters and the overlap is 300 characters.

Args:
text (str): The text to chunk.
"""
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1250, chunk_overlap=250)
text_splitter = RecursiveCharacterTextSplitter(chunk_size=2000, chunk_overlap=300)
return text_splitter.split_text(text)


Expand Down Expand Up @@ -114,7 +115,7 @@ def extract_text_from_pdf(file_path: str) -> str:
raise Exception(f"Error extracting text from PDF: {str(e)}")


def upload_pdf_file(file_path: str, file_name: str = None):
def upload_pdf_file(file_path: str, file_name: str = None, source_url: str = None):
"""
Upload a PDF file to a collection in Qdrant.
The file is converted to text and then chunked into smaller chunks.
Expand All @@ -123,6 +124,7 @@ def upload_pdf_file(file_path: str, file_name: str = None):
Args:
file_path (str): Path to the PDF file to upload.
file_name (str): Name to use for the file in the collection. If None, uses the original filename.
source_url (str): Optional source URL to record on each chunk.
"""
try:
# Ensure collection exists
Expand All @@ -138,6 +140,9 @@ def upload_pdf_file(file_path: str, file_name: str = None):
# Chunk the text
chunks = chunk_text(text)

# Shared by every chunk from this upload
uploaded_at = datetime.now(timezone.utc).isoformat()

# Embed and upload each chunk
for i, chunk in enumerate(chunks):
# Create unique point ID
Expand All @@ -152,6 +157,8 @@ def upload_pdf_file(file_path: str, file_name: str = None):
"chunk": chunk,
"chunk_index": i,
"total_chunks": len(chunks),
"source_url": source_url or "",
"uploaded_at": uploaded_at,
}

# Insert into Qdrant
Expand All @@ -170,7 +177,7 @@ def upload_pdf_file(file_path: str, file_name: str = None):
return {"status": "error", "message": f"Error uploading PDF file: {str(e)}"}


def upload_text_file(file_path: str, file_name: str = None):
def upload_text_file(file_path: str, file_name: str = None, source_url: str = None):
"""
Upload a text file to a collection in Qdrant.
The file is converted to text and then chunked into smaller chunks.
Expand All @@ -179,6 +186,7 @@ def upload_text_file(file_path: str, file_name: str = None):
Args:
file_path (str): Path to the text file to upload.
file_name (str): Name to use for the file in the collection. If None, uses the original filename.
source_url (str): Optional source URL to record on each chunk.
"""
try:
# Ensure collection exists
Expand All @@ -195,6 +203,9 @@ def upload_text_file(file_path: str, file_name: str = None):
# Chunk the text
chunks = chunk_text(text)

# Shared by every chunk from this upload
uploaded_at = datetime.now(timezone.utc).isoformat()

# Embed and upload each chunk
for i, chunk in enumerate(chunks):
# Create unique point ID
Expand All @@ -209,6 +220,8 @@ def upload_text_file(file_path: str, file_name: str = None):
"chunk": chunk,
"chunk_index": i,
"total_chunks": len(chunks),
"source_url": source_url or "",
"uploaded_at": uploaded_at,
}

# Insert into Qdrant
Expand Down
17 changes: 17 additions & 0 deletions backend/app/services/users_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,23 @@ def sync_auth0_user(payload: dict) -> dict | None:
return response.data[0] if response.data else None


def get_user_role(auth0_sub: str) -> str | None:
"""
Look up a synced user's role in Supabase.
"""
supabase = get_supabase_client()

response = (
supabase.table("users")
.select("role")
.eq("auth0_sub", auth0_sub)
.limit(1)
.execute()
)

return response.data[0].get("role") if response.data else None


async def sync_current_user(payload: dict) -> dict:
"""
Ensure the user has a LiteLLM virtual key, then sync them into Supabase.
Expand Down
21 changes: 18 additions & 3 deletions frontend/app/admin/knowledge-base/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useState, useRef } from "react";
import { getAccessToken } from "@auth0/nextjs-auth0/client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
Expand Down Expand Up @@ -45,8 +46,12 @@ export default function KnowledgeBasePage() {
const fetchFiles = async () => {
try {
setLoading(true);
const token = await getAccessToken();
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/kb/files`, {
headers: { accept: "application/json" },
headers: {
accept: "application/json",
Authorization: `Bearer ${token}`,
},
});
if (!res.ok) throw new Error("Failed to fetch files");
const data = await res.json();
Expand Down Expand Up @@ -109,8 +114,10 @@ export default function KnowledgeBasePage() {
const formData = new FormData();
formData.append("file", file);
try {
const token = await getAccessToken();
const res = await fetch(endpoint, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (!res.ok) throw new Error("Failed to upload file");
Expand All @@ -133,11 +140,15 @@ export default function KnowledgeBasePage() {
const handleDeleteFile = async (fileName: string) => {
if (!confirm("Are you sure you want to delete this file?")) return;
try {
const token = await getAccessToken();
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/kb/files/${encodeURIComponent(fileName)}`,
{
method: "DELETE",
headers: { accept: "application/json" },
headers: {
accept: "application/json",
Authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) throw new Error("Failed to delete file");
Expand All @@ -159,10 +170,14 @@ export default function KnowledgeBasePage() {
setSelectedFile(file);
setFileContent({ content: "", loading: true, error: "" });
try {
const token = await getAccessToken();
const res = await fetch(
`${process.env.NEXT_PUBLIC_API_URL}/kb/files/${encodeURIComponent(file.name)}/chunks`,
{
headers: { accept: "application/json" },
headers: {
accept: "application/json",
Authorization: `Bearer ${token}`,
},
},
);
if (!res.ok) throw new Error("Failed to fetch file content");
Expand Down
Loading
Loading