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