prasai-ap commited on
Commit
3b348a6
·
verified ·
1 Parent(s): 49bb88f

Upload 3 files

Browse files
Files changed (2) hide show
  1. README.md +19 -7
  2. app.py +384 -73
README.md CHANGED
@@ -3,21 +3,24 @@ title: Pathshala AI
3
  colorFrom: green
4
  colorTo: blue
5
  sdk: gradio
6
- sdk_version: 6.14.0
7
  app_file: app.py
8
  pinned: false
9
- license: mit
10
  ---
11
 
12
  # Pathshala AI
13
 
14
  Pathshala AI is a bilingual AI tutor demo for rural primary students in Nepal.
15
 
16
- The Gradio Space accepts a student question in English, Nepali, or romanized Nepali plus optional textbook context, then returns:
 
17
 
18
  - English explanation
19
  - Nepali explanation
20
  - 3 simple quiz questions
 
 
 
21
 
22
  ## Deploy To Hugging Face Spaces
23
 
@@ -45,7 +48,8 @@ git push
45
  ## Recommended Submission Mode
46
 
47
  For the easiest hackathon submission, deploy the Space without `BACKEND_URL`.
48
- It will use the built-in mock fallback, so judges can try it immediately.
 
49
 
50
  For the full RAG workflow, first deploy the FastAPI backend somewhere public, then set `BACKEND_URL` in the Space settings.
51
 
@@ -63,12 +67,20 @@ In Hugging Face Spaces, add it under:
63
  Space settings -> Variables and secrets -> New variable
64
  ```
65
 
66
- The app calls `POST /ask` and displays the backend response.
 
 
 
 
 
 
 
 
67
  If the backend returns `normalized_question`, the Space shows the interpreted question above the English explanation.
68
 
69
  ## Mock Mode
70
 
71
- If `BACKEND_URL` is missing or the backend is unavailable, the Space uses a simple mock fallback so the demo remains easy to try.
72
 
73
  Example question:
74
 
@@ -80,4 +92,4 @@ You can also try mixed romanized Nepali questions such as:
80
 
81
  ```text
82
  photosynthesis vaneko ke ho vana
83
- ```
 
3
  colorFrom: green
4
  colorTo: blue
5
  sdk: gradio
6
+ sdk_version: 4.44.0
7
  app_file: app.py
8
  pinned: false
 
9
  ---
10
 
11
  # Pathshala AI
12
 
13
  Pathshala AI is a bilingual AI tutor demo for rural primary students in Nepal.
14
 
15
+ The Gradio Space mirrors the local Streamlit/web app flow. It can accept a student
16
+ question in English, Nepali, or romanized Nepali plus optional textbook context, then returns:
17
 
18
  - English explanation
19
  - Nepali explanation
20
  - 3 simple quiz questions
21
+ - Retrieved textbook sources
22
+ - Quiz grading when a backend is configured
23
+ - Parent/teacher summary when a backend is configured
24
 
25
  ## Deploy To Hugging Face Spaces
26
 
 
48
  ## Recommended Submission Mode
49
 
50
  For the easiest hackathon submission, deploy the Space without `BACKEND_URL`.
51
+ It will use the built-in demo fallback, so judges can try it immediately by pasting
52
+ textbook context into the question tab.
53
 
54
  For the full RAG workflow, first deploy the FastAPI backend somewhere public, then set `BACKEND_URL` in the Space settings.
55
 
 
67
  Space settings -> Variables and secrets -> New variable
68
  ```
69
 
70
+ The app calls:
71
+
72
+ - `POST /upload-textbook` for PDF uploads
73
+ - `POST /ask` for bilingual textbook-grounded answers
74
+ - `POST /grade-quiz` for quiz grading
75
+ - `GET /parent-summary/{student_id}` for the parent/teacher summary
76
+
77
+ The `/ask` request sends both the student question and the optional textbook context.
78
+ If a user types context in the Space, the backend can answer from that context even when no PDF has been uploaded.
79
  If the backend returns `normalized_question`, the Space shows the interpreted question above the English explanation.
