banu4prasad commited on
Commit
0d45cfb
·
1 Parent(s): f862234

PDF service deprecated

Browse files
README.md CHANGED
@@ -13,7 +13,6 @@ A complete GATE exam preparation platform.
13
  - Admin panel (users, tests, series, checklist)
14
  - GATE-style test interface (fullscreen, tab detection, scientific calculator)
15
  - MCQ, MSQ, NAT question types
16
- - PDF auto-extraction
17
  - Question images via Cloudinary
18
  - Timed tests with auto-submit
19
  - Leaderboard (first attempt only)
@@ -72,7 +71,6 @@ UPLOAD_DIR=uploads
72
  AUTH_COOKIE_SECURE=false
73
  AUTH_COOKIE_SAMESITE=lax
74
 
75
- # Cloudinary (cloudinary.com - free 25GB)
76
  CLOUDINARY_CLOUD_NAME=your_cloud_name
77
  CLOUDINARY_API_KEY=your_api_key
78
  CLOUDINARY_API_SECRET=your_api_secret
@@ -105,13 +103,6 @@ for remote Postgres hosts.
105
  3. Use the pooler URL on port `6543`.
106
  4. URL-encode the database password before adding it to `DATABASE_URL`.
107
 
108
- Example:
109
-
110
- ```
111
- DATABASE_URL=postgresql+psycopg2://postgres.<project-ref>:<URL_ENCODED_PASSWORD>@aws-1-ap-south-1.pooler.supabase.com:6543/postgres?sslmode=require
112
- ```
113
-
114
- Do not commit real passwords or secrets.
115
 
116
  ### Backend: Hugging Face Spaces
117
  Create a Docker Space using the repository root. The root `Dockerfile` runs the
 
13
  - Admin panel (users, tests, series, checklist)
14
  - GATE-style test interface (fullscreen, tab detection, scientific calculator)
15
  - MCQ, MSQ, NAT question types
 
16
  - Question images via Cloudinary
17
  - Timed tests with auto-submit
18
  - Leaderboard (first attempt only)
 
71
  AUTH_COOKIE_SECURE=false
72
  AUTH_COOKIE_SAMESITE=lax
73
 
 
74
  CLOUDINARY_CLOUD_NAME=your_cloud_name
75
  CLOUDINARY_API_KEY=your_api_key
76
  CLOUDINARY_API_SECRET=your_api_secret
 
103
  3. Use the pooler URL on port `6543`.
104
  4. URL-encode the database password before adding it to `DATABASE_URL`.
105
 
 
 
 
 
 
 
 
106
 
107
  ### Backend: Hugging Face Spaces
108
  Create a Docker Space using the repository root. The root `Dockerfile` runs the
backend/alembic/versions/710e0c970c16_remove_pdf_filename.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """remove_pdf_filename
2
+
3
+ Revision ID: 710e0c970c16
4
+ Revises: c4b2f2f8e91d
5
+ Create Date: 2026-06-16 13:32:02.174814
6
+
7
+ """
8
+
9
+ from typing import Sequence, Union
10
+
11
+ from alembic import op
12
+ import sqlalchemy as sa
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = "710e0c970c16"
16
+ down_revision: Union[str, Sequence[str], None] = "c4b2f2f8e91d"
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def _has_column(table_name: str, column_name: str) -> bool:
22
+ inspector = sa.inspect(op.get_bind())
23
+ return any(
24
+ column["name"] == column_name for column in inspector.get_columns(table_name)
25
+ )
26
+
27
+
28
+ def upgrade() -> None:
29
+ """Drop the legacy PDF filename column from tests."""
30
+ if _has_column("tests", "pdf_filename"):
31
+ op.drop_column("tests", "pdf_filename")
32
+
33
+
34
+ def downgrade() -> None:
35
+ """Restore the legacy PDF filename column."""
36
+ if not _has_column("tests", "pdf_filename"):
37
+ op.add_column(
38
+ "tests",
39
+ sa.Column("pdf_filename", sa.String(length=500), nullable=True),
40
+ )
backend/app/api/routes/admin.py CHANGED
@@ -36,25 +36,9 @@ from app.services.answer_utils import (
36
  split_answer_tokens,
37
  )
38
  from app.services.cloudinary_service import delete_image, upload_image
39
- from app.services.pdf_service import extract_questions_from_pdf
40
 
41
  router = APIRouter(prefix="/admin", tags=["Admin"])
42
 
43
- MAX_PDF_UPLOAD_SIZE_BYTES = 1 * 1024 * 1024 * 1024
44
- PDF_UPLOAD_CHUNK_SIZE_BYTES = 8192
45
-
46
- PDF_IMPORT_BLOCKING_WARNINGS = {
47
- "missing_question_text",
48
- "missing_answer",
49
- "missing_options",
50
- "too_few_options",
51
- "too_many_options",
52
- "nat_has_non_numeric_answer",
53
- "mcq_has_multiple_answers",
54
- "invalid_choice_answer",
55
- "ambiguous_answer_key",
56
- }
57
-
58
 
59
  def _utcnow() -> datetime:
60
  return datetime.now(timezone.utc)
@@ -83,64 +67,6 @@ def _parse_user_cursor(cursor: str) -> tuple[datetime, int]:
83
  raise HTTPException(status_code=400, detail="Invalid cursor") from exc
84
 
85
 
86
- async def _save_pdf_upload(pdf_file: UploadFile, path: str) -> None:
87
- file_size = 0
88
-
89
- try:
90
- with open(path, "wb") as f:
91
- while chunk := await pdf_file.read(PDF_UPLOAD_CHUNK_SIZE_BYTES):
92
- file_size += len(chunk)
93
- if file_size > MAX_PDF_UPLOAD_SIZE_BYTES:
94
- raise HTTPException(status_code=413, detail="File too large")
95
- f.write(chunk)
96
- except HTTPException:
97
- try:
98
- os.remove(path)
99
- except OSError:
100
- pass
101
- raise
102
-
103
-
104
- def _pdf_blocking_warnings(question: dict) -> list[str]:
105
- warnings = question.get("warnings") or []
106
- return [warning for warning in warnings if warning in PDF_IMPORT_BLOCKING_WARNINGS]
107
-
108
-
109
- def _pdf_review_detail(questions: list[dict]) -> str:
110
- blocked = []
111
- review_only = []
112
- for idx, question in enumerate(questions, start=1):
113
- label = (
114
- question.get("question_number")
115
- or question.get("global_question_number")
116
- or idx
117
- )
118
- warnings = question.get("warnings") or []
119
- blocking = _pdf_blocking_warnings(question)
120
- if blocking:
121
- blocked.append(f"Q{label}: {', '.join(blocking)}")
122
- elif question.get("needs_review"):
123
- review_only.append(
124
- f"Q{label}: {', '.join(warnings) if warnings else 'needs_review'}"
125
- )
126
-
127
- if not blocked:
128
- return ""
129
-
130
- preview = "; ".join(blocked[:8])
131
- remaining = len(blocked) - 8
132
- if remaining > 0:
133
- preview += f"; and {remaining} more"
134
-
135
- suffix = ""
136
- if review_only:
137
- suffix = f" Non-blocking review warnings: {len(review_only)} question(s)."
138
-
139
- return (
140
- f"PDF extraction found {len(questions)} question(s), but {len(blocked)} have blocking issues. "
141
- f"{preview}. Fix these or upload JSON with corrected fields.{suffix}"
142
- )
143
-
144
 
145
  # ── Users ─────────────────────────────────────────────────────────
146
 
@@ -347,92 +273,46 @@ def get_test(test_id: int, db: Session = Depends(get_db), _=Depends(require_admi
347
  }
348
 
349
 
 
 
 
 
 
 
 
 
 
 
 
 
350
  @router.post("/tests", status_code=201)
351
- async def create_test(
352
- title: str = Form(...),
353
- description: str = Form(None),
354
- duration_minutes: int = Form(180),
355
- series_id: int = Form(None),
356
- series_order: int = Form(0),
357
- category: str = Form(None),
358
- series_name: str = Form(None),
359
- test_type: str = Form(None),
360
- subject: str = Form(None),
361
- pdf_file: UploadFile = File(None),
362
  db: Session = Depends(get_db),
363
  current=Depends(require_admin),
364
  ):
365
- pdf_filename = None
366
- extracted = []
367
-
368
- if pdf_file and pdf_file.filename:
369
- if not pdf_file.filename.lower().endswith(".pdf"):
370
- raise HTTPException(status_code=400, detail="Only PDF files accepted")
371
- safe_filename = f"{uuid.uuid4().hex}.pdf"
372
- os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
373
- path = os.path.join(settings.UPLOAD_DIR, safe_filename)
374
- await _save_pdf_upload(pdf_file, path)
375
- pdf_filename = safe_filename
376
- extracted = await run_in_threadpool(extract_questions_from_pdf, path)
377
- if not extracted:
378
- try:
379
- os.remove(path)
380
- except OSError:
381
- pass
382
- raise HTTPException(
383
- status_code=400, detail="No questions could be extracted from PDF."
384
- )
385
-
386
- review_detail = _pdf_review_detail(extracted)
387
- if review_detail:
388
- try:
389
- os.remove(path)
390
- except OSError:
391
- pass
392
- raise HTTPException(status_code=400, detail=review_detail)
393
-
394
- total_marks = sum(q["marks"] for q in extracted) if extracted else 0.0
395
  test = Test(
396
- title=title,
397
- description=description,
398
- duration_minutes=duration_minutes,
399
- pdf_filename=pdf_filename,
400
- total_marks=total_marks,
401
- series_id=series_id,
402
- series_order=series_order,
403
- category=category,
404
- series_name=series_name,
405
- test_type=test_type,
406
- subject=subject,
407
  created_by=current.id,
408
  )
409
  db.add(test)
410
  db.commit()
411
  db.refresh(test)
412
 
413
- for idx, q in enumerate(extracted):
414
- db.add(
415
- Question(
416
- test_id=test.id,
417
- question_type=QuestionType(q["question_type"]),
418
- question_text=q["question_text"],
419
- options=q["options"],
420
- correct_answer=q["correct_answer"],
421
- marks=q["marks"],
422
- negative_marks=q["negative_marks"],
423
- subject=q.get("subject"),
424
- topic=q.get("topic"),
425
- order_index=idx,
426
- )
427
- )
428
- if extracted:
429
- db.commit()
430
-
431
  return {
432
  "id": test.id,
433
  "title": test.title,
434
- "question_count": len(extracted),
435
- "total_marks": total_marks,
436
  }
437
 
438
 
 
36
  split_answer_tokens,
37
  )
