rairo commited on
Commit
fa67eb8
·
verified ·
1 Parent(s): 4d37637

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +683 -374
main.py CHANGED
@@ -5,6 +5,8 @@ import re
5
  import time
6
  import threading
7
  import base64
 
 
8
  import numpy as np
9
  import fitz # PyMuPDF
10
  from flask import Flask, request, jsonify
@@ -21,13 +23,20 @@ from firebase_admin import credentials, db as firebase_db
21
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
22
  logger = logging.getLogger(__name__)
23
 
24
- SYLLABI_DIR = "syllabi"
25
- PAST_EXAMS_DIR = "past_exams"
26
 
27
- # Support both naming conventions (HuggingFace Space may use either)
28
  GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY") or os.environ.get("Gemini")
29
- EMBEDDING_MODEL = "models/text-embedding-004"
30
- VISION_MODEL = "gemini-2.0-flash"
 
 
 
 
 
 
 
31
 
32
  # ---------------------------------------------------------------------------
33
  # COMPLETE SUBJECT REGISTRY (all 24 PDFs on HuggingFace)
@@ -61,14 +70,18 @@ O_LEVEL_SUBJECTS = {
61
  "O_0625": "Physics",
62
  }
63
  ALL_SUBJECTS = {**A_LEVEL_SUBJECTS, **O_LEVEL_SUBJECTS}
 
 
 
 
64
 
65
  # ---------------------------------------------------------------------------
66
  # GLOBAL STATE
67
  # ---------------------------------------------------------------------------
68
- SYLLABUS_MAP = {}
69
- VECTOR_DB = []
70
  VECTOR_MATRIX = None
71
- EXAM_MAP = {}
72
 
73
  app = Flask(__name__)
74
  CORS(app)
@@ -83,9 +96,9 @@ def init_firebase():
83
  global firebase_db_ref, FIREBASE_AVAILABLE
84
  try:
85
  creds_str = os.environ.get("FIREBASE")
86
- db_url = os.environ.get("Firebase_DB")
87
  if not creds_str or not db_url:
88
- logger.warning("Firebase env vars missing.")
89
  return False
90
  if not firebase_admin._apps:
91
  cred = credentials.Certificate(json.loads(creds_str))
@@ -101,13 +114,18 @@ def init_firebase():
101
  FIREBASE_AVAILABLE = init_firebase()
102
 
103
  def fb_set(path, data):
104
- if not FIREBASE_AVAILABLE: return
105
- try: firebase_db_ref.child(path).set(data)
106
- except Exception as e: logger.error(f"FB write [{path}]: {e}")
 
 
 
107
 
108
  def fb_get(path):
109
- if not FIREBASE_AVAILABLE: return None
110
- try: return firebase_db_ref.child(path).get()
 
 
111
  except Exception as e:
112
  logger.error(f"FB read [{path}]: {e}")
113
  return None
@@ -124,62 +142,230 @@ def get_gemini():
124
  return _gemini_client
125
 
126
  # ---------------------------------------------------------------------------
127
- # VISION-BASED PAGE CLASSIFIER
128
- # Renders each page as an image and asks Gemini to classify it.
129
- # Falls back to heuristic if vision call fails or key is absent.
130
  # ---------------------------------------------------------------------------
131
 
132
- DEFINITE_BOILERPLATE_RE = re.compile(
133
- r'^\s*(about\s+this\s+syllabus|foreword|acknowledgements?|'
134
- r'why\s+choose\s+(cambridge|zimsec|this\s+syllabus)|cambridge\s+learner|'
135
- r'key\s+benefits?|how\s+to\s+use\s+this\s+syllabus|'
136
- r'support\s+for\s+(cambridge|teachers)|resource\s+list|'
137
- r'further\s+information|copyright|legal\s+notice|'
138
- r'changes\s+to\s+this\s+syllabus|university\s+of\s+cambridge|'
139
- r'cambridge\s+assessment\s+international|published\s+by|'
140
- r'contents?\s*$|table\s+of\s+contents?|'
141
- r'assessment\s+at\s+a\s+glance|syllabus\s+at\s+a\s+glance|'
142
- r'grade\s+descriptions?|command\s+words|glossary\s+of\s+command|'
143
- r'mathematical\s+notation|other\s+cambridge\s+qualifications|'
144
- r'how\s+to\s+offer|progression|post[-\s]?qualification|'
145
- r'school\s+supported\s+candidate|cambridge\s+primary|cambridge\s+lower\s+secondary)\s*$',
146
- re.IGNORECASE
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  )
148
 
 
149
  CONTENT_START_RE = re.compile(
150
- r'(^|\n)\s*(\d+\.?\d*\s+[A-Z][a-z]|\d+\s+[A-Z][a-z]|'
151
- r'subject\s+content|'
152
- r'unit\s+\d|topic\s+\d|section\s+\d|module\s+\d|'
153
- r'component\s+\d|paper\s+\d|'
154
- r'learning\s+objectives|knowledge\s+and\s+understanding|'
155
- r'candidates\s+should\s+be\s+able)',
156
- re.IGNORECASE
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  )
158
 
159
- def _page_to_base64_png(page, dpi=72) -> str:
160
- mat = fitz.Matrix(dpi / 72, dpi / 72)
161
- pix = page.get_pixmap(matrix=mat, colorspace=fitz.csRGB)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
162
  return base64.b64encode(pix.tobytes("png")).decode("utf-8")
163
 
164
- def _vision_classify_page(page, page_num: int, subject_name: str) -> str:
 
165
  """Returns 'boilerplate', 'content', or 'uncertain'."""
166
  client = get_gemini()
167
  if client is None:
168
  return "uncertain"
169
  try:
170
- b64 = _page_to_base64_png(page)
 
171
  prompt = (
172
- f"This is page {page_num + 1} of a Cambridge International AS & A Level / "
173
- f"IGCSE syllabus for {subject_name}.\n\n"
174
  "Classify this page as ONE of:\n"
175
- "BOILERPLATE - administrative or introductory content: foreword, about this "
176
- "syllabus, why choose Cambridge, key benefits, Cambridge learner attributes, "
177
- "how to use this syllabus, table of contents, copyright, assessment overview "
178
- "tables, grade descriptions, command words, mathematical notation appendix, "
179
- "support information, changes to syllabus, qualification overview.\n"
180
- "CONTENT - actual subject matter students must learn: topic lists, learning "
181
- "objectives, numbered content sections, subject-specific knowledge points, "
182
- "skills, practical work descriptions, candidate assessment criteria.\n\n"
183
  "Reply with exactly one word: BOILERPLATE or CONTENT"
184
  )
185
  resp = client.models.generate_content(
@@ -190,244 +376,335 @@ def _vision_classify_page(page, page_num: int, subject_name: str) -> str:
190
  ]}]
191
  )
192
  answer = (resp.text or "").strip().upper()
193
- if "BOILERPLATE" in answer: return "boilerplate"
194
- if "CONTENT" in answer: return "content"
 
 
195
  return "uncertain"
196
  except Exception as e:
197
- logger.warning(f"Vision classify page {page_num}: {e}")
198
  return "uncertain"
199
 
200
- def classify_all_pages(doc, subject_name: str) -> list:
 
201
  """
202
- Returns list of 'boilerplate' or 'content' for each page.
203
- Uses vision for first 40 pages, heuristic after that.
204
- Caches result to avoid re-classifying on incremental runs.
205
  """
206
- classifications = []
207
  n = len(doc)
 
 
 
208
 
209
  for i, page in enumerate(doc):
210
- text = page.get_text("text").strip()
211
-
212
- # Pages beyond the front-matter zone are almost always content
213
- if i >= 40:
214
- classifications.append("content")
215
- continue
216
-
217
- # Hard-rule catch
218
- first_lines = [l.strip() for l in text.splitlines() if l.strip()][:3]
219
- if first_lines and DEFINITE_BOILERPLATE_RE.match(first_lines[0]):
220
- classifications.append("boilerplate")
221
- continue
222
 
223
- # Empty page
224
- if len(text) < 30:
225
- classifications.append("boilerplate")
226
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
 
228
- # Vision call
229
- verdict = _vision_classify_page(page, i, subject_name)
230
- if verdict == "uncertain":
231
- verdict = "content" if CONTENT_START_RE.search(text) else "boilerplate"
232
  classifications.append(verdict)
233
- logger.info(f" Page {i+1}/{n}: {verdict}")
 
234
 
235
- # Safety: if vision misclassified everything as boilerplate, use heuristic fallback
236
  if not any(c == "content" for c in classifications):
237
- logger.warning(f" All pages BOILERPLATE for {subject_name} applying heuristic fallback.")
238
- classifications = []
239
- found_content = False
240
- for i, page in enumerate(doc):
241
- text = page.get_text("text")
242
- if not found_content and CONTENT_START_RE.search(text):
243
- found_content = True
244
- classifications.append("content" if found_content else "boilerplate")
245
-
246
- return classifications
247
-
248
 
249
  # ---------------------------------------------------------------------------
250
- # PDF PARSER — Vision-enhanced
251
  # ---------------------------------------------------------------------------
252
 
253
  class PDFParser:
254
  def __init__(self, filepath):
255
- self.filepath = filepath
256
- self.filename = os.path.basename(filepath)
257
- self.doc = fitz.open(filepath)
258
- parts = filepath.replace("\\", "/").split("/")
259
- self.level = parts[-2] if len(parts) > 1 else "General"
260
- code_m = re.search(r'\d{4}', self.filename)
261
- self.subject_code = code_m.group(0) if code_m else "0000"
262
- self.unique_id = f"{self.level}_{self.subject_code}"
263
- self.subject_name = ALL_SUBJECTS.get(
264
- self.unique_id,
265
- re.sub(r'[_\-]\d{4}.*', '', self.filename.replace('_', ' ')).strip()
266
- )
 
 
 
 
 
 
 
 
 
 
267
 
268
  def get_body_font_size(self):
269
- sizes = {}
270
  for page in self.doc:
271
- for b in page.get_text("dict")["blocks"]:
272
- for l in b.get("lines", []):
273
- for s in l.get("spans", []):
274
- sz = round(s["size"], 1)
275
- sizes[sz] = sizes.get(sz, 0) + len(s["text"])
 
 
 
 
 
 
276
  return max(sizes, key=sizes.get) if sizes else 10.0
277
 
278
  def parse(self):
279
- body_size = self.get_body_font_size()
280
- page_classes = classify_all_pages(self.doc, self.subject_name)
281
- topic_pattern = re.compile(r'^(\d+\.?\s|Key\s+Question\s)', re.IGNORECASE)
 
282
 
283
- logger.info(f"Parsing content of {self.filename} (body ~{body_size}pt)")
284
  content_page_count = sum(1 for c in page_classes if c == "content")
285
- logger.info(f" {content_page_count} content pages out of {len(self.doc)} total")
286
-
287
- syllabus_tree = []
288
- current_topic = None
289
- current_subtopic = None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
  for page_num, page in enumerate(self.doc):