80
 
81
  ## Mock Mode
82
 
83
+ If `BACKEND_URL` is missing or the backend is unavailable, the Space uses a simple demo fallback so the demo remains easy to try. PDF upload, quiz grading, and parent summaries require the backend.
84
 
85
  Example question:
86
 
 
92
 
93
  ```text
94
  photosynthesis vaneko ke ho vana
95
+ ```
app.py CHANGED
@@ -8,7 +8,11 @@ import requests
8
 
9
  load_dotenv()
10
 
 
11
  BACKEND_URL = os.getenv("BACKEND_URL", "").rstrip("/")
 
 
 
12
  EXAMPLE_QUESTION = "soil erosion vaneko ke ho"
13
  EXAMPLE_CONTEXT = (
14
  "Soil erosion is the removal of topsoil by wind, water, or other natural forces. "
@@ -16,8 +20,46 @@ EXAMPLE_CONTEXT = (
16
  )
17
 
18
 
19
- def ask_tutor(question: str, textbook_context: str) -> tuple[str, str, str]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
20
  question = question.strip()
 
21
  textbook_context = textbook_context.strip()
22
 
23
  if not question:
@@ -25,10 +67,13 @@ def ask_tutor(question: str, textbook_context: str) -> tuple[str, str, str]:
25
  "Please type a student question.",
26
  "कृपया विद्यार्थीको प्रश्न लेख्नुहोस्।",
27
  "1. Add a question first.\n2. Then try again.\n3. Use a textbook topic.",
 
 
 
28
  )
29
 
30
  if BACKEND_URL:
31
- backend_result = ask_backend(question)
32
 
33
  if backend_result and not is_insufficient_backend_result(backend_result):
34
  return backend_result
@@ -36,16 +81,25 @@ def ask_tutor(question: str, textbook_context: str) -> tuple[str, str, str]:
36
  return mock_response(question=question, textbook_context=textbook_context)
37
 
38
 
39
- def ask_backend(question: str) -> tuple[str, str, str] | None:
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  try:
41
  response = requests.post(
42
  f"{BACKEND_URL}/ask",
43
- json={
44
- "question": question,
45
- "student_id": "hf-space-demo",
46
- "language_support": "English and Nepali",
47
- },
48
- timeout=60,
49
  )
50
  response.raise_for_status()
51
  data = response.json()
@@ -54,26 +108,113 @@ def ask_backend(question: str) -> tuple[str, str, str] | None:
54
  except ValueError:
55
  return None
56
 
57
- return format_backend_response(data)
58
 
59
 
60
- def format_backend_response(data: dict[str, Any]) -> tuple[str, str, str]:
61
- quiz_questions = data.get("quiz_questions", [])
62
- english_answer = data.get("answer_english", "No English answer returned.")
 
 
63
  normalized_question = str(data.get("normalized_question") or "").strip()
64
 
65
  if normalized_question:
66
  english_answer = f"Interpreted question: {normalized_question}\n\n{english_answer}"
67
 
 
 
 
 
 
 
 
68
  return (
69
  english_answer,
70
- data.get("answer_nepali", "नेपाली उत्तर प��राप्त भएन।"),
71
  format_quiz(quiz_questions),
 
 
 
72
  )
73
 
74
 
