Spaces:
Sleeping
Sleeping
Vineetiitg commited on
Commit ·
8358ef7
1
Parent(s): e7ea02e
fix: normalize logging, error handling, and streaming consistency
Browse files- .dockerignore +17 -0
- README.md +2 -2
- app/auth/security.py +4 -3
- app/engine/ingestion.py +13 -11
- app/graph/workflow.py +9 -8
- app/guardrails/input.py +5 -6
- app/main.py +39 -6
.dockerignore
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.venv/
|
| 2 |
+
venv/
|
| 3 |
+
__pycache__/
|
| 4 |
+
*.pyc
|
| 5 |
+
.git/
|
| 6 |
+
.gitignore
|
| 7 |
+
.env
|
| 8 |
+
qdrant_data/
|
| 9 |
+
data/document_registry.json
|
| 10 |
+
tests/
|
| 11 |
+
reports/
|
| 12 |
+
datasets/
|
| 13 |
+
ui/
|
| 14 |
+
*.md
|
| 15 |
+
.python/
|
| 16 |
+
*.log
|
| 17 |
+
fastembed_cache/
|
README.md
CHANGED
|
@@ -21,7 +21,7 @@ pip install -r requirements.txt
|
|
| 21 |
3. Ingest the sample docs:
|
| 22 |
|
| 23 |
```bash
|
| 24 |
-
python app
|
| 25 |
```
|
| 26 |
|
| 27 |
4. Start the backend:
|
|
@@ -43,7 +43,7 @@ Backend docs run at `http://127.0.0.1:8000/docs`; the Streamlit app runs at `htt
|
|
| 43 |
```bash
|
| 44 |
docker-compose up --build -d
|
| 45 |
docker exec -it $(docker-compose ps -q ollama) ollama run llama3
|
| 46 |
-
docker exec -it $(docker-compose ps -q backend) python app
|
| 47 |
```
|
| 48 |
|
| 49 |
## Suggested Commit Roadmap
|
|
|
|
| 21 |
3. Ingest the sample docs:
|
| 22 |
|
| 23 |
```bash
|
| 24 |
+
python -m app.engine.ingestion ingest
|
| 25 |
```
|
| 26 |
|
| 27 |
4. Start the backend:
|
|
|
|
| 43 |
```bash
|
| 44 |
docker-compose up --build -d
|
| 45 |
docker exec -it $(docker-compose ps -q ollama) ollama run llama3
|
| 46 |
+
docker exec -it $(docker-compose ps -q backend) python -m app.engine.ingestion ingest
|
| 47 |
```
|
| 48 |
|
| 49 |
## Suggested Commit Roadmap
|
app/auth/security.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
-
from fastapi import Header
|
| 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:
|
|
@@ -11,9 +12,9 @@ def resolve_user(x_api_key: str | None = Header(default=None)) -> UserContext:
|
|
| 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
|
| 15 |
|
| 16 |
|
| 17 |
def require_admin(user: UserContext) -> None:
|
| 18 |
if user.role != "admin":
|
| 19 |
-
raise
|
|
|
|
| 1 |
+
from fastapi import Header
|
| 2 |
|
| 3 |
from app.auth.models import UserContext
|
| 4 |
from app.core.config import settings
|
| 5 |
+
from app.core.errors import CopilotError
|
| 6 |
|
| 7 |
|
| 8 |
def resolve_user(x_api_key: str | None = Header(default=None)) -> UserContext:
|
|
|
|
| 12 |
return UserContext(role="admin", user_id="admin")
|
| 13 |
if x_api_key == settings.USER_API_KEY:
|
| 14 |
return UserContext(role="user", user_id="user")
|
| 15 |
+
raise CopilotError("Invalid or missing API key.", status_code=401)
|
| 16 |
|
| 17 |
|
| 18 |
def require_admin(user: UserContext) -> None:
|
| 19 |
if user.role != "admin":
|
| 20 |
+
raise CopilotError("Admin role required.", status_code=403)
|
app/engine/ingestion.py
CHANGED
|
@@ -5,6 +5,8 @@ from pathlib import Path
|
|
| 5 |
|
| 6 |
from langchain_core.documents import Document
|
| 7 |
|
|
|
|
|
|
|
| 8 |
from app.engine.chunking import chunk_documents
|
| 9 |
from app.engine.document_registry import (
|
| 10 |
file_hash,
|
|
@@ -19,7 +21,7 @@ from app.guardrails.document import filter_malicious_documents
|
|
| 19 |
|
| 20 |
|
| 21 |
def ingest_documents(data_dir: str = "data/docs", force: bool = False) -> None:
|
| 22 |
-
|
| 23 |
root = Path(data_dir)
|
| 24 |
root.mkdir(parents=True, exist_ok=True)
|
| 25 |
|
|
@@ -36,14 +38,14 @@ def ingest_documents(data_dir: str = "data/docs", force: bool = False) -> None:
|
|
| 36 |
|
| 37 |
content_hash = file_hash(path)
|
| 38 |
if not force and hash_exists(content_hash, registry):
|
| 39 |
-
|
| 40 |
continue
|
| 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 |
-
|
| 47 |
if not loaded:
|
| 48 |
continue
|
| 49 |
for document in loaded:
|
|
@@ -53,11 +55,11 @@ def ingest_documents(data_dir: str = "data/docs", force: bool = False) -> None:
|
|
| 53 |
doc_id_by_source[str(path)] = doc_id
|
| 54 |
|
| 55 |
if not source_documents:
|
| 56 |
-
|
| 57 |
return
|
| 58 |
|
| 59 |
chunks = chunk_documents(source_documents)
|
| 60 |
-
|
| 61 |
|
| 62 |
index_documents(chunks, force_recreate=force)
|
| 63 |
|
|
@@ -73,33 +75,33 @@ def ingest_documents(data_dir: str = "data/docs", force: bool = False) -> None:
|
|
| 73 |
registry=registry,
|
| 74 |
)
|
| 75 |
save_registry(registry)
|
| 76 |
-
|
| 77 |
|
| 78 |
|
| 79 |
def list_documents() -> None:
|
| 80 |
registry = load_registry()
|
| 81 |
if not registry:
|
| 82 |
-
|
| 83 |
return
|
| 84 |
for record in registry.values():
|
| 85 |
-
|
| 86 |
|
| 87 |
|
| 88 |
def delete_indexed_document(doc_id: str) -> None:
|
| 89 |
registry = load_registry()
|
| 90 |
if doc_id not in registry:
|
| 91 |
-
|
| 92 |
return
|
| 93 |
delete_document(doc_id)
|
| 94 |
del registry[doc_id]
|
| 95 |
save_registry(registry)
|
| 96 |
-
|
| 97 |
|
| 98 |
|
| 99 |
def reset_index() -> None:
|
| 100 |
reset_collection()
|
| 101 |
save_registry({})
|
| 102 |
-
|
| 103 |
|
| 104 |
|
| 105 |
def build_parser() -> argparse.ArgumentParser:
|
|
|
|
| 5 |
|
| 6 |
from langchain_core.documents import Document
|
| 7 |
|
| 8 |
+
from app.core.logging import logger
|
| 9 |
+
|
| 10 |
from app.engine.chunking import chunk_documents
|
| 11 |
from app.engine.document_registry import (
|
| 12 |
file_hash,
|
|
|
|
| 21 |
|
| 22 |
|
| 23 |
def ingest_documents(data_dir: str = "data/docs", force: bool = False) -> None:
|
| 24 |
+
logger.info(f"Loading documents from {data_dir}...")
|
| 25 |
root = Path(data_dir)
|
| 26 |
root.mkdir(parents=True, exist_ok=True)
|
| 27 |
|
|
|
|
| 38 |
|
| 39 |
content_hash = file_hash(path)
|
| 40 |
if not force and hash_exists(content_hash, registry):
|
| 41 |
+
logger.info(f"Skipping unchanged document: {path.name}")
|
| 42 |
continue
|
| 43 |
|
| 44 |
doc_id = str(uuid.uuid5(uuid.NAMESPACE_URL, f"{path.name}:{content_hash}"))
|
| 45 |
loaded = load_document(path)
|
| 46 |
loaded, flagged = filter_malicious_documents(loaded)
|
| 47 |
for flagged_source in flagged:
|
| 48 |
+
logger.info(f"Skipping document with suspicious instructions: {flagged_source}")
|
| 49 |
if not loaded:
|
| 50 |
continue
|
| 51 |
for document in loaded:
|
|
|
|
| 55 |
doc_id_by_source[str(path)] = doc_id
|
| 56 |
|
| 57 |
if not source_documents:
|
| 58 |
+
logger.info("No new documents found.")
|
| 59 |
return
|
| 60 |
|
| 61 |
chunks = chunk_documents(source_documents)
|
| 62 |
+
logger.info(f"Split documents into {len(chunks)} chunks.")
|
| 63 |
|
| 64 |
index_documents(chunks, force_recreate=force)
|
| 65 |
|
|
|
|
| 75 |
registry=registry,
|
| 76 |
)
|
| 77 |
save_registry(registry)
|
| 78 |
+
logger.info("Ingestion complete. Hybrid index is built.")
|
| 79 |
|
| 80 |
|
| 81 |
def list_documents() -> None:
|
| 82 |
registry = load_registry()
|
| 83 |
if not registry:
|
| 84 |
+
logger.info("No indexed documents found.")
|
| 85 |
return
|
| 86 |
for record in registry.values():
|
| 87 |
+
logger.info(f"{record['doc_id']} | {record['source']} | chunks={record['chunk_count']}")
|
| 88 |
|
| 89 |
|
| 90 |
def delete_indexed_document(doc_id: str) -> None:
|
| 91 |
registry = load_registry()
|
| 92 |
if doc_id not in registry:
|
| 93 |
+
logger.info(f"Document not found: {doc_id}")
|
| 94 |
return
|
| 95 |
delete_document(doc_id)
|
| 96 |
del registry[doc_id]
|
| 97 |
save_registry(registry)
|
| 98 |
+
logger.info(f"Deleted document: {doc_id}")
|
| 99 |
|
| 100 |
|
| 101 |
def reset_index() -> None:
|
| 102 |
reset_collection()
|
| 103 |
save_registry({})
|
| 104 |
+
logger.info("Vector collection and document registry reset.")
|
| 105 |
|
| 106 |
|
| 107 |
def build_parser() -> argparse.ArgumentParser:
|
app/graph/workflow.py
CHANGED
|
@@ -6,6 +6,7 @@ from langchain_ollama import ChatOllama
|
|
| 6 |
from langgraph.graph import START, END, StateGraph
|
| 7 |
|
| 8 |
from app.core.config import settings
|
|
|
|
| 9 |
from app.engine.context_builder import build_context, source_citations
|
| 10 |
from app.engine.retriever import retrieve_documents
|
| 11 |
|
|
@@ -20,14 +21,14 @@ llm = ChatOllama(model=settings.OLLAMA_MODEL, temperature=0, base_url=settings.O
|
|
| 20 |
llm_json = ChatOllama(model=settings.OLLAMA_MODEL, temperature=0, format="json", base_url=settings.OLLAMA_BASE_URL)
|
| 21 |
|
| 22 |
def retrieve(state: GraphState):
|
| 23 |
-
|
| 24 |
question = state["question"]
|
| 25 |
run_count = state.get("run_count", 0)
|
| 26 |
documents = retrieve_documents(question)
|
| 27 |
return {"documents": documents, "sources": source_citations(documents), "question": question, "run_count": run_count}
|
| 28 |
|
| 29 |
def grade_documents(state: GraphState):
|
| 30 |
-
|
| 31 |
question = state["question"]
|
| 32 |
documents = state.get("documents", [])
|
| 33 |
|
|
@@ -54,7 +55,7 @@ def grade_documents(state: GraphState):
|
|
| 54 |
return {"documents": filtered_docs}
|
| 55 |
|
| 56 |
def generate(state: GraphState):
|
| 57 |
-
|
| 58 |
question = state["question"]
|
| 59 |
documents = state["documents"]
|
| 60 |
run_count = state.get("run_count", 0) + 1
|
|
@@ -73,9 +74,9 @@ def generate(state: GraphState):
|
|
| 73 |
|
| 74 |
def decide_to_generate(state: GraphState):
|
| 75 |
if not state["documents"]:
|
| 76 |
-
|
| 77 |
return "end"
|
| 78 |
-
|
| 79 |
return "generate"
|
| 80 |
|
| 81 |
def check_hallucinations(state: GraphState):
|
|
@@ -84,7 +85,7 @@ def check_hallucinations(state: GraphState):
|
|
| 84 |
run_count = state["run_count"]
|
| 85 |
|
| 86 |
if run_count >= 3:
|
| 87 |
-
|
| 88 |
return "end"
|
| 89 |
|
| 90 |
context = build_context(documents)
|
|
@@ -105,9 +106,9 @@ def check_hallucinations(state: GraphState):
|
|
| 105 |
grade = "yes"
|
| 106 |
|
| 107 |
if grade.lower() == "yes":
|
| 108 |
-
|
| 109 |
return "end"
|
| 110 |
-
|
| 111 |
return "regenerate"
|
| 112 |
|
| 113 |
def compile_workflow():
|
|
|
|
| 6 |
from langgraph.graph import START, END, StateGraph
|
| 7 |
|
| 8 |
from app.core.config import settings
|
| 9 |
+
from app.core.logging import logger
|
| 10 |
from app.engine.context_builder import build_context, source_citations
|
| 11 |
from app.engine.retriever import retrieve_documents
|
| 12 |
|
|
|
|
| 21 |
llm_json = ChatOllama(model=settings.OLLAMA_MODEL, temperature=0, format="json", base_url=settings.OLLAMA_BASE_URL)
|
| 22 |
|
| 23 |
def retrieve(state: GraphState):
|
| 24 |
+
logger.info("NODE: RETRIEVE DOCS")
|
| 25 |
question = state["question"]
|
| 26 |
run_count = state.get("run_count", 0)
|
| 27 |
documents = retrieve_documents(question)
|
| 28 |
return {"documents": documents, "sources": source_citations(documents), "question": question, "run_count": run_count}
|
| 29 |
|
| 30 |
def grade_documents(state: GraphState):
|
| 31 |
+
logger.info("NODE: GRADE DOCUMENT RELEVANCE")
|
| 32 |
question = state["question"]
|
| 33 |
documents = state.get("documents", [])
|
| 34 |
|
|
|
|
| 55 |
return {"documents": filtered_docs}
|
| 56 |
|
| 57 |
def generate(state: GraphState):
|
| 58 |
+
logger.info("NODE: GENERATE ANSWER")
|
| 59 |
question = state["question"]
|
| 60 |
documents = state["documents"]
|
| 61 |
run_count = state.get("run_count", 0) + 1
|
|
|
|
| 74 |
|
| 75 |
def decide_to_generate(state: GraphState):
|
| 76 |
if not state["documents"]:
|
| 77 |
+
logger.info("ROUTE: ALL DOCS IRRELEVANT")
|
| 78 |
return "end"
|
| 79 |
+
logger.info("ROUTE: RELEVANT DOCS FOUND")
|
| 80 |
return "generate"
|
| 81 |
|
| 82 |
def check_hallucinations(state: GraphState):
|
|
|
|
| 85 |
run_count = state["run_count"]
|
| 86 |
|
| 87 |
if run_count >= 3:
|
| 88 |
+
logger.info("ROUTE: MAX RETRIES REACHED")
|
| 89 |
return "end"
|
| 90 |
|
| 91 |
context = build_context(documents)
|
|
|
|
| 106 |
grade = "yes"
|
| 107 |
|
| 108 |
if grade.lower() == "yes":
|
| 109 |
+
logger.info("ROUTE: GROUNDED")
|
| 110 |
return "end"
|
| 111 |
+
logger.info("ROUTE: HALLUCINATION DETECTED")
|
| 112 |
return "regenerate"
|
| 113 |
|
| 114 |
def compile_workflow():
|
app/guardrails/input.py
CHANGED
|
@@ -2,9 +2,8 @@ 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)
|
|
@@ -24,13 +23,13 @@ PROMPT_INJECTION_PATTERNS = [
|
|
| 24 |
|
| 25 |
def validate_query(query: str) -> None:
|
| 26 |
if not query.strip():
|
| 27 |
-
raise
|
| 28 |
if len(query) > settings.MAX_QUERY_LENGTH:
|
| 29 |
-
raise
|
| 30 |
normalized = query.lower()
|
| 31 |
for pattern in PROMPT_INJECTION_PATTERNS:
|
| 32 |
if pattern in normalized:
|
| 33 |
-
raise
|
| 34 |
|
| 35 |
|
| 36 |
def enforce_rate_limit(client_id: str) -> None:
|
|
@@ -41,7 +40,7 @@ def enforce_rate_limit(client_id: str) -> None:
|
|
| 41 |
while bucket and now - bucket[0] > 60:
|
| 42 |
bucket.popleft()
|
| 43 |
if len(bucket) >= settings.RATE_LIMIT_PER_MINUTE:
|
| 44 |
-
raise
|
| 45 |
bucket.append(now)
|
| 46 |
|
| 47 |
|
|
|
|
| 2 |
import time
|
| 3 |
from collections import defaultdict, deque
|
| 4 |
|
|
|
|
|
|
|
| 5 |
from app.core.config import settings
|
| 6 |
+
from app.core.errors import CopilotError
|
| 7 |
|
| 8 |
|
| 9 |
_requests_by_client: dict[str, deque[float]] = defaultdict(deque)
|
|
|
|
| 23 |
|
| 24 |
def validate_query(query: str) -> None:
|
| 25 |
if not query.strip():
|
| 26 |
+
raise CopilotError("Query cannot be empty.", status_code=400)
|
| 27 |
if len(query) > settings.MAX_QUERY_LENGTH:
|
| 28 |
+
raise CopilotError("Query is too long.", status_code=400)
|
| 29 |
normalized = query.lower()
|
| 30 |
for pattern in PROMPT_INJECTION_PATTERNS:
|
| 31 |
if pattern in normalized:
|
| 32 |
+
raise CopilotError("Prompt injection attempt detected.", status_code=400)
|
| 33 |
|
| 34 |
|
| 35 |
def enforce_rate_limit(client_id: str) -> None:
|
|
|
|
| 40 |
while bucket and now - bucket[0] > 60:
|
| 41 |
bucket.popleft()
|
| 42 |
if len(bucket) >= settings.RATE_LIMIT_PER_MINUTE:
|
| 43 |
+
raise CopilotError("Rate limit exceeded.", status_code=429)
|
| 44 |
bucket.append(now)
|
| 45 |
|
| 46 |
|
app/main.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
| 1 |
import asyncio
|
| 2 |
-
from
|
| 3 |
-
|
|
|
|
|
|
|
|
|
|
| 4 |
from pydantic import BaseModel
|
| 5 |
from guardrails import Guard
|
| 6 |
from langchain_core.prompts import PromptTemplate
|
|
@@ -10,6 +13,7 @@ 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
|
|
@@ -22,6 +26,22 @@ from app.observability.metrics import RequestMetrics, log_request_metrics, timed
|
|
| 22 |
|
| 23 |
configure_logging()
|
| 24 |
app = FastAPI(title=settings.PROJECT_NAME)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
rag_agent = compile_workflow()
|
| 26 |
input_guard = Guard().use(DetectPromptInjection, on_fail="exception")
|
| 27 |
|
|
@@ -64,7 +84,7 @@ async def login_endpoint(request: LoginRequest):
|
|
| 64 |
return LoginResponse(role="admin")
|
| 65 |
if request.api_key == settings.USER_API_KEY:
|
| 66 |
return LoginResponse(role="user")
|
| 67 |
-
raise
|
| 68 |
|
| 69 |
@app.get("/documents")
|
| 70 |
async def documents_endpoint(user: UserContext = Depends(resolve_user)):
|
|
@@ -76,6 +96,19 @@ async def admin_ingest_endpoint(request: IngestionRequest, user: UserContext = D
|
|
| 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)
|
|
@@ -97,7 +130,7 @@ async def chat_endpoint(request: ChatRequest, http_request: Request, user: UserC
|
|
| 97 |
try:
|
| 98 |
input_guard.validate(request.query)
|
| 99 |
except Exception as e:
|
| 100 |
-
raise
|
| 101 |
|
| 102 |
initial_state = {"question": request.query, "run_count": 0}
|
| 103 |
try:
|
|
@@ -106,7 +139,7 @@ async def chat_endpoint(request: ChatRequest, http_request: Request, user: UserC
|
|
| 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
|
| 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)
|
|
@@ -119,7 +152,7 @@ async def chat_stream_endpoint(request: ChatRequest, http_request: Request, user
|
|
| 119 |
try:
|
| 120 |
input_guard.validate(request.query)
|
| 121 |
except Exception as e:
|
| 122 |
-
raise
|
| 123 |
|
| 124 |
async def token_generator():
|
| 125 |
metrics = RequestMetrics()
|
|
|
|
| 1 |
import asyncio
|
| 2 |
+
from pathlib import Path
|
| 3 |
+
|
| 4 |
+
from fastapi import Depends, FastAPI, File, Request, UploadFile
|
| 5 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 6 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 7 |
from pydantic import BaseModel
|
| 8 |
from guardrails import Guard
|
| 9 |
from langchain_core.prompts import PromptTemplate
|
|
|
|
| 13 |
from app.auth.security import require_admin, resolve_user
|
| 14 |
from app.core.config import settings
|
| 15 |
from app.core.dependencies import check_ollama, check_qdrant
|
| 16 |
+
from app.core.errors import CopilotError
|
| 17 |
from app.core.logging import configure_logging, logger
|
| 18 |
from app.engine.document_registry import load_registry
|
| 19 |
from app.engine.ingestion import delete_indexed_document, ingest_documents, reset_index
|
|
|
|
| 26 |
|
| 27 |
configure_logging()
|
| 28 |
app = FastAPI(title=settings.PROJECT_NAME)
|
| 29 |
+
|
| 30 |
+
app.add_middleware(
|
| 31 |
+
CORSMiddleware,
|
| 32 |
+
allow_origins=["*"],
|
| 33 |
+
allow_credentials=True,
|
| 34 |
+
allow_methods=["*"],
|
| 35 |
+
allow_headers=["*"],
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
@app.exception_handler(CopilotError)
|
| 39 |
+
async def copilot_error_handler(request: Request, exc: CopilotError):
|
| 40 |
+
return JSONResponse(
|
| 41 |
+
status_code=exc.status_code,
|
| 42 |
+
content={"detail": exc.message},
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
rag_agent = compile_workflow()
|
| 46 |
input_guard = Guard().use(DetectPromptInjection, on_fail="exception")
|
| 47 |
|
|
|
|
| 84 |
return LoginResponse(role="admin")
|
| 85 |
if request.api_key == settings.USER_API_KEY:
|
| 86 |
return LoginResponse(role="user")
|
| 87 |
+
raise CopilotError("Invalid API key.", status_code=401)
|
| 88 |
|
| 89 |
@app.get("/documents")
|
| 90 |
async def documents_endpoint(user: UserContext = Depends(resolve_user)):
|
|
|
|
| 96 |
ingest_documents(data_dir=request.data_dir, force=request.force)
|
| 97 |
return {"status": "ok", "message": "Ingestion completed."}
|
| 98 |
|
| 99 |
+
@app.post("/admin/upload")
|
| 100 |
+
async def admin_upload_endpoint(files: list[UploadFile] = File(...), user: UserContext = Depends(resolve_user)):
|
| 101 |
+
require_admin(user)
|
| 102 |
+
target_dir = Path(settings.DATA_DIR)
|
| 103 |
+
target_dir.mkdir(parents=True, exist_ok=True)
|
| 104 |
+
saved_files = []
|
| 105 |
+
for uploaded_file in files:
|
| 106 |
+
filename = Path(uploaded_file.filename or "uploaded.txt").name
|
| 107 |
+
target_path = target_dir / filename
|
| 108 |
+
target_path.write_bytes(await uploaded_file.read())
|
| 109 |
+
saved_files.append(filename)
|
| 110 |
+
return {"status": "ok", "saved_files": saved_files, "data_dir": str(target_dir)}
|
| 111 |
+
|
| 112 |
@app.delete("/admin/documents/{doc_id}")
|
| 113 |
async def admin_delete_document_endpoint(doc_id: str, user: UserContext = Depends(resolve_user)):
|
| 114 |
require_admin(user)
|
|
|
|
| 130 |
try:
|
| 131 |
input_guard.validate(request.query)
|
| 132 |
except Exception as e:
|
| 133 |
+
raise CopilotError(str(getattr(e, "message", e)), status_code=400)
|
| 134 |
|
| 135 |
initial_state = {"question": request.query, "run_count": 0}
|
| 136 |
try:
|
|
|
|
| 139 |
answer = redact_sensitive_data(final_state.get("generation", "Unable to compile answer."))
|
| 140 |
sources = final_state.get("sources", [])
|
| 141 |
except Exception as e:
|
| 142 |
+
raise CopilotError(str(e), status_code=500)
|
| 143 |
|
| 144 |
log_request_metrics(metrics, route="/chat", sources=len(sources), model=settings.OLLAMA_MODEL)
|
| 145 |
return ChatResponse(query=request.query, answer=answer, sources=sources)
|
|
|
|
| 152 |
try:
|
| 153 |
input_guard.validate(request.query)
|
| 154 |
except Exception as e:
|
| 155 |
+
raise CopilotError(str(getattr(e, "message", e)), status_code=400)
|
| 156 |
|
| 157 |
async def token_generator():
|
| 158 |
metrics = RequestMetrics()
|