Spaces:
Sleeping
Sleeping
| """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 "") | |
| + (" " + " ".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; } | |
| } | |
| """ | |