Spaces:
Running
Running
| import tempfile | |
| import shutil | |
| import threading | |
| import time | |
| from pathlib import Path | |
| from typing import Any, Dict, List, Optional | |
| from uuid import uuid4 | |
| from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, Header, HTTPException, Query, Request, UploadFile | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import FileResponse | |
| from fastapi.staticfiles import StaticFiles | |
| from pydantic import BaseModel, Field | |
| from app.auth.supabase_auth import get_auth_client | |
| from app.config.settings import settings | |
| from app.ingestion.ingest import get_pipeline | |
| from app.retrieval.aggregator import get_aggregator | |
| from app.retrieval.chat import get_document_chat | |
| from app.retrieval.search import get_searcher | |
| from app.sources.connectors import get_source_connector | |
| FRONTEND_DIST = settings.PROJECT_ROOT / "frontend" / "dist" | |
| JOB_ROOT = settings.PROCESSED_DATA_DIR / "jobs" | |
| JOBS: Dict[str, Dict[str, Any]] = {} | |
| JOB_LOCK = threading.Lock() | |
| app = FastAPI(title="KnowYourRepo API", version="0.2.0") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"], | |
| allow_credentials=True, | |
| allow_methods=["*"], | |
| allow_headers=["*"], | |
| ) | |
| class SignInRequest(BaseModel): | |
| email: str | |
| password: str | |
| class SignUpRequest(SignInRequest): | |
| redirect_to: Optional[str] = None | |
| class SourceEstimateRequest(BaseModel): | |
| source_url: str | |
| class IngestSourceRequest(BaseModel): | |
| source_url: str | |
| session_id: str = Field(default_factory=lambda: f"session:{uuid4().hex}") | |
| class SearchRequest(BaseModel): | |
| query: str | |
| session_id: str | |
| top_k: int = 5 | |
| similarity_threshold: float = 0.3 | |
| class ChatRequest(BaseModel): | |
| question: str | |
| chunks: List[Dict[str, Any]] = Field(default_factory=list) | |
| class CleanupRequest(BaseModel): | |
| secret: Optional[str] = None | |
| def pipeline(): | |
| return get_pipeline() | |
| def searcher(): | |
| return get_searcher() | |
| def aggregator(): | |
| return get_aggregator() | |
| def chat(): | |
| return get_document_chat() | |
| def bearer_token(authorization: Optional[str]) -> str: | |
| if not authorization: | |
| return "" | |
| scheme, _, token = authorization.partition(" ") | |
| if scheme.lower() != "bearer": | |
| return "" | |
| return token.strip() | |
| def auth_user(authorization: Optional[str] = Header(default=None)) -> Dict[str, Any]: | |
| token = bearer_token(authorization) | |
| if not token: | |
| return {} | |
| try: | |
| return get_auth_client().get_user(token) | |
| except Exception as exc: | |
| raise HTTPException(status_code=401, detail=f"Invalid auth token: {exc}") from exc | |
| def auth_failure(exc: Exception, default_status: int = 401) -> HTTPException: | |
| message = str(exc) or "Authentication failed." | |
| if "SUPABASE_URL" in message or "SUPABASE_ANON_KEY" in message: | |
| return HTTPException(status_code=503, detail="Supabase authentication is not configured.") | |
| return HTTPException(status_code=default_status, detail=message) | |
| def identity(session_id: str, user: Dict[str, Any]) -> Dict[str, str | bool]: | |
| if user: | |
| return { | |
| "user_id": user.get("id", ""), | |
| "session_id": session_id, | |
| "signed_in": True, | |
| } | |
| if not session_id: | |
| raise HTTPException(status_code=400, detail="session_id is required for anonymous requests.") | |
| return { | |
| "user_id": f"anon:{session_id}", | |
| "session_id": session_id, | |
| "signed_in": False, | |
| } | |
| def owner_filter(user_id: str = "", session_id: str = "") -> Dict[str, Any]: | |
| owner_clauses = [] | |
| if user_id: | |
| owner_clauses.append({"user_id": user_id}) | |
| if session_id: | |
| owner_clauses.append({"session_id": session_id}) | |
| if not owner_clauses: | |
| return {} | |
| if len(owner_clauses) == 1: | |
| return owner_clauses[0] | |
| return {"$or": owner_clauses} | |
| def public_source_url(doc: Dict[str, Any]) -> str: | |
| metadata = doc.get("metadata", {}) | |
| return metadata.get("source_url", "") | |
| def create_job(kind: str, ident: Dict[str, str | bool], label: str) -> Dict[str, Any]: | |
| job_id = f"job:{uuid4().hex}" | |
| now = int(time.time()) | |
| job = { | |
| "job_id": job_id, | |
| "kind": kind, | |
| "label": label, | |
| "status": "queued", | |
| "progress": 0, | |
| "message": "Queued", | |
| "user_id": ident["user_id"], | |
| "session_id": ident["session_id"], | |
| "signed_in": ident["signed_in"], | |
| "created_at": now, | |
| "updated_at": now, | |
| "expires_at": now + int(settings.SESSION_TTL_SECONDS), | |
| "files_indexed": 0, | |
| "chunks_indexed": 0, | |
| "chunks_by_file": {}, | |
| "error": "", | |
| } | |
| with JOB_LOCK: | |
| JOBS[job_id] = job | |
| return job.copy() | |
| def update_job(job_id: str, **changes: Any) -> Dict[str, Any]: | |
| with JOB_LOCK: | |
| job = JOBS.get(job_id) | |
| if not job: | |
| raise KeyError(job_id) | |
| job.update(changes) | |
| job["updated_at"] = int(time.time()) | |
| return job.copy() | |
| def get_job(job_id: str) -> Dict[str, Any]: | |
| with JOB_LOCK: | |
| job = JOBS.get(job_id) | |
| if not job: | |
| raise HTTPException(status_code=404, detail="Job not found.") | |
| return job.copy() | |
| def authorize_job(job: Dict[str, Any], session_id: Optional[str], user: Dict[str, Any]) -> None: | |
| if user and job.get("user_id") == user.get("id"): | |
| return | |
| if session_id and job.get("session_id") == session_id: | |
| return | |
| raise HTTPException(status_code=403, detail="You do not have access to this job.") | |
| def run_source_ingestion_job(job_id: str, source_url: str, user_id: str, session_id: str) -> None: | |
| try: | |
| update_job(job_id, status="running", progress=10, message="Fetching source files") | |
| results = pipeline().ingest_source_url(source_url, user_id=user_id, session_id=session_id) | |
| chunks_indexed = sum(results.values()) | |
| update_job( | |
| job_id, | |
| status="completed", | |
| progress=100, | |
| message=f"Indexed {len(results)} files", | |
| files_indexed=len(results), | |
| chunks_indexed=chunks_indexed, | |
| chunks_by_file=results, | |
| ) | |
| except Exception as exc: | |
| update_job(job_id, status="failed", progress=100, message="Ingestion failed", error=str(exc)) | |
| def run_upload_ingestion_job( | |
| job_id: str, | |
| saved_files: List[Dict[str, str]], | |
| user_id: str, | |
| session_id: str, | |
| source_id: str, | |
| job_dir: str, | |
| ) -> None: | |
| chunks_by_file = {} | |
| try: | |
| update_job(job_id, status="running", progress=5, message="Indexing uploaded files") | |
| total_files = max(len(saved_files), 1) | |
| for index, saved_file in enumerate(saved_files, start=1): | |
| filename = saved_file["filename"] | |
| update_job( | |
| job_id, | |
| progress=max(5, int((index - 1) / total_files * 90)), | |
| message=f"Indexing {filename}", | |
| ) | |
| chunks_by_file[filename] = pipeline().ingest_file( | |
| saved_file["path"], | |
| extra_metadata={ | |
| "source_type": "upload", | |
| "source_path": filename, | |
| "document_id": f"upload:{filename}:{uuid4().hex[:8]}", | |
| }, | |
| user_id=user_id, | |
| source_id=source_id, | |
| session_id=session_id, | |
| ) | |
| update_job( | |
| job_id, | |
| status="completed", | |
| progress=100, | |
| message=f"Indexed {len(chunks_by_file)} uploaded files", | |
| files_indexed=len(chunks_by_file), | |
| chunks_indexed=sum(chunks_by_file.values()), | |
| chunks_by_file=chunks_by_file, | |
| ) | |
| except Exception as exc: | |
| update_job(job_id, status="failed", progress=100, message="Upload ingestion failed", error=str(exc)) | |
| finally: | |
| shutil.rmtree(job_dir, ignore_errors=True) | |
| def health() -> Dict[str, Any]: | |
| return {"ok": True, "time": int(time.time())} | |
| def config() -> Dict[str, Any]: | |
| return { | |
| "vector_backend": settings.VECTOR_DB_BACKEND, | |
| "collection_name": settings.COLLECTION_NAME, | |
| "embedding_provider": settings.EMBEDDING_PROVIDER, | |
| "embedding_model": settings.EMBEDDING_MODEL, | |
| "embedding_dimension": settings.EMBEDDING_DIMENSION, | |
| "chat_provider": settings.CHAT_PROVIDER, | |
| "chat_model": settings.CHAT_MODEL, | |
| "anonymous_repo_limit_mb": settings.ANONYMOUS_REPO_LIMIT_MB, | |
| "session_ttl_seconds": settings.SESSION_TTL_SECONDS, | |
| "supabase_configured": bool(settings.SUPABASE_URL and settings.SUPABASE_ANON_KEY), | |
| } | |
| def status( | |
| session_id: Optional[str] = Query(default=None), | |
| user: Dict[str, Any] = Depends(auth_user), | |
| ) -> Dict[str, Any]: | |
| user_id = user.get("id", "") if user else "" | |
| if not user_id and session_id: | |
| user_id = f"anon:{session_id}" | |
| filters = owner_filter(user_id=user_id, session_id=session_id or "") | |
| store_status = pipeline().get_status(where=filters, active_or_legacy=True) if filters else pipeline().get_status() | |
| if not filters: | |
| store_status["total_chunks"] = 0 | |
| return { | |
| "total_chunks": store_status["total_chunks"], | |
| "collection_name": store_status["collection_name"], | |
| "metadata": store_status["metadata"], | |
| "user": user, | |
| } | |
| def sign_in(payload: SignInRequest) -> Dict[str, Any]: | |
| try: | |
| session = get_auth_client().sign_in(payload.email.strip(), payload.password) | |
| except Exception as exc: | |
| raise auth_failure(exc, default_status=401) from exc | |
| return { | |
| "user": session.user, | |
| "access_token": session.access_token, | |
| "refresh_token": session.refresh_token, | |
| } | |
| def sign_up(payload: SignUpRequest) -> Dict[str, Any]: | |
| try: | |
| result = get_auth_client().sign_up(payload.email.strip(), payload.password, payload.redirect_to or "") | |
| except Exception as exc: | |
| raise auth_failure(exc, default_status=400) from exc | |
| return { | |
| "user": result.user, | |
| "confirmation_required": result.confirmation_required, | |
| "session": { | |
| "user": result.session.user, | |
| "access_token": result.session.access_token, | |
| "refresh_token": result.session.refresh_token, | |
| } | |
| if result.session | |
| else None, | |
| } | |
| def current_user(user: Dict[str, Any] = Depends(auth_user)) -> Dict[str, Any]: | |
| if not user: | |
| raise HTTPException(status_code=401, detail="Not signed in.") | |
| return {"user": user} | |
| def sign_out(authorization: Optional[str] = Header(default=None)) -> Dict[str, bool]: | |
| token = bearer_token(authorization) | |
| if token: | |
| try: | |
| get_auth_client().sign_out(token) | |
| except Exception: | |
| pass | |
| return {"ok": True} | |
| def estimate_source(payload: SourceEstimateRequest) -> Dict[str, Any]: | |
| size_mb = get_source_connector().estimate_size_mb(payload.source_url.strip()) | |
| limit_mb = settings.ANONYMOUS_REPO_LIMIT_MB | |
| return { | |
| "size_mb": size_mb, | |
| "anonymous_limit_mb": limit_mb, | |
| "requires_auth": bool(size_mb is not None and size_mb > limit_mb), | |
| } | |
| def ingest_source( | |
| payload: IngestSourceRequest, | |
| background_tasks: BackgroundTasks, | |
| user: Dict[str, Any] = Depends(auth_user), | |
| ) -> Dict[str, Any]: | |
| source_url = payload.source_url.strip() | |
| if not source_url: | |
| raise HTTPException(status_code=400, detail="source_url is required.") | |
| connector = get_source_connector() | |
| requires_auth, size_mb = connector.requires_auth_for_anonymous( | |
| source_url, | |
| settings.ANONYMOUS_REPO_LIMIT_MB, | |
| ) | |
| if requires_auth and not user: | |
| raise HTTPException( | |
| status_code=401, | |
| detail=( | |
| f"This GitHub repository is about {size_mb:.1f} MB. " | |
| f"Sign in to index repositories over {settings.ANONYMOUS_REPO_LIMIT_MB} MB." | |
| ), | |
| ) | |
| ident = identity(payload.session_id, user) | |
| job = create_job("source", ident, source_url) | |
| background_tasks.add_task( | |
| run_source_ingestion_job, | |
| job["job_id"], | |
| source_url, | |
| ident["user_id"], | |
| ident["session_id"], | |
| ) | |
| return { | |
| "job_id": job["job_id"], | |
| "status": job["status"], | |
| "message": job["message"], | |
| "signed_in": ident["signed_in"], | |
| "expires_in_seconds": settings.SESSION_TTL_SECONDS, | |
| } | |
| async def ingest_upload( | |
| background_tasks: BackgroundTasks, | |
| session_id: str = Form(...), | |
| files: List[UploadFile] = File(...), | |
| user: Dict[str, Any] = Depends(auth_user), | |
| ) -> Dict[str, Any]: | |
| if not files: | |
| raise HTTPException(status_code=400, detail="At least one file is required.") | |
| ident = identity(session_id, user) | |
| source_id = f"upload_batch:{ident['user_id']}:{uuid4().hex[:12]}" | |
| job = create_job("upload", ident, f"{len(files)} uploaded files") | |
| job_dir = JOB_ROOT / job["job_id"].replace(":", "_") | |
| job_dir.mkdir(parents=True, exist_ok=True) | |
| saved_files = [] | |
| for upload in files: | |
| filename = Path(upload.filename or f"upload_{uuid4().hex}").name | |
| destination = job_dir / filename | |
| destination.write_bytes(await upload.read()) | |
| saved_files.append({"filename": filename, "path": str(destination)}) | |
| background_tasks.add_task( | |
| run_upload_ingestion_job, | |
| job["job_id"], | |
| saved_files, | |
| ident["user_id"], | |
| ident["session_id"], | |
| source_id, | |
| str(job_dir), | |
| ) | |
| return { | |
| "job_id": job["job_id"], | |
| "status": job["status"], | |
| "message": job["message"], | |
| "signed_in": ident["signed_in"], | |
| "expires_in_seconds": settings.SESSION_TTL_SECONDS, | |
| } | |
| def job_status( | |
| job_id: str, | |
| session_id: Optional[str] = Query(default=None), | |
| user: Dict[str, Any] = Depends(auth_user), | |
| ) -> Dict[str, Any]: | |
| job = get_job(job_id) | |
| authorize_job(job, session_id, user) | |
| return job | |
| def search_documents( | |
| payload: SearchRequest, | |
| user: Dict[str, Any] = Depends(auth_user), | |
| ) -> Dict[str, Any]: | |
| ident = identity(payload.session_id, user) | |
| raw_results = searcher().search( | |
| payload.query, | |
| top_k=max(payload.top_k * 3, payload.top_k), | |
| user_id=ident["user_id"], | |
| session_id=ident["session_id"], | |
| ) | |
| filtered = [result for result in raw_results if result["similarity"] >= payload.similarity_threshold] | |
| docs = aggregator().aggregate_by_document(filtered, max_chunks_per_doc=3) | |
| return { | |
| "documents": docs, | |
| "chunks": [chunk for doc in docs for chunk in doc["chunks"]], | |
| "count": len(docs), | |
| } | |
| def chat_documents(payload: ChatRequest) -> Dict[str, Any]: | |
| if not payload.question.strip(): | |
| raise HTTPException(status_code=400, detail="question is required.") | |
| if not payload.chunks: | |
| raise HTTPException(status_code=400, detail="Run a search first so chat has document context.") | |
| chat_client = chat() | |
| chat_client.healthcheck() | |
| return chat_client.grounded_answer(payload.question, payload.chunks) | |
| def cleanup_expired_vectors( | |
| request: Request, | |
| payload: CleanupRequest = CleanupRequest(), | |
| cleanup_secret: Optional[str] = Header(default=None, alias="X-Cleanup-Secret"), | |
| ) -> Dict[str, Any]: | |
| configured_secret = getattr(settings, "CLEANUP_SECRET", "") | |
| provided_secret = cleanup_secret or payload.secret | |
| if configured_secret and provided_secret != configured_secret: | |
| raise HTTPException(status_code=403, detail="Invalid cleanup secret.") | |
| store = pipeline() | |
| before = store.get_status()["total_chunks"] | |
| store.cleanup_expired() | |
| after = store.get_status()["total_chunks"] | |
| return { | |
| "ok": True, | |
| "before": before, | |
| "after": after, | |
| "deleted": max(before - after, 0), | |
| "path": str(request.url.path), | |
| } | |
| if (FRONTEND_DIST / "assets").exists(): | |
| app.mount("/assets", StaticFiles(directory=FRONTEND_DIST / "assets"), name="assets") | |
| def serve_frontend(full_path: str): | |
| if full_path.startswith("api/"): | |
| raise HTTPException(status_code=404, detail="API route not found.") | |
| target = FRONTEND_DIST / full_path | |
| if target.exists() and target.is_file(): | |
| return FileResponse(target) | |
| index_file = FRONTEND_DIST / "index.html" | |
| if index_file.exists(): | |
| return FileResponse(index_file) | |
| return { | |
| "message": "React frontend has not been built yet.", | |
| "build_command": "cd frontend && npm install && npm run build", | |
| } | |