diff --git a/gemini_for_enonce.py b/gemini_for_enonce.py index ecb71a9..0554b6d 100644 --- a/gemini_for_enonce.py +++ b/gemini_for_enonce.py @@ -42,11 +42,32 @@ class ExamSolutions(BaseModel): # --- Modèles pour la Requête 3 --- class ExtractedContext(BaseModel): target_question_label: str = Field(description="The exact label of the FIRST question that comes immediately AFTER this information in the exam.") + last_question_label: str = Field(description="The exact label of the LAST question that uses or relies on this information.") context_content: str = Field(description="The source text of the definitions, notations, or hypotheses, extracted from the enonce.") class ExamContext(BaseModel): contexts: List[ExtractedContext] +# --- Modèles pour la Requête 4 (Barèmes) --- +class RubricItem(BaseModel): + label: str = Field(description="The exact label of the question.") + rubric_content: str = Field(description="Le barème détaillé en français.") + +class GroupRubrics(BaseModel): + rubrics: List[RubricItem] + +PROMPT_4 = """Je te fournis les questions, le contexte éventuel, et les corrections pour un groupe de questions d'un examen. +Ta tâche : +Établir un barème de correction détaillé en français pour CHAQUE question. +Chaque question DOIT être notée sur exactement 4 points. Propose une répartition logique de ces points. +Par exemple : + - Au moins 2 points si le résultat est correct. + - Mettre la moitié des points si le raisonnement est correct mais pas le résultat. + - Retirer 1.5 points si les hypothèses d'un théorème ou d'une question précédente ne sont pas vérifiées. + +Renvoie le résultat sous forme de liste JSON correspondant aux labels des questions fournies. +""" + # --- Modèle fusionné (pour le reste du script) --- class QuestionItem(BaseModel): label: str @@ -54,6 +75,8 @@ class QuestionItem(BaseModel): solution_content: str class ContextItem(BaseModel): + target_question_label: str + last_question_label: str content: str # Juste une string encapsulée pour le différencier facilement class ExamExtraction(BaseModel): @@ -91,7 +114,9 @@ PROMPT_3 = """I am providing: Your task: Extract important information necessary to understand the questions (e.g., definitions of objects, global notations, hypotheses, context) that are NOT part of the question texts themselves. - For each extracted piece of information, identify the label of the FIRST question that comes immediately AFTER this information in the exam. + For each extracted piece of information, identify: + 1. The label of the FIRST question that comes immediately AFTER this information in the exam. + 2. The label of the LAST question that uses or relies on this information. Return the result as a JSON list. """ @@ -247,21 +272,45 @@ def process_exam(folder_path: str): # ========================================== sol_map = {s.label: s.solution_content for s in solutions_data.solutions} + + # Map labels to their index to validate ordering + label_to_idx = {q.label: i for i, q in enumerate(questions_data.questions)} + + for c in context_data.contexts: + first_idx = label_to_idx.get(c.target_question_label, -1) + last_idx = label_to_idx.get(c.last_question_label, -1) + + # Enforce LAST is after (or equal to) FIRST + if first_idx != -1 and last_idx != -1 and last_idx < first_idx: + print(f"Warning: LAST question ({c.last_question_label}) is before FIRST ({c.target_question_label}). Fixing.") + c.last_question_label = c.target_question_label + elif last_idx == -1: # Fallback if invalid + c.last_question_label = c.target_question_label + # Grouper les contextes par label cible ctx_map = {} for c in context_data.contexts: if c.target_question_label in ctx_map: - ctx_map[c.target_question_label] += "\n\n" + c.context_content + ctx_map[c.target_question_label]['content'] += "\n\n" + c.context_content + # Keep the furthest LAST question label + curr_last = ctx_map[c.target_question_label]['last'] + if label_to_idx.get(c.last_question_label, -1) > label_to_idx.get(curr_last, -1): + ctx_map[c.target_question_label]['last'] = c.last_question_label else: - ctx_map[c.target_question_label] = c.context_content + ctx_map[c.target_question_label] = { + 'content': c.context_content, + 'last': c.last_question_label + } merged_items = [] for q in questions_data.questions: - # 1. S'il y a un contexte pour cette question, on l'insère d'abord dans la liste if q.label in ctx_map: - merged_items.append(ContextItem(content=ctx_map[q.label])) + merged_items.append(ContextItem( + target_question_label=q.label, + last_question_label=ctx_map[q.label]['last'], + content=ctx_map[q.label]['content'] + )) - # 2. Puis on ajoute la question sol_content = sol_map.get(q.label, "") merged_items.append(QuestionItem( label=q.label, @@ -328,9 +377,11 @@ def process_exam(folder_path: str): # ---- Transform labels, and check uniqueness seen_labels = set() + label_updates = {} for group in initial_groups: for item in group: if isinstance(item, QuestionItem): + orig_label = item.label # 1. Transform label item.label = item.label.replace("Exercice", "Ex") item.label = item.label.replace(".", ")") @@ -339,7 +390,14 @@ def process_exam(folder_path: str): while item.label in seen_labels: item.label = f"XX{item.label}" seen_labels.add(item.label) + label_updates[orig_label] = item.label + # Sync the modified labels to ContextItem + for group in initial_groups: + for item in group: + if isinstance(item, ContextItem): + item.target_question_label = label_updates.get(item.target_question_label, item.target_question_label) + item.last_question_label = label_updates.get(item.last_question_label, item.last_question_label) # --- WRITE TEXT FILES --- print(f"Writing items files to {items_file.name} and {full_items_file.name}...") @@ -438,7 +496,8 @@ def process_exam(folder_path: str): edited_content = edited_content_raw.replace(' \\n ', '\n') if orig_idx < len(extracted_data.items): - orig_item = extracted_data.items[orig_idx] + orig_item = extracted_data.items[orig_idx] # <-- Add this line + if isinstance(orig_item, QuestionItem): current_group.append(QuestionItem( label=new_label, @@ -446,7 +505,11 @@ def process_exam(folder_path: str): solution_content=orig_item.solution_content )) elif isinstance(orig_item, ContextItem): - current_group.append(ContextItem(content=edited_content)) + current_group.append(ContextItem( + target_question_label=orig_item.target_question_label, + last_question_label=orig_item.last_question_label, + content=edited_content + )) orig_idx += 1 if current_group: @@ -467,7 +530,8 @@ def process_exam(folder_path: str): sol_dir = folder / "Sol" text2_dir = folder / "Text2" sol2_dir = folder / "Sol2" - dirs = [text_dir, sol_dir, text2_dir, sol2_dir] + persp_dir = folder / "Persp" + dirs = [text_dir, sol_dir, text2_dir, sol2_dir, persp_dir] import shutil # Ask only if any directory already exists @@ -502,6 +566,47 @@ def process_exam(folder_path: str): if not labels: continue # Skip if a group has no questions (only contexts) + # ========================================== + # REQUÊTE 4 : Génération du Barème pour le groupe + # ========================================== + group_text_parts = [] + for item in group: + if isinstance(item, QuestionItem): + group_text_parts.append(f"Question [{item.label}]:\n{item.question_content}\nCorrection [{item.label}]:\n{item.solution_content}") + elif isinstance(item, ContextItem): + group_text_parts.append(f"Contexte (Cible: {item.target_question_label}):\n{item.content}") + + group_context_text = "\n\n---\n\n".join(group_text_parts) + + contents_4 = [ + types.Content( + role="user", + parts=[ + types.Part.from_text(text=PROMPT_4), + types.Part.from_text(text=f"--- CONTENU DU GROUPE ---\n{group_context_text}"), + ], + ) + ] + + config_4 = types.GenerateContentConfig( + temperature=0.2, + response_mime_type="application/json", + response_json_schema=GroupRubrics.model_json_schema(), + ) + + print(f"Generating rubric (Persp) for group: {', '.join(labels)}...") + try: + response_r = client.models.generate_content( + model=MODEL_ID, + contents=contents_4, + config=config_4 + ) + rubrics_data = GroupRubrics.model_validate_json(response_r.text) + rubrics_map = {r.label: r.rubric_content for r in rubrics_data.rubrics} + except Exception as e: + print(f"Error generating rubric for group {labels[0]}: {e}") + rubrics_map = {} + # 1. Compute the common prefix for the group prefix = labels[0] for lbl in labels[1:]: @@ -536,12 +641,30 @@ def process_exam(folder_path: str): with open(sol2_dir / f"{safe_label}.tex", "w", encoding="utf-8") as f_s2: f_s2.write(f"\\textbf{{{item.label}}} {item.solution_content}") + + # --- Écriture du Barème (Persp) --- + rubric_text = rubrics_map.get(item.label, "") + with open(persp_dir / safe_label, "w", encoding="utf-8") as f_persp: + f_persp.write(f"{item.label}\n{rubric_text}") + elif isinstance(item, ContextItem): raw_ctx = item.content.strip() tabulated_ctx = "\t" + re.sub(r'\n\s*', '\n\t', raw_ctx) text_content_lines.append(f"CONTEXT :") text_content_lines.append(tabulated_ctx) + # --- Save context to Text2 (Concatenating if exists) --- + safe_first = item.target_question_label.replace("/", "_") + safe_last = item.last_question_label.replace("/", "_") + ctxt_filename = f"CTXT {safe_first} -> {safe_last}.tex" + ctxt_path = text2_dir / ctxt_filename + + # If file exists, prepend some spacing before appending + prefix = "\n\n" if ctxt_path.exists() else "" + + with open(ctxt_path, "a", encoding="utf-8") as f_c2: + f_c2.write(prefix + item.content) + # 4. Write the grouped Text file with open(text_dir / safe_group_filename, "w", encoding="utf-8") as f_text: f_text.write("\n".join(text_content_lines)) diff --git a/prompting.py b/prompting.py index a842cbe..a1815ba 100644 --- a/prompting.py +++ b/prompting.py @@ -42,7 +42,7 @@ unless the distinction is very important. In some case, you may find that either - The student didn't answer the right question. Set the score to 0. - Since it could be a labeling error, indicate is by setting `error` + Since it could be a labeling error, indicate it by setting `error` to \"wrong-label\". - You can find an answer to another question of the exercice (taking more than a couple of lines). Score the question you are supposed diff --git a/reading_grouped_annotations.py b/reading_grouped_annotations.py index d5837a3..6c6aca9 100644 --- a/reading_grouped_annotations.py +++ b/reading_grouped_annotations.py @@ -9,14 +9,15 @@ import threading import annotating -from utils import natural_key, pdf_image_of_enonce, pdf_image_of_solution +from utils import natural_key, pdf_image_of_enonce, pdf_image_of_solution, pdf_images_of_contexts from reading_annotations import detect_checks_and_notes, has_significant_notes -def get_extra_pdfs_as_images(root_dir, label, annotating_module): +def get_extra_pdfs_as_images(root_dir, label, annotating_module, all_labels): """Fetches Text and Sol pdfs for a given label and converts them to images.""" extra_images = [] a, b = pdf_image_of_enonce(root_dir, label), pdf_image_of_solution(root_dir, label) - for c in [a, b]: + e = pdf_images_of_contexts(root_dir, label, all_labels) + for c in e + [a, b]: if c: img, _, _ = annotating_module.make_base_image(c) if img: @@ -239,7 +240,7 @@ def apply_actions_and_regenerate_grouped(root_dir, data, student_id, perfect_no_comment = False if not perfect_no_comment or has_notes: - extras = get_extra_pdfs_as_images(root_dir, label, annotating) + extras = get_extra_pdfs_as_images(root_dir, label, annotating, all_labels) extras.append(final_img) concat_list_F.append(extras) diff --git a/utils.py b/utils.py index 88fb3d8..1e6ac73 100644 --- a/utils.py +++ b/utils.py @@ -34,6 +34,43 @@ def pdf_image_of_solution(root_dir, label): if os.path.exists(pdf_path): return pdf_path +def pdf_images_of_contexts(root_dir, label, all_labels): + text2_dir = os.path.join(root_dir, "Text2") + if not os.path.isdir(text2_dir): + return [] + + # Map safe labels (with '/' replaced by '_') to their chronological index + safe_to_idx = {l.replace("/", "_"): i for i, l in enumerate(all_labels)} + + safe_target = label.replace("/", "_") + target_idx = safe_to_idx.get(safe_target, -1) + + if target_idx == -1: + return [] + + pertinent_contexts = [] + + for filename in os.listdir(text2_dir): + if filename.startswith("CTXT ") and filename.endswith(".pdf"): + # Extract "first_label -> last_label" from "CTXT first_label -> last_label.pdf" + core = filename[5:-4] + parts = core.split(" -> ") + + if len(parts) == 2: + first_safe, last_safe = parts + first_idx = safe_to_idx.get(first_safe, -1) + last_idx = safe_to_idx.get(last_safe, -1) + + # Check if the current label falls within the context's validity range + if first_idx != -1 and last_idx != -1: + if first_idx <= target_idx <= last_idx: + pdf_path = os.path.join(text2_dir, filename) + pertinent_contexts.append((first_idx, pdf_path)) + + # Sort by first_idx to ensure contexts are returned in logical reading order + pertinent_contexts.sort(key=lambda x: x[0]) + return [path for _, path in pertinent_contexts] + def get_exam_file_content(folder_path, mode, label): """