main.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504
  1. # -*- coding: utf-8 -*-
  2. """
  3. Trixy Wikipedia Plugin.
  4. Beantwortet Wissensfragen ueber die Wikipedia-API.
  5. Unterstuetzt mehrere Sprachen — Frage-Muster, Monatsbezeichnungen
  6. und Antwort-Templates werden aus Sprachdateien geladen (lang/*.json).
  7. Beispiele:
  8. "Wer war Ludwig van Beethoven?"
  9. "Wann starb Mozart?"
  10. "Wann wurde Madonna geboren?"
  11. "Was ist Photosynthese?"
  12. "Erzaehl mir etwas ueber die Relativitaetstheorie"
  13. """
  14. from __future__ import annotations
  15. import json
  16. import re
  17. from html import unescape
  18. from pathlib import Path
  19. from typing import Any
  20. import aiohttp
  21. from trixy_core.plugins.trixy_plugin import TrixyPlugin
  22. from trixy_core.utils.debug import pinfo, pdebug
  23. from trixy_core.nlp.decorators import intent
  24. from trixy_core.nlp.handler import IntentResult, IntentReceivedData
  25. # ============================================================================
  26. # Sprachdaten
  27. # ============================================================================
  28. class LanguageData:
  29. """Laedt und kapselt sprachspezifische Daten fuer Wikipedia-Abfragen."""
  30. def __init__(self, lang_dir: Path, language: str = "de") -> None:
  31. self._lang_dir = lang_dir
  32. self._language = language
  33. self._data: dict[str, Any] = {}
  34. self.load(language)
  35. def load(self, language: str) -> None:
  36. """Laedt Sprachdaten aus lang/{language}.json."""
  37. self._language = language
  38. path = self._lang_dir / f"{language}.json"
  39. if not path.exists():
  40. # Fallback auf Deutsch
  41. path = self._lang_dir / "de.json"
  42. try:
  43. with open(path, "r", encoding="utf-8") as f:
  44. self._data = json.load(f)
  45. except Exception:
  46. self._data = {}
  47. @property
  48. def question_prefixes(self) -> list[str]:
  49. return self._data.get("question_prefixes", [])
  50. @property
  51. def question_suffixes(self) -> list[str]:
  52. return self._data.get("question_suffixes", [])
  53. @property
  54. def strip_articles(self) -> str:
  55. return self._data.get("strip_articles", "")
  56. @property
  57. def months(self) -> list[str]:
  58. return self._data.get("months", [])
  59. @property
  60. def months_pattern(self) -> str:
  61. return "|".join(re.escape(m) for m in self.months)
  62. @property
  63. def birth_keywords(self) -> list[str]:
  64. return self._data.get("birth_keywords", [])
  65. @property
  66. def death_keywords(self) -> list[str]:
  67. return self._data.get("death_keywords", [])
  68. @property
  69. def age_keywords(self) -> list[str]:
  70. return self._data.get("age_keywords", [])
  71. @property
  72. def etymology_patterns(self) -> list[str]:
  73. return self._data.get("etymology_patterns", [])
  74. def template(self, key: str) -> str:
  75. return self._data.get("answer_templates", {}).get(key, "")
  76. def is_birth_question(self, text: str) -> bool:
  77. lower = text.lower()
  78. return any(kw in lower for kw in self.birth_keywords)
  79. def is_death_question(self, text: str) -> bool:
  80. lower = text.lower()
  81. return any(kw in lower for kw in self.death_keywords)
  82. def is_age_question(self, text: str) -> bool:
  83. lower = text.lower()
  84. return any(kw in lower for kw in self.age_keywords)
  85. # ============================================================================
  86. # Wikipedia API Client
  87. # ============================================================================
  88. WIKI_API_TEMPLATE = "https://{lang}.wikipedia.org"
  89. WIKI_REST_SUMMARY = "/api/rest_v1/page/summary/{title}"
  90. WIKI_ACTION_API = "/w/api.php"
  91. class WikipediaClient:
  92. """Async Client fuer die Wikipedia REST + Action API."""
  93. def __init__(self, language: str = "de", timeout: int = 10) -> None:
  94. self._lang = language
  95. self._timeout = aiohttp.ClientTimeout(total=timeout)
  96. self._session: aiohttp.ClientSession | None = None
  97. async def _ensure_session(self) -> aiohttp.ClientSession:
  98. if self._session is None or self._session.closed:
  99. self._session = aiohttp.ClientSession(
  100. timeout=self._timeout,
  101. headers={"User-Agent": "Trixy-VoiceAssistant/1.0"},
  102. )
  103. return self._session
  104. async def close(self) -> None:
  105. if self._session and not self._session.closed:
  106. await self._session.close()
  107. @property
  108. def language(self) -> str:
  109. return self._lang
  110. @language.setter
  111. def language(self, lang: str) -> None:
  112. self._lang = lang
  113. async def search(self, query: str, limit: int = 5) -> list[dict[str, str]]:
  114. """Sucht nach Wikipedia-Artikeln."""
  115. session = await self._ensure_session()
  116. url = WIKI_API_TEMPLATE.format(lang=self._lang) + WIKI_ACTION_API
  117. params = {
  118. "action": "query", "list": "search",
  119. "srsearch": query, "format": "json",
  120. "utf8": "1", "srlimit": str(limit),
  121. }
  122. try:
  123. async with session.get(url, params=params) as resp:
  124. if resp.status != 200:
  125. return []
  126. data = await resp.json()
  127. return [
  128. {"title": item["title"], "snippet": _strip_html(item.get("snippet", ""))}
  129. for item in data.get("query", {}).get("search", [])
  130. ]
  131. except Exception:
  132. return []
  133. async def get_summary(self, title: str) -> dict[str, Any] | None:
  134. """Holt die Zusammenfassung eines Artikels (REST API)."""
  135. session = await self._ensure_session()
  136. safe_title = title.replace(" ", "_")
  137. url = WIKI_API_TEMPLATE.format(lang=self._lang) + WIKI_REST_SUMMARY.format(title=safe_title)
  138. try:
  139. async with session.get(url) as resp:
  140. if resp.status != 200:
  141. return None
  142. data = await resp.json()
  143. if data.get("type") == "disambiguation":
  144. return {
  145. "title": data.get("title", ""),
  146. "description": "",
  147. "extract": data.get("extract", ""),
  148. "is_disambiguation": True,
  149. }
  150. result = {
  151. "title": data.get("title", ""),
  152. "description": data.get("description", ""),
  153. "extract": data.get("extract", ""),
  154. "page_url": data.get("content_urls", {}).get("desktop", {}).get("page", ""),
  155. }
  156. # REST-API kuerzt Lebensdaten — Intro nachladen wenn noetig
  157. actual_title = data.get("title", title)
  158. if not re.search(r"\d{1,2}\.\s*\w+\s*\d{4}", result["extract"]):
  159. full_intro = await self._get_intro(actual_title)
  160. if full_intro:
  161. result["extract"] = full_intro
  162. return result
  163. except Exception:
  164. return None
  165. async def _get_intro(self, title: str) -> str | None:
  166. """Holt den vollen Intro-Text (klassische API, enthaelt Lebensdaten)."""
  167. session = await self._ensure_session()
  168. url = WIKI_API_TEMPLATE.format(lang=self._lang) + WIKI_ACTION_API
  169. params = {
  170. "action": "query", "titles": title,
  171. "prop": "extracts", "exintro": "1",
  172. "explaintext": "1", "format": "json", "utf8": "1",
  173. }
  174. try:
  175. async with session.get(url, params=params) as resp:
  176. if resp.status != 200:
  177. return None
  178. data = await resp.json()
  179. for page in data.get("query", {}).get("pages", {}).values():
  180. extract = page.get("extract", "")
  181. if extract:
  182. return extract
  183. except Exception:
  184. pass
  185. return None
  186. async def find_answer(self, query: str) -> dict[str, Any] | None:
  187. """Sucht Artikel — direkt oder via Search bei Disambiguation."""
  188. summary = await self.get_summary(query)
  189. if summary and summary.get("extract") and not summary.get("is_disambiguation"):
  190. return summary
  191. results = await self.search(query, limit=5)
  192. if not results:
  193. return None
  194. for result in results:
  195. if "/" in result["title"]:
  196. continue
  197. summary = await self.get_summary(result["title"])
  198. if summary and summary.get("extract") and not summary.get("is_disambiguation"):
  199. return summary
  200. return None
  201. # ============================================================================
  202. # Frage-Analyse und Antwort-Formatierung
  203. # ============================================================================
  204. def extract_search_query(text: str, lang_data: LanguageData) -> str:
  205. """Extrahiert den Suchbegriff aus einer Frage."""
  206. query = text.strip().lower()
  207. for pattern in lang_data.question_prefixes:
  208. query = re.sub(f"^{pattern}", "", query, flags=re.IGNORECASE)
  209. for pattern in lang_data.question_suffixes:
  210. query = re.sub(pattern, "", query, flags=re.IGNORECASE)
  211. if lang_data.strip_articles:
  212. query = re.sub(lang_data.strip_articles, "", query, flags=re.IGNORECASE)
  213. return query.strip()
  214. def is_math_expression(text: str) -> bool:
  215. """Prueft ob ein Text eher eine Mathe-Aufgabe als eine Wissensfrage ist.
  216. Wird genutzt um Intent-Konflikte mit dem Mathe-Plugin aufzuloesen.
  217. """
  218. lower = text.lower()
  219. # Enthaelt Zahlen und Operatoren?
  220. has_numbers = bool(re.search(r"\d", lower))
  221. has_operators = bool(re.search(r"(?:plus|minus|mal|geteilt|hoch|modulo|prozent von|\+|\-|\*|/)", lower))
  222. if has_numbers and has_operators:
  223. return True
  224. # Mathe-Schluesselwoerter
  225. math_keywords = [
  226. "wurzel", "quadratwurzel", "quersumme", "fakultät", "fakultaet",
  227. "sinus", "cosinus", "tangens", "logarithmus", "betrag",
  228. "berechne", "rechne", "potenz", "kubik",
  229. ]
  230. if any(kw in lower for kw in math_keywords):
  231. return True
  232. return False
  233. def format_answer_for_tts(
  234. question: str,
  235. summary: dict[str, Any],
  236. lang_data: LanguageData,
  237. max_sentences: int = 3,
  238. ) -> str:
  239. """Formatiert eine Wikipedia-Antwort fuer TTS."""
  240. extract = summary.get("extract", "")
  241. description = summary.get("description", "")
  242. title = summary.get("title", "")
  243. if not extract:
  244. if description:
  245. return lang_data.template("description").format(name=title, description=description)
  246. return lang_data.template("not_found").format(topic=title)
  247. # Phonetische Transkriptionen entfernen [...]
  248. extract = re.sub(r"\[.*?\]", "", extract)
  249. # Etymologien entfernen
  250. for pattern in lang_data.etymology_patterns:
  251. extract = re.sub(pattern, "", extract)
  252. extract = re.sub(r"\s+", " ", extract).strip()
  253. # Lebensdaten-Klammern schuetzen fuer Satz-Splitting
  254. protected = re.sub(r"\([^)]*[*†][^)]*\)", lambda m: m.group().replace(".", "·"), extract)
  255. # Auf n Saetze kuerzen
  256. sentences = re.split(r"(?<=[.!?])\s+(?=[A-ZÄÖÜÉÈÀÁÂ])", protected)
  257. short = " ".join(sentences[:max_sentences])
  258. short = short.replace("·", ".")
  259. # Frage-spezifische Antwort
  260. combined = f"{description} {extract}"
  261. months_pattern = lang_data.months_pattern
  262. if lang_data.is_birth_question(question):
  263. date = _extract_date(combined, months_pattern, is_death=False)
  264. if date:
  265. return lang_data.template("born").format(name=title, date=date)
  266. if description:
  267. return lang_data.template("description").format(name=title, description=description)
  268. if lang_data.is_death_question(question):
  269. date = _extract_date(combined, months_pattern, is_death=True)
  270. if date:
  271. return lang_data.template("died").format(name=title, date=date)
  272. if description:
  273. return lang_data.template("description").format(name=title, description=description)
  274. if lang_data.is_age_question(question):
  275. if description:
  276. return lang_data.template("description").format(name=title, description=description)
  277. return short
  278. def _extract_date(text: str, months_pattern: str, is_death: bool = False) -> str | None:
  279. """Extrahiert ein Datum aus dem Text."""
  280. # 1. Volles Datum: "5. Dezember 1791"
  281. dates = re.findall(
  282. rf"(\d{{1,2}})\.\s*({months_pattern})\s*(\d{{4}})", text
  283. )
  284. if dates:
  285. idx = -1 if is_death else 0
  286. d = dates[idx]
  287. return f"{d[0]}. {d[1]} {d[2]}"
  288. # 2. Jahreszahlen: "(1770–1827)" oder "(*1756; †1791)"
  289. m = re.search(r"\((?:\*\s*)?(\d{4})\s*[–—\-;†]\s*(\d{4})\)", text)
  290. if m:
  291. year = m.group(2) if is_death else m.group(1)
  292. return year
  293. return None
  294. def _strip_html(text: str) -> str:
  295. """Entfernt HTML-Tags."""
  296. return unescape(re.sub(r"<[^>]+>", "", text)).strip()
  297. # ============================================================================
  298. # Plugin
  299. # ============================================================================
  300. class WikipediaPlugin(TrixyPlugin):
  301. """Wikipedia-Wissensabfragen per Sprache."""
  302. NAME = "wikipedia"
  303. VERSION = "1.0.0"
  304. DESCRIPTION = "Wikipedia-Wissensabfragen"
  305. AUTHOR = "Trixy"
  306. def __init__(self, **kwargs) -> None:
  307. super().__init__(**kwargs)
  308. self._client: WikipediaClient | None = None
  309. self._lang_data: LanguageData | None = None
  310. self._max_sentences: int = 3
  311. async def on_load(self) -> None:
  312. lang = self.config.get("language", "de")
  313. self._max_sentences = self.config.get("max_sentences", 3)
  314. self._client = WikipediaClient(language=lang)
  315. self._lang_data = LanguageData(self.plugin_path / "lang", lang)
  316. pinfo(f"[Wikipedia] Plugin geladen (Sprache: {lang})")
  317. async def on_unload(self) -> None:
  318. if self._client:
  319. await self._client.close()
  320. self._client = None
  321. def _is_online(self) -> bool:
  322. """Prueft ob Internet-Verbindung besteht via OnlineService.
  323. Wenn der OnlineService nicht verfuegbar ist, gehen wir optimistisch
  324. davon aus dass wir online sind.
  325. """
  326. try:
  327. online_service = self.application.services.get_service("OnlineService")
  328. if online_service and hasattr(online_service, "is_online"):
  329. return bool(online_service.is_online)
  330. except Exception:
  331. pass
  332. return True # Optimistic fallback
  333. @intent(
  334. "wikipedia_query",
  335. description="Wikipedia-Wissensabfrage",
  336. patterns=[
  337. "wer war {thema}",
  338. "wer ist {thema}",
  339. "was ist {thema}",
  340. "was sind {thema}",
  341. "was bedeutet {thema}",
  342. "wann wurde {thema} geboren",
  343. "wann starb {thema}",
  344. "wann ist {thema} gestorben",
  345. "wo liegt {thema}",
  346. "wo ist {thema}",
  347. "wie alt ist {thema}",
  348. "weisst du wer {thema} ist",
  349. "weißt du wer {thema} ist",
  350. "erzähl mir etwas über {thema}",
  351. "erzaehl mir etwas ueber {thema}",
  352. "erkläre mir {thema}",
  353. "erklaere mir {thema}",
  354. "kennst du {thema}",
  355. "beschreibe {thema}",
  356. "informationen über {thema}",
  357. "informationen ueber {thema}",
  358. "suche nach {thema}",
  359. "wikipedia {thema}",
  360. ],
  361. )
  362. async def handle_query(self, data: IntentReceivedData) -> IntentResult:
  363. """Beantwortet eine Wissensfrage ueber Wikipedia."""
  364. if not self._client or not self._lang_data:
  365. return IntentResult.failure("not_ready", "Wikipedia nicht verfuegbar.")
  366. thema = data.get_slot("thema", "")
  367. if not thema:
  368. thema = extract_search_query(data.original_text, self._lang_data)
  369. if not thema:
  370. return IntentResult.failure("missing_topic", "Worueber moechtest du etwas wissen?")
  371. # Mathe-Ausdruecke an das Mathe-Plugin weiterleiten
  372. if is_math_expression(thema):
  373. return IntentResult.failure(
  374. "math_expression",
  375. "", # Kein Text → NLP probiert naechsten Intent (math_calculate)
  376. )
  377. # Online-Status pruefen — Wikipedia braucht Internet
  378. if not self._is_online():
  379. return IntentResult.failure(
  380. "offline",
  381. "Ich kann Wikipedia gerade nicht erreichen, da keine "
  382. "Internetverbindung besteht.",
  383. )
  384. # Sprache aktualisieren
  385. lang = self.config.get("language", "de")
  386. if self._client.language != lang:
  387. self._client.language = lang
  388. self._lang_data.load(lang)
  389. pdebug(f"[Wikipedia] Suche: '{thema}'")
  390. try:
  391. summary = await self._client.find_answer(thema)
  392. except Exception as e:
  393. return IntentResult.failure("error", f"Wikipedia-Abfrage fehlgeschlagen: {e}")
  394. if not summary or not summary.get("extract"):
  395. not_found = self._lang_data.template("not_found").format(topic=thema)
  396. return IntentResult.failure("not_found", not_found)
  397. answer = format_answer_for_tts(
  398. data.original_text, summary, self._lang_data,
  399. max_sentences=self._max_sentences,
  400. )
  401. return IntentResult.success_with_response(answer)
  402. # Plugin-Export
  403. Plugin = WikipediaPlugin