diff --git a/plugins/omi-wikipedia-app/Procfile b/plugins/omi-wikipedia-app/Procfile new file mode 100644 index 00000000000..0e048402efc --- /dev/null +++ b/plugins/omi-wikipedia-app/Procfile @@ -0,0 +1 @@ +web: uvicorn main:app --host 0.0.0.0 --port $PORT diff --git a/plugins/omi-wikipedia-app/README.md b/plugins/omi-wikipedia-app/README.md new file mode 100644 index 00000000000..c1994efd610 --- /dev/null +++ b/plugins/omi-wikipedia-app/README.md @@ -0,0 +1,68 @@ +# Wikipedia Omi App + +Search and read Wikipedia from Omi chat tools. This app is useful for quick background research, definitions, people/place lookups, and random topic discovery without leaving a conversation. + +## Features + +- Search Wikipedia articles by keyword +- Fetch concise article summaries by title +- Discover a random article +- Optional language-code support, defaulting to English +- No OAuth, accounts, or API keys required + +## Chat Tools + +### `search_articles` + +Searches Wikipedia using the MediaWiki API. + +Parameters: + +- `query` (required): topic, person, place, event, or concept to search +- `language` (optional): Wikipedia language code, defaults to `en` +- `limit` (optional): maximum results, defaults to 5 and caps at 10 + +### `get_article_summary` + +Fetches a concise summary for a Wikipedia page using the REST summary endpoint. + +Parameters: + +- `title` (required): exact or near-exact article title +- `language` (optional): Wikipedia language code, defaults to `en` + +### `get_random_article` + +Returns a random article summary. + +Parameters: + +- `language` (optional): Wikipedia language code, defaults to `en` + +## Local Development + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn main:app --reload --port 8080 +``` + +Open: + +- `http://localhost:8080/health` +- `http://localhost:8080/.well-known/omi-tools.json` + +## Deployment + +Deploy this folder as a standalone FastAPI service. The app does not require environment variables. + +Railway can use the included `Procfile`: + +```bash +web: uvicorn main:app --host 0.0.0.0 --port $PORT +``` + +## Notes + +Wikimedia asks API clients to send an identifying user agent. This app sets a static Omi integration user agent for all outbound requests. diff --git a/plugins/omi-wikipedia-app/main.py b/plugins/omi-wikipedia-app/main.py new file mode 100644 index 00000000000..48f36218eb4 --- /dev/null +++ b/plugins/omi-wikipedia-app/main.py @@ -0,0 +1,284 @@ +""" +Wikipedia Integration App for Omi. + +Provides chat tools for searching Wikipedia, reading concise article summaries, +and finding a random article for exploration. +""" + +from html import unescape +import re +from typing import Any, Optional +from urllib.parse import quote + +import httpx +from fastapi import FastAPI +from fastapi.responses import HTMLResponse +from pydantic import BaseModel + + +REQUEST_TIMEOUT_SECONDS = 10 +MAX_LIMIT = 10 +DEFAULT_LANGUAGE = "en" +USER_AGENT = "omi-wikipedia-app/1.0 (https://omi.me)" + + +app = FastAPI( + title="Omi Wikipedia Integration", + description="Search and read Wikipedia from Omi chat tools", + version="1.0.0", +) + + +class ChatToolResponse(BaseModel): + """Response model for Omi chat tool endpoints.""" + + result: Optional[str] = None + error: Optional[str] = None + + +def _safe_limit(limit: Any) -> int: + if limit is None or limit == "": + return 5 + try: + limit = int(limit) + except (TypeError, ValueError): + return 5 + return max(1, min(limit, MAX_LIMIT)) + + +def _safe_language(language: Optional[str]) -> str: + lang = (language or DEFAULT_LANGUAGE).strip().lower() + if not lang.replace("-", "").isalpha() or len(lang) > 12: + return DEFAULT_LANGUAGE + return lang + + +async def _request_json(url: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]: + headers = {"User-Agent": USER_AGENT, "Accept": "application/json"} + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS, headers=headers) as client: + response = await client.get(url, params=params) + response.raise_for_status() + return response.json() + + +def _article_url(language: str, title: str) -> str: + return f"https://{language}.wikipedia.org/wiki/{quote(title.replace(' ', '_'))}" + + +def _clean_snippet(value: Optional[str]) -> str: + if not value: + return "" + + text = unescape(value) + text = re.sub(r"<[^>]+>", "", text) + text = re.sub(r"\s+", " ", text) + return text.strip() + + +def _format_summary(data: dict[str, Any], language: str) -> str: + title = data.get("title") or "Untitled" + extract = data.get("extract") or "No summary was returned for this article." + description = data.get("description") + page_url = data.get("content_urls", {}).get("desktop", {}).get("page") or _article_url(language, title) + + lines = [title] + if description: + lines.append(description) + lines.extend(["", extract, "", page_url]) + return "\n".join(lines) + + +@app.get("/") +async def root(): + return HTMLResponse( + """ + + Wikipedia x Omi + +

