File size: 8,584 Bytes
8d3145b
 
 
 
 
 
bc45e54
 
 
a43df97
25cb9ab
 
a43df97
bc45e54
 
55cde2f
25cb9ab
e07612c
 
 
 
 
25cb9ab
e07612c
8d3145b
 
bc45e54
a43df97
 
25cb9ab
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bc45e54
 
 
 
 
 
 
8d3145b
bc45e54
8d3145b
bc45e54
0641d27
 
 
8d3145b
 
bc45e54
8d3145b
 
 
 
 
 
25cb9ab
7986ac3
 
 
 
 
25cb9ab
bc45e54
8d3145b
 
bc45e54
0641d27
 
 
 
 
e07612c
 
 
7b8d5bb
0641d27
 
bc45e54
 
7b8d5bb
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0641d27
 
 
 
 
 
 
 
 
 
7b8d5bb
0641d27
 
 
 
 
 
 
8ef8427
b21ef71
 
 
 
8ef8427
 
0641d27
 
 
 
 
 
 
 
 
 
 
 
80a09a3
 
 
a43df97
 
 
 
 
 
 
 
 
 
80a09a3
 
 
a43df97
80a09a3
 
 
8bbeab6
80a09a3
 
 
 
e07612c
 
 
80a09a3
 
 
 
 
 
 
 
 
 
 
 
 
8bbeab6
80a09a3
 
bc45e54
0641d27
 
8d3145b
 
 
 
 
 
 
bc45e54
 
8d3145b
 
bc45e54
8d3145b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e07612c
 
 
 
8d3145b
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
"""File processing orchestrator.

Delegates extraction to a pluggable parser chosen by name or auto-picked.
Saves the parsed structured data to the database, along with metadata about
which parser produced it.
"""

from __future__ import annotations

import logging
import uuid
from pathlib import Path

from fastapi import UploadFile

from .answer_grid import seed_from_parsed, to_dict as grid_to_dict
from .config import get_settings
from .database import (
    delete_parsed_data,
    get_parsed_data,
    save_answer_grid,
    save_parsed_data,
    save_raw_file,
)
from .parsers import AUTO, get_parser, pick_auto
from .parsers.base import ParserFile, ParserResult

logger = logging.getLogger(__name__)

# Original uploaded scans/PDFs, kept so a teacher/admin can view the source
# file later — separate from ParsedData, which only holds the OCR-extracted
# structured fields.
_UPLOAD_DIR = Path(__file__).parent.parent / "uploads"


async def _save_raw_file(
    session_id: int, data_type: str, filename: str, content_type: str, content: bytes
) -> None:
    settings = get_settings()
    base_dir = Path(settings.upload_dir) if settings.upload_dir else _UPLOAD_DIR
    quiz_dir = base_dir / str(session_id)
    quiz_dir.mkdir(parents=True, exist_ok=True)

    ext = Path(filename).suffix
    stored_name = f"{uuid.uuid4().hex}{ext}"
    dest = quiz_dir / stored_name
    dest.write_bytes(content)

    await save_raw_file(
        quiz_id=session_id,
        data_type=data_type,
        file_name=filename,
        content_type=content_type,
        size_bytes=len(content),
        storage_path=str(dest),
    )


async def process_uploaded_files(
    files: list[UploadFile],
    data_type: str,
    session_id: int,
    description: str = "",
    model: str = "gpt-5.4",
    parser: str = AUTO,
) -> dict:
    """Parse uploaded files and persist the structured result.

    `data_type='answers'` triggers a dual extraction: the same files are parsed
    twice (once for student_answers, once for teacher_answers) and both rows are
    stored. The response merges both shapes under their respective keys.
    """
    parser_files: list[ParserFile] = []
    for file in files:
        content = await file.read()
        if not content:
            raise ValueError(f"File '{file.filename}' is empty.")
        parser_files.append(
            ParserFile(filename=file.filename or "unknown", content=content)
        )
        await _save_raw_file(
            session_id,
            data_type,
            file.filename or "unknown",
            file.content_type or "",
            content,
        )

    if not parser_files:
        raise ValueError("No files uploaded")

    if data_type == "answers":
        return await _process_combined_answers(
            parser_files, session_id, description, model, parser
        )

    result = await _run_with_fallback(
        parser_files, data_type, description, model, parser
    )
    _validate_extraction(data_type, result.data)
    await _persist(session_id, data_type, parser_files, result)
    return _response_payload(result)


