SANJAY788 commited on
Commit
d30eff4
Β·
verified Β·
1 Parent(s): af094e0

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +506 -116
app.py CHANGED
@@ -2,44 +2,24 @@ 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:
@@ -51,14 +31,14 @@ except Exception:
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")
@@ -67,18 +47,26 @@ if "gemini_api_key" not in st.session_state:
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 = []
@@ -169,48 +157,194 @@ def similarity_search(query_emb, all_embs, top_k=3):
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")
@@ -225,13 +359,7 @@ with col1:
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:
@@ -248,19 +376,13 @@ with col1:
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
@@ -272,7 +394,276 @@ with col2:
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
 
@@ -280,17 +671,20 @@ 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..."):
@@ -308,19 +702,20 @@ if st.button("Run OCR & Summarize"):
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:
@@ -337,12 +732,7 @@ if st.button("Run OCR & Summarize"):
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
 
2
  import io, re, json, html
3
  from typing import List
4
  import numpy as np
5
+ import math
 
6
 
7
+ # Optional Gemini client
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  try:
9
  from google import genai
10
  except Exception:
11
  genai = None
12
 
13
+ # Optional transformers (Hugging Face pipeline)
14
+ try:
15
+ from transformers import pipeline, AutoTokenizer, AutoModelForCausalLM
16
+ TRANSFORMERS_AVAILABLE = True
17
+ except Exception:
18
+ TRANSFORMERS_AVAILABLE = False
19
+ pipeline = None
20
+ AutoTokenizer = None
21
+ AutoModelForCausalLM = None
22
+
23
  # PDF extraction helpers
24
  use_pymupdf = False
25
  try:
 
31
  except Exception:
32
  PdfReader = None
33
 
34
+ # Sentence-transformers embedder
35
  try:
36
  from sentence_transformers import SentenceTransformer
37
  except Exception:
38
  SentenceTransformer = None
39
 
40
  st.set_page_config(page_title="StudyMate β€” PDF Q&A", layout="wide")
41
+ st.title("πŸ“˜ StudyMate β€” PDF Q&A")
42
 
43
  # ---------------- Sidebar ----------------
44
  st.sidebar.header("Config")
 
47
  if "gemini_model" not in st.session_state:
48
  st.session_state.gemini_model = "models/gemini-2.5-pro"
49
 
50
+ # Add HF pipeline settings
51
+ if "use_hf_pipeline" not in st.session_state:
52
+ st.session_state.use_hf_pipeline = False
53
+ if "hf_model_id" not in st.session_state:
54
+ st.session_state.hf_model_id = "ibm-granite/granite-3.3-2b-instruct"
55
+ if "hf_api_token" not in st.session_state:
56
+ st.session_state.hf_api_token = ""
57
+
58
+ st.session_state.gemini_api_key = st.sidebar.text_input("Gemini API Key", value=st.session_state.gemini_api_key, type="password")
59
+ st.session_state.gemini_model = st.sidebar.text_input("Gemini Model ID", value=st.session_state.gemini_model)
60
+
61
+ st.sidebar.markdown("---")
62
+ st.session_state.use_hf_pipeline = st.sidebar.checkbox("Enable Hugging Face pipeline (IBM Granite)", value=st.session_state.use_hf_pipeline)
63
+ st.session_state.hf_model_id = st.sidebar.text_input("HF Model ID", value=st.session_state.hf_model_id)
64
+ st.session_state.hf_api_token = st.sidebar.text_input("HF API token (optional)", value=st.session_state.hf_api_token, type="password")
65
+
66
  chunk_size = st.sidebar.number_input("Chunk size (chars)", 200, 5000, 1000, 100)
67
  chunk_overlap = st.sidebar.number_input("Chunk overlap (chars)", 0, 500, 200, 50)
68
  top_k = st.sidebar.slider("Top-k retrieval", 1, 8, 3)
69
+ use_llm = st.sidebar.checkbox("Use Gemini/HF for generation", value=True)
 
 
70
 