292
  if page_classes[page_num] == "boilerplate":
293
  continue
294
 
295
- for b in page.get_text("dict")["blocks"]:
296
- block_text = ""
297
- max_size = 0
298
- is_bold = False
 
 
 
 
 
 
299
 
300
  for l in b.get("lines", []):
 
301
  for s in l.get("spans", []):
302
- t = s["text"].strip()
303
- if not t: continue
304
- block_text += t + " "
305
- if s["size"] > max_size: max_size = s["size"]
306
- if "bold" in s["font"].lower(): is_bold = True
307
-
308
- block_text = block_text.strip()
309
- if len(block_text) < 3: continue
310
-
311
- # Skip residual boilerplate blocks within content pages
312
- first_words = " ".join(block_text.split()[:6])
313
- if DEFINITE_BOILERPLATE_RE.match(first_words):
 
 
 
 
 
 
314
  continue
315
 
316
- # TOPIC
317
- if max_size > body_size + 2:
318
- if current_subtopic and current_topic:
319
- current_topic["children"].append(current_subtopic)
320
- current_subtopic = None
321
- if current_topic:
322
- syllabus_tree.append(current_topic)
 
 
 
 
 
 
 
 
 
323
  current_topic = {
324
- "id": f"{self.unique_id}_{len(syllabus_tree)}",
325
- "title": block_text,
326
- "type": "topic",
327
- "children": []
 
328
  }
329
  current_subtopic = None
330
-
331
- # SUBTOPIC
332
- elif (is_bold and max_size >= body_size) or \
333
- (topic_pattern.match(block_text) and max_size >= body_size):
334
- if current_subtopic and current_topic:
335
- current_topic["children"].append(current_subtopic)
336
  if not current_topic:
337
  current_topic = {
338
  "id": f"{self.unique_id}_root",
339
- "title": "Syllabus Content",
340
  "type": "topic",
341
- "children": []
 
342
  }
 
343
  current_subtopic = {
344
- "id": f"{current_topic['id']}_{len(current_topic['children'])}",
345
- "title": block_text,
346
- "type": "subtopic",
347
- "content": []
 
348
  }
349
-
350
- # BODY
351
- elif max_size <= body_size + 1:
352
- if current_subtopic:
353
- current_subtopic["content"].append(block_text)
354
- elif current_topic:
 
 
 
 
355
  current_subtopic = {
356
- "id": f"{current_topic['id']}_intro",
357
- "title": "Overview",
358
- "type": "subtopic",
359
- "content": [block_text]
 
360
  }
 
 
 
 
361
 
362
- if current_subtopic and current_topic:
363
- current_topic["children"].append(current_subtopic)
364
- if current_topic:
365
- syllabus_tree.append(current_topic)
366
 
367
  return {
368
  "meta": {
369
- "id": self.unique_id,
370
- "subject": self.subject_name,
371
- "code": self.subject_code,
372
- "level": self.level,
373
- "filename": self.filename,
374
- "indexed_at": int(time.time())
 
 
 
 
 
375
  },
376
- "tree": syllabus_tree
 
 
 
377
  }
378
 
379
-
380
  # ---------------------------------------------------------------------------
381
  # PAST EXAM PARSER
382
  # ---------------------------------------------------------------------------
383
 
384
  class ExamPaperParser:
385
  def __init__(self, filepath):
386
- self.filepath = filepath
387
- self.filename = os.path.basename(filepath)
388
- self.doc = fitz.open(filepath)
389
- parts = filepath.replace("\\", "/").split("/")
390
- self.level = parts[-2] if len(parts) > 1 else "General"
391
- code_m = re.search(r'\b(\d{4})\b', self.filename)
392
- self.subject_code = code_m.group(1) if code_m else "0000"
393
- self.unique_id = f"{self.level}_{self.subject_code}"
394
- year_m = re.search(r'\b(20\d{2}|19\d{2})\b', self.filename)
395
- self.year = year_m.group(1) if year_m else "Unknown"
396
- sess_m = re.search(r'(may[_\-]?june|oct[_\-]?nov|feb[_\-]?mar|summer|winter|s\d|w\d|m\d)', self.filename, re.IGNORECASE)
397
- self.session = sess_m.group(1).upper() if sess_m else "Unknown"
398
- paper_m = re.search(r'[_\-]p(\d)|paper[\s_\-]?(\d)', self.filename, re.IGNORECASE)
399
- self.paper_num = (paper_m.group(1) or paper_m.group(2)) if paper_m else "1"
400
- self.paper_id = f"{self.unique_id}_{self.year}_{self.session}_P{self.paper_num}"
401
 
402
  def extract_pages(self):
403
- return [{"page": i + 1, "text": p.get_text("text").strip()[:3000]}
404
- for i, p in enumerate(self.doc) if p.get_text("text").strip()]
 
 
 
405
 
406
  def extract_questions(self):
407
  full = "\n".join(p["text"] for p in self.extract_pages())
408
- pat = re.compile(r'(?:^|\n)\s*(\d{1,2})\s*[\.\)]\s+(.+?)(?=\n\s*\d{1,2}\s*[\.\)]|\Z)', re.DOTALL | re.MULTILINE)
409
- return [{"number": int(m.group(1)), "text": m.group(2).strip()[:2000]}
410
- for m in pat.finditer(full) if len(m.group(2).strip()) > 20]
 
 
 
411
 
412
  def parse(self):
 
413
  return {
414
  "meta": {
415
- "paperId": self.paper_id,
416
- "subjectId": self.unique_id,
 
417
  "subjectCode": self.subject_code,
418
- "level": self.level,
419
- "year": self.year,
420
- "session": self.session,
421
  "paperNumber": self.paper_num,
422
- "filename": self.filename,
423
- "totalPages": len(self.doc),
424
- "indexed_at": int(time.time())
425
  },
426
- "pages": self.extract_pages(),
427
- "questions": self.extract_questions()
428
  }
429
 
430
-
431
  # ---------------------------------------------------------------------------
432
  # EMBEDDINGS
433
  # ---------------------------------------------------------------------------
@@ -435,7 +712,9 @@ class ExamPaperParser:
435
  def generate_embeddings(texts):
436
  client = get_gemini()
437
  if client is None:
 
438
  return [np.zeros(768).tolist() for _ in texts]
 
439
  results = []
440
  for i in range(0, len(texts), 10):
441
  batch = texts[i:i + 10]
@@ -449,30 +728,36 @@ def generate_embeddings(texts):
449
  results.append(np.zeros(768).tolist())
450
  return results
451
 
452
-
453
  # ---------------------------------------------------------------------------
454
  # FIREBASE PERSISTENCE
455
  # ---------------------------------------------------------------------------
456
 
457
  def load_index_from_firebase():
458
  global SYLLABUS_MAP, VECTOR_DB, VECTOR_MATRIX, EXAM_MAP
459
- if not FIREBASE_AVAILABLE: return False
 
460
  logger.info("Loading index from Firebase ...")
461
  try:
462
  fb_syllabi = fb_get("data_api/syllabi")
463
- if not fb_syllabi: return False
 
464
  SYLLABUS_MAP = fb_syllabi
465
 
466
  fb_vectors = fb_get("data_api/vectors")
467
- if not fb_vectors: return False
468
- VECTOR_DB = []
469
- valid = []
470
- expected_dim = 768 # text-embedding-004 output dimension
471
- for entry in sorted(fb_vectors.keys() if isinstance(fb_vectors, dict) else range(len(fb_vectors))):
 
 
 
472
  item = fb_vectors[entry] if isinstance(fb_vectors, dict) else fb_vectors[entry]
473
- if not item: continue
 
474
  raw_vec = item.get("vector")
475
- if not raw_vec: continue
 
476
  try:
477
  vec = np.array(raw_vec, dtype=np.float32)
478
  if vec.ndim != 1 or len(vec) != expected_dim:
@@ -483,8 +768,8 @@ def load_index_from_firebase():
483
  except Exception as ve:
484
  logger.warning(f"Skipping malformed vector entry: {ve}")
485
  continue
486
- if valid:
487
- VECTOR_MATRIX = np.vstack(valid).astype(np.float32)
488
  logger.info(f"Vector matrix shape: {VECTOR_MATRIX.shape if VECTOR_MATRIX is not None else None}")
489
 
490
  fb_exams = fb_get("data_api/exams")
@@ -497,63 +782,77 @@ def load_index_from_firebase():
497
  logger.error(f"Firebase load: {e}")
498
  return False
499
 
 
500
  def save_syllabus(sid, data):
501
  fb_set(f"data_api/syllabi/{sid}", data)
502
 
 
503
  def save_all_vectors():
504
  fb_data = {}
505
  for i, entry in enumerate(VECTOR_DB):
506
  fb_data[f"v_{i:06d}"] = {
507
  "vector": entry["vector"].tolist() if isinstance(entry["vector"], np.ndarray) else entry["vector"],
508
- "meta": entry["meta"]
509
  }
510
  fb_set("data_api/vectors", fb_data)
511
 
 
512
  def save_exam(sid, exam_data):
513
- safe = re.sub(r'[.\[\]#$/]', '_', exam_data["meta"]["paperId"])
514
  fb_set(f"data_api/exams/{sid}/{safe}", exam_data)
515
 
516
-
517
  # ---------------------------------------------------------------------------
518
  # INDEX BUILDER
519
  # ---------------------------------------------------------------------------
520
 
 
 
 
 
 
521
  def build_index():
522
  global SYLLABUS_MAP, VECTOR_DB, VECTOR_MATRIX, EXAM_MAP
523
  logger.info("Full index build starting ...")
 
524
  parsed_data = []
525
 
526
  if os.path.exists(SYLLABI_DIR):
 
527
  for root, _, files in os.walk(SYLLABI_DIR):
528
- for f in sorted(files):
529
- if not f.endswith(".pdf"): continue
530
- path = os.path.join(root, f)
531
- logger.info(f"Syllabus: {path}")
532
- try:
533
- parser = PDFParser(path)
534
- data = parser.parse()
535
- parsed_data.append(data)
536
- SYLLABUS_MAP[data["meta"]["id"]] = data
537
- save_syllabus(data["meta"]["id"], data)
538
- except Exception as e:
539
- logger.error(f"{path}: {e}")
 
 
540
 
541
  if os.path.exists(PAST_EXAMS_DIR):
 
542
  for root, _, files in os.walk(PAST_EXAMS_DIR):
543
- for f in sorted(files):
544
- if not f.endswith(".pdf"): continue
545
- path = os.path.join(root, f)
546
- logger.info(f"Exam: {path}")
547
- try:
548
- parser = ExamPaperParser(path)
549
- exam_data = parser.parse()
550
- sid = exam_data["meta"]["subjectId"]
551
- if sid not in EXAM_MAP: EXAM_MAP[sid] = {}
552
- safe = re.sub(r'[.\[\]#$/]', '_', exam_data["meta"]["paperId"])
553
- EXAM_MAP[sid][safe] = exam_data
554
- save_exam(sid, exam_data)
555
- except Exception as e:
556
- logger.error(f"{path}: {e}")
 
 
557
 