38
  from app.services.cloudinary_service import delete_image, upload_image
 
39
 
40
  router = APIRouter(prefix="/admin", tags=["Admin"])
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
 
43
  def _utcnow() -> datetime:
44
  return datetime.now(timezone.utc)
 
67
  raise HTTPException(status_code=400, detail="Invalid cursor") from exc
68
 
69
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
  # ── Users ─────────────────────────────────────────────────────────
72
 
 
273
  }
274
 
275
 
276
+ class TestCreate(BaseModel):
277
+ title: str = Field(...)
278
+ description: Optional[str] = None
279
+ duration_minutes: int = 180
280
+ series_id: Optional[int] = None
281
+ series_order: int = 0
282
+ category: Optional[str] = None
283
+ series_name: Optional[str] = None
284
+ test_type: Optional[str] = None
285
+ subject: Optional[str] = None
286
+
287
+
288
  @router.post("/tests", status_code=201)
289
+ def create_test(
290
+ payload: TestCreate,
 
 
 
 
 
 
 
 
 
291
  db: Session = Depends(get_db),
292
  current=Depends(require_admin),
293
  ):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
294
  test = Test(
295
+ title=payload.title,
296
+ description=payload.description,
297
+ duration_minutes=payload.duration_minutes,
298
+ total_marks=0.0,
299
+ series_id=payload.series_id,
300
+ series_order=payload.series_order,
301
+ category=payload.category,
302
+ series_name=payload.series_name,
303
+ test_type=payload.test_type,
304
+ subject=payload.subject,
 
305
  created_by=current.id,
306
  )
307
  db.add(test)
308
  db.commit()
309
  db.refresh(test)
310
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
311
  return {
312
  "id": test.id,
313
  "title": test.title,
314
+ "question_count": 0,
315
+ "total_marks": 0.0,
316
  }
317
 
318
 
backend/app/models/models.py CHANGED
@@ -103,7 +103,6 @@ class Test(Base):
103
  description: Mapped[str | None] = mapped_column(Text, nullable=True)
104
  duration_minutes: Mapped[int] = mapped_column(Integer, default=180)
105
  total_marks: Mapped[float] = mapped_column(Float, default=0.0)
106
- pdf_filename: Mapped[str | None] = mapped_column(String(500), nullable=True)
107
  is_published: Mapped[bool] = mapped_column(Boolean, default=True)
108
  series_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("test_series.id"), nullable=True, index=True)
109
  series_order: Mapped[int] = mapped_column(Integer, default=0)
 
103
  description: Mapped[str | None] = mapped_column(Text, nullable=True)
104
  duration_minutes: Mapped[int] = mapped_column(Integer, default=180)
105
  total_marks: Mapped[float] = mapped_column(Float, default=0.0)
 
106
  is_published: Mapped[bool] = mapped_column(Boolean, default=True)
107
  series_id: Mapped[int | None] = mapped_column(Integer, ForeignKey("test_series.id"), nullable=True, index=True)
108
  series_order: Mapped[int] = mapped_column(Integer, default=0)
