Spaces:
Sleeping
Sleeping
Upload 2 files
Browse files- app.py +309 -0
- requirements.txt +2 -0
app.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Standalone Gradio demo of the IdRef resolver MCP server, deployable as
|
| 4 |
+
a Hugging Face Space.
|
| 5 |
+
|
| 6 |
+
Like the server, this app is a transport adapter: every score, threshold and
|
| 7 |
+
status comes from the API, and no host but the API is called. Unlike the server,
|
| 8 |
+
a missing or unreachable API does not stop the process — the call comes back with
|
| 9 |
+
`error` set and `status` null.
|
| 10 |
+
|
| 11 |
+
Local run:
|
| 12 |
+
uv run --with 'gradio[mcp]>=6,<7' --with httpx app.py
|
| 13 |
+
|
| 14 |
+
Environment:
|
| 15 |
+
IDREF_API_URL API base URL (default http://localhost:8000)
|
| 16 |
+
IDREF_API_KEY sent as X-API-Key; never a tool argument, never echoed back
|
| 17 |
+
GRADIO_SERVER_NAME bind address (default 0.0.0.0)
|
| 18 |
+
GRADIO_SERVER_PORT port (default 7860)
|
| 19 |
+
GRADIO_MCP_SERVER "false" disables the demo MCP endpoint (default true)
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import os
|
| 25 |
+
from typing import Any, Literal
|
| 26 |
+
|
| 27 |
+
import gradio as gr
|
| 28 |
+
import httpx
|
| 29 |
+
|
| 30 |
+
# ── Configuration ─────────────────────────────────────────────────────────────
|
| 31 |
+
|
| 32 |
+
API_BASE_URL = os.environ.get("IDREF_API_URL", "http://localhost:8000").rstrip("/")
|
| 33 |
+
|
| 34 |
+
# The credential is read from the environment only: never a tool argument, never
|
| 35 |
+
# echoed into a payload.
|
| 36 |
+
API_KEY = os.environ.get("IDREF_API_KEY", "")
|
| 37 |
+
|
| 38 |
+
# A Space has no command line: connector policy is constant here. An alignment
|
| 39 |
+
# fans out to as many as 41 upstream ABES requests, so the budget is generous.
|
| 40 |
+
REQUEST_TIMEOUT = 180.0
|
| 41 |
+
|
| 42 |
+
# Clamped harder than the canonical server (which allows 100 / 20): this endpoint
|
| 43 |
+
# is public and every call spends the operator's API budget.
|
| 44 |
+
MAX_CANDIDATES = 30
|
| 45 |
+
MAX_RETURNED = 10
|
| 46 |
+
|
| 47 |
+
# Mirrors the API's `EmbeddingModel` Literal. A closed enum there is a closed
|
| 48 |
+
# enum here, so a wrong value fails in the client instead of costing a round trip.
|
| 49 |
+
EMBEDDING_MODELS = (
|
| 50 |
+
"lexical", "lexical-idf", "albert-bge-m3", "granite", "qwen", "minilm",
|
| 51 |
+
)
|
| 52 |
+
EmbeddingModel = Literal[
|
| 53 |
+
"lexical", "lexical-idf", "albert-bge-m3", "granite", "qwen", "minilm"
|
| 54 |
+
]
|
| 55 |
+
|
| 56 |
+
# One module-level pooled client for the process.
|
| 57 |
+
HTTP = httpx.Client(timeout=REQUEST_TIMEOUT, follow_redirects=True)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _post(path: str, payload: dict) -> tuple[dict | None, str | None]:
|
| 61 |
+
"""POST returning (data, error). Never raises — the demo answers with data."""
|
| 62 |
+
headers = {"Content-Type": "application/json"}
|
| 63 |
+
if API_KEY:
|
| 64 |
+
headers["X-API-Key"] = API_KEY
|
| 65 |
+
try:
|
| 66 |
+
resp = HTTP.post(f"{API_BASE_URL}{path}", json=payload, headers=headers)
|
| 67 |
+
except httpx.TimeoutException:
|
| 68 |
+
return None, f"the API did not answer within {REQUEST_TIMEOUT:g}s"
|
| 69 |
+
except httpx.HTTPError as exc:
|
| 70 |
+
return None, f"cannot reach the API at {API_BASE_URL}: {exc}"
|
| 71 |
+
if resp.status_code >= 400:
|
| 72 |
+
# Surface the API's own detail; never echo the key back.
|
| 73 |
+
return None, f"API {resp.status_code}: {resp.text[:300]}"
|
| 74 |
+
try:
|
| 75 |
+
return resp.json(), None
|
| 76 |
+
except ValueError:
|
| 77 |
+
return None, f"API returned a non-JSON body: {resp.text[:200]}"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ── MCP tools (the only functions exposed with gr.api) ────────────────────────
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def align_person(
|
| 84 |
+
name: str,
|
| 85 |
+
works: list[str] | None = None,
|
| 86 |
+
field: str = "",
|
| 87 |
+
affiliation: str = "",
|
| 88 |
+
year: str = "",
|
| 89 |
+
context: str = "",
|
| 90 |
+
embedding_model: EmbeddingModel = "lexical-idf",
|
| 91 |
+
max_candidates: int = 20,
|
| 92 |
+
accept_threshold: float = 0.65,
|
| 93 |
+
margin_threshold: float = 0.08,
|
| 94 |
+
max_returned_candidates: int = 5,
|
| 95 |
+
) -> dict:
|
| 96 |
+
"""
|
| 97 |
+
Align a person to an IdRef PPN from their name plus any disambiguating context.
|
| 98 |
+
|
| 99 |
+
`best_ppn` is populated only when `status == "accepted"`. On any other status the
|
| 100 |
+
alignment abstained: report the abstention, do not fall back to the top candidate's
|
| 101 |
+
PPN. Raising a threshold makes the service stricter, i.e. more abstentions.
|
| 102 |
+
|
| 103 |
+
Args:
|
| 104 |
+
name: Full person name, e.g. "Valérie Robert". Required.
|
| 105 |
+
works: Titles of documents the person is linked to. Often decisive.
|
| 106 |
+
field: Discipline or subject area, e.g. "sociologie des sciences".
|
| 107 |
+
affiliation: Institution, laboratory or place, e.g. "Université de Nancy".
|
| 108 |
+
year: A relevant year as a string, e.g. "2003".
|
| 109 |
+
context: Any other free text — a biographical note, an abstract.
|
| 110 |
+
embedding_model: How texts are compared — lexical, lexical-idf, albert-bge-m3, granite, qwen or minilm.
|
| 111 |
+
max_candidates: Candidate authorities to enrich and score, 1-30 on this demo endpoint.
|
| 112 |
+
accept_threshold: Minimum final score for "accepted" (0.0-1.0).
|
| 113 |
+
margin_threshold: Minimum lead over the runner-up for "accepted" (0.0-1.0).
|
| 114 |
+
max_returned_candidates: Ranked candidates to include in the reply, 0-10 on this demo endpoint.
|
| 115 |
+
|
| 116 |
+
Returns:
|
| 117 |
+
{"source": "idref-resolver-api", "command": "align_person", "status": "accepted" | "ambiguous" | "low_confidence" | "not_found" | null, "best_ppn": str | null, "best_candidate": object | null, "candidates": [object], "candidates_scored": int, "similarity": object | null, "error": str | null}
|
| 118 |
+
"""
|
| 119 |
+
reply: dict[str, Any] = {
|
| 120 |
+
"source": "idref-resolver-api",
|
| 121 |
+
"command": "align_person",
|
| 122 |
+
"status": None,
|
| 123 |
+
"best_ppn": None,
|
| 124 |
+
"best_candidate": None,
|
| 125 |
+
"candidates": [],
|
| 126 |
+
"candidates_scored": 0,
|
| 127 |
+
"similarity": None,
|
| 128 |
+
"error": None,
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
+
if not (name or "").strip():
|
| 132 |
+
reply["error"] = "name is required"
|
| 133 |
+
return reply
|
| 134 |
+
if embedding_model not in EMBEDDING_MODELS:
|
| 135 |
+
reply["error"] = "embedding_model must be one of " + ", ".join(EMBEDDING_MODELS)
|
| 136 |
+
return reply
|
| 137 |
+
|
| 138 |
+
payload = {
|
| 139 |
+
"name": name.strip(),
|
| 140 |
+
"works": [w for w in (works or []) if w],
|
| 141 |
+
"field": field or "",
|
| 142 |
+
"affiliation": affiliation or "",
|
| 143 |
+
"role": "",
|
| 144 |
+
"year": str(year or ""),
|
| 145 |
+
"context": context or "",
|
| 146 |
+
"embedding_model": embedding_model,
|
| 147 |
+
"max_candidates": max(1, min(int(max_candidates or 20), MAX_CANDIDATES)),
|
| 148 |
+
"accept_threshold": float(accept_threshold),
|
| 149 |
+
"margin_threshold": float(margin_threshold),
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
result, error = _post("/align/person", payload)
|
| 153 |
+
if error:
|
| 154 |
+
reply["error"] = error
|
| 155 |
+
return reply
|
| 156 |
+
|
| 157 |
+
ranked = result.get("candidates") or []
|
| 158 |
+
reply.update(
|
| 159 |
+
status=result.get("status"),
|
| 160 |
+
best_ppn=result.get("best_ppn"),
|
| 161 |
+
best_candidate=result.get("best_candidate"),
|
| 162 |
+
candidates=ranked[: max(0, min(int(max_returned_candidates or 5), MAX_RETURNED))],
|
| 163 |
+
candidates_scored=len(ranked),
|
| 164 |
+
similarity=result.get("similarity"),
|
| 165 |
+
error=result.get("error"),
|
| 166 |
+
)
|
| 167 |
+
return reply
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# ── Presentation ──────────────────────────────────────────────────────────────
|
| 171 |
+
|
| 172 |
+
VERDICTS = {
|
| 173 |
+
"accepted": "✅ **Accepté** — un PPN est retenu.",
|
| 174 |
+
"ambiguous": "🤔 **Ambigu** — plusieurs autorités se tiennent ; l'outil s'abstient.",
|
| 175 |
+
"low_confidence": "⚠️ **Confiance insuffisante** — le meilleur score reste sous le seuil.",
|
| 176 |
+
"not_found": "❌ **Aucune autorité candidate** pour ce nom.",
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _render(payload: dict) -> str:
|
| 181 |
+
lines: list[str] = []
|
| 182 |
+
status = payload.get("status")
|
| 183 |
+
lines.append(VERDICTS.get(status, f"**Statut** — `{status}`"))
|
| 184 |
+
if payload.get("best_ppn"):
|
| 185 |
+
ppn = payload["best_ppn"]
|
| 186 |
+
lines.append(f"\n**PPN retenu** — [{ppn}](https://www.idref.fr/{ppn})")
|
| 187 |
+
elif status:
|
| 188 |
+
lines.append(
|
| 189 |
+
"\n_Abstention : ne retenez pas le premier candidat comme s'il avait "
|
| 190 |
+
"été accepté. Une abstention est une réponse._"
|
| 191 |
+
)
|
| 192 |
+
if payload.get("error"):
|
| 193 |
+
lines.append(f"\n> ⚠️ {payload['error']}")
|
| 194 |
+
|
| 195 |
+
candidates = payload.get("candidates") or []
|
| 196 |
+
if candidates:
|
| 197 |
+
lines += [
|
| 198 |
+
"",
|
| 199 |
+
f"**{len(candidates)} candidats affichés sur {payload.get('candidates_scored', '?')} évalués**",
|
| 200 |
+
"",
|
| 201 |
+
"| PPN | Score final | Forme retenue |",
|
| 202 |
+
"|---|---|---|",
|
| 203 |
+
]
|
| 204 |
+
for c in candidates:
|
| 205 |
+
ppn = c.get("ppn") or "—"
|
| 206 |
+
score = ((c.get("score") or {}).get("final"))
|
| 207 |
+
score_txt = f"{score:.3f}" if isinstance(score, (int, float)) else "—"
|
| 208 |
+
forms = ((c.get("evidence") or {}).get("preferred_forms") or [])
|
| 209 |
+
form = (forms[0] if forms else "—").replace("|", "\\|")
|
| 210 |
+
link = f"[{ppn}](https://www.idref.fr/{ppn})" if ppn != "—" else "—"
|
| 211 |
+
lines.append(f"| {link} | {score_txt} | {form} |")
|
| 212 |
+
return "\n".join(lines)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def _run(name, works_text, field, affiliation, year, context,
|
| 216 |
+
embedding_model, max_candidates, accept_threshold, margin_threshold):
|
| 217 |
+
works = [line.strip() for line in (works_text or "").splitlines() if line.strip()]
|
| 218 |
+
payload = align_person(
|
| 219 |
+
name=name,
|
| 220 |
+
works=works,
|
| 221 |
+
field=field,
|
| 222 |
+
affiliation=affiliation,
|
| 223 |
+
year=year,
|
| 224 |
+
context=context,
|
| 225 |
+
embedding_model=embedding_model,
|
| 226 |
+
max_candidates=max_candidates,
|
| 227 |
+
accept_threshold=accept_threshold,
|
| 228 |
+
margin_threshold=margin_threshold,
|
| 229 |
+
)
|
| 230 |
+
if payload.get("error") and payload.get("status") is None:
|
| 231 |
+
raise gr.Error(payload["error"])
|
| 232 |
+
return _render(payload), payload
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
# ── UI ────────────────────────────────────────────────────────────────────────
|
| 236 |
+
|
| 237 |
+
with gr.Blocks(title="IdRef resolver MCP demo") as demo:
|
| 238 |
+
gr.Markdown(
|
| 239 |
+
"# IdRef resolver — démo MCP\n"
|
| 240 |
+
"Démo autonome du serveur MCP "
|
| 241 |
+
"[`idref-resolver-api`](https://github.com/smartbiblia-solutions/agentic-stack/tree/main/mcp/idref-resolver-api) "
|
| 242 |
+
", aligner une autoritéé personne extraite d'un document sur son PPN IdRef, ou "
|
| 243 |
+
"s'abstenir quand l'indice est trop faible. "
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
with gr.Row():
|
| 247 |
+
with gr.Column():
|
| 248 |
+
name = gr.Textbox(label="Nom de la personne", placeholder="Valérie Robert")
|
| 249 |
+
works_text = gr.Textbox(
|
| 250 |
+
label="Titres d'œuvres (un par ligne)",
|
| 251 |
+
lines=3,
|
| 252 |
+
placeholder="Nous n'avons jamais été modernes",
|
| 253 |
+
)
|
| 254 |
+
field = gr.Textbox(label="Domaine", placeholder="sociologie des sciences")
|
| 255 |
+
affiliation = gr.Textbox(label="Affiliation", placeholder="Université de Nancy")
|
| 256 |
+
with gr.Row():
|
| 257 |
+
year = gr.Textbox(label="Année", placeholder="2003")
|
| 258 |
+
embedding_model = gr.Dropdown(
|
| 259 |
+
list(EMBEDDING_MODELS), value="lexical-idf", label="Comparaison"
|
| 260 |
+
)
|
| 261 |
+
context = gr.Textbox(label="Contexte libre", lines=2)
|
| 262 |
+
with gr.Accordion("Seuils", open=False):
|
| 263 |
+
max_candidates = gr.Slider(
|
| 264 |
+
1, MAX_CANDIDATES, value=20, step=1, label="Candidats évalués"
|
| 265 |
+
)
|
| 266 |
+
accept_threshold = gr.Slider(
|
| 267 |
+
0.0, 1.0, value=0.65, step=0.01, label="Seuil d'acceptation"
|
| 268 |
+
)
|
| 269 |
+
margin_threshold = gr.Slider(
|
| 270 |
+
0.0, 1.0, value=0.08, step=0.01, label="Écart minimal au second"
|
| 271 |
+
)
|
| 272 |
+
align_btn = gr.Button("Aligner", variant="primary")
|
| 273 |
+
with gr.Column():
|
| 274 |
+
verdict_out = gr.Markdown()
|
| 275 |
+
raw_out = gr.JSON(label="Sortie brute de l'outil")
|
| 276 |
+
|
| 277 |
+
inputs = [name, works_text, field, affiliation, year, context,
|
| 278 |
+
embedding_model, max_candidates, accept_threshold, margin_threshold]
|
| 279 |
+
|
| 280 |
+
gr.Examples(
|
| 281 |
+
examples=[
|
| 282 |
+
["Bruno Latour", "Nous n'avons jamais été modernes", "sociologie des sciences",
|
| 283 |
+
"Sciences Po", "1991", "", "lexical-idf", 20, 0.65, 0.08],
|
| 284 |
+
["Michel Foucault", "Surveiller et punir : naissance de la prison",
|
| 285 |
+
"philosophie", "Collège de France", "1975", "", "lexical-idf", 20, 0.65, 0.08],
|
| 286 |
+
["Valérie Robert", "", "études germaniques",
|
| 287 |
+
"Université Sorbonne Nouvelle", "", "", "lexical-idf", 20, 0.65, 0.08],
|
| 288 |
+
["Bruno Latour", "Nous n'avons jamais été modernes", "sociologie des sciences",
|
| 289 |
+
"Sciences Po", "1991", "", "albert-bge-m3", 20, 0.65, 0.08],
|
| 290 |
+
["Bruno Latour", "Nous n'avons jamais été modernes", "sociologie des sciences",
|
| 291 |
+
"Sciences Po", "1991", "", "lexical-idf", 20, 0.85, 0.20],
|
| 292 |
+
],
|
| 293 |
+
inputs=inputs,
|
| 294 |
+
label="Titre décisif, autre titre décisif, indices sans œuvre, comparaison "
|
| 295 |
+
"sémantique, puis le même cas avec des seuils sévères",
|
| 296 |
+
)
|
| 297 |
+
align_btn.click(_run, inputs=inputs, outputs=[verdict_out, raw_out], api_name=False)
|
| 298 |
+
|
| 299 |
+
# The only declared MCP tool. The name matches the canonical server's.
|
| 300 |
+
gr.api(align_person, api_name="align_person")
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
if __name__ == "__main__":
|
| 304 |
+
demo.launch(
|
| 305 |
+
theme=gr.themes.Soft(), # Gradio 6 moved theme from Blocks() to launch()
|
| 306 |
+
server_name=os.getenv("GRADIO_SERVER_NAME", "0.0.0.0"),
|
| 307 |
+
server_port=int(os.getenv("GRADIO_SERVER_PORT", "7860")),
|
| 308 |
+
mcp_server=os.getenv("GRADIO_MCP_SERVER", "true").lower() == "true",
|
| 309 |
+
)
|
requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
gradio[mcp]>=6,<7
|
| 2 |
+
httpx>=0.27
|