fichiers oubliés
This commit is contained in:
1 parent
d048732613
commit
857efcef18
8 files changed
+722
No files matched your search
@@ -0,0 +1,28 @@
|
|||||||
|
"""Provenance of shared answer content, independent of Gemini corrections."""
|
||||||
|
from hashlib import sha256
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .json_io import atomic_update_json, read_json
|
||||||
|
|
||||||
|
|
||||||
|
def _path(root):
|
||||||
|
return Path(root) / ".copienator" / "answer_origins.json"
|
||||||
|
|
||||||
|
|
||||||
|
def origins_for(root, copy_id, label, pdf):
|
||||||
|
entry = read_json(_path(root), default={}).get(str(copy_id), {}).get(label, {})
|
||||||
|
if not entry or not Path(pdf).is_file():
|
||||||
|
return []
|
||||||
|
if entry.get("sha256") != sha256(Path(pdf).read_bytes()).hexdigest():
|
||||||
|
return []
|
||||||
|
return entry.get("labels", [])
|
||||||
|
|
||||||
|
|
||||||
|
def save_origins(root, copy_id, label, pdf, origins):
|
||||||
|
entry = {"labels": list(dict.fromkeys(origins)),
|
||||||
|
"sha256": sha256(Path(pdf).read_bytes()).hexdigest()}
|
||||||
|
|
||||||
|
def update(data):
|
||||||
|
data.setdefault(str(copy_id), {})[label] = entry
|
||||||
|
|
||||||
|
atomic_update_json(_path(root), update, default_factory=dict)
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Conservative repair of LaTeX escaping in raw model-generated JSON.
|
||||||
|
|
||||||
|
Call before JSON decoding, including for valid JSON: ``\\frac`` and ``\\not``
|
||||||
|
can otherwise be silently decoded as control characters. This is heuristic;
|
||||||
|
unknown commands starting with a valid JSON escape are left alone.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# Only these content fields carry TeX. Never rewrite labels or structural keys.
|
||||||
|
_CONTENT_FIELDS = {'question_content', 'solution_content', 'context_content', 'rubric_content'}
|
||||||
|
_CONTENT_KEYS = {f'"{field}"' for field in _CONTENT_FIELDS}
|
||||||
|
_STRING = re.compile(r'"(?:\\.|[^"\\])*"', re.DOTALL)
|
||||||
|
_COMMAND = re.compile(r'[a-zA-Z]+')
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_literal_newlines(text: str) -> str:
|
||||||
|
r"""Decode stray \n tokens in extracted TeX, preserving commands and \\.
|
||||||
|
|
||||||
|
An odd backslash run ends in an escaped newline; any preceding pairs
|
||||||
|
remain LaTeX row breaks. Even runs are already legitimate LaTeX.
|
||||||
|
"""
|
||||||
|
def replace(match: re.Match[str]) -> str:
|
||||||
|
slashes = match.group(1)
|
||||||
|
return slashes[:-1] + "\n" if len(slashes) % 2 else match.group(0)
|
||||||
|
|
||||||
|
return re.sub(r"(\\+)n(?![A-Za-z])", replace, text)
|
||||||
|
# Commands whose first letter is also a valid JSON escape. Other commands do
|
||||||
|
# not need a dictionary: their single backslash is invalid JSON already.
|
||||||
|
_AMBIGUOUS_COMMANDS = set('''
|
||||||
|
begin beta bar backslash binom boxed boldsymbol big bigl bigr biggl biggr
|
||||||
|
bigskip blacktriangle blacksquare bmod breve bullet bra braket
|
||||||
|
frac fbox forall footnote footnotesize framebox
|
||||||
|
not neq neg nabla newline newcommand newenvironment noindent nonumber
|
||||||
|
nexists notin nmid nparallel nsubseteq nsupseteq normalfont normalsize
|
||||||
|
rightarrow right leftarrow rangle rbrace rceil rfloor rightarrowtail
|
||||||
|
Rightarrow rightharpoonup rightharpoondown rightleftharpoons rightleftarrows
|
||||||
|
rm rule ref rho rightskip raisebox require renewcommand
|
||||||
|
text textbf textit textrm textsf texttt textnormal textcolor textsc textsl
|
||||||
|
textup textwidth theta tau tan tanh times to top triangle triangleleft
|
||||||
|
triangleright tilde tfrac today tag tiny thickspace thinspace tbinom
|
||||||
|
underbrace underline underset uparrow updownarrow upsilon usepackage
|
||||||
|
'''.split())
|
||||||
|
|
||||||
|
|
||||||
|
def _repair_string(token: str) -> str:
|
||||||
|
output = []
|
||||||
|
index = 0
|
||||||
|
while index < len(token):
|
||||||
|
char = token[index]
|
||||||
|
if char != '\\' or index + 1 == len(token):
|
||||||
|
output.append(char)
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
following = token[index + 1]
|
||||||
|
# Consume already escaped pairs together, never double-escape them.
|
||||||
|
if following in '\\"/':
|
||||||
|
output.append(token[index:index + 2])
|
||||||
|
index += 2
|
||||||
|
continue
|
||||||
|
command_match = _COMMAND.match(token, index + 1)
|
||||||
|
command = command_match.group() if command_match else ''
|
||||||
|
unicode_escape = following == 'u' and re.match(r'[0-9a-fA-F]{4}', token[index + 2:])
|
||||||
|
if not unicode_escape and (following not in 'bfnrtu' or command in _AMBIGUOUS_COMMANDS
|
||||||
|
or following == 'u'):
|
||||||
|
output.append('\\\\')
|
||||||
|
else:
|
||||||
|
output.append('\\')
|
||||||
|
index += 1
|
||||||
|
return ''.join(output)
|
||||||
|
|
||||||
|
|
||||||
|
def repair_latex_json(raw: str) -> str:
|
||||||
|
"""Repair single TeX backslashes without decoding or rewriting other JSON.
|
||||||
|
|
||||||
|
Preserve standard escapes, Unicode escapes, and already escaped TeX.
|
||||||
|
A literal newline followed by e.g. 'ot' has the same wire representation
|
||||||
|
as a broken ``\\not`` command; known TeX command names take precedence.
|
||||||
|
Raw responses/caches should be retained to allow reviewing such cases.
|
||||||
|
"""
|
||||||
|
output = []
|
||||||
|
end = 0
|
||||||
|
previous = None
|
||||||
|
for match in _STRING.finditer(raw):
|
||||||
|
token = match.group()
|
||||||
|
if (previous is not None and raw[previous.end():match.start()].strip() == ':'
|
||||||
|
and previous.group() in _CONTENT_KEYS):
|
||||||
|
token = _repair_string(token)
|
||||||
|
output.extend((raw[end:match.start()], token))
|
||||||
|
end = match.end()
|
||||||
|
previous = match
|
||||||
|
output.append(raw[end:])
|
||||||
|
return ''.join(output)
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import tkinter as tk
|
||||||
|
from collections import Counter
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from tkinter import messagebox, ttk
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from copienator import EvaluationWorkspace
|
||||||
|
from copienator.platform import open_path, safe_filename
|
||||||
|
|
||||||
|
|
||||||
|
RETURN_FOLDER_PATTERN = re.compile(r"^(?P<name>.+) \((?P<copy_id>\d+)\)$")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class NameIssue:
|
||||||
|
folder: Path
|
||||||
|
name: str
|
||||||
|
copy_id: str
|
||||||
|
reasons: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
def find_name_issues(evaluation: Path) -> list[NameIssue]:
|
||||||
|
"""Return unknown and duplicated names currently present in A Rendre."""
|
||||||
|
return_dir = EvaluationWorkspace(evaluation).return_dir
|
||||||
|
parsed: list[tuple[Path, str, str]] = []
|
||||||
|
if return_dir.is_dir():
|
||||||
|
for folder in return_dir.iterdir():
|
||||||
|
if not folder.is_dir() or folder.is_symlink():
|
||||||
|
continue
|
||||||
|
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
|
||||||
|
if match:
|
||||||
|
parsed.append(
|
||||||
|
(folder, match.group("name"), match.group("copy_id"))
|
||||||
|
)
|
||||||
|
|
||||||
|
counts = Counter(name.casefold() for _folder, name, _copy_id in parsed)
|
||||||
|
issues = []
|
||||||
|
for folder, name, copy_id in parsed:
|
||||||
|
reasons = []
|
||||||
|
if name.casefold() == "unknown":
|
||||||
|
reasons.append("nom inconnu")
|
||||||
|
if counts[name.casefold()] > 1:
|
||||||
|
reasons.append(f"nom attribué à {counts[name.casefold()]} copies")
|
||||||
|
if reasons:
|
||||||
|
issues.append(NameIssue(folder, name, copy_id, tuple(reasons)))
|
||||||
|
return sorted(issues, key=lambda issue: (int(issue.copy_id), issue.name.casefold()))
|
||||||
|
|
||||||
|
|
||||||
|
def original_copy_path(evaluation: Path, copy_id: str) -> Path | None:
|
||||||
|
workspace = EvaluationWorkspace(evaluation)
|
||||||
|
filename = f"Copie{copy_id}.pdf"
|
||||||
|
for candidate in (
|
||||||
|
workspace.original_copies_dir / filename,
|
||||||
|
workspace.copies_dir / filename,
|
||||||
|
workspace.root / filename,
|
||||||
|
):
|
||||||
|
if candidate.is_file():
|
||||||
|
return candidate
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def rename_return_copy(folder: Path, requested_name: str) -> Path:
|
||||||
|
"""Rename one return folder and its top-level JPG/PDF, preserving its ID."""
|
||||||
|
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
|
||||||
|
if not folder.is_dir() or folder.is_symlink() or match is None:
|
||||||
|
raise ValueError(f"Dossier de copie invalide : {folder}")
|
||||||
|
if folder.parent.name != "A Rendre":
|
||||||
|
raise ValueError("Le dossier à renommer doit se trouver dans A Rendre.")
|
||||||
|
|
||||||
|
entered_name = requested_name.strip()
|
||||||
|
if not entered_name:
|
||||||
|
raise ValueError("Saisissez un nom.")
|
||||||
|
name = safe_filename(entered_name, fallback="")
|
||||||
|
if not name:
|
||||||
|
raise ValueError("Le nom ne contient aucun caractère utilisable.")
|
||||||
|
|
||||||
|
copy_id = match.group("copy_id")
|
||||||
|
destination = folder.with_name(f"{name} ({copy_id})")
|
||||||
|
if destination != folder and os.path.lexists(destination):
|
||||||
|
raise FileExistsError(f"Le dossier {destination.name!r} existe déjà.")
|
||||||
|
|
||||||
|
assets: dict[str, Path] = {}
|
||||||
|
for child in folder.iterdir():
|
||||||
|
suffix = child.suffix.casefold()
|
||||||
|
if suffix not in {".jpg", ".pdf"}:
|
||||||
|
continue
|
||||||
|
if suffix in assets:
|
||||||
|
raise ValueError(
|
||||||
|
f"Plusieurs fichiers {suffix} sont présents dans {folder.name}."
|
||||||
|
)
|
||||||
|
assets[suffix] = child
|
||||||
|
|
||||||
|
renames: list[tuple[Path, Path]] = []
|
||||||
|
for suffix, source in assets.items():
|
||||||
|
target = source.with_name(f"{name}{suffix}")
|
||||||
|
if target != source and os.path.lexists(target):
|
||||||
|
raise FileExistsError(f"Le fichier {target.name!r} existe déjà.")
|
||||||
|
if target != source:
|
||||||
|
renames.append((source, target))
|
||||||
|
|
||||||
|
completed: list[tuple[Path, Path]] = []
|
||||||
|
try:
|
||||||
|
for source, target in renames:
|
||||||
|
source.rename(target)
|
||||||
|
completed.append((source, target))
|
||||||
|
if destination != folder:
|
||||||
|
folder.rename(destination)
|
||||||
|
except OSError:
|
||||||
|
for source, target in reversed(completed):
|
||||||
|
if os.path.lexists(target) and not os.path.lexists(source):
|
||||||
|
target.rename(source)
|
||||||
|
raise
|
||||||
|
return destination
|
||||||
|
|
||||||
|
|
||||||
|
class GivingNamesPanel(ttk.LabelFrame):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
master: tk.Misc,
|
||||||
|
evaluation: Path,
|
||||||
|
*,
|
||||||
|
opener: Callable[[Path], None] = open_path,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(master, text="Vérification des noms", padding=7)
|
||||||
|
self.evaluation = evaluation
|
||||||
|
self.opener = opener
|
||||||
|
self.columnconfigure(0, weight=1)
|
||||||
|
|
||||||
|
actions = ttk.Frame(self)
|
||||||
|
actions.grid(row=0, column=0, sticky="w", pady=(0, 7))
|
||||||
|
ttk.Button(
|
||||||
|
actions,
|
||||||
|
text="Ouvrir A Rendre",
|
||||||
|
command=self.open_return_folder,
|
||||||
|
).pack(side="left")
|
||||||
|
ttk.Button(actions, text="Recharger", command=self.reload).pack(
|
||||||
|
side="left", padx=6
|
||||||
|
)
|
||||||
|
|
||||||
|
self.issue_frame = ttk.Frame(self)
|
||||||
|
self.issue_frame.grid(row=1, column=0, sticky="ew")
|
||||||
|
self.issue_frame.columnconfigure(1, weight=1)
|
||||||
|
self.reload()
|
||||||
|
|
||||||
|
def open_return_folder(self) -> None:
|
||||||
|
path = EvaluationWorkspace(self.evaluation).return_dir
|
||||||
|
if not path.is_dir():
|
||||||
|
messagebox.showerror(
|
||||||
|
"Dossier introuvable",
|
||||||
|
"Exécutez d’abord l’attribution des noms pour créer A Rendre.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.opener(path)
|
||||||
|
except (OSError, RuntimeError) as exc:
|
||||||
|
messagebox.showerror("Ouverture impossible", str(exc))
|
||||||
|
|
||||||
|
def open_original(self, copy_id: str) -> None:
|
||||||
|
path = original_copy_path(self.evaluation, copy_id)
|
||||||
|
if path is None:
|
||||||
|
messagebox.showerror(
|
||||||
|
"Copie introuvable", f"Copie{copy_id}.pdf est introuvable."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.opener(path)
|
||||||
|
except (OSError, RuntimeError) as exc:
|
||||||
|
messagebox.showerror("Ouverture impossible", str(exc))
|
||||||
|
|
||||||
|
def rename_issue(self, issue: NameIssue, variable: tk.StringVar) -> None:
|
||||||
|
try:
|
||||||
|
destination = rename_return_copy(issue.folder, variable.get())
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
messagebox.showerror("Renommage impossible", str(exc))
|
||||||
|
return
|
||||||
|
self.reload()
|
||||||
|
messagebox.showinfo("Copie renommée", f"Nouveau dossier : {destination.name}")
|
||||||
|
|
||||||
|
def reload(self) -> None:
|
||||||
|
for child in self.issue_frame.winfo_children():
|
||||||
|
child.destroy()
|
||||||
|
issues = find_name_issues(self.evaluation)
|
||||||
|
if not issues:
|
||||||
|
return_dir = EvaluationWorkspace(self.evaluation).return_dir
|
||||||
|
text = (
|
||||||
|
"Aucun nom inconnu ou dupliqué."
|
||||||
|
if return_dir.is_dir()
|
||||||
|
else "A Rendre n’a pas encore été généré."
|
||||||
|
)
|
||||||
|
ttk.Label(self.issue_frame, text=text).grid(
|
||||||
|
row=0, column=0, columnspan=4, sticky="w"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
ttk.Label(self.issue_frame, text="Copie / problème").grid(
|
||||||
|
row=0, column=0, sticky="w", padx=(0, 6)
|
||||||
|
)
|
||||||
|
ttk.Label(self.issue_frame, text="Nouveau nom").grid(
|
||||||
|
row=0, column=1, sticky="w"
|
||||||
|
)
|
||||||
|
for row, issue in enumerate(issues, start=1):
|
||||||
|
ttk.Label(
|
||||||
|
self.issue_frame,
|
||||||
|
text=f"Copie{issue.copy_id} — {', '.join(issue.reasons)}",
|
||||||
|
).grid(row=row, column=0, sticky="w", padx=(0, 6), pady=2)
|
||||||
|
variable = tk.StringVar()
|
||||||
|
ttk.Entry(self.issue_frame, textvariable=variable).grid(
|
||||||
|
row=row, column=1, sticky="ew", pady=2
|
||||||
|
)
|
||||||
|
original = original_copy_path(self.evaluation, issue.copy_id)
|
||||||
|
ttk.Button(
|
||||||
|
self.issue_frame,
|
||||||
|
text="Ouvrir l’originale",
|
||||||
|
command=lambda ident=issue.copy_id: self.open_original(ident),
|
||||||
|
state="normal" if original else "disabled",
|
||||||
|
).grid(row=row, column=2, padx=6, pady=2)
|
||||||
|
ttk.Button(
|
||||||
|
self.issue_frame,
|
||||||
|
text="Renommer",
|
||||||
|
command=lambda item=issue, value=variable: self.rename_issue(
|
||||||
|
item, value
|
||||||
|
),
|
||||||
|
).grid(row=row, column=3, pady=2)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""Non-blocking controls for the system Syncthing instance."""
|
||||||
|
import queue
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from tkinter import ttk, messagebox
|
||||||
|
|
||||||
|
SERVICE = "syncthing@sebastien.service"
|
||||||
|
|
||||||
|
|
||||||
|
def service_status():
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemctl", "show", SERVICE, "--property=ActiveState", "--value"],
|
||||||
|
capture_output=True, text=True, timeout=10,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(result.stderr.strip() or "Statut indisponible")
|
||||||
|
return result.stdout.strip() or "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def control_service(action):
|
||||||
|
if action == "ensure" and service_status() == "active":
|
||||||
|
return "active"
|
||||||
|
command = "start" if action == "ensure" else action
|
||||||
|
result = subprocess.run(
|
||||||
|
["systemctl", "--no-ask-password", command, SERVICE],
|
||||||
|
capture_output=True, text=True, timeout=60,
|
||||||
|
)
|
||||||
|
if result.returncode:
|
||||||
|
raise RuntimeError(result.stderr.strip() or f"Échec de systemctl {command}")
|
||||||
|
status = service_status()
|
||||||
|
if command == "start" and status != "active":
|
||||||
|
raise RuntimeError(f"Syncthing n’est pas actif : {status}")
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
class SyncthingPanel(ttk.Frame):
|
||||||
|
def __init__(self, parent):
|
||||||
|
super().__init__(parent)
|
||||||
|
self.events = queue.Queue()
|
||||||
|
self.busy = False
|
||||||
|
self.pending_export = None
|
||||||
|
self.status = ttk.Label(self, text=f"{SERVICE} : vérification…")
|
||||||
|
self.status.pack(anchor="w")
|
||||||
|
controls = ttk.Frame(self)
|
||||||
|
controls.pack(anchor="w", pady=4)
|
||||||
|
self.start_button = ttk.Button(controls, text="Démarrer Syncthing",
|
||||||
|
command=lambda: self.request("start"))
|
||||||
|
self.start_button.pack(side="left")
|
||||||
|
self.stop_button = ttk.Button(controls, text="Arrêter Syncthing",
|
||||||
|
command=lambda: self.request("stop"))
|
||||||
|
self.stop_button.pack(side="left", padx=6)
|
||||||
|
self.timer = self.after(100, self.poll)
|
||||||
|
self.refresh_timer = None
|
||||||
|
self.bind("<Destroy>", self.cleanup, add=True)
|
||||||
|
self.request("status")
|
||||||
|
|
||||||
|
def ensure_started(self, callback):
|
||||||
|
self.pending_export = callback
|
||||||
|
if not self.busy:
|
||||||
|
self.request("ensure")
|
||||||
|
|
||||||
|
def request(self, action):
|
||||||
|
if self.busy:
|
||||||
|
return
|
||||||
|
self.busy = True
|
||||||
|
self.start_button.configure(state="disabled")
|
||||||
|
self.stop_button.configure(state="disabled")
|
||||||
|
if action != "status":
|
||||||
|
self.status.configure(text=f"{SERVICE} : opération en cours…")
|
||||||
|
|
||||||
|
def work():
|
||||||
|
try:
|
||||||
|
status = service_status() if action == "status" else control_service(action)
|
||||||
|
self.events.put((action, status, None))
|
||||||
|
except (OSError, RuntimeError, subprocess.TimeoutExpired) as exc:
|
||||||
|
self.events.put((action, None, str(exc)))
|
||||||
|
|
||||||
|
threading.Thread(target=work, daemon=True).start()
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
try:
|
||||||
|
action, status, error = self.events.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
self.busy = False
|
||||||
|
self.start_button.configure(state="normal")
|
||||||
|
self.stop_button.configure(state="normal")
|
||||||
|
self.status.configure(text=f"{SERVICE} : {error or status}")
|
||||||
|
if error and action != "status":
|
||||||
|
self.pending_export = None
|
||||||
|
messagebox.showerror("Syncthing", error)
|
||||||
|
elif action == "ensure":
|
||||||
|
callback, self.pending_export = self.pending_export, None
|
||||||
|
if callback:
|
||||||
|
callback()
|
||||||
|
elif self.pending_export:
|
||||||
|
self.request("ensure")
|
||||||
|
if self.winfo_exists():
|
||||||
|
self.timer = self.after(100, self.poll)
|
||||||
|
if self.refresh_timer is None:
|
||||||
|
self.refresh_timer = self.after(5000, self.refresh)
|
||||||
|
|
||||||
|
def refresh(self):
|
||||||
|
self.refresh_timer = None
|
||||||
|
self.request("status")
|
||||||
|
|
||||||
|
def cleanup(self, event):
|
||||||
|
if event.widget == self:
|
||||||
|
self.pending_export = None
|
||||||
|
for timer in (self.timer, self.refresh_timer):
|
||||||
|
if timer:
|
||||||
|
self.after_cancel(timer)
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import io
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from concurrent.futures import wait as real_wait
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Event
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
from copienator import EvaluationWorkspace, ExitCode
|
||||||
|
from copienator.commands import gemini_for_labels as labels
|
||||||
|
|
||||||
|
|
||||||
|
class LabelTimeoutTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(directory.cleanup)
|
||||||
|
self.workspace = EvaluationWorkspace(Path(directory.name))
|
||||||
|
(self.workspace.root / "labels").write_text("Ex 1\n")
|
||||||
|
(self.workspace.root / "names").write_text("Student\n")
|
||||||
|
self.workspace.copies_dir.mkdir()
|
||||||
|
(self.workspace.root / "Cutleft").mkdir()
|
||||||
|
self.images = []
|
||||||
|
for name in ("Copie01_01", "Copie01_02", "Copie02_01"):
|
||||||
|
image = self.workspace.root / "Cutleft" / f"{name}.jpg"
|
||||||
|
image.write_bytes(b"image")
|
||||||
|
self.images.append(image)
|
||||||
|
self.client = Mock()
|
||||||
|
self.response = Mock(text='{"name":"Student","list":[]}')
|
||||||
|
|
||||||
|
def group(self, images):
|
||||||
|
return labels.process_copy_group(
|
||||||
|
self.workspace, "Copie01", images, client=self.client,
|
||||||
|
labels_text="Ex 1\n", names_text="Student\n",
|
||||||
|
valid_labels={"Ex 1"}, valid_names={"Student"}, overwrite=False,
|
||||||
|
sleep=lambda _: None, target_interval=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_timeout_recovery_uses_bounded_requests_and_saves_result(self):
|
||||||
|
self.client.models.generate_content.side_effect = [TimeoutError(), self.response]
|
||||||
|
self.assertEqual(self.group(self.images[:1]), 1)
|
||||||
|
self.assertEqual(self.client.models.generate_content.call_count, 2)
|
||||||
|
for call in self.client.models.generate_content.call_args_list:
|
||||||
|
options = call.kwargs["config"].http_options
|
||||||
|
self.assertEqual(options.timeout, 120_000)
|
||||||
|
self.assertEqual(options.retry_options.attempts, 1)
|
||||||
|
self.assertTrue((self.workspace.copies_dir / "Copie01_01.json").exists())
|
||||||
|
|
||||||
|
def test_exhaustion_defers_dependent_pages_but_other_copies_finish_and_resume(self):
|
||||||
|
self.client.models.generate_content.side_effect = [
|
||||||
|
TimeoutError(), TimeoutError(), TimeoutError(), self.response,
|
||||||
|
]
|
||||||
|
output = io.StringIO()
|
||||||
|
with patch.object(labels, "selected_images", return_value=(self.images, [])), redirect_stdout(output):
|
||||||
|
result = labels.run(self.workspace, [], client=self.client,
|
||||||
|
sleep=lambda _: None, max_workers=1)
|
||||||
|
self.assertEqual(result, ExitCode.PARTIAL)
|
||||||
|
self.assertEqual(self.client.models.generate_content.call_count, 4)
|
||||||
|
self.assertFalse((self.workspace.copies_dir / "Copie01_01.json").exists())
|
||||||
|
self.assertFalse((self.workspace.copies_dir / "Copie01_02.json").exists())
|
||||||
|
self.assertTrue((self.workspace.copies_dir / "Copie02_01.json").exists())
|
||||||
|
self.assertIn("Incomplete images", output.getvalue())
|
||||||
|
self.assertIn("Copie01_02.jpg", output.getvalue())
|
||||||
|
self.client.models.generate_content.reset_mock(side_effect=True)
|
||||||
|
self.client.models.generate_content.return_value = self.response
|
||||||
|
with patch.object(labels, "selected_images", return_value=(self.images, [])):
|
||||||
|
result = labels.run(self.workspace, [], client=self.client, sleep=lambda _: None)
|
||||||
|
self.assertEqual(result, ExitCode.SUCCESS)
|
||||||
|
self.assertEqual(self.client.models.generate_content.call_count, 2)
|
||||||
|
|
||||||
|
def test_changing_invalid_labels_cannot_retry_forever(self):
|
||||||
|
self.client.models.generate_content.side_effect = [Mock(text=(
|
||||||
|
'{"name":"Student","list":[{"box_2d":[1,2,3,4],"label":"bad%d"}]}' % index
|
||||||
|
)) for index in range(3)]
|
||||||
|
with self.assertRaises(labels.IncompleteCopyError):
|
||||||
|
self.group(self.images[:1])
|
||||||
|
self.assertEqual(self.client.models.generate_content.call_count, 3)
|
||||||
|
|
||||||
|
def test_progress_reports_waiting_image_and_elapsed_time(self):
|
||||||
|
entered, release = Event(), Event()
|
||||||
|
self.addCleanup(release.set)
|
||||||
|
|
||||||
|
def request(**kwargs):
|
||||||
|
entered.set()
|
||||||
|
if not release.wait(2):
|
||||||
|
raise AssertionError("Test did not release request")
|
||||||
|
return self.response
|
||||||
|
|
||||||
|
first_wait = True
|
||||||
|
|
||||||
|
def wait(futures, timeout):
|
||||||
|
nonlocal first_wait
|
||||||
|
self.assertEqual(timeout, 30)
|
||||||
|
if first_wait:
|
||||||
|
first_wait = False
|
||||||
|
self.assertTrue(entered.wait(2))
|
||||||
|
return set(), set(futures)
|
||||||
|
release.set()
|
||||||
|
return real_wait(futures, timeout=2)
|
||||||
|
|
||||||
|
self.client.models.generate_content.side_effect = request
|
||||||
|
output = io.StringIO()
|
||||||
|
with patch.object(labels, "selected_images", return_value=(self.images[:1], [])), \
|
||||||
|
patch.object(labels, "wait", side_effect=wait), redirect_stdout(output):
|
||||||
|
result = labels.run(self.workspace, [], client=self.client, sleep=lambda _: None)
|
||||||
|
self.assertEqual(result, ExitCode.SUCCESS)
|
||||||
|
self.assertIn("1 copies outstanding", output.getvalue())
|
||||||
|
self.assertIn("Copie01_01.jpg, attempt 1/3", output.getvalue())
|
||||||
|
self.assertIn("elapsed for image", output.getvalue())
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from copienator.latex_json import repair_latex_json, normalize_literal_newlines
|
||||||
|
from copienator.commands.gemini_for_enonce import ExamQuestions, ExamSolutions, ExamContext, GroupRubrics
|
||||||
|
|
||||||
|
|
||||||
|
class LatexJsonTests(unittest.TestCase):
|
||||||
|
def test_literal_newlines_preserve_tex_commands_and_row_breaks(self):
|
||||||
|
content = r"Paragraph\n\n Next $\neq \notin \newline$ \\ next \\\n row"
|
||||||
|
expected = "Paragraph\n\n Next " + r"$\neq \notin \newline$ \\ next \\" + "\n row"
|
||||||
|
self.assertEqual(normalize_literal_newlines(content), expected)
|
||||||
|
self.assertEqual(normalize_literal_newlines(expected), expected)
|
||||||
|
raw = json.dumps({'solutions': [{'label': '1)', 'solution_content': content}]})
|
||||||
|
parsed = ExamSolutions.model_validate_json(repair_latex_json(raw))
|
||||||
|
self.assertEqual(parsed.solutions[0].solution_content, expected)
|
||||||
|
|
||||||
|
def test_mixed_correct_and_broken_escapes_in_all_extraction_fields(self):
|
||||||
|
cases = [
|
||||||
|
(ExamQuestions, 'questions', 'question_content', {'label': '1)'}),
|
||||||
|
(ExamSolutions, 'solutions', 'solution_content', {'label': '1)'}),
|
||||||
|
(ExamContext, 'contexts', 'context_content',
|
||||||
|
{'target_question_label': '1)', 'last_question_label': '2)'}),
|
||||||
|
(GroupRubrics, 'rubrics', 'rubric_content', {'label': '1)'})]
|
||||||
|
raw_content = r'$\circ \rightarrow \frac{1}{2} \not= \theta \begin{x} \unknown \underline{x}$\nSuite : $\\sqrt{x}$'
|
||||||
|
expected = r'$\circ \rightarrow \frac{1}{2} \not= \theta \begin{x} \unknown \underline{x}$' + '\n' + r'Suite : $\sqrt{x}$'
|
||||||
|
for model, collection, field, labels in cases:
|
||||||
|
with self.subTest(field=field):
|
||||||
|
raw = json.dumps({collection: [{**labels, field: 'PLACEHOLDER'}]}).replace('PLACEHOLDER', raw_content)
|
||||||
|
repaired = repair_latex_json(raw)
|
||||||
|
parsed = model.model_validate_json(repaired)
|
||||||
|
self.assertEqual(getattr(getattr(parsed, collection)[0], field), expected)
|
||||||
|
self.assertEqual(repair_latex_json(repaired), repaired)
|
||||||
|
|
||||||
|
def test_valid_json_is_unchanged(self):
|
||||||
|
content = 'Énoncé "cité"\nNouvelle ligne\ntexte\nnext\ni \ne \nu \tindent\r\n' + r'$\not= \frac{1}{2} \\ \begin{array}$'
|
||||||
|
raw = json.dumps({'question_content': content, 'label': r'\not'})
|
||||||
|
self.assertEqual(repair_latex_json(raw), raw)
|
||||||
|
self.assertEqual(repair_latex_json(r'{"rubric_content":"\u00e9 \/ \"ok\""}'),
|
||||||
|
r'{"rubric_content":"\u00e9 \/ \"ok\""}')
|
||||||
|
|
||||||
|
def test_only_content_values_are_repaired(self):
|
||||||
|
raw = r'{"label":"\not", "other":"\frac", "rubric_content":"\frac"}'
|
||||||
|
self.assertEqual(repair_latex_json(raw),
|
||||||
|
r'{"label":"\not", "other":"\frac", "rubric_content":"\\frac"}')
|
||||||
|
|
||||||
|
def test_does_not_repair_structural_errors(self):
|
||||||
|
with self.assertRaises(json.JSONDecodeError):
|
||||||
|
json.loads(repair_latex_json(r'{"question_content":"\frac", broken}'))
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
from copienator import CliError, EvaluationWorkspace, ExitCode, atomic_write_json, read_json
|
||||||
|
from copienator.commands.submit_batches import run
|
||||||
|
|
||||||
|
|
||||||
|
class BatchSubmissionResumeTests(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
directory = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(directory.cleanup)
|
||||||
|
self.workspace = EvaluationWorkspace(Path(directory.name))
|
||||||
|
for tier in ("flash", "pro"):
|
||||||
|
(self.workspace.root / f"batch_requests_{tier}.jsonl").write_text("{}\n")
|
||||||
|
self.client = Mock()
|
||||||
|
self.client.files.upload.return_value = SimpleNamespace(name="files/input")
|
||||||
|
self.client.batches.create.return_value = SimpleNamespace(name="batches/flash")
|
||||||
|
|
||||||
|
def test_restart_after_second_upload_failure_preserves_first_job(self):
|
||||||
|
self.client.files.upload.side_effect = [
|
||||||
|
SimpleNamespace(name="files/input"), ConnectionResetError("reset"),
|
||||||
|
]
|
||||||
|
with self.assertRaises(ConnectionResetError):
|
||||||
|
run(self.workspace, client=self.client)
|
||||||
|
original = read_json(self.workspace.batch_jobs_file)["jobs"]["flash"]
|
||||||
|
self.assertEqual(original["name"], "batches/flash")
|
||||||
|
|
||||||
|
self.client.reset_mock()
|
||||||
|
self.client.files.upload.side_effect = None
|
||||||
|
self.client.batches.create.return_value = SimpleNamespace(name="batches/pro")
|
||||||
|
self.assertEqual(run(self.workspace, client=self.client), ExitCode.SUCCESS)
|
||||||
|
self.client.files.upload.assert_called_once()
|
||||||
|
self.assertEqual(self.client.files.upload.call_args.kwargs["file"],
|
||||||
|
str(self.workspace.root / "batch_requests_pro.jsonl"))
|
||||||
|
self.client.batches.create.assert_called_once()
|
||||||
|
jobs = read_json(self.workspace.batch_jobs_file)["jobs"]
|
||||||
|
self.assertEqual(jobs["flash"], original)
|
||||||
|
self.assertEqual(jobs["pro"]["name"], "batches/pro")
|
||||||
|
|
||||||
|
self.client.reset_mock()
|
||||||
|
self.assertEqual(run(self.workspace, client=self.client), ExitCode.SUCCESS)
|
||||||
|
self.client.files.upload.assert_not_called()
|
||||||
|
self.client.batches.create.assert_not_called()
|
||||||
|
|
||||||
|
def test_legacy_manifest_is_skipped_and_explicit_overwrite_resubmits(self):
|
||||||
|
atomic_write_json(self.workspace.batch_jobs_file, {"jobs": {
|
||||||
|
"flash": {"name": "batches/old-flash"},
|
||||||
|
"pro": {"name": "batches/old-pro"},
|
||||||
|
}})
|
||||||
|
self.assertEqual(run(self.workspace, client=self.client), ExitCode.SUCCESS)
|
||||||
|
self.client.files.upload.assert_not_called()
|
||||||
|
self.assertEqual(run(self.workspace, client=self.client, overwrite=True), ExitCode.SUCCESS)
|
||||||
|
self.assertEqual(self.client.batches.create.call_count, 2)
|
||||||
|
|
||||||
|
def test_invalid_manifest_does_not_trigger_duplicate_submission(self):
|
||||||
|
for manifest in ({"jobs": []}, {"jobs": {"flash": {}}}):
|
||||||
|
with self.subTest(manifest=manifest):
|
||||||
|
atomic_write_json(self.workspace.batch_jobs_file, manifest)
|
||||||
|
with self.assertRaises(CliError):
|
||||||
|
run(self.workspace, client=self.client)
|
||||||
|
self.client.files.upload.assert_not_called()
|
||||||
|
|
||||||
|
def test_empty_inputs_report_partial(self):
|
||||||
|
for tier in ("flash", "pro"):
|
||||||
|
(self.workspace.root / f"batch_requests_{tier}.jsonl").write_text("")
|
||||||
|
self.assertEqual(run(self.workspace, client=self.client), ExitCode.PARTIAL)
|
||||||
|
self.client.files.upload.assert_not_called()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import subprocess
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from copienator_gui.syncthing import SERVICE, control_service
|
||||||
|
|
||||||
|
|
||||||
|
class SyncthingTests(unittest.TestCase):
|
||||||
|
def test_active_service_is_not_restarted(self):
|
||||||
|
with patch("copienator_gui.syncthing.service_status", return_value="active"), \
|
||||||
|
patch("copienator_gui.syncthing.subprocess.run") as run:
|
||||||
|
self.assertEqual(control_service("ensure"), "active")
|
||||||
|
run.assert_not_called()
|
||||||
|
|
||||||
|
def test_inactive_service_is_started_and_verified(self):
|
||||||
|
with patch("copienator_gui.syncthing.service_status", side_effect=["inactive", "active"]), \
|
||||||
|
patch("copienator_gui.syncthing.subprocess.run", return_value=subprocess.CompletedProcess([], 0)) as run:
|
||||||
|
self.assertEqual(control_service("ensure"), "active")
|
||||||
|
self.assertEqual(run.call_args.args[0], ["systemctl", "--no-ask-password", "start", SERVICE])
|
||||||
|
|
||||||
|
def test_failed_start_reports_error(self):
|
||||||
|
with patch("copienator_gui.syncthing.service_status", return_value="inactive"), \
|
||||||
|
patch("copienator_gui.syncthing.subprocess.run", return_value=subprocess.CompletedProcess([], 1, stderr="Access denied")):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "Access denied"):
|
||||||
|
control_service("ensure")
|
||||||
|
|
||||||
|
def test_stop_targets_system_instance(self):
|
||||||
|
with patch("copienator_gui.syncthing.service_status", return_value="inactive"), \
|
||||||
|
patch("copienator_gui.syncthing.subprocess.run", return_value=subprocess.CompletedProcess([], 0)) as run:
|
||||||
|
self.assertEqual(control_service("stop"), "inactive")
|
||||||
|
self.assertEqual(run.call_args.args[0], ["systemctl", "--no-ask-password", "stop", SERVICE])
|
||||||
Reference in new issue
Block a user