prasai-ap commited on
Commit
9f09438
·
verified ·
1 Parent(s): fc0a012

Upload 3 files

Browse files
Files changed (3) hide show
  1. README.md +7 -1
  2. app.py +403 -25
  3. requirements.txt +1 -0
README.md CHANGED
@@ -22,4 +22,10 @@ This Hugging Face Space supports:
22
  - Generating Nepali quiz questions
23
  - Basic quiz grading
24
 
25
- For scanned PDF OCR and persistent progress, deploy the FastAPI backend separately and add a Space variable named `BACKEND_URL`.
 
 
 
 
 
 
 
22
  - Generating Nepali quiz questions
23
  - Basic quiz grading
24
 
25
+ For the full web-app workflow, deploy the FastAPI backend separately and add a Space variable named `BACKEND_URL`.
26
+
27
+ Without `BACKEND_URL`, the Space can still run the same style of workflow locally. Add these Space secrets/variables to match the web app more closely:
28
+
29
+ - `LLM_BASE_URL`, `LLM_API_KEY`, `LLM_MODEL` for the AMD/vLLM tutor
30
+ - `TRANSLATION_PROVIDER=gemini`, `GEMINI_API_KEY`, `GEMINI_MODEL` for Nepali adaptation and romanized question normalization
31
+ - `OCR_PROVIDER=gemini`, `OCR_MAX_PAGES=5` for scanned or custom-font PDFs
app.py CHANGED
@@ -1,3 +1,4 @@
 
1
  import json
2
  import os
3
  from functools import lru_cache
@@ -12,6 +13,14 @@ load_dotenv()
12
 
13
  APP_NAME = os.getenv("APP_NAME", "Pathshala AI")
14
  BACKEND_URL = os.getenv("BACKEND_URL", "").rstrip("/")
 
 
 
 
 
 
 
 
15
  EMBEDDING_MODEL = os.getenv(
16
  "EMBEDDING_MODEL",
17
  "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
@@ -23,6 +32,7 @@ EXAMPLE_CONTEXT = (
23
  )
24
  MIN_CHUNK_CHARS = 250
25
  MAX_CHUNK_CHARS = 900
 
26
 
27
 
28
  def upload_textbook(pdf_path):
@@ -36,6 +46,16 @@ def upload_textbook(pdf_path):
36
 
37
  try:
38
  extracted = extract_pdf_text(pdf_path)
 
 
 
 
 
 
 
 
 
 
39
  chunks = chunk_text(extracted["text"])
40
  if not chunks:
41
  return "No readable text chunks could be created from this PDF.", "{}", gr.update()
@@ -52,6 +72,8 @@ def upload_textbook(pdf_path):
52
  f"Uploaded {state['filename']} inside this Space with "
53
  f"{state['page_count']} pages and {state['chunk_count']} chunks."
54
  )
 
 
55
  return message, encode_state(state), gr.update(value="")
56
  except Exception as exc:
57
  return f"Could not process uploaded PDF: {exc}", "{}", gr.update()
@@ -105,16 +127,19 @@ def ask_tutor(question, student_id, textbook_context, textbook_state):
105
  if not sources:
106
  sources = sources_from_context(EXAMPLE_CONTEXT)
107
 
 
108
  context = "\n\n".join(source["text"] for source in sources)
109
- english = (
110
- f"Interpreted question: {normalize_question(question)}\n\n"
111
- f"Answer from textbook context:\n{truncate(context, 700)}"
112
- )
113
- nepali = nepali_answer(normalize_question(question), context)
114
  quiz_questions = nepali_quiz_questions(context)
115
  quiz_state = {
116
  "quiz_questions": quiz_questions,
117
  "expected_answers": [source_answer(sources)] * 3,
 
 
 
 
118
  }
119
  return (
120
  english,
@@ -164,6 +189,187 @@ def ask_backend(question, student_id, textbook_context):
164
  )
165
 
166
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
167
  def grade_quiz(answer_1, answer_2, answer_3, student_id, quiz_state):
168
  state = decode_state(quiz_state)
169
 
