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

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +482 -1123
main.py CHANGED
@@ -1,1191 +1,550 @@
 
 
 
 
 
 
1
  import os
2
  import json
3
  import logging
4
- import re
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
13
  from flask_cors import CORS
14
- from google import genai
15
- from sklearn.metrics.pairwise import cosine_similarity
16
 
17
  import firebase_admin
18
- from firebase_admin import credentials, db as firebase_db
19
 
20
- # ---------------------------------------------------------------------------
21
- # CONFIGURATION
22
- # ---------------------------------------------------------------------------
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)
43
- # ---------------------------------------------------------------------------
44
- A_LEVEL_SUBJECTS = {
45
- "A_9706": "Accounting",
46
- "A_9700": "Biology",
47
- "A_9609": "Business",
48
- "A_9701": "Chemistry",
49
- "A_9618": "Computer Science",
50
- "A_9708": "Economics",
51
- "A_9231": "Further Mathematics",
52
- "A_9489": "History",
53
- "A_9695": "Literature in English",
54
- "A_9709": "Mathematics",
55
- "A_9702": "Physics",
56
- "A_9699": "Sociology",
57
- "A_9395": "Travel and Tourism",
58
- }
59
- O_LEVEL_SUBJECTS = {
60
- "O_0452": "Accounting",
61
- "O_0610": "Biology",
62
- "O_0450": "Business Studies",
63
- "O_0620": "Chemistry",
64
- "O_0478": "Computer Science",
65
- "O_0500": "English Language",
66
- "O_0475": "English Literature",
67
- "O_0680": "Environmental Management",
68
- "O_0460": "Geography",
69
- "O_0470": "History",
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)
88
 
89
- # ---------------------------------------------------------------------------
90
- # FIREBASE
91
- # ---------------------------------------------------------------------------
92
- firebase_db_ref = None
93
- FIREBASE_AVAILABLE = False
94
 
95
- def init_firebase():
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))
105
- firebase_admin.initialize_app(cred, {"databaseURL": db_url})
106
- firebase_db_ref = firebase_db.reference()
107
- FIREBASE_AVAILABLE = True
108
- logger.info("Firebase initialised (Data API).")
109
- return True
110
- except Exception as e:
111
- logger.error(f"Firebase init failed: {e}")
112
- return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
 
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
132
 
133
- # ---------------------------------------------------------------------------
134
- # GEMINI CLIENT
135
- # ---------------------------------------------------------------------------
136
- _gemini_client = None
137
 
