Spaces:
Running
Running
taboola
label image questions in report, warn on incomplete download, changes to report aesthetics
8bbeab6 | """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 | |
| from fastapi import UploadFile | |
| from .answer_grid import seed_from_parsed, to_dict as grid_to_dict | |
| from .database import delete_parsed_data, get_parsed_data, save_answer_grid, save_parsed_data | |
| from .parsers import AUTO, get_parser, pick_auto | |
| from .parsers.base import ParserFile, ParserResult | |
| logger = logging.getLogger(__name__) | |
| 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) | |
| # 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) | |