@@ -179,14 +385,18 @@ def grade_quiz(answer_1, answer_2, answer_3, student_id, quiz_state):
179
  timeout=45,
180
  )
181
  if response.ok:
182
- return format_grade(response.json())
 
 
 
 
183
  except (requests.RequestException, ValueError):
184
  pass
185
 
186
  questions = state.get("quiz_questions", [])
187
  expected_answers = state.get("expected_answers", [])
188
  if not questions:
189
- return "Ask the tutor first so a quiz can be created."
190
 
191
  answers = [answer_1, answer_2, answer_3]
192
  score = 0
@@ -199,15 +409,52 @@ def grade_quiz(answer_1, answer_2, answer_3, student_id, quiz_state):
199
  lines.append(f"{'Correct' if is_correct else 'Needs practice'}: {question}")
200
  if not is_correct and expected:
201
  lines.append(f"Expected idea: {expected}")
202
- return f"Score: {score} / {min(len(questions), 3)}\n" + "\n".join(lines)
 
 
 
 
 
203
 
204
 
205
- def parent_summary(student_id):
206
  if not BACKEND_URL:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  return (
208
  "Parent/teacher summary\n\n"
209
- "The student practiced with uploaded or pasted textbook context in this Space. "
210
- "For persistent progress, deploy the FastAPI backend and set BACKEND_URL."
 
 
 
211
  )
212
 
213
  try:
@@ -243,12 +490,84 @@ def extract_pdf_text(pdf_path):
243
  if text:
244
  page_texts.append(text)
245
 
246
- text = "\n\n".join(page_texts).strip()
247
- if not text:
248
- raise ValueError(
249
- "No selectable text found. For scanned PDFs, use backend OCR or paste a paragraph."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  )
251
- return {"text": text, "page_count": page_count}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
252
 
253
 
254
  def chunk_text(text):
@@ -268,6 +587,28 @@ def chunk_text(text):
268
  return chunks or ([text.strip()] if text.strip() else [])
269
 
270
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
271
  @lru_cache(maxsize=1)
272
  def get_embedding_model():
273
  from sentence_transformers import SentenceTransformer
@@ -322,14 +663,36 @@ def sources_from_context(text):
322
 
323
 
324
  def normalize_question(question):
325
- text = question.lower()
 
 
 
 
 
 
 
 
 
326
  if "mato" in text and "katan" in text:
327
  return "What is soil erosion?"
328
  if "prakash" in text and "sansleshan" in text:
329
  return "What is photosynthesis?"
330
  if "bhinn" in text or "fraction" in text:
331
  return "What is a fraction?"
332
- return question
 
 
 
 
 
 
 
 
 
 
 
 
 
333
 
334
 
335
  def nepali_answer(question, context):
@@ -358,7 +721,7 @@ def nepali_quiz_questions(context):
358
  return [
359
  "प्राप्त पाठ्यपुस्तक सन्दर्भको मुख्य कुरा के हो?",
360
  f"यो वाक्यले के बुझाउँछ: {short_context}",
361
- "यस विषयलाई आफ्नै सरल शब्दमा कसरी भन्न सकिन्छ?",
362
  ]
363
 
364
 
@@ -453,6 +816,24 @@ def truncate(text, max_length):
453
  return text[: max_length - 3] + "..."
454
 
