"""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"Aperçu de la page de titre" ) 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"{rows}
ComposanteScore
" def list_html(values: list[Any]) -> str: if not values: return "

Aucun indice.

" return "" 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"" 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"" "" ) 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 [])}
""" 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} {rows}
CodeLabelScoreRetenu
""" 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; } } """