File size: 4,280 Bytes
8d3145b
 
 
 
 
 
bc45e54
 
 
 
 
 
 
8d3145b
 
 
bc45e54
 
 
 
 
 
 
 
8d3145b
bc45e54
8d3145b
bc45e54
0641d27
 
 
8d3145b
 
bc45e54
8d3145b
 
 
 
 
 
bc45e54
8d3145b
 
bc45e54
0641d27
 
 
 
 
8d3145b
0641d27
 
bc45e54
 
0641d27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8d3145b
 
0641d27
8d3145b
 
 
 
 
 
 
 
 
 
 
 
bc45e54
0641d27
 
8d3145b
 
 
 
 
 
 
bc45e54
 
8d3145b
 
bc45e54
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
"""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 json

from fastapi import UploadFile

from .database import delete_parsed_data, save_parsed_data
from .parsers import AUTO, get_parser, pick_auto
from .parsers.base import ParserFile, ParserResult


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)
        )

    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)
    await _persist(session_id, data_type, parser_files, result)
    return _response_payload(result)


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
    )
    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)

    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:
    await delete_parsed_data(session_id, data_type)
    raw_text = json.dumps(
        {
            "source_files": [f.filename for f in parser_files],
            "parser": result.parser_name,
            "notes": list(result.notes),
        },
        ensure_ascii=False,
    )
    await save_parsed_data(
        session_id,
        data_type,
        parser_files[0].filename,
        raw_text,
        result.data,
    )


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)