From a0ab88421ff9a2b031c5a783fcca66559f628593 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=A2=81=E5=98=89=E5=91=88?= Date: Mon, 25 May 2026 06:47:57 +0800 Subject: [PATCH 1/3] Add PubMed no-auth chat tools plugin --- plugins/omi-pubmed-app/Procfile | 1 + plugins/omi-pubmed-app/README.md | 20 ++ plugins/omi-pubmed-app/main.py | 244 ++++++++++++++++++++++++ plugins/omi-pubmed-app/models.py | 7 + plugins/omi-pubmed-app/railway.toml | 7 + plugins/omi-pubmed-app/requirements.txt | 4 + 6 files changed, 283 insertions(+) create mode 100644 plugins/omi-pubmed-app/Procfile create mode 100644 plugins/omi-pubmed-app/README.md create mode 100644 plugins/omi-pubmed-app/main.py create mode 100644 plugins/omi-pubmed-app/models.py create mode 100644 plugins/omi-pubmed-app/railway.toml create mode 100644 plugins/omi-pubmed-app/requirements.txt diff --git a/plugins/omi-pubmed-app/Procfile b/plugins/omi-pubmed-app/Procfile new file mode 100644 index 00000000000..0e048402efc --- /dev/null +++ b/plugins/omi-pubmed-app/Procfile @@ -0,0 +1 @@ +web: uvicorn main:app --host 0.0.0.0 --port $PORT diff --git a/plugins/omi-pubmed-app/README.md b/plugins/omi-pubmed-app/README.md new file mode 100644 index 00000000000..0ce7fbc9d7b --- /dev/null +++ b/plugins/omi-pubmed-app/README.md @@ -0,0 +1,20 @@ +# Omi PubMed App + +PubMed chat tools integration for Omi. This app uses the public NCBI E-utilities API (no OAuth/API key required). + +## Tools +- `search_pubmed`: Search PubMed by query and return top matches. +- `get_pubmed_article`: Fetch article details for a PubMed ID. +- `get_related_pubmed`: Get related articles from a PubMed ID. + +## Run locally +```bash +pip install -r requirements.txt +uvicorn main:app --reload --port 8080 +``` + +## Omi manifest URL +`/.well-known/omi-tools.json` + +## Health check +`/health` diff --git a/plugins/omi-pubmed-app/main.py b/plugins/omi-pubmed-app/main.py new file mode 100644 index 00000000000..71ce4625a89 --- /dev/null +++ b/plugins/omi-pubmed-app/main.py @@ -0,0 +1,244 @@ +import html +from typing import Any + +import requests +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse + +from models import ChatToolResponse + +app = FastAPI( + title="Omi PubMed App", + description="PubMed chat tools for Omi", + version="1.0.0", +) + +EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" +TIMEOUT = 20 + + +def _safe(value: Any) -> str: + return html.unescape(str(value)) if value is not None else "" + + +def _extract_article_fields(record: dict) -> dict: + title = _safe(record.get("title", "Untitled")) + pubdate = _safe(record.get("pubdate", "")) + source = _safe(record.get("source", "")) + doi = _safe(record.get("elocationid", "")) + authors = [] + for a in record.get("authors", [])[:8]: + name = _safe(a.get("name")) + if name: + authors.append(name) + abstract = "" + if isinstance(record.get("abstract"), list): + abstract = " ".join(_safe(x) for x in record["abstract"] if x) + elif record.get("abstract"): + abstract = _safe(record["abstract"]) + return { + "title": title, + "pubdate": pubdate, + "source": source, + "doi": doi, + "authors": authors, + "abstract": abstract, + } + + +def _search_ids(query: str, retmax: int = 5) -> list[str]: + params = { + "db": "pubmed", + "term": query, + "retmode": "json", + "retmax": max(1, min(retmax, 20)), + "sort": "relevance", + } + resp = requests.get(f"{EUTILS}/esearch.fcgi", params=params, timeout=TIMEOUT) + resp.raise_for_status() + return resp.json().get("esearchresult", {}).get("idlist", []) + + +def _fetch_summaries(ids: list[str]) -> dict: + if not ids: + return {} + resp = requests.get( + f"{EUTILS}/esummary.fcgi", + params={"db": "pubmed", "id": ",".join(ids), "retmode": "json"}, + timeout=TIMEOUT, + ) + resp.raise_for_status() + return resp.json().get("result", {}) + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +@app.get("/") +async def home(): + return HTMLResponse( + """ + +