Wikipedia x Omi

+

Search Wikipedia, fetch article summaries, and discover random articles from Omi.

+

No sign-in is required.

+ + + """ + ) + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +@app.get("/.well-known/omi-tools.json") +async def get_omi_tools_manifest(): + return { + "tools": [ + { + "name": "search_articles", + "description": "Search Wikipedia articles by keyword. Use this when the user asks about a topic, person, place, event, concept, or wants matching encyclopedia articles.", + "endpoint": "/tools/search_articles", + "method": "POST", + "parameters": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Search query, such as a topic, person, place, event, or concept.", + }, + "language": { + "type": "string", + "description": "Wikipedia language code. Defaults to en.", + }, + "limit": { + "type": "integer", + "description": "Maximum results to return. Defaults to 5, maximum 10.", + }, + }, + "required": ["query"], + }, + "auth_required": False, + "status_message": "Searching Wikipedia...", + }, + { + "name": "get_article_summary", + "description": "Get a concise Wikipedia summary for an exact article title. Use this when the user asks for an overview, definition, background, or key facts about a known topic.", + "endpoint": "/tools/get_article_summary", + "method": "POST", + "parameters": { + "type": "object", + "properties": { + "title": { + "type": "string", + "description": "Exact or near-exact Wikipedia article title.", + }, + "language": { + "type": "string", + "description": "Wikipedia language code. Defaults to en.", + }, + }, + "required": ["title"], + }, + "auth_required": False, + "status_message": "Fetching Wikipedia article...", + }, + { + "name": "get_random_article", + "description": "Get a random Wikipedia article summary. Use this when the user wants to learn something random, discover a topic, or start an exploratory conversation.", + "endpoint": "/tools/get_random_article", + "method": "POST", + "parameters": { + "type": "object", + "properties": { + "language": { + "type": "string", + "description": "Wikipedia language code. Defaults to en.", + } + }, + "required": [], + }, + "auth_required": False, + "status_message": "Finding a random Wikipedia article...", + }, + ] + } + + +@app.post("/tools/search_articles", tags=["chat_tools"], response_model=ChatToolResponse) +async def search_articles(payload: dict[str, Any]): + query = (payload.get("query") or "").strip() + if not query: + return ChatToolResponse(error="Missing required field: query") + + language = _safe_language(payload.get("language")) + limit = _safe_limit(payload.get("limit")) + url = f"https://{language}.wikipedia.org/w/api.php" + + try: + data = await _request_json( + url, + { + "action": "query", + "list": "search", + "srsearch": query, + "srlimit": limit, + "format": "json", + "utf8": "1", + }, + ) + results = data.get("query", {}).get("search", [])[:limit] + if not results: + return ChatToolResponse(result=f"No Wikipedia articles found for '{query}'.") + + lines = [f"Wikipedia search results for '{query}':"] + for index, item in enumerate(results, start=1): + title = item.get("title") or "Untitled" + snippet = _clean_snippet(item.get("snippet")) + lines.append(f"\n{index}. {title}") + if snippet: + lines.append(f" {snippet}") + lines.append(f" {_article_url(language, title)}") + + return ChatToolResponse(result="\n".join(lines)) + except httpx.HTTPStatusError as exc: + return ChatToolResponse(error=f"Wikipedia search failed with status {exc.response.status_code}.") + except httpx.HTTPError as exc: + return ChatToolResponse(error=f"Wikipedia search failed: {exc}") + + +@app.post("/tools/get_article_summary", tags=["chat_tools"], response_model=ChatToolResponse) +async def get_article_summary(payload: dict[str, Any]): + title = (payload.get("title") or "").strip() + if not title: + return ChatToolResponse(error="Missing required field: title") + + language = _safe_language(payload.get("language")) + url = f"https://{language}.wikipedia.org/api/rest_v1/page/summary/{quote(title.replace(' ', '_'))}" + + try: + data = await _request_json(url) + if data.get("type") == "disambiguation": + return ChatToolResponse( + result=_format_summary(data, language) + + "\n\nThis is a disambiguation page. Use search_articles for more specific matches." + ) + return ChatToolResponse(result=_format_summary(data, language)) + except httpx.HTTPStatusError as exc: + if exc.response.status_code == 404: + return ChatToolResponse(error=f"No Wikipedia article found for '{title}'. Try search_articles first.") + return ChatToolResponse(error=f"Wikipedia article request failed with status {exc.response.status_code}.") + except httpx.HTTPError as exc: + return ChatToolResponse(error=f"Wikipedia article request failed: {exc}") + + +@app.post("/tools/get_random_article", tags=["chat_tools"], response_model=ChatToolResponse) +async def get_random_article(payload: dict[str, Any]): + language = _safe_language(payload.get("language")) + url = f"https://{language}.wikipedia.org/w/api.php" + + try: + data = await _request_json( + url, + { + "action": "query", + "list": "random", + "rnnamespace": "0", + "rnlimit": "1", + "format": "json", + "utf8": "1", + }, + ) + random_items = data.get("query", {}).get("random", []) + if not random_items: + return ChatToolResponse(result="No random Wikipedia article was returned.") + + title = random_items[0].get("title") + if not title: + return ChatToolResponse(result="Wikipedia returned a random article without a title.") + + summary_url = f"https://{language}.wikipedia.org/api/rest_v1/page/summary/{quote(title.replace(' ', '_'))}" + summary = await _request_json(summary_url) + return ChatToolResponse(result="Random Wikipedia article:\n\n" + _format_summary(summary, language)) + except httpx.HTTPStatusError as exc: + return ChatToolResponse(error=f"Wikipedia random article request failed with status {exc.response.status_code}.") + except httpx.HTTPError as exc: + return ChatToolResponse(error=f"Wikipedia random article request failed: {exc}") diff --git a/plugins/omi-wikipedia-app/railway.toml b/plugins/omi-wikipedia-app/railway.toml new file mode 100644 index 00000000000..6573527d5b3 --- /dev/null +++ b/plugins/omi-wikipedia-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-wikipedia-app/requirements.txt b/plugins/omi-wikipedia-app/requirements.txt new file mode 100644 index 00000000000..9b9db22ebb0 --- /dev/null +++ b/plugins/omi-wikipedia-app/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +pydantic==2.10.4 +httpx==0.28.1 diff --git a/plugins/omi-wikipedia-app/runtime.txt b/plugins/omi-wikipedia-app/runtime.txt new file mode 100644 index 00000000000..546f3c8de17 --- /dev/null +++ b/plugins/omi-wikipedia-app/runtime.txt @@ -0,0 +1 @@ +python-3.11.9