558
  if not parsed_data:
559
  logger.info("Nothing to vectorize.")
@@ -562,57 +861,66 @@ def build_index():
562
  chunks, metas = [], []
563
  for item in parsed_data:
564
  mb = item["meta"]
565
- for topic in item["tree"]:
566
  for sub in topic.get("children", []):
567
  blob = "\n".join(sub.get("content", []))
568
- if len(blob) < 10: continue
569
- chunks.append(f"{mb['subject']} {mb['level']} - {topic['title']} - {sub['title']}:\n{blob}")
 
570
  metas.append({
571
- "subject_id": mb["id"],
572
- "topic_id": topic["id"],
 
 
 
 
573
  "subtopic_id": sub["id"],
574
- "title": sub["title"],
575
- "content": blob
576
  })
577
 
578
- logger.info(f"Embedding {len(chunks)} chunks ...")
579
  vecs = generate_embeddings(chunks)
580
  VECTOR_DB = []
581
- valid = []
582
  for i, v in enumerate(vecs):
583
- nv = np.array(v)
584
  VECTOR_DB.append({"vector": nv, "meta": metas[i]})
585
  valid.append(nv)
586
- if valid:
587
- VECTOR_MATRIX = np.vstack(valid)
588
  save_all_vectors()
589
  logger.info(f"Index done: {len(SYLLABUS_MAP)} syllabi, {len(VECTOR_DB)} vectors.")
590
 
591
 
592
  def _incremental_vectorize(syllabus_data):
593
  global VECTOR_DB, VECTOR_MATRIX
594
- mb = syllabus_data["meta"]
595
  chunks, metas = [], []
596
- for topic in syllabus_data["tree"]:
597
  for sub in topic.get("children", []):
598
  blob = "\n".join(sub.get("content", []))
599
- if len(blob) < 10: continue
600
- chunks.append(f"{mb['subject']} {mb['level']} - {topic['title']} - {sub['title']}:\n{blob}")
 
601
  metas.append({
602
- "subject_id": mb["id"],
603
- "topic_id": topic["id"],
 
 
 
 
604
  "subtopic_id": sub["id"],
605
- "title": sub["title"],
606
- "content": blob
607
  })
608
- if not chunks: return
 
609
  for i, v in enumerate(generate_embeddings(chunks)):
610
- VECTOR_DB.append({"vector": np.array(v), "meta": metas[i]})
611
  if VECTOR_DB:
612
- VECTOR_MATRIX = np.vstack([e["vector"] for e in VECTOR_DB])
613
  save_all_vectors()
614
 
615
-
616
  # ---------------------------------------------------------------------------
617
  # WATCHER
618
  # ---------------------------------------------------------------------------
@@ -620,42 +928,46 @@ _indexed_files = set()
620
 
621
  def _collect_existing():
622
  for d in [SYLLABI_DIR, PAST_EXAMS_DIR]:
623
- if not os.path.exists(d): continue
 
624
  for root, _, files in os.walk(d):
625
  for f in files:
626
- if f.endswith(".pdf"):
627
  _indexed_files.add(os.path.join(root, f))
628
 
 
629
  def _watch(interval=30):
630
  while True:
631
  time.sleep(interval)
632
  for directory, is_exam in [(SYLLABI_DIR, False), (PAST_EXAMS_DIR, True)]:
633
- if not os.path.exists(directory): continue
 
634
  for root, _, files in os.walk(directory):
635
  for f in files:
636
- if not f.endswith(".pdf"): continue
 
637
  path = os.path.join(root, f)
638
- if path in _indexed_files: continue
 
639
  _indexed_files.add(path)
640
- logger.info(f"New PDF: {path}")
641
  try:
642
  if is_exam:
643
- parser = ExamPaperParser(path)
644
  exam_data = parser.parse()
645
- sid = exam_data["meta"]["subjectId"]
646
- if sid not in EXAM_MAP: EXAM_MAP[sid] = {}
647
- safe = re.sub(r'[.\[\]#$/]', '_', exam_data["meta"]["paperId"])
648
  EXAM_MAP[sid][safe] = exam_data
649
  save_exam(sid, exam_data)
650
  else:
651
  parser = PDFParser(path)
652
- data = parser.parse()
653
  SYLLABUS_MAP[data["meta"]["id"]] = data
654
  save_syllabus(data["meta"]["id"], data)
655
  _incremental_vectorize(data)
656
  except Exception as e:
657
- logger.error(f"Watch {path}: {e}")
658
-
659
 
660
  # ---------------------------------------------------------------------------
661
  # API
@@ -663,14 +975,15 @@ def _watch(interval=30):
663
 
664
  @app.route('/', methods=['GET'])
665
  def index():
666
- """Root route — required for HuggingFace Spaces health checks and iframe rendering."""
667
  return jsonify({
668
- "name": "Marka Data API",
669
- "version": "2.0",
670
- "status": "online",
671
  "subjects_loaded": len(SYLLABUS_MAP),
672
- "vector_chunks": len(VECTOR_DB),
673
- "firebase": FIREBASE_AVAILABLE,
 
 
674
  "endpoints": [
675
  "GET /health",
676
  "GET /v1/subjects",
@@ -679,34 +992,36 @@ def index():
679
  "GET /v1/exams",
680
  "GET /v1/exams/<paper_id>",
681
  "GET /v1/exams/<paper_id>/questions",
682
- "POST /v1/rebuild"
683
- ]
 
684
  })
685
 
686
-
687
  @app.route('/health', methods=['GET'])
688
  def health():
689
  return jsonify({
690
- "status": "online",
691
  "subjects_loaded": list(SYLLABUS_MAP.keys()),
692
- "subject_count": len(SYLLABUS_MAP),
693
- "vector_chunks": len(VECTOR_DB),
694
- "exam_subjects": list(EXAM_MAP.keys()),
695
- "firebase": FIREBASE_AVAILABLE,
696
- "registered_subjects": ALL_SUBJECTS
 
 
697
  })
698
 
699
  @app.route('/v1/subjects', methods=['GET'])
700
  def list_subjects():
701
  result = []
702
  for sid, data in SYLLABUS_MAP.items():
703
- result.append({**data.get("meta", {"id": sid}), "indexed": True})
 
704
  for uid, name in ALL_SUBJECTS.items():
705
  if uid not in SYLLABUS_MAP:
706
  level = "A" if uid.startswith("A_") else "O"
707
- result.append({"id": uid, "subject": name, "code": uid.split("_")[1],
708
- "level": level, "indexed": False})
709
- return jsonify(result)
710
 
711
  @app.route('/v1/structure/<subject_id>', methods=['GET'])
712
  def get_structure(subject_id):
@@ -719,9 +1034,9 @@ def get_structure(subject_id):
719
  def search():
720
  if VECTOR_MATRIX is None or not VECTOR_DB:
721
  return jsonify({"error": "Index not ready"}), 503
722
- req = request.json or {}
723
- q = req.get("query")
724
- sf = req.get("filter_subject_id")
725
  if not q:
726
  return jsonify({"error": "Query required"}), 400
727
  c = get_gemini()
@@ -729,24 +1044,35 @@ def search():
729
  return jsonify({"error": "Embedding API not configured"}), 503
730
  try:
731
  resp = c.models.embed_content(model=EMBEDDING_MODEL, contents=q)
732
- qv = np.array(resp.embeddings[0].values).reshape(1, -1)
733
  except Exception as e:
734
  logger.error(f"Embed query failed: {e}")
735
  return jsonify({"error": f"Embedding failed: {str(e)}"}), 500
736
  try:
737
- scores = cosine_similarity(qv, VECTOR_MATRIX)[0]
738
  except Exception as e:
739
  logger.error(f"Cosine similarity failed: {e}, matrix shape: {VECTOR_MATRIX.shape if VECTOR_MATRIX is not None else None}, query shape: {qv.shape}")
740
  return jsonify({"error": f"Search index error: {str(e)}"}), 500
741
  results = []
742
  for idx in np.argsort(scores)[::-1]:
743
- if scores[idx] < 0.3: break
 
744
  meta = VECTOR_DB[idx]["meta"]
745
- if sf and meta["subject_id"] != sf: continue
746
- results.append({"score": float(scores[idx]), "subject_id": meta["subject_id"],
747
- "title": meta["title"], "content": meta["content"],
748
- "node_id": meta["subtopic_id"]})
749
- if len(results) >= 5: break
 
 
 
 
 
 
 
 
 
 
750
  return jsonify({"results": results})
751
 
752
  @app.route('/v1/exams', methods=['GET'])
@@ -754,7 +1080,8 @@ def list_exams():
754
  sid = request.args.get("subject_id")
755
  out = []
756
  for s, papers in EXAM_MAP.items():
757
- if sid and s != sid: continue
 
758
  for p in papers.values():
759
  if isinstance(p, dict) and "meta" in p:
760
  out.append(p["meta"])
@@ -762,7 +1089,7 @@ def list_exams():
762
 
763
  @app.route('/v1/exams/<paper_id>', methods=['GET'])
764
  def get_exam(paper_id):
765
- safe = re.sub(r'[.\[\]#$/]', '_', paper_id)
766
  for _, papers in EXAM_MAP.items():
767
  for key, paper in papers.items():
768
  if key == safe or (isinstance(paper, dict) and paper.get("meta", {}).get("paperId") == paper_id):
@@ -771,78 +1098,54 @@ def get_exam(paper_id):
771
 
772
  @app.route('/v1/exams/<paper_id>/questions', methods=['GET'])
773
  def get_exam_questions(paper_id):
774
- safe = re.sub(r'[.\[\]#$/]', '_', paper_id)
775
  for _, papers in EXAM_MAP.items():
776
  for key, paper in papers.items():
777
  if key == safe or (isinstance(paper, dict) and paper.get("meta", {}).get("paperId") == paper_id):
778
  return jsonify({"paperId": paper_id, "meta": paper.get("meta"), "questions": paper.get("questions", [])})
779
  return jsonify({"error": "Not found"}), 404
780
 
781
-
782
  @app.route('/v1/reset', methods=['GET', 'POST'])
783
  def reset_and_rebuild():
784
  """
785
  One-time cache reset endpoint.
786
- Wipes data_api/syllabi and data_api/vectors from Firebase,
787
- then triggers a full fresh rebuild from the PDF files.
788
 
789
- Protected by REBUILD_SECRET env var. If that var is not set,
790
- protected by a hardcoded one-time token so it cannot be called accidentally.
791
-
792
- Call from browser: GET /v1/reset?token=marka-reset-2026
793
- Or as POST with Authorization: Bearer marka-reset-2026
794
  """
795
- # Token check — use REBUILD_SECRET if set, otherwise the hardcoded token
796
  expected = os.environ.get("REBUILD_SECRET", "marka-reset-2026")
