refractoryevasion commited on
Commit
af094e0
·
verified ·
1 Parent(s): 09ce24a

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +348 -0
app.py ADDED
@@ -0,0 +1,348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import io, re, json, html
3
+ from typing import List
4
+ import numpy as np
5
+ import os
6
+ import subprocess
7
+
8
+ def ensure_tesseract():
9
+ """Install Tesseract at runtime if missing (for Spaces that ignore apt.txt)."""
10
+ try:
11
+ subprocess.run(["tesseract", "--version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
12
+ st.sidebar.success("✅ Tesseract already installed")
13
+ except Exception:
14
+ st.sidebar.warning("Installing Tesseract OCR runtime packages... please wait ⏳")
15
+ try:
16
+ subprocess.run(
17
+ [
18
+ "apt-get", "update"
19
+ ],
20
+ check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
21
+ )
22
+ subprocess.run(
23
+ [
24
+ "apt-get", "install", "-y",
25
+ "tesseract-ocr", "libtesseract-dev",
26
+ "tesseract-ocr-eng", "tesseract-ocr-ara"
27
+ ],
28
+ check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE
29
+ )
30
+ st.sidebar.success("✅ Tesseract installed successfully")
31
+ except Exception as e:
32
+ st.sidebar.error(f"Failed to install Tesseract: {e}")
33
+
34
+ ensure_tesseract()
35
+
36
+
37
+ # Optional Gemini client (kept but not used for flashcards)
38
+ try:
39
+ from google import genai
40
+ except Exception:
41
+ genai = None
42
+
43
+ # PDF extraction helpers
44
+ use_pymupdf = False
45
+ try:
46
+ import fitz
47
+ use_pymupdf = True
48
+ except Exception:
49
+ try:
50
+ from PyPDF2 import PdfReader
51
+ except Exception:
52
+ PdfReader = None
53
+
54
+ # sentence-transformers embedder (optional, used for retrieval)
55
+ try:
56
+ from sentence_transformers import SentenceTransformer
57
+ except Exception:
58
+ SentenceTransformer = None
59
+
60
+ st.set_page_config(page_title="StudyMate — PDF Q&A", layout="wide")
61
+ st.title("📘 StudyMate — PDF Q&A (Basic Flashcards + OCR)")
62
+
63
+ # ---------------- Sidebar ----------------
64
+ st.sidebar.header("Config")
65
+ if "gemini_api_key" not in st.session_state:
66
+ st.session_state.gemini_api_key = ""
67
+ if "gemini_model" not in st.session_state:
68
+ st.session_state.gemini_model = "models/gemini-2.5-pro"
69
+
70
+ st.session_state.gemini_api_key = st.sidebar.text_input(
71
+ "Gemini API Key (optional)", value=st.session_state.gemini_api_key, type="password"
72
+ )
73
+ st.session_state.gemini_model = st.sidebar.text_input(
74
+ "Gemini Model ID (optional)", value=st.session_state.gemini_model
75
+ )
76
+ chunk_size = st.sidebar.number_input("Chunk size (chars)", 200, 5000, 1000, 100)
77
+ chunk_overlap = st.sidebar.number_input("Chunk overlap (chars)", 0, 500, 200, 50)
78
+ top_k = st.sidebar.slider("Top-k retrieval", 1, 8, 3)
79
+ use_llm = st.sidebar.checkbox(
80
+ "Use Gemini for generation (optional for OCR summaries)", value=False
81
+ )
82
+
83
+ # ---------------- Session state ----------------
84
+ if "docs" not in st.session_state: st.session_state.docs = []
85
+ if "chunks" not in st.session_state: st.session_state.chunks = []
86
+ if "chunk_sources" not in st.session_state: st.session_state.chunk_sources = []
87
+ if "embeddings" not in st.session_state: st.session_state.embeddings = np.zeros((0, 384), dtype=np.float32)
88
+ if "quiz_data" not in st.session_state: st.session_state.quiz_data = None
89
+ if "flashcards" not in st.session_state: st.session_state.flashcards = None
90
+ if "last_gemini_raw" not in st.session_state: st.session_state.last_gemini_raw = None
91
+ if "embedder_loaded" not in st.session_state: st.session_state.embedder_loaded = False
92
+
93
+ # ---------------- Helpers ----------------
94
+ @st.cache_resource
95
+ def load_embedder(name: str = "all-MiniLM-L6-v2"):
96
+ if SentenceTransformer is None:
97
+ raise RuntimeError("sentence_transformers not installed.")
98
+ return SentenceTransformer(name)
99
+
100
+ def get_embedder():
101
+ if not st.session_state.embedder_loaded:
102
+ st.session_state.embedder = load_embedder()
103
+ st.session_state.embedder_loaded = True
104
+ return st.session_state.embedder
105
+
106
+ def extract_text_from_pdf_stream(file_stream: io.BytesIO) -> str:
107
+ file_stream.seek(0)
108
+ if use_pymupdf:
109
+ try:
110
+ doc = fitz.open(stream=file_stream.read(), filetype="pdf")
111
+ pages = [p.get_text("text") or "" for p in doc]
112
+ return "\n".join(pages)
113
+ except Exception:
114
+ file_stream.seek(0)
115
+ if PdfReader is not None:
116
+ try:
117
+ file_stream.seek(0)
118
+ reader = PdfReader(file_stream)
119
+ pages = []
120
+ for p in reader.pages:
121
+ try:
122
+ pages.append(p.extract_text() or "")
123
+ except Exception:
124
+ pages.append("")
125
+ return "\n".join(pages)
126
+ except Exception:
127
+ return ""
128
+ return ""
129
+
130
+ def clean_text(s: str) -> str:
131
+ return re.sub(r"\s+", " ", (s or "")).strip()
132
+
133
+ def chunk_text(text: str, size: int, overlap: int) -> List[str]:
134
+ text = text.strip()
135
+ if not text:
136
+ return []
137
+ chunks = []
138
+ start = 0
139
+ L = len(text)
140
+ while start < L:
141
+ end = start + size
142
+ chunks.append(text[start:end].strip())
143
+ start = end - overlap
144
+ if start < 0:
145
+ start = end
146
+ rem = text[start:].strip()
147
+ if rem: chunks.append(rem)
148
+ return [c for c in chunks if c]
149
+
150
+ def embed_texts(embedder, texts):
151
+ if not texts: return np.zeros((0, embedder.get_sentence_embedding_dimension()), dtype=np.float32)
152
+ embs = embedder.encode(texts, convert_to_numpy=True, show_progress_bar=False)
153
+ norms = np.linalg.norm(embs, axis=1, keepdims=True)
154
+ norms[norms==0] = 1.0
155
+ embs = embs / norms
156
+ return embs.astype(np.float32)
157
+
158
+ def similarity_search(query_emb, all_embs, top_k=3):
159
+ if all_embs.shape[0] == 0: return []
160
+ q_norm = np.linalg.norm(query_emb)
161
+ if q_norm == 0: return []
162
+ q = query_emb / q_norm
163
+ sims = np.dot(all_embs, q)
164
+ if top_k < len(sims):
165
+ top_k_indices = np.argpartition(-sims, top_k)[:top_k]
166
+ sorted_top_k = top_k_indices[np.argsort(-sims[top_k_indices])]
167
+ idxs = sorted_top_k
168
+ else:
169
+ idxs = np.argsort(-sims)[:top_k]
170
+ return [(int(i), float(sims[i])) for i in idxs]
171
+
172
+ # ---------------- Simple local flashcard generator ----------------
173
+ def generate_basic_flashcards_from_docs(max_cards: int = 7):
174
+ texts = [d["text"] for d in st.session_state.docs if d.get("text")]
175
+ combined = "\n\n".join(texts).strip()
176
+ if not combined:
177
+ return []
178
+
179
+ paras = [p.strip() for p in re.split(r'\n{1,}', combined) if p.strip()]
180
+ candidates = []
181
+ for i, p in enumerate(paras):
182
+ words = p.split()
183
+ if 2 <= len(words) <= 8:
184
+ definition = ""
185
+ if i+1 < len(paras) and len(paras[i+1].split()) >= 6:
186
+ definition = paras[i+1]
187
+ if definition:
188
+ candidates.append({"term": p, "definition": definition})
189
+ sents = re.split(r'(?<=[.!?])\s+', p)
190
+ for s in sents:
191
+ s_strip = s.strip()
192
+ if not s_strip:
193
+ continue
194
+ sw = s_strip.split()
195
+ if 5 <= len(sw) <= 18:
196
+ term = " ".join(sw[:4])
197
+ definition = s_strip
198
+ candidates.append({"term": term, "definition": definition})
199
+ final = []
200
+ seen = set()
201
+ for c in candidates:
202
+ t = c.get("term","").strip()
203
+ d = c.get("definition","").strip()
204
+ if not t or not d: continue
205
+ key = (t.lower(), d[:80])
206
+ if key in seen: continue
207
+ seen.add(key)
208
+ if len(d) > 500:
209
+ d = d[:500].rsplit(" ",1)[0] + "..."
210
+ final.append({"term": t, "definition": d})
211
+ if len(final) >= max_cards:
212
+ break
213
+ return final
214
+
215
+ # ---------------- Upload & Index ----------------
216
+ st.subheader("1) Upload PDFs & Index")
217
+ uploaded_files = st.file_uploader("Upload PDF files", type=["pdf"], accept_multiple_files=True)
218
+ col1, col2 = st.columns([1,3])
219
+
220
+ with col1:
221
+ if st.button("Process & Index Files"):
222
+ if not uploaded_files:
223
+ st.warning("Upload at least one PDF.")
224
+ else:
225
+ st.session_state.docs = []
226
+ st.session_state.chunks = []
227
+ st.session_state.chunk_sources = []
228
+ embedder = None
229
+ if SentenceTransformer is not None:
230
+ try:
231
+ embedder = get_embedder()
232
+ except Exception:
233
+ embedder = None
234
+
235
+ all_texts = []
236
+ with st.spinner("Extracting text..."):
237
+ for f in uploaded_files:
238
+ try:
239
+ bio = io.BytesIO(f.read())
240
+ text = extract_text_from_pdf_stream(bio)
241
+ text = clean_text(text)
242
+ st.session_state.docs.append({"name": f.name, "text": text})
243
+ all_texts.append((f.name, text))
244
+ except Exception as e:
245
+ st.error(f"Failed to read {f.name}: {e}")
246
+ for name, text in all_texts:
247
+ chs = chunk_text(text, size=chunk_size, overlap=chunk_overlap)
248
+ for c in chs:
249
+ st.session_state.chunks.append(c)
250
+ st.session_state.chunk_sources.append(name)
251
+ if st.session_state.chunks and embedder is not None:
252
+ with st.spinner("Creating embeddings..."):
253
+ try:
254
+ embs = embed_texts(embedder, st.session_state.chunks)
255
+ st.session_state.embeddings = embs
256
+ except Exception:
257
+ st.session_state.embeddings = np.zeros((0, 384), dtype=np.float32)
258
+ st.success(f"Indexed {len(st.session_state.chunks)} chunks from {len(all_texts)} files.")
259
+ else:
260
+ if not st.session_state.chunks:
261
+ st.warning("No text extracted from the uploaded files.")
262
+ else:
263
+ st.success(f"Indexed {len(st.session_state.chunks)} chunks (embeddings not created).")
264
+ st.session_state.quiz_data = None
265
+ st.session_state.flashcards = None
266
+ st.session_state.last_gemini_raw = None
267
+
268
+ with col2:
269
+ if st.session_state.docs:
270
+ with st.expander("Indexed documents"):
271
+ for d in st.session_state.docs:
272
+ st.write(f"- **{d['name']}** — {len(d['text'])} chars")
273
+ st.write(f"**Total chunks:** {len(st.session_state.chunks)}")
274
+
275
+ # ---------------- OCR Section ----------------
276
+ st.markdown("---")
277
+ st.subheader("📷 Image to Text & Highlights (OCR)")
278
+
279
+ uploaded_images = st.file_uploader(
280
+ "Upload one or more images (JPG, PNG)",
281
+ type=["png", "jpg", "jpeg"],
282
+ accept_multiple_files=True,
283
+ )
284
+
285
+ if st.button("Run OCR & Summarize"):
286
+ if not uploaded_images:
287
+ st.warning("Please upload at least one image.")
288
+ else:
289
+ try:
290
+ import pytesseract
291
+ from PIL import Image
292
+ except ImportError:
293
+ st.error("Install pytesseract and pillow: pip install pytesseract pillow")
294
+ else:
295
+ ocr_texts = []
296
+ with st.spinner("Running OCR on uploaded images..."):
297
+ for img_file in uploaded_images:
298
+ try:
299
+ img = Image.open(img_file)
300
+ text = pytesseract.image_to_string(img)
301
+ clean_txt = re.sub(r"\s+", " ", text.strip())
302
+ ocr_texts.append((img_file.name, clean_txt))
303
+ except Exception as e:
304
+ st.error(f"OCR failed for {img_file.name}: {e}")
305
+
306
+ if not ocr_texts:
307
+ st.warning("No text extracted from any image.")
308
+ else:
309
+ combined_text = "\n\n".join(t for _, t in ocr_texts if t)
310
+
311
+ # Optional Gemini summarization
312
+ if use_llm and st.session_state.gemini_api_key and genai:
313
+ try:
314
+ client = genai.Client(api_key=st.session_state.gemini_api_key)
315
+ prompt = f"Summarize the following extracted text into 5-7 concise bullet points:\n\n{combined_text}"
316
+ resp = client.models.generate_content(model=st.session_state.gemini_model, contents=prompt)
317
+ summary_text = resp.text.strip()
318
+ st.markdown("### ✨ Gemini Highlights")
319
+ st.markdown(summary_text)
320
+ except Exception as e:
321
+ st.error(f"Gemini summarization failed: {e}")
322
+ else:
323
+ # Local summarization
324
+ sentences = re.split(r'(?<=[.!?])\s+', combined_text)
325
+ bullet_points = []
326
+ for s in sentences:
327
+ s = s.strip()
328
+ if len(s) > 15 and len(s) < 180:
329
+ bullet_points.append("• " + s)
330
+ if len(bullet_points) >= 6:
331
+ break
332
+ if not bullet_points:
333
+ bullet_points = ["No clear textual content detected."]
334
+ st.success("OCR and summarization complete ✅")
335
+ st.subheader("Extracted Highlights:")
336
+ st.markdown("\n".join(bullet_points))
337
+
338
+ with st.expander("Show Full Extracted OCR Text"):
339
+ for name, txt in ocr_texts:
340
+ st.markdown(f"**{name}**")
341
+ st.text_area("Text", value=txt, height=200)
342
+
343
+ # ---------------- Flashcards (existing section) ----------------
344
+ st.markdown("---")
345
+ st.subheader("Flashcards (Basic)")
346
+ # ... your flashcard generation and rendering section remains unchanged ...
347
+
348
+ # End of app