Vineetiitg commited on
Commit
8358ef7
·
1 Parent(s): e7ea02e

fix: normalize logging, error handling, and streaming consistency

Browse files
.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/engine/ingestion.py
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/engine/ingestion.py
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, 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:
@@ -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 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.")
 
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
- print(f"Loading documents from {data_dir}...")
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
- print(f"Skipping unchanged document: {path.name}")
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
- print(f"Skipping document with suspicious instructions: {flagged_source}")
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
- print("No new documents found.")
57
  return
58
 
59
  chunks = chunk_documents(source_documents)
60
- print(f"Split documents into {len(chunks)} chunks.")
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
- print("Ingestion complete. Hybrid index is built.")
77
 
78
 
79
  def list_documents() -> None:
80
  registry = load_registry()
81
  if not registry:
82
- print("No indexed documents found.")
83
  return
84
  for record in registry.values():
85
- print(f"{record['doc_id']} | {record['source']} | chunks={record['chunk_count']}")
86
 
87
 
88
  def delete_indexed_document(doc_id: str) -> None:
89
  registry = load_registry()
90
  if doc_id not in registry:
91
- print(f"Document not found: {doc_id}")
92
  return
93
  delete_document(doc_id)
94
  del registry[doc_id]
95
  save_registry(registry)
96
- print(f"Deleted document: {doc_id}")
97
 
98
 
99
  def reset_index() -> None:
100
  reset_collection()
101
  save_registry({})
102
- print("Vector collection and document registry reset.")
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
- print("--- NODE: RETRIEVE DOCS ---")
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
- print("--- NODE: GRADE DOCUMENT RELEVANCE ---")
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
- print("--- NODE: GENERATE ANSWER ---")
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
- print("--- ROUTE: ALL DOCS IRRELEVANT ---")
77
  return "end"
78
- print("--- ROUTE: RELEVANT DOCS FOUND ---")
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
- print("--- ROUTE: MAX RETRIES REACHED ---")
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
- print("--- ROUTE: GROUNDED ---")
109
  return "end"
110
- print("--- ROUTE: HALLUCINATION DETECTED ---")
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 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:
@@ -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 HTTPException(status_code=429, detail="Rate limit exceeded.")
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 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
@@ -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 HTTPException(status_code=401, detail="Invalid API key.")
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 HTTPException(status_code=400, detail=str(getattr(e, "message", e)))
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 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)
@@ -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 HTTPException(status_code=400, detail=str(getattr(e, "message", e)))
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()