797
- token = (
798
- request.args.get("token")
799
- or request.headers.get("Authorization", "").replace("Bearer ", "").strip()
800
- )
801
  if token != expected:
802
- return jsonify({
803
- "error": "Unauthorized",
804
- "hint": "Pass ?token=<REBUILD_SECRET> in the URL"
805
- }), 401
806
 
807
  def _reset_bg():
808
  global SYLLABUS_MAP, VECTOR_DB, VECTOR_MATRIX, EXAM_MAP
809
  logger.info("=== CACHE RESET STARTED ===")
810
-
811
- # 1. Wipe Firebase cache nodes
812
  if FIREBASE_AVAILABLE:
813
- try:
814
- firebase_db_ref.child("data_api/syllabi").delete()
815
- logger.info(" ✓ Deleted data_api/syllabi from Firebase")
816
- except Exception as e:
817
- logger.error(f" ✗ Failed to delete syllabi: {e}")
818
- try:
819
- firebase_db_ref.child("data_api/vectors").delete()
820
- logger.info(" ✓ Deleted data_api/vectors from Firebase")
821
- except Exception as e:
822
- logger.error(f" ✗ Failed to delete vectors: {e}")
823
  else:
824
  logger.warning(" Firebase not available — skipping Firebase wipe")
825
 
826
- # 2. Clear in-memory state
827
- SYLLABUS_MAP = {}
828
- VECTOR_DB = []
829
  VECTOR_MATRIX = None
830
- EXAM_MAP = {}
831
  logger.info(" ✓ In-memory state cleared")
832
-
833
- # 3. Full rebuild from PDFs with vision classifier
834
- logger.info(" Starting full rebuild with vision-based parser ...")
835
  build_index()
836
  logger.info("=== CACHE RESET COMPLETE ===")
837
 
838
  threading.Thread(target=_reset_bg, daemon=True).start()
839
-
840
  return jsonify({
841
- "status": "reset started",
842
- "message": "Firebase cache wiped. Full rebuild running in background. "
843
- "Check /health in 3-5 minutes to see subject and vector counts update. "
844
- "All 24 PDFs will be re-parsed with the vision classifier.",
845
- "watch": "/health"
846
  }), 202
847
 
848
  @app.route('/v1/rebuild', methods=['POST'])
@@ -850,13 +1153,17 @@ def trigger_rebuild():
850
  secret = os.environ.get("REBUILD_SECRET", "")
851
  if secret and request.headers.get("Authorization", "") != f"Bearer {secret}":
852
  return jsonify({"error": "Unauthorized"}), 401
 
853
  def _bg():
854
  global SYLLABUS_MAP, VECTOR_DB, VECTOR_MATRIX, EXAM_MAP
855
- SYLLABUS_MAP = {}; VECTOR_DB = []; VECTOR_MATRIX = None; EXAM_MAP = {}
 
 
 
856
  build_index()
857
- threading.Thread(target=_bg, daemon=True).start()
858
- return jsonify({"status": "rebuild started"}), 202
859
 
 
 
860
 
861
  # ---------------------------------------------------------------------------
862
  # STARTUP
@@ -867,10 +1174,12 @@ def start_app():
867
  if not os.path.exists(d):
868
  os.makedirs(os.path.join(d, "A"), exist_ok=True)
869
  os.makedirs(os.path.join(d, "O"), exist_ok=True)
 
870
  if not load_index_from_firebase():
871
  build_index()
872
  else:
873
- logger.info("Served from Firebase cache.")
 
874
  _collect_existing()
875
  threading.Thread(target=_watch, daemon=True).start()
876
  logger.info("Watcher started.")
@@ -879,4 +1188,4 @@ with app.app_context():
879
  start_app()
880
 
881
  if __name__ == '__main__':
882
- app.run(host='0.0.0.0', port=7860)
 
5
  import time
6
  import threading
7
  import base64
8
+ from typing import Dict, List, Tuple, Optional
9
+
10
  import numpy as np
11
  import fitz # PyMuPDF
12
  from flask import Flask, request, jsonify
 
23
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
24
  logger = logging.getLogger(__name__)
25
 
26
+ SYLLABI_DIR = os.environ.get("SYLLABI_DIR", "syllabi")
27
+ PAST_EXAMS_DIR = os.environ.get("PAST_EXAMS_DIR", "past_exams")
28
 
29
+ # Support both naming conventions used on HuggingFace / local envs.
30
  GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY") or os.environ.get("Gemini")
31
+ EMBEDDING_MODEL = os.environ.get("EMBEDDING_MODEL", "models/text-embedding-004")
32
+
33
+ # Requested model bump. Keep env override so you can hotfix without code edits.
34
+ VISION_MODEL = os.environ.get("GEMINI_MODEL", "gemini-3.1-flash-lite")
35
+
36
+ # Parser controls
37
+ MAX_VISION_PAGES = int(os.environ.get("MAX_VISION_PAGES", "80"))
38
+ PAGE_CLASSIFY_DPI = int(os.environ.get("PAGE_CLASSIFY_DPI", "72"))
39
+ MIN_VECTOR_TEXT_CHARS = int(os.environ.get("MIN_VECTOR_TEXT_CHARS", "20"))
40
 
41
  # ---------------------------------------------------------------------------
42
  # COMPLETE SUBJECT REGISTRY (all 24 PDFs on HuggingFace)
 
70
  "O_0625": "Physics",
71
  }
72
  ALL_SUBJECTS = {**A_LEVEL_SUBJECTS, **O_LEVEL_SUBJECTS}
73
+ CODE_TO_LEVEL_SUBJECT: Dict[str, List[Tuple[str, str, str]]] = {}
74
+ for sid, subject in ALL_SUBJECTS.items():
75
+ level, code = sid.split("_", 1)
76
+ CODE_TO_LEVEL_SUBJECT.setdefault(code, []).append((level, sid, subject))
77
 
78
  # ---------------------------------------------------------------------------
79
  # GLOBAL STATE
80
  # ---------------------------------------------------------------------------
81
+ SYLLABUS_MAP: Dict = {}
82
+ VECTOR_DB: List[Dict] = []
83
  VECTOR_MATRIX = None
84
+ EXAM_MAP: Dict = {}
85
 
86
  app = Flask(__name__)
87
  CORS(app)
 
96
  global firebase_db_ref, FIREBASE_AVAILABLE
97
  try:
98
  creds_str = os.environ.get("FIREBASE")
99
+ db_url = os.environ.get("Firebase_DB") or os.environ.get("FIREBASE_DB")
100
  if not creds_str or not db_url:
101
+ logger.warning("Firebase env vars missing. Running without Firebase persistence.")
102
  return False
103
  if not firebase_admin._apps:
104
  cred = credentials.Certificate(json.loads(creds_str))
 
114
  FIREBASE_AVAILABLE = init_firebase()
115
 
116
  def fb_set(path, data):
117
+ if not FIREBASE_AVAILABLE:
118
+ return
119
+ try:
120
+ firebase_db_ref.child(path).set(data)
121
+ except Exception as e:
122
+ logger.error(f"FB write [{path}]: {e}")
123
 
124
  def fb_get(path):
125
+ if not FIREBASE_AVAILABLE:
126
+ return None
127
+ try:
128
+ return firebase_db_ref.child(path).get()
129
  except Exception as e:
130
  logger.error(f"FB read [{path}]: {e}")
131
  return None
 
142
  return _gemini_client
143
 
144
  # ---------------------------------------------------------------------------
145
+ # PDF / SYLLABUS DETECTION HELPERS
 
 
146
  # ---------------------------------------------------------------------------
147
 
148
+ def clean_space(text: str) -> str:
149
+ return re.sub(r"\s+", " ", text or "").strip()
150
+
151
+ def normalise_line(line: str) -> str:
152
+ line = clean_space(line)
153
+ line = re.sub(r"^[\d\.\s]+", "", line).strip()
154
+ return line
155
+
156
+ def safe_firebase_key(value: str) -> str:
157
+ return re.sub(r'[.\[\]#$/]', '_', value)
158
+
159
+ def infer_level_code_subject(filepath: str, filename: str) -> Tuple[str, str, str, str, bool]:
160
+ """
161
+ Returns: level, code, unique_id, subject_name, registry_match.
162
+ Fixes the old weak behaviour where parent folder was trusted blindly.
163
+ """
164
+ parts = filepath.replace("\\", "/").split("/")
165
+ parent = parts[-2].upper() if len(parts) >= 2 else ""
166
+
167
+ code_m = re.search(r'(?<!\d)(\d{4})(?!\d)', filename)
168
+ code = code_m.group(1) if code_m else "0000"
169
+
170
+ candidates = CODE_TO_LEVEL_SUBJECT.get(code, [])
171
+
172
+ if parent in {"A", "O"}:
173
+ preferred = [c for c in candidates if c[0] == parent]
174
+ if preferred:
175
+ level, uid, subject = preferred[0]
176
+ return level, code, uid, subject, True
177
+ # Parent says A/O but code is not in registry. Still preserve parent.
178
+ level = parent
179
+ elif len(candidates) == 1:
180
+ level, uid, subject = candidates[0]
181
+ return level, code, uid, subject, True
182
+ elif len(candidates) > 1:
183
+ # There are no overlaps in the current registry, but this keeps future safety.
184
+ level, uid, subject = candidates[0]
185
+ return level, code, uid, subject, True
186
+ else:
187
+ level = parent if parent in {"A", "O"} else "General"
188
+
189
+ uid = f"{level}_{code}"
190
+ fallback_subject = re.sub(r'[_\-]?\d{4}.*', '', filename, flags=re.IGNORECASE)
191
+ fallback_subject = fallback_subject.replace('_', ' ').replace('-', ' ').strip() or "Unknown Subject"
192
+ return level, code, uid, fallback_subject, False
193
+
194
+ # Hard boilerplate headings. These are pages/blocks we do NOT want in the output tree.
195
+ BOILERPLATE_HEADING_RE = re.compile(
196
+ r"^(about\s+this\s+syllabus|foreword|introduction|acknowledgements?|"
197
+ r"why\s+choose\s+(cambridge|zimsec|this\s+syllabus)|cambridge\s+learner|"
198
+ r"key\s+benefits?|how\s+to\s+use\s+this\s+syllabus|"
199
+ r"support\s+for\s+(cambridge|teachers)|resource\s+list|"
200
+ r"further\s+information|copyright|legal\s+notice|"
201
+ r"changes\s+to\s+this\s+syllabus|university\s+of\s+cambridge|"
202
+ r"cambridge\s+assessment\s+international|published\s+by|"
203
+ r"contents?|table\s+of\s+contents|"
204
+ r"assessment\s+at\s+a\s+glance|syllabus\s+at\s+a\s+glance|"
205
+ r"assessment\s+overview|scheme\s+of\s+assessment|"
206
+ r"assessment\s+objectives?|grade\s+descriptions?|command\s+words|"
207
+ r"glossary(\s+of\s+command\s+words)?|mathematical\s+notation|"
208
+ r"other\s+cambridge\s+qualifications|how\s+to\s+offer|progression|"
209
+ r"post[-\s]?qualification|school\s+supported\s+candidate|"
210
+ r"cambridge\s+primary|cambridge\s+lower\s+secondary|"
211
+ r"what\s+else\s+you\s+need\s+to\s+know|before\s+you\s+start|"
212
+ r"making\s+entries|after\s+the\s+exam|appendix|appendices)\b",
213
+ re.IGNORECASE,
214
  )
