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..802e9331316 --- /dev/null +++ b/plugins/omi-pubmed-app/main.py @@ -0,0 +1,273 @@ +import html +from typing import Any + +import httpx +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.1", +) + +EUTILS = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils" +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 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, + "source": source, + "doi": doi, + "authors": authors, + "abstract": abstract, + } + + +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() + + +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", []) + + +async def _fetch_summaries(client: httpx.AsyncClient, ids: list[str]) -> dict: + if not ids: + return {} + data = await _fetch_json( + client, + "esummary.fcgi", + {"db": "pubmed", "id": ",".join(ids), "retmode": "json"}, + ) + return data.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 (numeric)"}, + }, + "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 (numeric)"}, + "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 = _clamp_max_results(body.get("max_results", 5)) + if not query: + return ChatToolResponse(error="query is required") + + 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) + + lines = [f"Top PubMed results for: {query}"] + 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 {result_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") + 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]) + + 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 = _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) + + lines = [f"Related PubMed articles for PMID {pmid}:"] + 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 {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}") 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..0461ccbacd3 --- /dev/null +++ b/plugins/omi-pubmed-app/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +httpx==0.27.2 +pydantic==2.5.2