humatheque-POC-Atelier / annotations.py
Geraldine's picture
Upload 19 files
9d9f0e5 verified
Raw
History Blame Contribute Delete
15.5 kB
"""Journal d'annotations collaboratif (append-only JSONL, partagé entre sessions).
Source de vérité des avis et, en aval, des datasets SFT/DPO. Délibérément
découplé du gr.State par session : les handlers lisent l'état pipeline pour
construire les événements qu'ils y ajoutent."""
from __future__ import annotations
import json
from copy import deepcopy
from datetime import datetime, timezone
from typing import Any
import gradio as gr
from config import ANNOTATIONS_PATH, METADATA_FIELDS, _ANNOTATIONS_LOCK
from metadata import corrected_from_fields, extract_people, normalize_metadata
from render import pipeline_summary, render_idref_table, status_card
from state import add_event, as_json, empty_state
# --------------------------------------------------------------------------
# Collaborative annotation store (append-only JSONL, shared across sessions).
# This is the source of truth for notation verdicts and, downstream, the
# SFT/DPO training datasets. It is intentionally decoupled from the per-session
# gr.State pipeline: the only coupling is that step handlers *read* pipeline
# state to build the payloads they append here.
# --------------------------------------------------------------------------
# Event schema (one JSON object per line in ANNOTATIONS_PATH):
# ts ISO-8601 UTC timestamp (stamped by append_event)
# annotator free-text reviewer name
# image_url MinIO title-page URL — the primary key everything folds onto
# action "extract" | "validate" | "note"
# step pipeline step the action concerns: vlm|sudoc|idref|dewey|draft
# verdict "ok" | "corrected" | "ko" (validate ; les notes des étapes 3-6
# n'utilisent que ok/ko ; "todo" subsiste dans les événements
# historiques uniquement)
# remark free-text reviewer comment (validate / note)
# doc_type "these" | "memoire" (extract)
# provider,model,prompt (extract)
# raw_json the model's raw extraction dict (extract)
# validated_json the human-accepted dict (validate on step "vlm")
# correction_provider, correction_model (validate on step "vlm", optional:
# set when the corrected JSON came from the tab-2 LLM-assisted
# correction — synthetic gold — rather than manual edits only)
def utc_now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def append_event(event: dict[str, Any]) -> dict[str, Any]:
"""Stamp and durably append one annotation event. Concurrency-safe: a single
process-wide lock serialises the append, and a lone `\\n`-terminated JSON line
is small enough to write atomically. Returns the stamped event."""
event = {"ts": utc_now_iso(), **event}
line = json.dumps(event, ensure_ascii=False)
with _ANNOTATIONS_LOCK:
with ANNOTATIONS_PATH.open("a", encoding="utf-8") as fh:
fh.write(line + "\n")
return event
def read_events() -> list[dict[str, Any]]:
"""Read the full event log, tolerating a missing file or partial trailing line."""
if not ANNOTATIONS_PATH.exists():
return []
events: list[dict[str, Any]] = []
with _ANNOTATIONS_LOCK:
raw = ANNOTATIONS_PATH.read_text(encoding="utf-8")
for line in raw.splitlines():
line = line.strip()
if not line:
continue
try:
events.append(json.loads(line))
except json.JSONDecodeError:
# Skip a torn line (e.g. a crash mid-write) rather than fail the fold.
continue
return events
def parse_minio_url(url: str) -> tuple[str, str]:
"""Return (collection, doc_type) parsed from a MinIO title-page URL.
e.g. .../images/theses/CESOR/doc_05/... -> ("CESOR", "these").
Falls back to ("", "") when the path does not match the expected layout."""
parts = [p for p in str(url or "").split("/") if p]
for kind, doc_type in (("theses", "these"), ("memoires", "memoire")):
if kind in parts:
idx = parts.index(kind)
collection = parts[idx + 1] if idx + 1 < len(parts) else ""
return collection, doc_type
return "", ""
def _blank_record(url: str) -> dict[str, Any]:
collection, doc_type = parse_minio_url(url)
return {
"image_url": url,
"collection": collection,
"doc_type": doc_type,
"last_extraction": None, # {provider, model, prompt, raw_json, doc_type, ts}
"steps": {}, # step -> {verdict, remark, annotator, ts}
"validated_json": None, # latest human-accepted VLM extraction
"raw_json": None, # raw extraction the validation refers to
"annotators": [],
"last_ts": None,
}
def fold_log(events: list[dict[str, Any]] | None = None) -> dict[str, dict[str, Any]]:
"""Fold the append-only log into current per-image state keyed by image_url.
Later events win for a given (image_url, step); extractions keep only the most
recent raw output. Derived flags (sft_ready, dpo) are computed by consumers."""
events = events if events is not None else read_events()
records: dict[str, dict[str, Any]] = {}
for ev in events:
url = ev.get("image_url")
if not url:
continue
rec = records.setdefault(url, _blank_record(url))
annotator = ev.get("annotator")
if annotator and annotator not in rec["annotators"]:
rec["annotators"].append(annotator)
rec["last_ts"] = ev.get("ts") or rec["last_ts"]
action = ev.get("action")
if action == "extract":
rec["last_extraction"] = {
"provider": ev.get("provider"),
"model": ev.get("model"),
"prompt": ev.get("prompt"),
"raw_json": ev.get("raw_json"),
"doc_type": ev.get("doc_type"),
"ts": ev.get("ts"),
}
if ev.get("doc_type"):
rec["doc_type"] = ev["doc_type"]
rec["raw_json"] = ev.get("raw_json")
elif action in ("validate", "note"):
step = ev.get("step") or "vlm"
rec["steps"][step] = {
"verdict": ev.get("verdict"),
"remark": ev.get("remark") or "",
"annotator": annotator or "",
"ts": ev.get("ts"),
}
if action == "validate" and step == "vlm":
rec["steps"][step]["correction_provider"] = ev.get("correction_provider")
rec["steps"][step]["correction_model"] = ev.get("correction_model")
if ev.get("validated_json") is not None:
rec["validated_json"] = ev.get("validated_json")
if ev.get("raw_json") is not None:
rec["raw_json"] = ev.get("raw_json")
return records
# Verdicts de validation (onglet 2, bouton unique) — deux issues seulement :
# ok extraction correcte telle quelle -> SFT, jamais de paire DPO
# corrected extraction corrigée à la main -> SFT (gold = champs corrigés)
# + paire DPO (rejected = extraction brute)
# (ko subsiste pour les notes qualité des onglets 3-6 ; ko/todo subsistent dans
# le journal pour les événements historiques, mais ne sont plus proposés à la
# validation ni — pour todo — à la notation.)
VALIDATED_VERDICTS = {"ok", "corrected"}
def _comparable(field: str, value: Any) -> Any:
"""Canonicalise une valeur pour la comparaison brut/corrigé : "" vaut null,
l'année est comparée en chaîne (les modèles renvoient tantôt 2015 tantôt
"2015", le formulaire re-parse en entier)."""
if value in ("", None):
return None
if field == "defense_year":
return str(value)
return value
def extraction_differs(extracted: dict[str, Any] | None, corrected: dict[str, Any] | None) -> bool:
"""Compare l'extraction brute et le JSON corrigé dans l'espace du schéma
normalisé, hors `confidence` (non-vérité-terrain) : les différences dues à la
seule normalisation (pipe -> tableau, int -> str, "" -> null) ne comptent pas."""
left = normalize_metadata(extracted or {})
right = normalize_metadata(corrected or {})
return any(
_comparable(f, left.get(f)) != _comparable(f, right.get(f))
for f in METADATA_FIELDS
if f != "confidence"
)
def validate_extraction(
state: dict[str, Any] | None,
annotator: str,
verdict: str,
remark: str,
*field_values: str,
) -> tuple[dict[str, Any], str, str, list[list[str]], str]:
"""Bouton unique « Enregistrer la validation » (onglet 2) : reflète le
formulaire dans state["vlm"]["corrected"] (transmis aux onglets suivants),
journalise le verdict, et alimente les datasets — "ok" (extraction correcte)
→ SFT sans paire DPO ; "corrected" → SFT (gold = champs corrigés) + paire DPO
(rejected = extraction brute). Retourne (state, JSON corrigé, carte de statut,
lignes personnes pour l'onglet 4, résumé pipeline)."""
state = deepcopy(state or empty_state())
vlm = state.get("vlm") or {}
extracted = vlm.get("extracted")
if extracted is None:
raise gr.Error("Lancez d'abord une extraction VLM (onglet 1).")
inp = state.get("input") or {}
url = (inp.get("image_url") or "").strip()
if not url:
raise gr.Error("Une URL d'image est requise pour enregistrer une validation exploitable dans le dataset.")
corrected = corrected_from_fields(*field_values)
state.setdefault("vlm", {})["corrected"] = corrected
verdict = (verdict or "ok").strip().lower()
differs = extraction_differs(extracted, corrected)
# Trace synthetic gold: if the corrected JSON was seeded by the tab-2 LLM-assisted
# correction, record which provider/model produced it (human-reviewed afterwards).
correction = vlm.get("correction") or {}
event = {
"annotator": (annotator or "").strip(),
"image_url": url,
"action": "validate",
"step": "vlm",
"verdict": verdict,
"remark": (remark or "").strip(),
"validated_json": corrected,
"raw_json": extracted,
"doc_type": inp.get("doc_type"),
"provider": inp.get("provider"),
"model": inp.get("model"),
}
if correction.get("provider") and not correction.get("error"):
event["correction_provider"] = correction.get("provider")
event["correction_model"] = correction.get("model")
append_event(event)
if verdict == "corrected":
message = "Extraction corrigée validée → dataset SFT (gold = champs corrigés)"
tone = "ok"
if differs:
message += " + paire DPO (rejected = extraction brute)."
else:
message += (
". ⚠ Aucune différence détectée avec l'extraction brute : "
"aucune paire DPO ne sera générée."
)
tone = "warn"
else:
message = "Extraction validée telle quelle → dataset SFT (pas de paire DPO)."
tone = "ok"
if differs:
message += (
" ⚠ Les champs diffèrent pourtant de l'extraction brute : si vous "
"avez corrigé, ré-enregistrez avec « Extraction corrigée » pour "
"générer la paire DPO."
)
tone = "warn"
message += " Métadonnées transmises aux onglets suivants (Sudoc, IdRef, Dewey)."
state.setdefault("notes", {})["vlm"] = verdict
state = add_event(state, "metadata", "ok", f"Validation enregistrée ({verdict}).")
return (
state,
as_json(corrected),
status_card("Validation", tone, message),
render_idref_table(people=extract_people(corrected)),
pipeline_summary(state),
)
STEP_NOTE_LABELS = {"sudoc": "Sudoc", "idref": "IdRef", "dewey": "Dewey", "draft": "Brouillon"}
# Onglet où se note chaque étape (rappels « avis non enregistré » au changement
# d'onglet ; "vlm" = la validation de l'onglet 2, qui alimente les datasets).
STEP_TAB_NUMBERS = {"vlm": 2, "sudoc": 3, "idref": 4, "dewey": 5, "draft": 6}
def note_button_update(step: str, mode: str):
"""gr.update du bouton de notation d'une étape : "pending" = résultat non
noté (bouton primaire, libellé alerte) ; "saved" = avis enregistré
(ré-enregistrer) ; "idle" = libellé de base."""
label = STEP_NOTE_LABELS.get(step, step)
if mode == "pending":
return gr.update(value=f"⚠ Enregistrer l'avis {label} (non enregistré)", variant="primary")
if mode == "saved":
return gr.update(value=f"Ré-enregistrer l'avis {label}", variant="secondary")
return gr.update(value=f"Enregistrer l'avis {label}", variant="secondary")
def note_pending_updates(step: str):
"""Chaîné (.success, sans état) après l'exécution d'une étape : passe le
bouton de notation en alerte et la carte en orange tant que l'avis n'est pas
enregistré. Le drapeau state["notes"][step] = "pending" est posé par le
handler de l'étape lui-même (pas ici — pas d'aller-retour d'état)."""
label = STEP_NOTE_LABELS.get(step, step)
return (
note_button_update(step, "pending"),
status_card(
f"Notation {label}", "warn",
"Résultat non noté — choisissez OK/KO puis enregistrez votre avis.",
),
)
def warn_pending_notes(state: dict[str, Any] | None, upto_tab: int) -> None:
"""À la sélection d'un onglet : toast non bloquant listant les avis encore
non enregistrés sur les étapes des onglets précédents (state["notes"])."""
notes = (state or {}).get("notes") or {}
pending = [
step for step, tab in STEP_TAB_NUMBERS.items()
if tab < upto_tab and notes.get(step) == "pending"
]
if not pending:
return
parts = [
"validation de l'extraction (onglet 2)" if step == "vlm"
else f"avis {STEP_NOTE_LABELS[step]} (onglet {STEP_TAB_NUMBERS[step]})"
for step in pending
]
gr.Warning("⚠ Non enregistré : " + " ; ".join(parts) + ".")
def save_step_note(state: dict[str, Any] | None, annotator: str, verdict: str, remark: str, step: str):
"""Append a collaborative note on a pipeline step (Sudoc/IdRef/Dewey/Brouillon).
Unlike the VLM verdict, these are pure quality signal and do not feed the datasets.
Retourne (state — drapeau de notation levé, bouton repassé en mode « saved »,
carte de statut)."""
state = deepcopy(state or empty_state())
url = ((state.get("input") or {}).get("image_url") or "").strip()
if not url:
raise gr.Error("Une URL d'image est requise pour enregistrer un avis (lancez d'abord l'extraction de l'onglet 1).")
verdict = (verdict or "ok").strip().lower()
append_event({
"annotator": (annotator or "").strip(),
"image_url": url,
"action": "note",
"step": step,
"verdict": verdict,
"remark": (remark or "").strip(),
})
state.setdefault("notes", {})[step] = verdict
labels = {"ok": "OK", "ko": "KO"}
tone = {"ok": "ok", "ko": "error"}
step_label = STEP_NOTE_LABELS.get(step, step)
return (
state,
note_button_update(step, "saved"),
status_card(f"Notation {step_label}", tone.get(verdict, "idle"), f"Avis « {labels.get(verdict, verdict)} » enregistré."),
pipeline_summary(state),
)