215
 
216
+ # Strong signals for the actual learning/course content section.
217
  CONTENT_START_RE = re.compile(
218
+ r"(^|\n)\s*((\d+\.?\s*)?(subject\s+content|syllabus\s+content|curriculum\s+content|"
219
+ r"content\s+overview|learning\s+content|topics?\s+and\s+content|"
220
+ r"knowledge\s+and\s+understanding|set\s+texts|subject\s+specific\s+skills))\b",
221
+ re.IGNORECASE,
222
+ )
223
+
224
+ # Some syllabi have content pages made of topic numbers rather than a clean "Subject content" heading.
225
+ STRONG_TOPIC_RE = re.compile(
226
+ r"(^|\n)\s*((\d+(\.\d+)*\s+)[A-Z][A-Za-z0-9,()/:\- ]{3,}|"
227
+ r"(Unit|Topic|Section|Module)\s+\d+\b|"
228
+ r"Candidates\s+should\s+be\s+able\s+to|Learning\s+objectives?)",
229
+ re.IGNORECASE,
230
+ )
231
+
232
+ CONTENT_END_RE = re.compile(
233
+ r"(^|\n)\s*((\d+\.?\s*)?(details\s+of\s+the\s+assessment|assessment\s+details|"
234
+ r"assessment\s+objectives?|assessment\s+criteria|scheme\s+of\s+assessment|"
235
+ r"command\s+words|glossary|grade\s+descriptions?|mathematical\s+notation|"
236
+ r"appendix|appendices|what\s+else\s+you\s+need\s+to\s+know|"
237
+ r"administrative\s+information|additional\s+information))\b",
238
+ re.IGNORECASE,
239
+ )
240
+
241
+ RESIDUAL_SKIP_RE = re.compile(
242
+ r"\b(Cambridge International|UCLES|Version\s+\d|Back to contents page|"
243
+ r"www\.cambridgeinternational\.org|For examination in|Syllabus for examination|"
244
+ r"Copyright|©|ISBN|Assessment at a glance|Why choose|About this syllabus)\b",
245
+ re.IGNORECASE,
246
  )
247
 
248
+ PAGE_NUMBER_RE = re.compile(r"^(page\s*)?\d{1,3}\s*$", re.IGNORECASE)
249
+
250
+
251
+ def get_page_lines(page, max_lines: int = 80) -> List[str]:
252
+ text = page.get_text("text") or ""
253
+ lines = [clean_space(x) for x in text.splitlines()]
254
+ return [x for x in lines if x][:max_lines]
255
+
256
+
257
+ def extract_heading_candidates(doc, max_pages: int = 12) -> List[str]:
258
+ headings: List[str] = []
259
+ for i, page in enumerate(doc):
260
+ if i >= max_pages:
261
+ break
262
+ try:
263
+ page_dict = page.get_text("dict")
264
+ spans = []
265
+ for b in page_dict.get("blocks", []):
266
+ for l in b.get("lines", []):
267
+ for s in l.get("spans", []):
268
+ text = clean_space(s.get("text", ""))
269
+ if text:
270
+ spans.append((round(float(s.get("size", 0)), 1), text))
271
+ if not spans:
272
+ continue
273
+ sizes = sorted([s for s, _ in spans], reverse=True)
274
+ threshold = sizes[min(5, len(sizes) - 1)] if sizes else 0
275
+ for sz, text in spans:
276
+ if sz >= threshold and 3 <= len(text) <= 90 and not PAGE_NUMBER_RE.match(text):
277
+ headings.append(text)
278
+ except Exception:
279
+ continue
280
+ seen = set()
281
+ out = []
282
+ for h in headings:
283
+ key = h.lower()
284
+ if key not in seen:
285
+ seen.add(key)
286
+ out.append(h)
287
+ return out[:25]
288
+
289
+
290
+ def find_course_content_window(doc) -> Tuple[int, int, str]:
291
+ """
292
+ Finds the page range that most likely contains actual course content.
293
+ Old parser started too early and let Foreword/About/Contents leak into the tree.
294
+ """
295
+ n = len(doc)
296
+ start_idx: Optional[int] = None
297
+ start_reason = "fallback_topic_signal"
298
+
299
+ for i, page in enumerate(doc):
300
+ text = page.get_text("text") or ""
301
+ first_blob = "\n".join(get_page_lines(page, 40))
302
+ # Avoid TOC false positives by requiring more than just a contents list.
303
+ looks_like_toc = bool(re.search(r"\bcontents\b|table\s+of\s+contents", first_blob, re.I)) and len(first_blob.splitlines()) > 8
304
+ if CONTENT_START_RE.search(first_blob) and not looks_like_toc:
305
+ start_idx = i
306
+ start_reason = "explicit_subject_content_heading"
307
+ break
308
+ if i > 3 and STRONG_TOPIC_RE.search(text) and not BOILERPLATE_HEADING_RE.search(first_blob[:200]):
309
+ start_idx = i
310
+ start_reason = "strong_topic_signal"
311
+ break
312
+
313
+ if start_idx is None:
314
+ # Last resort: skip the typical front matter zone.
315
+ start_idx = min(8, max(0, n - 1))
316
+ start_reason = "last_resort_skip_front_matter"
317
+
318
+ end_idx = n
319
+ for j in range(start_idx + 1, n):
320
+ first_blob = "\n".join(get_page_lines(doc[j], 30))
321
+ if CONTENT_END_RE.search(first_blob):
322
+ end_idx = j
323
+ break
324
+
325
+ if end_idx <= start_idx:
326
+ end_idx = n
327
+
328
+ return start_idx, end_idx, start_reason
329
+
330
+
331
+ def is_block_boilerplate(block_text: str) -> bool:
332
+ text = clean_space(block_text)
333
+ if not text:
334
+ return True
335
+ if PAGE_NUMBER_RE.match(text):
336
+ return True
337
+ if len(text) < 3:
338
+ return True
339
+ first_words = " ".join(text.split()[:10])
340
+ if BOILERPLATE_HEADING_RE.match(normalise_line(first_words)):
341
+ return True
342
+ if RESIDUAL_SKIP_RE.search(text) and len(text) < 180:
343
+ return True
344
+ return False
345
+
346
+
347
+ def _page_to_base64_png(page, dpi=PAGE_CLASSIFY_DPI) -> str:
348
+ mat = fitz.Matrix(dpi / 72, dpi / 72)
349
+ pix = page.get_pixmap(matrix=mat, colorspace=fitz.csRGB)
350
  return base64.b64encode(pix.tobytes("png")).decode("utf-8")
351
 
352
+
353
+ def _vision_classify_page(page, page_num: int, subject_name: str, level: str) -> str:
354
  """Returns 'boilerplate', 'content', or 'uncertain'."""
355
  client = get_gemini()
356
  if client is None:
357
  return "uncertain"
358
  try:
359
+ b64 = _page_to_base64_png(page)
360
+ qualification = "Cambridge International AS & A Level" if level == "A" else "Cambridge IGCSE / O Level"
361
  prompt = (
362
+ f"This is page {page_num + 1} of a {qualification} syllabus for {subject_name}.\n\n"
 
363
  "Classify this page as ONE of:\n"
364
+ "BOILERPLATE - foreword, about this syllabus, why choose Cambridge, key benefits, contents, "
365
+ "assessment overview, assessment objectives, grade descriptions, command words, glossary, appendices, "
366
+ "administration, legal/copyright/support information.\n"
367
+ "CONTENT - actual course/subject matter students must learn: subject content, topic lists, learning "
368
+ "objectives, numbered content sections, skills, practical work content, set texts, knowledge points.\n\n"
 
 
 
369
  "Reply with exactly one word: BOILERPLATE or CONTENT"
370
  )
371
  resp = client.models.generate_content(
 
376
  ]}]
377
  )
378
  answer = (resp.text or "").strip().upper()
379
+ if "BOILERPLATE" in answer:
380
+ return "boilerplate"
381
+ if "CONTENT" in answer:
382
+ return "content"
383
  return "uncertain"
384
  except Exception as e:
385
+ logger.warning(f"Vision classify page {page_num + 1}: {e}")
386
  return "uncertain"
387
 
388
+
389
+ def classify_all_pages(doc, subject_name: str, level: str) -> Tuple[List[str], Dict]:
390
  """
391
+ Returns page labels and debug info.
392
+ The main fix: do not let the parser start at foreword/contents. First find the course-content window,
393
+ then classify only inside that window.
394
  """
 
395
  n = len(doc)
396
+ start_idx, end_idx, start_reason = find_course_content_window(doc)
397
+ classifications: List[str] = []
398
+ debug_pages = []
399
 
400
  for i, page in enumerate(doc):
401
+ text = page.get_text("text") or ""
402
+ first_lines = get_page_lines(page, 5)
403
+ first_heading = first_lines[0] if first_lines else ""
 
 
 
 
 
 
 
 
 
404
 
405
+ if i < start_idx or i >= end_idx:
406
+ verdict = "boilerplate"
407
+ reason = "outside_content_window"
408
+ else:
409
+ first_blob = "\n".join(get_page_lines(page, 35))
410
+ if len(clean_space(text)) < 30:
411
+ verdict = "boilerplate"
412
+ reason = "empty_or_tiny_page"
413
+ elif CONTENT_END_RE.search(first_blob):
414
+ verdict = "boilerplate"
415
+ reason = "assessment_or_appendix_heading"
416
+ elif BOILERPLATE_HEADING_RE.match(normalise_line(first_heading)):
417
+ verdict = "boilerplate"
418
+ reason = "boilerplate_heading"
419
+ elif CONTENT_START_RE.search(first_blob) or STRONG_TOPIC_RE.search(text):
420
+ verdict = "content"
421
+ reason = "text_content_signal"
422
+ elif i < MAX_VISION_PAGES:
423
+ v = _vision_classify_page(page, i, subject_name, level)
424
+ if v == "uncertain":
425
+ verdict = "content" if STRONG_TOPIC_RE.search(text) else "boilerplate"
426
+ reason = "heuristic_after_uncertain_vision"
427
+ else:
428
+ verdict = v
429
+ reason = f"vision_{VISION_MODEL}"
430
+ else:
431
+ verdict = "content"
432
+ reason = "inside_window_after_vision_limit"
433
 
 
 
 
 
434
  classifications.append(verdict)
435
+ debug_pages.append({"page": i + 1, "verdict": verdict, "reason": reason, "first": first_heading[:100]})
436
+ logger.info(f" Page {i + 1}/{n}: {verdict} ({reason}) | {first_heading[:90]}")
437
 
 
438
  if not any(c == "content" for c in classifications):
