From 2f416e440519a6a581ac2c8da4f409e6abbee71d 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 09:14:31 +0800 Subject: [PATCH 1/2] Add Semantic Scholar no-auth chat tools app --- plugins/omi-semantic-scholar-app/Procfile | 1 + plugins/omi-semantic-scholar-app/README.md | 22 ++ plugins/omi-semantic-scholar-app/main.py | 202 ++++++++++++++++++ plugins/omi-semantic-scholar-app/models.py | 24 +++ plugins/omi-semantic-scholar-app/railway.toml | 9 + .../omi-semantic-scholar-app/requirements.txt | 4 + 6 files changed, 262 insertions(+) create mode 100644 plugins/omi-semantic-scholar-app/Procfile create mode 100644 plugins/omi-semantic-scholar-app/README.md create mode 100644 plugins/omi-semantic-scholar-app/main.py create mode 100644 plugins/omi-semantic-scholar-app/models.py create mode 100644 plugins/omi-semantic-scholar-app/railway.toml create mode 100644 plugins/omi-semantic-scholar-app/requirements.txt diff --git a/plugins/omi-semantic-scholar-app/Procfile b/plugins/omi-semantic-scholar-app/Procfile new file mode 100644 index 00000000000..0e048402efc --- /dev/null +++ b/plugins/omi-semantic-scholar-app/Procfile @@ -0,0 +1 @@ +web: uvicorn main:app --host 0.0.0.0 --port $PORT diff --git a/plugins/omi-semantic-scholar-app/README.md b/plugins/omi-semantic-scholar-app/README.md new file mode 100644 index 00000000000..fbff165c092 --- /dev/null +++ b/plugins/omi-semantic-scholar-app/README.md @@ -0,0 +1,22 @@ +# Omi Semantic Scholar App + +A standalone no-auth Omi integration app that provides chat tools for discovering academic papers from Semantic Scholar. + +## Tools + +- `search_semantic_scholar_papers`: Search papers by keyword. +- `get_semantic_scholar_paper`: Fetch paper details by Semantic Scholar paper ID or DOI. +- `get_semantic_scholar_author_papers`: Get recent papers by author ID. + +## Local Run + +```bash +pip install -r requirements.txt +uvicorn main:app --reload --host 0.0.0.0 --port 8080 +``` + +## Manifest + +The Omi tools manifest is served at: + +- `/.well-known/omi-tools.json` diff --git a/plugins/omi-semantic-scholar-app/main.py b/plugins/omi-semantic-scholar-app/main.py new file mode 100644 index 00000000000..637d203ef21 --- /dev/null +++ b/plugins/omi-semantic-scholar-app/main.py @@ -0,0 +1,202 @@ +"""Semantic Scholar no-auth chat tools app for Omi.""" +from __future__ import annotations + +from typing import Any, Dict, List +from urllib.parse import quote + +import httpx +from fastapi import FastAPI + +from models import ( + ChatToolResponse, + GetAuthorPapersRequest, + GetPaperRequest, + SearchPapersRequest, +) + +API_BASE = "https://api.semanticscholar.org/graph/v1" +TIMEOUT = 20 + +app = FastAPI( + title="Semantic Scholar Omi Integration", + description="No-auth Semantic Scholar chat tools for Omi", + version="1.0.0", +) + + +def format_authors(authors: List[Dict[str, Any]]) -> str: + names = [a.get("name", "Unknown") for a in authors if a.get("name")] + return ", ".join(names[:6]) if names else "Unknown" + + +def format_year(year: Any) -> str: + if isinstance(year, int): + return str(year) + return "Unknown" + + +def normalize_identifier(raw: str) -> str: + value = raw.strip() + if value.lower().startswith("doi:"): + value = value[4:] + return value + + +async def api_get(path: str, params: Dict[str, Any]) -> Dict[str, Any]: + url = f"{API_BASE}{path}" + async with httpx.AsyncClient(timeout=TIMEOUT) as client: + resp = await client.get(url, params=params) + resp.raise_for_status() + return resp.json() + + +@app.get("/.well-known/omi-tools.json") +async def manifest() -> Dict[str, Any]: + return { + "tools": [ + { + "name": "search_semantic_scholar_papers", + "description": "Search Semantic Scholar papers by keyword.", + "endpoint": "/tools/search_semantic_scholar_papers", + "method": "POST", + "parameters": { + "query": "string", + "max_results": "integer 1-10 (default 5)", + "min_year": "optional integer", + }, + }, + { + "name": "get_semantic_scholar_paper", + "description": "Get details for a paper by Semantic Scholar ID or DOI.", + "endpoint": "/tools/get_semantic_scholar_paper", + "method": "POST", + "parameters": {"paper_id_or_doi": "string"}, + }, + { + "name": "get_semantic_scholar_author_papers", + "description": "Get recent papers by Semantic Scholar author ID.", + "endpoint": "/tools/get_semantic_scholar_author_papers", + "method": "POST", + "parameters": {"author_id": "string", "max_results": "integer 1-10"}, + }, + ] + } + + +@app.post("/tools/search_semantic_scholar_papers", response_model=ChatToolResponse) +async def search_papers(req: SearchPapersRequest) -> ChatToolResponse: + params: Dict[str, Any] = { + "query": req.query, + "limit": req.max_results, + "fields": "title,year,authors,citationCount,url,venue", + } + if req.min_year: + params["year"] = f"{req.min_year}-" + + try: + data = await api_get("/paper/search", params) + papers = data.get("data", []) + if not papers: + return ChatToolResponse(result="No papers found.") + + lines = [] + for i, paper in enumerate(papers, start=1): + title = paper.get("title") or "Untitled" + year = format_year(paper.get("year")) + authors = format_authors(paper.get("authors", [])) + venue = paper.get("venue") or "Unknown venue" + cites = paper.get("citationCount", 0) + url = paper.get("url") or "" + lines.append( + f"{i}. {title}\n Authors: {authors}\n Year: {year} | Venue: {venue} | Citations: {cites}" + + (f"\n URL: {url}" if url else "") + ) + return ChatToolResponse(result="\n\n".join(lines)) + except httpx.HTTPStatusError as exc: + return ChatToolResponse(error=f"Semantic Scholar API error: {exc.response.status_code}") + except Exception as exc: + return ChatToolResponse(error=f"Unexpected error: {exc}") + + +@app.post("/tools/get_semantic_scholar_paper", response_model=ChatToolResponse) +async def get_paper(req: GetPaperRequest) -> ChatToolResponse: + try: + identifier = quote(normalize_identifier(req.paper_id_or_doi), safe="") + data = await api_get( + f"/paper/{identifier}", + {"fields": "title,abstract,year,authors,citationCount,referenceCount,url,venue"}, + ) + + title = data.get("title") or "Untitled" + year = format_year(data.get("year")) + authors = format_authors(data.get("authors", [])) + venue = data.get("venue") or "Unknown venue" + citations = data.get("citationCount", 0) + references = data.get("referenceCount", 0) + abstract = data.get("abstract") or "No abstract available." + url = data.get("url") or "" + + result = ( + f"Title: {title}\n" + f"Authors: {authors}\n" + f"Year: {year}\n" + f"Venue: {venue}\n" + f"Citations: {citations} | References: {references}\n" + f"Abstract: {abstract}" + + (f"\nURL: {url}" if url else "") + ) + return ChatToolResponse(result=result) + except httpx.HTTPStatusError as exc: + code = exc.response.status_code + if code == 404: + return ChatToolResponse(error="Paper not found.") + return ChatToolResponse(error=f"Semantic Scholar API error: {code}") + except Exception as exc: + return ChatToolResponse(error=f"Unexpected error: {exc}") + + +@app.post("/tools/get_semantic_scholar_author_papers", response_model=ChatToolResponse) +async def get_author_papers(req: GetAuthorPapersRequest) -> ChatToolResponse: + try: + author_id = quote(req.author_id.strip(), safe="") + data = await api_get( + f"/author/{author_id}", + { + "fields": "name,papers.title,papers.year,papers.citationCount,papers.url", + }, + ) + + author_name = data.get("name") or req.author_id + papers = data.get("papers", []) + if not papers: + return ChatToolResponse(result=f"No papers found for author {author_name}.") + + papers_sorted = sorted( + papers, + key=lambda p: ((p.get("year") or 0), (p.get("citationCount") or 0)), + reverse=True, + )[: req.max_results] + + lines = [f"Recent papers by {author_name}:"] + for i, paper in enumerate(papers_sorted, start=1): + title = paper.get("title") or "Untitled" + year = format_year(paper.get("year")) + cites = paper.get("citationCount", 0) + url = paper.get("url") or "" + lines.append( + f"{i}. {title}\n Year: {year} | Citations: {cites}" + (f"\n URL: {url}" if url else "") + ) + + return ChatToolResponse(result="\n\n".join(lines)) + except httpx.HTTPStatusError as exc: + code = exc.response.status_code + if code == 404: + return ChatToolResponse(error="Author not found.") + return ChatToolResponse(error=f"Semantic Scholar API error: {code}") + except Exception as exc: + return ChatToolResponse(error=f"Unexpected error: {exc}") + + +@app.get("/") +async def root() -> Dict[str, str]: + return {"message": "Semantic Scholar Omi integration is running."} diff --git a/plugins/omi-semantic-scholar-app/models.py b/plugins/omi-semantic-scholar-app/models.py new file mode 100644 index 00000000000..c1b570c2ced --- /dev/null +++ b/plugins/omi-semantic-scholar-app/models.py @@ -0,0 +1,24 @@ +"""Pydantic models for Semantic Scholar Omi integration.""" +from typing import Optional +from pydantic import BaseModel, Field + + +class ChatToolResponse(BaseModel): + """Response model for Omi chat tool endpoints.""" + result: Optional[str] = None + error: Optional[str] = None + + +class SearchPapersRequest(BaseModel): + query: str = Field(..., min_length=2, max_length=200) + max_results: int = Field(default=5, ge=1, le=10) + min_year: Optional[int] = Field(default=None, ge=1800, le=2100) + + +class GetPaperRequest(BaseModel): + paper_id_or_doi: str = Field(..., min_length=2, max_length=200) + + +class GetAuthorPapersRequest(BaseModel): + author_id: str = Field(..., min_length=1, max_length=100) + max_results: int = Field(default=5, ge=1, le=10) diff --git a/plugins/omi-semantic-scholar-app/railway.toml b/plugins/omi-semantic-scholar-app/railway.toml new file mode 100644 index 00000000000..0bbc151a537 --- /dev/null +++ b/plugins/omi-semantic-scholar-app/railway.toml @@ -0,0 +1,9 @@ +[build] +builder = "NIXPACKS" + +[deploy] +startCommand = "uvicorn main:app --host 0.0.0.0 --port $PORT" +healthcheckPath = "/" +healthcheckTimeout = 100 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 10 diff --git a/plugins/omi-semantic-scholar-app/requirements.txt b/plugins/omi-semantic-scholar-app/requirements.txt new file mode 100644 index 00000000000..61254b73429 --- /dev/null +++ b/plugins/omi-semantic-scholar-app/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.104.1 +uvicorn==0.24.0 +httpx==0.25.2 +pydantic==2.5.2 From c2099ae0dabb3bec077d90383c8c37d751766619 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 09:43:03 +0800 Subject: [PATCH 2/2] Fix Semantic Scholar DOI handling and tool manifest schema --- plugins/omi-semantic-scholar-app/main.py | 47 ++++++++++++++++++---- plugins/omi-semantic-scholar-app/models.py | 7 ++++ 2 files changed, 47 insertions(+), 7 deletions(-) diff --git a/plugins/omi-semantic-scholar-app/main.py b/plugins/omi-semantic-scholar-app/main.py index 637d203ef21..b50fdcea291 100644 --- a/plugins/omi-semantic-scholar-app/main.py +++ b/plugins/omi-semantic-scholar-app/main.py @@ -38,7 +38,8 @@ def format_year(year: Any) -> str: def normalize_identifier(raw: str) -> str: value = raw.strip() if value.lower().startswith("doi:"): - value = value[4:] + # Preserve DOI namespace expected by Semantic Scholar. + value = "DOI:" + value[4:].strip() return value @@ -60,9 +61,19 @@ async def manifest() -> Dict[str, Any]: "endpoint": "/tools/search_semantic_scholar_papers", "method": "POST", "parameters": { - "query": "string", - "max_results": "integer 1-10 (default 5)", - "min_year": "optional integer", + "type": "object", + "properties": { + "query": {"type": "string", "description": "Search query"}, + "max_results": { + "type": "integer", + "description": "Max results (1-10, default 5)", + }, + "min_year": { + "type": "integer", + "description": "Optional minimum publication year", + }, + }, + "required": ["query"], }, }, { @@ -70,14 +81,36 @@ async def manifest() -> Dict[str, Any]: "description": "Get details for a paper by Semantic Scholar ID or DOI.", "endpoint": "/tools/get_semantic_scholar_paper", "method": "POST", - "parameters": {"paper_id_or_doi": "string"}, + "parameters": { + "type": "object", + "properties": { + "paper_id_or_doi": { + "type": "string", + "description": "Semantic Scholar paper ID or DOI", + } + }, + "required": ["paper_id_or_doi"], + }, }, { "name": "get_semantic_scholar_author_papers", "description": "Get recent papers by Semantic Scholar author ID.", "endpoint": "/tools/get_semantic_scholar_author_papers", "method": "POST", - "parameters": {"author_id": "string", "max_results": "integer 1-10"}, + "parameters": { + "type": "object", + "properties": { + "author_id": { + "type": "string", + "description": "Semantic Scholar author ID", + }, + "max_results": { + "type": "integer", + "description": "Max results (1-10, default 5)", + }, + }, + "required": ["author_id"], + }, }, ] } @@ -121,7 +154,7 @@ async def search_papers(req: SearchPapersRequest) -> ChatToolResponse: @app.post("/tools/get_semantic_scholar_paper", response_model=ChatToolResponse) async def get_paper(req: GetPaperRequest) -> ChatToolResponse: try: - identifier = quote(normalize_identifier(req.paper_id_or_doi), safe="") + identifier = quote(normalize_identifier(req.paper_id_or_doi), safe=":") data = await api_get( f"/paper/{identifier}", {"fields": "title,abstract,year,authors,citationCount,referenceCount,url,venue"}, diff --git a/plugins/omi-semantic-scholar-app/models.py b/plugins/omi-semantic-scholar-app/models.py index c1b570c2ced..a113ed3871d 100644 --- a/plugins/omi-semantic-scholar-app/models.py +++ b/plugins/omi-semantic-scholar-app/models.py @@ -1,6 +1,7 @@ """Pydantic models for Semantic Scholar Omi integration.""" from typing import Optional from pydantic import BaseModel, Field +from pydantic import model_validator class ChatToolResponse(BaseModel): @@ -8,6 +9,12 @@ class ChatToolResponse(BaseModel): result: Optional[str] = None error: Optional[str] = None + @model_validator(mode="after") + def validate_result_or_error(self): + if self.result is None and self.error is None: + raise ValueError("Either result or error must be provided.") + return self + class SearchPapersRequest(BaseModel): query: str = Field(..., min_length=2, max_length=200)