backend/app/services/PDF_PARSER.md DELETED
@@ -1,86 +0,0 @@
1
- # PDF Parser Notes
2
-
3
- ## Supported Structures
4
-
5
- The parser is deterministic and layout-agnostic. It supports common exam formats such as:
6
-
7
- - `Q.1 ...`, `Q1 ...`, `Question 1 ...`
8
- - `1. [MCQ] ...`
9
- - `[MSQ] 1. ...`
10
- - `Q.1 MCQ | +2 -0`
11
- - `Q.1 NAT | +1 -0`
12
- - question types written as `MCQ`, `MSQ`, `NAT`, `Single correct`, `Multiple correct`, or `Numerical answer type`
13
- - options written as `(a)`, `(A)`, `a)`, `A.`, and options split across lines or compacted onto one line
14
- - inline answer lines such as `Answer: B` or `Correct Answer: A;C`
15
- - separate `Answer Key` / `Answers` / `Solutions` sections with numbered answers
16
- - NAT answers as numbers, decimals, negative numbers, ranges, or comma/semicolon-separated accepted values
17
- - marks written as `+2 -0.67`, `[2 marks]`, `(1 mark)`, or range rules like `Q.1 to Q.5 carry one mark each`
18
-
19
- ## Pipeline
20
-
21
- `extract_questions_from_pdf()` runs these stages:
22
-
23
- 1. Extract text page by page using `pdfplumber`, falling back to PyMuPDF.
24
- 2. Normalize text and line endings.
25
- 3. Select structural parser profiles from document patterns.
26
- 4. Parse answer key / solutions sections.
27
- 5. Parse section-level marks rules.
28
- 6. Detect question blocks and preserve page, section, local number, and global number.
29
- 7. Parse question type, marks, options, inline answers, and answer-key answers.
30
- 8. Validate each question and attach warnings.
31
- 9. Assign confidence and `needs_review`.
32
- 10. Return app-compatible question dictionaries with optional metadata.
33
-
34
- ## Confidence
35
-
36
- Confidence is a `0.0` to `1.0` score based on:
37
-
38
- - question text presence
39
- - explicit question type
40
- - expected options for MCQ/MSQ
41
- - valid answer shape
42
- - marks found or defaulted
43
- - obvious parsing problems
44
-
45
- Questions below `0.7` get `low_confidence` and `needs_review`.
46
-
47
- ## Review Warnings
48
-
49
- Common warnings include:
50
-
51
- - `missing_answer`
52
- - `missing_options`
53
- - `too_few_options`
54
- - `too_many_options`
55
- - `nat_has_non_numeric_answer`
56
- - `mcq_has_multiple_answers`
57
- - `msq_has_single_answer_but_allowed`
58
- - `image_context_required`
59
- - `duplicate_question_number_in_same_section`
60
- - `low_confidence`
61
-
62
- The parser never guesses missing answers. Missing answers are returned as an empty string with `missing_answer`.
63
-
64
- ## Upload Behavior
65
-
66
- The parser can return low-confidence questions for review, but the current database schema has no columns for parser review metadata. The admin PDF upload endpoint therefore rejects PDFs containing review-needed questions instead of saving uncertain questions into a live test.
67
-
68
- ## Limitations
69
-
70
- - It does not perform OCR.
71
- - It does not extract embedded diagrams or rendered page images.
72
- - Two-column text is handled only when the PDF text extraction preserves option markers.
73
- - Ambiguous answer keys with repeated question numbers may require manual review.
74
- - Image-dependent questions are flagged with `has_image` / `image_context_required`; image extraction can be added later using existing question image fields.
75
-
76
- ## Adding Patterns
77
-
78
- Add structural support by extending regexes in `pdf_service.py`:
79
-
80
- - question starts: `_parse_question_boundary`
81
- - type aliases: `TYPE_ALIASES`
82
- - answer key pairs: `ANSWER_PAIR_RE` / `ANSWER_PAIR_SPACE_RE`
83
- - marks: `MARKS_INLINE_RE`, `MARKS_WORD_RE`, `MARK_RULE_RE`
84
- - image-context phrases: `IMAGE_CONTEXT_RE`
85
-
86
- Add synthetic fixtures in `backend/tests/test_pdf_service.py` for every new pattern.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/app/services/pdf_service.py DELETED
@@ -1,959 +0,0 @@
1
- """
2
- Deterministic PDF question extraction.
3
-
4
- The public entry point remains extract_questions_from_pdf(pdf_path). Internally
5
- the parser runs a staged pipeline: page text extraction, normalization, profile
6
- detection, section/question block detection, answer-key extraction, question
7
- field parsing, validation, confidence scoring, and compatibility shaping.
8
- """
9
-
10
- from __future__ import annotations
11
-
12
- import logging
13
- import os
14
- import re
15
- from dataclasses import dataclass, field
16
- from typing import Any, Dict, List, Optional
17
-
18
- from app.services.answer_utils import (is_valid_nat_answer,
19
- normalize_choice_answer, parse_float,
20
- split_answer_tokens)
21
-
22
- logger = logging.getLogger(__name__)
23
-
24
- DEFAULT_MARKS = {"mcq": 1.0, "msq": 2.0, "nat": 2.0}
25
- DEFAULT_NEGATIVE = {"mcq": 0.33, "msq": 0.0, "nat": 0.0}
26
-
27
- TYPE_ALIASES = [
28
- (re.compile(r"\bMSQ\b|\bmultiple\s+correct\b", re.IGNORECASE), "msq"),
29
- (re.compile(r"\bNAT\b|\bnumerical\s+answer(?:\s+type)?\b", re.IGNORECASE), "nat"),
30
- (re.compile(r"\bMCQ\b|\bsingle\s+correct\b", re.IGNORECASE), "mcq"),
31
- ]
32
- TYPE_TOKEN_RE = r"(?:MCQ|MSQ|NAT|single\s+correct|multiple\s+correct|numerical\s+answer(?:\s+type)?)"
33
- QUESTION_HEADER_RE = re.compile(
34
- rf"^\s*(?:\[?\s*(?P<type>{TYPE_TOKEN_RE})\s*\]?)?"
35
- r"\s*(?:\|\s*)?"
36
- r"(?:(?:\+?(?P<marks>\d+(?:\.\d+)?)\s*-\s*(?P<negative>\d+(?:\.\d+)?))|"
37
- r"(?P<bracket_marks>[\[(]\s*\d+(?:\.\d+)?\s*marks?\s*[\])]))?"
38
- r"\s*(?P<rest>.*)$",
39
- re.IGNORECASE,
40
- )
41
- Q_PREFIX_RE = re.compile(
42
- r"^\s*(?:Q\.?\s*|Question\s+)(?P<num>\d{1,4})\s*[\).:\-]?\s*(?P<rest>.*)$",
43
- re.IGNORECASE,
44
- )
45
- TYPE_BEFORE_NUM_RE = re.compile(
46
- rf"^\s*\[?\s*(?P<type>{TYPE_TOKEN_RE})\s*\]?\s*"
47
- r"(?:Q\.?\s*|Question\s+)?(?P<num>\d{1,4})\s*[\).:\-]?\s*(?P<rest>.*)$",
48
- re.IGNORECASE,
49
- )
50
- NUMBERED_RE = re.compile(r"^\s*(?P<num>\d{1,4})\s*[\).:]\s*(?P<rest>.*)$")
51
- OPTION_MARKER_RE = re.compile(
52
- r"(?<![A-Za-z0-9])(?:[\(\[]\s*([A-Da-d])\s*[\)\]]|([A-Da-d])[\).])\s*"
53
- )
54
- ANSWER_LINE_RE = re.compile(
55
- r"\b(?:Correct\s+Answer|Answer|Ans)\s*[:.)\-]\s*(?P<answer>.+)$", re.IGNORECASE
56
- )
57
- ANSWER_KEY_TITLE_RE = re.compile(
58
- r"^\s*(?:answer\s*key|answers?|solutions?)\s*[:\-]?\s*$", re.IGNORECASE
59
- )
60
- CHOICE_ANSWER_VALUE_RE = (
61
- r"(?:option\s*)?[\(\[]?\s*[A-Da-d]\s*[\)\].]?"
62
- r"(?:\s*(?:[,;/&]|\band\b|\bor\b)\s*(?:option\s*)?[\(\[]?\s*[A-Da-d]\s*[\)\].]?)*"
63
- )
64
- ANSWER_VALUE_RE = (
65
- rf"(?:{CHOICE_ANSWER_VALUE_RE}|"
66
- r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
67
- r"(?:\s*-\s*[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)?"
68
- r"(?:\s*[,;/]\s*[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
69
- r"(?:\s*-\s*[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)?)*)"
70
- )
71
- CHOICE_TOKEN_RE = re.compile(
72
- r"(?<![A-Za-z0-9])(?:option\s*)?[\(\[]?\s*([A-Da-d])\s*[\)\].]?(?![A-Za-z0-9])",
73
- re.IGNORECASE,
74
- )
75
- LEADING_CHOICE_ANSWER_RE = re.compile(
76
- rf"^\s*(?P<choices>{CHOICE_ANSWER_VALUE_RE})(?=\s|$)",
77
- re.IGNORECASE,
78
- )
79
- NAT_VALUE_RE = re.compile(
80
- r"[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?"
81
- r"(?:\s*(?:-|to)\s*[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)?",
82
- re.IGNORECASE,
83
- )
84
- ANSWER_PAIR_RE = re.compile(
85
- rf"(?:Q\.?\s*|Question\s+)?(?P<num>\d{{1,4}})\s*[\).:\-]\s*"
86
- rf"(?:(?:Correct\s+Answer|Answer|Ans)\s*[:\-]?\s*)?(?P<answer>{ANSWER_VALUE_RE})(?=\s|$)",
87
- re.IGNORECASE,
88
- )
89
- ANSWER_PAIR_SPACE_RE = re.compile(
90
- rf"(?:Q\.?\s*|Question\s+)?(?P<num>\d{{1,4}})\s+"
91
- rf"(?:(?:Correct\s+Answer|Answer|Ans)\s*[:\-]?\s*)?(?P<answer>{ANSWER_VALUE_RE})(?=\s|$)",
92
- re.IGNORECASE,
93
- )
94
- SOLUTION_ANSWER_RE = re.compile(
95
- rf"\b(?:Solution|Sol)\s*(?P<num>\d{{1,4}})\b.*?"
96
- rf"(?:(?:Correct\s+Answer|Answer|Ans)\s*[:\-]?\s*)?(?P<answer>{ANSWER_VALUE_RE})(?=\s|$)",
97
- re.IGNORECASE,
98
- )
99
- MARKS_INLINE_RE = re.compile(
100
- r"\+?(?P<marks>\d+(?:\.\d+)?)\s*-\s*(?P<negative>\d+(?:\.\d+)?)"
101
- )
102
- MARKS_WORD_RE = re.compile(
103
- r"[\[(]\s*(?P<marks>\d+(?:\.\d+)?)\s*marks?\s*[\])]", re.IGNORECASE
104
- )
105
- MARK_RULE_RE = re.compile(
106
- r"(?:Q\.?\s*)?(?P<start>\d{1,4})\s*(?:to|\-|through)\s*(?:Q\.?\s*)?(?P<end>\d{1,4})"
107
- r".*?\bcarry\s+(?P<marks>one|two|three|four|five|\d+(?:\.\d+)?)\s+marks?\s+each",
108
- re.IGNORECASE,
109
- )
110
- IMAGE_CONTEXT_RE = re.compile(
111
- r"\b(following\s+(?:figure|diagram)|shown\s+below|given\s+circuit|truth\s+table|"
112
- r"hasse\s+diagram|automaton|graph)\b",
113
- re.IGNORECASE,
114
- )
115
-
116
- WORD_NUMBERS = {"one": 1.0, "two": 2.0, "three": 3.0, "four": 4.0, "five": 5.0}
117
- REVIEW_WARNINGS = {
118
- "missing_answer",
119
- "missing_options",
120
- "too_few_options",
121
- "too_many_options",
122
- "nat_has_non_numeric_answer",
123
- "mcq_has_multiple_answers",
124
- "image_context_required",
125
- "duplicate_question_number_in_same_section",
126
- "ambiguous_answer_key",
127
- "low_confidence",
128
- }
129
-
130
-
131
- @dataclass
132
- class PageText:
133
- page_number: int
134
- text: str
135
-
136
-
137
- @dataclass
138
- class SourceLine:
139
- text: str
140
- page_number: int
141
- index: int
142
-
143
-
144
- @dataclass
145
- class QuestionBoundary:
146
- number: int
147
- declared_type: Optional[str]
148
- rest: str
149
-
150
-
151
- @dataclass
152
- class QuestionBlock:
153
- question_number: int
154
- global_question_number: int
155
- section_title: Optional[str]
156
- source_page_start: int
157
- source_page_end: int
158
- declared_type: Optional[str]
159
- lines: List[SourceLine] = field(default_factory=list)
160
- occurrence_index: int = 1
161
-
162
-
163
- @dataclass
164
- class MarkRule:
165
- start: int
166
- end: int
167
- marks: float
168
-
169
-
170
- @dataclass
171
- class AnswerKeys:
172
- by_number: Dict[int, List[str]] = field(default_factory=dict)
173
- by_section: Dict[tuple[str, int], List[str]] = field(default_factory=dict)
174
- line_indexes: set[int] = field(default_factory=set)
175
-
176
-
177
- def extract_questions_from_pdf(pdf_path: str) -> List[Dict[str, Any]]:
178
- pages = _extract_pages(pdf_path)
179
- if not any(page.text.strip() for page in pages):
180
- logger.error("Could not extract any text from PDF")
181
- return []
182
-
183
- questions = _parse_pages(pages, source_filename=os.path.basename(pdf_path))
184
- return questions
185
-
186
-
187
- def _extract_text(pdf_path: str) -> str:
188
- return "\n".join(page.text for page in _extract_pages(pdf_path))
189
-
190
-
191
- def _extract_pages(pdf_path: str) -> List[PageText]:
192
- """Extract text page by page using existing PDF libraries."""
193
- try:
194
- import pdfplumber
195
-
196
- with pdfplumber.open(pdf_path) as pdf:
197
- pages = [
198
- PageText(i + 1, page.extract_text() or "")
199
- for i, page in enumerate(pdf.pages)
200
- ]
201
- if any(page.text.strip() for page in pages):
202
- logger.info("Extracted %s pages with pdfplumber", len(pages))
203
- return pages
204
- except Exception as exc:
205
- logger.warning("pdfplumber failed: %s", exc)
206
-
207
- try:
208
- import fitz
209
-
210
- doc = fitz.open(pdf_path)
211
- pages = [PageText(i + 1, page.get_text() or "") for i, page in enumerate(doc)]
212
- doc.close()
213
- logger.info("Extracted %s pages with PyMuPDF", len(pages))
214
- return pages
215
- except Exception as exc:
216
- logger.warning("PyMuPDF failed: %s", exc)
217
-
218
- return []
219
-
220
-
221
- def _parse_gate_questions(text: str) -> List[Dict[str, Any]]:
222
- """Compatibility helper used by tests and older call sites."""
223
- return _parse_pages([PageText(1, text)], source_filename=None)
224
-
225
-
226
- def _parse_pages(
227
- pages: List[PageText], source_filename: Optional[str]
228
- ) -> List[Dict[str, Any]]:
229
- normalized_pages = [
230
- PageText(page.page_number, _normalize_text(page.text)) for page in pages
231
- ]
232
- lines = _page_lines(normalized_pages)
233
- profiles = _select_profiles(lines)
234
- answer_keys = _parse_answer_key_sections(lines)
235
- mark_rules = _parse_mark_rules(lines)
236
- blocks = _detect_question_blocks(lines, answer_keys.line_indexes)
237
-
238
- question_number_counts: Dict[int, int] = {}
239
- for block in blocks:
240
- question_number_counts[block.question_number] = (
241
- question_number_counts.get(block.question_number, 0) + 1
242
- )
243
-
244
- seen_in_section: set[tuple[str, int]] = set()
245
- questions: List[Dict[str, Any]] = []
246
- for block in blocks:
247
- parsed = _parse_question_block(
248
- block, answer_keys, mark_rules, question_number_counts, source_filename
249
- )
250
- duplicate_key = (parsed.get("section_title") or "", parsed["question_number"])
251
- duplicate = duplicate_key in seen_in_section
252
- seen_in_section.add(duplicate_key)
253
- questions.append(_validate_and_score(parsed, duplicate=duplicate))
254
-
255
- needs_review = sum(1 for question in questions if question.get("needs_review"))
256
- logger.info(
257
- "Selected PDF parser profile(s): %s",
258
- ", ".join(profiles) if profiles else "generic_profile",
259
- )
260
- logger.info("PDF parser pages extracted: %s", len(pages))
261
- logger.info(
262
- "PDF parser sections detected: %s",
263
- len({q.get("section_title") for q in questions if q.get("section_title")}),
264
- )
265
- logger.info("PDF parser question blocks detected: %s", len(blocks))
266
- logger.info(
267
- "PDF parser answers detected: %s",
268
- sum(len(v) for v in answer_keys.by_number.values()),
269
- )
270
- logger.info("PDF parser questions returned: %s", len(questions))
271
- logger.info("PDF parser questions needing review: %s", needs_review)
272
- return questions
273
-
274
-
275
- def _normalize_text(text: str) -> str:
276
- text = text.replace("\r\n", "\n").replace("\r", "\n")
277
- text = text.replace("\u2013", "-").replace("\u2014", "-")
278
- text = text.replace("\u2212", "-")
279
- text = text.replace("\u00a0", " ")
280
- return text
281
-
282
-
283
- def _page_lines(pages: List[PageText]) -> List[SourceLine]:
284
- lines: List[SourceLine] = []
285
- index = 0
286
- for page in pages:
287
- for raw in page.text.split("\n"):
288
- lines.append(SourceLine(raw.strip(), page.page_number, index))
289
- index += 1
290
- return lines
291
-
292
-
293
- def _select_profiles(lines: List[SourceLine]) -> List[str]:
294
- profiles: List[str] = []
295
- text = "\n".join(line.text for line in lines)
296
- if ANSWER_KEY_TITLE_RE.search(text):
297
- profiles.append("separate_answer_key_profile")
298
- if ANSWER_LINE_RE.search(text):
299
- profiles.append("inline_answer_profile")
300
- if any(
301
- _parse_question_boundary(line.text) and _extract_question_type(line.text)
302
- for line in lines
303
- ):
304
- profiles.append("typed_numbered_question_profile")
305
- if any(len(list(OPTION_MARKER_RE.finditer(line.text))) > 1 for line in lines):
306
- profiles.append("compact_question_profile")
307
- return profiles or ["generic_numbered_question_profile"]
308
-
309
-
310
- def _parse_answer_key_sections(lines: List[SourceLine]) -> AnswerKeys:
311
- keys = AnswerKeys()
312
- in_key = False
313
- current_section: Optional[str] = None
314
-
315
- for line in lines:
316
- text = line.text.strip()
317
- if not text:
318
- if in_key:
319
- keys.line_indexes.add(line.index)
320
- continue
321
-
322
- if ANSWER_KEY_TITLE_RE.match(text):
323
- in_key = True
324
- current_section = None
325
- keys.line_indexes.add(line.index)
326
- continue
327
-
328
- if not in_key:
329
- solution_match = SOLUTION_ANSWER_RE.search(text)
330
- if solution_match:
331
- _store_answer(
332
- keys,
333
- int(solution_match.group("num")),
334
- _clean_answer_value(solution_match.group("answer")),
335
- None,
336
- )
337
- continue
338
-
339
- keys.line_indexes.add(line.index)
340
- if _looks_section_title(text):
341
- current_section = text
342
-
343
- for q_num, answer in _extract_answer_pairs(text):
344
- _store_answer(keys, q_num, answer, current_section)
345
-
346
- return keys
347
-
348
-
349
- def _store_answer(
350
- keys: AnswerKeys, q_num: int, answer: str, section_title: Optional[str]
351
- ) -> None:
352
- if not answer:
353
- return
354
- keys.by_number.setdefault(q_num, []).append(answer)
355
- if section_title:
356
- keys.by_section.setdefault((section_title, q_num), []).append(answer)
357
-
358
-
359
- def _extract_answer_pairs(text: str) -> List[tuple[int, str]]:
360
- pairs: List[tuple[int, str]] = []
361
- for pattern in (ANSWER_PAIR_RE, ANSWER_PAIR_SPACE_RE):
362
- for match in pattern.finditer(text):
363
- q_num = int(match.group("num"))
364
- answer = _clean_answer_value(match.group("answer"))
365
- if answer:
366
- pairs.append((q_num, answer))
367
- if pairs:
368
- break
369
- return pairs
370
-
371
-
372
- def _parse_mark_rules(lines: List[SourceLine]) -> List[MarkRule]:
373
- rules: List[MarkRule] = []
374
- for line in lines:
375
- match = MARK_RULE_RE.search(line.text)
376
- if not match:
377
- continue
378
- raw_marks = match.group("marks").lower()
379
- marks = WORD_NUMBERS.get(raw_marks, parse_float(raw_marks))
380
- if marks is not None:
381
- rules.append(
382
- MarkRule(int(match.group("start")), int(match.group("end")), marks)
383
- )
384
- return rules
385
-
386
-
387
- def _detect_question_blocks(
388
- lines: List[SourceLine], answer_key_line_indexes: set[int]
389
- ) -> List[QuestionBlock]:
390
- blocks: List[QuestionBlock] = []
391
- current: Optional[QuestionBlock] = None
392
- recent_context: List[str] = []
393
- current_section: Optional[str] = None
394
- section_index = 0
395
- last_question_number: Optional[int] = None
396
- global_question_number = 0
397
- occurrence_counts: Dict[int, int] = {}
398
-
399
- for line in lines:
400
- if line.index in answer_key_line_indexes:
401
- if current:
402
- blocks.append(current)
403
- current = None
404
- recent_context.clear()
405
- continue
406
-
407
- boundary = _parse_question_boundary(line.text)
408
- if boundary:
409
- if current:
410
- blocks.append(current)
411
-
412
- candidate_section = _latest_section_title(recent_context)
413
- if candidate_section and (current_section is None or boundary.number == 1):
414
- current_section = candidate_section
415
- elif (
416
- last_question_number is not None
417
- and boundary.number <= last_question_number
418
- ):
419
- section_index += 1
420
- current_section = candidate_section or f"Section {section_index + 1}"
421
-
422
- global_question_number += 1
423
- occurrence_counts[boundary.number] = (
424
- occurrence_counts.get(boundary.number, 0) + 1
425
- )
426
- current = QuestionBlock(
427
- question_number=boundary.number,
428
- global_question_number=global_question_number,
429
- section_title=current_section,
430
- source_page_start=line.page_number,
431
- source_page_end=line.page_number,
432
- declared_type=boundary.declared_type,
433
- occurrence_index=occurrence_counts[boundary.number],
434
- )
435
- if boundary.rest:
436
- current.lines.append(
437
- SourceLine(boundary.rest, line.page_number, line.index)
438
- )
439
-
440
- recent_context.clear()
441
- last_question_number = boundary.number
442
- continue
443
-
444
- if current:
445
- current.lines.append(line)
446
- current.source_page_end = max(current.source_page_end, line.page_number)
447
- elif line.text:
448
- recent_context.append(line.text)
449
- recent_context = recent_context[-8:]
450
-
451
- if current:
452
- blocks.append(current)
453
-
454
- return blocks
455
-
456
-
457
- def _parse_question_boundary(text: str) -> Optional[QuestionBoundary]:
458
- stripped = text.strip()
459
- if not stripped:
460
- return None
461
-
462
- type_before = TYPE_BEFORE_NUM_RE.match(stripped)
463
- if type_before:
464
- return QuestionBoundary(
465
- number=int(type_before.group("num")),
466
- declared_type=_normalize_question_type_label(type_before.group("type")),
467
- rest=type_before.group("rest").strip(),
468
- )
469
-
470
- q_prefix = Q_PREFIX_RE.match(stripped)
471
- if q_prefix:
472
- rest = q_prefix.group("rest").strip()
473
- return QuestionBoundary(
474
- number=int(q_prefix.group("num")),
475
- declared_type=_extract_question_type(rest),
476
- rest=rest,
477
- )
478
-
479
- numbered = NUMBERED_RE.match(stripped)
480
- if numbered:
481
- number = int(numbered.group("num"))
482
- if number > 500:
483
- return None
484
- rest = numbered.group("rest").strip()
485
- return QuestionBoundary(
486
- number=number, declared_type=_extract_question_type(rest), rest=rest
487
- )
488
-
489
- return None
490
-
491
-
492
- def _parse_question_block(
493
- block: QuestionBlock,
494
- answer_keys: AnswerKeys,
495
- mark_rules: List[MarkRule],
496
- question_number_counts: Dict[int, int],
497
- source_filename: Optional[str],
498
- ) -> Dict[str, Any]:
499
- cleaned_lines: List[SourceLine] = []
500
- inline_answer = ""
501
-
502
- for source_line in block.lines:
503
- text = source_line.text.strip()
504
- if not text:
505
- continue
506
- answer, remainder = _extract_inline_answer(text)
507
- if answer:
508
- inline_answer = answer
509
- text = remainder.strip()
510
- text = _strip_question_metadata(text)
511
- if text:
512
- cleaned_lines.append(
513
- SourceLine(text, source_line.page_number, source_line.index)
514
- )
515
-
516
- question_lines, options = _parse_options_and_question_text(cleaned_lines)
517
- question_text = _clean_spaces(" ".join(question_lines))
518
- raw_text = _clean_spaces(" ".join(line.text for line in cleaned_lines))
519
- type_found = bool(block.declared_type or _extract_question_type(raw_text))
520
- question_type = block.declared_type or _extract_question_type(raw_text)
521
- if not question_type:
522
- question_type = _infer_question_type(question_text, options, inline_answer)
523
-
524
- marks, negative_marks, marks_found = _resolve_marks(
525
- raw_text, question_type, block.question_number, mark_rules
526
- )
527
- answer = inline_answer or _lookup_answer(answer_keys, block, question_number_counts)
528
- answer = _normalize_answer(answer, question_type)
529
- if question_type == "mcq" and len(_choice_tokens(answer)) > 1:
530
- question_type = "msq"
531
- negative_marks = 0.0
532
-
533
- section_title = block.section_title
534
- has_image = bool(IMAGE_CONTEXT_RE.search(question_text))
535
- return {
536
- "question_type": question_type,
537
- "question_text": question_text,
538
- "options": options,
539
- "correct_answer": answer,
540
- "marks": marks,
541
- "negative_marks": negative_marks,
542
- "subject": None,
543
- "topic": section_title,
544
- "section_title": section_title,
545
- "question_number": block.question_number,
546
- "global_question_number": block.global_question_number,
547
- "source_filename": source_filename,
548
- "source_page_start": block.source_page_start,
549
- "source_page_end": block.source_page_end,
550
- "has_image": has_image,
551
- "_type_found": type_found,
552
- "_marks_found": marks_found,
553
- "_answer_source": (
554
- "inline" if inline_answer else "answer_key" if answer else None
555
- ),
556
- }
557
-
558
-
559
- def _extract_inline_answer(text: str) -> tuple[str, str]:
560
- match = ANSWER_LINE_RE.search(text)
561
- if not match:
562
- return "", text
563
- answer = _clean_answer_value(match.group("answer"))
564
- remainder = text[: match.start()].strip()
565
- return answer, remainder
566
-
567
-
568
- def _clean_answer_value(value: str) -> str:
569
- text = str(value or "").strip()
570
- text = re.split(
571
- r"\s+(?:because|since|for)\b", text, maxsplit=1, flags=re.IGNORECASE
572
- )[0]
573
- text = text.strip().strip("[]()")
574
- if re.fullmatch(r"[A-Da-d](?:\s*[,;/]\s*[A-Da-d])*[.)]?", text):
575
- text = text.rstrip(".)")
576
- return text.strip()
577
-
578
-
579
- def _strip_question_metadata(text: str) -> str:
580
- if not text:
581
- return text
582
- match = QUESTION_HEADER_RE.match(text)
583
- if match and (
584
- match.group("type") or match.group("marks") or match.group("bracket_marks")
585
- ):
586
- return match.group("rest").strip()
587
- text = re.sub(
588
- rf"^\s*\[?\s*{TYPE_TOKEN_RE}\s*\]?\s*(?:\|\s*)?", "", text, flags=re.IGNORECASE
589
- )
590
- text = MARKS_INLINE_RE.sub("", text, count=1).strip()
591
- text = MARKS_WORD_RE.sub("", text, count=1).strip()
592
- return text.strip(" |")
593
-
594
-
595
- def _parse_options_and_question_text(
596
- lines: List[SourceLine],
597
- ) -> tuple[List[str], List[str]]:
598
- question_lines: List[str] = []
599
- options_by_label: Dict[str, str] = {}
600
- current_label: Optional[str] = None
601
-
602
- for line in lines:
603
- text = line.text.strip()
604
- markers = list(OPTION_MARKER_RE.finditer(text))
605
- if markers:
606
- prefix = text[: markers[0].start()].strip()
607
- if prefix:
608
- if current_label:
609
- options_by_label[current_label] = _clean_spaces(
610
- f"{options_by_label.get(current_label, '')} {prefix}"
611
- )
612
- else:
613
- question_lines.append(prefix)
614
-
615
- for idx, marker in enumerate(markers):
616
- label = (marker.group(1) or marker.group(2)).upper()
617
- next_start = (
618
- markers[idx + 1].start() if idx + 1 < len(markers) else len(text)
619
- )
620
- value = text[marker.end() : next_start].strip()
621
- postfixed_value = False
622
- if (
623
- not value
624
- and len(markers) == 1
625
- and _looks_postfixed_option_text(
626
- question_lines[-1] if question_lines else ""
627
- )
628
- ):
629
- value = question_lines.pop()
630
- postfixed_value = True
631
- if label in options_by_label and value:
632
- options_by_label[label] = _clean_spaces(
633
- f"{options_by_label[label]} {value}"
634
- )
635
- else:
636
- options_by_label.setdefault(label, value)
637
- current_label = None if postfixed_value else label
638
- continue
639
-
640
- if current_label:
641
- options_by_label[current_label] = _clean_spaces(
642
- f"{options_by_label.get(current_label, '')} {text}"
643
- )
644
- elif text:
645
- question_lines.append(text)
646
-
647
- options = [
648
- _clean_spaces(options_by_label[label])
649
- for label in ("A", "B", "C", "D")
650
- if options_by_label.get(label, "").strip()
651
- ]
652
- return question_lines, options
653
-
654
-
655
- def _looks_postfixed_option_text(text: str) -> bool:
656
- value = _clean_spaces(text)
657
- if not value or len(value) > 80 or value.endswith(("?", ".")):
658
- return False
659
- if re.search(r"[,;$¬∧∨→↔≠=]", value):
660
- return True
661
- return len(value.split()) <= 4 and not re.search(
662
- r"\b(choose|select|determine|identify|consider|suppose|which)\b",
663
- value,
664
- re.IGNORECASE,
665
- )
666
-
667
-
668
- def _resolve_marks(
669
- raw_text: str,
670
- question_type: str,
671
- question_number: int,
672
- mark_rules: List[MarkRule],
673
- ) -> tuple[float, float, bool]:
674
- inline = MARKS_INLINE_RE.search(raw_text)
675
- if inline:
676
- return float(inline.group("marks")), float(inline.group("negative")), True
677
-
678
- word = MARKS_WORD_RE.search(raw_text)
679
- if word:
680
- return (
681
- float(word.group("marks")),
682
- DEFAULT_NEGATIVE.get(question_type, 0.33),
683
- True,
684
- )
685
-
686
- for rule in mark_rules:
687
- if rule.start <= question_number <= rule.end:
688
- return rule.marks, DEFAULT_NEGATIVE.get(question_type, 0.33), True
689
-
690
- return (
691
- DEFAULT_MARKS.get(question_type, 1.0),
692
- DEFAULT_NEGATIVE.get(question_type, 0.33),
693
- False,
694
- )
695
-
696
-
697
- def _lookup_answer(
698
- keys: AnswerKeys, block: QuestionBlock, question_number_counts: Dict[int, int]
699
- ) -> str:
700
- if block.section_title:
701
- section_answers = keys.by_section.get(
702
- (block.section_title, block.question_number)
703
- )
704
- if section_answers:
705
- return section_answers[
706
- min(block.occurrence_index - 1, len(section_answers) - 1)
707
- ]
708
-
709
- answers = keys.by_number.get(block.question_number, [])
710
- if not answers:
711
- return ""
712
- if question_number_counts.get(block.question_number, 0) == 1 and len(answers) == 1:
713
- return answers[0]
714
- if len(answers) >= block.occurrence_index:
715
- return answers[block.occurrence_index - 1]
716
- return ""
717
-
718
-
719
- def _validate_and_score(
720
- question: Dict[str, Any], duplicate: bool = False
721
- ) -> Dict[str, Any]:
722
- warnings: List[str] = []
723
- q_type = question["question_type"]
724
- answer = question.get("correct_answer") or ""
725
- options = question.get("options") or []
726
-
727
- if not question.get("question_text"):
728
- warnings.append("missing_question_text")
729
-
730
- if q_type in {"mcq", "msq"}:
731
- if not options:
732
- warnings.append("missing_options")
733
- elif len(options) < 4:
734
- warnings.append("too_few_options")
735
- elif len(options) > 4:
736
- warnings.append("too_many_options")
737
-
738
- if not answer:
739
- warnings.append("missing_answer")
740
- elif q_type == "nat" and not is_valid_nat_answer(answer):
741
- warnings.append("nat_has_non_numeric_answer")
742
- elif q_type == "mcq":
743
- tokens = _choice_tokens(answer)
744
- if len(tokens) > 1:
745
- warnings.append("mcq_has_multiple_answers")
746
- elif not tokens or tokens[0] not in {"A", "B", "C", "D"}:
747
- warnings.append("invalid_choice_answer")
748
- elif q_type == "msq":
749
- tokens = _choice_tokens(answer)
750
- if not tokens or any(token not in {"A", "B", "C", "D"} for token in tokens):
751
- warnings.append("invalid_choice_answer")
752
- elif len(tokens) == 1:
753
- warnings.append("msq_has_single_answer_but_allowed")
754
-
755
- if question.get("has_image"):
756
- warnings.append("image_context_required")
757
- if duplicate:
758
- warnings.append("duplicate_question_number_in_same_section")
759
- if question.get("_answer_source") is None and answer:
760
- warnings.append("ambiguous_answer_key")
761
-
762
- confidence = _confidence_score(question, warnings)
763
- if confidence < 0.7 and "low_confidence" not in warnings:
764
- warnings.append("low_confidence")
765
- confidence = _confidence_score(question, warnings)
766
-
767
- question["warnings"] = warnings
768
- question["confidence"] = confidence
769
- question["needs_review"] = (
770
- any(warning in REVIEW_WARNINGS for warning in warnings) or confidence < 0.7
771
- )
772
- question.pop("_type_found", None)
773
- question.pop("_marks_found", None)
774
- question.pop("_answer_source", None)
775
- return question
776
-
777
-
778
- def _confidence_score(question: Dict[str, Any], warnings: List[str]) -> float:
779
- score = 0.0
780
- q_type = question["question_type"]
781
- options = question.get("options") or []
782
- answer = question.get("correct_answer") or ""
783
-
784
- if question.get("question_text"):
785
- score += 0.2
786
- if question.get("_type_found"):
787
- score += 0.15
788
- elif q_type:
789
- score += 0.08
790
-
791
- if q_type == "nat":
792
- score += 0.15 if not options else 0.05
793
- elif len(options) == 4:
794
- score += 0.2
795
- elif 2 <= len(options) < 4:
796
- score += 0.1
797
-
798
- if answer:
799
- score += 0.25
800
- if question.get("_marks_found"):
801
- score += 0.1
802
- else:
803
- score += 0.05
804
-
805
- severe = {
806
- "missing_answer",
807
- "missing_options",
808
- "too_few_options",
809
- "too_many_options",
810
- "nat_has_non_numeric_answer",
811
- "mcq_has_multiple_answers",
812
- "invalid_choice_answer",
813
- "missing_question_text",
814
- }
815
- score -= 0.1 * sum(1 for warning in warnings if warning in severe)
816
- if "image_context_required" in warnings:
817
- score -= 0.05
818
- return round(max(0.0, min(1.0, score)), 2)
819
-
820
-
821
- def _infer_question_type(question_text: str, options: List[str], answer: str) -> str:
822
- text_type = _extract_question_type(question_text)
823
- if text_type:
824
- return text_type
825
- if options:
826
- return "msq" if len(_choice_tokens(answer)) > 1 else "mcq"
827
- if is_valid_nat_answer(answer) or re.search(
828
- r"\b(value|number|calculate|find|____|___)\b", question_text, re.IGNORECASE
829
- ):
830
- return "nat"
831
- return "mcq"
832
-
833
-
834
- def _extract_question_type(text: str) -> Optional[str]:
835
- for pattern, q_type in TYPE_ALIASES:
836
- if pattern.search(text or ""):
837
- return q_type
838
- return None
839
-
840
-
841
- def _normalize_question_type_label(value: str) -> Optional[str]:
842
- return _extract_question_type(value)
843
-
844
-
845
- def _normalize_answer(answer: str, question_type: str) -> str:
846
- if not answer:
847
- return ""
848
- if question_type in {"mcq", "msq"}:
849
- return _normalize_choice_answer(answer)
850
- return _normalize_nat_answer(answer)
851
-
852
-
853
- def _normalize_choice_answer(answer: str) -> str:
854
- text = _strip_answer_prefix(answer)
855
- match = LEADING_CHOICE_ANSWER_RE.match(text)
856
- if not match:
857
- return normalize_choice_answer(text)
858
-
859
- labels = []
860
- for token in CHOICE_TOKEN_RE.finditer(match.group("choices")):
861
- label = token.group(1).upper()
862
- if label not in labels:
863
- labels.append(label)
864
- return ",".join(labels)
865
-
866
-
867
- def _normalize_nat_answer(answer: str) -> str:
868
- text = _strip_answer_prefix(answer)
869
- if is_valid_nat_answer(text):
870
- return ",".join(token.replace(" ", "") for token in split_answer_tokens(text))
871
-
872
- values = []
873
- for segment in re.split(r"\s*[,;/]\s*", text):
874
- match = NAT_VALUE_RE.search(segment)
875
- if not match:
876
- continue
877
- value = match.group(0).replace(" ", "")
878
- value = re.sub(r"(?i)to", "-", value)
879
- if value and value not in values:
880
- values.append(value)
881
- return ",".join(values)
882
-
883
-
884
- def _strip_answer_prefix(answer: str) -> str:
885
- text = _clean_spaces(str(answer or ""))
886
- text = re.sub(
887
- r"^(?:correct\s+answer|answer|ans)\s*(?:is)?\s*[:.)\-]?\s*",
888
- "",
889
- text,
890
- flags=re.IGNORECASE,
891
- )
892
- return text.strip()
893
-
894
-
895
- def _choice_tokens(answer: str) -> List[str]:
896
- return [token.upper() for token in split_answer_tokens(answer)]
897
-
898
-
899
- def _latest_section_title(lines: List[str]) -> Optional[str]:
900
- for text in reversed(lines):
901
- if _looks_section_title(text):
902
- return _clean_spaces(text)
903
- return None
904
-
905
-
906
- def _looks_section_title(text: str) -> bool:
907
- value = _clean_spaces(text)
908
- if not value or len(value) > 120:
909
- return False
910
- if (
911
- _parse_question_boundary(value)
912
- or OPTION_MARKER_RE.match(value)
913
- or ANSWER_LINE_RE.search(value)
914
- ):
915
- return False
916
- lower = value.lower()
917
- if re.search(
918
- r"\b(section|chapter|topic|subject|part|test|quiz|dpp|module)\b", lower
919
- ):
920
- return True
921
- words = re.findall(r"[A-Za-z]+", value)
922
- return (
923
- 1 <= len(words) <= 10
924
- and value.upper() == value
925
- and any(len(word) > 2 for word in words)
926
- )
927
-
928
-
929
- def _clean_spaces(text: str) -> str:
930
- return re.sub(r"\s+", " ", str(text or "")).strip()
931
-
932
-
933
- def validate_json_questions(data: list) -> List[Dict[str, Any]]:
934
- normalized = []
935
- for q in data:
936
- q_type = str(q.get("question_type", "mcq")).lower()
937
- if q_type not in ("mcq", "msq", "nat"):
938
- q_type = "mcq"
939
-
940
- correct_answer = str(q.get("correct_answer", "")).strip()
941
- if q_type in {"mcq", "msq"}:
942
- correct_answer = normalize_choice_answer(correct_answer)
943
-
944
- normalized.append(
945
- {
946
- "question_type": q_type,
947
- "question_text": str(q.get("question_text", "")).strip(),
948
- "options": q.get("options", []),
949
- "correct_answer": correct_answer,
950
- "marks": float(q.get("marks", DEFAULT_MARKS[q_type])),
951
- "negative_marks": float(
952
- q.get("negative_marks", DEFAULT_NEGATIVE[q_type])
953
- ),
954
- "subject": q.get("subject"),
955
- "topic": q.get("topic"),
956
- }
957
- )
958
-
959
- return [q for q in normalized if q["question_text"]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/requirements.txt CHANGED
@@ -8,8 +8,6 @@ python-jose[cryptography]>=3.3.0
8
  passlib[bcrypt]>=1.7.4
9
  bcrypt==4.0.1
10
  python-multipart>=0.0.9
11
- pdfplumber>=0.11.0
12
- PyMuPDF>=1.24.3
13
  python-dotenv>=1.0.1
14
  pydantic[email]>=2.7.1
15
  pydantic-settings>=2.2.1
 
8
  passlib[bcrypt]>=1.7.4
9
  bcrypt==4.0.1
10
  python-multipart>=0.0.9
 
 
11
  python-dotenv>=1.0.1
12
  pydantic[email]>=2.7.1
13
  pydantic-settings>=2.2.1
backend/tests/test_admin_create_test_upload.py DELETED
@@ -1,132 +0,0 @@
1
- import os
2
- import tempfile
3
- import unittest
4
- from unittest.mock import patch
5
-
6
- from fastapi import FastAPI
7
- from fastapi.testclient import TestClient
8
- from sqlalchemy import create_engine
9
- from sqlalchemy.orm import sessionmaker
10
- from sqlalchemy.pool import StaticPool
11
-
12
- from app.api.deps import require_admin
13
- from app.api.routes.admin import router
14
- from app.core.config import settings
15
- from app.core.database import Base, get_db
16
- from app.models.models import Test, User, UserRole
17
-
18
-
19
- class AdminCreateTestUploadTests(unittest.TestCase):
20
- def setUp(self):
21
- self.original_upload_dir = settings.UPLOAD_DIR
22
- self.upload_dir = tempfile.TemporaryDirectory()
23
- settings.UPLOAD_DIR = self.upload_dir.name
24
- self.addCleanup(self.upload_dir.cleanup)
25
- self.addCleanup(self._restore_upload_dir)
26
-
27
- self.engine = create_engine(
28
- "sqlite:///:memory:",
29
- connect_args={"check_same_thread": False},
30
- poolclass=StaticPool,
31
- )
32
- Base.metadata.create_all(self.engine)
33
- self.SessionLocal = sessionmaker(bind=self.engine)
34
-
35
- db = self.SessionLocal()
36
- try:
37
- self.admin = User(email="admin@example.com", full_name="Admin", role=UserRole.admin)
38
- db.add(self.admin)
39
- db.commit()
40
- db.refresh(self.admin)
41
- finally:
42
- db.close()
43
-
44
- app = FastAPI()
45
- app.include_router(router)
46
-
47
- def override_get_db():
48
- db = self.SessionLocal()
49
- try:
50
- yield db
51
- finally:
52
- db.close()
53
-
54
- def override_require_admin():
55
- return self.admin
56
-
57
- app.dependency_overrides[get_db] = override_get_db
58
- app.dependency_overrides[require_admin] = override_require_admin
59
- self.client = TestClient(app)
60
-
61
- def _restore_upload_dir(self):
62
- settings.UPLOAD_DIR = self.original_upload_dir
63
-
64
- def test_create_test_uses_uuid_pdf_filename_for_traversal_upload_name(self):
65
- extracted_questions = [
66
- {
67
- "question_type": "mcq",
68
- "question_text": "Pick one.",
69
- "options": ["A", "B", "C", "D"],
70
- "correct_answer": "A",
71
- "marks": 1.0,
72
- "negative_marks": 0.33,
73
- }
74
- ]
75
-
76
- with patch("app.api.routes.admin.extract_questions_from_pdf", return_value=extracted_questions) as extract:
77
- response = self.client.post(
78
- "/admin/tests",
79
- data={"title": "Traversal test"},
80
- files={
81
- "pdf_file": (
82
- "../../../etc/cron.d/malicious.pdf",
83
- b"%PDF-1.4",
84
- "application/pdf",
85
- )
86
- },
87
- )
88
-
89
- self.assertEqual(response.status_code, 201)
90
-
91
- db = self.SessionLocal()
92
- try:
93
- test = db.query(Test).one()
94
- self.assertRegex(test.pdf_filename, r"^[0-9a-f]{32}\.pdf$")
95
- self.assertNotIn("malicious", test.pdf_filename)
96
- self.assertNotIn("..", test.pdf_filename)
97
- saved_path = os.path.join(settings.UPLOAD_DIR, test.pdf_filename)
98
- self.assertTrue(os.path.isfile(saved_path))
99
- self.assertEqual(os.path.dirname(os.path.abspath(saved_path)), os.path.abspath(settings.UPLOAD_DIR))
100
- finally:
101
- db.close()
102
-
103
- extract.assert_called_once()
104
- extracted_path = extract.call_args.args[0]
105
- self.assertRegex(os.path.basename(extracted_path), r"^[0-9a-f]{32}\.pdf$")
106
- self.assertEqual(os.path.dirname(os.path.abspath(extracted_path)), os.path.abspath(settings.UPLOAD_DIR))
107
-
108
- def test_create_test_rejects_pdf_upload_over_size_limit_and_removes_partial_file(self):
109
- with (
110
- patch("app.api.routes.admin.MAX_PDF_UPLOAD_SIZE_BYTES", 8),
111
- patch("app.api.routes.admin.extract_questions_from_pdf") as extract,
112
- ):
113
- response = self.client.post(
114
- "/admin/tests",
115
- data={"title": "Oversized PDF"},
116
- files={"pdf_file": ("oversized.pdf", b"%PDF-1.4 oversized", "application/pdf")},
117
- )
118
-
119
- self.assertEqual(response.status_code, 413)
120
- self.assertEqual(response.json()["detail"], "File too large")
121
- extract.assert_not_called()
122
- self.assertEqual(os.listdir(settings.UPLOAD_DIR), [])
123
-
124
- db = self.SessionLocal()
125
- try:
126
- self.assertEqual(db.query(Test).count(), 0)
127
- finally:
128
- db.close()
129
-
130
-
131
- if __name__ == "__main__":
132
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/tests/test_admin_pdf_review.py DELETED
@@ -1,41 +0,0 @@
1
- import unittest
2
-
3
- from app.api.routes.admin import _pdf_review_detail
4
-
5
-
6
- class AdminPDFReviewTests(unittest.TestCase):
7
- def test_non_blocking_review_warnings_do_not_reject_import(self):
8
- detail = _pdf_review_detail([
9
- {
10
- "question_number": 1,
11
- "needs_review": True,
12
- "warnings": ["image_context_required", "low_confidence"],
13
- "correct_answer": "A",
14
- }
15
- ])
16
-
17
- self.assertEqual(detail, "")
18
-
19
- def test_blocking_warnings_are_reported_with_question_numbers(self):
20
- detail = _pdf_review_detail([
21
- {
22
- "question_number": 1,
23
- "needs_review": True,
24
- "warnings": ["missing_answer", "low_confidence"],
25
- "correct_answer": "",
26
- },
27
- {
28
- "question_number": 2,
29
- "needs_review": True,
30
- "warnings": ["too_few_options"],
31
- "correct_answer": "B",
32
- },
33
- ])
34
-
35
- self.assertIn("2 have blocking issues", detail)
36
- self.assertIn("Q1: missing_answer", detail)
37
- self.assertIn("Q2: too_few_options", detail)
38
-
39
-
40
- if __name__ == "__main__":
41
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/tests/test_pdf_edge_cases.py DELETED
@@ -1,143 +0,0 @@
1
- import os
2
- import tempfile
3
- import pytest
4
- from app.services.pdf_service import extract_questions_from_pdf, _parse_gate_questions
5
-
6
- def _write_temp_file(content: bytes, suffix: str = ".pdf") -> str:
7
- fd, path = tempfile.mkstemp(suffix=suffix)
8
- os.write(fd, content)
9
- os.close(fd)
10
- return path
11
-
12
- def _create_empty_pdf() -> str:
13
- import fitz
14
- fd, path = tempfile.mkstemp(suffix=".pdf")
15
- os.close(fd)
16
- doc = fitz.open()
17
- doc.new_page()
18
- doc.save(path)
19
- doc.close()
20
- return path
21
-
22
- def _create_pdf_with_text(text: str) -> str:
23
- import fitz
24
- fd, path = tempfile.mkstemp(suffix=".pdf")
25
- os.close(fd)
26
- doc = fitz.open()
27
- page = doc.new_page()
28
- page.insert_text((50, 50), text, fontsize=10)
29
- doc.save(path)
30
- doc.close()
31
- return path
32
-
33
- def test_non_existent_file():
34
- assert extract_questions_from_pdf("/path/does/not/exist.pdf") == []
35
-
36
- def test_corrupted_pdf_file():
37
- path = _write_temp_file(b"This is not a pdf file")
38
- try:
39
- assert extract_questions_from_pdf(path) == []
40
- finally:
41
- os.remove(path)
42
-
43
- def test_empty_pdf():
44
- path = _create_empty_pdf()
45
- try:
46
- assert extract_questions_from_pdf(path) == []
47
- finally:
48
- os.remove(path)
49
-
50
- def test_pdf_with_only_whitespace():
51
- path = _create_pdf_with_text(" \n \t ")
52
- try:
53
- assert extract_questions_from_pdf(path) == []
54
- finally:
55
- os.remove(path)
56
-
57
- def test_very_long_question_text():
58
- long_text = "A" * 15000
59
- questions = _parse_gate_questions(f"Q.1 MCQ\n{long_text}\nA. a\nB. b\nC. c\nD. d\nAnswer: A")
60
- assert len(questions) == 1
61
- assert questions[0]["question_text"] == long_text
62
-
63
- def test_pdf_with_special_unicode_characters():
64
- text = "Q.1 MCQ\nCompute α + β ≈ 10.\nA. 10\nB. 20\nC. 30\nD. 40\nAnswer: A"
65
- questions = _parse_gate_questions(text)
66
- assert len(questions) == 1
67
- assert "α + β ≈ 10." in questions[0]["question_text"]
68
-
69
- def test_mixed_question_types():
70
- text = """
71
- Q.1 MCQ | +1 -0.33
72
- Choose.
73
- A. a
74
- B. b
75
- C. c
76
- D. d
77
- Answer: A
78
- Q.2 MSQ | +2 -0
79
- Choose all.
80
- A. a
81
- B. b
82
- C. c
83
- D. d
84
- Answer: A,B
85
- Q.3 NAT | +2 -0
86
- Enter value.
87
- Answer: 42
88
- """
89
- questions = _parse_gate_questions(text)
90
- assert len(questions) == 3
91
- assert questions[0]["question_type"] == "mcq"
92
- assert questions[1]["question_type"] == "msq"
93
- assert questions[2]["question_type"] == "nat"
94
-
95
- def test_missing_answer_keys():
96
- text = """
97
- Q.1 MCQ
98
- Choose.
99
- A. a
100
- B. b
101
- C. c
102
- D. d
103
- """
104
- questions = _parse_gate_questions(text)
105
- assert len(questions) == 1
106
- assert questions[0]["needs_review"] == True
107
- assert "missing_answer" in questions[0]["warnings"]
108
-
109
- def test_duplicate_question_numbers():
110
- text = """
111
- SECTION 1
112
- Q.1 MCQ
113
- A. a
114
- B. b
115
- C. c
116
- D. d
117
- Answer: A
118
- SECTION 2
119
- Q.1 MCQ
120
- A. a
121
- B. b
122
- C. c
123
- D. d
124
- Answer: B
125
- """
126
- questions = _parse_gate_questions(text)
127
- assert len(questions) == 2
128
- assert questions[0]["global_question_number"] == 1
129
- assert questions[1]["global_question_number"] == 2
130
-
131
- def test_malformed_option_labels():
132
- text = """
133
- Q.1 MCQ
134
- Find value.
135
- (a) first
136
- (b) second
137
- (c) third
138
- (d) fourth
139
- Answer: A
140
- """
141
- questions = _parse_gate_questions(text)
142
- assert len(questions) == 1
143
- assert questions[0]["options"] == ["first", "second", "third", "fourth"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/tests/test_pdf_service.py DELETED
@@ -1,267 +0,0 @@
1
- import unittest
2
-
3
- from app.services.pdf_service import _parse_gate_questions
4
-
5
-
6
- class PDFServiceParserTests(unittest.TestCase):
7
- def test_q_dot_format_with_inline_answer(self):
8
- questions = _parse_gate_questions(
9
- """
10
- Q.1 MCQ | +1 -0.33
11
- Choose the true statement.
12
- A. one
13
- B. two
14
- C. three
15
- D. four
16
- Answer: B
17
- """
18
- )
19
-
20
- self.assertEqual(len(questions), 1)
21
- self.assertEqual(questions[0]["question_type"], "mcq")
22
- self.assertEqual(questions[0]["correct_answer"], "B")
23
- self.assertEqual(questions[0]["negative_marks"], 0.33)
24
- self.assertFalse(questions[0]["needs_review"])
25
-
26
- def test_numbered_mcq_format(self):
27
- questions = _parse_gate_questions(
28
- """
29
- 1. [MCQ] Which option is correct?
30
- (a) alpha
31
- (b) beta
32
- (c) gamma
33
- (d) delta
34
- Correct Answer: c
35
- """
36
- )
37
-
38
- self.assertEqual(questions[0]["question_type"], "mcq")
39
- self.assertEqual(questions[0]["options"], ["alpha", "beta", "gamma", "delta"])
40
- self.assertEqual(questions[0]["correct_answer"], "C")
41
-
42
- def test_type_before_number_format(self):
43
- questions = _parse_gate_questions(
44
- """
45
- [MSQ] 1. Select valid propositions.
46
- A. p implies q
47
- B. q implies p
48
- C. p iff q
49
- D. not p
50
- Correct Answer: A;C
51
- """
52
- )
53
-
54
- self.assertEqual(questions[0]["question_type"], "msq")
55
- self.assertEqual(questions[0]["correct_answer"], "A,C")
56
-
57
- def test_separate_answer_key_is_merged(self):
58
- questions = _parse_gate_questions(
59
- """
60
- Q.1 MCQ | +1 -0.33
61
- Choose.
62
- A. x
63
- B. y
64
- C. z
65
- D. w
66
- Q.2 MSQ | +2 -0
67
- Pick all.
68
- A. a
69
- B. b
70
- C. c
71
- D. d
72
- Answer Key
73
- 1. B
74
- 2. A;C
75
- """
76
- )
77
-
78
- self.assertEqual([q["correct_answer"] for q in questions], ["B", "A,C"])
79
- self.assertEqual(questions[1]["question_type"], "msq")
80
-
81
- def test_choice_answers_with_option_prefix_and_explanations(self):
82
- questions = _parse_gate_questions(
83
- """
84
- Q.1 MCQ | +1 -0.33
85
- Choose.
86
- A. x
87
- B. y
88
- C. z
89
- D. w
90
- Answer: Option B
91
- Q.2 MCQ | +1 -0.33
92
- Choose again.
93
- A. x
94
- B. y
95
- C. z
96
- D. w
97
- Correct Answer: C. because z is correct
98
- Q.3 MSQ | +2 -0
99
- Pick all.
100
- A. a
101
- B. b
102
- C. c
103
- D. d
104
- Answer: A and C are correct
105
- """
106
- )
107
-
108
- self.assertEqual([q["correct_answer"] for q in questions], ["B", "C", "A,C"])
109
- self.assertFalse(any("invalid_choice_answer" in q["warnings"] for q in questions))
110
-
111
- def test_multiple_choice_answer_overrides_mcq_header(self):
112
- questions = _parse_gate_questions(
113
- """
114
- Q.1 MCQ | +1 -0.33
115
- Select the true statements.
116
- A. true
117
- B. false
118
- C. also true
119
- D. also also true
120
- Correct Answer: A;C;D Discuss
121
- """
122
- )
123
-
124
- self.assertEqual(questions[0]["question_type"], "msq")
125
- self.assertEqual(questions[0]["correct_answer"], "A,C,D")
126
- self.assertNotIn("mcq_has_multiple_answers", questions[0]["warnings"])
127
-
128
- def test_option_text_before_label_is_recovered(self):
129
- questions = _parse_gate_questions(
130
- """
131
- Q.1 MCQ | +1 -0.33
132
- If expression is false, values are respectively:
133
- F,T,F
134
- A.
135
- T,F,T
136
- B.
137
- T,T,T
138
- C.
139
- F,F,F
140
- D.
141
- Correct Answer: B Discuss
142
- Q.2 MCQ | +2 -0.67
143
- Identify expression.
144
- X$Y
145
- A.
146
- X$¬Y
147
- B.
148
- ¬X$Y
149
- C.
150
- D. none of the options
151
- Correct Answer: D Discuss
152
- """
153
- )
154
-
155
- self.assertEqual(questions[0]["options"], ["F,T,F", "T,F,T", "T,T,T", "F,F,F"])
156
- self.assertEqual(questions[1]["options"], ["X$Y", "X$¬Y", "¬X$Y", "none of the options"])
157
- self.assertFalse(any("too_few_options" in q["warnings"] for q in questions))
158
-
159
- def test_nat_numeric_answer_and_range(self):
160
- questions = _parse_gate_questions(
161
- """
162
- Q1 NAT | +2 -0
163
- The value is ____.
164
- Answer: -2--1
165
- Q2 Numerical answer type | +1 -0
166
- Enter the count.
167
- Answer: 4,5
168
- """
169
- )
170
-
171
- self.assertEqual([q["question_type"] for q in questions], ["nat", "nat"])
172
- self.assertEqual(questions[0]["correct_answer"], "-2--1")
173
- self.assertEqual(questions[1]["correct_answer"], "4,5")
174
- self.assertFalse(questions[0]["needs_review"])
175
-
176
- def test_nat_answer_with_units_is_normalized(self):
177
- questions = _parse_gate_questions(
178
- """
179
- Q.1 NAT | +1 -0
180
- Count the valid rows.
181
- Answer: 4 combinations
182
- Q.2 NAT | +1 -0
183
- Give the accepted interval.
184
- Correct Answer: -2 to -1 approximately
185
- """
186
- )
187
-
188
- self.assertEqual([q["correct_answer"] for q in questions], ["4", "-2--1"])
189
- self.assertFalse(any("nat_has_non_numeric_answer" in q["warnings"] for q in questions))
190
-
191
- def test_marks_from_section_range(self):
192
- questions = _parse_gate_questions(
193
- """
194
- Q.1 to Q.5 carry one mark each
195
- Q.1 MCQ
196
- Choose.
197
- A. a
198
- B. b
199
- C. c
200
- D. d
201
- Answer: A
202
- """
203
- )
204
-
205
- self.assertEqual(questions[0]["marks"], 1.0)
206
-
207
- def test_missing_answer_is_not_guessed_as_a(self):
208
- questions = _parse_gate_questions(
209
- """
210
- Q.1 MCQ | +1 -0.33
211
- Choose.
212
- A. a
213
- B. b
214
- C. c
215
- D. d
216
- """
217
- )
218
-
219
- self.assertEqual(questions[0]["correct_answer"], "")
220
- self.assertIn("missing_answer", questions[0]["warnings"])
221
- self.assertTrue(questions[0]["needs_review"])
222
-
223
- def test_repeated_question_numbers_across_sections(self):
224
- questions = _parse_gate_questions(
225
- """
226
- SECTION A
227
- Q.1 MCQ
228
- Choose A.
229
- A. a
230
- B. b
231
- C. c
232
- D. d
233
- Answer: A
234
- SECTION B
235
- Q.1 MCQ
236
- Choose B.
237
- A. a
238
- B. b
239
- C. c
240
- D. d
241
- Answer: B
242
- """
243
- )
244
-
245
- self.assertEqual(len(questions), 2)
246
- self.assertEqual([q["global_question_number"] for q in questions], [1, 2])
247
- self.assertEqual([q["correct_answer"] for q in questions], ["A", "B"])
248
-
249
- def test_image_context_and_low_confidence_detection(self):
250
- questions = _parse_gate_questions(
251
- """
252
- Q.1 MCQ
253
- In the following figure, choose the output.
254
- A. zero
255
- B. one
256
- Answer: A
257
- """
258
- )
259
-
260
- self.assertTrue(questions[0]["has_image"])
261
- self.assertIn("image_context_required", questions[0]["warnings"])
262
- self.assertIn("too_few_options", questions[0]["warnings"])
263
- self.assertTrue(questions[0]["needs_review"])
264
-
265
-
266
- if __name__ == "__main__":
267
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/api/api.js CHANGED
@@ -19,7 +19,7 @@ export const adminAPI = {
19
  createPasswordReset: (id) => api.post(`/admin/users/${id}/password-reset`),
20
  getTests: () => api.get('/admin/tests'),
21
  getTest: (id) => api.get(`/admin/tests/${id}`),
22
- createTest: (form) => api.post('/admin/tests', form, { headers: { 'Content-Type': 'multipart/form-data' } }),
23
  deleteTest: (id) => api.delete(`/admin/tests/${id}`),
24
  updateTest: (id, data) => api.patch(`/admin/tests/${id}`, data),
25
  getQuestions: (testId) => api.get(`/admin/tests/${testId}/questions`),
 
19
  createPasswordReset: (id) => api.post(`/admin/users/${id}/password-reset`),
20
  getTests: () => api.get('/admin/tests'),
21
  getTest: (id) => api.get(`/admin/tests/${id}`),
22
+ createTest: (data) => api.post('/admin/tests', data),
23
  deleteTest: (id) => api.delete(`/admin/tests/${id}`),
24
  updateTest: (id, data) => api.patch(`/admin/tests/${id}`, data),
25
  getQuestions: (testId) => api.get(`/admin/tests/${testId}/questions`),
frontend/src/pages/AdminTests.jsx CHANGED
@@ -21,9 +21,7 @@ function CreateTestModal({ onClose, onCreated }) {
21
  title: '', description: '', duration_minutes: 180,
22
  category: '', series_name: '', test_type: '', subject: ''
23
  })
24
- const [pdf, setPdf] = useState(null)
25
  const [loading, setLoading] = useState(false)
26
- const fileRef = useRef()
27
 
28
  const isWeeklyQuiz = form.category === 'weekly_quiz'
29
  const isTestSeries = form.category === 'test_series'
@@ -42,18 +40,8 @@ function CreateTestModal({ onClose, onCreated }) {
42
 
43
  setLoading(true)
44
  try {
45
- const fd = new FormData()
46
- fd.append('title', form.title)
47
- if (form.description) fd.append('description', form.description)
48
- fd.append('duration_minutes', form.duration_minutes)
49
- fd.append('category', form.category)
50
- if (form.series_name) fd.append('series_name', form.series_name)
51
- if (form.test_type) fd.append('test_type', form.test_type)
52
- if (form.subject) fd.append('subject', form.subject)
53
- if (pdf) fd.append('pdf_file', pdf)
54
-
55
- const res = await adminAPI.createTest(fd)
56
- toast.success(`Test created!${res.data.question_count > 0 ? ` Extracted ${res.data.question_count} questions.` : ''}`)
57
  onCreated(res.data)
58
  } catch (err) {
59
  toast.error(err.response?.data?.detail || 'Failed to create test')
@@ -164,29 +152,7 @@ function CreateTestModal({ onClose, onCreated }) {
164
  </>
165
  )}
166
 
167
- {/* PDF */}
168
- <div className="border-t pt-4" style={{ borderColor: 'var(--border)' }}>
169
- <label className="label">Upload PDF (optional)</label>
170
- <div role="button" tabIndex={0} onClick={() => fileRef.current?.click()}
171
- onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileRef.current?.click(); } }}
172
- className="border-2 border-dashed rounded-lg p-4 text-center cursor-pointer transition-colors hover:border-sky-500/40 focus-visible:ring-2 focus-visible:ring-sky-500 outline-none"
173
- style={{ borderColor: 'var(--border)' }}>
174
- {pdf ? (
175
- <div className="flex items-center justify-center gap-2 text-sky-400 text-sm">
176
- <FileText size={15} />{pdf.name}
177
- <button type="button" onClick={e => { e.stopPropagation(); setPdf(null) }}
178
- aria-label="Remove PDF" className="text-red-400"><X size={13} /></button>
179
- </div>
180
- ) : (
181
- <div className="text-sm" style={{ color: 'var(--text-muted)' }}>
182
- <Upload size={16} className="mx-auto mb-1" />
183
- Click to select PDF
184
- </div>
185
- )}
186
- </div>
187
- <input type="file" accept=".pdf" ref={fileRef} className="hidden"
188
- onChange={e => setPdf(e.target.files[0] || null)} />
189
- </div>
190
 
191
  <div className="flex gap-3">
192
  <button type="button" onClick={onClose} className="btn-ghost flex-1">Cancel</button>
 
21
  title: '', description: '', duration_minutes: 180,
22
  category: '', series_name: '', test_type: '', subject: ''
23
  })
 
24
  const [loading, setLoading] = useState(false)
 
25
 
26
  const isWeeklyQuiz = form.category === 'weekly_quiz'
27
  const isTestSeries = form.category === 'test_series'
 
40
 
41
  setLoading(true)
42
  try {
43
+ const res = await adminAPI.createTest(form)
44
+ toast.success('Test created!')
 
 
 
 
 
 
 
 
 
 
45
  onCreated(res.data)
46
  } catch (err) {
47
  toast.error(err.response?.data?.detail || 'Failed to create test')
 
152
  </>
153
  )}
154
 
155
+
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
  <div className="flex gap-3">
158
  <button type="button" onClick={onClose} className="btn-ghost flex-1">Cancel</button>