138
- def get_gemini():
139
- global _gemini_client
140
- if _gemini_client is None and GEMINI_API_KEY:
141
- _gemini_client = genai.Client(api_key=GEMINI_API_KEY)
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(
372
- model=VISION_MODEL,
373
- contents=[{"role": "user", "parts": [
374
- {"inline_data": {"mime_type": "image/png", "data": b64}},
375
- {"text": prompt}
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
- # ---------------------------------------------------------------------------
711
-
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]
721
  try:
722
- resp = client.models.embed_content(model=EMBEDDING_MODEL, contents=batch)
723
- for emb in resp.embeddings:
724
- results.append(emb.values)
 
 
 
 
 
 
 
 
 
 
725
  except Exception as e:
726
- logger.error(f"Embed batch {i}: {e}")
727
- for _ in batch:
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:
764
- logger.warning(f"Skipping vector with wrong shape: {vec.shape}")
765
- continue
766
- VECTOR_DB.append({"vector": vec, "meta": item["meta"]})
767
- valid.append(vec)
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")
776
- if fb_exams:
777
- EXAM_MAP = fb_exams
778
 
779
- logger.info(f"Loaded: {len(SYLLABUS_MAP)} syllabi, {len(VECTOR_DB)} vectors, {len(EXAM_MAP)} exam subjects.")
780
- return True
 
 
 
 
 
 
 
 
 
 
 
 
 
781
  except Exception as e:
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.")
859
- return
860
-
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
- # ---------------------------------------------------------------------------
927
- _indexed_files = set()
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
974
- # ---------------------------------------------------------------------------
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",
990
- "GET /v1/structure/<subject_id>",
991
- "POST /v1/search",
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):
1028
- data = SYLLABUS_MAP.get(subject_id)
1029
- if not data:
1030
- return jsonify({"error": "Subject not found"}), 404
1031
- return jsonify(data)
1032
-
1033
- @app.route('/v1/search', methods=['POST'])
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()
1043
- if c is None:
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'])
1079
- def list_exams():
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"])
1088
- return jsonify(out)
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):
1096
- return jsonify(paper)
1097
- return jsonify({"error": "Not found"}), 404
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'])
1152
- def trigger_rebuild():
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
1170
- # ---------------------------------------------------------------------------
1171
-
1172
- def start_app():
1173
- for d in [SYLLABI_DIR, PAST_EXAMS_DIR]:
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.")
1186
 
1187
- with app.app_context():
1188
- start_app()
 
1189
 
1190
- if __name__ == '__main__':
1191
- app.run(host='0.0.0.0', port=int(os.environ.get("PORT", "7860")))
 
 
1
+ """
2
+ Marka Data / Infra API Server
3
+ Serves syllabus structure trees and semantic search over syllabus content.
4
+ This is NOT the app server. Do not add auth, quiz, or user endpoints here.
5
+ """
6
+
7
  import os
8
  import json
9
  import logging
10
+ import uuid
11
+ from datetime import datetime
12
+
 
 
 
 
 
13
  from flask import Flask, request, jsonify
14
  from flask_cors import CORS
 
 
15
 
16
  import firebase_admin
17
+ from firebase_admin import credentials, db
18
 
19
+ import requests
 
 
 
 
20
 
21
+ from google import genai
22
+
23
+ # -----------------------------------------------------------------------------
24
+ # 1. CONFIGURATION & INITIALIZATION
25
+ # -----------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
  app = Flask(__name__)
28
  CORS(app)
29
 
30
+ logging.basicConfig(level=logging.INFO)
31
+ logger = logging.getLogger(__name__)
 
 
 
32
 
33
+ # --- Firebase ---
34
+ try:
35
+ credentials_json_string = os.environ.get("FIREBASE")
36
+ if not credentials_json_string:
37
+ raise ValueError("FIREBASE env var not set.")
38
+ credentials_json = json.loads(credentials_json_string)
39
+ firebase_db_url = os.environ.get("Firebase_DB")
40
+ if not firebase_db_url:
41
+ raise ValueError("Firebase_DB must be set.")
42
+ cred = credentials.Certificate(credentials_json)
43
+ firebase_admin.initialize_app(cred, {"databaseURL": firebase_db_url})
44
+ logger.info("Firebase Admin SDK initialised.")
45
+ except Exception as e:
46
+ logger.error(f"FATAL Firebase init: {e}")
47
+ raise SystemExit(1)
48
+
49
+ db_ref = db.reference()
50
+
51
+ # --- Gemini (for embedding-based search) ---
52
+ try:
53
+ api_key = os.environ.get("Gemini")
54
+ if not api_key:
55
+ raise ValueError("Gemini env var not set.")
56
+ client = genai.Client(api_key=api_key)
57
+ logger.info("GenAI client initialised.")
58
+ except Exception as e:
59
+ logger.error(f"FATAL GenAI init: {e}")
60
+ raise SystemExit(1)
61
+
62
+ EMBED_MODEL = os.environ.get("GEMINI_EMBED_MODEL", "text-embedding-004")
63
+ TEXT_MODEL = os.environ.get("GEMINI_TEXT_MODEL", "gemini-3.1-flash-lite")
64
+
65
+ # -----------------------------------------------------------------------------
66
+ # 2. SYLLABUS SUBJECT REGISTRY
67
+ # Maps remote_id (used in RTDB paths) to human-readable metadata
68
+ # -----------------------------------------------------------------------------
69
+
70
+ SUBJECT_REGISTRY = {
71
+ # A-Level
72
+ "A_9706": {"name": "Accounting", "level": "A", "code": "9706"},
73
+ "A_9700": {"name": "Biology", "level": "A", "code": "9700"},
74
+ "A_9609": {"name": "Business", "level": "A", "code": "9609"},
75
+ "A_9701": {"name": "Chemistry", "level": "A", "code": "9701"},
76
+ "A_9618": {"name": "Computer Science", "level": "A", "code": "9618"},
77
+ "A_9708": {"name": "Economics", "level": "A", "code": "9708"},
78
+ "A_9231": {"name": "Further Mathematics", "level": "A", "code": "9231"},
79
+ "A_9489": {"name": "History", "level": "A", "code": "9489"},
80
+ "A_9695": {"name": "Literature in English", "level": "A", "code": "9695"},
81
+ "A_9709": {"name": "Mathematics", "level": "A", "code": "9709"},
82
+ "A_9702": {"name": "Physics", "level": "A", "code": "9702"},
83
+ "A_9699": {"name": "Sociology", "level": "A", "code": "9699"},
84
+ "A_9395": {"name": "Travel & Tourism", "level": "A", "code": "9395"},
85
+ # O-Level / IGCSE
86
+ "O_0452": {"name": "Accounting", "level": "O", "code": "0452"},
87
+ "O_0610": {"name": "Biology", "level": "O", "code": "0610"},
88
+ "O_0450": {"name": "Business Studies", "level": "O", "code": "0450"},
89
+ "O_0620": {"name": "Chemistry", "level": "O", "code": "0620"},
90
+ "O_0478": {"name": "Computer Science", "level": "O", "code": "0478"},
91
+ "O_0500": {"name": "English Language", "level": "O", "code": "0500"},
92
+ "O_0475": {"name": "English Literature", "level": "O", "code": "0475"},
93
+ "O_0680": {"name": "Environmental Management","level": "O", "code": "0680"},
94
+ "O_0460": {"name": "Geography", "level": "O", "code": "0460"},
95
+ "O_0470": {"name": "History", "level": "O", "code": "0470"},
96
+ "O_0625": {"name": "Physics", "level": "O", "code": "0625"},
97
+ }
98
 
99
+ # -----------------------------------------------------------------------------
100
+ # 3. HELPERS
101
+ # -----------------------------------------------------------------------------
102
+
103
+ def _get_syllabus_ref(remote_id: str):
104
+ """Return Firebase RTDB reference for a syllabus tree."""
105
+ return db_ref.child(f"syllabi/{remote_id}")
106
 
 
 
 
 
 
 
 
107
 
108
+ def _get_chunks_ref(remote_id: str):
109
+ """Return Firebase RTDB reference for syllabus content chunks."""
110
+ return db_ref.child(f"syllabus_chunks/{remote_id}")
111
+
112
+
113
+ def _embed_text(text: str) -> list:
114
+ """Generate a text embedding using Gemini."""
115
  try:
116
+ result = client.models.embed_content(
117
+ model=EMBED_MODEL,
118
+ contents=text,
119
+ )
120
+ return result.embeddings[0].values
121
  except Exception as e:
122
+ logger.error(f"Embed error: {e}")
123
+ return []
124
 
 
 
 
 
125
 
126
+ def _cosine_similarity(a: list, b: list) -> float:
127
+ """Compute cosine similarity between two vectors."""
128
+ if not a or not b or len(a) != len(b):
129
+ return 0.0
130
+ dot = sum(x * y for x, y in zip(a, b))
131
+ mag_a = sum(x * x for x in a) ** 0.5
132
+ mag_b = sum(x * x for x in b) ** 0.5
133
+ if mag_a == 0 or mag_b == 0:
134
+ return 0.0
135
+ return dot / (mag_a * mag_b)
136
 
 
 
 
137
 
138
+ def _keyword_score(query: str, text: str) -> float:
139
+ """Simple keyword overlap fallback score."""
140
+ query_words = set(query.lower().split())
141
+ text_words = set(text.lower().split())
142
+ if not query_words:
143
+ return 0.0
144
+ return len(query_words & text_words) / len(query_words)
145
 
 
 
 
 
146
 
147
+ # -----------------------------------------------------------------------------
148
+ # 4. HEALTH
149
+ # -----------------------------------------------------------------------------
150
 
151
+ @app.route("/", methods=["GET"])
152
+ def root():
153
+ return jsonify({"service": "Marka Data API", "status": "ok",
154
+ "version": "2.0", "subjects": len(SUBJECT_REGISTRY)})
155
+
156
+
157
+ @app.route("/health", methods=["GET"])
158
+ def health():
159
+ return jsonify({"status": "healthy", "timestamp": datetime.utcnow().isoformat()})
160
+
161
+
162
+ # -----------------------------------------------------------------------------
163
+ # 5. SUBJECTS LIST
164
+ # -----------------------------------------------------------------------------
165
+
166
+ @app.route("/v1/subjects", methods=["GET"])
167
+ def list_subjects():
168
+ """List all subjects available in the data API."""
169
+ level = request.args.get("level")
170
+ result = []
171
+ for remote_id, meta in SUBJECT_REGISTRY.items():
172
+ if level and meta["level"] != level:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  continue
174
+ result.append({"remoteId": remote_id, **meta})
175
+ result.sort(key=lambda x: (x["level"], x["name"]))
176
+ return jsonify(result)
177
+
 
 
 
 
178
 
179
+ # -----------------------------------------------------------------------------
180
+ # 6. SYLLABUS STRUCTURE
181
+ # -----------------------------------------------------------------------------
182
 
183
+ @app.route("/v1/structure/<remote_id>", methods=["GET"])
184
+ def get_structure(remote_id):
185
  """
186
+ Return the syllabus tree for a subject.
187
+ Tree shape: { tree: [ { id, title, type, children: [...] } ] }
188
+ Data is read from Firebase RTDB syllabi/<remote_id>.
189
+ If not yet seeded, returns a minimal placeholder so the app doesn't crash.
190
  """
191
+ if remote_id not in SUBJECT_REGISTRY:
192
+ return jsonify({"error": f"Unknown subject: {remote_id}"}), 404
193
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
194
  try:
195
+ data = _get_syllabus_ref(remote_id).get()
196
+ if data and data.get("tree"):
197
+ return jsonify(data)
198
+ # Return empty but valid structure so app server can handle gracefully
199
+ logger.warning(f"No syllabus tree found for {remote_id} — returning empty.")
200
+ return jsonify({
201
+ "remoteId": remote_id,
202
+ "name": SUBJECT_REGISTRY[remote_id]["name"],
203
+ "level": SUBJECT_REGISTRY[remote_id]["level"],
204
+ "tree": [],
205
+ "seeded": False,
206
+ })
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  except Exception as e:
208
+ logger.error(f"Structure fetch {remote_id}: {e}")
209
+ return jsonify({"error": "Failed to fetch syllabus structure"}), 500
210
 
211
 
212
+ @app.route("/v1/structure/<remote_id>", methods=["POST"])
213
+ def upsert_structure(remote_id):
214
  """
215
+ Admin endpoint to seed or update a syllabus tree.
216
+ Body: { tree: [ { id, title, type, children: [...] } ] }
 
217
  """
218
+ if remote_id not in SUBJECT_REGISTRY:
219
+ return jsonify({"error": f"Unknown subject: {remote_id}"}), 404
220
+
221
+ data = request.get_json() or {}
222
+ tree = data.get("tree")
223
+ if not isinstance(tree, list):
224
+ return jsonify({"error": "tree (array) required"}), 400
225
+
226
+ doc = {
227
+ "remoteId": remote_id,
228
+ "name": SUBJECT_REGISTRY[remote_id]["name"],
229
+ "level": SUBJECT_REGISTRY[remote_id]["level"],
230
+ "tree": tree,
231
+ "updatedAt": datetime.utcnow().isoformat(),
232
+ "seeded": True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
  }
234
+ try:
235
+ _get_syllabus_ref(remote_id).set(doc)
236
+ logger.info(f"Syllabus tree seeded: {remote_id} ({len(tree)} top-level nodes)")
237
+ return jsonify({"success": True, "remoteId": remote_id,
238
+ "topLevelNodes": len(tree)}), 201
239
+ except Exception as e:
240
+ logger.error(f"Structure upsert {remote_id}: {e}")
241
+ return jsonify({"error": "Failed to save syllabus structure"}), 500
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
 
 
 
 
 
 
243
 
244
+ # -----------------------------------------------------------------------------
245
+ # 7. CONTENT CHUNKS (for search)
246
+ # -----------------------------------------------------------------------------
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
 
248
+ @app.route("/v1/chunks/<remote_id>", methods=["POST"])
249
+ def upsert_chunks(remote_id):
250
+ """
251
+ Seed content chunks for a subject.
252
+ Body: { chunks: [ { id, nodeId, nodeTitle, content, keywords } ] }
253
+ Embeddings are computed and stored alongside.
254
+ """
255
+ if remote_id not in SUBJECT_REGISTRY:
256
+ return jsonify({"error": f"Unknown subject: {remote_id}"}), 404
257
+
258
+ data = request.get_json() or {}
259
+ chunks = data.get("chunks") or []
260
+ if not chunks:
261
+ return jsonify({"error": "chunks array required"}), 400
262
+
263
+ saved = 0
264
+ errors = 0
265
+ chunks_ref = _get_chunks_ref(remote_id)
266
 
267
+ for chunk in chunks:
268
+ chunk_id = chunk.get("id") or uuid.uuid4().hex
269
+ content = chunk.get("content", "")
270
+ if not content:
271
+ continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  try:
273
+ embedding = _embed_text(content)
274
+ doc = {
275
+ "chunkId": chunk_id,
276
+ "subjectId": remote_id,
277
+ "nodeId": chunk.get("nodeId", ""),
278
+ "nodeTitle": chunk.get("nodeTitle", ""),
279
+ "content": content,
280
+ "keywords": chunk.get("keywords") or [],
281
+ "embedding": embedding,
282
+ "createdAt": datetime.utcnow().isoformat(),
283
+ }
284
+ chunks_ref.child(chunk_id).set(doc)
285
+ saved += 1
286
  except Exception as e:
287
+ logger.error(f"Chunk save error {chunk_id}: {e}")
288
+ errors += 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
 
290
+ return jsonify({"success": True, "saved": saved, "errors": errors,
291
+ "remoteId": remote_id}), 201
292
 
 
 
 
293
 
294
+ @app.route("/v1/chunks/<remote_id>", methods=["GET"])
295
+ def list_chunks(remote_id):
296
+ """List all chunks for a subject (without embeddings for brevity)."""
297
+ if remote_id not in SUBJECT_REGISTRY:
298
+ return jsonify({"error": f"Unknown subject: {remote_id}"}), 404
299
+ try:
300
+ raw = _get_chunks_ref(remote_id).get() or {}
301
+ result = []
302
+ for cid, chunk in raw.items():
303
+ if not chunk: continue
304
+ out = {k: chunk[k] for k in (
305
+ "chunkId", "nodeId", "nodeTitle", "content", "keywords", "createdAt"
306
+ ) if k in chunk}
307
+ result.append(out)
308
+ return jsonify({"remoteId": remote_id, "count": len(result), "chunks": result})
309
  except Exception as e:
310
+ logger.error(f"Chunk list {remote_id}: {e}")
311
+ return jsonify({"error": "Failed to list chunks"}), 500
312
+
313
+
314
+ # -----------------------------------------------------------------------------
315
+ # 8. SEMANTIC SEARCH
316
+ # -----------------------------------------------------------------------------
317
+
318
+ @app.route("/v1/search", methods=["POST"])
319
+ def search_syllabus():
320
+ """
321
+ Semantic search over syllabus content chunks.
322
+ Body: { query: "...", filter_subject_id: "A_9702" (optional), top_k: 5 }
323
+ Returns: { results: [ { chunkId, nodeId, nodeTitle, content, score } ] }
324
+
325
+ Strategy:
326
+ 1. If query embedding succeeds, use cosine similarity.
327
+ 2. Fall back to keyword overlap if embedding fails.
328
+ """
329
+ data = request.get_json() or {}
330
+ query = (data.get("query") or "").strip()
331
+ filter_id = data.get("filter_subject_id")
332
+ top_k = int(data.get("top_k") or 5)
333
+
334
+ if not query:
335
+ return jsonify({"error": "query required"}), 400
336
+
337
+ # Determine which subjects to search
338
+ if filter_id:
339
+ if filter_id not in SUBJECT_REGISTRY:
340
+ return jsonify({"error": f"Unknown subject: {filter_id}"}), 404
341
+ subject_ids = [filter_id]
342
+ else:
343
+ subject_ids = list(SUBJECT_REGISTRY.keys())
344
+
345
+ # Generate query embedding
346
+ query_embedding = _embed_text(query)
347
+ use_embedding = bool(query_embedding)
348
+
349
+ scored_chunks = []
350
+
351
+ for remote_id in subject_ids:
352
+ try:
353
+ raw = _get_chunks_ref(remote_id).get() or {}
354
+ except Exception as e:
355
+ logger.error(f"Search chunk fetch {remote_id}: {e}")
356
+ continue
357
+
358
+ for cid, chunk in raw.items():
359
+ if not chunk or not chunk.get("content"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
  continue
361
+
362
+ content = chunk["content"]
363
+
364
+ if use_embedding:
365
+ chunk_embedding = chunk.get("embedding") or []
366
+ score = _cosine_similarity(query_embedding, chunk_embedding)
367
+ else:
368
+ # Keyword fallback
369
+ score = _keyword_score(query, content)
370
+ # Also score against nodeTitle
371
+ title_score = _keyword_score(query, chunk.get("nodeTitle", ""))
372
+ score = max(score, title_score)
373
+
374
+ scored_chunks.append({
375
+ "chunkId": cid,
376
+ "subjectId": remote_id,
377
+ "nodeId": chunk.get("nodeId", ""),
378
+ "nodeTitle": chunk.get("nodeTitle", ""),
379
+ "content": content,
380
+ "score": round(score, 4),
381
  })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
 
383
+ # Sort by score descending, return top_k
384
+ scored_chunks.sort(key=lambda x: -x["score"])
385
+ results = scored_chunks[:top_k]
386
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
387
  return jsonify({
388
+ "query": query,
389
+ "filterSubject": filter_id,
390
+ "topK": top_k,
391
+ "totalScanned": len(scored_chunks),
392
+ "usedEmbedding": use_embedding,
393
+ "results": results,
 
 
 
 
 
 
 
 
 
 
 
 
 
394
  })
395
 
 
 
 
 
 
 
 
 
 
 
 
 
 
396
 
397
+ # -----------------------------------------------------------------------------
398
+ # 9. NODE DETAIL
399
+ # -----------------------------------------------------------------------------
400
+
401
+ @app.route("/v1/node/<remote_id>/<node_id>", methods=["GET"])
402
+ def get_node_detail(remote_id, node_id):
403
+ """
404
+ Return full content for a specific syllabus node.
405
+ Searches chunks for all content matching this nodeId.
406
+ """
407
+ if remote_id not in SUBJECT_REGISTRY:
408
+ return jsonify({"error": f"Unknown subject: {remote_id}"}), 404
409
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
410
  try:
411
+ raw = _get_chunks_ref(remote_id).get() or {}
412
+ chunks = [
413
+ {k: c[k] for k in ("chunkId", "nodeId", "nodeTitle", "content", "keywords") if k in c}
414
+ for c in raw.values()
415
+ if c and c.get("nodeId") == node_id
416
+ ]
417
+ if not chunks:
418
+ return jsonify({"error": "Node content not found"}), 404
419
+
420
+ combined_content = "\n\n".join(c["content"] for c in chunks)
421
+ node_title = chunks[0].get("nodeTitle", node_id) if chunks else node_id
422
+
423
+ return jsonify({
424
+ "remoteId": remote_id,
425
+ "nodeId": node_id,
426
+ "nodeTitle": node_title,
427
+ "chunks": chunks,
428
+ "fullContent": combined_content,
429
+ })
430
  except Exception as e:
431
+ logger.error(f"Node detail {remote_id}/{node_id}: {e}")
432
+ return jsonify({"error": "Failed to fetch node content"}), 500
433
+
434
+
435
+ # -----------------------------------------------------------------------------
436
+ # 10. PAST PAPERS INDEX
437
+ # -----------------------------------------------------------------------------
438
+
439
+ @app.route("/v1/papers/<remote_id>", methods=["GET"])
440
+ def list_papers_for_subject(remote_id):
441
+ """
442
+ List past paper metadata for a subject from RTDB.
443
+ Papers are stored by the app server; this endpoint provides the index.
444
+ """
445
+ if remote_id not in SUBJECT_REGISTRY:
446
+ return jsonify({"error": f"Unknown subject: {remote_id}"}), 404
447
+
448
  try:
449
+ papers_ref = db_ref.child("papers")
450
+ all_papers = papers_ref.get() or {}
451
+ # Match by remoteId or by subjectId prefix pattern
452
+ meta = SUBJECT_REGISTRY[remote_id]
453
+ matching = [
454
+ p for p in all_papers.values()
455
+ if p and (
456
+ p.get("remoteId") == remote_id
457
+ or (p.get("level") == meta["level"] and
458
+ p.get("code") == meta["code"])
459
+ )
460
+ ]
461
+ matching.sort(key=lambda x: x.get("year", ""), reverse=True)
462
+ return jsonify({"remoteId": remote_id, "papers": matching})
463
  except Exception as e:
464
+ logger.error(f"Papers index {remote_id}: {e}")
465
+ return jsonify({"error": "Failed to fetch papers index"}), 500
466
+
467
+
468
+ # -----------------------------------------------------------------------------
469
+ # 11. BATCH STRUCTURE SEED HELPER
470
+ # -----------------------------------------------------------------------------
471
+
472
+ @app.route("/v1/admin/seed-structures", methods=["POST"])
473
+ def batch_seed_structures():
474
+ """
475
+ Batch seed multiple syllabus trees at once.
476
+ Body: { structures: { "A_9702": { tree: [...] }, ... } }
477
+ """
478
+ data = request.get_json() or {}
479
+ structures = data.get("structures") or {}
480
+ if not structures:
481
+ return jsonify({"error": "structures object required"}), 400
482
+
483
+ results = {}
484
+ for remote_id, payload in structures.items():
485
+ if remote_id not in SUBJECT_REGISTRY:
486
+ results[remote_id] = {"ok": False, "error": "Unknown subject"}
487
  continue
488
+ tree = payload.get("tree")
489
+ if not isinstance(tree, list):
490
+ results[remote_id] = {"ok": False, "error": "tree array required"}
491
+ continue
492
+ try:
493
+ doc = {
494
+ "remoteId": remote_id,
495
+ "name": SUBJECT_REGISTRY[remote_id]["name"],
496
+ "level": SUBJECT_REGISTRY[remote_id]["level"],
497
+ "tree": tree,
498
+ "updatedAt": datetime.utcnow().isoformat(),
499
+ "seeded": True,
500
+ }
501
+ _get_syllabus_ref(remote_id).set(doc)
502
+ results[remote_id] = {"ok": True, "topLevelNodes": len(tree)}
503
+ except Exception as e:
504
+ results[remote_id] = {"ok": False, "error": str(e)}
505
+
506
  return jsonify({"results": results})
507
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
508
 
509
+ # -----------------------------------------------------------------------------
510
+ # 12. STATS
511
+ # -----------------------------------------------------------------------------
512
+
513
+ @app.route("/v1/stats", methods=["GET"])
514
+ def data_api_stats():
515
+ """Return coverage stats which subjects have trees and chunks seeded."""
516
+ stats = []
517
+ for remote_id, meta in SUBJECT_REGISTRY.items():
518
+ try:
519
+ tree_data = _get_syllabus_ref(remote_id).get() or {}
520
+ chunks_data = _get_chunks_ref(remote_id).get() or {}
521
+ tree_count = len(tree_data.get("tree", []))
522
+ chunk_count = len(chunks_data)
523
+ except Exception:
524
+ tree_count = 0
525
+ chunk_count = 0
526
+ stats.append({
527
+ "remoteId": remote_id,
528
+ "name": meta["name"],
529
+ "level": meta["level"],
530
+ "hasTree": tree_count > 0,
531
+ "treeNodes": tree_count,
532
+ "chunkCount": chunk_count,
533
+ })
534
+ stats.sort(key=lambda x: (x["level"], x["name"]))
535
+ seeded_count = sum(1 for s in stats if s["hasTree"])
 
 
 
536
  return jsonify({
537
+ "totalSubjects": len(stats),
538
+ "seededSubjects": seeded_count,
539
+ "subjects": stats,
540
+ "generatedAt": datetime.utcnow().isoformat(),
541
+ })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
542
 
 
 
 
543
 
544
+ # -----------------------------------------------------------------------------
545
+ # MAIN
546
+ # -----------------------------------------------------------------------------
547
 
548
+ if __name__ == "__main__":
549
+ port = int(os.environ.get("PORT", 7861))
550
+ app.run(debug=True, host="0.0.0.0", port=port)