Spaces:
Running
Running
| from __future__ import annotations | |
| import os | |
| import urllib.parse | |
| from typing import Any | |
| import gradio as gr | |
| import httpx | |
| # ── Configuration ───────────────────────────────────────────────────────────── | |
| BASE_URL = os.environ.get("THESES_FR_API_URL", "https://theses.fr/api/v1").rstrip("/") | |
| USER_AGENT = "smartbiblia-theses-fr-demo/0.1" | |
| STATUS_VALUES = ("soutenue", "enCours") | |
| ACCESSIBLE_VALUES = ("oui", "non") | |
| TRI_VALUES = ("pertinence", "dateDesc", "dateAsc", "auteursAsc", "auteursDesc", | |
| "disciplineAsc", "disciplineDesc") | |
| # A Space has no command line: connector policy is constant here. | |
| REQUEST_TIMEOUT = 20.0 | |
| # Clamped harder than the canonical server: this endpoint is public, and each | |
| # hydrated hit costs one extra upstream request. | |
| MAX_RESULTS = 10 | |
| # One module-level pooled client for the process. | |
| HTTP = httpx.Client( | |
| timeout=REQUEST_TIMEOUT, | |
| follow_redirects=True, | |
| headers={"Accept": "application/json", "User-Agent": USER_AGENT}, | |
| ) | |
| def _get(path: str, params: list[tuple[str, str]] | None = None) -> tuple[Any, str | None]: | |
| """GET returning (payload, error). Never raises — the demo answers with data.""" | |
| url = f"{BASE_URL}/{path.lstrip('/')}" | |
| try: | |
| resp = HTTP.get(url, params=params or []) | |
| resp.raise_for_status() | |
| # An unknown identifier answers 200 with an empty body rather than a | |
| # 404, so emptiness is the only "not found" signal there is. | |
| if not resp.content.strip(): | |
| return None, "No record found (empty response)" | |
| return resp.json(), None | |
| except httpx.HTTPStatusError as exc: | |
| return None, f"theses.fr returned HTTP {exc.response.status_code}" | |
| except httpx.TimeoutException: | |
| return None, f"theses.fr timed out after {REQUEST_TIMEOUT:g}s" | |
| except Exception as exc: # noqa: BLE001 - never crash the Space | |
| return None, f"cannot reach theses.fr: {exc}" | |
| def _clean(v: Any) -> str | None: | |
| if isinstance(v, list): | |
| v = v[0] if v else None | |
| if v is None: | |
| return None | |
| return str(v).strip() or None | |
| def _year(date: str | None) -> int | None: | |
| """dateSoutenance is DD/MM/YYYY, and null for a thesis still in progress.""" | |
| if not date or len(date) < 4: | |
| return None | |
| try: | |
| return int(date[-4:]) | |
| except ValueError: | |
| return None | |
| def _names(people: Any) -> list[str]: | |
| out: list[str] = [] | |
| for p in people or []: | |
| if isinstance(p, dict): | |
| full = f"{(p.get('prenom') or '').strip()} {(p.get('nom') or '').strip()}".strip() | |
| if full: | |
| out.append(full) | |
| return out | |
| def _keywords(t: dict) -> list[str]: | |
| """Free keywords and Rameau headings, merged — both ride along on the hit.""" | |
| out: list[str] = [] | |
| for s in (t.get("sujets") or []) + (t.get("sujetsRameau") or []): | |
| label = _clean(s.get("libelle")) if isinstance(s, dict) else None | |
| if label and label not in out: | |
| out.append(label) | |
| return out | |
| def _normalize_hit(t: dict) -> dict: | |
| ident = t.get("nnt") or t.get("id") | |
| date = _clean(t.get("dateSoutenance")) | |
| return { | |
| "source": "theses-fr", | |
| "id": ident, | |
| "nnt": t.get("nnt"), | |
| "title": _clean(t.get("titrePrincipal")), | |
| "title_en": _clean(t.get("titreEN")), | |
| "authors": _names(t.get("auteurs")), | |
| "directors": _names(t.get("directeurs")), | |
| "abstract": None, # absent from search hits — hydrate, or get_thesis | |
| "doi": _clean(t.get("doi")), | |
| "year": _year(date), | |
| "date": date, | |
| "doc_type": "thesis", | |
| "journal": None, | |
| "institution": _clean(t.get("etabSoutenanceN")), | |
| "institution_ppn": _clean(t.get("etabSoutenancePpn")), | |
| "discipline": _clean(t.get("discipline")), | |
| "keywords": _keywords(t), | |
| "status": _clean(t.get("status")), | |
| "url": f"https://theses.fr/{ident}" if ident else None, | |
| } | |
| def _normalize_detail(d: dict) -> dict: | |
| ident = d.get("nnt") or d.get("numSujet") | |
| resumes = d.get("resumes") or {} | |
| titres = d.get("titres") or {} | |
| date = _clean(d.get("dateSoutenance")) | |
| etab = d.get("etabSoutenance") or {} | |
| return { | |
| "source": "theses-fr", | |
| "id": ident, | |
| "nnt": d.get("nnt"), | |
| "title": _clean(d.get("titrePrincipal")) or _clean(titres.get("fr")) or _clean(titres.get("en")), | |
| "titles": {k: _clean(v) for k, v in titres.items()}, | |
| "authors": _names(d.get("auteurs")), | |
| "directors": _names(d.get("directeurs")), | |
| # English preferred for downstream NLP, French as the fallback. | |
| "abstract": _clean(resumes.get("en")) or _clean(resumes.get("fr")), | |
| "abstracts": {k: _clean(v) for k, v in resumes.items()}, | |
| "doi": _clean(d.get("doi")), | |
| "year": _year(date), | |
| "date": date, | |
| "doc_type": "thesis", | |
| "journal": None, | |
| "institution": etab.get("nom") if isinstance(etab, dict) else _clean(etab), | |
| "institution_ppn": etab.get("ppn") if isinstance(etab, dict) else None, | |
| "code_etab": _clean(d.get("codeEtab")), | |
| "discipline": _clean(d.get("discipline")), | |
| "languages": d.get("langues") or [], | |
| "status": _clean(d.get("status")), | |
| # "oui" only ever for a defended thesis: the full text is online. | |
| "accessible": d.get("accessible"), | |
| "url": f"https://theses.fr/{ident}" if ident else None, | |
| } | |
| # ── MCP tools (the only functions exposed with gr.api) ──────────────────────── | |
| def search_theses( | |
| query: str = "", | |
| establishment: str | None = None, | |
| discipline: str | None = None, | |
| domain: str | None = None, | |
| author: str | None = None, | |
| director: str | None = None, | |
| language: str | None = None, | |
| accessible: str | None = None, | |
| status: str | None = None, | |
| max_results: int = 5, | |
| sort: str | None = None, | |
| hydrate: bool = False, | |
| ) -> dict: | |
| """ | |
| Search theses.fr for French doctoral theses, defended or in preparation. | |
| Args: | |
| query: Raw Lucene query, e.g. "titrePrincipal:(informatique)". Empty matches everything. | |
| establishment: Establishment short code, e.g. "COAZ" — compiled to codeEtab:(COAZ), which also finds theses in preparation. | |
| discipline: Discipline, free text, e.g. "informatique". | |
| domain: Thematic domain, one of the controlled "Domaines thématiques" labels, e.g. "Informatique". | |
| author: Author name tokens, e.g. "Benoît Audelan". Never quoted upstream. | |
| director: Supervisor name tokens, e.g. "Frédéric Precioso". | |
| language: ISO code of the writing language, e.g. "fr" or "en". | |
| accessible: "oui" for theses whose full text is online, "non" otherwise. Defended theses only. | |
| status: "soutenue" (defended) or "enCours" (in preparation). Empty for both. | |
| max_results: Number of theses to return, 1-10 on this demo endpoint. | |
| sort: Ordering — "pertinence", "dateDesc", "dateAsc", "auteursAsc", "auteursDesc", "disciplineAsc" or "disciplineDesc". | |
| hydrate: Fetch each hit's résumé, which the search index does not carry. One extra request per hit. | |
| Returns: | |
| {"source": "theses-fr", "command": "search_theses", "query_used": str, "total_found": int | null, "returned": int, "results": [{"source": str, "id": str, "title": str, "authors": [str], "abstract": str | null, "doi": str | null, "year": int | null, "institution": str | null, "keywords": [str], "url": str}], "hydrated": bool, "error": str | null} | |
| """ | |
| out: dict = { | |
| "source": "theses-fr", "command": "search_theses", "query_used": "*", | |
| "total_found": None, "returned": 0, "results": [], | |
| "hydrated": bool(hydrate), "error": None, | |
| } | |
| if status and status not in STATUS_VALUES: | |
| out["error"] = "status must be one of " + ", ".join(STATUS_VALUES) | |
| return out | |
| if accessible and accessible not in ACCESSIBLE_VALUES: | |
| out["error"] = "accessible must be one of " + ", ".join(ACCESSIBLE_VALUES) | |
| return out | |
| if sort and sort not in TRI_VALUES: | |
| out["error"] = "sort must be one of " + ", ".join(TRI_VALUES) | |
| return out | |
| # theses.fr's own `filtres` parameter is inert; every constraint goes into q. | |
| # Quoting is per-field: a controlled label like oaiSetNames must be quoted, | |
| # a person-name field must not — its tokens are stored in no fixed order. | |
| clauses = [] | |
| if query and query.strip(): | |
| clauses.append(f"({query.strip()})") | |
| if establishment: | |
| clauses.append(f"codeEtab:({establishment.upper()})") # case-sensitive upstream | |
| if discipline: | |
| clauses.append(f"discipline:({discipline})") | |
| if domain: | |
| clauses.append(f'oaiSetNames:("{domain}")') | |
| if author: | |
| clauses.append(f"auteursNP:({author})") | |
| if director: | |
| clauses.append(f"directeursNP:({director})") | |
| if language: | |
| clauses.append(f"langues:({language})") | |
| if accessible: | |
| clauses.append(f"accessible:({accessible})") | |
| if status: | |
| clauses.append(f"status:({status})") | |
| q = " AND ".join(clauses) if clauses else "*" | |
| out["query_used"] = q | |
| rows = max(1, min(int(max_results or 5), MAX_RESULTS)) | |
| params = [("q", q), ("nombre", str(rows)), ("debut", "0")] | |
| if sort: | |
| params.append(("tri", sort)) | |
| data, error = _get("theses/recherche/", params) | |
| if error or not isinstance(data, dict): | |
| out["error"] = error or "unexpected response shape from theses.fr" | |
| return out | |
| results = [_normalize_hit(t) for t in data.get("theses") or [] if isinstance(t, dict)] | |
| out["total_found"] = data.get("totalHits") | |
| if hydrate: | |
| for r in results: | |
| ident = r.get("id") | |
| if not ident: | |
| continue | |
| detail, d_error = _get(f"theses/these/{urllib.parse.quote(str(ident))}") | |
| if d_error or not isinstance(detail, dict): | |
| r["hydrate_error"] = d_error or "unexpected record response shape" | |
| continue | |
| normalized = _normalize_detail(detail) | |
| r["abstract"] = normalized["abstract"] | |
| r["abstracts"] = normalized["abstracts"] | |
| out["returned"] = len(results) | |
| out["results"] = results | |
| return out | |
| def get_thesis(id: str) -> dict: | |
| """ | |
| Fetch one theses.fr record, including its bilingual résumés. | |
| Args: | |
| id: NNT, e.g. "2021COAZ4028", or subject number of a thesis in preparation, e.g. "s68236". | |
| Returns: | |
| {"source": "theses-fr", "command": "get_thesis", "query_used": str, "total_found": null, "returned": int, "results": [{"source": str, "id": str, "title": str, "titles": object, "authors": [str], "abstract": str | null, "abstracts": object, "doi": str | null, "year": int | null, "institution": str | null, "url": str}], "error": str | null} | |
| """ | |
| out: dict = { | |
| "source": "theses-fr", "command": "get_thesis", "query_used": id, | |
| "total_found": None, "returned": 0, "results": [], "error": None, | |
| } | |
| if not id or not id.strip(): | |
| out["error"] = "id is required — an NNT or a subject number" | |
| return out | |
| data, error = _get(f"theses/these/{urllib.parse.quote(id.strip())}") | |
| if error or not isinstance(data, dict): | |
| out["error"] = error or "unexpected response shape from theses.fr" | |
| return out | |
| out["returned"] = 1 | |
| out["results"] = [_normalize_detail(data)] | |
| return out | |
| # ── Presentation ────────────────────────────────────────────────────────────── | |
| def _render_search(payload: dict) -> str: | |
| results = payload.get("results") or [] | |
| if not results: | |
| return "_Aucune thèse ne correspond._" | |
| lines = [ | |
| f"**{payload.get('returned', len(results))} sur {payload.get('total_found', '?')} thèses** " | |
| f"— `{payload.get('query_used')}`", | |
| "", | |
| "| Titre | Auteur·rice | Établissement | Soutenance |", | |
| "|---|---|---|---|", | |
| ] | |
| for r in results: | |
| title = (r.get("title") or "Sans titre").replace("|", "\\|") | |
| url = r.get("url") | |
| authors = ", ".join(r.get("authors") or []) or "—" | |
| lines.append( | |
| "| {title} | {authors} | {etab} | {date} |".format( | |
| title=f"[{title}]({url})" if url else title, | |
| authors=authors.replace("|", "\\|"), | |
| etab=(r.get("institution") or "—").replace("|", "\\|"), | |
| date=r.get("date") or "en cours", | |
| ) | |
| ) | |
| if payload.get("hydrated"): | |
| first = next((r for r in results if r.get("abstract")), None) | |
| if first: | |
| lines += ["", "**Résumé du premier résultat**", "", first["abstract"][:1200]] | |
| return "\n".join(lines) | |
| def _render_detail(payload: dict) -> str: | |
| results = payload.get("results") or [] | |
| if not results: | |
| return "_Aucun enregistrement._" | |
| r = results[0] | |
| lines = [ | |
| f"### {r.get('title') or 'Sans titre'}", | |
| "", | |
| f"- **Auteur·rice** : {', '.join(r.get('authors') or []) or '—'}", | |
| f"- **Direction** : {', '.join(r.get('directors') or []) or '—'}", | |
| f"- **Établissement** : {r.get('institution') or '—'}", | |
| f"- **Discipline** : {r.get('discipline') or '—'}", | |
| f"- **Soutenance** : {r.get('date') or 'en cours'}", | |
| f"- **DOI** : {r.get('doi') or '—'}", | |
| f"- **theses.fr** : {r.get('url') or '—'}", | |
| ] | |
| for lang, text in (r.get("abstracts") or {}).items(): | |
| if text: | |
| lines += ["", f"**Résumé ({lang})**", "", text[:2000]] | |
| return "\n".join(lines) | |
| def _run_search(query, establishment, discipline, domain, author, director, | |
| language, accessible, status, max_results, sort, hydrate): | |
| payload = search_theses(query, establishment or None, discipline or None, | |
| domain or None, author or None, director or None, | |
| language or None, accessible or None, status or None, | |
| max_results, sort or None, hydrate) | |
| if payload.get("error"): | |
| raise gr.Error(payload["error"]) | |
| return _render_search(payload), payload | |
| def _run_get(id_value): | |
| payload = get_thesis(id_value) | |
| if payload.get("error"): | |
| raise gr.Error(payload["error"]) | |
| return _render_detail(payload), payload | |
| # ── UI ──────────────────────────────────────────────────────────────────────── | |
| with gr.Blocks(title="theses.fr MCP demo") as demo: | |
| gr.Markdown( | |
| "# theses.fr MCP demo\n" | |
| "Démo autonome du serveur MCP " | |
| "[`theses-fr`](https://github.com/smartbiblia-solutions/agentic-stack/tree/main/mcp/theses-fr) " | |
| ", le registre national des thèses de doctorat françaises (ABES).\n\n" | |
| "Les résultats de recherche ne portent **jamais** de résumé : cochez " | |
| "« Récupérer les résumés », ou consultez une thèse par son identifiant." | |
| ) | |
| with gr.Tab("Recherche"): | |
| query = gr.Textbox(label="Requête (syntaxe Lucene)", value="", | |
| placeholder="titrePrincipal:informatique") | |
| with gr.Row(): | |
| establishment = gr.Textbox(label="Code établissement", value="", placeholder="COAZ") | |
| discipline = gr.Textbox(label="Discipline", value="", placeholder="informatique") | |
| domain = gr.Textbox(label="Domaine thématique", value="", placeholder="Informatique") | |
| with gr.Row(): | |
| author = gr.Textbox(label="Auteur·rice", value="", placeholder="Benoît Audelan") | |
| director = gr.Textbox(label="Direction", value="", placeholder="Frédéric Precioso") | |
| language = gr.Textbox(label="Langue (code ISO)", value="", placeholder="fr") | |
| with gr.Row(): | |
| status = gr.Dropdown([""] + list(STATUS_VALUES), value="", label="Statut") | |
| accessible = gr.Dropdown([""] + list(ACCESSIBLE_VALUES), value="", | |
| label="Texte intégral en ligne (thèses soutenues)") | |
| sort = gr.Dropdown([""] + list(TRI_VALUES), value="", label="Tri") | |
| with gr.Row(): | |
| max_results = gr.Slider(1, MAX_RESULTS, value=5, step=1, label="Résultats") | |
| hydrate = gr.Checkbox(label="Récupérer les résumés (1 requête par thèse)", value=False) | |
| search_btn = gr.Button("Rechercher", variant="primary") | |
| search_out = gr.Markdown() | |
| search_raw = gr.JSON(label="Sortie brute de l'outil") | |
| search_inputs = [query, establishment, discipline, domain, author, director, | |
| language, accessible, status, max_results, sort, hydrate] | |
| gr.Examples( | |
| examples=[ | |
| ["", "COAZ", "", "Informatique", "", "Precioso", "", "oui", | |
| "soutenue", 5, "dateDesc", True], | |
| ["titrePrincipal:(qzxwvsansresultat)", "", "", "", "", "", "", "", | |
| "", 5, "", False], | |
| ], | |
| inputs=search_inputs, | |
| label="Une requête qui trouve, une qui ne trouve rien", | |
| ) | |
| search_btn.click( | |
| _run_search, | |
| inputs=search_inputs, | |
| outputs=[search_out, search_raw], | |
| api_name=False, | |
| ) | |
| with gr.Tab("Une thèse"): | |
| id_value = gr.Textbox(label="NNT ou numéro de sujet", value="2021COAZ4028") | |
| get_btn = gr.Button("Consulter", variant="primary") | |
| get_out = gr.Markdown() | |
| get_raw = gr.JSON(label="Sortie brute de l'outil") | |
| gr.Examples( | |
| examples=[["2021COAZ4028"], ["s68236"]], | |
| inputs=[id_value], | |
| label="Une thèse soutenue, une thèse en préparation", | |
| ) | |
| get_btn.click(_run_get, inputs=[id_value], outputs=[get_out, get_raw], api_name=False) | |
| # The only declared MCP tools. Names match the canonical server's. | |
| gr.api(search_theses, api_name="search_theses") | |
| gr.api(get_thesis, api_name="get_thesis") | |
| demo.launch(theme=gr.themes.Soft(), mcp_server=True) | |