theses.fr-MCP / app.py
Geraldine's picture
Update app.py
2a46933 verified
Raw
History Blame Contribute Delete
18.5 kB
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)