71
  # ---------------- Session state ----------------
72
  if "docs" not in st.session_state: st.session_state.docs = []
 
157
  idxs = np.argsort(-sims)[:top_k]
158
  return [(int(i), float(sims[i])) for i in idxs]
159
 
160
+ def get_context_from_hits(hits):
161
+ if not hits: return ""
162
+ ctx_chunks = []
163
+ for idx, score in hits:
164
+ src = st.session_state.chunk_sources[idx] if idx < len(st.session_state.chunk_sources) else "unknown"
165
+ chunk_text = st.session_state.chunks[idx]
166
+ ctx_chunks.append(f"CHUNK (source: {src}, score: {score:.4f})\n{chunk_text}")
167
+ return "\n\n---\n\n".join(ctx_chunks)
168
 
169
+ # ---------------- Gemini wrapper ----------------
170
+ def make_gemini_client():
171
+ if genai is None:
172
+ raise RuntimeError("google-genai package not installed.")
173
+ key = st.session_state.get("gemini_api_key")
174
+ if not key:
175
+ raise RuntimeError("Gemini API key not set.")
176
+ return genai.Client(api_key=key)
177
+
178
+ def call_gemini_with_context(prompt: str, model_id: str, context: str, max_output_tokens: int = 512):
179
+ client = make_gemini_client()
180
+ MAX_CTX = 20000
181
+ ctx = context if len(context) <= MAX_CTX else context[:MAX_CTX]
182
+ full_prompt = f"{prompt}\n\nCONTEXT:\n{ctx}\n\nAnswer:"
183
+ variants = [
184
+ lambda: client.models.generate_content(model=model_id, contents=full_prompt, max_output_tokens=max_output_tokens),
185
+ lambda: client.models.generate_content(model=model_id, contents=full_prompt, max_tokens=max_output_tokens),
186
+ lambda: client.models.generate_content(model=model_id, inputs=full_prompt, max_output_tokens=max_output_tokens),
187
+ lambda: client.models.generate_content(model=model_id, contents=full_prompt),
188
+ ]
189
+ last_exc = None
190
+ resp = None
191
+ for fn in variants:
192
+ try:
193
+ resp = fn()
 
 
 
 
 
 
 
 
194
  break