439
+ logger.warning(f" All pages BOILERPLATE for {subject_name}; applying fallback from page {start_idx + 1}.")
440
+ classifications = ["content" if i >= start_idx else "boilerplate" for i in range(n)]
441
+
442
+ debug = {
443
+ "content_start_page": start_idx + 1,
444
+ "content_end_page_exclusive": end_idx + 1 if end_idx < n else None,
445
+ "content_start_reason": start_reason,
446
+ "vision_model": VISION_MODEL,
447
+ "page_debug": debug_pages,
448
+ }
449
+ return classifications, debug
450
 
451
  # ---------------------------------------------------------------------------
452
+ # PDF PARSER
453
  # ---------------------------------------------------------------------------
454
 
455
  class PDFParser:
456
  def __init__(self, filepath):
457
+ self.filepath = filepath
458
+ self.filename = os.path.basename(filepath)
459
+ self.doc = fitz.open(filepath)
460
+ self.level, self.subject_code, self.unique_id, self.subject_name, self.registry_match = infer_level_code_subject(filepath, self.filename)
461
+ self.debug_info: Dict = {}
462
+
463
+ def log_pdf_detection_summary(self):
464
+ first_page_lines = get_page_lines(self.doc[0], 12) if len(self.doc) else []
465
+ heading_candidates = extract_heading_candidates(self.doc)
466
+ logger.info("=" * 78)
467
+ logger.info(f"PDF DETECTED: {self.filepath}")
468
+ logger.info(f" filename: {self.filename}")
469
+ logger.info(f" pages: {len(self.doc)}")
470
+ logger.info(f" folder level: {self.filepath.replace('\\\\', '/').split('/')[-2] if '/' in self.filepath.replace('\\\\', '/') else 'unknown'}")
471
+ logger.info(f" detected code: {self.subject_code}")
472
+ logger.info(f" resolved id: {self.unique_id}")
473
+ logger.info(f" subject: {self.subject_name}")
474
+ logger.info(f" registry match: {self.registry_match}")
475
+ logger.info(f" model: {VISION_MODEL}")
476
+ logger.info(f" first-page lines seen: {first_page_lines[:8]}")
477
+ logger.info(f" heading candidates seen: {heading_candidates[:15]}")
478
+ logger.info("=" * 78)
479
 
480
  def get_body_font_size(self):
481
+ sizes: Dict[float, int] = {}
482
  for page in self.doc:
483
+ try:
484
+ for b in page.get_text("dict").get("blocks", []):
485
+ for l in b.get("lines", []):
486
+ for s in l.get("spans", []):
487
+ text = s.get("text", "")
488
+ if not clean_space(text):
489
+ continue
490
+ sz = round(float(s.get("size", 10)), 1)
491
+ sizes[sz] = sizes.get(sz, 0) + len(text)
492
+ except Exception:
493
+ continue
494
  return max(sizes, key=sizes.get) if sizes else 10.0
495
 
496
  def parse(self):
497
+ self.log_pdf_detection_summary()
498
+ body_size = self.get_body_font_size()
499
+ page_classes, class_debug = classify_all_pages(self.doc, self.subject_name, self.level)
500
+ self.debug_info = class_debug
501
 
502
+ logger.info(f"Parsing COURSE CONTENT of {self.filename} (body ~{body_size}pt)")
503
  content_page_count = sum(1 for c in page_classes if c == "content")
504
+ logger.info(f" {content_page_count} course-content pages out of {len(self.doc)} total")
505
+ logger.info(f" content window starts page {class_debug.get('content_start_page')} via {class_debug.get('content_start_reason')}")
506
+
507
+ syllabus_tree: List[Dict] = []
508
+ current_topic: Optional[Dict] = None
509
+ current_subtopic: Optional[Dict] = None
510
+
511
+ def flush_subtopic():
512
+ nonlocal current_subtopic, current_topic
513
+ if current_subtopic and current_topic:
514
+ # Only keep subtopics with meaningful body content.
515
+ content = [c for c in current_subtopic.get("content", []) if not is_block_boilerplate(c)]
516
+ current_subtopic["content"] = content
517
+ if content or len(clean_space(current_subtopic.get("title", ""))) > 3:
518
+ current_topic["children"].append(current_subtopic)
519
+ current_subtopic = None
520
+
521
+ def flush_topic():
522
+ nonlocal current_topic
523
+ if current_topic:
524
+ # Drop empty topics or headings that are actually admin labels.
525
+ if current_topic.get("children") and not is_block_boilerplate(current_topic.get("title", "")):
526
+ syllabus_tree.append(current_topic)
527
+ current_topic = None
528
 
529
  for page_num, page in enumerate(self.doc):
530
  if page_classes[page_num] == "boilerplate":
531
  continue
532
 
533
+ try:
534
+ page_dict = page.get_text("dict")
535
+ except Exception as e:
536
+ logger.warning(f"Could not parse page {page_num + 1} of {self.filename}: {e}")
537
+ continue
538
+
539
+ for b in page_dict.get("blocks", []):
540
+ block_text_parts = []
541
+ max_size = 0.0
542
+ is_bold = False
543
 
544
  for l in b.get("lines", []):
545
+ line_parts = []
546
  for s in l.get("spans", []):
547
+ t = clean_space(s.get("text", ""))
548
+ if not t:
549
+ continue
550
+ line_parts.append(t)
551
+ sz = float(s.get("size", body_size))
552
+ if sz > max_size:
553
+ max_size = sz
554
+ if "bold" in str(s.get("font", "")).lower():
555
+ is_bold = True
556
+ if line_parts:
557
+ block_text_parts.append(" ".join(line_parts))
558
+
559
+ block_text = clean_space(" ".join(block_text_parts))
560
+ if is_block_boilerplate(block_text):
561
+ continue
562
+
563
+ # Prevent assessment/admin sections from leaking even if page got through.
564
+ if CONTENT_END_RE.match("\n" + block_text):
565
  continue
566
 
567
+ # Topic/subtopic detection tuned to syllabus PDFs.
568
+ looks_like_major_heading = (
569
+ max_size >= body_size + 2.0
570
+ and len(block_text) <= 140
571
+ and not block_text.endswith(".")
572
+ )
573
+ looks_like_numbered_topic = bool(re.match(r"^(\d+(\.\d+)*\s+)[A-Z]", block_text)) and len(block_text) <= 160
574
+ looks_like_subheading = (
575
+ (is_bold and max_size >= body_size and len(block_text) <= 180)
576
+ or looks_like_numbered_topic
577
+ or bool(re.match(r"^(Unit|Topic|Section|Module)\s+\d+\b", block_text, re.I))
578
+ )
579
+
580
+ if looks_like_major_heading:
581
+ flush_subtopic()
582
+ flush_topic()
583
  current_topic = {
584
+ "id": f"{self.unique_id}_{len(syllabus_tree)}",
585
+ "title": block_text,
586
+ "type": "topic",
587
+ "page": page_num + 1,
588
+ "children": [],
589
  }
590
  current_subtopic = None
591
+ elif looks_like_subheading:
 
 
 
 
 
592
  if not current_topic:
593
  current_topic = {
594
  "id": f"{self.unique_id}_root",
595
+ "title": "Course Content",
596
  "type": "topic",
597
+ "page": page_num + 1,
598
+ "children": [],
599
  }
600
+ flush_subtopic()
601
  current_subtopic = {
602
+ "id": f"{current_topic['id']}_{len(current_topic['children'])}",
603
+ "title": block_text,
604
+ "type": "subtopic",
605
+ "page": page_num + 1,
606
+ "content": [],
607
  }
608
+ else:
609
+ if not current_topic:
610
+ current_topic = {
611
+ "id": f"{self.unique_id}_root",
612
+ "title": "Course Content",
613
+ "type": "topic",
614
+ "page": page_num + 1,
615
+ "children": [],
616
+ }
617
+ if not current_subtopic:
618
  current_subtopic = {
619
+ "id": f"{current_topic['id']}_intro",
620
+ "title": "Overview",
621
+ "type": "subtopic",
622
+ "page": page_num + 1,
623
+ "content": [],
624
  }
625
+ current_subtopic["content"].append(block_text)
626
+
627
+ flush_subtopic()
628
+ flush_topic()
629
 
630
+ logger.info(f" Parsed topics for {self.unique_id}: {len(syllabus_tree)}")
631
+ preview = [t.get("title") for t in syllabus_tree[:8]]
632
+ logger.info(f" Topic preview: {preview}")
 
633
 
634
  return {
635
  "meta": {
636
+ "id": self.unique_id,
637
+ "subject": self.subject_name,
638
+ "code": self.subject_code,
639
+ "level": self.level,
640
+ "filename": self.filename,
641
+ "registryMatch": self.registry_match,
642
+ "indexed_at": int(time.time()),
643
+ "parserVersion": "3.1-course-content-window",
644
+ "model": VISION_MODEL,
645
+ "contentStartPage": class_debug.get("content_start_page"),
646
+ "contentStartReason": class_debug.get("content_start_reason"),
647
  },
648
+ "debug": {
649
+ "pageClassification": class_debug,
650
+ },
651
+ "tree": syllabus_tree,
652
  }
653
 
 
654
  # ---------------------------------------------------------------------------
655
  # PAST EXAM PARSER
656
  # ---------------------------------------------------------------------------
657
 
658
  class ExamPaperParser:
659
  def __init__(self, filepath):
660
+ self.filepath = filepath
661
+ self.filename = os.path.basename(filepath)
662
+ self.doc = fitz.open(filepath)
663
+ self.level, self.subject_code, self.unique_id, self.subject_name, self.registry_match = infer_level_code_subject(filepath, self.filename)
664
+ year_m = re.search(r'\b(20\d{2}|19\d{2})\b', self.filename)
665
+ self.year = year_m.group(1) if year_m else "Unknown"
666
+ sess_m = re.search(r'(may[_\-]?june|oct[_\-]?nov|feb[_\-]?mar|summer|winter|s\d|w\d|m\d)', self.filename, re.IGNORECASE)
667
+ self.session = sess_m.group(1).upper() if sess_m else "Unknown"
668
+ paper_m = re.search(r'[_\-]p(\d)|paper[\s_\-]?(\d)', self.filename, re.IGNORECASE)
669
+ self.paper_num = (paper_m.group(1) or paper_m.group(2)) if paper_m else "1"
670
+ self.paper_id = f"{self.unique_id}_{self.year}_{self.session}_P{self.paper_num}"
 
 
 
 
671
 
672
  def extract_pages(self):
673
+ return [
674
+ {"page": i + 1, "text": p.get_text("text").strip()[:3000]}
675
+ for i, p in enumerate(self.doc)
676
+ if p.get_text("text").strip()
677
+ ]
678
 
679
  def extract_questions(self):
680
  full = "\n".join(p["text"] for p in self.extract_pages())
