Geraldine's picture
Update render.py
8c4e0dd verified
Raw
History Blame Contribute Delete
21.6 kB
"""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 "<p class='muted'>Aucune image à prévisualiser (saisissez une URL ou sélectionnez-en une dans « Corpus / Dataset »).</p>"
src = html.escape(url, quote=True)
return (
f"<img src='{src}' alt='Aperçu de la page de titre' "
"style='max-width:100%;max-height:600px;border:1px solid #e5e7eb;border-radius:8px' "
"loading='lazy' />"
)
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"<div class='status-card' style='background:{bg};color:{fg}'>"
f"<strong>{html.escape(title)}</strong><br>{html.escape(message)}</div>"
)
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"<span class='badge' style='background:{bg};color:{fg}'>{html.escape(text)}</span>"
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"<br><span class='badge' style='background:{bg};color:{fg};font-size:11px'>"
f"{html.escape(text)}</span>"
)
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"""
<div class="summary-grid">
<div><strong>VLM</strong><br>{badge(vlm_status)}{note_badge("vlm", notes)}</div>
<div><strong>Sudoc</strong><br>{badge(sudoc_status)}{note_badge("sudoc", notes)}</div>
<div><strong>IdRef</strong><br>{badge(idref_label)}{note_badge("idref", notes)}</div>
<div><strong>Dewey</strong><br>{badge(dewey_label)}{note_badge("dewey", notes)}</div>
<div><strong>Brouillon</strong><br>{badge(draft_status)}{note_badge("draft", notes)}</div>
</div>
"""
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 <code>{html.escape(str(sru_filter))}</code>")
else:
filter_parts.append("aucun filtre <code>tdo</code>")
if note_subs:
filter_parts.append("NTH contient " + ", ".join(f'<code>{html.escape(str(sub))}</code>' for sub in note_subs))
filter_label = " · ".join(filter_parts)
excluded_html = (
f"<p class='muted'>Candidats écartés par le filtre profil : {int(excluded)}</p>"
if isinstance(excluded, int) and excluded > 0
else ""
)
def candidate_panel(title: str, candidate: dict[str, Any] | None) -> str:
if not candidate:
return f"<section class='panel'><h3>{html.escape(title)}</h3><p class='muted'>Aucun candidat.</p></section>"
evidence = candidate.get("evidence") or {}
return f"""
<section class="panel">
<h3>{html.escape(title)}</h3>
<p><strong>PPN :</strong> <a href="{html.escape(candidate.get('url') or '')}" target="_blank">{html.escape(candidate.get('ppn') or '')}</a></p>
<p><strong>Titre :</strong> {html.escape(str(candidate.get('title') or ''))}</p>
<p><strong>Support :</strong> {badge(candidate.get('carrier'))}</p>
<p><strong>Compte comme doublon imprimé :</strong> {html.escape(str(candidate.get('counts_as_print_duplicate')))}</p>
{score_table(candidate.get('score') or {})}
<h4>Indices de support</h4>
{list_html(evidence.get('carrier_evidence') or [])}
<h4>Requêtes SRU correspondantes</h4>
{list_html(evidence.get('matched_queries') or [])}
</section>
"""
return f"""
<section class="panel">
<h3>Décision Sudoc</h3>
<p>{badge(result.get('status'))}</p>
<p><strong>Profil documentaire :</strong> <code>{html.escape(profile_name)}</code></p>
<p><strong>Score doublon imprimé :</strong> {html.escape(str(result.get('duplicate_score')))}</p>
<p><strong>Stratégie SRU :</strong> {filter_label}</p>
{excluded_html}
</section>
{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"<tr><td>{html.escape(str(key))}</td><td><strong>{float(value):.4f}</strong></td></tr>"
for key, value in score.items()
if isinstance(value, int | float)
)
return f"<table class='data-table'><thead><tr><th>Composante</th><th>Score</th></tr></thead><tbody>{rows}</tbody></table>"
def list_html(values: list[Any]) -> str:
if not values:
return "<p class='muted'>Aucun indice.</p>"
return "<ul>" + "".join(f"<li>{html.escape(str(value))}</li>" for value in values) + "</ul>"
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
<datalist> 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"<option value=\"{html.escape(str(candidate.get('ppn') or ''), quote=True)}\">"
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 "")
+ "</option>"
for candidate in response.get("candidates") or []
if str(candidate.get("ppn") or "").strip()
)
dl_id = f"idref-dl-{idx}"
return (
"<td class='idref-manual-cell'>"
f"<input class='idref-ppn-input' list='{dl_id}' value=\"{html.escape(current, quote=True)}\" "
"placeholder='PPN' title='Choisissez un PPN candidat ou saisissez-en un' />"
f"<datalist id='{dl_id}'>{options}</datalist> "
f"<button type='button' class='idref-validate-btn' data-name=\"{name_attr}\">Valider</button>"
"</td>"
)
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"<a href=\"{html.escape(url_text, quote=True)}\" target='_blank'>{html.escape(url_text)}</a>"
if url_text else ""
)
body_rows.append(
"<tr>"
f"<td>{html.escape(str(name or ''))}</td>"
f"<td>{html.escape(str(roles or ''))}</td>"
f"<td>{badge(status)}</td>"
f"<td>{html.escape(str(ppn or ''))}</td>"
f"<td>{html.escape(str(score or ''))}</td>"
f"<td>{html.escape(str(forms or ''))}</td>"
f"<td>{url_html}</td>"
f"{idref_manual_cell(item, idx)}"
"</tr>"
)
elif people:
for person in people:
roles = " | ".join(ROLE_LABELS.get(role, role) for role in person.get("roles") or [])
body_rows.append(
"<tr>"
f"<td>{html.escape(str(person.get('name') or ''))}</td>"
f"<td>{html.escape(roles)}</td>"
f"<td>{badge('à aligner')}</td>"
"<td></td><td></td><td></td><td></td>"
"<td><span class='muted'>après alignement</span></td>"
"</tr>"
)
else:
return (
"<p class='muted'>Aucune personne détectée : enregistrez d'abord des "
"métadonnées corrigées (onglet 2).</p>"
)
head = "".join(f"<th>{html.escape(header)}</th>" for header in IDREF_TABLE_HEADERS)
return (
"<div style='overflow-x:auto'>"
f"<table class='data-table'><thead><tr>{head}</tr></thead>"
f"<tbody>{''.join(body_rows)}</tbody></table></div>"
)
def render_idref(aligned: list[dict[str, Any]]) -> str:
if not aligned:
return "<p class='muted'>Aucun alignement.</p>"
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("<span class='badge' style='background:#dbeafe;color:#1d4ed8'>meilleur score</span>")
if manual_ppn and ppn == manual_ppn:
tags.append("<span class='badge' style='background:#dcfce7;color:#166534'>retenu · validé manuellement</span>")
url = str(candidate.get("url") or (f"https://www.idref.fr/{ppn}" if ppn else ""))
summary = (
"<summary>"
f"<strong>{html.escape(ppn) or '?'}</strong>"
f" · score {score_value(candidate) or '?'}"
+ (f" · {html.escape(forms)}" if forms else "")
+ ("&nbsp;" + " ".join(tags) if tags else "")
+ "</summary>"
)
body = f"""
<div class='candidate-body'>
<p><a href="{html.escape(url, quote=True)}" target='_blank'>Fiche idref.fr</a></p>
{score_table(candidate.get('score') or {})}
<div class="evidence-grid">
<div><h4>Formes préférées</h4>{list_html(evidence.get('preferred_forms') or [])}</div>
<div><h4>Meilleure source attrra</h4><p>{html.escape(str(evidence.get('best_attrra_source') or ''))}</p></div>
<div><h4>Meilleure note attrra</h4><p>{html.escape(str(evidence.get('best_attrra_note') or ''))}</p></div>
<div><h4>Meilleures références</h4>{list_html(evidence.get('best_references') or [])}</div>
</div>
<button type='button' class='idref-pick-btn' data-name="{name_attr}" data-ppn="{html.escape(ppn, quote=True)}">Retenir ce PPN</button>
</div>
"""
open_attr = " open" if (ppn and ppn == open_ppn) else ""
blocks.append(f"<details class='candidate-details'{open_attr}>{summary}{body}</details>")
candidates_html = "\n".join(blocks) if blocks else "<p class='muted'>Aucun candidat.</p>"
panels.append(
f"""
<section class="panel">
<h3>{html.escape(name)}</h3>
<p><strong>Rôles :</strong> {html.escape(' | '.join(ROLE_LABELS.get(role, role) for role in item.get('roles') or []))}</p>
<p><strong>Décision :</strong> {badge(response.get('status'))}</p>
<p><strong>PPN retenu :</strong> {html.escape(str(item.get('manual_ppn') or response.get('best_ppn') or 'aucun'))}{" <em>(validé manuellement)</em>" if item.get('manual_ppn') else ""}</p>
<h4>Candidats ({len(candidates)})</h4>
<p class='muted'>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 ».</p>
{candidates_html}
</section>
"""
)
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 "<p class='muted'>Aucune classe Dewey proposée.</p>"
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(
"<tr>"
f"<td>{html.escape(str(cls.get('dewey') or ''))}</td>"
f"<td>{html.escape(str(cls.get('label') or ''))}</td>"
f"<td>{fmt_score(cls.get('score'))}</td>"
f"<td>{'✓' if str(cls.get('dewey')) == selected_code else ''}</td>"
"</tr>"
for cls in classes
)
meta_bits = []
if method:
meta_bits.append(f"méthode <strong>{html.escape(str(method))}</strong>")
if model:
meta_bits.append(f"modèle <code>{html.escape(str(model))}</code>")
meta = f"<p class='muted'>{' · '.join(meta_bits)}</p>" if meta_bits else ""
return f"""
<section class="panel">
<h3>Classes Dewey proposées (par score décroissant)</h3>
{meta}
<table class="data-table">
<thead><tr><th>Code</th><th>Label</th><th>Score</th><th>Retenu</th></tr></thead>
<tbody>{rows}</tbody>
</table>
</section>
"""
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; }
}
"""