195
+ except Exception as e:
196
+ last_exc = e
197
+ emsg = str(e).lower()
198
+ if any(x in emsg for x in ("quota", "unauthorized", "permission", "forbidden", "not found", "403", "401")):
199
+ raise
200
+ continue
201
+ if resp is None:
202
+ raise RuntimeError(f"Gemini call failed. Last error: {last_exc}")
203
+
204
+ out_text = ""
205
+ try:
206
+ if hasattr(resp, "candidates") and resp.candidates:
207
+ cand = resp.candidates[0]
208
+ if hasattr(cand, "content") and cand.content:
209
+ parts = getattr(cand.content, "parts", None)
210
+ if parts:
211
+ texts = []
212
+ for p in parts:
213
+ if hasattr(p, "text") and p.text:
214
+ texts.append(str(p.text))
215
+ elif isinstance(p, dict) and p.get("text"):
216
+ texts.append(str(p.get("text")))
217
+ if texts: out_text = "\n".join(texts)
218
+ except Exception:
219
+ out_text = ""
220
+
221
+ if not out_text:
222
+ try:
223
+ out_text = getattr(resp, "output_text", "") or out_text
224
+ except Exception:
225
+ out_text = out_text
226
+
227
+ if not out_text:
228
+ try:
229
+ d = resp.to_dict() if hasattr(resp, "to_dict") else (resp if isinstance(resp, dict) else None)
230
+ if isinstance(d, dict):
231
+ if "candidates" in d and isinstance(d["candidates"], list) and d["candidates"]:
232
+ first = d["candidates"][0]
233
+ if isinstance(first, dict):
234
+ cont = first.get("content") or first.get("message") or {}
235
+ if isinstance(cont, dict):
236
+ parts = cont.get("parts") or []
237
+ texts = []
238
+ for p in parts:
239
+ if isinstance(p, dict) and p.get("text"):
240
+ texts.append(p.get("text"))
241
+ if texts:
242
+ out_text = "\n".join(texts)
243
+ for k in ("generated_text", "output_text", "text", "content", "result"):
244
+ if k in d and isinstance(d[k], str) and d[k].strip():
245
+ out_text = d[k]; break
246
+ except Exception:
247
+ pass
248
+
249
+ if not out_text:
250
+ try:
251
+ out_text = str(resp)
252
+ except Exception:
253
+ out_text = ""
254
+
255
+ # store raw only for debugging; do NOT show by default
256
+ st.session_state.last_gemini_raw = out_text
257
+ return out_text
258
+
259
+ # ---------------- Hugging Face pipeline wrapper ----------------
260
+ @st.cache_resource
261
+ def init_hf_pipeline(model_id: str):
262
+ if not TRANSFORMERS_AVAILABLE:
263
+ raise RuntimeError("transformers package not installed.")
264
+ # allow pass-through of token via environment or huggingface-cli; this snippet does not set env
265
+ # We'll load model/tokenizer using Auto classes for safety; for very large models you may want to use from_pretrained with device_map
266
+ try:
267
+ # Use the pipeline abstraction for text generation
268
+ gen_pipe = pipeline("text-generation", model=model_id, trust_remote_code=True)
269
+ return gen_pipe
270
+ except Exception as e:
271
+ # Try a lighter loader fallback
272
+ try:
273
+ tokenizer = AutoTokenizer.from_pretrained(model_id)
274
+ model = AutoModelForCausalLM.from_pretrained(model_id)
275
+ gen_pipe = pipeline("text-generation", model=model, tokenizer=tokenizer)
276
+ return gen_pipe
277
+ except Exception as e2:
278
+ raise RuntimeError(f"Failed to initialize HF pipeline for {model_id}: {e}; fallback error: {e2}")
279
+
280
+ def call_hf_pipeline(prompt: str, model_id: str, max_new_tokens: int = 512):
281
+ """
282
+ Calls HF pipeline. Returns generated text string.
283
+ """
284
+ if not TRANSFORMERS_AVAILABLE:
285
+ raise RuntimeError("transformers package not installed.")
286
+ # initialize
287
+ try:
288
+ pipe = init_hf_pipeline(model_id)
289
+ except Exception as e:
290
+ raise RuntimeError(f"Failed to init HF pipeline: {e}")
291
+ # The user's example used a 'messages' structure. Pipeline expects text; we pass prompt text.
292
+ try:
293
+ out = pipe(prompt, max_new_tokens=max_new_tokens, do_sample=False)
294
+ # pipeline returns list of dicts [{'generated_text': '...'}]
295
+ if isinstance(out, list) and out:
296
+ text = out[0].get("generated_text") or out[0].get("text") or str(out[0])
297
+ return text
298
+ return str(out)
299
+ except Exception as e:
300
+ raise RuntimeError(f"HF pipeline generation failed: {e}")
301
+
302
+ # ---------------- JSON/HTML cleaning helpers ----------------
303
+ def extract_json_substring(text: str):
304
+ if not text:
305
+ return None
306
+ t = re.sub(r"^```(?:json)?\s*|\s*```$", "", text.strip(), flags=re.IGNORECASE)
307
+ fenced = re.findall(r"```(?:json)?\s*(\{.*?\}|\[.*?\])\s*```", text, flags=re.DOTALL | re.IGNORECASE)
308
+ if fenced:
309
+ return fenced[0]
310
+ arr = re.search(r"(\[.*\])", t, flags=re.DOTALL)
311
+ if arr: return arr.group(1)
312
+ obj = re.search(r"(\{.*\})", t, flags=re.DOTALL)
313
+ if obj: return obj.group(1)
314
+ return None
315
+
316
+ def clean_json_like_output(raw: str):
317
+ if not raw: return raw
318
+ s = raw.strip()
319
+ s = re.sub(r"^```(?:json)?\s*", "", s, flags=re.IGNORECASE)
320
+ s = re.sub(r"```$", "", s, flags=re.IGNORECASE).strip()
321
+ js = extract_json_substring(s)
322
+ if js: return js
323
+ return s
324
+
325
+ def html_term_def_pairs_from_text(html_text: str):
326
+ out = []
327
+ if not html_text:
328
+ return out
329
+ term_classes = r'(?:card-term|flashcard-term|term|front-term|term-title)'
330
+ def_classes = r'(?:card-def|flashcard-back-content|definition|def|desc|front-def|back-def)'
331
+ term_pat = rf'<[^>]*class=["\']{term_classes}["\'][^>]*>(.*?)</'
332
+ def_pat = rf'<[^>]*class=["\']{def_classes}["\'][^>]*>(.*?)</'
333
+ term_matches = re.findall(term_pat, html_text, flags=re.IGNORECASE | re.DOTALL)
334
+ def_matches = re.findall(def_pat, html_text, flags=re.IGNORECASE | re.DOTALL)
335
+ if term_matches and def_matches and len(term_matches) == len(def_matches):
336
+ for t, d in zip(term_matches, def_matches):
337
+ t_clean = re.sub(r'<[^>]+>', '', t).strip()
338
+ d_clean = re.sub(r'<[^>]+>', '', d).strip()
339
+ out.append({"term": t_clean, "definition": d_clean})
340
+ return out
341
+ divs = re.findall(r'<div[^>]*>(.*?)</div>', html_text, flags=re.DOTALL | re.IGNORECASE)
342
+ divs = [re.sub(r'<[^>]+>', '', d).strip() for d in divs if d.strip()]
343
+ for i in range(0, len(divs)-1, 2):
344
+ t = divs[i]; d = divs[i+1]
345
+ if len(t) < 100 and len(d) < 2000:
346
+ out.append({"term": t, "definition": d})
347
+ return out
348
 
