Geraldine commited on
Commit
32e40d4
·
verified ·
1 Parent(s): 33ff890

Upload 2 files

Browse files
Files changed (2) hide show
  1. app.py +309 -0
  2. 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