681
+ pat = re.compile(r'(?:^|\n)\s*(\d{1,2})\s*[\.\)]\s+(.+?)(?=\n\s*\d{1,2}\s*[\.\)]|\Z)', re.DOTALL | re.MULTILINE)
682
+ return [
683
+ {"number": int(m.group(1)), "text": m.group(2).strip()[:2000]}
684
+ for m in pat.finditer(full)
685
+ if len(m.group(2).strip()) > 20
686
+ ]
687
 
688
  def parse(self):
689
+ logger.info(f"Exam PDF detected: {self.filepath} -> {self.unique_id} {self.subject_name}, year={self.year}, paper={self.paper_num}")
690
  return {
691
  "meta": {
692
+ "paperId": self.paper_id,
693
+ "subjectId": self.unique_id,
694
+ "subject": self.subject_name,
695
  "subjectCode": self.subject_code,
696
+ "level": self.level,
697
+ "year": self.year,
698
+ "session": self.session,
699
  "paperNumber": self.paper_num,
700
+ "filename": self.filename,
701
+ "totalPages": len(self.doc),
702
+ "indexed_at": int(time.time()),
703
  },
704
+ "pages": self.extract_pages(),
705
+ "questions": self.extract_questions(),
706
  }
707
 
 
708
  # ---------------------------------------------------------------------------
709
  # EMBEDDINGS
710
  # ---------------------------------------------------------------------------
 
712
  def generate_embeddings(texts):
713
  client = get_gemini()
714
  if client is None:
715
+ logger.warning("Gemini API key not configured. Returning zero embeddings.")
716
  return [np.zeros(768).tolist() for _ in texts]
717
+
718
  results = []
719
  for i in range(0, len(texts), 10):
720
  batch = texts[i:i + 10]
 
728
  results.append(np.zeros(768).tolist())
729
  return results
730
 
 
731
  # ---------------------------------------------------------------------------
732
  # FIREBASE PERSISTENCE
733
  # ---------------------------------------------------------------------------
734
 
735
  def load_index_from_firebase():
736
  global SYLLABUS_MAP, VECTOR_DB, VECTOR_MATRIX, EXAM_MAP
737
+ if not FIREBASE_AVAILABLE:
738
+ return False
739
  logger.info("Loading index from Firebase ...")
740
  try:
741
  fb_syllabi = fb_get("data_api/syllabi")
742
+ if not fb_syllabi:
743
+ return False
744
  SYLLABUS_MAP = fb_syllabi
745
 
746
  fb_vectors = fb_get("data_api/vectors")
747
+ if not fb_vectors:
748
+ return False
749
+
750
+ VECTOR_DB = []
751
+ valid = []
752
+ expected_dim = 768
753
+ iterable = sorted(fb_vectors.keys()) if isinstance(fb_vectors, dict) else range(len(fb_vectors))
754
+ for entry in iterable:
755
  item = fb_vectors[entry] if isinstance(fb_vectors, dict) else fb_vectors[entry]
756
+ if not item:
757
+ continue
758
  raw_vec = item.get("vector")
759
+ if not raw_vec:
760
+ continue
761
  try:
762
  vec = np.array(raw_vec, dtype=np.float32)
763
  if vec.ndim != 1 or len(vec) != expected_dim:
 
768
  except Exception as ve:
769
  logger.warning(f"Skipping malformed vector entry: {ve}")
770
  continue
771
+
772
+ VECTOR_MATRIX = np.vstack(valid).astype(np.float32) if valid else None
773
  logger.info(f"Vector matrix shape: {VECTOR_MATRIX.shape if VECTOR_MATRIX is not None else None}")
774
 
775
  fb_exams = fb_get("data_api/exams")
 
782
  logger.error(f"Firebase load: {e}")
783
  return False
784
 
785
+
786
  def save_syllabus(sid, data):
787
  fb_set(f"data_api/syllabi/{sid}", data)
788
 
789
+
790
  def save_all_vectors():
791
  fb_data = {}
792
  for i, entry in enumerate(VECTOR_DB):
793
  fb_data[f"v_{i:06d}"] = {
794
  "vector": entry["vector"].tolist() if isinstance(entry["vector"], np.ndarray) else entry["vector"],
795
+ "meta": entry["meta"],
796
  }
797
  fb_set("data_api/vectors", fb_data)
798
 
799
+
800
  def save_exam(sid, exam_data):
801
+ safe = safe_firebase_key(exam_data["meta"]["paperId"])
802
  fb_set(f"data_api/exams/{sid}/{safe}", exam_data)
803
 
 
804
  # ---------------------------------------------------------------------------
805
  # INDEX BUILDER
806
  # ---------------------------------------------------------------------------
807
 
808
+ def syllabus_sort_key(path: str):
809
+ level, code, uid, subject, registry_match = infer_level_code_subject(path, os.path.basename(path))
810
+ return (level, subject, code, path)
811
+
812
+
813
  def build_index():
814
  global SYLLABUS_MAP, VECTOR_DB, VECTOR_MATRIX, EXAM_MAP
815
  logger.info("Full index build starting ...")
816
+ logger.info(f"Using Gemini model for page classification: {VISION_MODEL}")
817
  parsed_data = []
818
 
819
  if os.path.exists(SYLLABI_DIR):
820
+ syllabus_paths = []
821
  for root, _, files in os.walk(SYLLABI_DIR):
822
+ for f in files:
823
+ if f.lower().endswith(".pdf"):
824
+ syllabus_paths.append(os.path.join(root, f))
825
+ logger.info(f"Found {len(syllabus_paths)} syllabus PDF(s).")
826
+ for path in sorted(syllabus_paths, key=syllabus_sort_key):
827
+ logger.info(f"Syllabus queued: {path}")
828
+ try:
829
+ parser = PDFParser(path)
830
+ data = parser.parse()
831
+ parsed_data.append(data)
832
+ SYLLABUS_MAP[data["meta"]["id"]] = data
833
+ save_syllabus(data["meta"]["id"], data)
834
+ except Exception as e:
835
+ logger.exception(f"Failed syllabus parse {path}: {e}")
836
 
837
  if os.path.exists(PAST_EXAMS_DIR):
838
+ exam_paths = []
839
  for root, _, files in os.walk(PAST_EXAMS_DIR):
840
+ for f in files:
841
+ if f.lower().endswith(".pdf"):
842
+ exam_paths.append(os.path.join(root, f))
843
+ logger.info(f"Found {len(exam_paths)} past exam PDF(s).")
844
+ for path in sorted(exam_paths):
845
+ logger.info(f"Exam queued: {path}")
846
+ try:
847
+ parser = ExamPaperParser(path)
848
+ exam_data = parser.parse()
849
+ sid = exam_data["meta"]["subjectId"]
850
+ EXAM_MAP.setdefault(sid, {})
851
+ safe = safe_firebase_key(exam_data["meta"]["paperId"])
852
+ EXAM_MAP[sid][safe] = exam_data
853
+ save_exam(sid, exam_data)
854
+ except Exception as e:
855
+ logger.exception(f"Failed exam parse {path}: {e}")
856
 
857
  if not parsed_data:
858
  logger.info("Nothing to vectorize.")
 
861
  chunks, metas = [], []
862
  for item in parsed_data:
863
  mb = item["meta"]
864
+ for topic in item.get("tree", []):
865
  for sub in topic.get("children", []):
866
  blob = "\n".join(sub.get("content", []))
867
+ if len(clean_space(blob)) < MIN_VECTOR_TEXT_CHARS:
868
+ continue
869
+ chunks.append(f"{mb['subject']} {mb['level']} {mb['code']} - {topic['title']} - {sub['title']}:\n{blob}")
870
  metas.append({
871
+ "subject_id": mb["id"],
872
+ "subject": mb["subject"],
873
+ "level": mb["level"],
874
+ "code": mb["code"],
875
+ "topic_id": topic["id"],
876
+ "topic_title": topic["title"],
877
  "subtopic_id": sub["id"],
878
+ "title": sub["title"],
879
+ "content": blob,
880
  })
881
 
882
+ logger.info(f"Embedding {len(chunks)} course-content chunks ...")
883
  vecs = generate_embeddings(chunks)
884
  VECTOR_DB = []
885
+ valid = []
886
  for i, v in enumerate(vecs):
887
+ nv = np.array(v, dtype=np.float32)
888
  VECTOR_DB.append({"vector": nv, "meta": metas[i]})
889
  valid.append(nv)
890
+ VECTOR_MATRIX = np.vstack(valid).astype(np.float32) if valid else None
 
891
  save_all_vectors()
892
  logger.info(f"Index done: {len(SYLLABUS_MAP)} syllabi, {len(VECTOR_DB)} vectors.")
893
 
894
 
895
  def _incremental_vectorize(syllabus_data):
896
  global VECTOR_DB, VECTOR_MATRIX
897
+ mb = syllabus_data["meta"]
898
  chunks, metas = [], []
899
+ for topic in syllabus_data.get("tree", []):
900
  for sub in topic.get("children", []):
901
  blob = "\n".join(sub.get("content", []))
902
+ if len(clean_space(blob)) < MIN_VECTOR_TEXT_CHARS:
903
+ continue
904
+ chunks.append(f"{mb['subject']} {mb['level']} {mb['code']} - {topic['title']} - {sub['title']}:\n{blob}")
905
  metas.append({
906
+ "subject_id": mb["id"],
907
+ "subject": mb["subject"],
908
+ "level": mb["level"],
909
+ "code": mb["code"],
910
+ "topic_id": topic["id"],
911
+ "topic_title": topic["title"],
912
  "subtopic_id": sub["id"],
913
+ "title": sub["title"],
914
+ "content": blob,
915
  })
916
+ if not chunks:
917
+ return
918
  for i, v in enumerate(generate_embeddings(chunks)):
919
+ VECTOR_DB.append({"vector": np.array(v, dtype=np.float32), "meta": metas[i]})
920
  if VECTOR_DB:
921
+ VECTOR_MATRIX = np.vstack([e["vector"] for e in VECTOR_DB]).astype(np.float32)
922
  save_all_vectors()
923
 
 
924
  # ---------------------------------------------------------------------------
925
  # WATCHER
926
  # ---------------------------------------------------------------------------
 
928
 
929
  def _collect_existing():
930
  for d in [SYLLABI_DIR, PAST_EXAMS_DIR]:
931
+ if not os.path.exists(d):
932
+ continue
933
  for root, _, files in os.walk(d):
934
  for f in files:
935
+ if f.lower().endswith(".pdf"):
936
  _indexed_files.add(os.path.join(root, f))
937
 
938
+
939
  def _watch(interval=30):
940
  while True:
941
  time.sleep(interval)
942
  for directory, is_exam in [(SYLLABI_DIR, False), (PAST_EXAMS_DIR, True)]:
943
+ if not os.path.exists(directory):
944
+ continue
945
  for root, _, files in os.walk(directory):
946
  for f in files:
947
+ if not f.lower().endswith(".pdf"):
948
+ continue
949
  path = os.path.join(root, f)