Omi PubMed App

+

Use PubMed search and article lookup from Omi chat.

+

Manifest: /.well-known/omi-tools.json

+ + """ + ) + + +@app.get("/.well-known/omi-tools.json") +async def manifest(): + return { + "tools": [ + { + "name": "search_pubmed", + "description": "Search PubMed by keywords and return relevant papers.", + "endpoint": "/tools/search_pubmed", + "method": "POST", + "parameters": { + "properties": { + "query": {"type": "string", "description": "Search query"}, + "max_results": {"type": "integer", "description": "1-10, default 5"}, + }, + "required": ["query"], + }, + "auth_required": False, + "status_message": "Searching PubMed...", + }, + { + "name": "get_pubmed_article", + "description": "Get detailed citation and abstract for a PubMed ID.", + "endpoint": "/tools/get_pubmed_article", + "method": "POST", + "parameters": { + "properties": { + "pmid": {"type": "string", "description": "PubMed ID"}, + }, + "required": ["pmid"], + }, + "auth_required": False, + "status_message": "Fetching PubMed article...", + }, + { + "name": "get_related_pubmed", + "description": "Find related PubMed articles from a PubMed ID.", + "endpoint": "/tools/get_related_pubmed", + "method": "POST", + "parameters": { + "properties": { + "pmid": {"type": "string", "description": "PubMed ID"}, + "max_results": {"type": "integer", "description": "1-10, default 5"}, + }, + "required": ["pmid"], + }, + "auth_required": False, + "status_message": "Finding related PubMed articles...", + }, + ] + } + + +@app.get("/manifest.json") +async def manifest_alias(): + return await manifest() + + +@app.post("/tools/search_pubmed", response_model=ChatToolResponse, tags=["chat_tools"]) +async def search_pubmed(request: Request): + try: + body = await request.json() + query = (body.get("query") or "").strip() + max_results = int(body.get("max_results", 5)) + if not query: + return ChatToolResponse(error="query is required") + + ids = _search_ids(query, max_results) + if not ids: + return ChatToolResponse(result=f"No PubMed results found for: {query}") + + summaries = _fetch_summaries(ids) + lines = [f"Top PubMed results for: {query}"] + for idx, pmid in enumerate(ids, start=1): + row = summaries.get(pmid, {}) + title = _safe(row.get("title", "Untitled")) + journal = _safe(row.get("fulljournalname", row.get("source", ""))) + date = _safe(row.get("pubdate", "")) + lines.append(f"{idx}. PMID {pmid}: {title} ({journal}, {date})") + return ChatToolResponse(result="\n".join(lines)) + except Exception as e: + return ChatToolResponse(error=f"PubMed search failed: {e}") + + +@app.post("/tools/get_pubmed_article", response_model=ChatToolResponse, tags=["chat_tools"]) +async def get_pubmed_article(request: Request): + try: + body = await request.json() + pmid = (body.get("pmid") or "").strip() + if not pmid: + return ChatToolResponse(error="pmid is required") + + summaries = _fetch_summaries([pmid]) + if pmid not in summaries: + return ChatToolResponse(error=f"No PubMed record found for PMID {pmid}") + + record = _extract_article_fields(summaries[pmid]) + lines = [ + f"PMID {pmid}", + f"Title: {record['title']}", + f"Authors: {', '.join(record['authors']) if record['authors'] else 'N/A'}", + f"Journal/Date: {record['source']} ({record['pubdate']})", + f"DOI/Location: {record['doi'] or 'N/A'}", + ] + if record["abstract"]: + lines.append(f"Abstract: {record['abstract'][:1800]}") + return ChatToolResponse(result="\n".join(lines)) + except Exception as e: + return ChatToolResponse(error=f"Failed to fetch PubMed article: {e}") + + +@app.post("/tools/get_related_pubmed", response_model=ChatToolResponse, tags=["chat_tools"]) +async def get_related_pubmed(request: Request): + try: + body = await request.json() + pmid = (body.get("pmid") or "").strip() + max_results = int(body.get("max_results", 5)) + if not pmid: + return ChatToolResponse(error="pmid is required") + + link_resp = requests.get( + f"{EUTILS}/elink.fcgi", + params={ + "dbfrom": "pubmed", + "db": "pubmed", + "id": pmid, + "linkname": "pubmed_pubmed", + "retmode": "json", + }, + timeout=TIMEOUT, + ) + link_resp.raise_for_status() + data = link_resp.json() + linksets = data.get("linksets", []) + related = [] + if linksets: + dbs = linksets[0].get("linksetdbs", []) + if dbs: + related = [str(x) for x in dbs[0].get("links", [])[: max(1, min(max_results, 10))]] + + if not related: + return ChatToolResponse(result=f"No related articles found for PMID {pmid}") + + summaries = _fetch_summaries(related) + lines = [f"Related PubMed articles for PMID {pmid}:"] + for idx, rid in enumerate(related, start=1): + row = summaries.get(rid, {}) + title = _safe(row.get("title", "Untitled")) + journal = _safe(row.get("fulljournalname", row.get("source", ""))) + date = _safe(row.get("pubdate", "")) + lines.append(f"{idx}. PMID {rid}: {title} ({journal}, {date})") + return ChatToolResponse(result="\n".join(lines)) + except Exception as e: + return ChatToolResponse(error=f"Failed to fetch related PubMed articles: {e}") diff --git a/plugins/omi-pubmed-app/models.py b/plugins/omi-pubmed-app/models.py new file mode 100644 index 00000000000..26d0fe21bec --- /dev/null +++ b/plugins/omi-pubmed-app/models.py @@ -0,0 +1,7 @@ +from typing import Optional +from pydantic import BaseModel + + +class ChatToolResponse(BaseModel): + result: Optional[str] = None + error: Optional[str] = None diff --git a/plugins/omi-pubmed-app/railway.toml b/plugins/omi-pubmed-app/railway.toml new file mode 100644 index 00000000000..6573527d5b3 --- /dev/null +++ b/plugins/omi-pubmed-app/railway.toml @@ -0,0 +1,7 @@ +[build] +builder = "NIXPACKS" + +[deploy] +startCommand = "uvicorn main:app --host 0.0.0.0 --port $PORT" +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 10 diff --git a/plugins/omi-pubmed-app/requirements.txt b/plugins/omi-pubmed-app/requirements.txt new file mode 100644 index 00000000000..fb319fe6f05 --- /dev/null +++ b/plugins/omi-pubmed-app/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +requests==2.34.2 +pydantic==2.5.2 From 9a1c6243f9c1e6c801d7f8043aeb301c60de1691 Mon Sep 17 00:00:00 2001 From: liangtovi-debug Date: Mon, 25 May 2026 07:48:42 +0800 Subject: [PATCH 2/3] fix(pubmed-app): use async httpx and validate pmid/max_results --- plugins/omi-pubmed-app/main.py | 151 ++++++++++++++++++++------------- 1 file changed, 90 insertions(+), 61 deletions(-) diff --git a/plugins/omi-pubmed-app/main.py b/plugins/omi-pubmed-app/main.py index 71ce4625a89..802e9331316 100644 --- a/plugins/omi-pubmed-app/main.py +++ b/plugins/omi-pubmed-app/main.py @@ -1,7 +1,7 @@ import html from typing import Any -import requests +import httpx from fastapi import FastAPI, Request from fastapi.responses import HTMLResponse @@ -10,32 +10,46 @@ app = FastAPI( title="Omi PubMed App", description="PubMed chat tools for Omi", - version="1.0.0", + version="1.0.1", ) EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" -TIMEOUT = 20 +TIMEOUT = 20.0 def _safe(value: Any) -> str: return html.unescape(str(value)) if value is not None else "" +def _is_valid_pmid(pmid: str) -> bool: + return pmid.isdigit() and len(pmid) <= 12 + + +def _clamp_max_results(value: Any, default: int = 5) -> int: + try: + parsed = int(value) + except (TypeError, ValueError): + return default + return max(1, min(parsed, 10)) + + def _extract_article_fields(record: dict) -> dict: title = _safe(record.get("title", "Untitled")) pubdate = _safe(record.get("pubdate", "")) source = _safe(record.get("source", "")) doi = _safe(record.get("elocationid", "")) authors = [] - for a in record.get("authors", [])[:8]: - name = _safe(a.get("name")) + for author in record.get("authors", [])[:8]: + name = _safe(author.get("name")) if name: authors.append(name) + abstract = "" if isinstance(record.get("abstract"), list): abstract = " ".join(_safe(x) for x in record["abstract"] if x) elif record.get("abstract"): abstract = _safe(record["abstract"]) + return { "title": title, "pubdate": pubdate, @@ -46,29 +60,36 @@ def _extract_article_fields(record: dict) -> dict: } -def _search_ids(query: str, retmax: int = 5) -> list[str]: - params = { - "db": "pubmed", - "term": query, - "retmode": "json", - "retmax": max(1, min(retmax, 20)), - "sort": "relevance", - } - resp = requests.get(f"{EUTILS}/esearch.fcgi", params=params, timeout=TIMEOUT) +async def _fetch_json(client: httpx.AsyncClient, endpoint: str, params: dict) -> dict: + resp = await client.get(f"{EUTILS}/{endpoint}", params=params) resp.raise_for_status() - return resp.json().get("esearchresult", {}).get("idlist", []) + return resp.json() + + +async def _search_ids(client: httpx.AsyncClient, query: str, retmax: int = 5) -> list[str]: + data = await _fetch_json( + client, + "esearch.fcgi", + { + "db": "pubmed", + "term": query, + "retmode": "json", + "retmax": _clamp_max_results(retmax), + "sort": "relevance", + }, + ) + return data.get("esearchresult", {}).get("idlist", []) -def _fetch_summaries(ids: list[str]) -> dict: +async def _fetch_summaries(client: httpx.AsyncClient, ids: list[str]) -> dict: if not ids: return {} - resp = requests.get( - f"{EUTILS}/esummary.fcgi", - params={"db": "pubmed", "id": ",".join(ids), "retmode": "json"}, - timeout=TIMEOUT, + data = await _fetch_json( + client, + "esummary.fcgi", + {"db": "pubmed", "id": ",".join(ids), "retmode": "json"}, ) - resp.raise_for_status() - return resp.json().get("result", {}) + return data.get("result", {}) @app.get("/health") @@ -115,7 +136,7 @@ async def manifest(): "method": "POST", "parameters": { "properties": { - "pmid": {"type": "string", "description": "PubMed ID"}, + "pmid": {"type": "string", "description": "PubMed ID (numeric)"}, }, "required": ["pmid"], }, @@ -129,7 +150,7 @@ async def manifest(): "method": "POST", "parameters": { "properties": { - "pmid": {"type": "string", "description": "PubMed ID"}, + "pmid": {"type": "string", "description": "PubMed ID (numeric)"}, "max_results": {"type": "integer", "description": "1-10, default 5"}, }, "required": ["pmid"], @@ -151,22 +172,23 @@ async def search_pubmed(request: Request): try: body = await request.json() query = (body.get("query") or "").strip() - max_results = int(body.get("max_results", 5)) + max_results = _clamp_max_results(body.get("max_results", 5)) if not query: return ChatToolResponse(error="query is required") - ids = _search_ids(query, max_results) - if not ids: - return ChatToolResponse(result=f"No PubMed results found for: {query}") + async with httpx.AsyncClient(timeout=TIMEOUT) as client: + ids = await _search_ids(client, query, max_results) + if not ids: + return ChatToolResponse(result=f"No PubMed results found for: {query}") + summaries = await _fetch_summaries(client, ids) - summaries = _fetch_summaries(ids) lines = [f"Top PubMed results for: {query}"] - for idx, pmid in enumerate(ids, start=1): - row = summaries.get(pmid, {}) + for idx, result_pmid in enumerate(ids, start=1): + row = summaries.get(result_pmid, {}) title = _safe(row.get("title", "Untitled")) journal = _safe(row.get("fulljournalname", row.get("source", ""))) date = _safe(row.get("pubdate", "")) - lines.append(f"{idx}. PMID {pmid}: {title} ({journal}, {date})") + lines.append(f"{idx}. PMID {result_pmid}: {title} ({journal}, {date})") return ChatToolResponse(result="\n".join(lines)) except Exception as e: return ChatToolResponse(error=f"PubMed search failed: {e}") @@ -179,8 +201,12 @@ async def get_pubmed_article(request: Request): pmid = (body.get("pmid") or "").strip() if not pmid: return ChatToolResponse(error="pmid is required") + if not _is_valid_pmid(pmid): + return ChatToolResponse(error="pmid must be a numeric PubMed ID") + + async with httpx.AsyncClient(timeout=TIMEOUT) as client: + summaries = await _fetch_summaries(client, [pmid]) - summaries = _fetch_summaries([pmid]) if pmid not in summaries: return ChatToolResponse(error=f"No PubMed record found for PMID {pmid}") @@ -204,41 +230,44 @@ async def get_related_pubmed(request: Request): try: body = await request.json() pmid = (body.get("pmid") or "").strip() - max_results = int(body.get("max_results", 5)) + max_results = _clamp_max_results(body.get("max_results", 5)) if not pmid: return ChatToolResponse(error="pmid is required") + if not _is_valid_pmid(pmid): + return ChatToolResponse(error="pmid must be a numeric PubMed ID") + + async with httpx.AsyncClient(timeout=TIMEOUT) as client: + data = await _fetch_json( + client, + "elink.fcgi", + { + "dbfrom": "pubmed", + "db": "pubmed", + "id": pmid, + "linkname": "pubmed_pubmed", + "retmode": "json", + }, + ) + + linksets = data.get("linksets", []) + related = [] + if linksets: + dbs = linksets[0].get("linksetdbs", []) + if dbs: + related = [str(x) for x in dbs[0].get("links", [])[:max_results]] + + if not related: + return ChatToolResponse(result=f"No related articles found for PMID {pmid}") + + summaries = await _fetch_summaries(client, related) - link_resp = requests.get( - f"{EUTILS}/elink.fcgi", - params={ - "dbfrom": "pubmed", - "db": "pubmed", - "id": pmid, - "linkname": "pubmed_pubmed", - "retmode": "json", - }, - timeout=TIMEOUT, - ) - link_resp.raise_for_status() - data = link_resp.json() - linksets = data.get("linksets", []) - related = [] - if linksets: - dbs = linksets[0].get("linksetdbs", []) - if dbs: - related = [str(x) for x in dbs[0].get("links", [])[: max(1, min(max_results, 10))]] - - if not related: - return ChatToolResponse(result=f"No related articles found for PMID {pmid}") - - summaries = _fetch_summaries(related) lines = [f"Related PubMed articles for PMID {pmid}:"] - for idx, rid in enumerate(related, start=1): - row = summaries.get(rid, {}) + for idx, related_pmid in enumerate(related, start=1): + row = summaries.get(related_pmid, {}) title = _safe(row.get("title", "Untitled")) journal = _safe(row.get("fulljournalname", row.get("source", ""))) date = _safe(row.get("pubdate", "")) - lines.append(f"{idx}. PMID {rid}: {title} ({journal}, {date})") + lines.append(f"{idx}. PMID {related_pmid}: {title} ({journal}, {date})") return ChatToolResponse(result="\n".join(lines)) except Exception as e: return ChatToolResponse(error=f"Failed to fetch related PubMed articles: {e}") From 847878a477410ed4020d62fe4ce0c3a5d5fdc76f Mon Sep 17 00:00:00 2001 From: liangtovi-debug Date: Mon, 25 May 2026 07:48:43 +0800 Subject: [PATCH 3/3] chore(pubmed-app): replace requests with httpx --- plugins/omi-pubmed-app/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/omi-pubmed-app/requirements.txt b/plugins/omi-pubmed-app/requirements.txt index fb319fe6f05..0461ccbacd3 100644 --- a/plugins/omi-pubmed-app/requirements.txt +++ b/plugins/omi-pubmed-app/requirements.txt @@ -1,4 +1,4 @@ fastapi==0.104.1 uvicorn==0.24.0 -requests==2.34.2 +httpx==0.27.2 pydantic==2.5.2