"""Rendus HTML partagés (cartes de statut, badges, tableaux statiques) et CSS.
Convention : les helpers retournent des chaînes HTML échappées via html.escape ;
préférer un tableau HTML statique à gr.Dataframe pour les grandes listes."""
from __future__ import annotations
import html
from typing import Any
from config import ROLE_LABELS
from state import empty_state
def score_value(candidate: dict[str, Any] | None, key: str = "final") -> str:
if not candidate:
return ""
value = (candidate.get("score") or {}).get(key)
if value is None:
return ""
return f"{float(value):.4f}"
def render_image_preview(url: str | None) -> str:
"""Inline preview of the title-page image referenced by URL, so a reviewer sees it
without opening a new browser tab. Only http(s)/data URLs are rendered."""
url = (url or "").strip()
if not (url.startswith("http://") or url.startswith("https://") or url.startswith("data:")):
return "
Aucune image à prévisualiser (saisissez une URL ou sélectionnez-en une dans « Corpus / Dataset »).
"
src = html.escape(url, quote=True)
return (
f" "
)
def status_card(title: str, status: str, message: str) -> str:
colors = {
"ok": ("#dcfce7", "#166534"),
"warn": ("#fef9c3", "#854d0e"),
"error": ("#fee2e2", "#991b1b"),
"idle": ("#e5e7eb", "#374151"),
}
bg, fg = colors.get(status, colors["idle"])
return (
f""
f"{html.escape(title)} {html.escape(message)}
"
)
def badge(value: Any) -> str:
text = str(value or "non exécuté")
colors = {
"duplicate_found": ("#fee2e2", "#991b1b"),
"ambiguous_print_candidate": ("#fef9c3", "#854d0e"),
"electronic_only": ("#dbeafe", "#1d4ed8"),
"no_print_duplicate_found": ("#dcfce7", "#166534"),
"accepted": ("#dcfce7", "#166534"),
"ambiguous": ("#fef9c3", "#854d0e"),
"low_confidence": ("#fee2e2", "#991b1b"),
"not_found": ("#e5e7eb", "#374151"),
"error": ("#fee2e2", "#991b1b"),
}
bg, fg = colors.get(text, ("#e5e7eb", "#374151"))
return f"{html.escape(text)} "
NOTE_BADGE_COLORS = {
"pending": ("#fef9c3", "#854d0e"),
"ok": ("#dcfce7", "#166534"),
"corrected": ("#dcfce7", "#166534"),
"ko": ("#fee2e2", "#991b1b"),
}
def note_badge(step: str, notes: dict[str, Any] | None) -> str:
"""Pastille « avis » d'une tuile du résumé, d'après state["notes"] :
"pending" (résultat non noté) en orange, verdict enregistré en vert/rouge,
rien si l'étape n'a pas tourné."""
value = str((notes or {}).get(step) or "")
if not value:
return ""
if step == "vlm":
labels = {"pending": "validation à enregistrer", "ok": "validée", "corrected": "validée (corrigée)"}
else:
labels = {"pending": "avis à enregistrer", "ok": "avis OK", "ko": "avis KO"}
text = labels.get(value, f"avis « {value} »")
bg, fg = NOTE_BADGE_COLORS.get(value, ("#e5e7eb", "#374151"))
return (
f""
f"{html.escape(text)} "
)
def pipeline_summary(state: dict[str, Any] | None) -> str:
state = state or empty_state()
notes = state.get("notes") or {}
vlm_status = "ok" if state.get("vlm", {}).get("corrected") else "idle"
sudoc_status = (state.get("sudoc", {}).get("response") or {}).get("status") or "idle"
idref_people = state.get("idref", {}).get("persons") or []
# Retenu = accepté par le service OU PPN validé manuellement (onglet 4).
accepted = sum(
1 for item in idref_people
if item.get("manual_ppn") or (item.get("response") or {}).get("status") == "accepted"
)
idref_label = f"{accepted}/{len(idref_people)} retenus" if idref_people else "idle"
dewey_selected = state.get("dewey", {}).get("selected") or {}
dewey_label = str(dewey_selected.get("code")) if dewey_selected.get("code") else "idle"
draft_status = "ok" if state.get("record_draft") else "idle"
return f"""
VLM {badge(vlm_status)}{note_badge("vlm", notes)}
Sudoc {badge(sudoc_status)}{note_badge("sudoc", notes)}
IdRef {badge(idref_label)}{note_badge("idref", notes)}
Dewey {badge(dewey_label)}{note_badge("dewey", notes)}
Brouillon {badge(draft_status)}{note_badge("draft", notes)}
"""
def render_sudoc(result: dict[str, Any]) -> str:
best_print = result.get("best_print_candidate")
best_elec = result.get("best_electronic_candidate")
profile = result.get("profile") or {}
profile_name = profile.get("name") or "thesis"
sru_filter = profile.get("sru_type_filter")
note_subs = profile.get("note_required_substrings") or []
excluded = (result.get("sru") or {}).get("excluded_by_profile_filter")
filter_parts = []
if sru_filter:
filter_parts.append(f"SRU {html.escape(str(sru_filter))}")
else:
filter_parts.append("aucun filtre tdo")
if note_subs:
filter_parts.append("NTH contient " + ", ".join(f'{html.escape(str(sub))}' for sub in note_subs))
filter_label = " · ".join(filter_parts)
excluded_html = (
f"Candidats écartés par le filtre profil : {int(excluded)}
"
if isinstance(excluded, int) and excluded > 0
else ""
)
def candidate_panel(title: str, candidate: dict[str, Any] | None) -> str:
if not candidate:
return f"{html.escape(title)} Aucun candidat.
"
evidence = candidate.get("evidence") or {}
return f"""
{html.escape(title)}
PPN : {html.escape(candidate.get('ppn') or '')}
Titre : {html.escape(str(candidate.get('title') or ''))}
Support : {badge(candidate.get('carrier'))}
Compte comme doublon imprimé : {html.escape(str(candidate.get('counts_as_print_duplicate')))}
{score_table(candidate.get('score') or {})}
Indices de support
{list_html(evidence.get('carrier_evidence') or [])}
Requêtes SRU correspondantes
{list_html(evidence.get('matched_queries') or [])}
"""
return f"""
Décision Sudoc
{badge(result.get('status'))}
Profil documentaire : {html.escape(profile_name)}
Score doublon imprimé : {html.escape(str(result.get('duplicate_score')))}
Stratégie SRU : {filter_label}
{excluded_html}
{candidate_panel('Meilleur candidat imprimé/physique', best_print)}
{candidate_panel('Meilleur candidat électronique', best_elec)}
"""
def score_table(score: dict[str, Any]) -> str:
rows = "".join(
f"{html.escape(str(key))} {float(value):.4f} "
for key, value in score.items()
if isinstance(value, int | float)
)
return f""
def list_html(values: list[Any]) -> str:
if not values:
return "Aucun indice.
"
return "" + "".join(f"{html.escape(str(value))} " for value in values) + " "
def sudoc_candidate_rows(result: dict[str, Any]) -> list[list[Any]]:
rows = []
for candidate in result.get("candidates") or []:
rows.append(
[
candidate.get("ppn"),
score_value(candidate),
candidate.get("carrier"),
candidate.get("counts_as_print_duplicate"),
candidate.get("title"),
" | ".join(candidate.get("authors") or []),
candidate.get("year"),
candidate.get("nnt"),
candidate.get("url"),
]
)
return rows
def manual_idref_candidate(item: dict[str, Any]) -> dict[str, Any] | None:
"""Le candidat correspondant au PPN validé manuellement, s'il figure dans la
liste retournée par le service (None pour un PPN saisi à la main)."""
manual_ppn = item.get("manual_ppn")
if not manual_ppn:
return None
candidates = (item.get("response") or {}).get("candidates") or []
return next((c for c in candidates if str(c.get("ppn")) == str(manual_ppn)), None)
def idref_rows(aligned: list[dict[str, Any]]) -> list[list[Any]]:
rows = []
for item in aligned:
response = item.get("response") or {}
best = response.get("best_candidate") or {}
manual_ppn = item.get("manual_ppn")
# Le PPN validé manuellement (onglet 4) prime sur la décision du service.
shown = (manual_idref_candidate(item) or {}) if manual_ppn else best
status = response.get("status")
if manual_ppn:
status = f"{status} · validé manuellement" if status else "validé manuellement"
rows.append(
[
item.get("name"),
" | ".join(ROLE_LABELS.get(role, role) for role in item.get("roles") or []),
status,
manual_ppn or response.get("best_ppn"),
score_value(shown),
" | ".join((shown.get("evidence") or {}).get("preferred_forms") or []),
(item.get("manual_url") if manual_ppn else best.get("url")),
]
)
return rows
IDREF_TABLE_HEADERS = ["Nom", "Rôles", "Statut", "PPN retenu", "Score", "Formes préférées", "URL", "Validation manuelle"]
def idref_manual_cell(item: dict[str, Any], idx: int) -> str:
"""Cellule « Validation manuelle » d'une ligne alignée : champ PPN adossé à un
des candidats du service (saisie libre possible pour un PPN trouvé
à la main sur idref.fr) + bouton « Valider » relayé par le pont JS IDREF_HEAD
vers services.validate_idref_ppn_from_bridge."""
response = item.get("response") or {}
name_attr = html.escape(str(item.get("name") or ""), quote=True)
current = str(item.get("manual_ppn") or response.get("best_ppn") or "")
options = "".join(
f""
f"score {score_value(candidate) or '?'}"
+ (f" — {html.escape(' | '.join((candidate.get('evidence') or {}).get('preferred_forms') or []))}"
if (candidate.get("evidence") or {}).get("preferred_forms") else "")
+ " "
for candidate in response.get("candidates") or []
if str(candidate.get("ppn") or "").strip()
)
dl_id = f"idref-dl-{idx}"
return (
""
f" "
f"{options} "
f"Valider "
" "
)
def render_idref_table(aligned: list[dict[str, Any]] | None = None, people: list[dict[str, Any]] | None = None) -> str:
"""Tableau statique « Personnes détectées / résumé des alignements » (HTML,
pas gr.Dataframe). Avant alignement (people) : personnes détectées dans les
métadonnées corrigées, statut « à aligner ». Après call_idref_for_all
(aligned) : résumé d'alignement + validation manuelle du PPN par ligne."""
body_rows = []
if aligned:
for idx, (item, row) in enumerate(zip(aligned, idref_rows(aligned))):
name, roles, status, ppn, score, forms, url = row
url_text = str(url or "")
url_html = (
f"{html.escape(url_text)} "
if url_text else ""
)
body_rows.append(
""
f"{html.escape(str(name or ''))} "
f"{html.escape(str(roles or ''))} "
f"{badge(status)} "
f"{html.escape(str(ppn or ''))} "
f"{html.escape(str(score or ''))} "
f"{html.escape(str(forms or ''))} "
f"{url_html} "
f"{idref_manual_cell(item, idx)}"
" "
)
elif people:
for person in people:
roles = " | ".join(ROLE_LABELS.get(role, role) for role in person.get("roles") or [])
body_rows.append(
""
f"{html.escape(str(person.get('name') or ''))} "
f"{html.escape(roles)} "
f"{badge('à aligner')} "
" "
"après alignement "
" "
)
else:
return (
"Aucune personne détectée : enregistrez d'abord des "
"métadonnées corrigées (onglet 2).
"
)
head = "".join(f"{html.escape(header)} " for header in IDREF_TABLE_HEADERS)
return (
""
f"
{head} "
f"{''.join(body_rows)}
"
)
def render_idref(aligned: list[dict[str, Any]]) -> str:
if not aligned:
return "Aucun alignement.
"
panels = []
for item in aligned:
name = str(item.get("name") or "")
name_attr = html.escape(name, quote=True)
response = item.get("response") or {}
best = response.get("best_candidate") or {}
candidates = response.get("candidates") or []
best_ppn = str(response.get("best_ppn") or best.get("ppn") or "")
manual_ppn = str(item.get("manual_ppn") or "")
# Le candidat déplié par défaut : le PPN validé manuellement, sinon le mieux scoré.
open_ppn = manual_ppn or best_ppn
blocks = []
for candidate in candidates[:8]:
ppn = str(candidate.get("ppn") or "")
evidence = candidate.get("evidence") or {}
forms = " | ".join(evidence.get("preferred_forms") or [])
tags = []
if ppn and ppn == best_ppn:
tags.append("meilleur score ")
if manual_ppn and ppn == manual_ppn:
tags.append("retenu · validé manuellement ")
url = str(candidate.get("url") or (f"https://www.idref.fr/{ppn}" if ppn else ""))
summary = (
""
f"{html.escape(ppn) or '?'} "
f" · score {score_value(candidate) or '?'}"
+ (f" · {html.escape(forms)}" if forms else "")
+ (" " + " ".join(tags) if tags else "")
+ " "
)
body = f"""
Fiche idref.fr
{score_table(candidate.get('score') or {})}
Formes préférées {list_html(evidence.get('preferred_forms') or [])}
Meilleure source attrra {html.escape(str(evidence.get('best_attrra_source') or ''))}
Meilleure note attrra {html.escape(str(evidence.get('best_attrra_note') or ''))}
Meilleures références {list_html(evidence.get('best_references') or [])}
Retenir ce PPN
"""
open_attr = " open" if (ppn and ppn == open_ppn) else ""
blocks.append(f"{summary}{body} ")
candidates_html = "\n".join(blocks) if blocks else "Aucun candidat.
"
panels.append(
f"""
{html.escape(name)}
Rôles : {html.escape(' | '.join(ROLE_LABELS.get(role, role) for role in item.get('roles') or []))}
Décision : {badge(response.get('status'))}
PPN retenu : {html.escape(str(item.get('manual_ppn') or response.get('best_ppn') or 'aucun'))}{" (validé manuellement) " if item.get('manual_ppn') else ""}
Candidats ({len(candidates)})
Dépliez un candidat pour comparer ses attributs (formes préférées, sources/notes attrra, références) ; « Retenir ce PPN » le valide comme alignement fort même si la décision du service n'est pas « accepted ».
{candidates_html}
"""
)
return "\n".join(panels)
def render_dewey(
classes: list[dict[str, Any]],
selected: dict[str, Any] | None,
method: str | None = None,
model: str | None = None,
) -> str:
if not classes:
return "Aucune classe Dewey proposée.
"
selected_code = str((selected or {}).get("code")) if selected else None
def fmt_score(value: Any) -> str:
return f"{float(value):.4f}" if isinstance(value, int | float) else ""
rows = "".join(
""
f"{html.escape(str(cls.get('dewey') or ''))} "
f"{html.escape(str(cls.get('label') or ''))} "
f"{fmt_score(cls.get('score'))} "
f"{'✓' if str(cls.get('dewey')) == selected_code else ''} "
" "
for cls in classes
)
meta_bits = []
if method:
meta_bits.append(f"méthode {html.escape(str(method))} ")
if model:
meta_bits.append(f"modèle {html.escape(str(model))}")
meta = f"{' · '.join(meta_bits)}
" if meta_bits else ""
return f"""
Classes Dewey proposées (par score décroissant)
{meta}
Code Label Score Retenu
{rows}
"""
def CSS() -> str:
return """
/*.gradio-container { max-width: 1320px !important; }*/
.gradio-container {margin: 0 !important}
.status-card { border-radius: 8px; padding: 12px 14px; margin: 8px 0; }
.summary-grid { display:grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap:10px; margin: 8px 0 14px; }
.summary-grid > div, .panel { border:1px solid #e5e7eb; border-radius:8px; padding:12px; background:#fff; }
.badge { display:inline-block; padding:4px 9px; border-radius:999px; font-weight:700; font-size:13px; }
.muted { color:#6b7280; }
.data-table { width:100%; border-collapse:collapse; margin-top:10px; }
.data-table th, .data-table td { border-bottom:1px solid #e5e7eb; padding:7px 8px; text-align:left; vertical-align:top; }
.data-table th { background:#f9fafb; font-weight:700; }
.evidence-grid { display:grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap:10px; }
pre { white-space: pre-wrap; background:#f9fafb; border:1px solid #e5e7eb; border-radius:8px; padding:12px; }
.corpus-load-btn, .idref-validate-btn, .idref-pick-btn { cursor:pointer; border:1px solid #d1d5db; background:#f3f4f6; border-radius:6px; padding:3px 10px; font-size:12px; font-weight:600; white-space:nowrap; }
.corpus-load-btn:hover, .idref-validate-btn:hover, .idref-pick-btn:hover { background:#e5e7eb; }
.corpus-bridge { display:none !important; }
.idref-manual-cell { white-space:nowrap; }
.idref-ppn-input { border:1px solid #d1d5db; border-radius:6px; padding:3px 8px; width:120px; font-size:13px; }
.candidate-details { border:1px solid #e5e7eb; border-radius:8px; margin:8px 0; background:#fff; }
.candidate-details > summary { cursor:pointer; padding:8px 12px; }
.candidate-details[open] > summary { border-bottom:1px solid #e5e7eb; }
.candidate-details .candidate-body { padding:10px 12px; }
@media (max-width: 900px) {
.summary-grid, .evidence-grid { grid-template-columns: 1fr; }
}
"""