SanketJadhav commited on
Commit
95b3fdd
Β·
1 Parent(s): 7b4b0ca

Add application and requirements file

Browse files
Files changed (2) hide show
  1. app.py +243 -0
  2. requirements.txt +5 -0
app.py ADDED
@@ -0,0 +1,243 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ RAG Document Q&A
3
+ ----------------
4
+ Upload documents (PDF / .txt) or use the built-in sample job postings, then ask
5
+ questions. Answers are generated by an LLM but grounded ONLY in the retrieved
6
+ passages, with inline [n] citations back to the source.
7
+
8
+ Pipeline: ingest -> chunk -> embed (sentence-transformers) -> index (FAISS)
9
+ -> retrieve (cosine top-k) -> generate (Groq, OpenAI-compatible API)
10
+
11
+ Everything except the final generation runs locally on the free Spaces CPU.
12
+ Generation is a single API call to Groq's free tier (no credit card).
13
+ """
14
+
15
+ import os
16
+ import numpy as np
17
+ import gradio as gr
18
+ from sentence_transformers import SentenceTransformer
19
+ import faiss
20
+ from pypdf import PdfReader
21
+ from openai import OpenAI
22
+
23
+ # ----------------------------- Config --------------------------------------
24
+ EMBED_MODEL = "sentence-transformers/all-MiniLM-L6-v2" # ~80 MB, runs on CPU
25
+ GROQ_MODEL = "openai/gpt-oss-20b" # current Groq production model.
26
+ # Model IDs change. List active ones any time at:
27
+ # https://console.groq.com/docs/models (or GET /openai/v1/models)
28
+ CHUNK_SIZE = 200 # words per chunk
29
+ CHUNK_OVERLAP = 40 # words shared between neighbouring chunks
30
+ TOP_K = 4 # passages retrieved per question
31
+
32
+ # Load the embedding model once at startup (reused for every request).
33
+ embedder = SentenceTransformer(EMBED_MODEL)
34
+
35
+
36
+ def get_llm_client():
37
+ """Return a Groq client via the OpenAI-compatible API, or None if no key."""
38
+ key = os.environ.get("GROQ_API_KEY")
39
+ if not key:
40
+ return None
41
+ return OpenAI(api_key=key, base_url="https://api.groq.com/openai/v1")
42
+
43
+
44
+ # ------------------- Sample corpus (so the demo works instantly) -----------
45
+ # Three short, realistic working-student postings with overlapping and
46
+ # distinct skills, so the example questions return interesting answers.
47
+ SAMPLE_DOCS = {
48
+ "werkstudent_data_science_munich.txt": (
49
+ "Working Student Data Science (m/w/d), Munich. You will support our team "
50
+ "in building data pipelines and prototyping machine learning models for "
51
+ "product analytics. Requirements: currently enrolled in computer science, "
52
+ "data science, statistics or a related field. Strong Python skills and "
53
+ "solid knowledge of SQL. Experience with pandas and scikit-learn. "
54
+ "Familiarity with a cloud platform (AWS preferred) is a plus. English is "
55
+ "our working language; German is advantageous but not required. "
56
+ "16 hours per week, hybrid."
57
+ ),
58
+ "ml_engineer_working_student_berlin.txt": (
59
+ "Machine Learning Working Student (m/f/d), Berlin. Join us to bring models "
60
+ "into production. You will work on deep learning models using PyTorch, help "
61
+ "deploy them as APIs, and experiment with large language models and "
62
+ "retrieval-augmented generation. Requirements: completed coursework in "
63
+ "machine learning or neural networks, good Python, hands-on experience with "
64
+ "PyTorch or TensorFlow, and Git. Bonus: Hugging Face Transformers, Docker, "
65
+ "AWS SageMaker. Fluent English required. Fully remote within Germany."
66
+ ),
67
+ "data_analyst_working_student_frankfurt.txt": (
68
+ "Working Student Data Analyst (m/w/d), Frankfurt am Main. You will build "
69
+ "dashboards and reports and derive insights from customer data. "
70
+ "Requirements: enrolled in a quantitative field. Strong SQL, comfortable "
71
+ "with Power BI or Tableau, and good Excel skills. Some Python for data "
72
+ "cleaning is welcome. German at B2 level is required for this client-facing "
73
+ "role; English is a plus. 20 hours per week, on-site."
74
+ ),
75
+ }
76
+
77
+
78
+ # ------------------------------ Ingest -------------------------------------
79
+ def extract_file(path: str) -> str:
80
+ """Read text from a PDF or plain-text file."""
81
+ if path.lower().endswith(".pdf"):
82
+ reader = PdfReader(path)
83
+ return "\n".join((page.extract_text() or "") for page in reader.pages)
84
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
85
+ return f.read()
86
+
87
+
88
+ def chunk_text(text: str, source: str,
89
+ size: int = CHUNK_SIZE, overlap: int = CHUNK_OVERLAP):
90
+ """Split text into overlapping word windows, tagged with their source."""
91
+ words = text.split()
92
+ chunks, start = [], 0
93
+ while start < len(words):
94
+ end = start + size
95
+ piece = " ".join(words[start:end]).strip()
96
+ if piece:
97
+ chunks.append({"text": piece, "source": source})
98
+ if end >= len(words):
99
+ break
100
+ start += size - overlap
101
+ return chunks
102
+
103
+
104
+ # ------------------------- Embed + index -----------------------------------
105
+ def build_index(chunks):
106
+ """Embed chunks and build a cosine-similarity FAISS index."""
107
+ texts = [c["text"] for c in chunks]
108
+ emb = embedder.encode(texts, convert_to_numpy=True,
109
+ normalize_embeddings=True).astype(np.float32)
110
+ index = faiss.IndexFlatIP(emb.shape[1]) # inner product on unit vectors = cosine
111
+ index.add(emb)
112
+ return index
113
+
114
+
115
+ # ------------------------------ Retrieve -----------------------------------
116
+ def retrieve(query, index, chunks, k=TOP_K):
117
+ q = embedder.encode([query], convert_to_numpy=True,
118
+ normalize_embeddings=True).astype(np.float32)
119
+ scores, idxs = index.search(q, min(k, len(chunks)))
120
+ out = []
121
+ for score, i in zip(scores[0], idxs[0]):
122
+ if i != -1:
123
+ out.append({**chunks[i], "score": float(score)})
124
+ return out
125
+
126
+
127
+ # ------------------------------ Generate -----------------------------------
128
+ def generate_answer(query, retrieved, client):
129
+ context = "\n\n".join(
130
+ f"[{i+1}] (source: {r['source']})\n{r['text']}"
131
+ for i, r in enumerate(retrieved)
132
+ )
133
+ system = (
134
+ "You answer questions using ONLY the numbered context passages provided. "
135
+ "Cite the passages you rely on inline with their bracket numbers, e.g. [1] "
136
+ "or [2][3]. If the answer is not contained in the context, say you don't "
137
+ "have enough information rather than guessing."
138
+ )
139
+ user = f"Context:\n{context}\n\nQuestion: {query}\n\nAnswer (with citations):"
140
+ resp = client.chat.completions.create(
141
+ model=GROQ_MODEL,
142
+ messages=[{"role": "system", "content": system},
143
+ {"role": "user", "content": user}],
144
+ temperature=0.2,
145
+ max_tokens=600,
146
+ )
147
+ return resp.choices[0].message.content
148
+
149
+
150
+ # --------------------------- Gradio handlers -------------------------------
151
+ def do_build(files):
152
+ """Build (or rebuild) the index from uploaded files, or the sample corpus."""
153
+ all_chunks = []
154
+ if files:
155
+ for path in files:
156
+ all_chunks += chunk_text(extract_file(path), os.path.basename(path))
157
+ n_docs = len(files)
158
+ else:
159
+ for name, text in SAMPLE_DOCS.items():
160
+ all_chunks += chunk_text(text, name)
161
+ n_docs = len(SAMPLE_DOCS)
162
+
163
+ if not all_chunks:
164
+ return None, "No readable text found. Try a different file."
165
+
166
+ index = build_index(all_chunks)
167
+ state = {"index": index, "chunks": all_chunks}
168
+ src = "uploaded document(s)" if files else "built-in sample job postings"
169
+ return state, f"Indexed {len(all_chunks)} chunks from {n_docs} {src}. Ask away below."
170
+
171
+
172
+ def do_ask(query, state):
173
+ if not state:
174
+ return "Build the index first (the sample corpus loads automatically).", ""
175
+ if not query.strip():
176
+ return "Please enter a question.", ""
177
+
178
+ retrieved = retrieve(query, state["index"], state["chunks"])
179
+ sources_md = "\n\n".join(
180
+ f"**[{i+1}]** *{r['source']}* β€” similarity {r['score']:.2f}\n\n> {r['text'][:300]}…"
181
+ for i, r in enumerate(retrieved)
182
+ )
183
+
184
+ client = get_llm_client()
185
+ if client is None:
186
+ return (
187
+ "**No `GROQ_API_KEY` set**, so generation is off β€” but retrieval works: "
188
+ "the relevant passages are shown under *Retrieved sources*. Add a free "
189
+ "Groq key as a Space secret to enable generated answers.",
190
+ sources_md,
191
+ )
192
+ try:
193
+ answer = generate_answer(query, retrieved, client)
194
+ except Exception as e: # surface API errors instead of crashing the UI
195
+ answer = f"Generation error: {e}\n\n(The retrieved passages are still shown below.)"
196
+ return answer, sources_md
197
+
198
+
199
+ # ------------------------------- UI ----------------------------------------
200
+ with gr.Blocks(title="RAG Document Q&A") as demo:
201
+ gr.Markdown(
202
+ "# πŸ“„ RAG Document Q&A\n"
203
+ "Ask questions and get answers **grounded in your documents**, with inline "
204
+ "`[n]` citations. Upload your own PDFs/text, or just use the built-in "
205
+ "sample job postings (loaded automatically).\n\n"
206
+ "*Retrieval (embeddings + FAISS) runs locally; only the final answer is "
207
+ "generated via Groq's free API.*"
208
+ )
209
+ state = gr.State()
210
+
211
+ with gr.Row():
212
+ files = gr.File(
213
+ label="Upload PDF or .txt (optional β€” leave empty to use sample postings)",
214
+ file_count="multiple", file_types=[".pdf", ".txt"], type="filepath",
215
+ )
216
+ build_btn = gr.Button("Build / Rebuild index", variant="secondary")
217
+ status = gr.Markdown()
218
+
219
+ query = gr.Textbox(label="Your question", lines=2,
220
+ placeholder="e.g. What skills do these data science roles have in common?")
221
+ ask_btn = gr.Button("Ask", variant="primary")
222
+ answer = gr.Markdown()
223
+ with gr.Accordion("Retrieved sources", open=False):
224
+ sources = gr.Markdown()
225
+
226
+ gr.Examples(
227
+ examples=[
228
+ "What skills do these roles have in common?",
229
+ "Which positions require cloud or AWS experience?",
230
+ "Do any of these jobs need German language skills?",
231
+ "Which role is the best fit for someone strong in PyTorch?",
232
+ ],
233
+ inputs=query,
234
+ )
235
+
236
+ build_btn.click(do_build, inputs=[files], outputs=[state, status])
237
+ ask_btn.click(do_ask, inputs=[query, state], outputs=[answer, sources])
238
+ # Auto-build the sample corpus on load so the demo is never an empty box.
239
+ demo.load(lambda: do_build(None), outputs=[state, status])
240
+
241
+
242
+ if __name__ == "__main__":
243
+ demo.launch()
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ gradio
2
+ sentence-transformers
3
+ faiss-cpu
4
+ pypdf
5
+ openai