75
- def is_insufficient_backend_result(result: tuple[str, str, str]) -> bool:
76
- combined = " ".join(result).lower()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
77
  markers = [
78
  "not have enough textbook context",
79
  "not enough textbook context",
@@ -84,33 +225,81 @@ def is_insufficient_backend_result(result: tuple[str, str, str]) -> bool:
84
  return any(marker in combined for marker in markers)
85
 
86
 
87
- def mock_response(question: str, textbook_context: str) -> tuple[str, str, str]:
88
  context = textbook_context or EXAMPLE_CONTEXT
89
- simple_context = truncate(context, max_length=450)
90
  normalized_question = normalize_question_mock(question)
 
91
 
92
- english = (
93
- f"Interpreted question: {normalized_question}\n\n"
94
- "Mock demo answer: I am using the textbook context only. "
95
- f"A simple explanation is: {simple_context}"
96
- )
97
- nepali = (
98
- "मक डेमो उत्तर: म पाठ्यपुस्तकको सन्दर्भ मात्र प्रयोग गर्दैछु। "
99
- f"{mock_nepali_explanation(normalized_question)}"
 
 
 
 
 
 
 
 
 
 
 
100
  )
101
- quiz = format_quiz(
102
- [
103
- "What is the main idea from the explanation?",
104
- "Can you give one simple example?",
105
- "Can you explain it in your own words?",
106
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  )
108
 
109
- return english, nepali, quiz
110
 
 
 
111
 
112
- def mock_nepali_explanation(normalized_question: str) -> str:
113
- text = normalized_question.lower()
 
 
 
 
 
114
 
115
  if "soil erosion" in text:
116
  return (
@@ -147,6 +336,9 @@ def normalize_question_mock(question: str) -> str:
147
  if "soil erosion" in text or ("mato" in text and "katan" in text):
148
  return "What is soil erosion?"
149
 
 
 
 
150
  if "photosynthesis" in text or ("prakash" in text and "sansleshan" in text):
151
  return "What is photosynthesis?"
152
 
@@ -205,6 +397,23 @@ def extract_mixed_language_topic(text: str) -> str:
205
  return topic
206
 
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  def format_quiz(quiz_questions: list[Any]) -> str:
209
  questions = [
210
  str(question).strip()
@@ -225,6 +434,52 @@ def format_quiz(quiz_questions: list[Any]) -> str:
225
  )
226
 
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  def truncate(text: str, max_length: int) -> str:
229
  if len(text) <= max_length:
230
  return text
@@ -232,54 +487,81 @@ def truncate(text: str, max_length: int) -> str:
232
  return f"{text[: max_length - 3]}..."
233
 
234
 
235
- with gr.Blocks(title="Pathshala AI", theme=gr.themes.Soft()) as demo:
236
  gr.Markdown(
237
  """
238
  # Pathshala AI
239
- Bilingual AI tutor for rural primary students in Nepal. Try English, Nepali,
240
- or romanized Nepali questions like `soil erosion vaneko ke ho`.
241
  """
242
  )
243
 
 
 
244
  with gr.Row():
245
- with gr.Column(scale=1):
246
- question_input = gr.Textbox(
247
- label="Student question",
248
- placeholder=EXAMPLE_QUESTION,
249
- value=EXAMPLE_QUESTION,
250
- lines=2,
251
- )
252
- context_input = gr.Textbox(
253
- label="Optional textbook context",
254
- placeholder="Paste a short textbook paragraph here.",
255
- value=EXAMPLE_CONTEXT,
256
- lines=5,
257
- )
258
- ask_button = gr.Button("Ask Tutor", variant="primary")
259
 
260
- with gr.Column(scale=1):
261
- english_output = gr.Textbox(
262
- label="English explanation",
263
- lines=6,
264
- )
265
- nepali_output = gr.Textbox(
266
- label="Nepali explanation",
267
- lines=6,
268
- )
269
- quiz_output = gr.Textbox(
270
- label="3 quiz questions",
271
- lines=5,
272
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
 
274
  gr.Examples(
275
  examples=[
276
  [EXAMPLE_QUESTION, EXAMPLE_CONTEXT],
277
  [
278
- "What is a fraction?",
279
  (
280
- "A fraction shows a part of a whole. The top number tells how many "
281
- "parts we have. The bottom number tells how many equal parts the "
282
- "whole is divided into."
283
  ),
284
  ],
285
  [
@@ -291,15 +573,44 @@ with gr.Blocks(title="Pathshala AI", theme=gr.themes.Soft()) as demo:
291
  ],
292
  ],
293
  inputs=[question_input, context_input],
294
- outputs=[english_output, nepali_output, quiz_output],
295
- fn=ask_tutor,
 
 
 
 
 
 
 
296
  cache_examples=False,
297
  )
298
 
 
 
 
 
 
299
  ask_button.click(
300
  fn=ask_tutor,
301
- inputs=[question_input, context_input],
302
- outputs=[english_output, nepali_output, quiz_output],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
  )
304
 
305
 
 
8
 
9
  load_dotenv()
10
 
11
+ APP_NAME = os.getenv("APP_NAME", "Pathshala AI")
12
  BACKEND_URL = os.getenv("BACKEND_URL", "").rstrip("/")
13
+ UPLOAD_TIMEOUT_SECONDS = 900
14
+ ASK_TIMEOUT_SECONDS = 180
15
+ SHORT_TIMEOUT_SECONDS = 45
16
  EXAMPLE_QUESTION = "soil erosion vaneko ke ho"
17
  EXAMPLE_CONTEXT = (
18
  "Soil erosion is the removal of topsoil by wind, water, or other natural forces. "
 
20
  )
21
 
22
 
23
+ def upload_textbook(pdf_path: str | None) -> str:
24
+ if not pdf_path:
25
+ return "Choose a PDF first."
26
+
27
+ if not BACKEND_URL:
28
+ return "Backend URL is not configured for this Space. Paste context below to use demo mode."
29
+
30
+ try:
31
+ with open(pdf_path, "rb") as pdf_file:
32
+ response = requests.post(
33
+ f"{BACKEND_URL}/upload-textbook",
34
+ files={"file": (os.path.basename(pdf_path), pdf_file, "application/pdf")},
35
+ timeout=UPLOAD_TIMEOUT_SECONDS,
36
+ )
37
+
38
+ if response.ok:
39
+ result = response.json()
40
+ extraction_method = result.get("extraction_method")
41
+ method_text = f" Text extraction: {extraction_method}." if extraction_method else ""
42
+ return (
43
+ f"Uploaded {result['filename']} with {result['page_count']} pages "
44
+ f"and {result['chunk_count']} chunks.{method_text}"
45
+ )
46
+
47
+ return _response_error(response, "Upload failed.")
48
+ except requests.Timeout:
49
+ return "Backend is still processing the PDF. Try a smaller PDF for the demo."
50
+ except requests.RequestException as exc:
51
+ return f"Could not reach backend: {exc}"
52
+ except OSError as exc:
53
+ return f"Could not read uploaded PDF: {exc}"
54
+
55
+
56
+ def ask_tutor(
57
+ question: str,
58
+ student_id: str,
59
+ textbook_context: str,
60
+ ) -> tuple[str, str, str, str, str, dict[str, Any]]:
61
  question = question.strip()
62
+ student_id = (student_id or "hf-space-demo").strip()
63
  textbook_context = textbook_context.strip()
64
 
65
  if not question:
 
67
  "Please type a student question.",
68
  "कृपया विद्यार्थीको प्रश्न लेख्नुहोस्।",
69
  "1. Add a question first.\n2. Then try again.\n3. Use a textbook topic.",
70
+ "",
71
+ "Waiting for a question.",
72
+ {},
73
  )
74
 
75
  if BACKEND_URL:
76
+ backend_result = ask_backend(question, student_id, textbook_context)
77
 
78
  if backend_result and not is_insufficient_backend_result(backend_result):
79
  return backend_result
 
81
  return mock_response(question=question, textbook_context=textbook_context)
82
 
83
 
84
+ def ask_backend(
85
+ question: str,
86
+ student_id: str,
87
+ textbook_context: str,
88
+ ) -> tuple[str, str, str, str, str, dict[str, Any]] | None:
89
+ payload: dict[str, Any] = {
90
+ "question": question,
91
+ "student_id": student_id,
92
+ "language_support": "English and Nepali",
93
+ }
94
+
95
+ if textbook_context:
96
+ payload["textbook_context"] = textbook_context
97
+
98
  try:
99
  response = requests.post(
100
  f"{BACKEND_URL}/ask",
101
+ json=payload,
102
+ timeout=ASK_TIMEOUT_SECONDS,
 
 
 
 
103
  )
104
  response.raise_for_status()
105
  data = response.json()
 
108
  except ValueError:
109
  return None
110
 
111
+ return format_backend_response(data, student_id=student_id)
112
 
113
 
114
+ def format_backend_response(
115
+ data: dict[str, Any],
116
+ student_id: str,
117
+ ) -> tuple[str, str, str, str, str, dict[str, Any]]:
118
+ english_answer = str(data.get("answer_english", "No English answer returned."))
119
  normalized_question = str(data.get("normalized_question") or "").strip()
120
 
121
  if normalized_question:
122
  english_answer = f"Interpreted question: {normalized_question}\n\n{english_answer}"
123
 
124
+ quiz_questions = data.get("quiz_questions", [])
125
+ state = {
126
+ "quiz_id": data.get("quiz_id"),
127
+ "quiz_questions": quiz_questions,
128
+ "student_id": student_id,
129
+ }
130
+
131
  return (
132
  english_answer,
133
+ str(data.get("answer_nepali", "नेपाली उत्तर पराप्त भएन।")),
134
  format_quiz(quiz_questions),
135
+ format_sources(data.get("retrieved_sources", [])),
136
+ "Answered with the backend RAG workflow.",
137
+ state,
138
  )
139
 
140
 
141
+ def grade_quiz(
142
+ answer_1: str,
143
+ answer_2: str,
144
+ answer_3: str,
145
+ student_id: str,
146
+ quiz_state: dict[str, Any] | None,
147
+ ) -> str:
148
+ if not BACKEND_URL:
149
+ return "Quiz grading needs the backend. Demo mode can show questions but cannot grade them."
150
+
151
+ quiz_state = quiz_state or {}
152
+ quiz_id = quiz_state.get("quiz_id")
153
+
154
+ if not quiz_id:
155
+ return "Ask the tutor first so a quiz can be created."
156
+
157
+ try:
158
+ response = requests.post(
159
+ f"{BACKEND_URL}/grade-quiz",
160
+ json={
161
+ "student_id": (student_id or "hf-space-demo").strip(),
162
+ "quiz_id": quiz_id,
163
+ "answers": [answer_1, answer_2, answer_3],
164
+ },
165
+ timeout=SHORT_TIMEOUT_SECONDS,
166
+ )
167
+
168
+ if not response.ok:
169
+ return _response_error(response, "Quiz grading failed.")
170
+
171
+ return format_grade(response.json())
172
+ except requests.Timeout:
173
+ return "Quiz grading timed out. Please try again."
174
+ except requests.RequestException as exc:
175
+ return f"Could not reach backend: {exc}"
176
+ except ValueError:
177
+ return "Quiz grading returned an invalid response."
178
+
179
+
180
+ def parent_summary(student_id: str) -> str:
181
+ if not BACKEND_URL:
182
+ return "Parent/teacher summary needs the backend."
183
+
184
+ student_id = (student_id or "hf-space-demo").strip()
185
+
186
+ try:
187
+ response = requests.get(
188
+ f"{BACKEND_URL}/parent-summary/{student_id}",
189
+ timeout=SHORT_TIMEOUT_SECONDS,
190
+ )
191
+
192
+ if not response.ok:
193
+ return _response_error(response, "Summary failed.")
194
+
195
+ summary = response.json()
196
+ except requests.Timeout:
197
+ return "Summary request timed out. Please try again."
198
+ except requests.RequestException as exc:
199
+ return f"Could not reach backend: {exc}"
200
+ except ValueError:
201
+ return "Summary returned an invalid response."
202
+
203
+ strengths = "\n".join(f"- {item}" for item in summary.get("strengths", []))
204
+ weak_topics = summary.get("weak_topics", [])
205
+ weak_topic_text = "\n".join(f"- {item}" for item in weak_topics) if weak_topics else "No weak topics recorded yet."
206
+
207
+ return (
208
+ f"Strengths\n{strengths}\n\n"
209
+ f"Weak topics\n{weak_topic_text}\n\n"
210
+ f"Suggested next practice\n{summary.get('suggested_next_practice', '')}\n\n"
211
+ f"Encouraging note\n{summary.get('encouraging_note', '')}\n\n"
212
+ f"Questions asked: {summary.get('questions_asked', 0)}"
213
+ )
214
+
215
+
216
+ def is_insufficient_backend_result(result: tuple[str, str, str, str, str, dict[str, Any]]) -> bool:
217
+ combined = " ".join(str(item) for item in result[:5]).lower()
218
  markers = [
219
  "not have enough textbook context",
220
  "not enough textbook context",
 
225
  return any(marker in combined for marker in markers)
226
 
227
 
228
+ def mock_response(question: str, textbook_context: str) -> tuple[str, str, str, str, str, dict[str, Any]]:
229
  context = textbook_context or EXAMPLE_CONTEXT
 
230
  normalized_question = normalize_question_mock(question)
231
+ concept_answer = mock_english_explanation(normalized_question, context)
232
 
233
+ english = f"Interpreted question: {normalized_question}\n\n{concept_answer}"
234
+ nepali = mock_nepali_explanation(normalized_question, context)
235
+ quiz_questions = mock_quiz_questions(normalized_question)
236
+
237
+ return (
238
+ english,
239
+ nepali,
240
+ format_quiz(quiz_questions),
241
+ format_sources(
242
+ [
243
+ {
244
+ "score": 1.0,
245
+ "text": context,
246
+ "metadata": {"filename": "demo-context", "chunk_index": 0},
247
+ }
248
+ ]
249
+ ),
250
+ "Demo fallback is active. Configure BACKEND_URL in Space settings for PDF upload, RAG search, quiz grading, and parent summary.",
251
+ {"quiz_questions": quiz_questions},
252
  )
253
+
254
+
255
+ def mock_english_explanation(normalized_question: str, context: str) -> str:
256
+ text = f"{normalized_question} {context}".lower()
257
+
258
+ if "reflection" in text or "mirror" in text:
259
+ return (
260
+ "Reflection of light means light bounces back after hitting a surface. "
261
+ "A mirror reflects light in an orderly way, so we can see a clear image "
262
+ "of an object in it. Smooth, flat surfaces make clearer reflections, "
263
+ "while rough surfaces scatter light and do not show a clear image."
264
+ )
265
+
266
+ if "soil erosion" in text:
267
+ return (
268
+ "Soil erosion means the top fertile layer of soil is carried away by "
269
+ "water, wind, or other causes. It makes land less useful for growing "
270
+ "plants, so planting trees and grass helps protect the soil."
271
+ )
272
+
273
+ if "photosynthesis" in text:
274
+ return (
275
+ "Photosynthesis is the process by which green plants make their own food "
276
+ "using sunlight, water, and carbon dioxide. Chlorophyll in leaves helps "
277
+ "plants capture sunlight, and oxygen is released during the process."
278
+ )
279
+
280
+ if "fraction" in text:
281
+ return (
282
+ "A fraction shows a part of a whole. The top number tells how many parts "
283
+ "we have, and the bottom number tells how many equal parts the whole was "
284
+ "divided into."
285
+ )
286
+
287
+ return (
288
+ "Demo answer from the pasted textbook context: "
289
+ f"{truncate(context, max_length=450)}"
290
  )
291
 
 
292
 
293
+ def mock_nepali_explanation(normalized_question: str, context: str = "") -> str:
294
+ text = f"{normalized_question} {context}".lower()
295
 
296
+ if "reflection" in text or "mirror" in text:
297
+ return (
298
+ "प्रकाशको परावर्तन भनेको प्रकाश कुनै सतहमा ठोक्किएर फर्कनु हो। ऐनाले "
299
+ "प्रकाशलाई राम्रोसँग फर्काउँछ, त्यसैले त्यसमा वस्तुको प्रतिबिम्ब देखिन्छ। "
300
+ "समथर र चिल्लो सतहमा प्रतिबिम्ब प्रस्ट देखिन्छ, तर खस्रो सतहमा प्रकाश धेरै "
301
+ "दिशामा छरिने भएकाले प्रतिबिम्ब प्रस्ट देखिँदैन।"
302
+ )
303
 
304
  if "soil erosion" in text:
305
  return (
 
336
  if "soil erosion" in text or ("mato" in text and "katan" in text):
337
  return "What is soil erosion?"
338
 
339
+ if "reflection" in text or "mirror" in text or "ainaa" in text or "aaina" in text:
340
+ return "What is reflection of light?"
341
+
342
  if "photosynthesis" in text or ("prakash" in text and "sansleshan" in text):
343
  return "What is photosynthesis?"
344
 
 
397
  return topic
398
 
399
 
400
+ def mock_quiz_questions(normalized_question: str) -> list[str]:
401
+ text = normalized_question.lower()
402
+
403
+ if "reflection" in text:
404
+ return [
405
+ "What happens to light during reflection?",
406
+ "Why does a mirror show a clear image?",
407
+ "Why do rough surfaces not show clear reflections?",
408
+ ]
409
+
410
+ return [
411
+ "What is the main idea from the explanation?",
412
+ "Can you give one simple example?",
413
+ "Can you explain it in your own words?",
414
+ ]
415
+
416
+
417
  def format_quiz(quiz_questions: list[Any]) -> str:
418
  questions = [
419
  str(question).strip()
 
434
  )
435
 
436
 
437
+ def format_sources(sources: list[Any]) -> str:
438
+ if not sources:
439
+ return "No retrieved sources returned."
440
+
441
+ formatted = []
442
+
443
+ for source in sources[:5]:
444
+ if not isinstance(source, dict):
445
+ continue
446
+
447
+ metadata = source.get("metadata", {}) if isinstance(source.get("metadata"), dict) else {}
448
+ filename = metadata.get("filename", "textbook")
449
+ chunk_index = metadata.get("chunk_index", "unknown")
450
+ score = source.get("score", 0)
451
+ text = str(source.get("text", "")).strip()
452
+ formatted.append(
453
+ f"Source: {filename}, chunk {chunk_index}, score {float(score):.3f}\n{text}"
454
+ )
455
+
456
+ return "\n\n".join(formatted) if formatted else "No retrieved sources returned."
457
+
458
+
459
+ def format_grade(data: dict[str, Any]) -> str:
460
+ lines = [f"Score: {data.get('score', 0)} / {data.get('total', 0)}"]
461
+ weak_areas = data.get("weak_areas", [])
462
+
463
+ if weak_areas:
464
+ lines.append(f"Weak areas: {', '.join(str(item) for item in weak_areas)}")
465
+
466
+ for item in data.get("results", []):
467
+ status = "Correct" if item.get("is_correct") else "Needs practice"
468
+ lines.append(f"{status}: {item.get('question', '')}")
469
+
470
+ if not item.get("is_correct"):
471
+ lines.append(f"Expected idea: {item.get('expected_answer', '')}")
472
+
473
+ return "\n".join(lines)
474
+
475
+
476
+ def _response_error(response: requests.Response, fallback: str) -> str:
477
+ try:
478
+ return str(response.json().get("detail", fallback))
479
+ except ValueError:
480
+ return fallback
481
+
482
+
483
  def truncate(text: str, max_length: int) -> str:
484
  if len(text) <= max_length:
485
  return text
 
487
  return f"{text[: max_length - 3]}..."
488
 
489
 
490
+ with gr.Blocks(title=APP_NAME, theme=gr.themes.Soft()) as demo:
491
  gr.Markdown(
492
  """
493
  # Pathshala AI
494
+ Bilingual AI tutor for rural primary students in Nepal. Upload a PDF when a
495
+ public backend is configured, or paste textbook context for the Space demo.
496
  """
497
  )
498
 
499
+ quiz_state = gr.State({})
500
+
501
  with gr.Row():
502
+ student_id_input = gr.Textbox(
503
+ label="Student ID",
504
+ value="hf-space-demo",
505
+ scale=1,
506
+ )
507
+ status_output = gr.Textbox(
508
+ label="Status",
509
+ value=(
510
+ "Backend connected." if BACKEND_URL else
511
+ "Demo fallback active. Set BACKEND_URL in Space settings for full RAG."
512
+ ),
513
+ interactive=False,
514
+ scale=2,
515
+ )
516
 
517
+ with gr.Tab("Ask"):
518
+ with gr.Row():
519
+ with gr.Column(scale=1):
520
+ pdf_input = gr.File(label="Upload textbook or worksheet PDF", file_types=[".pdf"], type="filepath")
521
+ upload_button = gr.Button("Upload PDF")
522
+ upload_output = gr.Textbox(label="Upload result", lines=3, interactive=False)
523
+
524
+ question_input = gr.Textbox(
525
+ label="Student question",
526
+ placeholder=EXAMPLE_QUESTION,
527
+ value=EXAMPLE_QUESTION,
528
+ lines=2,
529
+ )
530
+ context_input = gr.Textbox(
531
+ label="Optional textbook context",
532
+ placeholder="Paste a short textbook paragraph here.",
533
+ value=EXAMPLE_CONTEXT,
534
+ lines=7,
535
+ )
536
+ ask_button = gr.Button("Ask Tutor", variant="primary")
537
+
538
+ with gr.Column(scale=1):
539
+ english_output = gr.Textbox(label="English explanation", lines=8)
540
+ nepali_output = gr.Textbox(label="Nepali explanation", lines=8)
541
+ quiz_output = gr.Textbox(label="3 quiz questions", lines=5)
542
+
543
+ sources_output = gr.Textbox(label="Retrieved sources", lines=8)
544
+
545
+ with gr.Tab("Quiz"):
546
+ answer_1 = gr.Textbox(label="Your answer 1")
547
+ answer_2 = gr.Textbox(label="Your answer 2")
548
+ answer_3 = gr.Textbox(label="Your answer 3")
549
+ grade_button = gr.Button("Submit Quiz Answers", variant="primary")
550
+ grade_output = gr.Textbox(label="Quiz result", lines=10)
551
+
552
+ with gr.Tab("Parent Summary"):
553
+ summary_button = gr.Button("Show Parent/Teacher Summary")
554
+ summary_output = gr.Textbox(label="Summary", lines=14)
555
 
556
  gr.Examples(
557
  examples=[
558
  [EXAMPLE_QUESTION, EXAMPLE_CONTEXT],
559
  [
560
+ "What is reflection of light?",
561
  (
562
+ "When an object is placed in front of the mirror, the image is formed "
563
+ "due to reflection of light from the mirror. Flat and smooth surfaces "
564
+ "reflect light clearly, while rough surfaces do not."
565
  ),
566
  ],
567
  [
 
573
  ],
574
  ],
575
  inputs=[question_input, context_input],
576
+ outputs=[
577
+ english_output,
578
+ nepali_output,
579
+ quiz_output,
580
+ sources_output,
581
+ status_output,
582
+ quiz_state,
583
+ ],
584
+ fn=lambda question, context: ask_tutor(question, "hf-space-demo", context),
585
  cache_examples=False,
586
  )
587
 
588
+ upload_button.click(
589
+ fn=upload_textbook,
590
+ inputs=[pdf_input],
591
+ outputs=[upload_output],
592
+ )
593
  ask_button.click(
594
  fn=ask_tutor,
595
+ inputs=[question_input, student_id_input, context_input],
596
+ outputs=[
597
+ english_output,
598
+ nepali_output,
599
+ quiz_output,
600
+ sources_output,
601
+ status_output,
602
+ quiz_state,
603
+ ],
604
+ )
605
+ grade_button.click(
606
+ fn=grade_quiz,
607
+ inputs=[answer_1, answer_2, answer_3, student_id_input, quiz_state],
608
+ outputs=[grade_output],
609
+ )
610
+ summary_button.click(
611
+ fn=parent_summary,
612
+ inputs=[student_id_input],
613
+ outputs=[summary_output],
614
  )
615
 
616