Spaces:
Running
Running
| """ | |
| GuichetOI ML — Streamlit demo. | |
| One-page workflow: upload all files for a demande de localisation PAR | |
| (loose files OR a ZIP archive of the demande folder), and the recommendation | |
| engine produces a complétude verdict + a draft AR mail. | |
| Run: | |
| pip install -e .[ui] # one-time, installs guichetoi | |
| streamlit run apps/streamlit_demo.py | |
| """ | |
| from __future__ import annotations | |
| import io | |
| import sys | |
| import tempfile | |
| import zipfile | |
| from pathlib import Path | |
| import streamlit as st | |
| # Repo layout: apps/streamlit_demo.py → parents[1] = repo root → repo_root/src/ | |
| # When the package is installed via `pip install -e .` this is a no-op; we keep | |
| # the sys.path insert so the demo also runs straight from a fresh checkout. | |
| ROOT = Path(__file__).resolve().parents[1] | |
| sys.path.insert(0, str(ROOT / "src")) | |
| from guichetoi import cms as cms_gen | |
| from guichetoi import inference, recommendation as reco | |
| def get_pipeline(): | |
| return inference.GuichetOIPipeline() | |
| def get_engine(): | |
| return reco.RecommendationEngine(pipeline=get_pipeline()) | |
| # ──────────────────────────────────────────────────────────────────────────── | |
| # Demo samples — pre-cached verdicts so the demo recording stays snappy | |
| # ──────────────────────────────────────────────────────────────────────────── | |
| import json as _json | |
| def load_sample_verdicts() -> dict[str, dict]: | |
| """Read assets/sample_verdicts.json and index by ZIP basename.""" | |
| p = ROOT / "assets" / "sample_verdicts.json" | |
| if not p.exists(): | |
| return {} | |
| data = _json.loads(p.read_text(encoding="utf-8")) | |
| return {r["zip"]: r["verdict"] for r in data if r.get("verdict")} | |
| # Curated demo flow: one example per outcome, in narrative order | |
| DEMO_SAMPLES: list[tuple[str, str, str]] = [ | |
| ("✅ Demande complète — PIM résidentiel", | |
| "Cas standard : 1 logement, tous les champs extraits, CMS pré-rempli.", | |
| "PF0442402600168.zip"), | |
| ("✅ Demande complète — noms de fichiers atypiques", | |
| "Filenames ALL-CAPS sans préfixe PF : 'ARRETE PC', 'CERTIFICAT ADRESSAGE'. " | |
| "Les heuristiques de nom de fichier corrigent la classification.", | |
| "PF0331402600885.zip"), | |
| ("⚠️ Demande incomplète — collectif, champ manquant", | |
| "Projet collectif (14 logements). nb_log_totale non lisible sur la fiche → " | |
| "incomplète, mais le consultant peut toujours générer un CMS partiel.", | |
| "PF0335202600876.zip"), | |
| ("🔁 Hors-périmètre — dossier de récolement", | |
| "Fichiers post-installation (tranchées, points de raccordement). Détecté " | |
| "automatiquement et routé en vérification manuelle.", | |
| "PF0820002600007_Dossier-de-recolement_RAR-1-1_1.zip"), | |
| ] | |
| def verdict_from_dict(d: dict) -> "reco.Verdict": | |
| """Reconstruct a Verdict dataclass from its dict serialisation.""" | |
| docs = [] | |
| for doc_d in d.get("documents", []) or []: | |
| docs.append(reco.DocumentSummary( | |
| file=doc_d.get("file", ""), | |
| doc_class=doc_d.get("doc_class", ""), | |
| doc_confidence=float(doc_d.get("doc_confidence", 0.0) or 0.0), | |
| fields=doc_d.get("fields", {}) or {}, | |
| flags=list(doc_d.get("flags", []) or []), | |
| )) | |
| return reco.Verdict( | |
| status=d.get("status", ""), | |
| missing_documents=list(d.get("missing_documents", []) or []), | |
| incomplete_documents=list(d.get("incomplete_documents", []) or []), | |
| documents=docs, | |
| fiche_summary=d.get("fiche_summary", {}) or {}, | |
| manual_review_documents=list(d.get("manual_review_documents", []) or []), | |
| ar_mail_body=d.get("ar_mail_body", ""), | |
| ) | |
| # ──────────────────────────────────────────────────────────────────────────── | |
| # Constants — class icons, field names, expected doc set | |
| # ──────────────────────────────────────────────────────────────────────────── | |
| CLASS_ICON: dict[str, str] = { | |
| "fiche": "📋", | |
| "Autorisation": "📜", | |
| "Mandat": "✍️", | |
| "Certificat": "📌", | |
| "PlanMasse": "🗺️", | |
| "PlanSituation": "📍", | |
| } | |
| CLASS_LABEL: dict[str, str] = { | |
| "fiche": "Fiche de renseignement", | |
| "Autorisation": "Autorisation d'urbanisme", | |
| "Mandat": "Mandat", | |
| "Certificat": "Certificat d'adressage", | |
| "PlanMasse": "Plan de masse", | |
| "PlanSituation": "Plan de situation", | |
| } | |
| FIELD_LABEL_FR: dict[str, str] = { | |
| "Reference_Urbanisme": "N° d'urbanisme", | |
| "DLPI": "Date de livraison (DLPI)", | |
| "Disposition_Mandat": "Mandat de représentation", | |
| "Nombre_Logement_Lot_MacroLot": "Nb logements/lots/macrolots", | |
| "Nb_log_pro": "Bâtiments professionnels", | |
| "Nb_log_res": "Bâtiments résidentiels", | |
| "nb_log_totale": "Nb total de logements", | |
| "cabinet_conseil": "Cabinet conseil", | |
| "Representant_Nom_Complet": "Nom du représentant", | |
| "Representant_Telephone": "Téléphone", | |
| "Representant_Email": "Email", | |
| "Batiment_Adresse": "Adresse du bâtiment", | |
| } | |
| EXPECTED_CLASSES = ("fiche", "Autorisation", "PlanMasse", "PlanSituation", "Mandat") | |
| # ──────────────────────────────────────────────────────────────────────────── | |
| # Page setup + global CSS | |
| # ──────────────────────────────────────────────────────────────────────────── | |
| st.set_page_config( | |
| page_title="Orange · Guichet Accueil Infrastructures", | |
| page_icon="🟧", | |
| layout="wide", | |
| initial_sidebar_state="expanded", | |
| ) | |
| st.markdown( | |
| """ | |
| <style> | |
| :root { | |
| --bg: #07101e; | |
| --surface: rgba(15, 23, 39, 0.92); | |
| --surface-strong: #11192c; | |
| --text: #f5f7fb; | |
| --muted: #aab3c2; | |
| --border: rgba(255, 121, 0, 0.20); | |
| --shadow: 0 22px 60px rgba(0, 0, 0, 0.32); | |
| --accent: #ff7900; /* Orange brand color */ | |
| --accent-soft: rgba(255, 121, 0, 0.18); | |
| --accent-bright: #ff9a3d; | |
| } | |
| html, body, [class*="css"] { | |
| color: var(--text); | |
| font-family: "Aptos", "Segoe UI", "Trebuchet MS", sans-serif; | |
| } | |
| .stApp { | |
| background: | |
| radial-gradient(circle at top left, rgba(255, 121, 0, 0.18), transparent 32%), | |
| radial-gradient(circle at top right, rgba(255, 154, 61, 0.10), transparent 24%), | |
| linear-gradient(180deg, #0a121f 0%, var(--bg) 100%); | |
| color: var(--text); | |
| } | |
| .block-container { | |
| padding-top: 2rem; | |
| max-width: 1400px; | |
| color: var(--text); | |
| } | |
| h1, h2, h3, h4, h5, h6, p, label, span, div { | |
| color: inherit; | |
| } | |
| h1 { letter-spacing: -0.03em; } | |
| .stMarkdown, .stCaption, .stMetric, .stText, .stSelectbox, .stFileUploader { | |
| color: var(--text); | |
| } | |
| section[data-testid="stSidebar"] { | |
| background: linear-gradient(180deg, rgba(14, 22, 38, 0.98), rgba(8, 17, 31, 0.98)); | |
| border-right: 1px solid var(--border); | |
| } | |
| section[data-testid="stSidebar"] * { | |
| color: var(--text); | |
| } | |
| .stTabs [data-baseweb="tab-list"] { | |
| gap: 0.5rem; | |
| } | |
| .stTabs [data-baseweb="tab"] { | |
| background: rgba(255,255,255,0.04); | |
| border: 1px solid var(--border); | |
| border-radius: 999px; | |
| padding: 0.55rem 1rem; | |
| color: var(--muted); | |
| box-shadow: 0 4px 18px rgba(0, 0, 0, 0.16); | |
| } | |
| .stTabs [aria-selected="true"] { | |
| background: var(--surface-strong); | |
| color: var(--text); | |
| border-color: var(--accent); | |
| } | |
| .stApp [data-testid="stHeader"] { | |
| background: transparent; | |
| } | |
| /* Orange brand logo (recreated in CSS to avoid external assets) */ | |
| .orange-logo { | |
| display: inline-flex; | |
| align-items: flex-end; | |
| justify-content: flex-start; | |
| background: #ff7900; | |
| color: #ffffff; | |
| font-family: "Helvetica Neue", "Arial Black", sans-serif; | |
| font-weight: 900; | |
| font-size: 28px; | |
| line-height: 1; | |
| letter-spacing: -0.02em; | |
| padding: 14px 16px 12px; | |
| border-radius: 6px; | |
| width: 96px; | |
| height: 96px; | |
| box-shadow: 0 14px 32px rgba(255, 121, 0, 0.32); | |
| } | |
| .orange-logo sup { | |
| font-size: 0.45em; | |
| font-weight: 800; | |
| margin-left: 2px; | |
| vertical-align: super; | |
| } | |
| /* Brand wordmark next to logo */ | |
| .brand-title { | |
| color: var(--text); | |
| font-size: 1.9rem; | |
| font-weight: 800; | |
| letter-spacing: -0.02em; | |
| margin: 0 0 4px 0; | |
| } | |
| .brand-subtitle { | |
| color: var(--muted); | |
| font-size: 0.95rem; | |
| margin: 0; | |
| } | |
| /* Verdict banner */ | |
| .verdict-banner { | |
| padding: 18px 28px; border-radius: 14px; font-weight: 700; | |
| font-size: 1.6em; color: white; text-align: center; | |
| letter-spacing: 0.02em; box-shadow: 0 4px 12px rgba(0,0,0,0.22); | |
| margin: 10px 0 20px 0; | |
| } | |
| .verdict-ok { background: linear-gradient(135deg,#15803d 0%,#22c55e 100%); } | |
| .verdict-bad { background: linear-gradient(135deg,#b91c1c 0%,#ef4444 100%); } | |
| .verdict-review { background: linear-gradient(135deg,#b45309 0%,#f59e0b 100%); } | |
| /* Class badge */ | |
| .cls-badge { | |
| display: inline-block; background:#132238; color:#f8fbff; | |
| padding:6px 14px; border-radius:8px; font-weight:600; | |
| margin-right: 8px; | |
| } | |
| /* Confidence dot */ | |
| .conf-dot { | |
| display: inline-block; padding:3px 10px; border-radius:12px; | |
| color:white; font-size:0.82em; font-weight:600; | |
| margin-left: 6px; | |
| } | |
| .conf-hi { background:#16a34a; } | |
| .conf-mid { background:#ca8a04; } | |
| .conf-lo { background:#dc2626; } | |
| /* Field row */ | |
| .field-row { | |
| display:flex; align-items:center; gap:12px; | |
| padding: 8px 12px; border-radius: 8px; margin-bottom: 6px; | |
| background: rgba(255,255,255,0.04); | |
| } | |
| .field-name { font-family: monospace; color:#94a3b8; min-width: 200px; } | |
| .field-value{ flex:1; font-weight:600; color:#f8fbff; } | |
| /* Doc checklist */ | |
| .check-row { | |
| display:flex; align-items:center; gap:10px; | |
| padding: 8px 14px; border-radius: 8px; margin-bottom: 4px; | |
| background: rgba(255,255,255,0.04); | |
| } | |
| .check-ok { color:#4ade80; font-weight:700; } | |
| .check-no { color:#94a3b8; } | |
| /* Streamlit widgets */ | |
| div[data-testid="stMetric"] { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 16px; | |
| padding: 0.9rem 1rem; | |
| box-shadow: var(--shadow); | |
| } | |
| div[data-testid="stMetric"] * { | |
| color: var(--text); | |
| } | |
| .stTextArea textarea { | |
| background: rgba(7, 13, 24, 0.96); | |
| color: var(--text) !important; | |
| border: 1px solid var(--border); | |
| border-radius: 14px; | |
| } | |
| div[data-testid="stFileUploader"] { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 16px; | |
| box-shadow: var(--shadow); | |
| padding: 0.35rem 0.75rem 0.5rem; | |
| } | |
| details { | |
| background: var(--surface); | |
| border: 1px solid var(--border); | |
| border-radius: 16px; | |
| box-shadow: var(--shadow); | |
| } | |
| hr { | |
| border-color: var(--border); | |
| } | |
| </style> | |
| """, | |
| unsafe_allow_html=True, | |
| ) | |
| # ──────────────────────────────────────────────────────────────────────────── | |
| # UI helpers | |
| # ──────────────────────────────────────────────────────────────────────────── | |
| def conf_class(pct: float) -> str: | |
| if pct >= 0.85: return "conf-hi" | |
| if pct >= 0.60: return "conf-mid" | |
| return "conf-lo" | |
| def confidence_dot(pct: float) -> str: | |
| return f"<span class='conf-dot {conf_class(pct)}'>{pct:.0%}</span>" | |
| def class_pill(name: str, conf: float) -> str: | |
| icon = CLASS_ICON.get(name, "📄") | |
| label = CLASS_LABEL.get(name, name) | |
| return (f"<span class='cls-badge'>{icon} {label}</span>" | |
| f"{confidence_dot(conf)}") | |
| def verdict_banner(status: str, needs_review: bool = False): | |
| if status == "hors-périmètre": | |
| label = "🔁 HORS PÉRIMÈTRE — routage manuel requis" | |
| cls = "verdict-review" | |
| elif status.startswith("complèt"): | |
| if needs_review: | |
| label = "✅ COMPLÈTE — sous réserve de vérification manuelle" | |
| cls = "verdict-review" | |
| else: | |
| label = "✅ DEMANDE COMPLÈTE" | |
| cls = "verdict-ok" | |
| else: | |
| label = "⚠️ DEMANDE INCOMPLÈTE" | |
| cls = "verdict-bad" | |
| st.markdown(f"<div class='verdict-banner {cls}'>{label}</div>", | |
| unsafe_allow_html=True) | |
| def render_field_row(field_name: str, value: str, confidence: float): | |
| pretty = FIELD_LABEL_FR.get(field_name, field_name) | |
| st.markdown( | |
| f"<div class='field-row'>" | |
| f"<span class='field-name'>{pretty}</span>" | |
| f"<span class='field-value'>{value}</span>" | |
| f"{confidence_dot(confidence)}" | |
| f"</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| def render_page_preview(file_bytes: bytes, suffix: str, zoom: float = 1.2): | |
| try: | |
| import fitz | |
| from PIL import Image | |
| except ImportError: | |
| st.warning("PyMuPDF / Pillow non disponible — aperçu désactivé.") | |
| return | |
| if suffix.lower() == ".pdf": | |
| with fitz.open(stream=file_bytes, filetype="pdf") as doc: | |
| if len(doc) == 0: | |
| st.warning("PDF vide.") | |
| return | |
| pix = doc[0].get_pixmap(matrix=fitz.Matrix(zoom, zoom)) | |
| img = Image.frombytes("RGB", (pix.width, pix.height), pix.samples) | |
| else: | |
| img = Image.open(io.BytesIO(file_bytes)).convert("RGB") | |
| st.image(img, use_container_width=True) | |
| def write_uploaded_to_tempfile(uploaded) -> Path: | |
| suffix = Path(uploaded.name).suffix or ".bin" | |
| tmp = tempfile.NamedTemporaryFile(prefix="guichetoi_", suffix=suffix, delete=False) | |
| tmp.write(uploaded.getbuffer()) | |
| tmp.close() | |
| return Path(tmp.name) | |
| SUPPORTED_EXTS = {".pdf", ".png", ".jpg", ".jpeg", ".bmp", ".tif", ".tiff"} | |
| def collect_files(uploaded_files) -> list[Path]: | |
| """ | |
| Take Streamlit UploadedFile objects (regular docs and/or .zip archives) | |
| and return a flat list of paths on disk pointing at every supported | |
| document inside. ZIP contents are extracted to a temp directory. | |
| Hidden files and macOS resource forks (`__MACOSX/…`, `._foo`) are skipped. | |
| """ | |
| out: list[Path] = [] | |
| for f in uploaded_files: | |
| suffix = Path(f.name).suffix.lower() | |
| if suffix == ".zip": | |
| extract_dir = Path(tempfile.mkdtemp(prefix="guichetoi_zip_")) | |
| try: | |
| with zipfile.ZipFile(io.BytesIO(f.getbuffer())) as zf: | |
| zf.extractall(extract_dir) | |
| except zipfile.BadZipFile: | |
| st.error(f"« {f.name} » n'est pas un ZIP valide.") | |
| continue | |
| for p in extract_dir.rglob("*"): | |
| if not p.is_file(): | |
| continue | |
| if p.suffix.lower() not in SUPPORTED_EXTS: | |
| continue | |
| if p.name.startswith("._") or "__MACOSX" in p.parts: | |
| continue | |
| out.append(p) | |
| elif suffix in SUPPORTED_EXTS: | |
| out.append(write_uploaded_to_tempfile(f)) | |
| else: | |
| st.warning(f"Format non supporté ignoré : {f.name}") | |
| return out | |
| # ──────────────────────────────────────────────────────────────────────────── | |
| # Header | |
| # ──────────────────────────────────────────────────────────────────────────── | |
| col_logo, col_title = st.columns([1, 8]) | |
| with col_logo: | |
| logo_path = ROOT / "assets" / "fibergate_logo.svg" | |
| if logo_path.exists(): | |
| st.image(str(logo_path), width=140) | |
| else: | |
| # Inline CSS fallback (no asset required) — keeps the brand visible | |
| st.markdown( | |
| "<div class='orange-logo'>FiberGate</div>", | |
| unsafe_allow_html=True, | |
| ) | |
| with col_title: | |
| st.markdown( | |
| "<p class='brand-title'>Guichet Accueil Infrastructures</p>" | |
| "<p class='brand-subtitle'>Analyse automatique des demandes de " | |
| "localisation du Point d'Accès au Réseau (PAR). Téléversez les pièces — " | |
| "individuellement ou en archive ZIP — et récupérez le verdict de " | |
| "complétude et le brouillon d'accusé de réception.</p>", | |
| unsafe_allow_html=True, | |
| ) | |
| st.markdown("---") | |
| # ──────────────────────────────────────────────────────────────────────────── | |
| # Sidebar | |
| # ──────────────────────────────────────────────────────────────────────────── | |
| with st.sidebar: | |
| st.markdown("## 📘 Mode d'emploi") | |
| st.markdown( | |
| "1. **Téléversez** tous les fichiers de la demande " | |
| "(individuellement ou via un ZIP du dossier).\n" | |
| "2. Le moteur **identifie** chaque document.\n" | |
| "3. Il **extrait** les champs métier (n° d'urbanisme, " | |
| "DLPI, nb de logements, etc.).\n" | |
| "4. Il **détecte** les pièces manquantes ou incomplètes.\n" | |
| "5. Téléchargez le **brouillon de mail** d'accusé de réception." | |
| ) | |
| st.markdown("---") | |
| st.markdown("### Pièces attendues") | |
| for cls in EXPECTED_CLASSES: | |
| st.markdown(f"{CLASS_ICON[cls]} {CLASS_LABEL[cls]}") | |
| st.markdown("---") | |
| st.caption( | |
| "Modèle : LayoutLMv3 fine-tuné · 6 classes · 13 champs · " | |
| "post-traitement par règles." | |
| ) | |
| # ════════════════════════════════════════════════════════════════════════════ | |
| # Main view — upload + analyse + verdict | |
| # ════════════════════════════════════════════════════════════════════════════ | |
| st.markdown("### Vérification d'une demande de localisation PAR") | |
| st.caption( | |
| "Choisissez un échantillon de démonstration ci-dessous **ou** téléversez vos " | |
| "propres fichiers (un par un, en multi-sélection, ou en archive ZIP)." | |
| ) | |
| # ── Demo samples — one click, instant cached result ─────────────────────── | |
| samples_data = load_sample_verdicts() | |
| if samples_data: | |
| st.markdown("#### 🎬 Échantillons de démonstration") | |
| st.caption( | |
| "Cas de référence avec résultats précalculés — affichage instantané pour " | |
| "la présentation. Pour une analyse en direct, utilisez le téléversement plus bas." | |
| ) | |
| sample_cols = st.columns(2) | |
| for i, (label, blurb, zip_name) in enumerate(DEMO_SAMPLES): | |
| if zip_name not in samples_data: | |
| continue | |
| with sample_cols[i % 2]: | |
| if st.button(label, key=f"sample_btn_{i}", use_container_width=True, | |
| help=blurb): | |
| st.session_state["sample_verdict"] = samples_data[zip_name] | |
| st.session_state["sample_label"] = label | |
| st.session_state["sample_zip"] = zip_name | |
| st.caption(blurb) | |
| if st.session_state.get("sample_verdict"): | |
| if st.button("✖ Effacer l'échantillon", key="clear_sample"): | |
| for k in ("sample_verdict", "sample_label", "sample_zip"): | |
| st.session_state.pop(k, None) | |
| st.rerun() | |
| st.markdown("---") | |
| # ── File uploader (live analysis) ───────────────────────────────────────── | |
| st.markdown("#### 📤 Ou téléversez votre propre demande") | |
| uploaded_files = st.file_uploader( | |
| "Glissez-déposez vos fichiers ici (PDF, images ou archive ZIP)", | |
| type=["pdf", "png", "jpg", "jpeg", "bmp", "tif", "tiff", "zip"], | |
| accept_multiple_files=True, | |
| key="multi_upload", | |
| help=( | |
| "Vous pouvez téléverser :\n" | |
| "• un ou plusieurs documents (PDF / image)\n" | |
| "• une archive ZIP contenant tout le dossier de la demande\n" | |
| "Les sous-dossiers à l'intérieur du ZIP sont parcourus automatiquement." | |
| ), | |
| ) | |
| # Determine which source we're using: uploaded files take priority IF the | |
| # user has just uploaded; otherwise fall back to the selected sample. | |
| using_sample = bool(st.session_state.get("sample_verdict")) and not uploaded_files | |
| if not uploaded_files and not using_sample: | |
| st.info( | |
| "👆 Sélectionnez un échantillon ci-dessus pour la démonstration, " | |
| "ou téléversez les fichiers d'une demande réelle." | |
| ) | |
| st.stop() | |
| # ── Build the verdict, either from cache or by running the engine ───────── | |
| if using_sample: | |
| sample_label = st.session_state.get("sample_label", "") | |
| sample_zip = st.session_state.get("sample_zip", "") | |
| st.success( | |
| f"📦 Résultat précalculé — **{sample_label}** · source : `{sample_zip}`" | |
| ) | |
| verdict = verdict_from_dict(st.session_state["sample_verdict"]) | |
| # Inventory of the documents in the cached verdict | |
| with st.expander( | |
| f"Voir les {len(verdict.documents)} fichier(s) analysé(s)", | |
| expanded=False, | |
| ): | |
| for doc in verdict.documents: | |
| st.markdown(f"- `{Path(doc.file).name}`") | |
| else: | |
| # Live mode: extract files (ZIP → flat list), then run engine | |
| with st.spinner("📦 Préparation des fichiers…"): | |
| temp_paths = collect_files(uploaded_files) | |
| if not temp_paths: | |
| st.error("Aucun document exploitable trouvé dans les fichiers téléversés.") | |
| st.stop() | |
| n_zip = sum(1 for f in uploaded_files if Path(f.name).suffix.lower() == ".zip") | |
| header = f"📥 **{len(temp_paths)} document(s) à analyser**" | |
| if n_zip: | |
| header += f" · extraits depuis {n_zip} archive(s) ZIP" | |
| st.markdown(header) | |
| with st.expander("Voir la liste des fichiers", expanded=False): | |
| for p in temp_paths: | |
| st.markdown(f"- `{p.name}`") | |
| with st.spinner(f"🔍 Analyse de {len(temp_paths)} document(s) — peut prendre quelques minutes…"): | |
| engine = get_engine() | |
| verdict = engine.evaluate_files(temp_paths) | |
| # ── Verdict banner | |
| needs_review = bool(getattr(verdict, "manual_review_documents", None)) | |
| verdict_banner(verdict.status, needs_review=needs_review) | |
| # ── Doc checklist + counts | |
| by_class: dict[str, int] = {} | |
| for d in verdict.documents: | |
| by_class[d.doc_class] = by_class.get(d.doc_class, 0) + 1 | |
| st.markdown("#### 📋 Composition de la demande") | |
| cols = st.columns(len(EXPECTED_CLASSES)) | |
| for col, cls in zip(cols, EXPECTED_CLASSES): | |
| n = by_class.get(cls, 0) | |
| icon = CLASS_ICON[cls] | |
| label = CLASS_LABEL[cls] | |
| with col: | |
| if n > 0: | |
| st.metric(f"{icon}\n{label}", n, delta="Présent") | |
| else: | |
| st.metric(f"{icon}\n{label}", "—", delta="Manquant") | |
| st.markdown("---") | |
| # ── Missing / Incomplete details | |
| col_miss, col_inc = st.columns(2) | |
| with col_miss: | |
| st.markdown("#### 🚫 Documents manquants") | |
| if verdict.missing_documents: | |
| for m in verdict.missing_documents: | |
| st.error(m) | |
| else: | |
| st.success("Aucun document manquant") | |
| with col_inc: | |
| st.markdown("#### ⚠️ Documents incomplets") | |
| if verdict.incomplete_documents: | |
| for m in verdict.incomplete_documents: | |
| st.warning(m) | |
| else: | |
| st.success("Aucun document incomplet") | |
| # ── Manual review (separate — does NOT make the demande incomplète) | |
| if getattr(verdict, "manual_review_documents", None): | |
| st.markdown("---") | |
| st.markdown("#### 👤 Vérification manuelle requise") | |
| st.caption( | |
| "Ces documents sont fournis mais le modèle ne peut pas les analyser " | |
| "automatiquement avec certitude. La demande n'est **pas** marquée " | |
| "incomplète pour autant — un consultant doit confirmer manuellement." | |
| ) | |
| for m in verdict.manual_review_documents: | |
| st.info(m) | |
| # ── Fiche summary (always shown if any fiche was processed) | |
| if verdict.fiche_summary: | |
| st.markdown("---") | |
| st.markdown("#### 📋 Synthèse de la fiche de renseignement") | |
| for name, payload in sorted(verdict.fiche_summary.items()): | |
| render_field_row(name, str(payload["value"]), payload["confidence"]) | |
| # ── Per-document detail (collapsed by default) | |
| st.markdown("---") | |
| st.markdown("#### 🗂️ Détails par document") | |
| for d in verdict.documents: | |
| file_name = Path(d.file).name | |
| icon = CLASS_ICON.get(d.doc_class, "📄") | |
| header = f"{icon} **{file_name}** — classé {CLASS_LABEL.get(d.doc_class, d.doc_class)} ({d.doc_confidence:.0%})" | |
| with st.expander(header): | |
| st.markdown(class_pill(d.doc_class, d.doc_confidence), unsafe_allow_html=True) | |
| if d.flags: | |
| nice_flags = [] | |
| for flag in d.flags: | |
| if flag.startswith("class_overridden"): | |
| nice_flags.append("⚙️ classe ajustée par nom de fichier") | |
| elif flag == "plan_inexploitable": | |
| nice_flags.append("⚠️ plan possiblement inexploitable") | |
| elif flag == "low_classification_confidence": | |
| nice_flags.append("ℹ️ classification incertaine") | |
| else: | |
| nice_flags.append(flag) | |
| st.caption(" · ".join(nice_flags)) | |
| if d.fields: | |
| for fname, payload in sorted(d.fields.items()): | |
| render_field_row(fname, str(payload["value"]), payload["confidence"]) | |
| else: | |
| st.caption("(aucun champ extrait pour ce type de document)") | |
| # ── CMS file generation (only when the demande is complète) ────────────── | |
| verdict_dict = verdict.to_dict() | |
| # CMS generation is available for ALL statuses — the consultant chooses when | |
| # to pre-fill the spreadsheet. For non-complete demandes the file will simply | |
| # carry more gaps (listed below the download button) for manual completion. | |
| st.markdown("---") | |
| _is_complete = (verdict.status or "").startswith("complèt") | |
| _is_hors_perim = verdict.status == "hors-périmètre" | |
| st.markdown("#### 📊 Génération du fichier CMS IMMO 9 BANBOU") | |
| if _is_complete: | |
| st.caption( | |
| "La demande est **complète** — le moteur pré-remplit l'onglet " | |
| "*création IMB* (et *création syndic* pour les projets collectifs) " | |
| "avec les informations extraites. Les coordonnées XY (Géoréso), " | |
| "l'identifiant Mondofi et le SIRET restent à compléter manuellement." | |
| ) | |
| elif _is_hors_perim: | |
| st.warning( | |
| "Cette demande est **hors-périmètre** (dossier de récolement). " | |
| "Vous pouvez quand même générer un CMS si nécessaire, mais le " | |
| "fichier n'aura aucun sens métier — utilisez-le uniquement " | |
| "comme gabarit vide." | |
| ) | |
| else: | |
| st.info( | |
| "Cette demande n'est **pas marquée complète**. Vous pouvez quand " | |
| "même générer un CMS partiel pour le compléter manuellement — " | |
| "tous les champs manquants seront listés ci-dessous." | |
| ) | |
| # Preview of what will be filled in the CMS (regardless of status) | |
| cms_preview = cms_gen.summarise_cms_fields(verdict_dict) | |
| cms_cols = st.columns(3) | |
| keys = list(cms_preview.keys()) | |
| for i, k in enumerate(keys): | |
| v = cms_preview[k] | |
| cms_cols[i % 3].metric(k, str(v)) | |
| # Build the CMS xlsx into a temp file then surface as a download_button | |
| try: | |
| out_path = Path(tempfile.gettempdir()) / "GuichetOI_CMS_prerempli.xlsx" | |
| cms_result = cms_gen.fill_cms(verdict_dict, out_path) | |
| with open(out_path, "rb") as f: | |
| cms_bytes = f.read() | |
| btn_label = ( | |
| "⬇️ Télécharger le CMS pré-rempli (.xlsx)" | |
| if _is_complete else | |
| "⬇️ Télécharger le CMS partiel (.xlsx)" | |
| ) | |
| st.download_button( | |
| btn_label, | |
| data=cms_bytes, | |
| file_name="GuichetOI_CMS_prerempli.xlsx", | |
| mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", | |
| use_container_width=True, | |
| ) | |
| # ── Tell the consultant which cells still need attention ────────── | |
| missing_x = cms_result.get("missing_extractions") or [] | |
| manual_x = cms_result.get("manual_lookup") or [] | |
| if missing_x or manual_x: | |
| st.markdown("##### 🛠️ À compléter manuellement avant envoi") | |
| if missing_x: | |
| st.warning( | |
| f"**{len(missing_x)} champ(s) attendu(s) n'ont pas pu être " | |
| "extraits automatiquement** — vérifier dans les documents source " | |
| "et compléter dans le CMS :" | |
| ) | |
| for f in missing_x: | |
| st.markdown(f"- {f}") | |
| if manual_x: | |
| with st.expander( | |
| f"ℹ️ {len(manual_x)} champ(s) toujours saisis manuellement " | |
| "(Géoréso, Mondofi, Siret…)", | |
| expanded=False, | |
| ): | |
| for f in manual_x: | |
| st.markdown(f"- {f}") | |
| except FileNotFoundError as e: | |
| st.error(f"Modèle CMS introuvable : {e}") | |
| except Exception as e: | |
| st.error(f"Erreur lors de la génération du CMS : {e}") | |
| # ── Downloadable artefacts | |
| st.markdown("---") | |
| st.markdown("#### 📨 Brouillon de mail d'accusé de réception") | |
| st.text_area( | |
| "Corps du mail", | |
| value=verdict.ar_mail_body, | |
| height=320, | |
| help="Sélectionnez et copiez pour coller dans MSURVEY.", | |
| key="ar_mail_text", | |
| ) | |
| col_d1, col_d2 = st.columns(2) | |
| with col_d1: | |
| st.download_button( | |
| "⬇️ Télécharger le mail", | |
| data=verdict.ar_mail_body.encode("utf-8"), | |
| file_name="ar_mail.txt", | |
| mime="text/plain", | |
| use_container_width=True, | |
| ) | |
| with col_d2: | |
| import json as _json | |
| st.download_button( | |
| "⬇️ Télécharger le verdict JSON", | |
| data=_json.dumps(verdict.to_dict(), ensure_ascii=False, indent=2).encode("utf-8"), | |
| file_name="verdict.json", | |
| mime="application/json", | |
| use_container_width=True, | |
| ) | |
| with st.expander("📦 Verdict JSON brut"): | |
| st.json(verdict.to_dict()) | |