950
+ if path in _indexed_files:
951
+ continue
952
  _indexed_files.add(path)
953
+ logger.info(f"New PDF detected by watcher: {path}")
954
  try:
955
  if is_exam:
956
+ parser = ExamPaperParser(path)
957
  exam_data = parser.parse()
958
+ sid = exam_data["meta"]["subjectId"]
959
+ EXAM_MAP.setdefault(sid, {})
960
+ safe = safe_firebase_key(exam_data["meta"]["paperId"])
961
  EXAM_MAP[sid][safe] = exam_data
962
  save_exam(sid, exam_data)
963
  else:
964
  parser = PDFParser(path)
965
+ data = parser.parse()
966
  SYLLABUS_MAP[data["meta"]["id"]] = data
967
  save_syllabus(data["meta"]["id"], data)
968
  _incremental_vectorize(data)
969
  except Exception as e:
970
+ logger.exception(f"Watch parse failed {path}: {e}")
 
971
 
972
  # ---------------------------------------------------------------------------
973
  # API
 
975
 
976
  @app.route('/', methods=['GET'])
977
  def index():
 
978
  return jsonify({
979
+ "name": "Marka Data API",
980
+ "version": "3.1",
981
+ "status": "online",
982
  "subjects_loaded": len(SYLLABUS_MAP),
983
+ "vector_chunks": len(VECTOR_DB),
984
+ "firebase": FIREBASE_AVAILABLE,
985
+ "model": VISION_MODEL,
986
+ "parser": "course-content-window-with-pdf-logging",
987
  "endpoints": [
988
  "GET /health",
989
  "GET /v1/subjects",
 
992
  "GET /v1/exams",
993
  "GET /v1/exams/<paper_id>",
994
  "GET /v1/exams/<paper_id>/questions",
995
+ "POST /v1/rebuild",
996
+ "GET|POST /v1/reset",
997
+ ],
998
  })
999
 
 
1000
  @app.route('/health', methods=['GET'])
1001
  def health():
1002
  return jsonify({
1003
+ "status": "online",
1004
  "subjects_loaded": list(SYLLABUS_MAP.keys()),
1005
+ "subject_count": len(SYLLABUS_MAP),
1006
+ "vector_chunks": len(VECTOR_DB),
1007
+ "exam_subjects": list(EXAM_MAP.keys()),
1008
+ "firebase": FIREBASE_AVAILABLE,
1009
+ "registered_subjects": ALL_SUBJECTS,
1010
+ "model": VISION_MODEL,
1011
+ "embedding_model": EMBEDDING_MODEL,
1012
  })
1013
 
1014
  @app.route('/v1/subjects', methods=['GET'])
1015
  def list_subjects():
1016
  result = []
1017
  for sid, data in SYLLABUS_MAP.items():
1018
+ meta = data.get("meta", {"id": sid})
1019
+ result.append({**meta, "indexed": True})
1020
  for uid, name in ALL_SUBJECTS.items():
1021
  if uid not in SYLLABUS_MAP:
1022
  level = "A" if uid.startswith("A_") else "O"
1023
+ result.append({"id": uid, "subject": name, "code": uid.split("_")[1], "level": level, "indexed": False})
1024
+ return jsonify(sorted(result, key=lambda x: (x.get("level", ""), x.get("subject", ""), x.get("code", ""))))
 
1025
 
1026
  @app.route('/v1/structure/<subject_id>', methods=['GET'])
1027
  def get_structure(subject_id):
 
1034
  def search():
1035
  if VECTOR_MATRIX is None or not VECTOR_DB:
1036
  return jsonify({"error": "Index not ready"}), 503
1037
+ req = request.json or {}
1038
+ q = req.get("query")
1039
+ sf = req.get("filter_subject_id")
1040
  if not q:
1041
  return jsonify({"error": "Query required"}), 400
1042
  c = get_gemini()
 
1044
  return jsonify({"error": "Embedding API not configured"}), 503
1045
  try:
1046
  resp = c.models.embed_content(model=EMBEDDING_MODEL, contents=q)
1047
+ qv = np.array(resp.embeddings[0].values, dtype=np.float32).reshape(1, -1)
1048
  except Exception as e:
1049
  logger.error(f"Embed query failed: {e}")
1050
  return jsonify({"error": f"Embedding failed: {str(e)}"}), 500
1051
  try:
1052
+ scores = cosine_similarity(qv, VECTOR_MATRIX)[0]
1053
  except Exception as e:
1054
  logger.error(f"Cosine similarity failed: {e}, matrix shape: {VECTOR_MATRIX.shape if VECTOR_MATRIX is not None else None}, query shape: {qv.shape}")
1055
  return jsonify({"error": f"Search index error: {str(e)}"}), 500
1056
  results = []
1057
  for idx in np.argsort(scores)[::-1]:
1058
+ if scores[idx] < 0.3:
1059
+ break
1060
  meta = VECTOR_DB[idx]["meta"]
1061
+ if sf and meta["subject_id"] != sf:
1062
+ continue
1063
+ results.append({
1064
+ "score": float(scores[idx]),
1065
+ "subject_id": meta["subject_id"],
1066
+ "subject": meta.get("subject"),
1067
+ "level": meta.get("level"),
1068
+ "code": meta.get("code"),
1069
+ "topic": meta.get("topic_title"),
1070
+ "title": meta["title"],
1071
+ "content": meta["content"],
1072
+ "node_id": meta["subtopic_id"],
1073
+ })
1074
+ if len(results) >= int(req.get("limit", 5)):
1075
+ break
1076
  return jsonify({"results": results})
1077
 
1078
  @app.route('/v1/exams', methods=['GET'])
 
1080
  sid = request.args.get("subject_id")
1081
  out = []
1082
  for s, papers in EXAM_MAP.items():
1083
+ if sid and s != sid:
1084
+ continue
1085
  for p in papers.values():
1086
  if isinstance(p, dict) and "meta" in p:
1087
  out.append(p["meta"])
 
1089
 
1090
  @app.route('/v1/exams/<paper_id>', methods=['GET'])
1091
  def get_exam(paper_id):
1092
+ safe = safe_firebase_key(paper_id)
1093
  for _, papers in EXAM_MAP.items():
1094
  for key, paper in papers.items():
1095
  if key == safe or (isinstance(paper, dict) and paper.get("meta", {}).get("paperId") == paper_id):
 
1098
 
1099
  @app.route('/v1/exams/<paper_id>/questions', methods=['GET'])
1100
  def get_exam_questions(paper_id):
1101
+ safe = safe_firebase_key(paper_id)
1102
  for _, papers in EXAM_MAP.items():
1103
  for key, paper in papers.items():
1104
  if key == safe or (isinstance(paper, dict) and paper.get("meta", {}).get("paperId") == paper_id):
1105
  return jsonify({"paperId": paper_id, "meta": paper.get("meta"), "questions": paper.get("questions", [])})
1106
  return jsonify({"error": "Not found"}), 404
1107
 
 
1108
  @app.route('/v1/reset', methods=['GET', 'POST'])
1109
  def reset_and_rebuild():
1110
  """
1111
  One-time cache reset endpoint.
1112
+ Wipes Firebase syllabus/vector/exam cache, then triggers a full fresh rebuild from PDF files.
 
1113
 
1114
+ Browser: GET /v1/reset?token=marka-reset-2026
1115
+ Better: set REBUILD_SECRET and call GET /v1/reset?token=<your-secret>
 
 
 
1116
  """
 
1117
  expected = os.environ.get("REBUILD_SECRET", "marka-reset-2026")
1118
+ token = request.args.get("token") or request.headers.get("Authorization", "").replace("Bearer ", "").strip()
 
 
 
1119
  if token != expected:
1120
+ return jsonify({"error": "Unauthorized", "hint": "Pass ?token=<REBUILD_SECRET> in the URL"}), 401
 
 
 
1121
 
1122
  def _reset_bg():
1123
  global SYLLABUS_MAP, VECTOR_DB, VECTOR_MATRIX, EXAM_MAP
1124
  logger.info("=== CACHE RESET STARTED ===")
 
 
1125
  if FIREBASE_AVAILABLE:
1126
+ for node in ["data_api/syllabi", "data_api/vectors", "data_api/exams"]:
1127
+ try:
1128
+ firebase_db_ref.child(node).delete()
1129
+ logger.info(f" ✓ Deleted {node} from Firebase")
1130
+ except Exception as e:
1131
+ logger.error(f" ✗ Failed to delete {node}: {e}")
 
 
 
 
1132
  else:
1133
  logger.warning(" Firebase not available — skipping Firebase wipe")
1134
 
1135
+ SYLLABUS_MAP = {}
1136
+ VECTOR_DB = []
 
1137
  VECTOR_MATRIX = None
1138
+ EXAM_MAP = {}
1139
  logger.info(" ✓ In-memory state cleared")
 
 
 
1140
  build_index()
1141
  logger.info("=== CACHE RESET COMPLETE ===")
1142
 
1143
  threading.Thread(target=_reset_bg, daemon=True).start()
 
1144
  return jsonify({
1145
+ "status": "reset started",
1146
+ "message": "Firebase cache wiped. Full rebuild running in background. Check /health and HuggingFace logs.",
1147
+ "model": VISION_MODEL,
1148
+ "watch": "/health",
 
1149
  }), 202
1150
 
1151
  @app.route('/v1/rebuild', methods=['POST'])
 
1153
  secret = os.environ.get("REBUILD_SECRET", "")
1154
  if secret and request.headers.get("Authorization", "") != f"Bearer {secret}":
1155
  return jsonify({"error": "Unauthorized"}), 401
1156
+
1157
  def _bg():
1158
  global SYLLABUS_MAP, VECTOR_DB, VECTOR_MATRIX, EXAM_MAP
1159
+ SYLLABUS_MAP = {}
1160
+ VECTOR_DB = []
1161
+ VECTOR_MATRIX = None
1162
+ EXAM_MAP = {}
1163
  build_index()
 
 
1164
 
1165
+ threading.Thread(target=_bg, daemon=True).start()
1166
+ return jsonify({"status": "rebuild started", "model": VISION_MODEL}), 202
1167
 
1168
  # ---------------------------------------------------------------------------
1169
  # STARTUP
 
1174
  if not os.path.exists(d):
1175
  os.makedirs(os.path.join(d, "A"), exist_ok=True)
1176
  os.makedirs(os.path.join(d, "O"), exist_ok=True)
1177
+
1178
  if not load_index_from_firebase():
1179
  build_index()
1180
  else:
1181
+ logger.info("Served from Firebase cache. Use /v1/reset to force re-parse with the new parser.")
1182
+
1183
  _collect_existing()
1184
  threading.Thread(target=_watch, daemon=True).start()
1185
  logger.info("Watcher started.")
 
1188
  start_app()
1189
 
1190
  if __name__ == '__main__':
1191
+ app.run(host='0.0.0.0', port=int(os.environ.get("PORT", "7860")))