from __future__ import annotations import base64 import html import json import os from copy import deepcopy from io import BytesIO from pathlib import Path from typing import Any import gradio as gr import requests from PIL import Image DEFAULT_VLM_URL = os.getenv("VLM_API_URL", "https://vlm.smartbiblia.fr/v1/chat/completions") DEFAULT_VLM_MODEL = os.getenv("VLM_MODEL", "Qwen3-VL-8B-Instruct-GGUF") #DEFAULT_VLM_URL = os.getenv("VLM_API_URL", "https://mqt7w4m4abb63whh.eu-west-1.aws.endpoints.huggingface.cloud/v1/chat/completions") #DEFAULT_VLM_MODEL = os.getenv("VLM_MODEL", "unsloth/Qwen3-VL-8B-Instruct-GGUF") DEFAULT_OPENAI_URL = os.getenv("OPENAI_API_URL", "https://api.openai.com/v1/chat/completions") DEFAULT_OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o") DEFAULT_HF_URL = os.getenv("HF_API_URL", "https://router.huggingface.co/v1/chat/completions") HF_MODEL_CHOICES = [ "google/gemma-3-12b-it:featherless-ai", "Qwen/Qwen3.6-35B-A3B:featherless-ai", "moonshotai/Kimi-K2.6:fireworks-ai", ] DEFAULT_HF_MODEL = os.getenv("HF_MODEL", HF_MODEL_CHOICES[0]) DEFAULT_ALBERT_URL = os.getenv("ALBERT_API_URL", "https://albert.api.etalab.gouv.fr/v1/chat/completions") DEFAULT_ALBERT_MODEL = os.getenv("ALBERT_MODEL", "mistralai/Ministral-3-8B-Instruct-2512") # datalab-to/lift, quantized Q8 (GGUF), served by llama.cpp on a HF dedicated endpoint. # Strict JSON-schema model: uses its own prompt template (build_lift_prompt) and # returns arrays for multivalued fields, coerced to pipe-strings downstream. DEFAULT_LIFT_URL = os.getenv("LIFT_API_URL", "https://jpectntdw48b1jip.eu-west-1.aws.endpoints.huggingface.cloud/v1/chat/completions") DEFAULT_LIFT_MODEL = os.getenv("LIFT_MODEL", "prithivMLmods/lift-GGUF") #DEFAULT_LIFT_URL = os.getenv("LIFT_API_URL", "https://vlm.smartbiblia.fr/v1/chat/completions") #DEFAULT_LIFT_MODEL = os.getenv("LIFT_MODEL", "lift-GGUF") DEFAULT_SUDOC_URL = os.getenv("SUDOC_CHECKER_API_URL", "https://sudoc-checker.smartbiblia.fr") DEFAULT_IDREF_URL = os.getenv("IDREF_QUALINKA_API_URL", "https://idref-linker.smartbiblia.fr") DEFAULT_IDREF_API_KEY = os.getenv("IDREF_QUALINKA_API_KEY", "") DEFAULT_DEWEY_URL = os.getenv("DEWEY_CLASSIFICATION_API_URL", "https://dewey-classifier.smartbiblia.fr") DEFAULT_DEWEY_API_KEY = os.getenv("CLASSIFICATION_API_KEY", "") EMBEDDING_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2" # Poids de scoring du service idref-linker (voir README de humatheque-idref-qualinka-api). DEFAULT_WEIGHT_NAME = float(os.getenv("IDREF_WEIGHT_NAME", "0.40")) DEFAULT_WEIGHT_ATTRRA_SOURCE = float(os.getenv("IDREF_WEIGHT_ATTRRA_SOURCE", "0.25")) DEFAULT_WEIGHT_ATTRRA_NOTE = float(os.getenv("IDREF_WEIGHT_ATTRRA_NOTE", "0.15")) DEFAULT_WEIGHT_REFERENCES = float(os.getenv("IDREF_WEIGHT_REFERENCES", "0.15")) DEFAULT_WEIGHT_INSTITUTION_YEAR = float(os.getenv("IDREF_WEIGHT_INSTITUTION_YEAR", "0.05")) # Title-page scans can be huge (e.g. 2597x3670), which makes base64 payloads slow # to upload and slow for the on-premise llama.cpp VLM to process. Downscale the # longest side and re-encode as JPEG before sending. VLM_IMAGE_MAX_SIZE = int(os.getenv("VLM_IMAGE_MAX_SIZE", "768")) #512, 1024,1248 VLM_IMAGE_JPEG_QUALITY = int(os.getenv("VLM_IMAGE_JPEG_QUALITY", "85")) THESIS_DEGREE_TYPE_VALUES = [ "Thèse d'État", "Thèse de doctorat", "Thèse de 3e cycle", "Thèse d'université", "Thèse de docteur-ingénieur", "Thèse d'exercice", ] DISSERTATION_DEGREE_TYPE_VALUES = [ "Habilitation à diriger des recherches", "Mémoire de DEA", "Mémoire de DES", "Mémoire de DESS", "Mémoire de DU", "Mémoire de DIU", "Mémoire de DUT", "Mémoire de maîtrise", "Mémoire de master professionnel 1re année", "Mémoire de master professionnel 2e année", "Mémoire de master recherche 1re année", "Mémoire de master recherche 2e année", ] METADATA_FIELDS = [ "title", "subtitle", "author", "degree_type", "discipline", "granting_institution", "co_tutelle_institutions", "doctoral_school", "defense_year", "advisor", "jury_president", "reviewers", "committee_members", "language", "confidence", ] PERSON_FIELDS = ["author", "advisor", "jury_president", "reviewers", "committee_members"] ROLE_LABELS = { "author": "Auteur", "advisor": "Directeur", "jury_president": "President du jury", "reviewers": "Rapporteur", "committee_members": "Membre du jury", } PERSON_ROLE_CODES = { "author": "070", "advisor_thesis": "727", "advisor_dissertation": "003", "jury_president": "956", "reviewers": "958", "committee_members": "555", } CORPORATE_ROLE_CODES = { "granting_institution": "295", "co_tutelle_institutions": "995", "doctoral_school": "996", "partner_institutions": "985", } STRONG_IDREF_STATUSES = {"accepted"} EXAMPLE_IMAGE_URL = "https://minio.smartbiblia.fr/images/theses/theses.fr/2015EPHE4076/p1.png" def empty_state() -> dict[str, Any]: return { "input": {"doc_type": "these", "image_url": "", "image_filename": ""}, "vlm": {"request": None, "raw_response": None, "extracted": None, "corrected": None, "error": None}, "sudoc": {"request": None, "response": None, "error": None}, "idref": {"persons": [], "settings": {}, "error": None}, "dewey": {"request": None, "response": None, "classes": [], "selected": None, "error": None}, "record_draft": None, "events": [], } def add_event(state: dict[str, Any], step: str, status: str, message: str) -> dict[str, Any]: state = deepcopy(state or empty_state()) state.setdefault("events", []).append({"step": step, "status": status, "message": message}) return state def build_eval_prompt(doc_type: str) -> str: doc_type_norm = str(doc_type or "").strip().lower() if doc_type_norm == "memoire": degree_type_values = DISSERTATION_DEGREE_TYPE_VALUES document_label = "graduate dissertation" else: degree_type_values = THESIS_DEGREE_TYPE_VALUES document_label = "graduate thesis" degree_type_str = " | ".join(degree_type_values) return f"""You are extracting metadata from a French {document_label} title page. General rules: - Return ONLY valid JSON. - Do not add markdown. - Do not add explanations. - Do not invent missing data. - Use null when a field is absent. - Use an empty array [] for co_tutelle_institutions when none are present. - If source text is written in all-caps, transform it in sentence case with french accents. - For person names, apply sentence case (e.g. Jean-Daniel DUBOIS → Jean-Daniel Dubois, MARIE-FRANCE Dupont → Marie-France Dupond) and remove all civility and title prefixes (e.g. Monsieur, Madame, M., Mme, Professeur, Pr, Dr, etc.) before returning the name. Role attribution rules: - A role is explicit only when a textual role label is directly associated with the person. - Assign a person to a specific role ONLY if that role is explicitly written on the title page. - Do NOT infer roles from position, ordering, typography, academic conventions, or prior knowledge. - Do not infer role equivalence from document structure or visual grouping. - If people are listed without explicit role labels, place them ONLY in committee_members. - If no jury or committee members are explicitly listed, use null. - Do NOT guess who is advisor, reviewer, or jury president. {{ "title": "Main title as it appears on the title page", "subtitle": "Subtitle or remainder of the title, usually following a colon; null if not present", "author": "Full name of the author (student) who wrote the {document_label}", "degree_type": "Academic degree sought by the author. Possible values are {degree_type_str}", "discipline": "Academic field or discipline of the {document_label}.", "granting_institution": "Institution where the {document_label} was submitted and the degree is granted", "co_tutelle_institutions": "List of institutions involved in a joint supervision or co-tutelle agreement; empty list if none", "doctoral_school": "Doctoral school or graduate program, if explicitly mentioned", "defense_year": "Year the {document_label} was defended. Format yyyy", "advisor": "ONLY persons explicitly identified as {document_label} advisor/supervisor/director. Use | as separator. Null if the role is not explicitly stated.", "jury_president": "ONLY the person explicitly identified as president/chair of the jury. Null if not explicitly stated.", "reviewers": "ONLY official reviewers/rapporteurs explicitly identified as such. Use | as separator. Null if not explicitly stated.", "committee_members": "Persons explicitly listed as jury or committee members whose role is not explicitly identified as president/chair or reviewer/rapporteur. Include unlabeled persons here. Use | as separator.", "language": "Language in ISO 639-3 codes. Example: fre, eng, ita...", "confidence": "Confidence score between 0.0 and 1.0 reflecting certainty based only on explicitly visible evidence on the page" }}""" def build_lift_prompt(doc_type: str) -> str: """Prompt for the datalab-to/lift model, which enforces a strict JSON schema and is prompted differently from the on-prem VLM: a schema-driven extraction template rather than the free-form role-attribution instructions of build_eval_prompt.""" doc_type_norm = str(doc_type or "").strip().lower() if doc_type_norm == "memoire": degree_type_values = DISSERTATION_DEGREE_TYPE_VALUES document_label = "graduate dissertation" else: degree_type_values = THESIS_DEGREE_TYPE_VALUES document_label = "graduate thesis" degree_type_str = " | ".join(degree_type_values) schema = { "type": "object", "properties": { "title": {"type": "string", "description": "Main title as it appears on the title page. Apply sentence case."}, "subtitle": {"type": "string", "description": "Subtitle or remainder of the title, usually following a colon; null if not present. Apply sentence case."}, "author": {"type": "string", "description": f"Full name of the author (student) who wrote the {document_label}. Apply sentence case and remove all civility/title prefixes (Monsieur, Madame, M., Mme, Professeur, Pr, Dr, etc.)."}, "degree_type": {"type": "string", "enum": list(degree_type_values), "description": f"Academic degree sought by the author. Possible values are {degree_type_str}"}, "discipline": {"type": "string", "description": f"Academic field or discipline of the {document_label}. Apply sentence case."}, "granting_institution": {"type": "string", "description": f"Institution where the {document_label} was submitted and the degree is granted"}, "co_tutelle_institutions": {"type": "array", "items": {"type": "string"}, "description": "List of institutions involved in a joint supervision or co-tutelle agreement; empty list if none"}, "doctoral_school": {"type": "string", "description": "Doctoral school or graduate program, if explicitly mentioned"}, "defense_year": {"type": "integer", "description": f"Year the {document_label} was defended. Format yyyy"}, "advisor": {"type": "array", "items": {"type": "string"}, "description": f"ONLY persons explicitly identified as {document_label} advisor/supervisor/director. Apply sentence case and remove all civility/title prefixes (Monsieur, Madame, M., Mme, Professeur, Pr, Dr, etc.). Empty if the role is not explicitly stated."}, "jury_president": {"type": "string", "description": "ONLY the person explicitly identified as president/chair of the jury. Apply sentence case and remove all civility/title prefixes (Monsieur, Madame, M., Mme, Professeur, Pr, Dr, etc.). Null if not explicitly stated."}, "reviewers": {"type": "array", "items": {"type": "string"}, "description": "ONLY official reviewers/rapporteurs explicitly identified as such. Apply sentence case and remove all civility/title prefixes (Monsieur, Madame, M., Mme, Professeur, Pr, Dr, etc.). Empty if not explicitly stated."}, "committee_members": {"type": "array", "items": {"type": "string"}, "description": "Persons explicitly listed as jury or committee members whose role is not explicitly identified as president/chair or reviewer/rapporteur. Apply sentence case and remove all civility/title prefixes (Monsieur, Madame, M., Mme, Professeur, Pr, Dr, etc.). Include unlabeled persons here."}, "language": {"type": "string", "description": "Language in ISO 639-3 codes. Example: fre, eng, ita..."}, }, } schema_json = json.dumps(schema, ensure_ascii=False, indent=2) return f"""Extract structured data from this document according to the provided JSON schema. The document is provided as images, in page order. ## JSON Schema ```json {schema_json} ``` ## Instructions - Return a JSON object matching the schema - Use the correct type for each field (string, number, array)""" def coerce_lift_response(extracted: dict[str, Any]) -> dict[str, Any]: """The lift model returns arrays for multivalued person fields; tab 2 and the rest of the pipeline expect pipe-separated strings. Join those, leaving co_tutelle_institutions as a list (normalize_metadata handles that one).""" coerced = dict(extracted) for field in ("advisor", "reviewers", "committee_members"): value = coerced.get(field) if isinstance(value, list): coerced[field] = " | ".join(str(item).strip() for item in value if str(item).strip()) return coerced def as_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, indent=2) def load_json_object(text: str, label: str = "JSON") -> dict[str, Any]: try: value = json.loads(text) except json.JSONDecodeError as exc: raise gr.Error(f"{label} invalide: {exc}") from exc if not isinstance(value, dict): raise gr.Error(f"{label} doit être un objet.") return value def compress_image_to_data_url(data: bytes, max_size: int = VLM_IMAGE_MAX_SIZE) -> str: """Downscale and JPEG-encode image bytes, returning a base64 data URI. Keeps the base64 path (llama.cpp may not support image URLs) but shrinks the payload: large scans dominate upload and inference time. Falls back to the raw bytes if Pillow cannot decode the image. """ try: img = Image.open(BytesIO(data)) img.load() except Exception: # Not a decodable image — pass the original bytes through unchanged. encoded = base64.b64encode(data).decode("ascii") return f"data:image/jpeg;base64,{encoded}" if img.mode not in ("RGB", "L"): img = img.convert("RGB") w, h = img.size ratio = min(max_size / w, max_size / h, 1.0) if ratio < 1: img = img.resize((int(w * ratio), int(h * ratio)), Image.LANCZOS) buf = BytesIO() img.save(buf, format="JPEG", quality=VLM_IMAGE_JPEG_QUALITY, optimize=True) encoded = base64.b64encode(buf.getvalue()).decode("ascii") return f"data:image/jpeg;base64,{encoded}" def image_file_to_data_url(path: str | None, max_size: int = VLM_IMAGE_MAX_SIZE) -> str: if not path: return "" return compress_image_to_data_url(Path(path).read_bytes(), max_size) def resolve_image_to_data_url( image_url: str | None, image_path: str | None, max_size: int = VLM_IMAGE_MAX_SIZE, ) -> str: raw = (image_url or "").strip() if not raw: # Fall back to local file return image_file_to_data_url(image_path, max_size) if raw.startswith("data:"): # Already a data URI: decode, recompress, and re-encode. try: header, _, b64 = raw.partition(",") if "base64" in header and b64: return compress_image_to_data_url(base64.b64decode(b64), max_size) except Exception: pass return raw if raw.startswith("http://") or raw.startswith("https://"): # Fetch on our side with a hard timeout — never let llama.cpp do this resp = requests.get(raw, timeout=10, allow_redirects=True) resp.raise_for_status() return compress_image_to_data_url(resp.content, max_size) # Assume raw base64 string without the data URI prefix try: return compress_image_to_data_url(base64.b64decode(raw), max_size) except Exception: return f"data:image/jpeg;base64,{raw}" def resolve_vlm_provider( provider: str, vlm_endpoint: str, vlm_model: str, openai_endpoint: str, openai_model: str, hf_endpoint: str, hf_model: str, albert_endpoint: str, albert_model: str, lift_endpoint: str, lift_model: str, ) -> tuple[str, str, dict[str, str]]: """Return (endpoint, model, auth_headers) for the selected OpenAI-compatible provider.""" provider = str(provider or "vlm").strip().lower() if provider == "openai": api_key = os.getenv("OPENAI_API_KEY", "").strip() if not api_key: raise gr.Error("Définissez la variable d'environnement OPENAI_API_KEY.") endpoint = openai_endpoint.strip() or DEFAULT_OPENAI_URL model = openai_model.strip() or DEFAULT_OPENAI_MODEL return endpoint, model, {"Authorization": f"Bearer {api_key}"} if provider in {"huggingface", "hf"}: api_key = os.getenv("HF_TOKEN", "").strip() if not api_key: raise gr.Error("Définissez la variable d'environnement HF_TOKEN.") endpoint = hf_endpoint.strip() or DEFAULT_HF_URL model = hf_model.strip() or DEFAULT_HF_MODEL return endpoint, model, {"Authorization": f"Bearer {api_key}"} if provider == "albert": api_key = os.getenv("ALBERT_API_KEY", "").strip() if not api_key: raise gr.Error("Définissez la variable d'environnement ALBERT_API_KEY.") endpoint = albert_endpoint.strip() or DEFAULT_ALBERT_URL model = albert_model.strip() or DEFAULT_ALBERT_MODEL return endpoint, model, {"Authorization": f"Bearer {api_key}"} if provider == "lift": # HF dedicated endpoint: authenticate with the same HF token. api_key = os.getenv("HF_TOKEN", "").strip() if not api_key: raise gr.Error("Définissez la variable d'environnement HF_TOKEN.") endpoint = lift_endpoint.strip() or DEFAULT_LIFT_URL model = lift_model.strip() or DEFAULT_LIFT_MODEL return endpoint, model, {"Authorization": f"Bearer {api_key}"} endpoint = vlm_endpoint.strip() or DEFAULT_VLM_URL model = vlm_model.strip() or DEFAULT_VLM_MODEL return endpoint, model, {} def call_vlm( state: dict[str, Any] | None, doc_type: str, image_path: str | None, image_url: str, prompt: str, provider: str, vlm_endpoint: str, vlm_model: str, openai_endpoint: str, openai_model: str, hf_endpoint: str, hf_model: str, albert_endpoint: str, albert_model: str, lift_endpoint: str, lift_model: str, image_max_size: int, ) -> tuple[dict[str, Any], str, str, str, str, str, str, str, str, str, str, str, str, str, str, str, str]: state = deepcopy(state or empty_state()) provider_norm = str(provider or "vlm").strip().lower() resolved_image_url = resolve_image_to_data_url(image_url, image_path, int(image_max_size)) if not resolved_image_url: raise gr.Error("Ajoutez une image locale ou une URL d'image.") if not prompt.strip(): prompt = build_lift_prompt(doc_type) if provider_norm == "lift" else build_eval_prompt(doc_type) endpoint, model, auth_headers = resolve_vlm_provider( provider, vlm_endpoint, vlm_model, openai_endpoint, openai_model, hf_endpoint, hf_model, albert_endpoint, albert_model, lift_endpoint, lift_model ) payload = { "model": model, "messages": [ { "role": "user", "content": [ {"type": "text", "text": prompt}, {"type": "image_url", "image_url": {"url": resolved_image_url}}, ], } ], "response_format": {"type": "json_object"}, } try: response = requests.post( endpoint.strip(), headers={"Content-Type": "application/json", **auth_headers}, json=payload, timeout=None, ) response.raise_for_status() raw_response = response.json() except requests.RequestException as exc: state["vlm"]["error"] = str(exc) state = add_event(state, "vlm", "error", str(exc)) raise gr.Error(f"Erreur VLM: {exc}") from exc except ValueError as exc: state["vlm"]["error"] = "Réponse VLM non JSON" state = add_event(state, "vlm", "error", "Réponse VLM non JSON") raise gr.Error("La réponse VLM n'est pas un JSON valide.") from exc content = (((raw_response.get("choices") or [{}])[0].get("message") or {}).get("content") or "").strip() extracted = load_json_object(content, "Contenu JSON du VLM") if provider_norm == "lift": extracted = coerce_lift_response(extracted) normalized = normalize_metadata(extracted) state["input"] = { "doc_type": doc_type, "image_url": image_url.strip(), "image_filename": str(image_path or ""), "provider": str(provider or "vlm").strip().lower(), "endpoint": endpoint, "model": model, } state["vlm"] = {"request": payload, "raw_response": raw_response, "extracted": extracted, "corrected": normalized, "error": None} state = add_event(state, "vlm", "ok", "Extraction VLM terminée.") return state, as_json(extracted), as_json(raw_response), status_card("Extraction VLM", "ok", "Métadonnées extraites."), *metadata_to_outputs(normalized) def normalize_metadata(payload: dict[str, Any]) -> dict[str, Any]: normalized = {field: payload.get(field) for field in METADATA_FIELDS} if normalized.get("co_tutelle_institutions") in (None, ""): normalized["co_tutelle_institutions"] = [] if not isinstance(normalized.get("co_tutelle_institutions"), list): normalized["co_tutelle_institutions"] = [str(normalized["co_tutelle_institutions"])] return normalized def metadata_to_outputs(payload: dict[str, Any]) -> tuple[str, ...]: values = [] for field in METADATA_FIELDS: value = payload.get(field) if field == "co_tutelle_institutions": values.append(" | ".join(str(item) for item in (value or []))) elif value is None: values.append("") else: values.append(str(value)) return tuple(values) def parse_year(value: str) -> int | None: value = str(value or "").strip() if not value: return None try: return int(value) except ValueError: return None def parse_float(value: str) -> float | None: value = str(value or "").strip() if not value: return None try: return float(value) except ValueError: return None def corrected_from_fields(*values: str) -> dict[str, Any]: payload = dict(zip(METADATA_FIELDS, values)) payload["co_tutelle_institutions"] = [item.strip() for item in str(payload.get("co_tutelle_institutions") or "").split("|") if item.strip()] payload["defense_year"] = parse_year(str(payload.get("defense_year") or "")) payload["confidence"] = parse_float(str(payload.get("confidence") or "")) for key, value in list(payload.items()): if value == "": payload[key] = None if key not in {"co_tutelle_institutions"} else [] return payload def string_or_empty(value: Any) -> str: if value is None: return "" return str(value) def sudoc_request_payload(corrected: dict[str, Any]) -> dict[str, Any]: """Match the Sudoc checker schema, which expects strings for optional text fields. Document-kind specifics (TDO filter, NTH content filter) are not in the body: they are selected server-side by routing to /check/thesis or /check/dissertation. """ payload = { "title": string_or_empty(corrected.get("title")), "subtitle": string_or_empty(corrected.get("subtitle")), "author": string_or_empty(corrected.get("author")), "degree_type": string_or_empty(corrected.get("degree_type")), "discipline": string_or_empty(corrected.get("discipline")), "granting_institution": string_or_empty(corrected.get("granting_institution")), "co_tutelle_institutions": corrected.get("co_tutelle_institutions") or [], "doctoral_school": string_or_empty(corrected.get("doctoral_school")), "defense_year": corrected.get("defense_year"), "advisor": string_or_empty(corrected.get("advisor")), "jury_president": string_or_empty(corrected.get("jury_president")), "reviewers": string_or_empty(corrected.get("reviewers")), "committee_members": string_or_empty(corrected.get("committee_members")), "language": string_or_empty(corrected.get("language")), "confidence": corrected.get("confidence"), } if payload["defense_year"] is None: payload["defense_year"] = "" if payload["confidence"] is None: payload.pop("confidence") return payload def sudoc_route_for(doc_type: str) -> str: return "/check/dissertation" if str(doc_type or "").strip().lower() == "memoire" else "/check/thesis" def update_corrected_metadata(state: dict[str, Any] | None, *values: str) -> tuple[dict[str, Any], str, str, list[list[str]], str]: state = deepcopy(state or empty_state()) corrected = corrected_from_fields(*values) state.setdefault("vlm", {})["corrected"] = corrected state = add_event(state, "metadata", "ok", "Métadonnées corrigées mises à jour.") return state, as_json(corrected), status_card("Métadonnées corrigées", "ok", "Ces valeurs alimenteront Sudoc et IdRef."), people_rows(corrected), pipeline_summary(state) def call_sudoc(state: dict[str, Any] | None, doc_type: str, api_url: str) -> tuple[dict[str, Any], str, str, list[list[Any]], str]: state = deepcopy(state or empty_state()) corrected = state.get("vlm", {}).get("corrected") if not corrected: raise gr.Error("Validez d'abord les métadonnées corrigées.") request_payload = sudoc_request_payload(corrected) base_url = api_url.rstrip("/") route = sudoc_route_for(doc_type) try: response = requests.post(f"{base_url}{route}", headers={"Content-Type": "application/json"}, json=request_payload, timeout=None) response.raise_for_status() result = response.json() except requests.RequestException as exc: state["sudoc"]["error"] = str(exc) state = add_event(state, "sudoc", "error", str(exc)) raise gr.Error(f"Erreur Sudoc: {exc}") from exc except ValueError as exc: state["sudoc"]["error"] = "Réponse Sudoc non JSON" state = add_event(state, "sudoc", "error", "Réponse Sudoc non JSON") raise gr.Error("La réponse Sudoc n'est pas un JSON valide.") from exc state["sudoc"] = {"request": request_payload, "route": route, "response": result, "error": None} state = add_event(state, "sudoc", "ok", f"Sudoc ({route}): {result.get('status')}") return state, render_sudoc(result), as_json(result), sudoc_candidate_rows(result), pipeline_summary(state) def split_people(value: Any) -> list[str]: if value is None: return [] if isinstance(value, list): parts = value else: parts = str(value).split("|") return [str(part).strip() for part in parts if str(part).strip()] def normalize_name_key(name: str) -> str: return " ".join(str(name).lower().split()) def extract_people(payload: dict[str, Any]) -> list[dict[str, Any]]: people: dict[str, dict[str, Any]] = {} for field in PERSON_FIELDS: for name in split_people(payload.get(field)): key = normalize_name_key(name) if key not in people: people[key] = {"name": name, "roles": []} if field not in people[key]["roles"]: people[key]["roles"].append(field) return list(people.values()) def people_rows(payload: dict[str, Any]) -> list[list[str]]: rows = [] for person in extract_people(payload): rows.append([person["name"], " | ".join(ROLE_LABELS.get(role, role) for role in person["roles"])]) return rows def build_align_payload( extraction: dict[str, Any], name: str, use_embeddings: bool, weights: dict[str, float], ) -> dict[str, Any]: return { "name": name, "title": str(extraction.get("title") or ""), "subtitle": str(extraction.get("subtitle") or ""), "discipline": str(extraction.get("discipline") or ""), "institution": str(extraction.get("granting_institution") or ""), "doctoral_school": str(extraction.get("doctoral_school") or ""), "degree_type": str(extraction.get("degree_type") or ""), "year": str(extraction.get("defense_year") or ""), "max_candidates": 20, "max_docs_per_role": 20, "reference_top_k": 3, "embedding_model": EMBEDDING_MODEL if use_embeddings else "", "weight_name": float(weights["weight_name"]), "weight_attrra_source": float(weights["weight_attrra_source"]), "weight_attrra_note": float(weights["weight_attrra_note"]), "weight_references": float(weights["weight_references"]), "weight_institution_year": float(weights["weight_institution_year"]), } def call_idref_for_all( state: dict[str, Any] | None, api_url: str, api_key: str, use_embeddings: bool, weight_name: float, weight_attrra_source: float, weight_attrra_note: float, weight_references: float, weight_institution_year: float, ) -> tuple[dict[str, Any], str, str, list[list[Any]], str]: state = deepcopy(state or empty_state()) corrected = state.get("vlm", {}).get("corrected") if not corrected: raise gr.Error("Validez d'abord les métadonnées corrigées.") people = extract_people(corrected) if not people: raise gr.Error("Aucune personne à aligner dans les métadonnées corrigées.") base_url = api_url.rstrip("/") headers = {"Content-Type": "application/json"} if api_key.strip(): headers["X-API-Key"] = api_key.strip() weights = { "weight_name": weight_name, "weight_attrra_source": weight_attrra_source, "weight_attrra_note": weight_attrra_note, "weight_references": weight_references, "weight_institution_year": weight_institution_year, } aligned = [] for person in people: payload = build_align_payload(corrected, person["name"], use_embeddings, weights) try: response = requests.post(f"{base_url}/align/person", headers=headers, json=payload, timeout=None) response.raise_for_status() result = response.json() except requests.RequestException as exc: result = {"status": "error", "error": str(exc), "query": {"name": person["name"]}, "candidates": []} except ValueError: result = {"status": "error", "error": "Réponse non JSON", "query": {"name": person["name"]}, "candidates": []} aligned.append({"name": person["name"], "roles": person["roles"], "request": payload, "response": result}) state["idref"] = { "persons": aligned, "settings": {"api_url": base_url, "use_embeddings": use_embeddings, "weights": weights}, "error": None, } state = add_event(state, "idref", "ok", f"{len(aligned)} personne(s) alignée(s).") return state, render_idref(aligned), as_json(aligned), idref_rows(aligned), pipeline_summary(state) def build_dewey_text(corrected: dict[str, Any]) -> str: """Concatenate title + subtitle (when present) + discipline for the classifier.""" parts = [ string_or_empty(corrected.get("title")).strip(), string_or_empty(corrected.get("subtitle")).strip(), string_or_empty(corrected.get("discipline")).strip(), ] return " ".join(part for part in parts if part) def dewey_choice_label(cls: dict[str, Any]) -> str: code = str(cls.get("dewey") or "") label = str(cls.get("label") or "") score = cls.get("score") score_text = f" — {float(score):.4f}" if isinstance(score, int | float) else "" return f"{code} · {label}{score_text}".strip(" ·") def dewey_radio_update(classes: list[dict[str, Any]], selected: dict[str, Any] | None): choices = [(dewey_choice_label(cls), str(cls.get("dewey") or "")) for cls in classes] value = str(selected.get("code")) if selected and selected.get("code") is not None else None return gr.update(choices=choices, value=value) def selected_from_class(cls: dict[str, Any]) -> dict[str, Any]: return {"code": cls.get("dewey"), "label": cls.get("label"), "score": cls.get("score")} def call_dewey( state: dict[str, Any] | None, api_url: str, api_key: str, top_k: int, threshold: float, method: str, ) -> tuple[dict[str, Any], str, str, Any, str]: state = deepcopy(state or empty_state()) corrected = state.get("vlm", {}).get("corrected") if not corrected: raise gr.Error("Validez d'abord les métadonnées corrigées.") text = build_dewey_text(corrected) if not text: raise gr.Error("Titre et discipline manquants pour la classification Dewey.") method = (method or "local").strip().lower() if method not in ("local", "albert"): raise gr.Error("Méthode de classification invalide (local ou albert).") request_payload = { "text": text, "classification_type": "multi-label", "top_k": int(top_k), "threshold": float(threshold), "method": method, } base_url = api_url.rstrip("/") headers = {"Content-Type": "application/json"} if api_key.strip(): headers["X-API-Key"] = api_key.strip() try: response = requests.post(f"{base_url}/classify", headers=headers, json=request_payload, timeout=None) response.raise_for_status() result = response.json() except requests.RequestException as exc: state["dewey"]["error"] = str(exc) state = add_event(state, "dewey", "error", str(exc)) raise gr.Error(f"Erreur Dewey: {exc}") from exc except ValueError as exc: state["dewey"]["error"] = "Réponse Dewey non JSON" state = add_event(state, "dewey", "error", "Réponse Dewey non JSON") raise gr.Error("La réponse Dewey n'est pas un JSON valide.") from exc classes = (((result.get("results") or [{}])[0]).get("classes")) or [] selected = selected_from_class(classes[0]) if classes else None state["dewey"] = { "request": request_payload, "response": result, "classes": classes, "selected": selected, "error": None, } result_method = result.get("method") or method result_model = result.get("model") state = add_event( state, "dewey", "ok", f"{len(classes)} classe(s) Dewey proposée(s) (méthode {result_method}).", ) return ( state, render_dewey(classes, selected, result_method, result_model), as_json(result), dewey_radio_update(classes, selected), pipeline_summary(state), ) def select_dewey_class(state: dict[str, Any] | None, code: str | None) -> tuple[dict[str, Any], str, str]: state = deepcopy(state or empty_state()) classes = state.get("dewey", {}).get("classes") or [] chosen = next((cls for cls in classes if str(cls.get("dewey")) == str(code)), None) if not chosen: raise gr.Error("Sélectionnez une classe Dewey valide.") selected = selected_from_class(chosen) state.setdefault("dewey", {})["selected"] = selected state = add_event(state, "dewey", "ok", f"Classe Dewey retenue: {selected['code']} {selected['label']}") message = f"Classe retenue : {selected['code']} — {selected['label']}" return state, status_card("Classification Dewey", "ok", message), pipeline_summary(state) def make_record_draft(state: dict[str, Any] | None) -> tuple[dict[str, Any], str, str, str]: state = deepcopy(state or empty_state()) corrected = state.get("vlm", {}).get("corrected") or {} sudoc_response = state.get("sudoc", {}).get("response") or {} aligned = state.get("idref", {}).get("persons") or [] doc_type = (state.get("input") or {}).get("doc_type") or "these" persons_by_name: dict[str, dict[str, Any]] = {} for item in aligned: response = item.get("response") or {} best = response.get("best_candidate") or {} name = item.get("name") if not name: continue entry = persons_by_name.setdefault( normalize_name_key(name), { "name": name, "roles": [], "role_labels": [], "idref_ppn": response.get("best_ppn") if response.get("status") in STRONG_IDREF_STATUSES else None, "status": response.get("status"), "score": (best.get("score") or {}).get("final"), "idref_url": best.get("url"), "strong_idref": response.get("status") in STRONG_IDREF_STATUSES, }, ) for role in item.get("roles") or []: if role not in entry["roles"]: entry["roles"].append(role) entry["role_labels"].append(ROLE_LABELS.get(role, role)) if response.get("status") in STRONG_IDREF_STATUSES: entry["idref_ppn"] = response.get("best_ppn") entry["strong_idref"] = True entry["idref_url"] = best.get("url") entry["score"] = (best.get("score") or {}).get("final") entry["status"] = response.get("status") persons = list(persons_by_name.values()) draft = { "profile": "memoire_original_imprime" if doc_type == "memoire" else "these_originale_imprimee", "work_metadata": { "title": corrected.get("title"), "subtitle": corrected.get("subtitle"), "language": corrected.get("language"), "defense_year": corrected.get("defense_year"), }, "thesis_metadata": { "degree_type": corrected.get("degree_type"), "discipline": corrected.get("discipline"), "granting_institution": corrected.get("granting_institution"), "co_tutelle_institutions": corrected.get("co_tutelle_institutions") or [], "doctoral_school": corrected.get("doctoral_school"), "partner_institutions": corrected.get("partner_institutions") or [], "abstract": corrected.get("abstract"), }, "persons": persons, "dewey_classification": state.get("dewey", {}).get("selected") or None, "sudoc_check": { "status": sudoc_response.get("status"), "duplicate_score": sudoc_response.get("duplicate_score"), "printed_duplicate_ppn": (sudoc_response.get("best_print_candidate") or {}).get("ppn"), "best_electronic_ppn": (sudoc_response.get("best_electronic_candidate") or {}).get("ppn"), }, } state["record_draft"] = draft state = add_event(state, "draft", "ok", "Brouillon consolidé généré.") return state, render_draft(draft), as_json(draft), as_json(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 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"
{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"Aucun candidat.
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 {})}{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}| Composante | Score |
|---|
Aucun indice.
" return "Aucun alignement.
" panels = [] for item in aligned: response = item.get("response") or {} best = response.get("best_candidate") or {} candidates = response.get("candidates") or [] evidence = best.get("evidence") or {} rows = "".join( "Rôles : {html.escape(' | '.join(ROLE_LABELS.get(role, role) for role in item.get('roles') or []))}
Décision : {badge(response.get('status'))}
PPN accepté : {html.escape(str(response.get('best_ppn') or 'aucun'))}
{score_table(best.get('score') or {})}{html.escape(str(evidence.get('best_attrra_source') or ''))}
{html.escape(str(evidence.get('best_attrra_note') or ''))}
| PPN | Score | Formes |
|---|
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( "{html.escape(str(model))}")
meta = f"{' · '.join(meta_bits)}
" if meta_bits else "" return f"""| Code | Label | Score | Retenu |
|---|
Profil : {html.escape(str(draft.get('profile') or ''))}
Titre : {html.escape(str((draft.get('work_metadata') or {}).get('title') or ''))}
Diplôme : {html.escape(str((draft.get('thesis_metadata') or {}).get('degree_type') or ''))}
Discipline : {html.escape(str((draft.get('thesis_metadata') or {}).get('discipline') or ''))}
Sudoc : {badge((draft.get('sudoc_check') or {}).get('status'))}
| Rôle | Nom | PPN IdRef | Statut | Score |
|---|
{html.escape(pseudo_unimarc(draft))}
Aperçu de travail uniquement. Ce n'est pas encore une sortie UNIMARC conforme.