349
  # ---------------- Upload & Index ----------------
350
  st.subheader("1) Upload PDFs & Index")
 
359
  st.session_state.docs = []
360
  st.session_state.chunks = []
361
  st.session_state.chunk_sources = []
362
+ embedder = get_embedder()
 
 
 
 
 
 
363
  all_texts = []
364
  with st.spinner("Extracting text..."):
365
  for f in uploaded_files:
 
376
  for c in chs:
377
  st.session_state.chunks.append(c)
378
  st.session_state.chunk_sources.append(name)
379
+ if st.session_state.chunks:
380
  with st.spinner("Creating embeddings..."):
381
+ embs = embed_texts(embedder, st.session_state.chunks)
382
+ st.session_state.embeddings = embs
 
 
 
383
  st.success(f"Indexed {len(st.session_state.chunks)} chunks from {len(all_texts)} files.")
384
  else:
385
+ st.warning("No text extracted from the uploaded files.")
 
 
 
386
  st.session_state.quiz_data = None
387
  st.session_state.flashcards = None
388
  st.session_state.last_gemini_raw = None
 
394
  st.write(f"- **{d['name']}** β€” {len(d['text'])} chars")
395
  st.write(f"**Total chunks:** {len(st.session_state.chunks)}")
396
 
397
+ # ---------------- Tools ----------------
398
+ st.markdown("---")
399
+ st.subheader("2) Tools")
400
+
401
+ def build_context_for_action(query_text: str = None):
402
+ if st.session_state.chunks and st.session_state.embeddings.shape[0] > 0 and query_text:
403
+ try:
404
+ embedder = get_embedder()
405
+ q_emb = embedder.encode(query_text, convert_to_numpy=True)
406
+ hits = similarity_search(q_emb, st.session_state.embeddings, top_k=top_k)
407
+ ctx = get_context_from_hits(hits)
408
+ if ctx:
409
+ return ctx
410
+ except Exception:
411
+ pass
412
+ return "\n\n".join([d["text"] for d in st.session_state.docs]) if st.session_state.docs else ""
413
+
414
+ def call_tool(prompt: str, question: str = None, max_out=512):
415
+ """
416
+ Main LLM caller. Priority:
417
+ - Gemini (if use_llm and gemini key present)
418
+ - Hugging Face pipeline (if enabled)
419
+ - returns helpful error messages if neither available
420
+ """
421
+ if not use_llm:
422
+ return "LLM disabled in sidebar."
423
+ context = build_context_for_action(question or prompt)
424
+ if not context:
425
+ return "No document text available. Upload & index PDFs first."
426
+
427
+ # 1) Try Gemini if user has set key & use_llm True
428
+ if st.session_state.get("gemini_api_key"):
429
+ try:
430
+ raw = call_gemini_with_context(prompt, st.session_state.gemini_model, context, max_output_tokens=max_out)
431
+ return raw
432
+ except Exception as e:
433
+ # store the error in last_gemini_raw for debugging
434
+ st.session_state.last_gemini_raw = f"Gemini failed: {e}"
435
+ # fall through to HF pipeline if enabled
436
+ # 2) Try HF pipeline (if enabled)
437
+ if st.session_state.get("use_hf_pipeline"):
438
+ hf_model = st.session_state.get("hf_model_id") or st.session_state.hf_model_id
439
+ try:
440
+ # build prompt: we include context so the HF model sees it (be mindful of token limits)
441
+ MAX_CTX = 20000
442
+ ctx = context if len(context) <= MAX_CTX else context[:MAX_CTX]
443
+ full_prompt = f"{prompt}\n\nCONTEXT:\n{ctx}\n\nAnswer:"
444
+ raw = call_hf_pipeline(full_prompt, hf_model, max_new_tokens=max_out)
445
+ return raw
446
+ except Exception as e:
447
+ return f"HF pipeline failed: {e}"
448
+
449
+ # Nothing available
450
+ return "No LLM available: set Gemini API key in sidebar or enable HF pipeline."
451
+
452
+ # Summary
453
+ if st.button("Generate Summary"):
454
+ PROMPT = "Summarize the context in 5 concise bullet points. Use only the context."
455
+ raw = call_tool(PROMPT, question="summary", max_out=400)
456
+ st.subheader("Summary")
457
+ st.write(raw or "No output")
458
+
459
+ # Q&A
460
+ query = st.text_input("Ask a question about your PDFs:")
461
+ if st.button("Get Answer"):
462
+ if not query.strip():
463
+ st.warning("Enter a question.")
464
+ else:
465
+ PROMPT = f"Answer the question using ONLY the context. QUESTION: {query}"
466
+ raw = call_tool(PROMPT, question=query, max_out=512)
467
+ st.subheader("Answer")
468
+ st.write(raw or "No output")
469
+
470
+ # Quiz generation
471
+ if st.button("Generate Quiz (5 MCQs)"):
472
+ PROMPT = (
473
+ "Generate exactly 5 MCQs based ONLY on the context. "
474
+ "Return JSON array only: [{\"question\":\"...\",\"options\":[\"A\",\"B\",\"C\",\"D\"],\"answer\":\"A\"}, ...]"
475
+ )
476
+ raw = call_tool(PROMPT, question="quiz", max_out=800)
477
+ cleaned = clean_json_like_output(raw or "")
478
+ parsed = None
479
+ try:
480
+ parsed = json.loads(cleaned)
481
+ except Exception:
482
+ parsed = None
483
+ if isinstance(parsed, list):
484
+ norm = []
485
+ for it in parsed:
486
+ qtext = it.get("question") if isinstance(it.get("question"), str) else str(it.get("question"))
487
+ opts = list(it.get("options") or [])
488
+ if len(opts) < 4: opts = opts + ["(option)"]*(4-len(opts))
489
+ norm.append({"question": qtext, "options": opts, "answer": str(it.get("answer") or "").strip()})
490
+ st.session_state.quiz_data = norm
491
+ st.success("Quiz parsed.")
492
+ st.session_state.last_gemini_raw = None
493
+ else:
494
+ st.warning("Could not parse quiz output. Use 'Inspect raw output' to debug.")
495
+ if st.session_state.last_gemini_raw:
496
+ with st.expander("Inspect raw Gemini output (parsing failed)"):
497
+ st.code(st.session_state.last_gemini_raw)
498
+
499
+ # Render interactive quiz
500
+ if st.session_state.get("quiz_data"):
501
+ st.markdown("### 🧠 Quiz (interactive)")
502
+ for i, q in enumerate(st.session_state.quiz_data, start=1):
503
+ st.markdown(f"**Q{i}. {q.get('question','')}**")
504
+ options = q.get("options", [])
505
+ choice = st.radio(f"Select for Q{i}:", options, key=f"quiz_q{i}")
506
+ if st.button(f"Check Q{i}", key=f"check_{i}"):
507
+ correct_field = q.get("answer","")
508
+ correct_text = None
509
+ if isinstance(correct_field, str) and len(correct_field.strip()) == 1 and correct_field.strip().upper() in "ABCD":
510
+ idx = ord(correct_field.strip().upper()) - 65
511
+ if 0 <= idx < len(options): correct_text = options[idx]
512
+ if correct_text is None:
513
+ if correct_field in options: correct_text = correct_field
514
+ if correct_text is None:
515
+ cf = str(correct_field).strip()
516
+ if cf and cf[0].upper() in "ABCD":
517
+ idx = ord(cf[0].upper()) - 65
518
+ if 0 <= idx < len(options): correct_text = options[idx]
519
+ if correct_text is None: correct_text = str(correct_field)
520
+ if choice == correct_text:
521
+ st.success("βœ… Correct")
522
+ else:
523
+ # show the option text if we have it, else show raw answer
524
+ show_ans = correct_text if correct_text else str(correct_field)
525
+ st.error(f"❌ Incorrect β€” correct: {show_ans}")
526
+ st.markdown("---")
527
+
528
+ # Flashcards generation & rendering (kept your block, with HF/Gemini caller preserved)
529
+ if st.button("Generate Flashcards (7)"):
530
+ PROMPT = "Generate 7 flashcards in pure JSON: [{\"term\":\"T\",\"definition\":\"D\"}, ...]. Return JSON only."
531
+ raw = call_tool(PROMPT, question="flashcards", max_out=800)
532
+ cleaned = clean_json_like_output(raw or "")
533
+ parsed_cards = []
534
+ parsed_ok = False
535
+
536
+ # Try JSON parse
537
+ try:
538
+ j = json.loads(cleaned)
539
+ if isinstance(j, list) and j:
540
+ for item in j:
541
+ t = item.get("term") if isinstance(item.get("term"), str) else str(item.get("term") or "")
542
+ d = item.get("definition") if isinstance(item.get("definition"), str) else str(item.get("definition") or "")
543
+ parsed_cards.append({"term": t.strip(), "definition": d.strip()})
544
+ parsed_ok = True
545
+ except Exception:
546
+ parsed_ok = False
547
+
548
+ # Fallback: attempt smart HTML extraction
549
+ if not parsed_ok and raw:
550
+ pairs = html_term_def_pairs_from_text(raw)
551
+ if pairs:
552
+ parsed_cards = pairs
553
+ parsed_ok = True
554
+
555
+ # Fallback: try extracting JSON-like objects in text
556
+ if not parsed_ok and raw:
557
+ objs = re.findall(r'\{[^{}]{10,}\}', raw, flags=re.DOTALL)
558
+ for o in objs:
559
+ try:
560
+ od = json.loads(o)
561
+ if isinstance(od, dict) and (od.get("term") or od.get("definition")):
562
+ t = od.get("term") or od.get("question") or ""
563
+ d = od.get("definition") or od.get("answer") or ""
564
+ parsed_cards.append({"term": str(t).strip(), "definition": str(d).strip()})
565
+ except Exception:
566
+ continue
567
+ if parsed_cards:
568
+ parsed_ok = True
569
+
570
+ if parsed_ok and parsed_cards:
571
+ # dedupe & normalize
572
+ final = []
573
+ seen = set()
574
+ for c in parsed_cards:
575
+ term = (c.get("term") or "").strip()
576
+ definition = (c.get("definition") or "").strip()
577
+ if not term and not definition:
578
+ continue
579
+ key = (term.lower(), definition[:80])
580
+ if key in seen:
581
+ continue
582
+ seen.add(key)
583
+ final.append({"term": term or "Untitled", "definition": definition or ""})
584
+ st.session_state.flashcards = final[:20]
585
+ st.success(f"Flashcards parsed: {len(st.session_state.flashcards)}")
586
+ st.session_state.last_gemini_raw = None
587
+ else:
588
+ st.session_state.flashcards = None
589
+ st.warning("Could not parse flashcards automatically. Inspect raw output to debug.")
590
+ if st.session_state.last_gemini_raw:
591
+ with st.expander("Inspect raw Gemini output (parsing failed)"):
592
+ st.code(st.session_state.last_gemini_raw)
593
+
594
+ # Render flashcards (colored basic view). This renders HTML via st.components.v1.html for reliability
595
+ if st.session_state.get("flashcards"):
596
+ st.markdown("### πŸ’‘ Flashcards β€” Colored View")
597
+ # CSS β€” make cards size consistent and content fill inside
598
+ card_css = (
599
+ "<style>"
600
+ ".cards-grid { display:flex; flex-wrap:wrap; gap:18px; align-items:flex-start; margin-bottom:18px; }"
601
+ ".basic-card { width: 320px; min-height: 150px; border-radius: 12px; padding: 14px; box-sizing: border-box; display:flex; flex-direction:column; color: #fff; box-shadow: 0 6px 18px rgba(0,0,0,0.25); overflow:hidden; }"
602
+ ".card-term { font-weight:800; font-size:16px; margin-bottom:10px; text-align:center; }"
603
+ ".card-def { flex:1 1 auto; overflow:auto; font-size:14px; line-height:1.45; white-space:normal; word-wrap:break-word; }"
604
+ ".card-color-0 { background: linear-gradient(135deg, #6EE7B7 0%, #3B82F6 100%); color:#042c2e; }"
605
+ ".card-color-1 { background: linear-gradient(135deg, #FDE68A 0%, #F59E0B 100%); color:#2b2b0b; }"
606
+ ".card-color-2 { background: linear-gradient(135deg, #A78BFA 0%, #7C3AED 100%); color:#ffffff; }"
607
+ ".card-color-3 { background: linear-gradient(135deg, #FDB4BF 0%, #F472B6 100%); color:#2b0b10; }"
608
+ ".card-color-4 { background: linear-gradient(135deg, #93C5FD 0%, #2563EB 100%); color:#04203b; }"
609
+ ".card-color-5 { background: linear-gradient(135deg, #C7F9CC 0%, #10B981 100%); color:#05321f; }"
610
+ "@media (max-width:900px) { .basic-card { width: 95%; } }"
611
+ "</style>"
612
+ )
613
+
614
+ # Build HTML for all cards (escape text to avoid raw HTML injection)
615
+ card_html = card_css + '<div class="cards-grid">'
616
+ palette_count = 6
617
+ for idx, card in enumerate(st.session_state.flashcards):
618
+ cls = f"card-color-{idx % palette_count}"
619
+ # escape values so nothing renders raw
620
+ term = html.escape(card.get("term", "")) # safe
621
+ definition = html.escape(card.get("definition", "")).replace("\n", "<br/>")
622
+ # ensure small content fits β€” definition inside .card-def will scroll if long
623
+ card_html += f"""
624
+ <div class="basic-card {cls}">
625
+ <div class="card-term">{term}</div>
626
+ <div class="card-def">{definition}</div>
627
+ </div>
628
+ """
629
+ card_html += "</div>"
630
+
631
+ # Render the card HTML using the HTML component so it's interpreted as HTML reliably.
632
+ # We keep escaping of term/definition above (html.escape) to avoid injection.
633
+ try:
634
+ # estimate height: base 220 + 180 per row (approx 3 cards per row)
635
+ cards_per_row = 3
636
+ rows = math.ceil(len(st.session_state.flashcards) / cards_per_row)
637
+ height = min(1200, 220 + rows * 180)
638
+ except Exception:
639
+ height = 600
640
+
641
+ st.components.v1.html(card_html, height=height, scrolling=True)
642
+ st.caption("Tip: long definitions will scroll inside the card. Use 'Generate Flashcards' again to refresh.")
643
+
644
+ # ----------------- OCR integration (APPENDED; does not change anything above) -----------------
645
+ # The user asked not to modify any code above; OCR helpers and UI are added below.
646
+
647
+ def ensure_tesseract():
648
+ """Install or check Tesseract at runtime if missing."""
649
+ try:
650
+ import subprocess
651
+ subprocess.run(["tesseract", "--version"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
652
+ # don't write sidebar messages during import-time in some runtimes; safe to attempt
653
+ except Exception:
654
+ try:
655
+ import subprocess
656
+ subprocess.run(["apt-get", "update"], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
657
+ subprocess.run([
658
+ "apt-get", "install", "-y",
659
+ "tesseract-ocr", "libtesseract-dev",
660
+ "tesseract-ocr-eng", "tesseract-ocr-ara"
661
+ ], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
662
+ except Exception:
663
+ # best-effort; if installation fails, OCR later will error and show message
664
+ pass
665
+
666
+ # OCR UI
667
  st.markdown("---")
668
  st.subheader("πŸ“· Image to Text & Highlights (OCR)")
669
 
 
671
  "Upload one or more images (JPG, PNG)",
672
  type=["png", "jpg", "jpeg"],
673
  accept_multiple_files=True,
674
+ key="ocr_images_uploader"
675
  )
676
 
677
+ if st.button("Run OCR & Summarize (Images)"):
678
  if not uploaded_images:
679
  st.warning("Please upload at least one image.")
680
  else:
681
+ # ensure tesseract binary present (best-effort)
682
+ ensure_tesseract()
683
  try:
684
  import pytesseract
685
  from PIL import Image
686
+ except Exception:
687
+ st.error("OCR dependencies missing. Install pytesseract and pillow: pip install pytesseract pillow")
688
  else:
689
  ocr_texts = []
690
  with st.spinner("Running OCR on uploaded images..."):
 
702
  else:
703
  combined_text = "\n\n".join(t for _, t in ocr_texts if t)
704
 
705
+ # Optional Gemini summarization (if user enabled & key present)
706
+ if use_llm and st.session_state.get("gemini_api_key") and genai:
707
  try:
708
  client = genai.Client(api_key=st.session_state.gemini_api_key)
709
+ prompt = f"Summarize the following extracted text into 5 concise bullet points:\n\n{combined_text}"
710
  resp = client.models.generate_content(model=st.session_state.gemini_model, contents=prompt)
711
+ # resp parsing best-effort
712
+ summary_text = getattr(resp, 'text', None) or str(resp)
713
  st.markdown("### ✨ Gemini Highlights")
714
  st.markdown(summary_text)
715
  except Exception as e:
716
  st.error(f"Gemini summarization failed: {e}")
717
  else:
718
+ # Local simple summarization: pick short sentences
719
  sentences = re.split(r'(?<=[.!?])\s+', combined_text)
720
  bullet_points = []
721
  for s in sentences:
 
732
 
733
  with st.expander("Show Full Extracted OCR Text"):
734
  for name, txt in ocr_texts:
735
+ st.markdown(f"*{name}*")
736
  st.text_area("Text", value=txt, height=200)
737
 
738
+ # End of appended OCR integration