def _validate_extraction(data_type: str, data: dict) -> None:
    """Reject an extraction that "succeeded" but found nothing — the model is
    instructed to return an empty list rather than fabricate data when it
    can't read the content (see NO_FABRICATION_RULE), so a bad scan or wrong
    file doesn't raise on its own; without this check it silently produces an
    unusable session that only surfaces later as an unexplained dead end in
    step 2. Only `student_answers` is checked (never legitimately empty) —
    `questions`/`teacher_answers` can legitimately be uploaded separately or
    filled in later via the answer grid editor, so an empty result there is
    not necessarily a failure.
    """
    if data_type == "student_answers" and not data.get("students"):
        raise ValueError(
            "No students could be extracted from this file. The scan may be "
            "blurry, blank, or not an answer sheet — please check the file "
            "and try again."
        )


async def _process_combined_answers(
    parser_files: list[ParserFile],
    session_id: int,
    description: str,
    model: str,
    parser: str,
) -> dict:
    student_result = await _run_with_fallback(
        parser_files, "student_answers", description, model, parser
    )
    _validate_extraction("student_answers", student_result.data)
    teacher_result = await _run_with_fallback(
        parser_files, "teacher_answers", description, model, parser
    )

    await _persist(session_id, "student_answers", parser_files, student_result)
    await _persist(session_id, "teacher_answers", parser_files, teacher_result)

    # Seed and save a draft answer grid so step 2 has all data (students + correct answers).
    # Use only the answers extracted from teacher_result to avoid inheriting the question count
    # from a separately uploaded questions PDF (which may have a different total).
    t_data = teacher_result.data
    draft_grid = seed_from_parsed({}, student_result.data, t_data)
    await save_answer_grid(session_id, grid_to_dict(draft_grid))

    return {
        "student_answers": _response_payload(student_result),
        "teacher_answers": _response_payload(teacher_result),
    }


async def _persist(
    session_id: int,
    data_type: str,
    parser_files: list[ParserFile],
    result: ParserResult,
) -> None:
    if data_type == "questions":
        await delete_parsed_data(session_id, data_type)
        for q in result.data.get("questions", []):
            text = (q.get("text", "") or "").strip()
            options = q.get("options") or []
            if not text and not options:
                logger.warning(
                    "session %d: question %s extracted with no text and no "
                    "options — likely a failed extraction, not a legitimate "
                    "picture-only question",
                    session_id,
                    q.get("number", "?"),
                )
            await save_parsed_data(
                session_id,
                q.get("number", 0),
                text,
                "",
                "",
                [],
                options=options,
            )
    elif data_type == "teacher_answers":
        existing = {r["question_num"]: r for r in await get_parsed_data(session_id)}
        answers_map = {
            a.get("question_number", 0): (
                a.get("correct_answer") or a.get("answer") or ""
            )
            for a in result.data.get("answers", [])
        }
        all_nums = sorted(set(existing) | set(answers_map))
        await delete_parsed_data(session_id, data_type)
        for qnum in all_nums:
            row = existing.get(qnum, {})
            await save_parsed_data(
                session_id,
                qnum,
                row.get("question_str", ""),
                answers_map.get(qnum, row.get("answer", "")),
                row.get("main_category", ""),
                row.get("tags", []),
                options=row.get("options", []),
            )
    # student_answers are not stored in ParsedData


def _response_payload(result: ParserResult) -> dict:
    return {
        **result.data,
        "_meta": {
            "parser": result.parser_name,
            "notes": list(result.notes),
        },
    }


async def _run_with_fallback(
    parser_files: list[ParserFile],
    data_type: str,
    description: str,
    model: str,
    parser_name: str,
) -> ParserResult:
    """Run the selected parser; in auto mode, fall back to LLM on failure."""
    if parser_name == AUTO:
        first_file = parser_files[0]
        chosen = pick_auto(first_file.content, first_file.filename, data_type)
        try:
            return await chosen.parse(parser_files, data_type, description, model)
        except Exception as primary_err:
            if chosen.name == "llm_vision":
                raise
            # Fallback to LLM vision on any native-parser failure
            llm = get_parser("llm_vision")
            result = await llm.parse(parser_files, data_type, description, model)
            return ParserResult(
                data=result.data,
                parser_name=result.parser_name,
                notes=(
                    f"auto-fallback from {chosen.name}: {primary_err}",
                    *result.notes,
                ),
            )

    chosen = get_parser(parser_name)
    return await chosen.parse(parser_files, data_type, description, model)