455
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  with gr.Blocks(title=APP_NAME, theme=gr.themes.Soft()) as demo:
457
  gr.Markdown(
458
  """
@@ -468,10 +849,7 @@ with gr.Blocks(title=APP_NAME, theme=gr.themes.Soft()) as demo:
468
  student_id_input = gr.Textbox(label="Student ID", value="hf-space-demo")
469
  status_output = gr.Textbox(
470
  label="Status",
471
- value=(
472
- "Backend connected." if BACKEND_URL else
473
- "Space-local PDF upload is active. Set BACKEND_URL for full backend OCR/progress."
474
- ),
475
  interactive=False,
476
  )
477
 
@@ -535,12 +913,12 @@ with gr.Blocks(title=APP_NAME, theme=gr.themes.Soft()) as demo:
535
  grade_button.click(
536
  fn=grade_quiz,
537
  inputs=[answer_1, answer_2, answer_3, student_id_input, quiz_state],
538
- outputs=[grade_output],
539
  api_name=False,
540
  )
541
  summary_button.click(
542
  fn=parent_summary,
543
- inputs=[student_id_input],
544
  outputs=[summary_output],
545
  api_name=False,
546
  )
 
1
+ import base64
2
  import json
3
  import os
4
  from functools import lru_cache
 
13
 
14
  APP_NAME = os.getenv("APP_NAME", "Pathshala AI")
15
  BACKEND_URL = os.getenv("BACKEND_URL", "").rstrip("/")
16
+ LLM_BASE_URL = os.getenv("LLM_BASE_URL", "").strip().rstrip("/")
17
+ LLM_API_KEY = os.getenv("LLM_API_KEY", "")
18
+ LLM_MODEL = os.getenv("LLM_MODEL", "Qwen/Qwen2.5-1.5B-Instruct")
19
+ TRANSLATION_PROVIDER = os.getenv("TRANSLATION_PROVIDER", "mock").strip().lower()
20
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "")
21
+ GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")
22
+ OCR_PROVIDER = os.getenv("OCR_PROVIDER", "off").strip().lower()
23
+ OCR_MAX_PAGES = int(os.getenv("OCR_MAX_PAGES", "5") or "5")
24
  EMBEDDING_MODEL = os.getenv(
25
  "EMBEDDING_MODEL",
26
  "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2",
 
32
  )
33
  MIN_CHUNK_CHARS = 250
34
  MAX_CHUNK_CHARS = 900
35
+ MIN_TEXT_CHARACTERS_FOR_DIRECT_EXTRACTION = 300
36
 
37
 
38
  def upload_textbook(pdf_path):
 
46
 
47
  try:
48
  extracted = extract_pdf_text(pdf_path)
49
+ if is_garbled_pdf_text(extracted["text"]):
50
+ return (
51
+ "This PDF has a broken custom-font text layer, so the extracted text "
52
+ "is not readable Nepali. Use the backend with Gemini OCR enabled, "
53
+ "upload a Unicode Nepali PDF, or paste a readable lesson paragraph "
54
+ "into the context box.",
55
+ "{}",
56
+ gr.update(),
57
+ )
58
+
59
  chunks = chunk_text(extracted["text"])
60
  if not chunks:
61
  return "No readable text chunks could be created from this PDF.", "{}", gr.update()
 
72
  f"Uploaded {state['filename']} inside this Space with "
73
  f"{state['page_count']} pages and {state['chunk_count']} chunks."
74
  )
75
+ if extracted.get("extraction_method"):
76
+ message = f"{message} Text extraction: {extracted['extraction_method']}."
77
  return message, encode_state(state), gr.update(value="")
78
  except Exception as exc:
79
  return f"Could not process uploaded PDF: {exc}", "{}", gr.update()
 
127
  if not sources:
128
  sources = sources_from_context(EXAMPLE_CONTEXT)
129
 
130
+ normalized_question = normalize_question(question)
131
  context = "\n\n".join(source["text"] for source in sources)
132
+ english_answer = generate_english_answer(normalized_question, sources)
133
+ english = f"Interpreted question: {normalized_question}\n\n{english_answer}"
134
+ nepali = adapt_nepali_answer(question, english_answer, sources)
 
 
135
  quiz_questions = nepali_quiz_questions(context)
136
  quiz_state = {
137
  "quiz_questions": quiz_questions,
138
  "expected_answers": [source_answer(sources)] * 3,
139
+ "topic": display_topic(normalized_question),
140
+ "question": question,
141
+ "score": None,
142
+ "total": 3,
143
  }
144
  return (
145
  english,
 
189
  )
190
 
191
 
192
+ def generate_english_answer(question, sources):
193
+ if not sources:
194
+ return "I do not have enough textbook context to answer this question."
195
+
196
+ if not LLM_BASE_URL:
197
+ return fallback_english_answer(sources)
198
+
199
+ system_prompt = (
200
+ "You are a primary-school tutor. Use only the provided textbook context. "
201
+ "Write the answer in simple English. Keep the explanation short. Explain "
202
+ "the idea in your own words instead of copying long textbook lines. Ignore "
203
+ "OCR artifacts, broken words, page numbers, and source labels. If the "
204
+ "context is insufficient, say that you do not have enough textbook context."
205
+ )
206
+ prompt = (
207
+ f"Student question:\n{question}\n\n"
208
+ f"Textbook context:\n{format_sources_for_prompt(sources)}\n\n"
209
+ "Answer the student's question directly in 2 to 4 simple sentences."
210
+ )
211
+
212
+ try:
213
+ return complete_with_llm(
214
+ prompt=prompt,
215
+ system_prompt=system_prompt,
216
+ temperature=0.2,
217
+ max_tokens=450,
218
+ )
219
+ except (requests.RequestException, KeyError, IndexError, TypeError, ValueError):
220
+ return fallback_english_answer(sources)
221
+
222
+
223
+ def complete_with_llm(prompt, system_prompt="", temperature=0.2, max_tokens=512):
224
+ messages = []
225
+ if system_prompt:
226
+ messages.append({"role": "system", "content": system_prompt})
227
+ messages.append({"role": "user", "content": prompt})
228
+
229
+ headers = {"Content-Type": "application/json"}
230
+ if LLM_API_KEY:
231
+ headers["Authorization"] = f"Bearer {LLM_API_KEY}"
232
+
233
+ response = requests.post(
234
+ f"{LLM_BASE_URL}/chat/completions",
235
+ json={
236
+ "model": LLM_MODEL,
237
+ "messages": messages,
238
+ "temperature": temperature,
239
+ "max_tokens": max_tokens,
240
+ },
241
+ headers=headers,
242
+ timeout=180,
243
+ )
244
+ response.raise_for_status()
245
+ data = response.json()
246
+ return str(data["choices"][0]["message"]["content"]).strip()
247
+
248
+
249
+ def adapt_nepali_answer(question, english_answer, sources):
250
+ if TRANSLATION_PROVIDER == "gemini" and GEMINI_API_KEY:
251
+ try:
252
+ translated = translate_with_gemini(question, english_answer)
253
+ translated = remove_source_lines(translated)
254
+ if is_valid_nepali(translated):
255
+ return translated
256
+ except (requests.RequestException, KeyError, IndexError, TypeError, ValueError):
257
+ pass
258
+
259
+ return nepali_answer(question, " ".join(str(source.get("text", "")) for source in sources))
260
+
261
+
262
+ def translate_with_gemini(question, english_answer):
263
+ prompt = (
264
+ "Translate and simplify this grounded English tutoring answer into natural "
265
+ "Nepali for a primary-school student in Nepal. Keep the same meaning. "
266
+ "Use Nepali Devanagari only. Do not add new facts. Do not include source "
267
+ "citations or headings.\n\n"
268
+ f"Student question:\n{question}\n\n"
269
+ f"English answer:\n{english_answer}"
270
+ )
271
+ return gemini_generate_text(prompt, temperature=0.1, max_output_tokens=450)
272
+
273
+
274
+ def normalize_with_gemini(question):
275
+ prompt = (
276
+ "Convert this student question into one clear, simple English question for "
277
+ "textbook search. The question may be written in English, Nepali Devanagari, "
278
+ "or romanized Nepali typed with English letters. Do not answer the question. "
279
+ "Return only the rewritten English question.\n\n"
280
+ f"Student question:\n{question}"
281
+ )
282
+ normalized = gemini_generate_text(prompt, temperature=0, max_output_tokens=80)
283
+ normalized = normalized.strip().strip("\"'`").splitlines()[0].strip()
284
+ if normalized and "?" not in normalized and len(normalized.split()) > 1:
285
+ normalized = f"{normalized}?"
286
+ if len(normalized) > 180 or len(normalized.strip("?").split()) < 3:
287
+ return ""
288
+ return normalized
289
+
290
+
291
+ def gemini_generate_text(prompt, temperature=0.1, max_output_tokens=450, parts=None):
292
+ endpoint = (
293
+ "https://generativelanguage.googleapis.com/v1beta/"
294
+ f"models/{GEMINI_MODEL}:generateContent"
295
+ )
296
+ content_parts = parts or [{"text": prompt}]
297
+ response = requests.post(
298
+ endpoint,
299
+ json={
300
+ "contents": [{"parts": content_parts}],
301
+ "generationConfig": {
302
+ "temperature": temperature,
303
+ "maxOutputTokens": max_output_tokens,
304
+ },
305
+ },
306
+ headers={
307
+ "Content-Type": "application/json",
308
+ "x-goog-api-key": GEMINI_API_KEY,
309
+ },
310
+ timeout=60,
311
+ )
312
+ response.raise_for_status()
313
+ data = response.json()
314
+ return data["candidates"][0]["content"]["parts"][0]["text"].strip()
315
+
316
+
317
+ def fallback_english_answer(sources):
318
+ context = str(sources[0].get("text", "")).strip()
319
+ if not context:
320
+ return "I do not have enough textbook context to answer this question."
321
+
322
+ topic_text = " ".join(str(source.get("text", "")) for source in sources[:3]).lower()
323
+ if "soil erosion" in topic_text or "erosion" in topic_text:
324
+ return (
325
+ "Soil erosion means the top fertile layer of soil is carried away by "
326
+ "water, wind, or other causes. It makes land less useful for growing "
327
+ "plants, so protecting soil with plants and controlled water flow is important."
328
+ )
329
+ if "photosynthesis" in topic_text or "chlorophyll" in topic_text:
330
+ return (
331
+ "Photosynthesis is the process by which green plants make their own food "
332
+ "using sunlight, water, and carbon dioxide. Chlorophyll in leaves helps "
333
+ "plants capture sunlight, and oxygen is released during the process."
334
+ )
335
+
336
+ return "Based on the textbook context, here is the simple explanation: " + truncate(
337
+ " ".join(context.split()),
338
+ 500,
339
+ )
340
+
341
+
342
+ def format_sources_for_prompt(sources):
343
+ formatted = []
344
+ for index, source in enumerate(sources, start=1):
345
+ metadata = source.get("metadata", {})
346
+ filename = metadata.get("filename", "textbook")
347
+ chunk_index = metadata.get("chunk_index", "unknown")
348
+ formatted.append(
349
+ f"[Source {index}: {filename}, chunk {chunk_index}]\n{source.get('text', '')}"
350
+ )
351
+ return "\n\n".join(formatted)
352
+
353
+
354
+ def is_valid_nepali(text):
355
+ devanagari_count = sum(1 for character in text if "\u0900" <= character <= "\u097f")
356
+ latin_count = sum(1 for character in text if character.isascii() and character.isalpha())
357
+ if devanagari_count < 20 or latin_count > 12:
358
+ return False
359
+ forbidden_markers = ["source", "student question", "english answer", "external"]
360
+ return not any(marker in text.lower() for marker in forbidden_markers)
361
+
362
+
363
+ def remove_source_lines(text):
364
+ lines = []
365
+ for line in str(text).splitlines():
366
+ lowered = line.lower()
367
+ if "source" in lowered or "स्रोत:" in line:
368
+ continue
369
+ lines.append(line)
370
+ return "\n".join(lines).strip()
371
+
372
+
373
  def grade_quiz(answer_1, answer_2, answer_3, student_id, quiz_state):
374
  state = decode_state(quiz_state)
375
 
 
385
  timeout=45,
386
  )
387
  if response.ok:
388
+ data = response.json()
389
+ state["score"] = data.get("score")
390
+ state["total"] = data.get("total")
391
+ state["weak_topics"] = data.get("weak_areas", [])
392
+ return format_grade(data), encode_state(state)
393
  except (requests.RequestException, ValueError):
394
  pass
395
 
396
  questions = state.get("quiz_questions", [])
397
  expected_answers = state.get("expected_answers", [])
398
  if not questions:
399
+ return "Ask the tutor first so a quiz can be created.", encode_state(state)
400
 
401
  answers = [answer_1, answer_2, answer_3]
402
  score = 0
 
409
  lines.append(f"{'Correct' if is_correct else 'Needs practice'}: {question}")
410
  if not is_correct and expected:
411
  lines.append(f"Expected idea: {expected}")
412
+
413
+ state["score"] = score
414
+ state["total"] = min(len(questions), 3)
415
+ state["last_result"] = f"Score: {score} / {min(len(questions), 3)}"
416
+ state["weak_topics"] = [] if score >= state["total"] else [state.get("topic", "मुख्य पाठ")]
417
+ return f"Score: {score} / {min(len(questions), 3)}\n" + "\n".join(lines), encode_state(state)
418
 
419
 
420
+ def parent_summary(student_id, quiz_state):
421
  if not BACKEND_URL:
422
+ state = decode_state(quiz_state)
423
+ topic = state.get("topic") or "आजको पाठ"
424
+ score = state.get("score")
425
+ total = state.get("total") or 3
426
+ question = state.get("question") or "पाठ्यपुस्तकको प्रश्न"
427
+
428
+ if score is None:
429
+ return (
430
+ "Parent/teacher summary\n\n"
431
+ f"विद्यार्थीले {question} बारे प्रश्न सोधेको छ। अझै क्विज पेश गरिएको छैन। "
432
+ "उत्तर पढेपछि ३ वटा छोटा प्रश्न प्रयास गराउनुहोस्।"
433
+ )
434
+
435
+ if score >= max(total - 1, 1):
436
+ strength = f"{topic} को मुख्य विचार राम्रोसँग समात्दैछ।"
437
+ weak = "अहिले कुनै स्पष्ट कमजोर क्षेत्र देखिएको छैन।"
438
+ next_step = f"{topic} बाट अर्को उदाहरण वा अभ्यास प्रश्न गराउनुहोस्।"
439
+ note = "विद्यार्थीले राम्रो प्रगति देखाएको छ। छोटो दैनिक अभ्यास जारी राख्नुहोस्।"
440
+ elif score > 0:
441
+ strength = "विद्यार्थीले केही मुख्य कुरा बुझ्न थालेको छ।"
442
+ weak = f"{topic} का परिभाषा, मुख्य शब्द, र उदाहरण अझै अभ्यास गर्नुपर्छ।"
443
+ next_step = f"{topic} को पाठ फेरि पढेर सजिलो उदाहरणसहित ३ छोटा प्रश्न गराउनुहोस्।"
444
+ note = "विद्यार्थी प्रयासरत छ। गलत भएका प्रश्नलाई उदाहरणसँग जोडेर दोहोर्‍याउँदा सुधार हुन्छ।"
445
+ else:
446
+ strength = "विद्यार्थीले प्रश्न सोधेर अभ्यास सुरु गरेको छ।"
447
+ weak = f"{topic} को आधारभूत अर्थ र मुख्य शब्दहरू फेरि बुझाउनुपर्छ।"
448
+ next_step = f"{topic} को छोटो परिभाषा, चित्र/उदाहरण, र एक-एक गरी प्रश्न अभ्यास गराउनुहोस्।"
449
+ note = "अहिले थप सहारा चाहिन्छ, तर नियमित सानो अभ���यासले सुधार ल्याउँछ।"
450
+
451
  return (
452
  "Parent/teacher summary\n\n"
453
+ f"Quiz score: {score} / {total}\n\n"
454
+ f"Strength\n{strength}\n\n"
455
+ f"Needs practice\n{weak}\n\n"
456
+ f"Suggested next practice\n{next_step}\n\n"
457
+ f"Encouraging note\n{note}"
458
  )
459
 
460
  try:
 
490
  if text:
491
  page_texts.append(text)
492
 
493
+ text = "\n\n".join(page_texts).strip()
494
+ if (
495
+ len(text) >= MIN_TEXT_CHARACTERS_FOR_DIRECT_EXTRACTION
496
+ and not is_garbled_pdf_text(text)
497
+ ):
498
+ return {"text": text, "page_count": page_count, "extraction_method": "pymupdf"}
499
+
500
+ ocr_text = extract_text_with_gemini_ocr(document)
501
+ if ocr_text:
502
+ combined_text = (
503
+ ocr_text
504
+ if is_garbled_pdf_text(text)
505
+ else "\n\n".join(part for part in [text, ocr_text] if part.strip())
506
+ )
507
+ return {
508
+ "text": combined_text,
509
+ "page_count": page_count,
510
+ "extraction_method": "gemini-ocr",
511
+ }
512
+
513
+ if is_garbled_pdf_text(text):
514
+ raise ValueError(
515
+ "The PDF text layer is not readable Unicode Nepali. Add GEMINI_API_KEY "
516
+ "and set OCR_PROVIDER=gemini in the Space secrets, or upload a Unicode "
517
+ "Nepali PDF."
518
+ )
519
+
520
+ if text:
521
+ return {"text": text, "page_count": page_count, "extraction_method": "pymupdf-low-text"}
522
+
523
+ raise ValueError(
524
+ "No readable text found. For scanned PDFs, add GEMINI_API_KEY and set "
525
+ "OCR_PROVIDER=gemini in the Space secrets, or paste a readable lesson paragraph."
526
+ )
527
+
528
+
529
+ def extract_text_with_gemini_ocr(document):
530
+ import fitz
531
+
532
+ if OCR_PROVIDER != "gemini" or not GEMINI_API_KEY:
533
+ return ""
534
+
535
+ page_limit = document.page_count
536
+ if OCR_MAX_PAGES > 0:
537
+ page_limit = min(document.page_count, OCR_MAX_PAGES)
538
+
539
+ page_texts = []
540
+ for page_index in range(page_limit):
541
+ page = document.load_page(page_index)
542
+ pixmap = page.get_pixmap(matrix=fitz.Matrix(1.5, 1.5), alpha=False)
543
+ image_data = base64.b64encode(pixmap.tobytes("png")).decode("ascii")
544
+ prompt = (
545
+ "Extract all readable textbook text from this page. The text may be in "
546
+ "Nepali Devanagari or English. Return plain text only. Preserve the original "
547
+ "language and script. Do not translate or summarize."
548
  )
549
+ try:
550
+ page_text = gemini_generate_text(
551
+ prompt,
552
+ temperature=0,
553
+ max_output_tokens=1800,
554
+ parts=[
555
+ {"text": prompt},
556
+ {
557
+ "inline_data": {
558
+ "mime_type": "image/png",
559
+ "data": image_data,
560
+ }
561
+ },
562
+ ],
563
+ )
564
+ except (requests.RequestException, KeyError, IndexError, TypeError, ValueError):
565
+ continue
566
+
567
+ if page_text:
568
+ page_texts.append(f"Page {page_index + 1}\n{page_text}")
569
+
570
+ return "\n\n".join(page_texts).strip()
571
 
572
 
573
  def chunk_text(text):
 
587
  return chunks or ([text.strip()] if text.strip() else [])
588
 
589
 
590
+ def is_garbled_pdf_text(text):
591
+ cleaned = "".join(character for character in str(text) if not character.isspace())
592
+ if len(cleaned) < 300:
593
+ return False
594
+
595
+ devanagari_count = sum(1 for character in cleaned if "\u0900" <= character <= "\u097f")
596
+ ascii_letter_count = sum(1 for character in cleaned if character.isascii() and character.isalpha())
597
+ suspicious_symbol_count = sum(1 for character in cleaned if character in "/\\|;:{}[]'\"`~")
598
+ suspicious_markers = ["kf7", "lj", "cfwf", "tsnf", ";sf", "PsF", "ofsf"]
599
+ marker_hits = sum(1 for marker in suspicious_markers if marker in text)
600
+
601
+ devanagari_ratio = devanagari_count / len(cleaned)
602
+ ascii_ratio = ascii_letter_count / len(cleaned)
603
+ symbol_ratio = suspicious_symbol_count / len(cleaned)
604
+
605
+ return (
606
+ devanagari_ratio < 0.05
607
+ and ascii_ratio > 0.35
608
+ and (symbol_ratio > 0.12 or marker_hits >= 2)
609
+ )
610
+
611
+
612
  @lru_cache(maxsize=1)
613
  def get_embedding_model():
614
  from sentence_transformers import SentenceTransformer
 
663
 
664
 
665
  def normalize_question(question):
666
+ cleaned = str(question or "").strip()
667
+ if TRANSLATION_PROVIDER == "gemini" and GEMINI_API_KEY and cleaned:
668
+ try:
669
+ normalized = normalize_with_gemini(cleaned)
670
+ if normalized:
671
+ return normalized
672
+ except (requests.RequestException, KeyError, IndexError, TypeError, ValueError):
673
+ pass
674
+
675
+ text = cleaned.lower()
676
  if "mato" in text and "katan" in text:
677
  return "What is soil erosion?"
678
  if "prakash" in text and "sansleshan" in text:
679
  return "What is photosynthesis?"
680
  if "bhinn" in text or "fraction" in text:
681
  return "What is a fraction?"
682
+ return cleaned
683
+
684
+
685
+ def display_topic(question):
686
+ normalized = str(question).lower()
687
+ if "photosynthesis" in normalized or "prakash" in normalized:
688
+ return "प्रकाश संश्लेषण"
689
+ if "soil erosion" in normalized or ("mato" in normalized and "katan" in normalized):
690
+ return "माटो कटान"
691
+ if "fraction" in normalized or "bhinn" in normalized:
692
+ return "भिन्न"
693
+ if "oxygen" in normalized:
694
+ return "अक्सिजन"
695
+ return str(question).strip() or "आजको पाठ"
696
 
697
 
698
  def nepali_answer(question, context):
 
721
  return [
722
  "प्राप्त पाठ्यपुस्तक सन्दर्भको मुख्य कुरा के हो?",
723
  f"यो वाक्यले के बुझाउँछ: {short_context}",
724
+ "यस विषयलाई आफ्नै सरल नेपाली शब्दमा कसरी भन्न सकिन्छ?",
725
  ]
726
 
727
 
 
816
  return text[: max_length - 3] + "..."
817
 
818
 
819
+ def startup_status():
820
+ if BACKEND_URL:
821
+ return "Backend connected."
822
+
823
+ llm_status = "AMD/vLLM tutor enabled." if LLM_BASE_URL else "Local tutor fallback enabled."
824
+ nepali_status = (
825
+ "Gemini Nepali adaptation enabled."
826
+ if TRANSLATION_PROVIDER == "gemini" and GEMINI_API_KEY
827
+ else "Mock Nepali adaptation enabled."
828
+ )
829
+ ocr_status = (
830
+ "Gemini OCR enabled."
831
+ if OCR_PROVIDER == "gemini" and GEMINI_API_KEY
832
+ else "Text-based PDF extraction enabled."
833
+ )
834
+ return f"{llm_status} {nepali_status} {ocr_status}"
835
+
836
+
837
  with gr.Blocks(title=APP_NAME, theme=gr.themes.Soft()) as demo:
838
  gr.Markdown(
839
  """
 
849
  student_id_input = gr.Textbox(label="Student ID", value="hf-space-demo")
850
  status_output = gr.Textbox(
851
  label="Status",
852
+ value=startup_status(),
 
 
 
853
  interactive=False,
854
  )
855
 
 
913
  grade_button.click(
914
  fn=grade_quiz,
915
  inputs=[answer_1, answer_2, answer_3, student_id_input, quiz_state],
916
+ outputs=[grade_output, quiz_state],
917
  api_name=False,
918
  )
919
  summary_button.click(
920
  fn=parent_summary,
921
+ inputs=[student_id_input, quiz_state],
922
  outputs=[summary_output],
923
  api_name=False,
924
  )
requirements.txt CHANGED
@@ -1,3 +1,4 @@
 
1
  python-dotenv>=1.0.0
2
  requests>=2.31.0
3
  numpy>=1.26.0
 
1
+ gradio==4.44.1
2
  python-dotenv>=1.0.0
3
  requests>=2.31.0
4
  numpy>=1.26.0