varun2808 commited on
Commit
2f27a28
·
verified ·
1 Parent(s): 313ff39

Upload folder using huggingface_hub

Browse files
app/__init__.py ADDED
File without changes
app/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (169 Bytes). View file
 
app/__pycache__/config.cpython-311.pyc ADDED
Binary file (1.52 kB). View file
 
app/__pycache__/main.cpython-311.pyc ADDED
Binary file (4.43 kB). View file
 
app/__pycache__/rag.cpython-311.pyc ADDED
Binary file (6.69 kB). View file
 
app/__pycache__/schemas.cpython-311.pyc ADDED
Binary file (2.62 kB). View file
 
app/config.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Application configuration, loaded from environment variables."""
2
+ import os
3
+ from pathlib import Path
4
+
5
+ from dotenv import load_dotenv
6
+
7
+ load_dotenv()
8
+
9
+ BASE_DIR = Path(__file__).resolve().parent.parent
10
+
11
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
12
+ GROQ_MODEL = os.getenv("GROQ_MODEL", "llama-3.1-8b-instant")
13
+
14
+ CHROMA_DIR = os.getenv("CHROMA_DIR", str(BASE_DIR / "data" / "chroma"))
15
+ COLLECTION_NAME = os.getenv("COLLECTION_NAME", "python_qa")
16
+
17
+ TOP_K = int(os.getenv("TOP_K", "5"))
18
+ # Cosine distance above which a retrieved doc is considered irrelevant.
19
+ RELEVANCE_THRESHOLD = float(os.getenv("RELEVANCE_THRESHOLD", "0.50"))
20
+ MAX_QUESTION_LEN = int(os.getenv("MAX_QUESTION_LEN", "2000"))
21
+ ANSWER_CACHE_SIZE = int(os.getenv("ANSWER_CACHE_SIZE", "256"))
22
+ LLM_TIMEOUT_SECONDS = float(os.getenv("LLM_TIMEOUT_SECONDS", "30"))
app/main.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI service exposing the Python Q&A RAG pipeline."""
2
+ import logging
3
+ import time
4
+ from collections import OrderedDict
5
+ from contextlib import asynccontextmanager
6
+
7
+ from fastapi import FastAPI, HTTPException
8
+ from fastapi.responses import RedirectResponse
9
+ from groq import GroqError
10
+
11
+ from app import config
12
+ from app.rag import RAGPipeline, Retriever
13
+ from app.schemas import AskRequest, AskResponse, HealthResponse
14
+
15
+ logging.basicConfig(level=logging.INFO)
16
+ logger = logging.getLogger(__name__)
17
+
18
+ # question -> response dict, evicted FIFO once full.
19
+ _answer_cache: OrderedDict[str, dict] = OrderedDict()
20
+
21
+
22
+ @asynccontextmanager
23
+ async def lifespan(app: FastAPI):
24
+ retriever = Retriever()
25
+ app.state.pipeline = RAGPipeline(retriever)
26
+ logger.info("Index loaded: %d documents", retriever.count())
27
+ yield
28
+
29
+
30
+ app = FastAPI(
31
+ title="Python Programming Q&A Assistant",
32
+ description=(
33
+ "RAG-powered Q&A over the Stack Overflow Python dataset "
34
+ "(Kaggle: stackoverflow/pythonquestions), answered by Llama on Groq."
35
+ ),
36
+ version="1.0.0",
37
+ lifespan=lifespan,
38
+ )
39
+
40
+
41
+ @app.get("/", include_in_schema=False)
42
+ async def root():
43
+ return RedirectResponse(url="/docs")
44
+
45
+
46
+ @app.get("/health", response_model=HealthResponse)
47
+ async def health():
48
+ return HealthResponse(
49
+ status="ok",
50
+ index_size=app.state.pipeline.retriever.count(),
51
+ model=config.GROQ_MODEL,
52
+ )
53
+
54
+
55
+ @app.post("/ask", response_model=AskResponse)
56
+ async def ask(req: AskRequest):
57
+ cache_key = f"{req.question.strip().lower()}|{req.top_k}"
58
+ if cache_key in _answer_cache:
59
+ cached = _answer_cache[cache_key]
60
+ return AskResponse(**{**cached, "cached": True, "latency_ms": 0})
61
+
62
+ start = time.perf_counter()
63
+ try:
64
+ result = await app.state.pipeline.ask(req.question, top_k=req.top_k)
65
+ except GroqError as e:
66
+ logger.exception("LLM call failed")
67
+ raise HTTPException(status_code=502, detail=f"LLM provider error: {e}") from e
68
+
69
+ result["latency_ms"] = int((time.perf_counter() - start) * 1000)
70
+
71
+ _answer_cache[cache_key] = result
72
+ if len(_answer_cache) > config.ANSWER_CACHE_SIZE:
73
+ _answer_cache.popitem(last=False)
74
+
75
+ return AskResponse(**result)
app/rag.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RAG pipeline: Chroma retrieval over Stack Overflow Python Q&A + Groq Llama generation."""
2
+ import logging
3
+
4
+ import chromadb
5
+ from groq import AsyncGroq
6
+
7
+ from app import config
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+ SYSTEM_PROMPT = """\
12
+ You are a Python programming Q&A assistant for data science learners. You answer \
13
+ questions using the provided Stack Overflow excerpts as your primary source of truth.
14
+
15
+ Rules:
16
+ - Ground your answer in the provided context. When you use information from an \
17
+ excerpt, cite it inline as [1], [2], etc. matching the excerpt numbers.
18
+ - Include short, runnable code examples where they help.
19
+ - If the context does not contain enough information, say so explicitly and then \
20
+ give your best general answer, clearly marked as not sourced from the context.
21
+ - If the question is not about Python programming, politely say you only answer \
22
+ Python programming questions and do not attempt to answer it.
23
+ - Be concise and accurate. Prefer modern Python 3 idioms; if an excerpt shows \
24
+ Python 2 syntax, modernise it and mention that you did.
25
+ """
26
+
27
+ USER_PROMPT_TEMPLATE = """\
28
+ Stack Overflow excerpts:
29
+
30
+ {context}
31
+
32
+ Learner's question: {question}
33
+ """
34
+
35
+
36
+ class Retriever:
37
+ """Thin wrapper around a persistent Chroma collection of SO Python Q&A pairs."""
38
+
39
+ def __init__(self, chroma_dir: str = config.CHROMA_DIR,
40
+ collection_name: str = config.COLLECTION_NAME):
41
+ client = chromadb.PersistentClient(path=chroma_dir)
42
+ self.collection = client.get_collection(collection_name)
43
+
44
+ def count(self) -> int:
45
+ return self.collection.count()
46
+
47
+ def query(self, question: str, k: int = config.TOP_K) -> list[dict]:
48
+ res = self.collection.query(
49
+ query_texts=[question],
50
+ n_results=k,
51
+ include=["documents", "metadatas", "distances"],
52
+ )
53
+ hits = []
54
+ for doc, meta, dist in zip(res["documents"][0], res["metadatas"][0], res["distances"][0]):
55
+ hits.append({
56
+ "text": doc,
57
+ "title": meta["title"],
58
+ "url": meta["url"],
59
+ "answer_score": meta["answer_score"],
60
+ "tags": meta.get("tags", ""),
61
+ # Cosine distance -> similarity in [0, 1].
62
+ "relevance": round(max(0.0, 1.0 - dist), 4),
63
+ "distance": dist,
64
+ })
65
+ return hits
66
+
67
+
68
+ def build_context(hits: list[dict]) -> str:
69
+ blocks = []
70
+ for i, h in enumerate(hits, start=1):
71
+ blocks.append(f"[{i}] {h['title']} (answer score: {h['answer_score']})\n{h['text']}")
72
+ return "\n\n---\n\n".join(blocks)
73
+
74
+
75
+ class RAGPipeline:
76
+ def __init__(self, retriever: Retriever):
77
+ self.retriever = retriever
78
+ self.llm = AsyncGroq(api_key=config.GROQ_API_KEY)
79
+
80
+ async def ask(self, question: str, top_k: int = config.TOP_K) -> dict:
81
+ hits = self.retriever.query(question, k=top_k)
82
+ # RELEVANCE_THRESHOLD is a minimum similarity (0-1); distance = 1 - similarity.
83
+ relevant = [h for h in hits if h["relevance"] >= config.RELEVANCE_THRESHOLD]
84
+ grounded = len(relevant) > 0
85
+ # When nothing passes the threshold, pass all hits anyway —
86
+ # the prompt instructs the model to flag unsourced answers and decline off-topic ones.
87
+ context_hits = relevant if grounded else hits
88
+
89
+ completion = await self.llm.chat.completions.create(
90
+ model=config.GROQ_MODEL,
91
+ messages=[
92
+ {"role": "system", "content": SYSTEM_PROMPT},
93
+ {"role": "user", "content": USER_PROMPT_TEMPLATE.format(
94
+ context=build_context(context_hits), question=question)},
95
+ ],
96
+ temperature=0.2,
97
+ max_tokens=1024,
98
+ timeout=config.LLM_TIMEOUT_SECONDS,
99
+ )
100
+ answer = completion.choices[0].message.content
101
+
102
+ return {
103
+ "answer": answer,
104
+ "sources": [
105
+ {
106
+ "title": h["title"],
107
+ "url": h["url"],
108
+ "relevance": h["relevance"],
109
+ "answer_score": h["answer_score"],
110
+ }
111
+ for h in context_hits
112
+ ],
113
+ "grounded": grounded,
114
+ "model": config.GROQ_MODEL,
115
+ }
app/schemas.py ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic request/response models for the API."""
2
+ from pydantic import BaseModel, Field
3
+
4
+ from app import config
5
+
6
+
7
+ class AskRequest(BaseModel):
8
+ question: str = Field(
9
+ ...,
10
+ min_length=3,
11
+ max_length=config.MAX_QUESTION_LEN,
12
+ description="A Python programming question in natural language.",
13
+ examples=["How do I merge two dictionaries in Python?"],
14
+ )
15
+ top_k: int = Field(
16
+ default=config.TOP_K,
17
+ ge=1,
18
+ le=10,
19
+ description="Number of Stack Overflow Q&A pairs to retrieve for grounding.",
20
+ )
21
+
22
+
23
+ class Source(BaseModel):
24
+ title: str
25
+ url: str
26
+ relevance: float = Field(description="Similarity score in [0, 1]; higher is more relevant.")
27
+ answer_score: int = Field(description="Stack Overflow vote count of the answer used.")
28
+
29
+
30
+ class AskResponse(BaseModel):
31
+ answer: str
32
+ sources: list[Source]
33
+ grounded: bool = Field(
34
+ description="False when no sufficiently relevant Stack Overflow context was found."
35
+ )
36
+ model: str
37
+ latency_ms: int
38
+ cached: bool = False
39
+
40
+
41
+ class HealthResponse(BaseModel):
42
+ status: str
43
+ index_size: int
44
+ model: str